Compare commits

..

No commits in common. "main" and "claude/bold-galileo" have entirely different histories.

80 changed files with 215 additions and 15695 deletions

View file

@ -1,14 +1,7 @@
{
"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(npm install:*)",
"Bash(git add:*)",
"Bash(git commit -m ':*)",
"Bash(git push:*)",
"Bash(git commit:*)",
"Bash(node -e \"console.log\\(Buffer.from\\('49 6e 76 61 6c 69 64 20 4a 53 4f 4e 20 70 61 79 6c 6f 61 64 20 72 65 63 65 69 76 65 64 2e 20 55 6e 65 78 70 65 63 74 65 64 20 74 6f 6b 65 6e 2e 0a 77'.replace\\(/ /g, ''\\), 'hex'\\).toString\\(\\)\\)\")"
"Bash(npm run:*)"
]
}
}

@ -1 +0,0 @@
Subproject commit bf2c48b2c7701a81a6590a2e164de221b0da27c7

View file

@ -5,10 +5,6 @@ SUPABASE_SERVICE_ROLE_KEY=xxx
# APIs
DEEPSEEK_API_KEY=xxx
GEMINI_API_KEY=xxx
DEEPGRAM_API_KEY=xxx
# Proxy SOCKS5 optionnel pour les WS Gemini Live (ex: Cloudflare WARP sur le VPS
# prod dont l'IP est bloquée par Google). Absent = connexion directe (dev local).
GEMINI_PROXY_URL=
# Stripe
STRIPE_SECRET_KEY=xxx

View file

@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

View file

@ -1,24 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run test
- run: npm audit --audit-level=high
- name: Install Semgrep
run: python3 -m pip install --user semgrep
- name: Semgrep scan
run: semgrep scan --config=auto --error --severity=ERROR

8
.gitignore vendored
View file

@ -2,11 +2,3 @@ node_modules
dist
.env
.env.local
.claude/
# Artefacts jetables de test/spike T1 Live (non versionnés)
scripts/t1-spike.mjs
scripts/check-sujet-nullable.mjs
scripts/t1-route-test.mjs
*.pcm
candidat-*.wav

View file

@ -1,98 +0,0 @@
#!/usr/bin/env bash
# Expria auto-deploy script (VPS Paris).
#
# Runs as the non-root `deploy` user, invoked by deploy/webhook-listener.mjs on
# a verified push to origin/main. Fast-forwards the checkout, installs, builds,
# and restarts expria-backend.service via ONE restricted sudo rule:
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart expria-backend.service
#
# On a build or health-check failure it auto-rolls back to the previous commit.
# All output goes to stdout -> journald (journalctl -u expria-deploy).
set -euo pipefail
REPO_DIR="/opt/expria/expria-backend"
BRANCH="main"
SERVICE="expria-backend.service"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4000/}"
LOCK_FILE="/tmp/expria-deploy.lock"
log() { echo "$(date --iso-8601=seconds) [deploy] $*"; }
# Serialize concurrent deploys: a second webhook waits here, or bails out.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "another deploy is already in progress — aborting this run"
exit 0
fi
cd "$REPO_DIR"
PREV="$(git rev-parse HEAD)"
log "start (current=${PREV:0:8})"
restart_backend() {
sudo /usr/bin/systemctl restart "$SERVICE"
}
# Poll the liveness endpoint (GET / always returns 200 when the process is up).
health_check() {
for _ in $(seq 1 10); do
if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
rollback() {
log "ROLLBACK to ${PREV:0:8}"
if git reset --hard "$PREV" && npm ci && npm run build; then
if restart_backend && health_check; then
log "ROLLBACK ok — service healthy on ${PREV:0:8}"
return 0
fi
fi
log "ROLLBACK FAILED — manual intervention required (was on ${PREV:0:8})"
return 1
}
# 1. Fetch + fast-forward only (refuses a diverged history, no destructive pull).
git fetch --prune origin "$BRANCH"
if ! git merge --ff-only "origin/$BRANCH"; then
log "fast-forward failed (diverged history) — aborting, no changes applied"
exit 1
fi
NEW="$(git rev-parse HEAD)"
log "pulled ${PREV:0:8} -> ${NEW:0:8}"
if [ "$PREV" = "$NEW" ]; then
log "already up to date — nothing to deploy"
exit 0
fi
# 2. Install + build (rollback on failure).
if ! npm ci; then
log "npm ci FAILED"
rollback
exit 1
fi
if ! npm run build; then
log "npm run build FAILED"
rollback
exit 1
fi
# 3. Restart + health check (rollback on failure).
if ! restart_backend; then
log "systemctl restart FAILED"
rollback
exit 1
fi
if health_check; then
log "SUCCESS — deployed ${NEW:0:8}"
else
log "health check FAILED after restart"
rollback
exit 1
fi

View file

@ -1,34 +0,0 @@
# Expria auto-deploy webhook listener — systemd unit (VPS Paris).
#
# DISTINCT from expria-backend.service: this runs the webhook listener, not the
# API. Keeping them separate is required — a deploy restarts expria-backend, so
# the listener must NOT be a child of it or the deploy would kill itself.
#
# Install (ops step, after CP1 validation):
# sudo cp deploy/expria-deploy.service /etc/systemd/system/expria-deploy.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now expria-deploy
# Logs:
# journalctl -u expria-deploy -f
[Unit]
Description=Expria auto-deploy webhook listener (Forgejo push -> deploy)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/expria/expria-backend/deploy
EnvironmentFile=/etc/expria/webhook.env
ExecStart=/usr/bin/node /opt/expria/expria-backend/deploy/webhook-listener.mjs
Restart=always
RestartSec=5
# NOTE: NoNewPrivileges MUST stay false (default) — deploy.sh relies on sudo for
# the single restricted rule: systemctl restart expria-backend.service.
# ProtectSystem=strict is intentionally NOT set: deploy.sh writes the checkout
# under /opt/expria/expria-backend (git pull, npm ci, build).
[Install]
WantedBy=multi-user.target

View file

@ -1,140 +0,0 @@
#!/usr/bin/env node
// Expria auto-deploy webhook listener (VPS Paris).
//
// Zero external dependencies: Node stdlib only. Runs as the non-root `deploy`
// user under systemd (deploy/expria-deploy.service), bound to 127.0.0.1 only.
// Caddy (deploy.expria.app) terminates TLS + filters source IP, then proxies
// here. Forgejo "push" events are authenticated by HMAC-SHA256 over the raw
// body (header X-Gitea-Signature) using a DEDICATED secret (never the git token).
//
// On a verified push to refs/heads/main, it spawns deploy/deploy.sh. Concurrent
// deploys are serialized by flock inside deploy.sh.
import http from "node:http";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const HOST = "127.0.0.1";
const PORT = Number(process.env.WEBHOOK_PORT) || 9000;
const HOOK_PATH = process.env.WEBHOOK_PATH || "/hooks/deploy";
const TARGET_REF = "refs/heads/main";
const MAX_BODY = 5 * 1024 * 1024; // 5 MB — generous for a push payload
const SECRET = process.env.WEBHOOK_SECRET;
if (!SECRET || SECRET.length < 16) {
console.error(
"[webhook] WEBHOOK_SECRET missing or too short (<16 chars) — refusing to start",
);
process.exit(1);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEPLOY_SCRIPT = path.join(__dirname, "deploy.sh");
let deployRunning = false;
function log(...args) {
console.log(new Date().toISOString(), "[webhook]", ...args);
}
// Constant-time HMAC comparison. Never logs the secret or the signature.
function verifySignature(rawBody, headerSig) {
if (!headerSig || typeof headerSig !== "string") return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
let a;
let b;
try {
a = Buffer.from(expected, "hex");
b = Buffer.from(headerSig, "hex");
} catch {
return false;
}
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function runDeploy(triggerSha) {
deployRunning = true;
log("deploy launch", triggerSha ? `after=${triggerSha.slice(0, 8)}` : "");
const child = spawn("/bin/bash", [DEPLOY_SCRIPT], {
cwd: __dirname,
env: { ...process.env },
stdio: ["ignore", "inherit", "inherit"], // deploy.sh logs go to journald
});
child.on("exit", (code) => {
deployRunning = false;
log("deploy finished exit=" + code);
});
child.on("error", (err) => {
deployRunning = false;
log("deploy spawn error:", err.message);
});
}
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== HOOK_PATH) {
res.writeHead(404).end("not found\n");
return;
}
const chunks = [];
let size = 0;
let aborted = false;
req.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BODY) {
aborted = true;
res.writeHead(413).end("payload too large\n");
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (aborted) return;
const rawBody = Buffer.concat(chunks);
const sig =
req.headers["x-gitea-signature"] || req.headers["x-forgejo-signature"];
if (!verifySignature(rawBody, sig)) {
log("rejected: invalid signature from", req.socket.remoteAddress);
res.writeHead(401).end("unauthorized\n");
return;
}
let payload;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
res.writeHead(400).end("bad json\n");
return;
}
if (payload.ref !== TARGET_REF) {
log("ignored ref", payload.ref);
res.writeHead(200).end("ignored (not main)\n");
return;
}
if (deployRunning) {
log("deploy already running — new run will queue on flock");
}
res.writeHead(202).end("accepted\n");
runDeploy(payload.after);
});
req.on("error", () => {
if (!res.headersSent) res.writeHead(400).end();
});
});
server.listen(PORT, HOST, () => {
log(`listening on ${HOST}:${PORT}${HOOK_PATH}`);
});

View file

@ -1,6 +1,6 @@
# ARCHITECTURE.md — Expria / Coach TCF Canada
> **Document de référence — Version 1.2**
> **Document de référence — Version 1.1**
> 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.
@ -24,9 +24,9 @@ Utilisateur (navigateur)
┌─────────────▼───────────────┐
│ BACKEND │
│ Hono.js (Node.js) │
VPS Paris (systemd + Caddy)
Render (Frankfurt)
│ — toutes les routes API │
│ — WebSocket proxy T1/T2 EO │
│ — WebSocket proxy T2 EO
└──────┬──────────────┬───────┘
│ │
┌──────▼──────┐ ┌────▼────────────────┐
@ -72,7 +72,6 @@ Tier gratuit, déploiement automatique depuis GitHub.
### Pourquoi Supabase est conservé
Supabase fournit trois services critiques déjà en production :
- Authentification complète (email, OAuth Google/Apple, sessions JWT)
- Base de données PostgreSQL avec Row Level Security
- Stockage de fichiers (enregistrements audio EO)
@ -160,8 +159,8 @@ expria-backend/
│ │ ├── auth.ts # POST /auth/verify-token
│ │ ├── simulations.ts # POST /simulations, GET /simulations/:id
│ │ ├── corrections.ts # POST /corrections/ee, POST /corrections/eo
│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade-prorata
│ │ ├── stripe.ts # POST /stripe/checkout, /stripe/customer-portal, /stripe/webhook
│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade
│ │ ├── stripe.ts # POST /stripe/checkout, POST /stripe/webhook
│ │ └── t2live.ts # WebSocket /t2/live (T2 EO proxy Gemini)
│ ├── controllers/ # Logique métier (une par domaine)
│ │ ├── simulationController.ts
@ -293,13 +292,11 @@ USING (auth.uid() = user_id);
## 6. Routes API backend
### Authentification
```
POST /auth/verify-token Vérifie le JWT Supabase, retourne le profil + plan
```
### Simulations
```
POST /simulations Crée une simulation, vérifie les quotas selon le plan
GET /simulations/:id Récupère une simulation par ID
@ -307,29 +304,25 @@ GET /simulations Liste les simulations de l'utilisateur connec
```
### Corrections
```
POST /corrections/ee Soumet une production EE pour correction (DeepSeek)
POST /corrections/eo Soumet une production EO pour correction (Gemini)
```
### Plans
```
GET /plans/status Retourne le plan actuel + permissions de l'utilisateur
POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe — preview du montant)
POST /plans/upgrade Crée une session Stripe Checkout (nouveau abonnement)
POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe)
```
### Stripe
```
POST /stripe/checkout Crée une Checkout Session Stripe (nouveau abonnement)
POST /stripe/customer-portal Crée une Billing Portal Session (gestion abonnement self-service)
POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted) — idempotent (TD-13 résolu Sprint 5a)
POST /stripe/checkout Crée une Checkout Session Stripe
POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted)
```
### T2 EO Live
```
WS /t2/live WebSocket — proxy Gemini Live API (Premium uniquement)
```
@ -395,7 +388,6 @@ WS /t2/live WebSocket — proxy Gemini Live API (Premium
## 8. Variables d'environnement
### Frontend (.env)
```
VITE_API_URL=https://api.expria.app # URL du backend Render
VITE_SUPABASE_URL=https://xxx.supabase.co
@ -403,7 +395,6 @@ VITE_SUPABASE_ANON_KEY=xxx # Clé publique uniquement
```
### Backend (.env)
```
# Supabase
SUPABASE_URL=https://xxx.supabase.co
@ -433,17 +424,35 @@ NODE_ENV=production
## 9. Déploiement
### Hébergement Git — Forgejo (auto-hébergé)
### Contexte — Contrainte d'hébergement Git
- Plateforme : Forgejo (fork Gitea) auto-hébergé sur le VPS Moscou (`157.22.190.91`), exposé via `git.expria.app` (proxifié par un Caddy Moscou).
- Dépôt frontend : `https://git.expria.app/hermann/expria-frontend`
- Dépôt backend : `https://git.expria.app/hermann/expria-backend`
- Push : HTTPS + token Forgejo (branche `main`). Un miroir SSH historique (`ssh://157.22.190.91:2222`) peut exister mais n'est pas la voie de référence.
- Note : migration depuis GitHub actée pour s'affranchir des restrictions OFAC et reprendre la main sur l'hébergement Git.
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
### Frontend — Cloudflare Pages
- Source : dépôt Forgejo `expria-frontend`
- Source : dépôt Codeberg `expria-frontend`
- Build command : `npm run build`
- Output directory : `dist`
- Domaine : `expria.app` (DNS pointé depuis Vercel vers Cloudflare Pages)
@ -455,14 +464,25 @@ npm run build
npx wrangler pages deploy dist --project-name=expria
```
### Backend — VPS Paris (HostVDS)
### Backend — Render
- Hébergement : VPS **HostVDS Paris**, IP `95.182.84.3`.
- Source : dépôt Forgejo `expria-backend`, checkout dans `/opt/expria/expria-backend`.
- Runtime : Node.js (v20.x) géré par **systemd** — unité `expria-backend.service` (`ExecStart=/usr/bin/node dist/index.js`, `Restart=always`). Le process écoute sur `localhost:4000`.
- Reverse proxy : **Caddy** (v2) sert `api.expria.app` en TLS (Let's Encrypt forcé, `acme_ca` production) et proxifie vers `localhost:4000`. WebSocket (T1/T2 Live) supporté nativement par Caddy.
- Proxy Gemini : **Cloudflare WARP** tourne en mode SOCKS5 (`socks5://127.0.0.1:40000`) sur le VPS ; **seul** le trafic WebSocket Gemini Live y est routé (via `GEMINI_PROXY_URL`, cf. §8) car l'IP du datacenter est bloquée par Google. Supabase, DeepSeek, Stripe et les clients restent en connexion directe.
- **Auto-deploy (webhook Forgejo)** : un push sur `main` déclenche un webhook Forgejo vers `deploy.expria.app` (sous-domaine dédié, proxifié par Caddy vers un listener local `127.0.0.1:9000`). Le listener (`deploy/webhook-listener.mjs`, Node stdlib) vérifie la signature **HMAC-SHA256** (`X-Gitea-Signature`) et l'IP source (allowlist `157.22.190.91`), puis lance `deploy/deploy.sh` (git fast-forward → `npm ci``npm run build``systemctl restart expria-backend`, avec **rollback automatique** si le build ou le health-check échoue). Listener isolé sous l'utilisateur non-root `deploy` (unité `expria-deploy.service`).
- Source : dépôt Codeberg `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`
- 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
```
### Base de données — Supabase
@ -470,53 +490,67 @@ npx wrangler pages deploy dist --project-name=expria
- Migrations : versionnées dans `supabase/migrations/`
- Déploiement : `supabase db push` (manuel, après validation)
### Procédure de déploiement complète
### Procédure de déploiement complète (Phase 1)
```
1. Tester localement (npm run test — tous les tests verts)
2. Rejouer le Golden Dataset
3. Commit + push sur Forgejo (branche main)
4. Backend : auto-deploy déclenché par le webhook Forgejo → VPS Paris
(git pull → npm ci → build → restart systemd, rollback auto si échec)
3. Commit + push sur Codeberg (branche main)
4. Déployer le backend : render deploy (ou dashboard Render)
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
### Règle 1 — Séparation stricte
Le frontend ne contient aucune logique métier.
Il appelle le backend et affiche ce qu'il reçoit.
Toute vérification de plan, de quota, de droit d'accès se fait côté backend.
### Règle 2 — Source de vérité unique des plans
`lib/access.ts` existe dans les deux dépôts (frontend et backend).
Le fichier doit être identique dans les deux.
Toute modification des plans tarifaires met à jour ce fichier en premier,
dans les deux dépôts, avant tout autre changement de code.
### Règle 3 — Jamais plus de 3 fichiers touchés par session Claude
Si une modification nécessite de toucher plus de 3 fichiers,
elle doit être découpée en plusieurs sessions avec validation intermédiaire.
### Règle 4 — Plan avant code
Claude Code ne commence jamais à coder sans avoir d'abord produit
un plan détaillé (fichiers impactés, risques, étapes).
Le plan est validé par Hermann avant l'exécution.
### Règle 5 — Tests manuels après chaque session
Après chaque session Claude Code, rejouer le golden dataset
(voir GOLDEN_DATASET.md) avant de passer à la session suivante.
### Règle 6 — Variables d'environnement
Aucune valeur de variable d'environnement n'est jamais écrite en dur dans le code.
Toujours lire depuis `process.env` (backend) ou `import.meta.env` (frontend).

View file

@ -1,232 +0,0 @@
# Changelog — Expria Backend
Toutes les modifications notables du backend sont documentées dans ce fichier.
Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
---
## [Unreleased] — 2026-07-01 — Auto-deploy par webhook Forgejo (VPS Paris)
### Added
- `deploy/webhook-listener.mjs` — listener HTTP **Node stdlib pur** (aucune dépendance externe), bindé sur `127.0.0.1:9000`, path `POST /hooks/deploy`. Vérifie la signature **HMAC-SHA256** du corps brut (header `X-Gitea-Signature`, fallback `X-Forgejo-Signature`) via `crypto.timingSafeEqual`, refuse de démarrer sans `WEBHOOK_SECRET`, ne traite que `ref === refs/heads/main`, cape le body à 5 Mo, ne logge jamais le secret ni la signature. Lance `deploy.sh` sans bloquer la réponse.
- `deploy/deploy.sh` — script de déploiement (`set -euo pipefail`) : verrou `flock` (deploys sérialisés), `git fetch` + `git merge --ff-only origin/main` (refuse un historique divergé), `npm ci`, `npm run build`, `sudo systemctl restart expria-backend.service` (unique commande privilégiée), health-check `GET http://127.0.0.1:4000/`. **Rollback automatique** (`git reset --hard` vers le commit précédent + rebuild + restart) si le build ou le health-check échoue.
- `deploy/expria-deploy.service` — unité systemd du listener, **distincte** de `expria-backend.service`, sous l'utilisateur non-root `deploy`, secret injecté via `EnvironmentFile=/etc/expria/webhook.env`.
### Notes
- Exposition : sous-domaine dédié `deploy.expria.app` proxifié par Caddy vers `127.0.0.1:9000`, avec **allowlist IP** sur l'egress Forgejo Moscou (`157.22.190.91`) + signature HMAC = double garde.
- Sécurité : le listener tourne sous `deploy` (non-root) avec une règle sudoers restreinte à la seule ligne `systemctl restart expria-backend.service`. Secret dédié (≠ token git), hors dépôt, jamais loggé.
- Résout **TD-04** (déploiement manuel). Étapes ops VPS (création user `deploy`, sudoers, DNS `deploy.expria.app`, bloc Caddy, secret, `systemctl enable --now expria-deploy`, config webhook Forgejo) documentées et exécutées hors code.
---
## [Unreleased] — 2026-06-30 — Proxy SOCKS5 (Cloudflare WARP) pour les WS Gemini Live
### Added
- `resolveGeminiProxyAgent()` (`geminiLive.ts`) — helper qui lit la variable d'environnement **optionnelle** `GEMINI_PROXY_URL` et renvoie un `SocksProxyAgent` si elle est définie, sinon `undefined` (connexion directe). Contexte : l'IP du VPS de production (datacenter) est bloquée par Google ; Cloudflare WARP tourne en mode proxy SOCKS5 sur le VPS (`socks5://127.0.0.1:40000`). Seul le trafic WS Gemini est routé via ce proxy ; Supabase, DeepSeek et les clients restent en direct.
- Dépendance `socks-proxy-agent` (`package.json`).
- `GEMINI_PROXY_URL` ajoutée à `.env.example` (vide par défaut → dev local inchangé).
- Tests `geminiLive.test.ts` — 2 tests pour `resolveGeminiProxyAgent` (absente → `undefined` ; `socks5://…` → instance `SocksProxyAgent`).
### Changed
- Factory WS par défaut de `openGeminiLiveSession` (T2, `geminiLive.ts`) et `openGeminiLiveT1Session` (T1, `geminiLiveT1.ts`) — passe désormais `{ agent }` au constructeur `new WebSocket(url, options)` quand un proxy est résolu. La factory injectée par les tests (`clientFactory`) n'est pas affectée. Prompt système, interruption, flush, `runT1/T2LiveCorrection` et close codes inchangés.
### Notes
- Tests backend : 311/311 verts. `tsc --noEmit` OK.
- Activation en prod : définir `GEMINI_PROXY_URL=socks5://127.0.0.1:40000` côté VPS/Render (config d'env, hors code).
---
## [Unreleased] — 2026-06-30 — Sprint 7a-patch — T1 Live : suppression de la dépendance au questionnaire
### Changed
- `buildT1SystemPrompt` (`geminiLiveT1.ts`) — prompt système T1 désormais **statique** (signature sans argument). La section « CONTEXTE DU CANDIDAT » (5 variables `${reponses.*}`) est retirée et remplacée par une consigne d'écoute : « Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire. » Les 8 règles sont conservées (silence par défaut, relance sur signal, ton bienveillant, jamais d'évaluation/hors-rôle, règle 5 « DOIS poser des questions »). Règle 4 : retrait de « ou à son contexte ci-dessus ».
- `openGeminiLiveT1Session` (`geminiLiveT1.ts`) — option `reponses` retirée de `OpenGeminiLiveT1SessionOptions` et de la signature. Le handler de message client ignore désormais explicitement (log debug + return) tout message non reconnu (ni `audio` ni `end`) — un éventuel `{type:'context'}` d'un ancien front ne provoque ni crash ni close.
- `WS /t1/live` (`t1live.ts`) — la session Gemini s'ouvre **immédiatement après l'auth** (calque T2), dans `onOpen`. Le flux client devient `{type:'audio', data}` puis `{type:'end'}`.
- `docs/Prompt_t1live.md` — §1 (table « Subject-based »), §2 (règle 3), §3 (prompt statique, retrait des variables et de la section « Variables à substituer »), §4.1 (retrait du message de contexte), §4.3 (retrait du close 4004).
- Tests `geminiLiveT1.test.ts` / `t1live.test.ts` — appels `buildT1SystemPrompt()` sans argument, retrait de `reponses` des appels de session ; suppression du test d'intégration des réponses (remplacé par un test « écoute / plus de CONTEXTE DU CANDIDAT ») et du bloc `parseT1Context` ; tests interruption, flush terminal, timeout et correction inchangés.
### Removed
- Message client `{type:'context', reponses}` (1er message obligatoire) et close **4004 `CONTEXT_MISSING`** — la route ne lit plus de contexte.
- Fonction `parseT1Context` (`t1live.ts`) et ses imports `validateReponses` / `PresentationReponses`.
### Notes
- Tests backend : 309/309 verts. `tsc --noEmit` OK.
- **Suivi frontend (hors scope)** : le frontend Sprint 7b (non commité) envoie encore `{type:'context'}` et possède un `QuestionnaireT1Page` Live ; ce message est désormais inoffensif côté backend (ignoré). Le questionnaire Live devient sémantiquement obsolète — à retirer dans une session frontend dédiée.
---
## [Unreleased] — 2026-06-28 — Sprint 6d — T2 Live : durcissement prompt + VAD + cleanup SDK
### Changed
- `buildT2SystemPrompt` (`geminiLive.ts`) — prompt système T2 durci : 13 règles absolues (Bug 1). Interdiction stricte de poser des questions (aucun point d'interrogation), rôle passif/inerte, silence total après réponse, aucune formule de politesse de fin. Objectif : stopper la relance systématique de l'examinateur IA. Réf TD-22. ⚠ Spécifique T2 — l'interdiction du « ? » ne doit pas être propagée au prompt T1 (Sprint 7).
- `buildSetupFrame` (`geminiLive.ts`) — `realtimeInputConfig.automaticActivityDetection` (VAD) réintégré dans le setup frame Gemini (Bug 2), 4 champs : `disabled:false`, `startOfSpeechSensitivity:START_SENSITIVITY_LOW`, `endOfSpeechSensitivity:END_SENSITIVITY_LOW`, `silenceDurationMs:2000`.
- `geminiLive.test.ts` — assertion VAD mise à jour (`realtimeInputConfig` : `toBeUndefined` → présence + valeurs des 4 champs).
### Removed
- Dépendance `@google/genai` retirée de `package.json` / `package-lock.json` et script de debug `test-gemini-live.js` supprimé (Bug 8) — SDK abandonné au profit du WebSocket brut (`ws`).
### Notes
- Tests backend : 292/292 verts (0 échec).
- Validé au test manuel Golden Dataset Groupe D — Bug 1 (plus de relance systématique) et Bug 2 (VAD, pauses de réflexion respectées) confirmés en conditions réelles.
- **TD-22 (🟡 ouvert)** — comportement T2 garanti uniquement par prompt engineering ; pas de garantie déterministe sur modèle Flash Live. À revisiter si la relance persiste.
---
## [Unreleased] — 2026-04-26 — Sprint 6a — Backend T2 Live
### Added
- `buildT2SystemPrompt({role, contexte})` dans `geminiLive.ts` — prompt dynamique conforme `Prompt_t2live.md §3`, remplace la constante `T2_SYSTEM_PROMPT` (agent immobilier).
- Accumulation transcripts pendant la session WS : `inputTranscription[]` + `outputTranscription[]` parsés depuis les messages Gemini, reconstruits en transcript chronologique à la fin.
- VAD config dans le setup frame Gemini : `endOfSpeechSensitivity: END_SENSITIVITY_LOW`, `startOfSpeechSensitivity: START_SENSITIVITY_LOW`, `silenceDurationMs: 2000`.
- Timeout session 210 s (3 min 30) + warning client à 180 s (30 s restantes).
- Signal client `{type:'end'}` pour fin anticipée du dialogue.
- Close codes : 4005 `GEMINI_CONFIG`, 4006 `GEMINI_DISCONNECTED`.
- Orchestration `t2live.ts` : fetch sujet par UUID (`?sujet=<uuid>`, validation `mode='EO'` + `tache=2`), close 4004 `SUJET_NOT_FOUND` si absent.
- Post-session : `runT2LiveCorrection` — insert `productions(tache='EO_T2_LIVE')`, appel `deepseekCorrectEO(transcript, 'EO_T2')`, `PHONOLOGY_STUB` (TD-08), persist rapport + score + nclc, envoi `{type:'report'}` au client, close 1000.
- `TacheEO` étendu avec `'EO_T2'` dans `deepseek.ts` + `VALID_TACHES_EO` dans `corrections.ts`.
- 10 tests d'intégration `t2live.test.ts` (auth, sujet, pipeline correction nominal + erreurs).
- 11 tests `geminiLive.test.ts` (7 réécrits + 4 nouveaux : prompt builder, accumulation, timeout/warning, end signal).
### Changed
- `geminiLive.ts` réécrit — setup frame paramétrable, `inputAudioTranscription` + `outputAudioTranscription` activés, callback `onSessionEnd(transcript)`.
- `corrections.ts``VALID_TACHES_EO` inclut `'EO_T2'`.
### Notes
- Tests backend : 292/292 verts (+15 vs baseline 277).
- Phonologie T2 Live = 0 (TD-08 — pas d'audio brut pour évaluation phonologique).
- Le frontend n'est pas encore connecté — test e2e au Sprint 6c.
---
## [Unreleased] — 2026-04-26 — Sprint 5a — Backend billing cleanup
### Added
- `supabase/migrations/007_sprint_5a_stripe_webhook_events.sql` — table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` + index sur `processed_at`. Idempotente (`CREATE TABLE IF NOT EXISTS`).
- `src/lib/stripeWebhookEvents.ts` — helpers `isEventProcessed` / `markEventProcessed` (insert idempotent, conflit unique `23505` avalé silencieusement).
- `src/lib/__tests__/stripeWebhookEvents.test.ts` — 8 tests (lecture, écriture, edge cases vide/erreur DB).
- `src/lib/__tests__/createBillingPortalSession.test.ts` — 4 tests (succès, customerId vide, returnUrl vide, URL Stripe vide).
- `POST /stripe/customer-portal` — endpoint authentifié qui crée une Stripe Billing Portal Session (gestion abonnement self-service) et redirige l'utilisateur. 400 `NO_ACTIVE_SUBSCRIPTION` si pas de `stripe_customer_id` ; return_url = `${APP_URL}/dashboard`.
### Changed
- `POST /stripe/webhook` — déduplication explicite des events Stripe (TD-13 résolu) : check `isEventProcessed(event.id)` avant traitement → early return `200 { received: true, replayed: true }` ; `markEventProcessed` après succès uniquement (pas si exception, pour permettre rejeu Stripe).
- `src/lib/stripe.ts` — nouvelle fonction `createBillingPortalSession({ customerId, returnUrl })` (mirror de `createCheckoutSession`).
- `src/routes/__tests__/stripe.test.ts` — 5 nouveaux tests (2 idempotency webhook + 3 customer-portal route).
- `docs/ARCHITECTURE-backend.md` — §3 commentaire `plans.ts` corrigé (`POST /plans/upgrade-prorata` au lieu de `POST /plans/upgrade` qui n'existait pas) ; §6 retrait de la ligne dupliquée `POST /plans/upgrade` (la création d'abonnement passe par `POST /stripe/checkout`) ; §6 ajout `POST /stripe/customer-portal`.
### Fixed
- `src/lib/stripe.ts``cancel_url` Stripe Checkout corrigé : `${APP_URL}/tarifs?upgrade=cancelled``${APP_URL}/plan?upgrade=cancelled`. La route `/tarifs` n'existe pas côté frontend (route réelle : `/plan`) ; les checkouts annulés aboutissaient sur un 404. Bug détecté lors du Sprint 5c frontend (gestion des retours post-Checkout) et corrigé en cross-repo.
### Resolved
- **TD-13 🔴 → Résolu** : Webhook Stripe idempotent (table `stripe_webhook_events` + helpers + wiring route + 10 tests).
### Notes
- Tests : 261 → 278 verts (+17).
- Aucun changement code frontend dans ce sprint — Sprint 5b/5c/5d (frontend billing) livrés en parallèle.
---
## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO
### Added
- `src/lib/geminiPhonology.ts` — évaluation phonologique via Gemini 2.5 Flash (audio brut, JSON strict, timeout 45s + 1 retry). `PHONOLOGY_STUB` pour Mode A (transcript sans audio).
- `src/lib/__tests__/geminiPhonology.test.ts` — 9 tests (parse, cap 0..4, retry, erreurs HTTP).
- `src/controllers/__tests__/correctionEoPhonology.test.ts` — 4 tests (injection 5e critère, stub Mode A, fallback Gemini down, score max 20).
### Changed
- `POST /corrections/eo` — passe de 4 critères × /5 à 5 critères × /4 (score total /20 inchangé). Phonologie évaluée par Gemini en parallèle de la transcription (Mode B audio). Fallback stub si Gemini phonologie échoue.
- `src/lib/deepseek.ts` — prompt EO : cap critère /5 → /4, libellés officiels TCF Canada, mention phonologie évaluée séparément. Cap EE inchangé /5.
- TD-08 partiellement résolu (T1/T3). T2 Live reste à 0 (Sprint 6).
### Notes
- ⚠️ Breaking change frontend : criteres.length passe de 4 à 5, échelle /5 → /4.
- Tests : 248 → 261 verts (+13).
---
## [Unreleased] — 2026-04-25 — Sprint 4a/4b — Backend EO
### Added
- `POST /corrections/eo` aligné format Sprint 3.6a : revelation, diagnostic, criteres enrichis (exemple/suggestion/astuce), conseil_nclc adaptatif (4 niveaux selon score vs cible), erreurs_codes, jobs fire-and-forget modèle + exercices
- `POST /presentations/generate` — génération présentation T1 via DeepSeek (220-260 mots, registre oral NCLC 7-8, 5 champs)
- `POST /transcriptions/token` — token Deepgram éphémère (600s TTL, dormant côté frontend MVP)
- `src/lib/deepgram.ts` — client Deepgram /v1/auth/grant (scope Member requis)
- `src/lib/audioStorage.ts` — supprimé (audio non stocké côté serveur)
- Migration `006_sprint_4a_eo.sql` — documentation bucket Storage (no-op)
### Changed
- `correctEO` : accepte audioBase64+mimeType (Gemini batch) OU transcript texte
- MIME normalisé avant validation (audio/webm;codecs=opus → audio/webm)
- `sanitizeJsonContent` : gère single-quote JSON DeepSeek
- Gemini timeout 30s → 45s, DeepSeek correctEO 55s → 90s
- `gemini-2.0-flash``gemini-2.5-flash`
- conseil_nclc adaptatif EE + EO (4 niveaux : dépassé / atteint / proche / loin)
- Tests : 205 → 248 verts (+43)
---
## [Unreleased] — 2026-04-25 — Fix health check keepalive Supabase
### Changed
- Route `GET /` : ajout d'un ping Supabase (`profiles.select('id', { head: true }).limit(1)`) à chaque appel. Garde le pool de connexions DB actif via les pings UptimeRobot (toutes les 5 min). Réponse enrichie : `{ message, db: 'connected' | 'error' }`. Toujours 200 (liveness, pas readiness).
---
## [Unreleased] — 2026-04-22 — Sprint 3.6a — Qualité correction Backend
### Added
- Nouveaux prompts DeepSeek spécifiés dans `docs/Prompt_maître.md` et `docs/Prompt_production_modèle.md` — builders dynamiques `buildCorrectionPrompt`, `buildModelPrompt`, `buildExercicesPrompt` dans `src/lib/deepseek.ts`.
- `expria-frontend/docs/TAXONOMIE_ERREURS.md` — 63 codes d'erreurs TCF Canada sur 4 critères + 4 codes « autre ». Validation runtime via `src/lib/taxonomieErreurs.ts` (`isValidCode`, `isValidCritere`, `buildTaxonomyPromptSection`). Codes invalides retournés par DeepSeek sont filtrés ; le code `autre` sans description est rejeté.
- Génération parallèle correction + modèle — option (b) : `generateProductionModele` démarre en même temps que `correctEE` avec `nclcObtenu = nclcCible - 1` comme estimation provisoire, `await` uniquement sur la correction pour répondre à la requête HTTP.
- Exercices personnalisés fire-and-forget déclenchés après la résolution de la correction (dépendent de `rapport.erreurs_codes` et `rapport.criteres`). Format aligné sur les captures d'écran : `{difficulte, theme, diagnostic, consigne, extrait, indice, correction, explication}`.
- Nouveaux champs dans `productions` : `revelation` (JSONB), `diagnostic` (TEXT), `conseil_nclc` (JSONB), `erreurs_codes` (JSONB), `exercices` (JSONB), `modele` (JSONB), `nclc_cible` (INTEGER), `exercices_status` / `modele_status` (TEXT, 'pending'/'ready'/'error').
- Migration SQL `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` — première migration versionnée du projet (cf. backend TD-06) ; idempotente grâce à `IF NOT EXISTS`.
- Paramètre `nclc_cible` optionnel sur `POST /corrections/ee` (défaut 9, valeurs acceptées : 9 ou 10 ; sinon 400 VALIDATION_ERROR).
- Index GIN sur `erreurs_codes` pour préparer l'agrégation du Sprint 3.6c (analyse patterns).
- Nouveau fichier de tests `src/controllers/__tests__/correctionController.test.ts` — 8 tests (parallélisme option b, statuts ready/error, nclc_cible propagé, simulation introuvable, autre utilisateur).
- 2 tests ajoutés à `simulationController.test.ts``getById` renvoie `nclc_cible`, `exercices`, `modele` + statuts.
- Logs d'erreur détaillés : `callDeepSeek` classifie TIMEOUT / ABORT / JSON_PARSE / NETWORK / OTHER ; `correctionController.correctEE` logue `{simulationId, tache, nclcCible, message, stack}` avant de retourner 500.
- FTD-23 🟡 ajoutée dans `expria-frontend/docs/TECH_DEBT.md``useAutosave` peut fire un PATCH `/simulations/:id/contenu` après correction, ce qui retourne 400 VALIDATION_ERROR. À corriger dans une session dédiée (préexistant au Sprint 3.6a, détecté lors des tests manuels).
### Changed
- `correctEE` dans `src/lib/deepseek.ts` — nouvelle signature `correctEE(CorrectionInput)` (contenu, tache, sujet, sourceDoc1/2, nclcCible) et nouvelle forme de retour `CorrectionRapport` (revelation, diagnostic, criteres avec exemple/suggestion/astuce, conseil_nclc, erreurs_codes). `EERapport` devient alias de `CorrectionRapport`. EO inchangé.
- `correctionController.correctEE` — charge le sujet + documents T3 depuis Supabase pour alimenter le prompt maître ; persiste les nouveaux champs (revelation, diagnostic, conseil_nclc, erreurs_codes, nclc_cible) + statuts pending initiaux ; lance `runModeleJob` en parallèle (option b) et `runExercicesJob` après correction.
- `simulationController.getById` — retourne désormais `nclc_cible`, `exercices`, `exercices_status`, `modele`, `modele_status` en plus du `rapport` enrichi ; fallback `'pending'` si les colonnes sont absentes (compat avec productions pré-migration).
- Timeout DeepSeek côté backend : `callDeepSeek` abort à **55 s** via `AbortSignal.timeout(55_000)` (avant : aucun timeout) ; timeout frontend corrections monte de **30 s à 60 s** — marge de 5 s entre abort backend et abort client.
- Routes `/simulations/*` : réorganisation défensive — les `PATCH /:id/contenu` et `PATCH /:id/sujet` sont déclarées avant `GET /:id` pour éviter tout risque de masquage.
- `deepseek.test.ts` réécrit (25 tests) — couvre correctEE nouvelle signature, generateProductionModele, generateExercices, helpers post-traitement, EO inchangé.
### Notes
- **Option A retenue** pour la compatibilité frontend : backend renvoie uniquement la nouvelle forme. Le Sprint 3.6b (frontend) est immédiatement suivant et corrige l'écran blanc sur `RapportPage`.
- **Option (b) retenue** pour le parallélisme : modèle en parallèle avec correction (nclcObtenu estimé), exercices strictement après correction.
- Migration SQL à exécuter manuellement via `supabase db push` ou SQL Editor du dashboard (cf. Règle F) — aucune exécution automatique.
- Tests : **174 tests verts** (+19 vs baseline 155), 18 fichiers de tests.
- TD-15 🟡 ouvert : si le process redémarre pendant un job fire-and-forget (modèle/exercices), le statut reste `pending` indéfiniment. À traiter après observation en production.

View file

@ -118,11 +118,6 @@ Si pendant l'implémentation Claude Code réalise que le plan doit être modifi
il STOP, signale le changement, explique pourquoi, et attend une nouvelle validation.
Il ne prend jamais de décision architecturale de sa propre initiative.
### Règle I — Pas de worktree Git
Claude Code ne crée jamais de worktree Git (`git worktree add`).
Toutes les modifications se font directement dans le dossier
du projet principal.
---
## 3. Structure du code — conventions

View file

@ -86,9 +86,9 @@ Ces tests vérifient le parcours complet d'un utilisateur Premium.
| # | Test | Compte | Résultat attendu | ✅ / ❌ |
|---|---|---|---|---|
| D1 | Dashboard Premium après connexion | test.premium | Historique, indice, bouton "Lancer un examen" actif, T2 live accessible | |
| D2 | Accéder à EO Tâche 2 live | test.premium@gmail.com | Page de préparation T2 affichée, bouton "Démarrer le dialogue" | ✅ 17 avril 2026 (api.expria.app) |
| D3 | Démarrer le dialogue T2 | test.premium@gmail.com | L'IA prend la parole en premier, audio reçu et joué | ✅ 17 avril 2026 (api.expria.app) |
| D4 | Répondre en audio (T2) | test.premium@gmail.com | L'IA réagit après la réponse du candidat | ✅ 17 avril 2026 (api.expria.app) |
| D2 | Accéder à EO Tâche 2 live | test.premium | Page de préparation T2 affichée, bouton "Démarrer le dialogue" | |
| D3 | Démarrer le dialogue T2 | test.premium | L'IA prend la parole en premier, audio reçu et joué | |
| D4 | Répondre en audio (T2) | test.premium | L'IA réagit après la réponse du candidat | |
| D5 | Fin de dialogue T2 | test.premium | Rapport complet affiché, production enregistrée avec tag "T2 Live" | |
| D6 | Lancer mode Examen EE | test.premium | Page d'avertissement affichée avant démarrage | |
| D7 | Confirmer le lancement Examen EE | test.premium | 3 tâches visibles, timer 60:00 démarré, inarrêtable | |

View file

@ -1,697 +0,0 @@
# IMPLEMENTATION_T2_LIVE.md — Algorithme d'implémentation T2 EO Live
> **Document de référence technique — Sprint 6**
> Basé exclusivement sur la documentation officielle Google Gemini Live API.
> Sources : ai.google.dev/gemini-api/docs/live-api (get-started-websocket, capabilities, session-management, ephemeral-tokens, best-practices, rate-limits, pricing)
> Date de vérification : 2026-04-26
---
## 1. Spécifications officielles vérifiées
### 1.1 Modèle
| Paramètre | Valeur | Source |
|---|---|---|
| Modèle cible | `gemini-2.5-flash-native-audio` ou `gemini-2.5-flash-native-audio-preview-12-2025` | ai.google.dev/gemini-api/docs/models |
| Accès Hermann | Confirmé | Session 2026-04-26 |
### 1.2 Audio — formats officiels
| Direction | Format | Sample rate | Encoding | MIME type |
|---|---|---|---|---|
| Client → Gemini | PCM brut | 16 kHz | 16 bits, little-endian, mono | `audio/pcm;rate=16000` |
| Gemini → Client | PCM brut | 24 kHz | 16 bits, little-endian, mono | `audio/pcm;rate=24000` |
Source : « Audio data in the Live API is always raw, little-endian, 16-bit PCM. Audio output always uses a sample rate of 24kHz. Input audio is natively 16kHz, but the Live API will resample if needed so any sample rate can be sent. » — ai.google.dev/gemini-api/docs/live-guide
### 1.3 Endpoint WebSocket
```
wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key={API_KEY}
```
Source : ai.google.dev/gemini-api/docs/live-api/get-started-websocket
### 1.4 Limites de session
| Paramètre | Valeur | Source |
|---|---|---|
| Durée max session audio-only | **15 minutes** | ai.google.dev/gemini-api/docs/live-guide §Limitations |
| Context window | 128k tokens (native audio) | idem |
| Session state | Stateful dans une session, pas de mémoire inter-session | idem |
### 1.5 Fonctionnalités natives utilisées
| Fonctionnalité | Activation | Utilité Expria |
|---|---|---|
| Voice Activity Detection (VAD) | Automatique, configuré : `endOfSpeechSensitivity: LOW`, `silenceDurationMs: 2000` | Détecte quand le candidat parle/s'arrête. 2s de silence avant que l'IA réponde — laisse le temps de réfléchir |
| Barge-in (interruption) | Natif, non configurable | L'utilisateur peut interrompre l'IA naturellement |
| Input transcription | `inputAudioTranscription: {}` dans config | Transcript de ce que dit le candidat |
| Output transcription | `outputAudioTranscription: {}` dans config | Transcript de ce que dit l'IA |
| Affective dialog | `enableAffectiveDialog: true` (v1alpha) | Optionnel — ton naturel |
### 1.6 Configuration VAD — justification
| Paramètre | Valeur retenue | Justification |
|---|---|---|
| `disabled` | `false` | VAD automatique côté Gemini — le frontend n'a pas à gérer la détection de parole |
| `startOfSpeechSensitivity` | `START_SENSITIVITY_LOW` | Évite les faux positifs (bruits ambiants, respiration) |
| `endOfSpeechSensitivity` | `END_SENSITIVITY_LOW` | Tolère les pauses de réflexion du candidat sans couper la parole |
| `silenceDurationMs` | `2000` | 2 secondes de silence avant que l'IA considère que le candidat a fini. À ajuster entre 1500-3000ms après tests manuels |
**Fallback si le VAD automatique ne convient pas :**
Désactiver le VAD (`disabled: true`) et basculer sur un mode "talkie-walkie" :
le frontend envoie `activityStart` quand le candidat appuie sur un bouton "Parler",
et `activityEnd` quand il relâche. Moins naturel mais fiable à 100%.
### 1.6 Tarification
| Tier | Audio input | Audio output | Source |
|---|---|---|---|
| Free | Disponible avec rate limits | idem | ai.google.dev/gemini-api/docs/pricing |
| Paid (Tier 1+) | Inclus dans le token count | idem | idem |
Note : le pricing Live API est basé sur le token count, pas sur la durée. Les tokens audio sont comptés différemment des tokens texte. Vérifier les rate limits réels dans AI Studio pour le projet Expria.
---
## 2. Architecture — vue d'ensemble
```
┌─────────────────────────┐
│ Navigateur candidat │
│ (React + AudioWorklet) │
│ PCM 16kHz → base64 │
│ base64 → PCM 24kHz │
└──────────┬──────────────┘
│ WebSocket (wss://api.expria.app/t2/live?token=jwt&sujet=uuid)
┌──────────▼──────────────┐
│ Backend Expria │
│ (Hono / Node.js) │
│ Render Frankfurt │
│ │
│ 1. Auth JWT + plan │
│ 2. Fetch sujet │
│ 3. Build prompt │
│ 4. Proxy bidirectionnel│
│ 5. Accumule transcript │
│ 6. Évaluation finale │
│ 7. Sauvegarde BDD │
└──────────┬──────────────┘
│ WebSocket (wss://generativelanguage.googleapis.com/ws/...)
┌──────────▼──────────────┐
│ Gemini Live API │
│ gemini-2.5-flash- │
│ native-audio │
│ Google Cloud │
└─────────────────────────┘
```
### Pourquoi un proxy backend (et pas client-to-server direct)
Google recommande officiellement les tokens éphémères pour les apps client-to-server. Cependant, pour Expria :
1. La clé API (`GEMINI_API_KEY`) ne doit jamais être exposée côté frontend (règle absolue SECURITY.md)
2. Le backend doit accumuler le transcript pour l'évaluation finale
3. Le backend doit sauvegarder la production en base après la session
4. Le gating plan Premium doit être vérifié côté serveur
Le proxy backend est la seule architecture viable.
---
## 3. Algorithme d'exécution — Backend
### Phase 1 : Connexion (< 2 secondes)
```
ENTRÉE : WebSocket client avec ?token=jwt&sujet=uuid
1. EXTRAIRE jwt et sujet_id des query params
2. VÉRIFIER jwt via Supabase → obtenir profile
├─ INVALIDE → close(4001, "AUTH_REQUIRED")
└─ VALIDE → continuer
3. VÉRIFIER hasAccess(profile.plan, 'oral_t2_live')
├─ INSUFFISANT → close(4003, "PLAN_INSUFFICIENT")
└─ OK → continuer
4. FETCH sujet FROM sujets WHERE id = sujet_id AND mode = 'EO' AND tache = 2
├─ NOT FOUND → close(4004, "SUJET_NOT_FOUND")
└─ TROUVÉ → extraire consigne, contexte, role
5. CONSTRUIRE systemPrompt à partir du template Prompt_t2live.md §3
avec substitution {role} et {contexte}
6. OUVRIR WebSocket vers Gemini Live API :
URL = wss://generativelanguage.googleapis.com/ws/
google.ai.generativelanguage.v1beta.
GenerativeService.BidiGenerateContent?key={GEMINI_API_KEY}
7. ENVOYER setup frame :
{
"config": {
"model": "models/gemini-2.5-flash-native-audio",
"responseModalities": ["AUDIO"],
"systemInstruction": {
"parts": [{ "text": systemPrompt }]
},
"inputAudioTranscription": {},
"outputAudioTranscription": {},
"speechConfig": {
"voiceConfig": {
"prebuiltVoiceConfig": { "voiceName": "Kore" }
}
},
"realtimeInputConfig": {
"automaticActivityDetection": {
"disabled": false,
"startOfSpeechSensitivity": "START_SENSITIVITY_LOW",
"endOfSpeechSensitivity": "END_SENSITIVITY_LOW",
"silenceDurationMs": 2000
}
}
}
}
// VAD : END_SENSITIVITY_LOW + 2s de silence avant que l'IA réponde
// → le candidat peut réfléchir entre ses phrases sans être interrompu
// À ajuster entre 1500-3000ms après tests manuels
// Si le VAD automatique ne convient pas : option fallback VAD manuel
// disabled: true + activityStart/activityEnd côté client (mode talkie-walkie)
8. INITIALISER accumulateurs :
- inputTranscript = [] // ce que dit le candidat
- outputTranscript = [] // ce que dit l'IA
- sessionStartTime = Date.now()
```
### Phase 2 : Proxy bidirectionnel (durée libre, max 15 min)
```
BOUCLE PARALLÈLE :
THREAD A — Client → Gemini :
POUR CHAQUE message reçu du client :
SI message.type === 'audio' :
TRANSMETTRE à Gemini : {
"realtimeInput": {
"audio": {
"data": message.data, // base64 PCM 16kHz
"mimeType": "audio/pcm;rate=16000"
}
}
}
SI message.type === 'end' :
DÉCLENCHER Phase 3
THREAD B — Gemini → Client :
POUR CHAQUE message reçu de Gemini :
SI message.serverContent.modelTurn?.parts :
POUR CHAQUE part :
SI part.inlineData :
TRANSMETTRE au client : {
type: 'audio',
data: part.inlineData.data, // base64 PCM 24kHz
mimeType: part.inlineData.mimeType
}
SI message.serverContent.inputTranscription :
ACCUMULER dans inputTranscript[]
SI message.serverContent.outputTranscription :
ACCUMULER dans outputTranscript[]
SI message.serverContent.interrupted :
TRANSMETTRE au client : { type: 'interrupted' }
SI message.serverContent.turnComplete :
TRANSMETTRE au client : { type: 'turnComplete' }
GUARD — Timeout 15 min :
SI Date.now() - sessionStartTime > 14 * 60 * 1000 :
TRANSMETTRE au client : { type: 'warning', message: '1 minute restante' }
SI Date.now() - sessionStartTime > 15 * 60 * 1000 :
DÉCLENCHER Phase 3
```
### Phase 3 : Fin de session + évaluation (< 30 secondes)
```
1. FERMER WebSocket Gemini (close 1000)
2. RECONSTRUIRE le transcript complet :
fullTranscript = inputTranscript.map(t => "Candidat : " + t.text)
.interleave(outputTranscript.map(t => "Examinateur : " + t.text))
3. CRÉER production en base :
INSERT INTO productions (
user_id, tache, mode, contenu, created_at
) VALUES (
profile.id, 'EO_T2_LIVE', 'entrainement', fullTranscript, NOW()
)
→ obtenir production.id
4. ENVOYER le transcript au pipeline de correction EO existant :
correctionResult = await correctEO({
transcript: fullTranscript,
tache: 'EO_T2',
nclcCible: profile.nclc_cible || 9,
productionId: production.id
})
// Réutilise le prompt de correction EO + DeepSeek
// Note : phonologie = 0 (TD-08, pas d'audio brut disponible)
5. METTRE À JOUR la production :
UPDATE productions SET rapport = correctionResult.rapport,
score = correctionResult.score,
nclc = correctionResult.nclc
WHERE id = production.id
6. TRANSMETTRE au client :
{ type: 'report', data: correctionResult }
7. FERMER WebSocket client (close 1000)
```
---
## 4. Algorithme d'exécution — Frontend
### Phase 1 : Initialisation
```
1. PAGE DE SÉLECTION SUJET :
- Fetch GET /sujets?mode=EO&tache=2 → liste sujets
- Afficher grille de sujets (réutiliser SujetsEOPage)
- Clic sujet → stocker sujet.id + sujet.consigne
2. PAGE DE PRÉPARATION :
- Afficher consigne + contexte du sujet
- Explication : "Vous êtes le candidat. C'est à vous de prendre la parole
en premier pour initier la conversation, comme à l'examen réel."
- Bouton "Démarrer le dialogue"
- Demander permission micro (navigator.mediaDevices.getUserMedia)
```
### Phase 2 : Connexion audio + WebSocket
```
AU CLIC "Démarrer" :
1. OUVRIR WebSocket :
ws = new WebSocket(`wss://api.expria.app/t2/live?token=${jwt}&sujet=${sujetId}`)
2. STATE MACHINE → 'connecting'
3. INITIALISER AudioContext capture (16kHz) :
captureCtx = new AudioContext({ sampleRate: 16000 })
// Si le navigateur ne supporte pas 16kHz nativement,
// créer à sampleRate par défaut et rééchantillonner dans le worklet
4. CHARGER AudioWorklet :
await captureCtx.audioWorklet.addModule('pcm-capture-processor.js')
// Le worklet :
// - Reçoit des Float32 du micro
// - Rééchantillonne à 16kHz si nécessaire
// - Convertit Float32 → Int16 PCM little-endian
// - Envoie les chunks via port.postMessage
5. CONNECTER le micro :
stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
})
source = captureCtx.createMediaStreamSource(stream)
workletNode = new AudioWorkletNode(captureCtx, 'pcm-capture-processor')
source.connect(workletNode)
6. INITIALISER AudioContext playback (24kHz) :
playbackCtx = new AudioContext({ sampleRate: 24000 })
// File d'attente de buffers audio pour lecture séquentielle
7. ÉCOUTER les chunks du worklet :
workletNode.port.onmessage = (e) => {
const pcmBase64 = arrayBufferToBase64(e.data)
ws.send(JSON.stringify({ type: 'audio', data: pcmBase64 }))
}
```
### Phase 3 : Dialogue en temps réel
```
STATE MACHINE :
'connecting' → ws.onopen → 'ready' (candidat peut parler)
'ready' → audio candidat détecté → 'speaking'
'speaking' → silence détecté (VAD) → 'listening' (IA répond)
'listening' → audio candidat détecté → 'speaking'
'speaking' ↔ 'listening' (boucle dialogue)
'*' → bouton "Terminer" → 'processing'
'processing' → rapport reçu → 'ended'
'*' → erreur WS → 'error'
// Le candidat initie la conversation (Option A — conforme à l'examen réel).
// L'IA attend en silence que le candidat prenne la parole.
// Gemini VAD (silenceDurationMs: 2000) gère la détection automatiquement.
RÉCEPTION messages WebSocket :
POUR CHAQUE message reçu :
SI message.type === 'audio' :
DÉCODER base64 → Int16 PCM
CRÉER AudioBuffer (24kHz, mono)
AJOUTER à la file de lecture
SI pas déjà en lecture → DÉMARRER lecture
SI message.type === 'turnComplete' :
STATE → 'ready' (le candidat peut reprendre la parole)
SI message.type === 'interrupted' :
ARRÊTER lecture audio en cours
VIDER file de lecture
SI message.type === 'report' :
STATE → 'ended'
NAVIGUER vers /rapport/:productionId
SI message.type === 'warning' :
AFFICHER notification "1 minute restante"
SI message.type === 'error' :
STATE → 'error'
AFFICHER message + bouton "Réessayer"
ENVOI fin de dialogue :
AU CLIC "Terminer" :
ws.send(JSON.stringify({ type: 'end' }))
STATE → 'processing'
ARRÊTER capture micro
AFFICHER spinner "Évaluation en cours..."
```
### Phase 4 : Cleanup
```
À LA FERMETURE (fin normale, erreur, ou navigation) :
1. FERMER WebSocket si ouvert
2. ARRÊTER MediaStream (stream.getTracks().forEach(t => t.stop()))
3. FERMER captureCtx (captureCtx.close())
4. FERMER playbackCtx (playbackCtx.close())
5. ANNULER tout rAF ou timer en cours
6. SI fin normale (state === 'ended') :
- Conserver recordingChunks pour le bouton "Télécharger"
SINON :
- Libérer recordingChunks (= [])
```
---
## 5. AudioWorklet — pcm-capture-processor.js
```javascript
// Exécuté dans un thread séparé (Audio Worklet Thread)
class PcmCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.buffer = new Float32Array(0)
// Chunk size : 4096 samples à 16kHz = 256ms de latence
// Compromis entre latence et overhead réseau
this.chunkSize = 4096
}
process(inputs) {
const input = inputs[0]
if (!input || !input[0]) return true
const channelData = input[0] // mono
// Accumuler
const newBuffer = new Float32Array(this.buffer.length + channelData.length)
newBuffer.set(this.buffer)
newBuffer.set(channelData, this.buffer.length)
this.buffer = newBuffer
// Envoyer quand on a assez de samples
while (this.buffer.length >= this.chunkSize) {
const chunk = this.buffer.slice(0, this.chunkSize)
this.buffer = this.buffer.slice(this.chunkSize)
// Float32 → Int16 PCM little-endian
const pcm = new ArrayBuffer(chunk.length * 2)
const view = new DataView(pcm)
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true) // true = little-endian
}
this.port.postMessage(pcm, [pcm]) // Transferable
}
return true
}
}
registerProcessor('pcm-capture-processor', PcmCaptureProcessor)
```
Note : si le navigateur crée l'AudioContext à 44.1kHz ou 48kHz au lieu de 16kHz, un rééchantillonnage est nécessaire dans le processor. Gemini accepte tout sample rate (il rééchantillonne côté serveur), mais envoyer du 48kHz triple la bande passante inutilement. Privilégier `new AudioContext({ sampleRate: 16000 })` — supporté sur Chrome, Firefox, Edge modernes.
---
## 5bis. Enregistrement audio téléchargeable
La conversation complète (candidat + IA) est enregistrée côté frontend pour
permettre le téléchargement en fin de session — comme pour EO T1/T3.
### Principe : buffer chronologique unique
Les chunks audio du candidat (16kHz) et de l'IA (24kHz) arrivent en temps réel.
Ils sont **horodatés et accumulés dans un buffer unique** dans l'ordre
chronologique réel, puis assemblés en un fichier WAV mono 24kHz en fin de session.
```
AU DÉMARRAGE de la session :
recordingChunks = [] // { data: Int16Array, source: 'candidate'|'ai', time: number }
sessionStartTime = Date.now()
À CHAQUE chunk envoyé par le candidat (PCM 16kHz) :
1. RÉÉCHANTILLONNER 16kHz → 24kHz (interpolation linéaire : chaque sample
est dupliqué × 1.5 — ou utiliser OfflineAudioContext pour un résultat propre)
2. recordingChunks.push({
data: resampled24k, // Int16Array à 24kHz
source: 'candidate',
time: Date.now()
})
À CHAQUE chunk reçu de l'IA (PCM 24kHz) :
recordingChunks.push({
data: chunk, // Int16Array déjà à 24kHz
source: 'ai',
time: Date.now()
})
EN FIN DE SESSION (après réception du rapport) :
1. TRIER recordingChunks par time (normalement déjà ordonné)
2. CONCATÉNER tous les .data en un seul Int16Array
3. ENCODER en WAV :
- Header WAV : 44 octets (PCM, mono, 24kHz, 16 bits)
- Data : le buffer concaténé
4. CRÉER Blob + URL.createObjectURL
5. PROPOSER bouton "Télécharger l'audio" (download filename :
expria-t2-{date}.wav)
```
### Détail du rééchantillonnage candidat 16kHz → 24kHz
```javascript
// Méthode simple : interpolation linéaire
// Ratio : 24000 / 16000 = 1.5 → pour 2 samples en entrée, 3 en sortie
function resample16to24(input16k) {
const ratio = 24000 / 16000 // 1.5
const outputLength = Math.ceil(input16k.length * ratio)
const output = new Int16Array(outputLength)
for (let i = 0; i < outputLength; i++) {
const srcIndex = i / ratio
const srcFloor = Math.floor(srcIndex)
const srcCeil = Math.min(srcFloor + 1, input16k.length - 1)
const frac = srcIndex - srcFloor
output[i] = Math.round(
input16k[srcFloor] * (1 - frac) + input16k[srcCeil] * frac
)
}
return output
}
```
### Estimation mémoire
- Dialogue de 10 minutes = 600 secondes
- PCM 24kHz mono 16 bits = 48 000 octets/seconde
- Total : 600 × 48 000 = **~28 Mo en mémoire**
- Acceptable pour un navigateur moderne (RAM > 1 Go)
### Fichier WAV header
```javascript
function createWavFile(pcmData, sampleRate = 24000) {
const numChannels = 1
const bitsPerSample = 16
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
const blockAlign = numChannels * (bitsPerSample / 8)
const dataSize = pcmData.byteLength
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
// RIFF header
writeString(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true)
writeString(view, 8, 'WAVE')
// fmt chunk
writeString(view, 12, 'fmt ')
view.setUint32(16, 16, true) // chunk size
view.setUint16(20, 1, true) // PCM format
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, byteRate, true)
view.setUint16(32, blockAlign, true)
view.setUint16(34, bitsPerSample, true)
// data chunk
writeString(view, 36, 'data')
view.setUint32(40, dataSize, true)
// PCM data
new Uint8Array(buffer, 44).set(new Uint8Array(pcmData.buffer))
return new Blob([buffer], { type: 'audio/wav' })
}
```
---
## 6. Scalabilité et limites
### 6.1 Render (plan Starter)
| Contrainte | Valeur | Impact Expria |
|---|---|---|
| Connexions WS simultanées | Pas de limite documentée (Starter) | OK pour MVP |
| Timeout connexion | Pas de hard limit WS | OK — Gemini a son propre cap de 15 min |
| Mémoire | 512 Mo (Starter) | Chaque session T2 = 2 WS + buffers audio ≈ 5-10 Mo. ~50 sessions simultanées max théorique |
| CPU | Partagé (Starter) | Le backend est un proxy passif (pas de traitement audio) — charge CPU minimale |
**Scalabilité :** le goulot d'étranglement n'est pas Render mais le rate limit Gemini. Avec le plan Paid Tier 1 Google, le nombre de sessions simultanées est limité par les RPM/TPM du projet Google AI.
### 6.2 Gemini Live API
| Contrainte | Valeur | Impact |
|---|---|---|
| Session max | 15 min (audio-only) | Suffisant pour T2 EO — dialogue libre en entraînement |
| Context window | 128k tokens | Largement suffisant pour un dialogue oral de 15 min |
| Rate limits | Variables par tier — vérifier dans AI Studio | À monitorer en production |
| Sessions simultanées | Non documenté précisément — dépend du tier | Commencer avec 1-3 simultanées, scaler au besoin |
### 6.3 Stratégie de scalabilité progressive
```
Phase 1 — MVP (Sprint 6) :
- 1 seul projet Google AI
- Plan Free ou Paid Tier 1
- Objectif : < 5 sessions T2 simultanées
- Monitoring : log chaque session (durée, tokens, erreurs)
Phase 2 — Production (post-launch) :
- Passer en Paid Tier 2 si nécessaire
- Ajouter un rate limiter côté backend (max 1 session T2 par utilisateur)
- Queue de sessions si le rate limit Gemini est atteint
- Monitoring : alertes sur le coût token mensuel
Phase 3 — Scale (si croissance) :
- Considérer Vertex AI pour SLA et rate limits supérieurs
- Load balancing multi-instance Render
- Session affinity (sticky sessions pour les WS)
```
---
## 7. Gestion des erreurs
### 7.1 Erreurs de connexion Gemini
| Erreur | Cause probable | Action backend |
|---|---|---|
| Gemini WS refuse connexion | Rate limit atteint ou clé invalide | close(4005, "GEMINI_UNAVAILABLE") → client affiche "Service temporairement indisponible" |
| Gemini WS drop en cours | Instabilité réseau | Tenter 1 reconnexion automatique. Si échec → close(4006, "GEMINI_DISCONNECTED") |
| Gemini setup frame rejeté | Modèle invalide ou config incorrecte | Log erreur + close(4005) |
### 7.2 Erreurs côté client
| Erreur | Cause | Action frontend |
|---|---|---|
| `getUserMedia` refusé | Permission micro refusée | Afficher message explicite + lien vers paramètres navigateur |
| AudioContext non supporté | Navigateur ancien | Afficher "Navigateur non supporté" (Firefox < 76, Safari < 14.1) |
| WebSocket drop | Réseau instable | State → 'error' + bouton "Réessayer" |
---
## 8. Fichiers à créer / modifier — inventaire Sprint 6
### Backend (expria-backend)
| Fichier | Action | Description |
|---|---|---|
| `src/lib/geminiLive.ts` | **Modifier** | Remplacer prompt agent immobilier par prompt dynamique, ajouter inputAudioTranscription + outputAudioTranscription dans config, accumuler transcript |
| `src/routes/t2live.ts` | **Modifier** | Ajouter fetch sujet, passer consigne/contexte/role à openGeminiLiveSession, déclencher évaluation finale + sauvegarde BDD après fin de session |
| `docs/Prompt_t2live.md` | **Créer** | Déjà rédigé — à committer |
### Frontend (expria-frontend)
| Fichier | Action | Description |
|---|---|---|
| `public/pcm-capture-processor.js` | **Créer** | AudioWorklet pour capture PCM 16kHz |
| `src/features/t2-live/pages/T2LivePage.tsx` | **Créer** | Page de sélection sujet T2 |
| `src/features/t2-live/pages/T2PreparationPage.tsx` | **Créer** | Page de préparation (consigne + bouton démarrer) |
| `src/features/t2-live/pages/T2DialoguePage.tsx` | **Créer** | Page de dialogue live (waveform, état IA, bouton terminer) |
| `src/features/t2-live/hooks/useT2LiveSession.ts` | **Créer** | Hook WebSocket + state machine |
| `src/features/t2-live/hooks/useAudioCapture.ts` | **Créer** | AudioContext + AudioWorklet + envoi PCM |
| `src/features/t2-live/hooks/useAudioPlayback.ts` | **Créer** | Réception PCM 24kHz + file de lecture |
| `src/features/t2-live/hooks/useAudioRecording.ts` | **Créer** | Buffer chronologique candidat+IA, rééchantillonnage 16→24kHz, export WAV, bouton télécharger |
| `src/features/t2-live/state/t2-machine.ts` | **Créer** | State machine pure (testable — FTD-09) |
| `src/features/t2-live/state/__tests__/t2-machine.test.ts` | **Créer** | 7+ tests state machine |
| `src/app/router.tsx` | **Modifier** | Ajouter routes /simulation/eo/t2/* |
---
## 9. Découpage en sous-sprints recommandé
Le Sprint 6 est trop large pour une seule session. Découpage proposé :
```
Sprint 6a — Backend T2 Live (1 session)
- Modifier geminiLive.ts (prompt dynamique, transcription, accumulation)
- Modifier t2live.ts (fetch sujet, évaluation finale, sauvegarde)
- Tests backend
- Test manuel : connexion WS via wscat ou script Node
Sprint 6b — Frontend capture + playback audio (1 session)
- pcm-capture-processor.js (AudioWorklet)
- useAudioCapture.ts
- useAudioPlayback.ts
- Test manuel : enregistrer + lire du PCM dans le navigateur
Sprint 6c — Frontend state machine + UI (1 session)
- t2-machine.ts + tests
- useT2LiveSession.ts
- Pages T2 (sélection, préparation, dialogue)
- Intégration complète
Sprint 6d — Clean + Golden Dataset (1 session)
- Tests Groupe D (D2-D6)
- Factorisation
- CHANGELOG
```
---
## 10. Références officielles
| Document | URL |
|---|---|
| Get started WebSockets | https://ai.google.dev/gemini-api/docs/live-api/get-started-websocket |
| Capabilities guide | https://ai.google.dev/gemini-api/docs/live-api/capabilities |
| Session management | https://ai.google.dev/gemini-api/docs/live-api/session-management |
| Ephemeral tokens | https://ai.google.dev/gemini-api/docs/live-api/ephemeral-tokens |
| Best practices | https://ai.google.dev/gemini-api/docs/live-api/best-practices |
| WebSocket API reference | https://ai.google.dev/api/live |
| Rate limits | https://ai.google.dev/gemini-api/docs/rate-limits |
| Pricing | https://ai.google.dev/gemini-api/docs/pricing |
| Example app (JS + proxy) | https://github.com/google-gemini/gemini-live-api-examples |

View file

@ -1,176 +0,0 @@
# Prompt Exercices Long Terme — Analyse patterns TCF Canada
> **Source :** `src/lib/deepseek.ts` → fonction `generatePatternExercices(patterns)`
> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.4` · `response_format: json_object` · `AbortSignal.timeout(20_000)`
> **Introduit :** Sprint 3.6c (2026-04-22) — commit `c48ae8d`
---
## Contexte & Variables dynamiques
Ce prompt est déclenché par `GET /users/patterns` (Premium uniquement) quand l'analyse des 5 dernières productions révèle un ou plusieurs **patterns confirmés** (même code d'erreur présent dans ≥ 3 productions sur 5).
Les exercices produits sont **distincts** des exercices individuels générés par correction (prompt dans `Prompt_maître.md`) : ils ciblent des faiblesses *structurelles récurrentes* plutôt qu'une production spécifique.
| Variable | Description | Exemple |
|---|---|---|
| `patterns` | Liste des patterns confirmés issus de `aggregatePatterns` | Voir structure ci-dessous |
### Structure d'un pattern en entrée
```typescript
interface PatternInput {
code: string // Code taxonomie (cf. TAXONOMIE_ERREURS.md)
critere: Critere // 'adequation_tache' | 'coherence_cohesion' |
// 'competence_lexicale' | 'competence_grammaticale'
frequency: number // 3, 4 ou 5 (seuil d'agrégation)
description: string | null // non-null uniquement pour code === 'autre'
}
```
Exemple d'entrée (3 patterns) :
```
- accord_sujet_verbe (competence_grammaticale) — apparu 4/5 fois
- connecteurs_repetes (coherence_cohesion) — apparu 3/5 fois
- repetition_lexicale (competence_lexicale) — apparu 3/5 fois
```
---
## Prompt système envoyé au modèle
```
Tu es un coach spécialisé dans la préparation au TCF Canada (Test de connaissance du français).
Un candidat commet systématiquement les mêmes erreurs sur ses 5 dernières productions écrites. Tu dois produire UN exercice ciblé par pattern d'erreur récurrent identifié.
CONTEXTE :
- Ces exercices sont des exercices LONG TERME destinés à corriger des faiblesses structurelles récurrentes.
- Ils sont DISTINCTS des exercices individuels générés après chaque correction (qui ciblent une production spécifique).
- Tu n'as PAS accès au texte du candidat. Tes exemples doivent être génériques et représentatifs de l'erreur.
RÈGLES :
1. Un exercice par pattern en entrée, dans le même ordre.
2. Le diagnostic explique en 1-2 phrases POURQUOI cette erreur est problématique pour le TCF Canada.
3. La consigne demande au candidat de corriger ou reformuler une phrase.
4. L'exemple est une phrase incorrecte illustrant le pattern (inventée, pas tirée du candidat).
5. La correction est la version correcte de l'exemple.
6. L'astuce est un procédé mnémotechnique, une règle pratique ou un réflexe de relecture que le candidat doit appliquer APRÈS avoir rédigé son texte pour détecter et corriger cette erreur lui-même. Formulée comme un conseil direct et actionnable.
Exemples d'astuces :
- Subjonctif : "Après 'bien que', 'pourvu que', 'avant que' → le verbe qui suit est TOUJOURS au subjonctif. Relisez votre texte en cherchant ces expressions."
- Accords : "Relisez chaque phrase en pointant du doigt le sujet et son verbe. S'ils sont éloignés, vérifiez l'accord."
- Connecteurs : "Après rédaction, surlignez tous vos connecteurs. Si le même revient plus de 2 fois, remplacez-en un."
7. Niveau de langue : NCLC 7-9 (ni trop simple, ni trop littéraire).
8. Les exemples doivent être en contexte TCF Canada : courriels, lettres formelles, essais argumentatifs, situations professionnelles canadiennes.
FORMAT DE SORTIE — JSON strict, aucun texte avant ni après :
{
"exercises": [
{
"code": "<code_taxonomie>",
"critere": "<critere>",
"diagnostic": "<1-2 phrases>",
"exercice": {
"consigne": "<instruction au candidat>",
"exemple": "<phrase incorrecte>",
"correction": "<phrase corrigée>",
"astuce": "<procédé mnémotechnique ou réflexe de relecture>"
}
}
]
}
```
---
## Prompt utilisateur — template dynamique
Construit par `buildPatternExercicesUserPrompt(patterns)` dans `src/lib/deepseek.ts` :
```
Voici les patterns d'erreurs récurrents détectés sur les 5 dernières productions du candidat :
- <code> (<critere>) — apparu <frequency>/5 fois[ — « <description> »]
- ...
Produis un exercice ciblé par pattern. JSON strict uniquement.
```
Le fragment `— « <description> »` n'apparaît que lorsque le code est `autre` (description textuelle obligatoire selon la taxonomie — cf. `TAXONOMIE_ERREURS.md` §Règles d'utilisation).
---
## Structure de la réponse JSON attendue
```json
{
"exercises": [
{
"code": "accord_sujet_verbe",
"critere": "competence_grammaticale",
"diagnostic": "<1-2 phrases expliquant pourquoi cette erreur coûte des points au TCF>",
"exercice": {
"consigne": "<instruction claire au candidat>",
"exemple": "<phrase incorrecte générique, contexte TCF Canada>",
"correction": "<version correcte>",
"astuce": "<procédé mnémotechnique ou réflexe de relecture actionnable>"
}
}
]
}
```
---
## Champs expliqués
| Champ | Rôle |
|---|---|
| `code` | Code taxonomie propagé depuis l'entrée — permet au frontend de rattacher l'exercice à son pattern |
| `critere` | Critère TCF parmi les 4 officiels — validé en runtime via `isValidCritere` |
| `diagnostic` | Explication pédagogique courte : pourquoi cette erreur pénalise au TCF |
| `exercice.consigne` | Instruction explicite au candidat (« Corrigez », « Reformulez », « Complétez ») |
| `exercice.exemple` | Phrase **incorrecte** illustrant l'erreur — inventée par le modèle, en contexte TCF Canada (courriel formel, lettre, essai, cadre professionnel) |
| `exercice.correction` | Version correcte de l'exemple |
| `exercice.astuce` | **Conseil de relecture actionnable** — procédé mnémotechnique ou règle pratique que le candidat applique lors de la phase de relecture pour détecter ses propres erreurs |
---
## Post-traitement côté serveur
Après réception de la réponse DeepSeek, `generatePatternExercices` valide chaque item :
1. **Présence stricte** des champs `code`, `critere`, `diagnostic`, `exercice.consigne`, `exercice.exemple`, `exercice.correction`, `exercice.astuce` — tous doivent être des chaînes non vides.
2. **Validation du critère** via `isValidCritere` (cf. `src/lib/taxonomieErreurs.ts`) — tout critère hors taxonomie est **filtré**.
3. **Les items invalides sont silencieusement ignorés** — pas de throw. La liste retournée peut donc être plus courte que la liste de patterns en entrée.
La réponse persistée dans `pattern_analyses.exercises` (JSONB) est la liste filtrée.
---
## Dégradation gracieuse
Si l'appel DeepSeek échoue (timeout, API error, JSON invalide) :
- `patternsController.list` capture l'erreur, logue `[patternsController.list] generatePatternExercices failed`.
- L'analyse est persistée avec `exercises: []` — patterns et indice de préparation restent disponibles.
- Le frontend affiche la liste des patterns sans section exercices (cf. `ProgressionPremium`).
- Un refetch ultérieur (après `staleTime` ou nouvelle correction) retentera la génération.
---
## Contraintes opérationnelles
| Paramètre | Valeur | Justification |
|---|---|---|
| Modèle | `deepseek-chat` | Cohérent avec les autres prompts (correction, modèle, idées) |
| Température | `0.4` | Légèrement plus élevée que la correction (`0.2`) pour favoriser la variété des exemples |
| Format | `json_object` | Strict, pas de markdown parasite |
| Timeout | 20 000 ms | Plus long que `generateIdees` (15 s) car la réponse peut contenir jusqu'à ~10 exercices (4 critères × patterns) |
---
## Historique de ce document
| Version | Date | Changements |
|---|---|---|
| 1.0 | 2026-04-23 | Création — documentation du prompt validé par Hermann lors du Sprint 3.6c (2026-04-22) |

View file

@ -1,153 +0,0 @@
# Prompt Maître — Correction Expression Écrite TCF Canada
> **Source :** `app/api/corriger/route.ts` → fonction `buildPrompt()`
> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.2` · `response_format: json_object`
---
## Contexte & Variables dynamiques
| Variable | Description | Valeur par défaut |
|---|---|---|
| `nclc` | Niveau NCLC cible du candidat (7 à 10) | `9` |
| `minScore` | Score minimum requis sur 20 | `14` (NCLC 9) |
| `taskDesc` | Description de la tâche (voir ci-dessous) | — |
| `sujet` | Consigne ou sujet donné au candidat | `"Non précisé"` |
| `texte` | Production écrite du candidat | — |
| `sourceDoc1` | Document POUR (Tâche 3 uniquement) | — |
| `sourceDoc2` | Document CONTRE (Tâche 3 uniquement) | — |
### Barème NCLC → score minimum
| NCLC | Score minimum /20 |
|---|---|
| 7 | 10 |
| 8 | 12 |
| 9 | 14 |
| 10 | 16 |
---
## Descriptions des tâches
### Tâche 1 — Expression Écrite
> Message / mail / annonce **(60-120 mots)** : décrire, raconter, expliquer à un destinataire dont le registre (formel/informel) est précisé dans la consigne.
### Tâche 2 — Expression Écrite
> Article de blog / forum **(120-150 mots)** : compte rendu d'expérience ou récit, accompagné de commentaires, opinions ou arguments selon un objectif.
### Tâche 3 — Expression Écrite
> Texte comparatif **(120-180 mots)** :
> - **Partie 1** (40-60 mots) : synthèse des deux points de vue des documents sources
> - **Partie 2** (80-120 mots) : prise de position personnelle argumentée
---
## Prompt envoyé au modèle
```
Tu es un correcteur TCF Canada certifié par France Éducation International. Tu corriges avec précision et bienveillance.
OBJECTIF DU CANDIDAT : NCLC {nclc} — score minimum requis : {minScore}/20.
TÂCHE : {taskDesc}
[SI TÂCHE 3]
DOCUMENTS SOURCES :
Document 1 (point de vue POUR) : {sourceDoc1}
Document 2 (point de vue CONTRE) : {sourceDoc2}
[FIN SI TÂCHE 3]
CONSIGNE / SUJET : {sujet}
PRODUCTION DU CANDIDAT :
"""
{texte}
"""
CRITÈRES OFFICIELS TCF (chacun noté de 0 à 5) :
1. Adéquation à la tâche et au registre — respect des consignes, longueur, registre (formel/informel), pertinence du contenu.
2. Cohérence et cohésion du discours — structure logique, connecteurs, progression thématique, lisibilité globale.
3. Compétence lexicale — étendue du vocabulaire, précision, variété, absence de répétitions excessives.
4. Compétence grammaticale — correction des structures, morphologie verbale, syntaxe, ponctuation.
RÈGLES ABSOLUES :
- "exemple" = citation textuelle EXACTE, mot pour mot, extraite de la production du candidat. Jamais inventée.
- "commentaire" = 2 phrases maximum, directes, sans formule introductive.
- Interdit : "Voici", "Bien sûr", "Il convient de", toute formule introductive, tout markdown, tout backtick.
- "score" global = somme exacte des 4 scores critères (0 à 20).
- JSON strict sans aucun texte avant ni après.
```
---
## Structure de la réponse JSON attendue
```json
{
"score": "<entier 0-20, somme des 4 critères>",
"revelation": {
"croyance": "<ce que le candidat croit faire bien>",
"realite": "<ce que le correcteur observe réellement>",
"consequence": "<impact concret sur la note>"
},
"diagnostic": "<phrase courte et directe identifiant le principal frein>",
"criteres": [
{
"nom": "Adéquation à la tâche et au registre",
"score": "<0-5>",
"commentaire": "<2 phrases max>",
"exemple": "<citation textuelle exacte extraite de la production>",
"suggestion": "<reformulation ou correction concrète>",
"astuce": "<conseil court et mémorisable>"
},
{
"nom": "Cohérence et cohésion du discours",
"score": "<0-5>",
"commentaire": "<2 phrases max>",
"exemple": "<citation textuelle exacte extraite de la production>",
"suggestion": "<reformulation ou correction concrète>",
"astuce": "<conseil court et mémorisable>"
},
{
"nom": "Compétence lexicale",
"score": "<0-5>",
"commentaire": "<2 phrases max>",
"exemple": "<citation textuelle exacte extraite de la production>",
"suggestion": "<reformulation ou correction concrète>",
"astuce": "<conseil court et mémorisable>"
},
{
"nom": "Compétence grammaticale",
"score": "<0-5>",
"commentaire": "<2 phrases max>",
"exemple": "<citation textuelle exacte extraite de la production>",
"suggestion": "<reformulation ou correction concrète>",
"astuce": "<conseil court et mémorisable>"
}
],
"conseil_nclc": {
"nclc_cible": "NCLC {nclc}",
"ecart": "<manque X points / objectif atteint / X points au-dessus de l'objectif>",
"action_prioritaire": "<conseil direct, concret et personnalisé sur quoi travailler en priorité>"
}
}
```
---
## Champs expliqués
| Champ | Rôle |
|---|---|
| `score` | Note globale /20 = somme stricte des 4 critères |
| `revelation.croyance` | Perception erronée du candidat sur sa production |
| `revelation.realite` | Constat objectif du correcteur |
| `revelation.consequence` | Impact de cet écart sur la note finale |
| `diagnostic` | Diagnostic court : le frein principal identifié |
| `criteres[].commentaire` | Observation directe, 2 phrases max, sans introduction |
| `criteres[].exemple` | Citation **mot pour mot** tirée du texte du candidat |
| `criteres[].suggestion` | Reformulation ou correction concrète de l'exemple |
| `criteres[].astuce` | Conseil mémorisable pour progresser sur ce critère |
| `conseil_nclc.ecart` | Distance entre le score obtenu et l'objectif NCLC |
| `conseil_nclc.action_prioritaire` | Plan d'action personnalisé et prioritaire |

View file

@ -1,164 +0,0 @@
# Prompt Maître — Génération de la Production Modèle TCF Canada
> **Source :** `app/api/modele/route.ts` → handler `POST` (ligne 115)
> **Modèle :** DeepSeek Chat (`deepseek-chat`) · `temperature: 0.3` · `max_tokens: 2200`
---
## Principe de fonctionnement
Le prompt **réécrit la production du candidat** un niveau NCLC au-dessus de son score obtenu, en conservant intégralement ses idées et arguments. Il ne génère pas un texte de zéro.
```
nclcModele = min(nclcObtenu + 1, 10)
```
---
## Variables dynamiques
| Variable | Description | Exemple |
|---|---|---|
| `sujet` | Consigne ou sujet donné au candidat | `"Écrivez un mail à votre voisin..."` |
| `taskDescription` | Description officielle de la tâche (voir ci-dessous) | — |
| `texte` | Production originale du candidat | — |
| `nclcObtenu` | Niveau NCLC réellement atteint par le candidat (710) | `8` |
| `nclcModele` | Niveau NCLC cible de la production modèle (`nclcObtenu + 1`) | `9` |
| `scoreModele` | Score minimum requis pour atteindre `nclcModele` | `14` |
### Barème NCLC → score minimum
| NCLC | Score minimum /20 |
|---|---|
| 7 | 10 |
| 8 | 12 |
| 9 | 14 |
| 10 | 16 |
---
## Descriptions des tâches
### Tâche 1
> Expression écrite — Tâche 1 : Message / mail / annonce **(60-120 mots)**. Respect du registre (formel ou informel), salutation, corps du message, formule de clôture et signature.
### Tâche 2
> Expression écrite — Tâche 2 : Article de blog ou forum **(120-150 mots)**. Accroche, récit personnel à la 1re personne, opinion argumentée, conseil au lecteur.
### Tâche 3
> Expression écrite — Tâche 3 : Texte comparatif **(120-180 mots)**. Partie 1 (40-60 mots) : présentation neutre des deux documents. Partie 2 (80-120 mots) : prise de position personnelle argumentée.
---
## Prompt envoyé au modèle
```
Tu es un correcteur expert TCF Canada.
Le candidat a rédigé cette production sur le sujet suivant :
SUJET : {sujet}
TÂCHE : {taskDescription}
PRODUCTION DU CANDIDAT :
{texte}
Le candidat a obtenu NCLC {nclcObtenu}. Ta mission est de lui montrer comment atteindre NCLC {nclcModele} (score minimum {scoreModele}/20).
Ta mission : réécrire cette production EN CONSERVANT le fond, les idées, le positionnement et les arguments du candidat — mais en appliquant parfaitement les 4 critères officiels TCF Canada :
1. Réalisation de la tâche — respecter le format, les limites de mots, la consigne, le registre
2. Cohérence / Structure — paragraphes clairs, connecteurs logiques variés, progression cohérente
3. Étendue du lexique — vocabulaire riche et précis, zéro répétition, registre adapté
4. Maîtrise grammaticale — structures complexes, subjonctif, passif, subordination
RÈGLES ABSOLUES :
- Conserver les idées et arguments du candidat — ne pas inventer
- Respecter STRICTEMENT les limites de mots ci-dessous pour le champ production_modele_propre (ne jamais dépasser le maximum)
- Viser exactement le niveau NCLC {nclcModele}
- Le texte d'examen ne contient AUCUNE note : pas de [NOTE:], pas de commentaire entre parenthèses dans production_modele_propre
- Proposer exactement 3 entrées dans notes_pedagogiques (passage court + explication)
- Répondre en JSON valide sans markdown
COMPTAGE DES MOTS (TCF Canada, Expression écrite) :
- Un mot = segment séparé par des espaces (ou fins de ligne) ; l'apostrophe (' ou ') et le tiret (-) ne créent pas un mot supplémentaire.
- Exemples : « c'est », « l'eau », « aujourd'hui », « c'est-à-dire », « vas-y » comptent chacun pour un seul mot.
LONGUEUR production_modele_propre pour cette tâche (respecter min conseillé et max STRICT) :
- Tâche 1 : 60 à 120 mots — ne pas dépasser 120 mots
- Tâche 2 : 120 à 150 mots — ne pas dépasser 150 mots
- Tâche 3 : 120 à 180 mots — ne pas dépasser 180 mots
FORMAT JSON :
{
"production_modele_propre": "texte final seul, prêt pour l'examen, sans aucune annotation",
"notes_pedagogiques": [
{"passage": "extrait court du texte modèle", "explication": "pourquoi ce passage est efficace au TCF"}
],
"transformations": [
{"original": "extrait original du candidat", "ameliore": "version améliorée", "explication": "pourquoi c'est mieux"}
],
"message": "phrase courte encourageante sur les idées du candidat"
}
```
---
## Structure de la réponse JSON attendue
```json
{
"production_modele_propre": "<texte final réécrit, prêt pour l'examen, sans annotation>",
"notes_pedagogiques": [
{
"passage": "<extrait court du texte modèle>",
"explication": "<pourquoi ce passage est efficace au TCF>"
},
{
"passage": "<extrait court du texte modèle>",
"explication": "<pourquoi ce passage est efficace au TCF>"
},
{
"passage": "<extrait court du texte modèle>",
"explication": "<pourquoi ce passage est efficace au TCF>"
}
],
"transformations": [
{
"original": "<extrait original du candidat>",
"ameliore": "<version améliorée>",
"explication": "<pourquoi c'est mieux>"
}
],
"message": "<phrase courte encourageante sur les idées du candidat>"
}
```
---
## Champs expliqués
| Champ | Rôle |
|---|---|
| `production_modele_propre` | Texte final réécrit au niveau NCLC cible, sans aucune annotation, prêt pour l'examen |
| `notes_pedagogiques` | Exactement **3** passages du texte modèle commentés pédagogiquement |
| `notes_pedagogiques[].passage` | Extrait court tiré du texte modèle |
| `notes_pedagogiques[].explication` | Raison pour laquelle ce passage est efficace au TCF |
| `transformations` | Liste des améliorations appliquées sur des extraits précis |
| `transformations[].original` | Extrait original du candidat |
| `transformations[].ameliore` | Version améliorée de cet extrait |
| `transformations[].explication` | Justification pédagogique de l'amélioration |
| `message` | Message court et encourageant adressé au candidat |
---
## Post-traitement côté serveur
Après réception de la réponse du modèle, le serveur applique les traitements suivants :
1. **Nettoyage** — suppression de toutes les annotations entre crochets `[NOTE: ...]` ou parenthèses dans `production_modele_propre` via `stripModelAnnotations()`
2. **Vérification du nombre de mots** — comptage TCF via `wordCount()` (apostrophes et tirets ne créent pas de mots supplémentaires)
3. **Troncature automatique** — si le texte dépasse le maximum de mots autorisé, il est tronqué via `truncateToMaxWords()` et le flag `tcf_truncated: true` est retourné
4. **Enrichissement de la réponse** — ajout des métadonnées : `nclcModele`, `nclcObtenu`, `scoreCible`, `tcf_word_count`, `tcf_word_min`, `tcf_word_max`, `tcf_truncated`
5. **Persistance** — enregistrement dans la table `productions` avec `record_kind: "production_modele"` et lien vers le rapport parent via `parent_production_id`

View file

@ -1,198 +0,0 @@
# Prompt_t1live.md — Expria Backend
# Spécification du prompt système T1 EO Live + contrat WebSocket
> **Document de référence — Sprint 7a**
> À lire conjointement avec `Prompt_t2live.md` (symétrie / divergences T1↔T2) et
> `TECH_DEBT-backend.md` (TD-22, TD-23, TD-24, TD-25).
> Source de vérité du prompt : `buildT1SystemPrompt` dans `src/lib/geminiLiveT1.ts`.
---
> **⚠ NOTE LIMINAIRE — anti-TD-22 (symétrie avec `Prompt_t2live.md §3`)**
>
> La **règle 7 du T2** (« STRICTE INTERDICTION DE POSER DES QUESTIONS / ban du
> point d'interrogation ») n'est **PAS** propagée au T1. En **Tâche 1**,
> l'examinateur **DOIT relancer le candidat par des questions** : c'est le cœur
> de son rôle. Le point d'interrogation est utilisé normalement.
>
> Le prompt T1 (`buildT1SystemPrompt`) et le prompt T2 (`buildT2SystemPrompt`)
> vivent dans des fonctions distinctes de `geminiLive.ts` / `geminiLiveT1.ts`
> précisément pour éviter toute contamination de règle.
---
## 1. Contexte pédagogique
La **Tâche 1** de l'Expression Orale TCF Canada est un **entretien dirigé** : le
candidat se présente (identité, parcours, situation familiale, loisirs, projet
d'immigration au Canada) sous forme de **monologue**, et l'examinateur le
**relance** ponctuellement par des questions courtes pour approfondir.
**Différence structurelle avec le T2 :**
| Axe | T1 (entretien dirigé) | T2 (interaction de service) |
| ----------------- | ------------------------------ | ---------------------------- |
| Qui mène | L'examinateur relance | Le candidat mène |
| Questions de l'IA | **Obligatoires** (relances) | **Interdites** (rôle passif) |
| Forme candidat | Monologue + relances | Dialogue |
| Subject-based | **Non** (écoute en temps réel) | Oui (table `sujets`) |
---
## 2. Rôle de l'IA (examinateur)
L'IA joue un **examinateur bienveillant** du TCF Canada. Son comportement :
1. **Silencieux par défaut.** Tant que le candidat parle, elle n'intervient
jamais de sa propre initiative.
2. **Relance sur signal uniquement.** Elle ne prend la parole que lorsque le
**backend** le lui signale (injection `clientContent`). C'est le backend —
via une **horloge probabiliste** — qui décide du **TIMING** ; l'examinateur,
lui, **formule librement** une relance courte à partir de son contexte audio
interne. Le backend ne lit PAS la transcription partielle pour décider
(Modèle 1 acté — cf. ROADMAP / `geminiLiveT1.ts`).
3. **Relance courte et unique.** Une seule question de 10 à 20 mots, liée à ce
que le candidat vient de dire. Jamais d'enchaînement.
4. **Ton bienveillant et professionnel**, français B2-C1.
5. **N'évalue jamais** le candidat, ne corrige pas ses erreurs, ne commente pas
sa langue.
6. **Ne sort jamais du rôle**, ne mentionne jamais être une IA.
---
## 3. Prompt système (source : `buildT1SystemPrompt`)
Le prompt est **statique** : aucune variable substituée. L'examinateur formule
ses relances à partir de ce qu'il **entend en temps réel** (son contexte audio
interne) — il n'existe ni sujet T1 en base, ni questionnaire pré-rempli (T1 EO
n'est PAS subject-based).
```
RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
RÈGLES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire.
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
8. Tu restes toujours dans ton rôle d'examinateur. Tu ne mentionnes jamais que tu es une IA ou un modèle.
```
> **⚠ Spécificité T1 — règle 5 :** elle est l'**exact inverse** de la règle 7 du
> T2. Toute fusion des deux prompts est interdite (TD-22 / TD-23).
---
## 4. Contrat WebSocket T1 (figé — la suite Sprint 7b en dépend)
Route : **`WS /t1/live?token=<jwt>`**
Auth : JWT Supabase + permission Premium `oral_t2_live` (réutilise
`authenticate` de `t2live.ts` — cf. dette de nommage TD-24).
La session Gemini s'ouvre **immédiatement après l'auth** (pas de message de
contexte ni de questionnaire). Le client envoie directement son audio. Tout
message non reconnu (ni `audio` ni `end`) est **ignoré silencieusement** (log
debug + return) — jamais de close.
### 4.1 Client → Backend
| Message | Forme | Effet |
| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Audio candidat | `{type:'audio', data}` (PCM 16 kHz base64) | Relayé à Gemini tant qu'un tour candidat est ouvert et qu'aucune interruption n'est en cours. |
| Fin de session | `{type:'end'}` | Déclenche `endSession()` (flush terminal + correction). |
### 4.2 Backend → Client
| Message | Forme | Sens |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- |
| Audio examinateur | frames Gemini verbatim (PCM 24 kHz) | Relances audio de l'examinateur. |
| Début d'interruption | `{type:'interruption_start'}` | L'examinateur prend la parole ; le front doit suspendre la capture candidat. |
| Fin d'interruption | `{type:'interruption_end'}` | Le candidat peut reprendre. |
| Avertissement temps | `{type:'warning', message}` | 30 s avant le timeout (`T1_SESSION_WARNING_MS`). |
| Rapport final | `{type:'report', data}` + **close 1000** | Évaluation EO_T1 prête. |
| Erreur applicative | `{type:'error', code, message}` | Codes : `EMPTY_TRANSCRIPT`, `PERSISTENCE_FAILED`, `CORRECTION_FAILED`. |
### 4.3 Codes de fermeture WebSocket
| Close code | Cause | Origine |
| ---------- | ------------------------------------------------------- | ------------------------- |
| 1000 | Fin normale + rapport prêt (ou transcript vide) | `runT1LiveCorrection` |
| 1011 | `PERSISTENCE_FAILED` / `CORRECTION_FAILED` | `runT1LiveCorrection` |
| 4001 | `AUTH_REQUIRED` (JWT absent/invalide) | `authenticate` |
| 4003 | `PLAN_INSUFFICIENT` (pas Premium) | `authenticate` |
| 4005 | `GEMINI_CONFIG` (clé API Gemini manquante côté serveur) | `openGeminiLiveT1Session` |
| 4006 | `GEMINI_DISCONNECTED` (WS Gemini fermé/erreur) | `openGeminiLiveT1Session` |
---
## 5. Comportement de fin de session (flush terminal)
**Contrainte VAD manuel (découverte spike — TD-23).** En VAD manuel
(`realtimeInputConfig.automaticActivityDetection.disabled = true`), Gemini ne
flushe `inputTranscription` (le texte candidat) **qu'à l'envoi d'un
`activityEnd`**, pas en continu. Le backend doit donc envoyer un **`activityEnd`
FINAL** aux bornes de tour pour récupérer le dernier segment candidat.
**Effet de bord et son traitement.** Cet `activityEnd` final déclenche AUSSI une
**relance examinateur « terminale »** non désirée. Elle est **coupée** :
- L'**audio** de cette relance terminale (`modelTurn … inlineData`) **n'est pas
forwardé** au client (le candidat ne l'entend jamais).
- Le **texte** de cette relance terminale (`outputTranscription`) est **jeté**
(non ajouté au transcript).
- Seul le **texte candidat final** (`inputTranscription`) est **conservé** pour
l'évaluation.
Le tri se fait **champ par champ** (pas message par message), car le segment
candidat à garder et la relance terminale à couper peuvent arriver dans le
**même** message Gemini. Implémentation : flag `terminalFlush` +
`T1_TERMINAL_FLUSH_GRACE_MS` (3 s) avant `finalize()`
(cf. `geminiLiveT1.ts`).
---
## 6. Évaluation finale (pipeline post-session)
`runT1LiveCorrection` (`src/routes/t1live.ts`) :
1. Insert `productions` : `tache='EO_T1'`, `sujet_id=null` (T1 non subject-based),
`mode='entrainement'`, `contenu=transcript`.
2. `correctEO(transcript, 'EO_T1', 9, null)` (DeepSeek — pas de consigne de
sujet en T1).
3. Phonologie = `PHONOLOGY_STUB` (TD-08 — pas d'audio brut côté backend) :
score textuel /16 + phonologie /4 = /20.
4. Update `productions` (`rapport`, `score`, `nclc`).
5. `{type:'report', data}` + close 1000.
> **Rappel TD-08 :** la phonologie live reste gelée (stub) tant qu'aucun audio
> brut n'est bufferisé côté backend.
---
## 7. Spécifications audio
| Direction | Format | Sample rate | Encoding |
| ----------------- | -------- | ----------- | ---------------------------- |
| Frontend → Gemini | PCM brut | 16 kHz | 16 bits, little-endian, mono |
| Gemini → Frontend | PCM brut | 24 kHz | 16 bits, little-endian, mono |
**MIME envoyé à Gemini :** `audio/pcm;rate=16000` (`T1_INPUT_AUDIO_MIME`).
---
## 8. Constantes de session (source : `geminiLiveT1.ts`)
| Constante | Valeur | Rôle |
| ------------------------------------- | --------------- | ------------------------------------------------- |
| `T1_SESSION_TIMEOUT_MS` | 180 000 | Filet de sécurité (fin forcée). |
| `T1_SESSION_WARNING_MS` | 150 000 | Émet `{type:'warning'}` 30 s avant timeout. |
| `T1_INTERRUPTION_P0/P1/P2` | 0.2 / 0.6 / 0.2 | Distribution du nombre de relances (0/1/2). |
| `T1_INTERRUPTION_WINDOW_START/END_MS` | 25 000 / 75 000 | Fenêtre où placer les relances. |
| `T1_INTERRUPTION_MIN_SPACING_MS` | 20 000 | Espacement minimal entre 2 relances. |
| `T1_TERMINAL_FLUSH_GRACE_MS` | 3 000 | Délai après `activityEnd` final avant `finalize`. |

View file

@ -1,227 +0,0 @@
# Prompt_t2live.md — Expria Backend
# Spécification du prompt système T2 EO Live
> **Document de référence — Sprint 6**
> À lire conjointement avec PARCOURS_UTILISATEURS.md et PLANS_TARIFAIRES.md.
> Ce document doit être commité dans `expria-backend/docs/` avant le démarrage du Sprint 6.
---
## 1. Contexte pédagogique
La Tâche 2 de l'Expression Orale TCF Canada est une **interaction de service** :
le candidat joue le rôle d'une personne dans une situation concrète du quotidien
qui a besoin d'informations pour prendre une décision. Il pose des questions à
un interlocuteur (joué par l'IA) qui détient ces informations.
**Ce que cette tâche évalue :**
- La capacité à initier et maintenir une conversation en français
- La formulation de questions claires et adaptées au registre
- Le lexique lié à la vie quotidienne
- La morphosyntaxe en situation d'interaction orale
- La phonologie (évaluée sur l'audio)
**Ce que cette tâche n'est pas :**
- Un débat d'opinions
- Un exposé monologique
- Un jeu de questions-réponses guidé par l'examinateur
---
## 2. Rôle de l'IA
L'IA joue le rôle de l'interlocuteur de la situation décrite dans le sujet
(ex : un bailleur, un employeur, un vendeur, un agent de voyage, etc.).
**Règles absolues du comportement de l'IA :**
1. **Répondre uniquement en français** — quelle que soit la langue utilisée
par le candidat.
2. **Ne pas faciliter la tâche** — ne pas reformuler les questions du candidat,
ne pas anticiper ce qu'il veut savoir, ne pas lui souffler les mots.
3. **Répondre aux questions posées** — réponses naturelles, réalistes,
ni trop courtes (monosyllabiques) ni trop longues (monologues).
4. **Ne pas relancer au-delà de** : _"Avez-vous d'autres questions ?"_
si le candidat marque une pause prolongée ou semble avoir terminé.
5. **Ne pas évaluer le candidat** pendant la conversation — aucun commentaire
sur sa langue, ses erreurs, ou sa performance.
6. **Ne pas sortir du rôle** — même si le candidat pose des questions hors sujet
ou tente de changer de registre.
7. **Attendre que le candidat prenne la parole** — c'est le candidat qui initie
la conversation, comme à l'examen réel. L'IA ne parle pas en premier.
Elle attend en silence et répond dès que le candidat s'adresse à elle.
---
## 3. Prompt système (à injecter dans `geminiLive.ts`)
```
RÔLE : Tu incarnes {role}.
CONTEXTE : {contexte}
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. Tu continues naturellement.
3. Tu attends que le candidat finisse sa question avant de répondre.
4. Tes réponses sont courtes (15 à 25 mots maximum) pour laisser la place au dialogue.
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 brièvement sans chercher à compléter — c'est à lui de reformuler.
7. STRICTE INTERDICTION DE POSER DES QUESTIONS. Tu n'as pas le droit d'utiliser de point d'interrogation. Tes phrases se terminent par un point.
8. SILENCE TOTAL APRÈS LA RÉPONSE. Réponds de manière factuelle, puis arrête-toi immédiatement. Ne suggère rien, ne relance pas, ne dis pas "et vous ?".
9. RÔLE PASSIF : tu es une source d'information inerte. Tu n'aides pas le candidat à tenir la conversation. S'il ne parle plus, le silence s'installe.
10. AUCUNE FORMULE DE POLITESSE DE FIN : bannis "n'hésitez pas", "j'espère que ça vous aide", "qu'en pensez-vous ?".
11. JAMAIS de listes ni de structure numérotée — parle naturellement.
12. Ne mentionne jamais que tu es une IA ou un modèle.
13. Tu ne prends PAS la parole en premier. Tu attends que le candidat s'adresse à toi.
```
> **⚠ Spécificité T2 — règle 7 :** l'interdiction absolue de poser des questions
> (et du point d'interrogation) est **propre à la Tâche 2** (interaction de service
> où le candidat doit mener l'échange). Elle ne doit **jamais** être propagée au
> prompt T1 (Sprint 7), où l'examinateur doit au contraire relancer le candidat.
> Voir aussi TD-22 (TECH_DEBT-backend.md) : contournement par prompt engineering,
> sans garantie déterministe sur modèle Flash Live.
**Variables à substituer dynamiquement depuis le sujet :**
- `{role}` — ex : "un bailleur qui loue un appartement"
- `{contexte}` — la consigne + contexte du sujet issu de la table `sujets`
---
## 4. Format du sujet T2 en base
Les sujets T2 sont stockés dans la table `sujets` avec les champs :
- `consigne` — la situation décrite au candidat (ce qu'il doit faire)
- `contexte` — les informations de cadrage (lieu, situation, interlocuteur)
- `tache` — valeur `'EO_T2'`
- `mode` — valeur `'entrainement'`
**Exemple de sujet :**
```
consigne : "Vous avez vu une annonce pour un appartement à louer.
Appelez le bailleur pour obtenir les informations
nécessaires avant de prendre votre décision."
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."
role : "un bailleur qui propose un appartement à louer"
```
> **Note :** Le champ `role` existe dans la table `sujets` et a été
> alimenté pour les 9 sujets EO T2 (session 2026-04-26, script SQL
> `update_sujets_t2.sql`).
---
## 5. Structure du rapport T2
Le rapport T2 suit **exactement la même structure** que les rapports EO T1 et T3 :
4 critères officiels TCF Canada :
| Critère | Pondération |
| ------------------------------ | ----------- |
| Cohérence et cohésion | 25 % |
| Étendue et maîtrise du lexique | 25 % |
| Maîtrise morphosyntaxique | 25 % |
| Phonologie | 25 % |
**Conséquence :** l'évaluation finale peut réutiliser le prompt de correction EO
existant (`POST /corrections/eo`) en passant le transcript de la session comme
entrée, avec `tache: 'EO_T2'`.
> **Rappel TD-08 (backend) :** la phonologie est temporairement fixée à 0
> pour les tâches EO live car l'évaluation nécessite l'audio brut.
> Applicable à T2 également — à résoudre post-MVP.
---
## 6. Flux technique complet Sprint 6
```
1. Candidat choisit un sujet T2 dans la liste → clic → page préparation
2. Page préparation : consigne affichée + bouton "Démarrer le dialogue"
3. Frontend ouvre WS : wss://api.expria.app/t2/live?token=<jwt>&sujet=<uuid>
4. Backend vérifie JWT + plan oral_t2_live (Premium)
5. Backend lit le sujet depuis Supabase (id sujet passé en query param)
6. Backend ouvre WS vers Gemini Live API avec prompt système construit
dynamiquement depuis {role} + {contexte} du sujet
7. Backend → Gemini : setup frame (modèle + prompt + responseModalities: AUDIO
+ VAD : endOfSpeechSensitivity LOW, silenceDurationMs 2000)
8. L'IA attend en silence — c'est le CANDIDAT qui prend la parole en premier
(conforme à l'examen réel TCF Canada)
9. Frontend → Backend → Gemini : audio candidat (PCM 16kHz base64) en continu
+ accumulation dans le buffer d'enregistrement chronologique (rééchantillonné 24kHz)
10. Gemini → Backend → Frontend : réponses audio (PCM 24kHz base64) en continu
+ accumulation dans le buffer d'enregistrement
11. Candidat clique "Terminer" → Frontend envoie signal de fin
12. Backend ferme WS Gemini, récupère transcript complet (inputTranscription
+ outputTranscription accumulés pendant la session)
13. Backend POST /corrections/eo avec transcript + tache='EO_T2'
→ rapport généré (même pipeline que T1/T3)
14. Backend sauvegarde production en base (tache='EO_T2_LIVE')
15. Backend envoie rapport au Frontend via WS (close code 1000 + payload rapport)
16. Frontend → state machine 'ended' → affichage rapport
17. Frontend propose bouton "Télécharger l'audio" (WAV mono 24kHz assemblé
depuis le buffer chronologique)
```
---
## 7. Spécifications audio
| Direction | Format | Sample rate | Encoding |
| ----------------- | -------- | ----------- | ---------------------------- |
| Frontend → Gemini | PCM brut | 16kHz | 16 bits, little-endian, mono |
| Gemini → Frontend | PCM brut | 24kHz | 16 bits, little-endian, mono |
**MIME type à envoyer à Gemini :** `audio/pcm;rate=16000`
**Côté frontend :**
- Capture via `AudioContext` + `AudioWorklet` (ou `ScriptProcessorNode` provisoire)
- Rééchantillonnage obligatoire : le navigateur capture à 44.1kHz ou 48kHz → downsampler à 16kHz
- Conversion Float32 → Int16 PCM avant envoi
- Lecture de l'audio reçu : `AudioContext` à 24kHz + `AudioBufferSourceNode` par chunk
---
## 8. Gestion des erreurs WebSocket
| Close code | Cause | Action frontend |
| ---------- | -------------------------- | ------------------------------------- |
| 1000 | Fin normale + rapport prêt | State → 'ended', afficher rapport |
| 4001 | AUTH_REQUIRED | State → 'error', redirect /login |
| 4003 | PLAN_INSUFFICIENT | State → 'error', PaywallModal Premium |
| 4004 | SUJET_NOT_FOUND | State → 'error', retour liste sujets |
| Autre | Erreur réseau / Gemini | State → 'error', bouton "Réessayer" |
---
## 9. Questions ouvertes à trancher au Sprint 6
| # | Question | Impact |
| --- | ----------------------------------------------------------------------------------------------------- | ----------------------------------- |
| Q1 | Le champ `role` existe-t-il dans la table `sujets` ou faut-il le dériver du `contexte` ? | Migration SQL ou prompt engineering |
| Q2 | L'id du sujet est-il passé en query param WS (`?token=jwt&sujet=uuid`) ou via le premier message WS ? | Protocole de connexion |
| Q3 | Le transcript est-il accumulé côté backend pendant la session ou demandé à Gemini en fin de session ? | Architecture geminiLive.ts |
---
## 10. Ce qui existe déjà (à ne pas recoder)
- `src/routes/t2live.ts` — 101 lignes, route WS + auth + gating ✅
- `src/lib/geminiLive.ts` — 154 lignes, proxy bidirectionnel + setup frame ✅
- Pipeline correction EO (`POST /corrections/eo`) — réutilisable pour évaluation finale ✅
- Modèle `gemini-live-2.5-flash-native-audio` — accès confirmé ✅
**À modifier :**
- `src/lib/geminiLive.ts` — remplacer le prompt agent immobilier par le prompt
dynamique §3, brancher la récupération du sujet depuis Supabase,
accumuler le transcript, déclencher l'évaluation finale.

View file

@ -1,228 +0,0 @@
<!-- AUTO-GÉNÉRÉ depuis expria-frontend/docs/ROADMAP.md — NE PAS ÉDITER À LA MAIN.
Toute modification passe par le frontend, puis : npm run sync:roadmap -->
# ROADMAP.md — Expria Frontend
> Source de vérité de l'ordre d'implémentation des sprints.
> Ne pas modifier sans validation de Hermann.
---
## Sprint 0 — Fondations ✅
1. Scaffold Vite + TypeScript + Tailwind + shadcn/ui
2. Structure de dossiers complète
3. docs/ copiés depuis backend + adaptations
4. ONBOARDING.md rédigé
## Sprint 0.5 — Design System ✅
- Direction artistique Boréal validée
- Tokens CSS dans index.css
- DESIGN_SYSTEM.md rédigé
## Sprint 1 — Auth + API layer ✅
5. auth-client.ts
6. api-client.ts
7. query-client.ts
8. entities/user/\*
9. features/auth (Login, Register, ProtectedRoute)
## Sprint 2 — Dashboard conditionnel ✅
10. usePlan hook
11. shared/components/PaywallModal
12. features/dashboard (Free / Standard / Premium)
## Sprint 3 — Simulations EE ✅
13. entities/production/_ + entities/report/_
14. features/simulations (EE T1/T2/T3)
15. Affichage rapport avec floutage conditionnel
## Sprint 3.5 — Clean
- Factorisation des fichiers modifiés Sprint 3
- Tests manuels Groupe B + C rejoués
- Commit refactor(simulation-ee)
## Sprint 3.6a — Qualité correction — Backend ✅
- Remplacement prompt maître (docs/Prompt_maître.md) + intégration taxonomie erreurs (docs/TAXONOMIE_ERREURS.md)
- Remplacement prompt production modèle (docs/Prompt_production_modèle.md) — cible fixe NCLC 9
- Génération parallèle correction + exercices + modèle (await correction, fire-and-forget sur les deux autres)
- Nouveaux champs DB : revelation, diagnostic, conseil_nclc, erreurs_codes, exercices_status, modele_status, nclc_cible
- Mise à jour GET /simulations/:id
- Migration SQL : `supabase/migrations/004_sprint_3_6a_qualite_correction.sql` (à exécuter manuellement)
- Tests : 173 tests verts (+18 vs baseline)
## Sprint 3.6b — Qualité correction — Frontend ✅
- Sélecteur NCLC cible dans SimulationForm (9 ou 10, défaut 9) — NclcCibleSelector
- RapportPage réécrite : ScoreHero (jauge + seuil NCLC cible + écart), RevelationCards, DiagnosticCallout, CritereCard enrichie (exemple/suggestion/astuce + codes taxonomie), ConseilNclcCallout
- ExerciceInteractive : badge difficulté, zone texte, bouton Indice (une fois), bouton Voir la correction (activé après saisie), explication
- ProductionModeleSection : texte final + notes pédagogiques + transformations original/amélioré + message
- JobStatusFallback : gère exercices_status / modele_status (pending / error) — refresh manuel, polling tracé en FTD-24
- Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+
- Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive)
## Sprint 3.7 — Historique ✅
- Backend : `GET /simulations` — liste paginée des productions de l'utilisateur connecté (page/limit, tri `created_at DESC`, projection légère). 186 tests backend verts.
- Frontend : page `/historique` (route sous AppLayout), liste d'items (date relative, tâche, score /20, NCLC, badge Examen / En cours), pagination Précédent/Suivant, clic → `/rapport/:id`.
- Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète.
- État vide : CTA « Démarrer une simulation ».
- Hook `useSimulationsList(page, limit)` — TanStack Query, `staleTime: 30s`, `keepPreviousData` pour transitions fluides.
- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance).
- 102 tests frontend verts (+18 vs baseline 84).
## Sprint 3.6c — Analyse patterns (Premium) ✅
- Backend : `GET /users/patterns` — agrégation des `erreurs_codes` sur les 5 dernières productions corrigées, seuil 3/5, tri DESC, cache `pattern_analyses` avec invalidation si nouvelle production plus récente que la dernière analyse.
- Backend : exercices long terme générés par DeepSeek sur patterns confirmés — format `{ consigne, exemple, correction, astuce }` avec prompt dédié (température 0.4, timeout 20 s). Dégradation gracieuse si DeepSeek échoue.
- Backend : indice de préparation 0→100 — formule 60 % score moyen + 20 % régularité + 20 % tendance, messages figés (`<40`, `40-70`, `>70`).
- Backend : migration SQL `005_sprint_3_6c_pattern_analyses.sql` (RLS SELECT par user_id, index composite, CHECK constraints).
- Backend : 205 tests verts (+19 vs baseline 186).
- Frontend : page `/progression` — orchestration hero (indice + jauge), liste patterns, cartes exercices long terme, footer « il y a X » ; gate plan via `hasAccess('pattern_analysis')` (Free/Standard → aperçu flouté + upgrade).
- Frontend : `PatternExerciceCard` — composant lesson-style dédié (non interactif, UX distincte de `ExerciceInteractive`) avec encart astuce proéminent.
- Frontend : Dashboard Premium — section compacte `MonProfilPreparation` (MetricCard indice + nb patterns + CTA vers `/progression`). Absente pour Free/Standard.
- Frontend : hook `usePatterns` (staleTime 60 s, cache partagé entre page et dashboard, `enabled` conditionné par feature).
- Frontend : 115 tests verts (+13 vs baseline 102).
## Sprint DA Charcoal — Reskin ✅
- Remplacement palette Boréal par Charcoal (dark default, light override)
- Sidebar navy permanent, layout radial-gradient, anti-FOUC
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
- ADR 006 mis à jour
## Sprint UI Polish — Sidebar + Topbar + Dashboard ✅
- Sidebar : icônes lucide, cadenas gating, badge upgrade, user footer, logo "EX|PRIA"
- Topbar : sticky backdrop-blur, breadcrumb centralisé, recherche placeholder
- Dashboard : split Free/Standard/Premium, NclcHero + StatCards + RecentSimulations + NextStepCard + PaywallBanner refonte
- MobileHeader supprimé (remplacé par Topbar)
## Sprint 4 — Simulations EO (audio) ✅
- MediaRecorder + Gemini batch transcription (EO T1/T3)
- Questionnaire T1 + génération présentation IA (POST /presentations/generate)
- Auto-submit à expiration de la durée recommandée
- Rapport EO format 3.6a (4 critères officiels TCF Canada)
## Sprint 4.5 — Clean
- Factorisation des fichiers modifiés Sprint 4
- Tests manuels Groupe B + D rejoués
- Commit refactor(simulation-eo)
## Sprint 4.6 — UI EO (waveform + timeline)
- Waveform visualizer pendant l'enregistrement (barres audio animées)
- Barre timeline colorée : verte → orange (75%) → rouge (dernières 15s)
- Applicable à toutes les tâches EO (T1 et T3)
## Sprint 4.7 — Historique refonte
- Stats en haut : Total simulations, Score moyen, Meilleur score
- Filtre par tâche (EE T1/T2/T3, EO T1/T3, Examen blanc)
- Filtre par période (Ce mois, 3 mois, Tout)
- Design conforme aux captures de référence
## Sprint 4.8 — Phonologie EO
- Affichage note_phonologie dans RapportPage (déjà stocké en base)
- Analyse phonologique réelle via Gemini audio (TD-08 backend)
- Score phonologie dans les 4 critères EO (actuellement fixé à 0)
## Sprint 5 — Billing ✅
- **5a (backend)** : TD-13 webhook idempotency (table `stripe_webhook_events` + helpers + 10 tests) ; route `POST /stripe/customer-portal` + `createBillingPortalSession` ; doc cleanup `ARCHITECTURE-backend.md` (`POST /plans/upgrade` retiré, duplication doc) ; tests backend 261 → 278.
- **5b (frontend)** : `PricingPage` `/plan` (3 colonnes Découverte/Standard/Premium) + `useStripeCheckout` initial + uniformisation CTA upgrade « Voir les plans » sur 5 emplacements ; env vars `VITE_STRIPE_PRICE_*` ; tests 198 → 203.
- **5c (frontend + cross-repo backend fix)** : `useStripeCheckout` hook isolé + `useUpgradeSuccessHandler` (détection `?upgrade=success` + invalidation cache plan + URL clean) + `UpgradeSuccessBanner` ; migration `PricingPage` + injection banner `DashboardPage` ; fix backend `cancel_url /tarifs → /plan` ; tests 203 → 212.
- **5d (frontend)** : `useCustomerPortal` hook + `AccountBillingSection` + `ParametresPage` `/parametres` (Abonnement + Session/déconnexion) ; **Standard→Premium routé via Customer Portal** (prorata natif Stripe) ; tests 212 → 219.
## Sprint 5.5 — Clean
- Factorisation des fichiers modifiés Sprint 5
- Tests manuels Groupe E rejoués
- Commit refactor(billing)
## Sprint 6 — T2 Live ✅
18. features/t2-live (ws-client + audio worklet + state machine)
- **6b (frontend)** : capture micro (AudioWorklet 16 kHz uplink) + playback IA + helpers audio purs.
- **6c (frontend)** : state machine T2 (9 états), `useT2LiveSession` (WebSocket + audio + format Gemini natif), pages Sujets / Préparation / Dialogue + routes ; carte EO T2 Live déverrouillée Premium.
- **6d (backend)** : prompt T2 durci (anti-relance, interdiction du `?`, règles dures Gemini — TD-22), VAD `realtimeInputConfig` réintégré, `@google/genai` retiré. Validé Groupe D en conditions réelles. Commits `94387a7` (code) + `5f7e52d` (docs), poussés sur `forgejo`.
- **6e (frontend)** : architecture audio « Voie A » — un seul AudioContext au rate natif partagé (capture + playback + enregistrement), mix temps réel via tap worklet, WAV mono single-track aligné, indicateur de prise de parole (VAD), correction des blancs EO, nettoyage `[BISECT]`. Tests 269/37 ; validation audio à l'oreille.
## Sprint 6.5 — Clean
- Factorisation des fichiers modifiés Sprint 6
- Tests manuels Groupe D rejoués
- Commit refactor(t2-live)
## Sprint 7 — T1 Live (interruption aléatoire)
- **7a (backend) ✅** : extension du proxy WebSocket Gemini Live (`gemini-3.1-flash-live-preview`, ws brut, pas de SDK) au mode T1 — system prompt « examinateur », décision d'interruption probabiliste, génération de la question de relance sur transcription partielle (DeepSeek). Réutilise l'infra T2 Live. Scoring EO 5 critères × /4. Phonologie live = 0 (TD-08, gelé). Contraintes héritées : pas de `speechConfig`. Livré : commits `868bd09` (code) + `3722e2a` (docs) ; dettes tracées TD-23/24/25 (cf. `TECH_DEBT-backend.md`).
- **7b (frontend) ✅** : UI T1 Live — machine d'état T1 (8 états, `interrupted ⇄ presenting`), `useT1LiveSession` (WS `/t1/live`, sans message `context` post-Patch 7a, uplink coupé par ref pendant interruption), `T1PreparationPage` / `T1DialoguePage` / `T1SpeakingIndicator`, carte `EO_T1_LIVE` gatée Premium (`oral_t2_live`). Parcours simplifié carte → prépa → dialogue. `T1LiveQuestionnairePage` + `T1LiveContext` retirés. Réutilise les hooks audio T2 (FTD-44 gelée). **Bugs amont observés au test manuel** (hors contrôle frontend) : **FTD-45** (relances Gemini hors-sujet, extension TD-23) et **FTD-46** (transcription Gemini Live hasardeuse).
## Sprint 7.5 — Clean
- Factorisation des fichiers modifiés Sprint 7
- Tests manuels Groupe D étendu (T1 Live) rejoués
- Commit refactor(t1-live)
## Sprint 7e — Transcription live à l'écran (T2 + T1)
- Affichage incrémental temps réel des prises de parole pendant le dialogue : router `inputTranscription` + `outputTranscription` (déjà produits côté backend pour l'évaluation) jusqu'au frontend via le WebSocket, puis rendu progressif à l'écran.
- Placé après le T1 Live pour couvrir **les deux modes live** d'un seul chantier.
- **Chantier non trivial** (flux WS + affichage incrémental) — à décomposer en sous-étapes ; pas « cosmétique ».
- **MAJ post-7a** : source backend de la transcription déjà disponible (confirmé par 7a).
- **Caveat TD-23** : en VAD manuel, `inputTranscription` candidat n'est flushé qu'à `activityEnd` (pas token par token) → l'affichage incrémental temps réel n'est possible que pour `outputTranscription` (examinateur) ; l'incrémental côté candidat est à reconcevoir.
## Sprint 8 — Mode Examen
- Timer inarrêtable + readOnly à T=0
## Sprint 8.5 — Clean
- Factorisation des fichiers modifiés Sprint 8
- Tests manuels Groupe D rejoués
- Commit refactor(exam-mode)
## Sprint 9 — Page Admin (outillage opérationnel)
- **9a (backend)** : middleware auth admin (modèle de sécurité à trancher — cf. SECURITY.md) ; endpoint agrégation chiffres clés (inscrits, corrections jour/mois, abonnements actifs, waitlist) ; endpoint waitlist (liste + export CSV).
- **9b (backend)** : CRUD sujets (liste + filtres mode·tâche·statut, create, update, toggle actif, delete) — réutilise le modèle de sujets existant, service role.
- **9c (frontend)** : route admin protégée (hors navigation publique) + Dashboard chiffres clés (compteurs cliquables, refresh périodique).
- **9d (frontend)** : module Gestion des sujets + module Waitlist (tableau + bouton Export CSV).
## Sprint 9.5 — Clean
- Factorisation des fichiers modifiés Sprint 9
- Tests manuels Groupe H (admin) joués
- Commit refactor(admin)
## Sprint 10 — Paiement Orange Money (semi-manuel)
- **10a (backend)** : migration Supabase `commandes_om` (RLS, accès service role) ; endpoint création de commande (code unique + insertion) ; job d'expiration via scheduler Render (pas de cron Vercel).
- **10b (backend)** : endpoint d'activation → écrit le plan via le même chemin que le webhook Stripe (planController / source de vérité unique, ADR 005) — jamais d'écriture SQL directe du plan ; email de confirmation client.
- **10c (frontend)** : page client `/paiement-om` (depuis `/plan`, lien WhatsApp pré-rempli) + ajout de l'option « Payer via Orange Money » sur la page plans.
- **10d (frontend)** : module Commandes OM dans l'admin (onglets en attente / activées / expirées, bouton Activer, countdown, note interne).
## Sprint 10.5 — Clean
- Factorisation des fichiers modifiés Sprint 10
- Tests manuels Groupe H étendu (flux OM complet) joués
- Commit refactor(paiement-om)
## Sprint 11 — Pré-lancement
- MAINTENANCE_MODE implémenté ✅ (2026-04-19)
- Sentry configuré
- /ultrareview avant bascule
- Smoke test Groupe Z complet
- Procédure DEPLOYMENT.md exécutée

View file

@ -1,346 +0,0 @@
# TECH_DEBT.md — Expria / Coach TCF Canada
> **Document de référence — Version 1.0**
> Ce document recense les décisions techniques prises par pragmatisme
> qui devront être revisitées, les stubs temporaires, et les fonctionnalités
> reportées. À mettre à jour après chaque session de développement.
>
> Format : chaque entrée a un identifiant (TD-XX), une priorité, et un statut.
> Priorités : 🔴 Critique (bloque la production) / 🟡 Important / 🟢 Mineur
---
## 1. Stubs temporaires — à compléter
### TD-01 — src/lib/supabase.ts (backend)
**Priorité :** 🔴 Critique
**Statut :** Ouvert
**Description :** Client Supabase créé comme stub. Fonctionne en développement avec les variables d'environnement mais n'a pas de gestion d'erreur robuste si `SUPABASE_URL` ou `SUPABASE_SERVICE_ROLE_KEY` sont absentes.
**À faire :** Ajouter une validation au démarrage — si les variables manquent, le serveur refuse de démarrer avec un message clair.
**Session concernée :** Initialisation backend
---
### TD-02 — src/lib/planController.ts (backend)
**Priorité :** 🟡 Important
**Statut :** Résolu — session Stripe
**Description :** Stub créé pour permettre les tests de `updateUserPlan`. La vraie implémentation (mise à jour Supabase + gestion Stripe) n'est pas encore codée.
**À faire :** Implémenter lors de la session Stripe (POST /stripe/webhook).
**Session concernée :** Tests automatisés
---
### TD-03 — src/lib/stripe.ts (backend)
**Priorité :** 🟡 Important
**Statut :** Résolu — session Stripe
**Description :** Stub créé pour permettre les tests de `verifyStripeWebhook` et `calculateProrata`. La vraie implémentation Stripe n'est pas encore codée.
**À faire :** Implémenter lors de la session Stripe.
**Session concernée :** Tests automatisés
---
## 2. Décisions pragmatiques — à revisiter
### TD-04 — Déploiement manuel (backend)
**Priorité :** 🟢 Mineur
**Statut :** Résolu — Auto-deploy webhook Forgejo → VPS Paris (2026-07-01)
**Description :** Le déploiement backend était manuel (git pull + build + restart à la main sur le VPS). Résolu par un **webhook auto-deploy custom** : un push sur `main` du dépôt Forgejo (`git.expria.app`) déclenche un webhook vers `deploy.expria.app`, reçu par un listener **Node stdlib** (`deploy/webhook-listener.mjs`) qui vérifie la signature HMAC-SHA256 + l'IP source, puis exécute `deploy/deploy.sh` (fast-forward → `npm ci``npm run build``systemctl restart expria-backend`, avec rollback automatique sur échec). Le listener tourne sous l'utilisateur non-root `deploy` via `expria-deploy.service`. Solution retenue plutôt que la cible historique « VPS Hetzner + Coolify » (jamais implémentée) : le backend est déjà sur VPS Paris (HostVDS) avec systemd + Caddy, un webhook léger suffit.
**Note frontend :** le déploiement frontend (Cloudflare Pages CLI) reste manuel — hors périmètre de cette résolution.
---
### TD-26 — `expria-backend.service` tourne en root
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit/constaté au Sprint auto-deploy (2026-07-01)
**Description :** L'unité systemd `expria-backend.service` sur le VPS Paris définit `User=root`. Le process API (Hono, WebSocket) s'exécute donc avec les pleins privilèges, ce qui n'est pas nécessaire et augmente la surface d'impact en cas de compromission du runtime.
**À faire :** Migrer le service vers un utilisateur dédié non-root (ex. `expria`), ajuster les permissions du checkout `/opt/expria/expria-backend`, du port (< 1024 non requis, le service écoute sur 4000) et du `EnvironmentFile`. Le listener auto-deploy tourne déjà sous `deploy` (non-root) le backend applicatif doit suivre.
**Hors scope :** ce durcissement est indépendant de la mise en place du webhook et sera planifié ultérieurement.
---
### TD-05 — Comptes de test avec emails @gmail.com
**Priorité :** 🟢 Mineur
**Statut :** Ouvert
**Description :** Les comptes de test utilisent `@gmail.com` au lieu de `@expria.local` prévu dans TEST_ENVIRONMENT.md. Raison : Supabase bloque la création d'utilisateurs avec des domaines non standards via l'API admin, et le dashboard est inaccessible depuis la Russie.
**Emails actuels :**
- `test.free@gmail.com`
- `test.standard@gmail.com`
- `test.premium@gmail.com`
- `test.quota@gmail.com`
**À faire :** Mettre à jour TEST_ENVIRONMENT.md pour refléter les vrais emails. Vérifier que la validation `@expria.local` dans le middleware n'est pas implémentée (elle ne l'est pas).
---
### TD-06 — Pas de migration SQL versionnée pour les tables initiales
**Priorité :** 🟡 Important
**Statut :** Ouvert
**Description :** Les tables `profiles` et `productions` ont été créées directement via SQL Editor, sans fichier de migration dans `supabase/migrations/`. Viole la Règle F de DEVELOPMENT_PRINCIPLES.md.
**À faire :** Créer les fichiers de migration correspondants :
- `supabase/migrations/001_create_profiles.sql`
- `supabase/migrations/002_create_productions.sql`
- `supabase/migrations/003_create_test_accounts.sql`
**Impact :** Si la base doit être recréée (nouveau projet Supabase), les migrations permettent de tout reconstruire en une commande.
---
### TD-07 — Ancien projet Supabase partagé
**Priorité :** 🟡 Important
**Statut :** Ouvert — accepté temporairement
**Description :** Le nouveau projet Expria V2 utilise la même base Supabase que l'ancien projet (en maintenance). Les anciennes tables ont été remplacées mais d'autres tables de l'ancien projet subsistent (`sujets`, `eo_t2_results`, `payment_transactions`, etc.).
**À faire :** Nettoyer les tables inutilisées quand V2 est stable en production.
**Tables à évaluer :** `anon_rate_limits`, `contact_submissions`, `eo_t2_results`, `events`, `payment_transactions`, `sujets`, `waitlist`
**Condition de résolution :** Après 30 jours de production stable de V2.
---
### TD-13 — Webhook Stripe non idempotent
**Priorité :** 🔴 Critique
**Statut :** Résolu — Sprint 5a (2026-04-26)
**Description :** Stripe peut livrer un même event webhook deux fois (retries réseau, rejeu manuel depuis le dashboard). La route `POST /stripe/webhook` traite désormais chaque réception via une déduplication explicite : check `stripe_webhook_events(id)` avant traitement, INSERT après succès.
**Résolution Sprint 5a :**
- Migration `supabase/migrations/007_sprint_5a_stripe_webhook_events.sql` — table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` + index sur `processed_at`.
- Helper `src/lib/stripeWebhookEvents.ts``isEventProcessed` / `markEventProcessed` (insert idempotent, conflit unique avalé silencieusement).
- `src/routes/stripe.ts` — early return `200 { received: true, replayed: true }` si l'event est déjà journalisé ; `markEventProcessed(event.id)` après traitement réussi (pas si exception, pour permettre rejeu Stripe).
- 8 tests unitaires + 2 tests d'intégration (`isEventProcessed`/`markEventProcessed` + comportement route).
---
### TD-15 — Jobs asynchrones modèle/exercices : status peut rester "pending" indéfiniment
**Priorité :** 🟡 Important
**Statut :** Ouvert — introduit au Sprint 3.6a
**Description :** Le flux POST /corrections/ee lance deux jobs DeepSeek en fire-and-forget (`runModeleJob`, `runExercicesJob` dans `correctionController.ts`). Si le process Node redémarre (deploy Render, crash, OOM) pendant l'exécution d'un de ces jobs, la colonne `exercices_status` ou `modele_status` reste figée à `'pending'` — l'utilisateur voit un loader infini côté frontend.
**Impact actuel :** faible en conditions normales (DeepSeek répond en ~5-15 s, Render redémarre rarement). Perceptible uniquement si un deploy a lieu pendant une correction active.
**À faire :**
- Option 1 (simple) : job de reprise au boot → scanner `productions WHERE (exercices_status='pending' OR modele_status='pending') AND created_at < NOW() - INTERVAL '2 minutes'` → relancer.
- Option 2 (robuste) : file d'attente persistée (pg-boss, BullMQ) au lieu de fire-and-forget.
- Option 3 (minimal) : timeout côté frontend → si `pending` depuis > 2 min, afficher "La génération a échoué, réessayer ?" + endpoint `POST /simulations/:id/retry-jobs`.
**Session concernée :** à planifier après livraison Sprint 3.6a/3.6b en prod stable.
**Condition de résolution :** après 7 jours d'observation en prod avec monitoring des colonnes `*_status='pending'` âgées.
---
### TD-14 — Erreurs TypeScript TS2835 pré-existantes
**Priorité :** 🟡 Important
**Statut :** Résolu — session correction build TypeScript
**Description :** Erreurs TS2835 sur plusieurs fichiers de routes.
Non bloquant (tests verts) mais à corriger.
Gate de qualité actuel : npm run test.
**À faire :** Ajouter les extensions `.js` aux imports relatifs ou ajuster `moduleResolution` dans `tsconfig.json` pour permettre `npm run build` de passer.
---
## 3. Fonctionnalités reportées
### TD-08 — Phonologie T2 EO à 0
**Priorité :** 🟡 Important
**Statut :** Partiellement résolu — Sprint 4.8
**Description :** L'évaluation de la phonologie est désormais opérationnelle pour **EO T1 et T3** : `POST /corrections/eo` reçoit l'audio brut (Mode B), Gemini 2.5 Flash évalue la phonologie en parallèle de la transcription via `evaluatePhonology` (cf. `src/lib/geminiPhonology.ts`), et le score `/4` est injecté comme 5e critère du rapport. Le format passe officiellement à 5 critères × /4 (total /20 inchangé).
**Reste à faire :** **EO T2 Live (Sprint 6)** continue de retourner phonologie 0/4 — pas d'audio brut côté backend dans le pipeline WebSocket actuel (`t2live.ts` proxifie l'audio entre client et Gemini Live sans le bufferiser pour évaluation différée). À résoudre lors du Sprint 6 en accumulant l'audio côté backend ou en demandant à Gemini Live de produire une note phonologique en fin de session.
**Session concernée :** T2 Live (WebSocket) — Sprint 6.
---
### TD-09 — ScriptProcessorNode déprécié (T2 live)
**Priorité :** 🟢 Mineur
**Statut :** Reporté à après le lancement
**Description :** Le traitement audio côté client utilise `ScriptProcessorNode` qui est déprécié. Doit être remplacé par `AudioWorklet`.
**Impact :** Fonctionne mais génère des warnings dans la console. Peut poser problème sur certains navigateurs futurs.
**À faire :** Migrer vers AudioWorklet après le lancement MVP.
---
### TD-10 — Analyse des patterns (Premium) non implémentée
**Priorité :** 🟡 Important
**Statut :** Résolu — Sprint 3.6c
**Description :** La feature d'analyse des patterns sur les 5 dernières productions (Premium) a été livrée Sprint 3.6c (table `pattern_analyses`, `generatePatternExercices`).
---
### TD-11 — Indice de préparation non implémenté
**Priorité :** 🟢 Mineur
**Statut :** Résolu — Sprint 3.6c
**Description :** Le calcul de l'indice de préparation (0-100) a été livré Sprint 3.6c en même temps que l'analyse des patterns (colonne `preparation_index` + `preparation_message`).
---
## 4. Tests à automatiser
### TD-12 — Tests manuels du Golden Dataset non automatisés
**Priorité :** 🟢 Mineur
**Statut :** Accepté — par conception
**Description :** Les 41 tests du Golden Dataset sont manuels. Certains pourraient être automatisés (tests d'intégration HTTP avec Supertest).
**À faire :** Ajouter des tests d'intégration pour les routes critiques après le lancement MVP.
---
### TD-16 — Bucket Supabase Storage `audio-productions` créé manuellement
**Priorité :** 🟡 Important
**Statut :** Résolu — Sprint 4b
**Description :** Décision Hermann (2026-04-25) : abandon du stockage audio backend. La transcription live passe par Deepgram en connexion directe navigateur ↔ Deepgram via token éphémère. L'audio brut est téléchargé en local par l'utilisateur. Plus aucun bucket Storage requis côté serveur.
---
### TD-17 — Limite audioBase64 in-memory à 14 Mo (≈ 10 Mo binaire)
**Priorité :** 🟢 Mineur
**Statut :** Résolu — Sprint 4b
**Description :** Plus de payload audio reçu côté backend (POST /corrections/eo accepte uniquement `transcript`). La limite n'a plus lieu d'être.
---
### TD-18 — RLS Storage `audio-productions` non testée en intégration
**Priorité :** 🟡 Important
**Statut :** Résolu — Sprint 4b
**Description :** Plus de bucket Storage backend à protéger. Les policies RLS de la migration 006 sont supprimées (DROP IF EXISTS) au profit d'un commentaire historique.
---
### TD-19 — Token Deepgram non rotatif côté frontend
**Priorité :** 🟡 Important
**Statut :** Ouvert — introduit au Sprint 4b
**Description :** `POST /transcriptions/token` retourne un token Deepgram éphémère valide 600 s (10 min). Une session EO T1 (2 min) tient largement, mais une session T3 (4:30) ou un enchaînement de 2 tâches dépasse la fenêtre si l'utilisateur prend des pauses. Si le token expire en cours de session, la connexion Deepgram drop sans renégociation automatique.
**À faire (côté frontend Sprint 4c) :**
- Demander un nouveau token via `/transcriptions/token` à T-60 s avant expiration.
- Reconnecter Deepgram en réutilisant la même session WebSocket si supporté.
**Condition de résolution :** stratégie de rotation de token implémentée et testée côté frontend.
---
### TD-20 — `transcribeAudio` (Gemini) sans consommateur
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4b
**Description :** La fonction `transcribeAudio` dans `src/lib/gemini.ts` n'est plus appelée par le flux EO (Deepgram a remplacé Gemini batch). Conservée volontairement comme point d'extension futur pour TD-08 (évaluation phonologique séparée) ou un fallback si Deepgram est indisponible.
**À faire :**
- Si TD-08 reste fermé 30 jours après la mise en prod du Sprint 4b sans plan d'usage, supprimer `transcribeAudio` et `gemini.ts` complet.
**Condition de résolution :** décision sur TD-08 (résolution ou abandon).
---
### TD-21 — Pas de rate limiting sur `/transcriptions/token`
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 4b
**Description :** Un utilisateur authentifié peut générer un nombre illimité de tokens Deepgram. Chaque token consomme un crédit côté Deepgram (selon usage de la connexion live qui suit). Un user malveillant pourrait scripter des appels en boucle pour épuiser le quota Deepgram.
**À faire :**
- Ajouter un rate limit (par user, ex. 30 tokens/heure) via le middleware `rateLimit.ts` existant.
**Condition de résolution :** middleware rate-limit branché sur la route et testé.
---
### TD-22 — Comportement T2 Live garanti uniquement par prompt engineering
**Priorité :** 🟡 Important
**Statut :** Ouvert — introduit au Sprint 6d
**Description :** Le respect du rôle passif de l'examinateur T2 (réponses courtes, aucune question posée, aucune relance, silence après réponse) repose entièrement sur le durcissement du prompt système dans `buildT2SystemPrompt` (`src/lib/geminiLive.ts`, cf. `docs/Prompt_t2live.md §3`). Le modèle Gemini Flash Live n'offre **aucune garantie déterministe** : il peut malgré tout poser une question, ajouter une formule de politesse de fin ou relancer le candidat.
**À faire :** Revisiter si la relance persiste en tests manuels — pistes : reformulation/renforcement du prompt, post-filtrage des sorties, ou évaluation d'un modèle Live plus dirigeable.
**⚠ Spécificité T2 :** l'interdiction de poser des questions (et du point d'interrogation, règle 7) est **propre à la Tâche 2**. Ne jamais la propager au prompt T1 (Sprint 7), où l'examinateur doit relancer le candidat.
**Session concernée :** T2 Live — Sprint 6d.
---
### TD-23 — Comportement Gemini Live T1 non déterministe + contrainte VAD manuel
**Priorité :** 🟡 Important
**Statut :** Ouvert — introduit au Sprint 7a
**Description :** Deux risques distincts sur le flux T1 Live (`geminiLiveT1.ts`) :
1. **Relance non garantie.** Comme pour le T2 (TD-22), le modèle Gemini Flash
Live n'offre aucune garantie déterministe : sur le signal d'injection
(`clientContent` de relance), il peut ignorer la consigne, formuler une
relance hors-sujet, enchaîner plusieurs questions, ou commenter la langue du
candidat malgré l'interdiction du prompt.
2. **Découverte spike — flush VAD manuel.** En VAD manuel
(`realtimeInputConfig.automaticActivityDetection.disabled = true`), Gemini ne
flushe `inputTranscription` (texte candidat) **qu'à l'envoi d'un
`activityEnd`**, pas en continu. Le backend doit donc envoyer `activityEnd`
aux bornes de tour pour récupérer le transcript. **Effet de bord :**
`activityEnd` déclenche AUSSI une réponse audio de l'examinateur (relance
« terminale »), qu'il faut couper en fin de session (audio non forwardé,
texte jeté — cf. `Prompt_t1live.md §5`).
**À faire :** Surveiller en tests manuels (Groupe D étendu) la pertinence des
relances et l'absence de relance terminale audible. Pistes si dérive :
renforcement du prompt, post-filtrage des sorties, ajustement du grace delay
(`T1_TERMINAL_FLUSH_GRACE_MS`).
**Session concernée :** T1 Live — Sprint 7a.
---
### TD-24 — Dette de nommage : `oral_t2_live` gate aussi le T1 Live
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — introduit au Sprint 7a
**Description :** La route `WS /t1/live` réutilise `authenticate` de
`t2live.ts`, qui gate sur la permission `checkFeatureAccess(plan, 'oral_t2_live')`.
La feature `oral_t2_live` contrôle donc **aussi** l'accès au T1 Live, ce qui est
sémantiquement trompeur. Le couplage casserait si une différenciation d'accès
T1 vs T2 était souhaitée un jour (ex. T1 ouvert à Standard, T2 réservé Premium).
**À faire :** Renommer en `oral_live` (générique) ou introduire `oral_t1_live`
distinct. C'est une décision d'architecture + une migration `lib/access.ts`
(et potentiellement l'enum de features). Hors scope Sprint 7a.
**Session concernée :** T1 Live — Sprint 7a.
---
### TD-25 — Dette DRY : `runT1LiveCorrection``runT2LiveCorrection`
**Priorité :** 🟢 Mineur
**Statut :** Ouvert — report conscient (Sprint 7a)
**Description :** `runT1LiveCorrection` (`t1live.ts`) est à ~90 % identique à
`runT2LiveCorrection` (`t2live.ts`) : même pipeline (guard transcript vide →
insert `productions` → DeepSeek `correctEO` → phonologie stub → update → frame
`report` + close), mêmes codes d'erreur (`EMPTY_TRANSCRIPT`,
`PERSISTENCE_FAILED`, `CORRECTION_FAILED`) et de fermeture (1000 / 1011). Les
seules divergences : `tache` (`EO_T1` vs `EO_T2_LIVE`), `sujet_id` (`null` vs
`sujet.id`), arguments DeepSeek (`'EO_T1', 9, null` vs `'EO_T2', 9,
sujet.consigne`), signature (présence ou non de `sujet`), préfixe de log.
**À faire :** Factoriser en un helper partagé
`runEoLiveCorrection({ clientWs, profile, transcript, tache, sujetId, consigne, logTag })`.
**Report assumé :** factoriser à 2 cas seulement risque l'abstraction prématurée
et touche `t2live.ts` (stable, déjà commité). À factoriser quand un **3e cas
live** apparaîtra (ex. T3 Live).
**Session concernée :** T1 Live — Sprint 7a.
---
## 5. Historique des résolutions
| ID | Description | Résolu le | Comment |
| ----- | ------------------------------------------- | ---------- | ----------------------------- |
| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe |
| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe |
| TD-14 | Erreurs TS2835 + TS18046 + TS7053 corrigées | 2026-04-17 | Session build Render |
| TD-10 | Analyse des patterns (Premium) livrée | 2026-04-25 | Sprint 3.6c |
| TD-11 | Indice de préparation livré | 2026-04-25 | Sprint 3.6c |
| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct |
| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b |
| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b |
| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a |
| TD-04 | Auto-deploy webhook Forgejo → VPS Paris | 2026-07-01 | Node stdlib + systemd + Caddy |

View file

@ -15,7 +15,7 @@ Il consiste en 4 comptes Supabase préconfigurés, un par situation critique.
**Règles absolues :**
- Ces comptes n'existent que dans l'environnement de développement / staging
- Jamais en production
- Les emails se terminent par `@gmail.com` — bloqués à l'inscription dans le code
- Les emails se terminent par `@expria.local` — bloqués à l'inscription dans le code
- Les mots de passe sont documentés ici — ne jamais les utiliser pour de vrais comptes
---
@ -24,10 +24,10 @@ Il consiste en 4 comptes Supabase préconfigurés, un par situation critique.
| Compte | Plan | simulations_used | Cas testé |
|---|---|---|---|
| test.free@gmail.com | free | 0 | Parcours Free normal |
| test.standard@gmail.com | standard | 12 | Parcours Standard complet |
| test.premium@gmail.com | premium | 28 | Parcours Premium complet |
| test.quota@gmail.com | free | 5 | Blocage quota Free |
| test.free@expria.local | free | 0 | Parcours Free normal |
| test.standard@expria.local | standard | 12 | Parcours Standard complet |
| test.premium@expria.local | premium | 28 | Parcours Premium complet |
| test.quota@expria.local | free | 5 | Blocage quota Free |
**Mot de passe pour tous les comptes de test :** `Expria2025!test`
@ -61,7 +61,7 @@ INSERT INTO auth.users (
) VALUES
(
'00000000-0000-0000-0000-000000000001',
'test.free@gmail.com',
'test.free@expria.local',
crypt('Expria2025!test', gen_salt('bf')),
NOW(), NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
@ -69,7 +69,7 @@ INSERT INTO auth.users (
),
(
'00000000-0000-0000-0000-000000000002',
'test.standard@gmail.com',
'test.standard@expria.local',
crypt('Expria2025!test', gen_salt('bf')),
NOW(), NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
@ -77,7 +77,7 @@ INSERT INTO auth.users (
),
(
'00000000-0000-0000-0000-000000000003',
'test.premium@gmail.com',
'test.premium@expria.local',
crypt('Expria2025!test', gen_salt('bf')),
NOW(), NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
@ -85,7 +85,7 @@ INSERT INTO auth.users (
),
(
'00000000-0000-0000-0000-000000000004',
'test.quota@gmail.com',
'test.quota@expria.local',
crypt('Expria2025!test', gen_salt('bf')),
NOW(), NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
@ -107,27 +107,27 @@ INSERT INTO profiles (
) VALUES
(
'00000000-0000-0000-0000-000000000001',
'test.free@gmail.com',
'test.free@expria.local',
'free', 0, NULL, NULL, NULL,
NOW(), NOW()
),
(
'00000000-0000-0000-0000-000000000002',
'test.standard@gmail.com',
'test.standard@expria.local',
'standard', 12, 'cus_test_standard', 'sub_test_standard',
NOW() + INTERVAL '14 days',
NOW(), NOW()
),
(
'00000000-0000-0000-0000-000000000003',
'test.premium@gmail.com',
'test.premium@expria.local',
'premium', 28, 'cus_test_premium', 'sub_test_premium',
NOW() + INTERVAL '21 days',
NOW(), NOW()
),
(
'00000000-0000-0000-0000-000000000004',
'test.quota@gmail.com',
'test.quota@expria.local',
'free', 5, NULL, NULL, NULL,
NOW(), NOW()
)
@ -231,7 +231,7 @@ SELECT
simulations_used,
plan_expires_at
FROM profiles
WHERE email LIKE '%@gmail.com'
WHERE email LIKE '%@expria.local'
ORDER BY email;
-- Vérifier les productions créées
@ -243,7 +243,7 @@ SELECT
prod.created_at
FROM productions prod
JOIN profiles p ON p.id = prod.user_id
WHERE p.email LIKE '%@gmail.com'
WHERE p.email LIKE '%@expria.local'
ORDER BY p.email, prod.created_at;
```
@ -260,7 +260,7 @@ ORDER BY p.email, prod.created_at;
-- Supprimer les productions de test
DELETE FROM productions
WHERE user_id IN (
SELECT id FROM profiles WHERE email LIKE '%@gmail.com'
SELECT id FROM profiles WHERE email LIKE '%@expria.local'
);
-- Remettre les profils à leur état initial
@ -271,7 +271,7 @@ UPDATE profiles SET
stripe_subscription_id = NULL,
plan_expires_at = NULL,
updated_at = NOW()
WHERE email = 'test.free@gmail.com';
WHERE email = 'test.free@expria.local';
UPDATE profiles SET
plan = 'standard',
@ -280,7 +280,7 @@ UPDATE profiles SET
stripe_subscription_id = 'sub_test_standard',
plan_expires_at = NOW() + INTERVAL '14 days',
updated_at = NOW()
WHERE email = 'test.standard@gmail.com';
WHERE email = 'test.standard@expria.local';
UPDATE profiles SET
plan = 'premium',
@ -289,7 +289,7 @@ UPDATE profiles SET
stripe_subscription_id = 'sub_test_premium',
plan_expires_at = NOW() + INTERVAL '21 days',
updated_at = NOW()
WHERE email = 'test.premium@gmail.com';
WHERE email = 'test.premium@expria.local';
UPDATE profiles SET
plan = 'free',
@ -298,21 +298,21 @@ UPDATE profiles SET
stripe_subscription_id = NULL,
plan_expires_at = NULL,
updated_at = NOW()
WHERE email = 'test.quota@gmail.com';
WHERE email = 'test.quota@expria.local';
-- Réinsérer les productions (copier-coller le bloc INSERT de la section 3)
```
---
## 6. Bloquer les inscriptions @gmail.com en production
## 6. Bloquer les inscriptions @expria.local en production
Ajouter cette validation dans le backend (middleware d'inscription) :
```typescript
// src/middleware/auth.ts — backend Hono
const BLOCKED_EMAIL_DOMAINS = ['@gmail.com']
const BLOCKED_EMAIL_DOMAINS = ['@expria.local']
export function validateEmail(email: string): boolean {
const isBlocked = BLOCKED_EMAIL_DOMAINS.some(domain =>
@ -348,7 +348,7 @@ app.post('/auth/register', async (c) => {
Étape 3 : Exécuter
Étape 4 : Copier-coller le script de vérification (section 4)
Étape 5 : Vérifier : 4 profils + 12 productions affichés
Étape 6 : Tester une connexion avec test.free@gmail.com
Étape 6 : Tester une connexion avec test.free@expria.local
dans l'application (mot de passe : Expria2025!test)
Étape 7 : Vérifier que le dashboard Free s'affiche correctement
```

80
package-lock.json generated
View file

@ -9,17 +9,13 @@
"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",
"socks-proxy-agent": "^10.1.0",
"stripe": "^17.7.0",
"ws": "^8.20.0"
"stripe": "^17.7.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",
@ -554,22 +550,6 @@
"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",
@ -1276,15 +1256,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@ -1461,6 +1432,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1858,15 +1830,6 @@
"node": ">=20.0.0"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -2052,6 +2015,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -2383,44 +2347,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -12,17 +12,13 @@
},
"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",
"socks-proxy-agent": "^10.1.0",
"stripe": "^17.7.0",
"ws": "^8.20.0"
"stripe": "^17.7.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",

View file

@ -1,633 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CorrectionRapport } from "../../lib/deepseek";
import type { AuthProfile } from "../../middleware/auth";
// ── Helpers mocks ────────────────────────────────────────────────────────
const PROFILE: AuthProfile = {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 3,
};
// Sprint 4.8 — DeepSeek renvoie 4 critères textuels /4 (somme ≤ 16). Le
// controller ajoute la 5e dimension Phonologie (Gemini) puis recalcule le
// score final /20.
const VALID_RAPPORT_EO: CorrectionRapport = {
score: 14,
nclc: 9,
nclc_cible: 9,
revelation: { croyance: "c", realite: "r", consequence: "co" },
diagnostic: "d",
criteres: [
{
nom: "Adéquation à la tâche",
score: 4,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Cohérence et cohésion",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Étendue et maîtrise du lexique",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Maîtrise morphosyntaxique",
score: 4,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
],
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "ok", action_prioritaire: "a" },
erreurs_codes: [
{
code: "vocabulaire_basique",
critere: "competence_lexicale",
description: null,
},
],
transcription_affichee: "Bonjour. Je m'appelle Pierre.",
};
interface ProductionRow {
id: string;
user_id: string;
tache: string;
sujet_id: string | null;
}
function createSupabaseMock(production: ProductionRow | null) {
const updates: {
table: string;
data: Record<string, unknown>;
id?: string;
}[] = [];
const fromMock = vi.fn((table: string) => {
if (table === "productions") {
return {
select: () => ({
eq: () => ({
single: async () => ({
data: production,
error: production ? null : { message: "not found" },
}),
}),
}),
update: (data: Record<string, unknown>) => ({
eq: async (_col: string, id: string) => {
updates.push({ table, data, id });
return { error: null };
},
}),
};
}
if (table === "sujets") {
return {
select: () => ({
eq: () => ({
single: async () => ({
data: { consigne: "Présentez-vous." },
error: null,
}),
}),
}),
};
}
if (table === "profiles") {
return {
update: (data: Record<string, unknown>) => ({
eq: async (_col: string, id: string) => {
updates.push({ table, data, id });
return { error: null };
},
}),
};
}
return {};
});
return {
mock: { from: fromMock },
updates,
};
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("correctionController.correctEO — Sprint 4b.2 (transcript ou audio batch)", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("retourne un rapport EO 3.6a et persiste les champs (mode transcript)", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-1",
user_id: "user-1",
tache: "EO_T1",
sujet_id: "sujet-1",
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn().mockResolvedValue(VALID_RAPPORT_EO),
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: "texte",
notes_pedagogiques: [],
transformations: [],
message: "",
nclc_modele: 9,
nclc_obtenu: 8,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 200,
tcf_word_max: 300,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-1",
tache: "EO_T1",
nclcCible: 9,
transcript: "Bonjour je m appelle Pierre",
},
PROFILE,
);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data.simulation_id).toBe("sim-1");
// Mode transcript : phonologie = stub 0/4 → total = 14 (textuel) + 0 = 14.
expect(result.data.score).toBe(14);
// Sprint 4.8 : 5 critères (4 textuels + Phonologie).
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(0);
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
}
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted).toBeDefined();
expect(persisted!.data).toMatchObject({
score: 14,
nclc: 9,
nclc_cible: 9,
});
expect(persisted!.data.contenu).toBe("Bonjour je m appelle Pierre");
// Pas de modele_status / exercices_status dans l'update principal (race).
expect(persisted!.data.modele_status).toBeUndefined();
expect(persisted!.data.exercices_status).toBeUndefined();
// Sprint 4b — plus de stockage audio backend.
expect(persisted!.data.audio_url).toBeUndefined();
});
it("simulation introuvable → SIMULATION_NOT_FOUND 404", async () => {
const { mock } = createSupabaseMock(null);
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-x",
tache: "EO_T1",
nclcCible: 9,
transcript: "t",
},
PROFILE,
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("SIMULATION_NOT_FOUND");
expect(result.status).toBe(404);
}
});
it("simulation appartenant à un autre user → AUTH_REQUIRED 401", async () => {
const { mock } = createSupabaseMock({
id: "sim-4",
user_id: "other-user",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-4",
tache: "EO_T1",
nclcCible: 9,
transcript: "t",
},
PROFILE,
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("AUTH_REQUIRED");
expect(result.status).toBe(401);
}
});
it("nclc_cible=10 propagé jusqu'au prompt et au rapport persisté", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-7",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
const correctEOSpy = vi
.fn()
.mockResolvedValue({ ...VALID_RAPPORT_EO, nclc_cible: 10 });
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOSpy,
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: "t",
notes_pedagogiques: [],
transformations: [],
message: "",
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 200,
tcf_word_max: 300,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
}));
const { correctEO } = await import("../correctionController");
await correctEO(
{
simulationId: "sim-7",
tache: "EO_T1",
nclcCible: 10,
transcript: "t",
},
PROFILE,
);
expect(correctEOSpy).toHaveBeenCalledWith("t", "EO_T1", 10, null);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.nclc_cible !== undefined,
);
expect(persisted!.data.nclc_cible).toBe(10);
});
// ── Mode audio batch (Sprint 4b.2) ────────────────────────────────────
it("mode audio : transcrit via Gemini puis utilise le transcript pour la correction", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-audio-1",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
const correctEOSpy = vi.fn().mockResolvedValue(VALID_RAPPORT_EO);
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOSpy,
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: "t",
notes_pedagogiques: [],
transformations: [],
message: "",
nclc_modele: 9,
nclc_obtenu: 8,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 200,
tcf_word_max: 300,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
}));
const transcribeAudio = vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie.");
const isAcceptedAudioMime = vi.fn().mockReturnValue(true);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio,
isAcceptedAudioMime,
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-audio-1",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
expect(transcribeAudio).toHaveBeenCalledWith("AAAA", "audio/webm");
expect(correctEOSpy).toHaveBeenCalledWith(
"Bonjour, je m'appelle Marie.",
"EO_T1",
9,
null,
);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted!.data.contenu).toBe("Bonjour, je m'appelle Marie.");
// Pas d'audio_url — le backend ne stocke aucun audio.
expect(persisted!.data.audio_url).toBeUndefined();
});
it("mimeType non accepté → VALIDATION_ERROR 400", async () => {
const { mock } = createSupabaseMock({
id: "sim-audio-2",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}));
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi.fn(),
isAcceptedAudioMime: vi.fn().mockReturnValue(false),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-audio-2",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/ogg",
},
PROFILE,
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("VALIDATION_ERROR");
expect(result.status).toBe(400);
}
});
it("transcription Gemini échoue → INTERNAL_ERROR 500", async () => {
const { mock } = createSupabaseMock({
id: "sim-audio-3",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}));
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi.fn().mockRejectedValue(new Error("Gemini timeout")),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-audio-3",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("INTERNAL_ERROR");
expect(result.status).toBe(500);
}
});
it("ni transcript ni audioBase64 → VALIDATION_ERROR 400", async () => {
const { mock } = createSupabaseMock({
id: "sim-audio-4",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
vi.doMock("../../lib/deepseek", () => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}));
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi.fn(),
isAcceptedAudioMime: vi.fn(),
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-audio-4",
tache: "EO_T1",
nclcCible: 9,
},
PROFILE,
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("VALIDATION_ERROR");
}
});
});

View file

@ -1,424 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CorrectionRapport } from '../../lib/deepseek'
import type { AuthProfile } from '../../middleware/auth'
// ── Helpers mocks ────────────────────────────────────────────────────────
const PROFILE: AuthProfile = {
id: 'user-1',
email: 'u@test.com',
plan: 'standard',
simulations_used: 3,
}
const VALID_RAPPORT: CorrectionRapport = {
score: 14,
nclc: 9,
nclc_cible: 9,
revelation: {
croyance: 'c',
realite: 'r',
consequence: 'co',
},
diagnostic: 'd',
criteres: [
{ nom: 'Adéquation à la tâche et au registre', score: 4, commentaire: '', exemple: '', suggestion: '', astuce: '' },
{ nom: 'Cohérence et cohésion du discours', score: 3, commentaire: '', exemple: '', suggestion: '', astuce: '' },
{ nom: 'Compétence lexicale', score: 3, commentaire: '', exemple: '', suggestion: '', astuce: '' },
{ nom: 'Compétence grammaticale', score: 4, commentaire: '', exemple: '', suggestion: '', astuce: '' },
],
conseil_nclc: { nclc_cible: 'NCLC 9', ecart: 'ok', action_prioritaire: 'a' },
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
}
function createSupabaseMock() {
const updates: { table: string; data: Record<string, unknown>; id: string }[] = []
const builder = (table: string, productionRow: unknown) => {
const obj: Record<string, unknown> = {}
obj.select = () => obj
obj.eq = () => obj
obj.single = async () => ({ data: productionRow, error: null })
obj.update = (data: Record<string, unknown>) => {
return {
eq: async (_col: string, id: string) => {
updates.push({ table, data, id })
return { error: null }
},
}
}
return obj
}
const mock = {
from: vi.fn((table: string) => {
if (table === 'productions') {
return builder(table, {
id: 'sim-1',
user_id: 'user-1',
tache: 'EE_T1',
sujet_id: null,
rapport: null,
})
}
if (table === 'profiles') {
return builder(table, null)
}
if (table === 'sujets') {
return builder(table, null)
}
return builder(table, null)
}),
updates,
}
return mock
}
// ── Tests ────────────────────────────────────────────────────────────────
describe('correctionController.correctEE — Sprint 3.6a', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('retourne la correction dès que DeepSeek correction résout (ne bloque pas sur modele/exercices)', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
// correction résout vite, modele + exercices résolvent plus tard
const deepseekMocks = {
correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT),
correctEO: vi.fn(),
generateProductionModele: vi.fn().mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
production_modele_propre: 'modele',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
}),
50,
),
),
),
generateExercices: vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 50)),
),
}
vi.doMock('../../lib/deepseek', () => deepseekMocks)
const { correctEE } = await import('../correctionController')
const start = Date.now()
const result = await correctEE(
{ simulationId: 'sim-1', contenu: 'texte', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
const elapsed = Date.now() - start
// La réponse arrive avant les 50 ms de setTimeout des jobs asynchrones
expect(elapsed).toBeLessThan(40)
expect('data' in result).toBe(true)
if ('data' in result) {
expect(result.data.simulation_id).toBe('sim-1')
expect(result.data.score).toBe(14)
}
// La persistance de la correction inclut les nouveaux champs.
// Les statuts ne sont PAS dans l'update principal (race condition — les
// jobs async les pilotent exclusivement). DEFAULT 'pending' côté migration.
const persisted = supabaseMock.updates.find(
(u) => u.table === 'productions' && u.data.score !== undefined,
)
expect(persisted).toBeDefined()
expect(persisted!.data).toMatchObject({
score: 14,
nclc: 9,
nclc_cible: 9,
})
expect(persisted!.data.revelation).toBeDefined()
expect(persisted!.data.diagnostic).toBeDefined()
expect(persisted!.data.conseil_nclc).toBeDefined()
expect(persisted!.data.erreurs_codes).toBeDefined()
// Race condition : ces champs ne doivent PAS être touchés par l'update principal.
expect(persisted!.data.modele_status).toBeUndefined()
expect(persisted!.data.exercices_status).toBeUndefined()
})
it('modele_status passe à "ready" quand le job réussit', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT),
correctEO: vi.fn(),
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: 'texte',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
}))
const { correctEE } = await import('../correctionController')
await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
// Laisser les jobs async se résoudre
await new Promise((r) => setTimeout(r, 10))
const modeleReady = supabaseMock.updates.find((u) => u.data.modele_status === 'ready')
const exercicesReady = supabaseMock.updates.find(
(u) => u.data.exercices_status === 'ready',
)
expect(modeleReady).toBeDefined()
expect(exercicesReady).toBeDefined()
})
it('modele_status passe à "error" quand le job DeepSeek échoue', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT),
correctEO: vi.fn(),
generateProductionModele: vi.fn().mockRejectedValue(new Error('DeepSeek down')),
generateExercices: vi.fn().mockRejectedValue(new Error('DeepSeek down')),
}))
const { correctEE } = await import('../correctionController')
await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
await new Promise((r) => setTimeout(r, 10))
const modeleError = supabaseMock.updates.find((u) => u.data.modele_status === 'error')
const exercicesError = supabaseMock.updates.find(
(u) => u.data.exercices_status === 'error',
)
expect(modeleError).toBeDefined()
expect(exercicesError).toBeDefined()
})
it('correction DeepSeek échoue → INTERNAL_ERROR 500 ; exercices jamais lancé ; modèle peut avoir démarré (parallélisme option b)', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
const modeleSpy = vi.fn().mockResolvedValue({
production_modele_propre: 't',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 8,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
})
const exercicesSpy = vi.fn()
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn().mockRejectedValue(new Error('DeepSeek down')),
correctEO: vi.fn(),
generateProductionModele: modeleSpy,
generateExercices: exercicesSpy,
}))
const { correctEE } = await import('../correctionController')
const result = await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe('INTERNAL_ERROR')
expect(result.status).toBe(500)
}
// Exercices dépend du rapport → jamais lancé si correction échoue.
// Modèle a été lancé en parallèle avec la correction (option b) → peut avoir
// été appelé avant que la correction ne rejette.
await new Promise((r) => setTimeout(r, 10))
expect(exercicesSpy).not.toHaveBeenCalled()
// nclcObtenu passé au modèle = nclcCible - 1 = 8
if (modeleSpy.mock.calls.length > 0) {
expect(modeleSpy).toHaveBeenCalledWith(expect.objectContaining({ nclcObtenu: 8 }))
}
})
it('parallélisme option b : modèle est appelé avec nclcObtenu = nclcCible - 1 (provisoire)', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
const modeleSpy = vi.fn().mockResolvedValue({
production_modele_propre: 't',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
})
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn().mockResolvedValue(VALID_RAPPORT),
correctEO: vi.fn(),
generateProductionModele: modeleSpy,
generateExercices: vi.fn().mockResolvedValue([]),
}))
const { correctEE } = await import('../correctionController')
await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 10 },
PROFILE,
)
await new Promise((r) => setTimeout(r, 10))
// nclcCible=10 → nclcObtenu estimé = 9
expect(modeleSpy).toHaveBeenCalledWith(expect.objectContaining({ nclcObtenu: 9 }))
})
it('nclc_cible=10 est propagé jusqu\'à DeepSeek', async () => {
const supabaseMock = createSupabaseMock()
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
const correctEESpy = vi.fn().mockResolvedValue({ ...VALID_RAPPORT, nclc_cible: 10 })
vi.doMock('../../lib/deepseek', () => ({
correctEE: correctEESpy,
correctEO: vi.fn(),
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: 't',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
}))
const { correctEE } = await import('../correctionController')
await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 10 },
PROFILE,
)
expect(correctEESpy).toHaveBeenCalledWith(
expect.objectContaining({ nclcCible: 10 }),
)
})
it('simulation introuvable → SIMULATION_NOT_FOUND 404', async () => {
const supabaseMock = {
from: vi.fn(() => ({
select: () => ({
eq: () => ({ single: async () => ({ data: null, error: { message: 'not found' } }) }),
}),
})),
updates: [],
}
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}))
const { correctEE } = await import('../correctionController')
const result = await correctEE(
{ simulationId: 'sim-missing', contenu: 't', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe('SIMULATION_NOT_FOUND')
expect(result.status).toBe(404)
}
})
it('simulation d\'un autre utilisateur → AUTH_REQUIRED 401', async () => {
const supabaseMock = {
from: vi.fn(() => ({
select: () => ({
eq: () => ({
single: async () => ({
data: {
id: 'sim-1',
user_id: 'other-user',
tache: 'EE_T1',
sujet_id: null,
rapport: null,
},
error: null,
}),
}),
}),
})),
updates: [],
}
vi.doMock('../../lib/supabase', () => ({ supabase: supabaseMock }))
vi.doMock('../../lib/deepseek', () => ({
correctEE: vi.fn(),
correctEO: vi.fn(),
generateProductionModele: vi.fn(),
generateExercices: vi.fn(),
}))
const { correctEE } = await import('../correctionController')
const result = await correctEE(
{ simulationId: 'sim-1', contenu: 't', tache: 'EE_T1', nclcCible: 9 },
PROFILE,
)
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe('AUTH_REQUIRED')
expect(result.status).toBe(401)
}
})
})

View file

@ -1,369 +0,0 @@
/**
* Tests Sprint 4.8 fusion phonologie Gemini dans correctionController.correctEO.
*
* Couvre :
* - Mode B (audioBase64) : phonologie /4 injectée comme 5e critère, score
* final /20 = somme des 5 critères.
* - Mode A (transcript) : phonologie = stub 0/4 avec commentaire.
* - evaluatePhonology rejette fallback stub, la correction n'échoue pas.
* - Persistance Supabase : criteres à 5 entrées, score recalculé.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CorrectionRapport } from "../../lib/deepseek";
import type { AuthProfile } from "../../middleware/auth";
const PROFILE: AuthProfile = {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 3,
};
const RAPPORT_TEXTUEL: CorrectionRapport = {
score: 12, // somme textuelle 4+3+2+3 = 12
nclc: 8,
nclc_cible: 9,
revelation: { croyance: "c", realite: "r", consequence: "co" },
diagnostic: "d",
criteres: [
{
nom: "Adéquation à la tâche",
score: 4,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Cohérence et cohésion",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Étendue et maîtrise du lexique",
score: 2,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
{
nom: "Maîtrise morphosyntaxique",
score: 3,
commentaire: "",
exemple: "",
suggestion: "",
astuce: "",
},
],
conseil_nclc: { nclc_cible: "NCLC 9", ecart: "ok", action_prioritaire: "a" },
erreurs_codes: [],
transcription_affichee: "Bonjour.",
};
interface ProductionRow {
id: string;
user_id: string;
tache: string;
sujet_id: string | null;
}
function createSupabaseMock(production: ProductionRow) {
const updates: { table: string; data: Record<string, unknown> }[] = [];
const fromMock = vi.fn((table: string) => {
if (table === "productions") {
return {
select: () => ({
eq: () => ({
single: async () => ({ data: production, error: null }),
}),
}),
update: (data: Record<string, unknown>) => ({
eq: async () => {
updates.push({ table, data });
return { error: null };
},
}),
};
}
if (table === "sujets") {
return {
select: () => ({
eq: () => ({
single: async () => ({
data: { consigne: "Présentez-vous." },
error: null,
}),
}),
}),
};
}
if (table === "profiles") {
return {
update: (data: Record<string, unknown>) => ({
eq: async () => {
updates.push({ table, data });
return { error: null };
},
}),
};
}
return {};
});
return { mock: { from: fromMock }, updates };
}
const STANDARD_DEEPSEEK_MOCK = (correctEOImpl: ReturnType<typeof vi.fn>) => ({
CRITERE_LABEL_PHONOLOGIE: "Phonologie",
correctEE: vi.fn(),
correctEO: correctEOImpl,
generateProductionModele: vi.fn().mockResolvedValue({
production_modele_propre: "t",
notes_pedagogiques: [],
transformations: [],
message: "",
nclc_modele: 9,
nclc_obtenu: 8,
score_cible: 14,
tcf_word_count: 1,
tcf_word_min: 200,
tcf_word_max: 300,
tcf_truncated: false,
}),
generateExercices: vi.fn().mockResolvedValue([]),
});
const STANDARD_GEMINI_MOCK = {
transcribeAudio: vi.fn().mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
};
describe("correctionController.correctEO — phonologie (Sprint 4.8)", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("Mode B (audio) : phonologie injectée comme 5e critère, score = textuel + phono", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-phono-1",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 3,
commentaire: "Prononciation correcte avec quelques liaisons manquées.",
exemple: "les amis",
suggestion: "Réaliser la liaison.",
astuce: "S'entraîner sur les liaisons.",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "stub",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-1",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// 4 textuels (4+3+2+3 = 12) + phonologie 3 = 15
expect(result.data.score).toBe(15);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(3);
expect(result.data.criteres[4]!.commentaire).toMatch(/Prononciation/);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted!.data.score).toBe(15);
});
it("Mode A (transcript) : phonologie = stub 0/4 avec commentaire indisponibilité", async () => {
const { mock } = createSupabaseMock({
id: "sim-phono-2",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
const evaluatePhonology = vi.fn();
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology,
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-2",
tache: "EO_T1",
nclcCible: 9,
transcript: "Bonjour je m appelle Pierre",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// Mode A → phonologie stub 0 → score = 12 + 0 = 12.
expect(result.data.score).toBe(12);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.nom).toBe("Phonologie");
expect(result.data.criteres[4]!.score).toBe(0);
expect(result.data.criteres[4]!.commentaire).toMatch(/audio requis/);
// evaluatePhonology n'est PAS appelée en Mode A.
expect(evaluatePhonology).not.toHaveBeenCalled();
});
it("Mode B + evaluatePhonology rejette → fallback stub, correction réussit", async () => {
const { mock, updates } = createSupabaseMock({
id: "sim-phono-3",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_TEXTUEL)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi
.fn()
.mockRejectedValue(new Error("Gemini phonology timeout")),
PHONOLOGY_STUB: {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-3",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
// Phonologie tombe sur le stub → score = 12 + 0 = 12, correction OK.
expect(result.data.score).toBe(12);
expect(result.data.criteres).toHaveLength(5);
expect(result.data.criteres[4]!.score).toBe(0);
const persisted = updates.find(
(u) => u.table === "productions" && u.data.score !== undefined,
);
expect(persisted!.data.score).toBe(12);
});
it("score phonologie 4 + textuel 16 → total final 20 (cap respecté)", async () => {
const RAPPORT_PARFAIT: CorrectionRapport = {
...RAPPORT_TEXTUEL,
score: 16,
criteres: RAPPORT_TEXTUEL.criteres.map((c) => ({ ...c, score: 4 })),
};
const { mock } = createSupabaseMock({
id: "sim-phono-4",
user_id: "user-1",
tache: "EO_T1",
sujet_id: null,
});
vi.doMock("../../lib/supabase", () => ({ supabase: mock }));
vi.doMock("../../lib/deepseek", () =>
STANDARD_DEEPSEEK_MOCK(vi.fn().mockResolvedValue(RAPPORT_PARFAIT)),
);
vi.doMock("../../lib/gemini", () => ({
transcribeAudio: vi
.fn()
.mockResolvedValue("Bonjour, je m'appelle Marie."),
isAcceptedAudioMime: vi.fn().mockReturnValue(true),
}));
vi.doMock("../../lib/geminiPhonology", () => ({
evaluatePhonology: vi.fn().mockResolvedValue({
score: 4,
commentaire: "Prononciation native.",
exemple: "",
suggestion: "",
astuce: "",
}),
PHONOLOGY_STUB: {
score: 0,
commentaire: "stub",
exemple: "",
suggestion: "",
astuce: "",
},
}));
const { correctEO } = await import("../correctionController");
const result = await correctEO(
{
simulationId: "sim-phono-4",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
},
PROFILE,
);
expect("data" in result).toBe(true);
if (!("data" in result)) return;
expect(result.data.score).toBe(20);
expect(result.data.criteres[4]!.score).toBe(4);
});
});

View file

@ -1,511 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
import type { AppVariables } from '../../middleware/auth'
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,
generatePatternExercices: vi.fn(),
}
})
import { supabase } from '../../lib/supabase'
import { generatePatternExercices } from '../../lib/deepseek'
import usersRoutes from '../../routes/users'
import {
aggregatePatterns,
computePreparationIndex,
type ProductionForAnalysis,
} from '../patternsController'
// ─── Helpers communs ──────────────────────────────────────────────────────────
function buildProfile(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'user-prem',
email: 'premium@test.com',
plan: 'premium',
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
}
}
function mockAuth(profile: ReturnType<typeof buildProfile>) {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
data: { user: { id: profile.id, email: profile.email } as any },
error: null,
})
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(() => ({ data: profile, error: null })),
})),
})),
} as any)
}
/** Mock from('productions').select(...).eq(...).not(...).order(...).limit(...) */
function mockProductionsQuery(rows: unknown[]) {
const limitFn = vi.fn(() => ({ data: rows, error: null }))
const orderFn = vi.fn(() => ({ limit: limitFn }))
const notFn = vi.fn(() => ({ order: orderFn }))
const eqFn = vi.fn(() => ({ not: notFn }))
const selectFn = vi.fn(() => ({ eq: eqFn }))
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
return { selectFn, eqFn, notFn, orderFn, limitFn }
}
/** Mock from('pattern_analyses').select().eq().order().limit().maybeSingle() */
function mockLastAnalysis(data: unknown) {
const maybeSingleFn = vi.fn(() => ({ data, error: null }))
const limitFn = vi.fn(() => ({ maybeSingle: maybeSingleFn }))
const orderFn = vi.fn(() => ({ limit: limitFn }))
const eqFn = vi.fn(() => ({ order: orderFn }))
const selectFn = vi.fn(() => ({ eq: eqFn }))
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
return { maybeSingleFn, limitFn, orderFn, eqFn, selectFn }
}
/** Mock from('pattern_analyses').insert(...).select(...).single() */
function mockInsertAnalysis(created_at: string) {
const singleFn = vi.fn(() => ({ data: { created_at }, error: null }))
const selectFn = vi.fn(() => ({ single: singleFn }))
const insertFn = vi.fn(() => ({ select: selectFn }))
vi.mocked(supabase.from).mockReturnValueOnce({ insert: insertFn } as any)
return { insertFn, selectFn, singleFn }
}
function createApp() {
const app = new Hono<{ Variables: AppVariables }>()
app.route('/users', usersRoutes)
return app
}
// ─── Fonctions pures ──────────────────────────────────────────────────────────
describe('aggregatePatterns', () => {
function prod(id: string, erreurs: unknown): ProductionForAnalysis {
return { id, score: 14, created_at: '2026-04-22T12:00:00Z', erreurs_codes: erreurs }
}
it('code à 3 occurrences sur 5 → pattern confirmé frequency=3', () => {
const patterns = aggregatePatterns([
prod('1', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
prod('2', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
prod('3', [{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null }]),
prod('4', []),
prod('5', []),
])
expect(patterns).toHaveLength(1)
expect(patterns[0]).toMatchObject({
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 3,
})
})
it('code à 2 occurrences → PAS un pattern (seuil 3)', () => {
const patterns = aggregatePatterns([
prod('1', [{ code: 'repetition_lexicale', critere: 'competence_lexicale', description: null }]),
prod('2', [{ code: 'repetition_lexicale', critere: 'competence_lexicale', description: null }]),
prod('3', []),
prod('4', []),
prod('5', []),
])
expect(patterns).toHaveLength(0)
})
it('code à 5/5 → frequency=5', () => {
const patterns = aggregatePatterns(
Array.from({ length: 5 }).map((_, i) =>
prod(String(i), [
{ code: 'virgule_exces', critere: 'competence_grammaticale', description: null },
]),
),
)
expect(patterns[0]?.frequency).toBe(5)
})
it('deux patterns confirmés triés par fréquence DESC', () => {
const patterns = aggregatePatterns([
prod('1', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
]),
prod('2', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
]),
prod('3', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', description: null },
]),
prod('4', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
]),
prod('5', []),
])
expect(patterns).toHaveLength(2)
expect(patterns[0]?.code).toBe('accord_sujet_verbe')
expect(patterns[0]?.frequency).toBe(4)
expect(patterns[1]?.code).toBe('connecteurs_repetes')
expect(patterns[1]?.frequency).toBe(3)
})
it('code "autre" : descriptions différentes comptées séparément', () => {
const patterns = aggregatePatterns([
prod('1', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
prod('2', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
prod('3', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur A' }]),
prod('4', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur B' }]),
prod('5', [{ code: 'autre', critere: 'coherence_cohesion', description: 'erreur B' }]),
])
// "erreur A" 3/5 → confirmé ; "erreur B" 2/5 → non confirmé
expect(patterns).toHaveLength(1)
expect(patterns[0]?.description).toBe('erreur A')
expect(patterns[0]?.frequency).toBe(3)
})
it('dédoublonnage intra-production : même code 2x dans le même rapport ne compte qu\'une fois', () => {
const patterns = aggregatePatterns([
prod('1', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
]),
prod('2', [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
]),
prod('3', []),
prod('4', []),
prod('5', []),
])
expect(patterns).toHaveLength(0) // seulement 2/5 distinctes → non confirmé
})
it('code hors taxonomie → ignoré', () => {
const patterns = aggregatePatterns([
prod('1', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
prod('2', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
prod('3', [{ code: 'accord_sujet_verbe', critere: 'critere_invente', description: null }]),
prod('4', []),
prod('5', []),
])
expect(patterns).toHaveLength(0)
})
})
describe('computePreparationIndex', () => {
function prodAt(score: number, date: string): ProductionForAnalysis {
return { id: date, score, created_at: date, erreurs_codes: [] }
}
it('scores élevés + réguliers → score > 70, message "NCLC 9+"', () => {
const result = computePreparationIndex([
prodAt(16, '2026-04-22T12:00:00Z'),
prodAt(15, '2026-04-20T12:00:00Z'),
prodAt(16, '2026-04-18T12:00:00Z'),
prodAt(15, '2026-04-16T12:00:00Z'),
prodAt(14, '2026-04-14T12:00:00Z'),
])
expect(result.score).toBeGreaterThan(70)
expect(result.message).toMatch(/NCLC 9/)
})
it('scores bas + intervalles espacés → score < 40, message "Continuez"', () => {
// Scores moyens ~5/20 + intervalles de ~30 jours → régularité 15, moy 25, trend 50
const result = computePreparationIndex([
prodAt(5, '2026-04-22T12:00:00Z'),
prodAt(6, '2026-03-22T12:00:00Z'),
prodAt(4, '2026-02-20T12:00:00Z'),
prodAt(5, '2026-01-20T12:00:00Z'),
prodAt(6, '2025-12-20T12:00:00Z'),
])
expect(result.score).toBeLessThan(40)
expect(result.message).toMatch(/Continuez/)
})
it('scores moyens → score entre 40 et 70, message "Bonne progression"', () => {
const result = computePreparationIndex([
prodAt(11, '2026-04-22T12:00:00Z'),
prodAt(12, '2026-04-20T12:00:00Z'),
prodAt(11, '2026-04-18T12:00:00Z'),
prodAt(12, '2026-04-16T12:00:00Z'),
prodAt(11, '2026-04-14T12:00:00Z'),
])
expect(result.score).toBeGreaterThanOrEqual(40)
expect(result.score).toBeLessThanOrEqual(70)
expect(result.message).toMatch(/Bonne progression/)
})
it('score clampé entre 0 et 100', () => {
const result = computePreparationIndex([
prodAt(20, '2026-04-22T12:00:00Z'),
prodAt(20, '2026-04-21T12:00:00Z'),
prodAt(20, '2026-04-20T12:00:00Z'),
prodAt(20, '2026-04-19T12:00:00Z'),
prodAt(20, '2026-04-18T12:00:00Z'),
])
expect(result.score).toBeLessThanOrEqual(100)
expect(result.score).toBeGreaterThanOrEqual(0)
})
})
// ─── Route GET /users/patterns ────────────────────────────────────────────────
describe('GET /users/patterns — gate plan', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sans JWT → 401 AUTH_REQUIRED', async () => {
const app = createApp()
const res = await app.request('/users/patterns')
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('plan free → 403 PLAN_INSUFFICIENT', async () => {
mockAuth(buildProfile({ plan: 'free' }))
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(403)
const body = await res.json()
expect(body.code).toBe('PLAN_INSUFFICIENT')
})
it('plan standard → 403 PLAN_INSUFFICIENT', async () => {
mockAuth(buildProfile({ plan: 'standard' }))
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(403)
const body = await res.json()
expect(body.code).toBe('PLAN_INSUFFICIENT')
})
})
describe('GET /users/patterns — premium', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('< 5 productions → { ready: false, minimum: 5, current: N }', async () => {
mockAuth(buildProfile())
mockProductionsQuery([
{ id: '1', score: 14, created_at: '2026-04-22T00:00:00Z', erreurs_codes: [] },
{ id: '2', score: 15, created_at: '2026-04-21T00:00:00Z', erreurs_codes: [] },
{ id: '3', score: 13, created_at: '2026-04-20T00:00:00Z', erreurs_codes: [] },
])
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({ ready: false, minimum: 5, current: 3 })
})
it('cache hit (analyse plus récente que dernière prod) → pas d\'appel DeepSeek', async () => {
mockAuth(buildProfile())
mockProductionsQuery([
{ id: '1', score: 14, created_at: '2026-04-20T12:00:00Z', erreurs_codes: [] },
{ id: '2', score: 15, created_at: '2026-04-19T12:00:00Z', erreurs_codes: [] },
{ id: '3', score: 13, created_at: '2026-04-18T12:00:00Z', erreurs_codes: [] },
{ id: '4', score: 14, created_at: '2026-04-17T12:00:00Z', erreurs_codes: [] },
{ id: '5', score: 14, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
])
mockLastAnalysis({
created_at: '2026-04-22T00:00:00Z', // plus récent que la prod la plus récente
patterns: [{ code: 'virgule_exces', critere: 'competence_grammaticale', frequency: 3 }],
exercises: [],
preparation_index: 65,
preparation_message: 'Bonne progression — visez NCLC 7-8',
analyzed_count: 5,
})
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ready).toBe(true)
expect(body.preparation_index.score).toBe(65)
expect(body.last_analysis).toBe('2026-04-22T00:00:00Z')
// Cache hit → DeepSeek non appelé
expect(vi.mocked(generatePatternExercices)).not.toHaveBeenCalled()
})
it('cache miss (prod postérieure à last_analysis) → recompute + insert', async () => {
mockAuth(buildProfile())
const recent = '2026-04-22T12:00:00Z'
mockProductionsQuery([
{
id: '1',
score: 14,
created_at: recent,
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{
id: '2',
score: 15,
created_at: '2026-04-20T12:00:00Z',
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{
id: '3',
score: 14,
created_at: '2026-04-18T12:00:00Z',
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{
id: '4',
score: 15,
created_at: '2026-04-16T12:00:00Z',
erreurs_codes: [],
},
{
id: '5',
score: 14,
created_at: '2026-04-14T12:00:00Z',
erreurs_codes: [],
},
])
mockLastAnalysis({
created_at: '2026-04-10T00:00:00Z', // antérieur à la prod la plus récente
patterns: [],
exercises: [],
preparation_index: 50,
preparation_message: 'Bonne progression — visez NCLC 7-8',
analyzed_count: 5,
})
vi.mocked(generatePatternExercices).mockResolvedValueOnce([
{
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
diagnostic: 'Erreurs d\'accord récurrentes.',
exercice: {
consigne: 'Corrigez.',
exemple: 'les enfants joue',
correction: 'les enfants jouent',
astuce: 'Vérifiez le sujet avant le verbe.',
},
},
])
const inserts = mockInsertAnalysis('2026-04-22T13:00:00Z')
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ready).toBe(true)
expect(body.patterns).toHaveLength(1)
expect(body.patterns[0].code).toBe('accord_sujet_verbe')
expect(body.exercises).toHaveLength(1)
expect(body.exercises[0].exercice.astuce).toBe('Vérifiez le sujet avant le verbe.')
expect(vi.mocked(generatePatternExercices)).toHaveBeenCalledTimes(1)
expect(inserts.insertFn).toHaveBeenCalled()
})
it('aucun pattern confirmé → DeepSeek non appelé, exercises=[]', async () => {
mockAuth(buildProfile())
mockProductionsQuery([
{ id: '1', score: 14, created_at: '2026-04-22T12:00:00Z', erreurs_codes: [] },
{ id: '2', score: 15, created_at: '2026-04-20T12:00:00Z', erreurs_codes: [] },
{ id: '3', score: 14, created_at: '2026-04-18T12:00:00Z', erreurs_codes: [] },
{ id: '4', score: 15, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
{ id: '5', score: 14, created_at: '2026-04-14T12:00:00Z', erreurs_codes: [] },
])
mockLastAnalysis(null)
mockInsertAnalysis('2026-04-22T13:00:00Z')
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ready).toBe(true)
expect(body.patterns).toEqual([])
expect(body.exercises).toEqual([])
expect(vi.mocked(generatePatternExercices)).not.toHaveBeenCalled()
})
it('DeepSeek échoue → dégradation gracieuse (exercises=[], persistance OK)', async () => {
mockAuth(buildProfile())
mockProductionsQuery([
{
id: '1',
score: 14,
created_at: '2026-04-22T12:00:00Z',
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{
id: '2',
score: 14,
created_at: '2026-04-20T12:00:00Z',
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{
id: '3',
score: 14,
created_at: '2026-04-18T12:00:00Z',
erreurs_codes: [
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', description: null },
],
},
{ id: '4', score: 14, created_at: '2026-04-16T12:00:00Z', erreurs_codes: [] },
{ id: '5', score: 14, created_at: '2026-04-14T12:00:00Z', erreurs_codes: [] },
])
mockLastAnalysis(null)
vi.mocked(generatePatternExercices).mockRejectedValueOnce(new Error('DeepSeek timeout'))
mockInsertAnalysis('2026-04-22T13:00:00Z')
const app = createApp()
const res = await app.request('/users/patterns', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ready).toBe(true)
expect(body.patterns).toHaveLength(1)
expect(body.exercises).toEqual([]) // dégradation gracieuse
})
})

View file

@ -1,148 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Validation pure (pas de fetch).
describe("presentationController.validateReponses", () => {
beforeEach(() => {
vi.resetModules();
});
it("accepte les 5 champs requis non vides", async () => {
const { validateReponses } = await import("../presentationController");
const result = validateReponses({
prenom_age_ville: "Pierre, 30 ans, Alger",
formation_metier: "Ingénieur",
situation_familiale: "Marié, deux enfants",
loisirs: "Lecture, randonnée",
motivation_canada: "Opportunités professionnelles",
});
expect("ok" in result && result.ok).toBe(true);
});
it("rejette si reponses non objet", async () => {
const { validateReponses } = await import("../presentationController");
const result = validateReponses("string");
expect("error" in result).toBe(true);
if ("error" in result) expect(result.code).toBe("VALIDATION_ERROR");
});
it.each([
"prenom_age_ville",
"formation_metier",
"situation_familiale",
"loisirs",
"motivation_canada",
])("rejette si %s manquant", async (field) => {
const { validateReponses } = await import("../presentationController");
const all: Record<string, string> = {
prenom_age_ville: "a",
formation_metier: "b",
situation_familiale: "c",
loisirs: "d",
motivation_canada: "e",
};
delete all[field];
const result = validateReponses(all);
expect("error" in result).toBe(true);
});
it("rejette les champs vides ou whitespace", async () => {
const { validateReponses } = await import("../presentationController");
const result = validateReponses({
prenom_age_ville: " ",
formation_metier: "b",
situation_familiale: "c",
loisirs: "d",
motivation_canada: "e",
});
expect("error" in result).toBe(true);
});
});
// Pipeline complet — fetch mocké.
describe("presentationController.generate", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
const VALID_REPONSES = {
prenom_age_ville: "Pierre, 30 ans, Alger",
formation_metier: "Ingénieur",
situation_familiale: "Marié",
loisirs: "Lecture",
motivation_canada: "Travail",
};
it("succès → renvoie { presentation: string }", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
choices: [
{ message: { content: "Bonjour, je m'appelle Pierre. Voilà." } },
],
}),
}),
);
const { generate } = await import("../presentationController");
const result = await generate(VALID_REPONSES);
expect("data" in result).toBe(true);
if ("data" in result) {
expect(result.data.presentation).toContain("Pierre");
}
});
it("DeepSeek non-OK → INTERNAL_ERROR 500", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "I" }),
);
const { generate } = await import("../presentationController");
const result = await generate(VALID_REPONSES);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("INTERNAL_ERROR");
expect(result.status).toBe(500);
}
});
it("réponse vide → INTERNAL_ERROR 500", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ choices: [{ message: { content: "" } }] }),
}),
);
const { generate } = await import("../presentationController");
const result = await generate(VALID_REPONSES);
expect("error" in result).toBe(true);
});
it("fetch throw (timeout) → INTERNAL_ERROR 500", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network down")),
);
const { generate } = await import("../presentationController");
const result = await generate(VALID_REPONSES);
expect("error" in result).toBe(true);
if ("error" in result) expect(result.code).toBe("INTERNAL_ERROR");
});
it("rejette les body invalides en court-circuitant fetch", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { generate } = await import("../presentationController");
const result = await generate({ prenom_age_ville: "" });
expect("error" in result).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});
});

View file

@ -55,72 +55,15 @@ function mockInsert(returnData: { id: string; tache: string; mode: string; creat
} as any)
}
/** Mock from('productions').select(...).eq(...).single() pour getById */
function mockProductionSelect(data: unknown, error: unknown = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(() => ({ data, error })),
})),
})),
} as any)
}
/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create (liste filtrée) */
function mockSujets(rows: unknown[]) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({ data: rows, error: null })),
})),
})),
})),
} as any)
}
/** Mock from('sujets').select(...).eq(...).single() pour getById/updateSujet */
function mockSujetById(data: unknown, error: unknown = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(() => ({ data, error })),
})),
})),
} as any)
}
/** Mock from('productions').update(...).eq(...) pour autosave/updateSujet */
function mockUpdate(error: unknown = null) {
/** Mock from('profiles').update(...).eq(...) */
function mockUpdate() {
vi.mocked(supabase.from).mockReturnValueOnce({
update: vi.fn(() => ({
eq: vi.fn(() => ({ error })),
eq: vi.fn(() => ({ data: null, error: null })),
})),
} as any)
}
const MOCK_SUJET_EE_T1 = {
id: 'sujet-1',
consigne: 'Écrivez un texte argumentatif sur le télétravail.',
role: 'journaliste',
contexte: 'magazine francophone',
doc1_titre: null,
doc1_texte: null,
doc2_titre: null,
doc2_texte: null,
}
const MOCK_SUJET_EE_T2 = {
id: 'sujet-2',
consigne: 'Rédigez un article sur la transition énergétique.',
role: 'chroniqueur',
contexte: 'rubrique environnement',
doc1_titre: 'Doc 1',
doc1_texte: 'Contenu 1',
doc2_titre: null,
doc2_texte: null,
}
function createApp() {
const app = new Hono<{ Variables: AppVariables }>()
app.route('/simulations', simulationsRoutes)
@ -134,11 +77,11 @@ describe('POST /simulations', () => {
vi.clearAllMocks()
})
it('free + 4 simulations utilisées → création OK, pas d\'incrément (il a lieu après correction)', async () => {
it('free + 4 simulations utilisées → création OK, simulations_used incrémenté', async () => {
const profile = buildProfile({ plan: 'free', simulations_used: 4 })
mockAuth(profile)
mockSujets([MOCK_SUJET_EE_T1])
mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
mockUpdate()
const app = createApp()
const res = await app.request('/simulations', {
@ -152,11 +95,9 @@ describe('POST /simulations', () => {
expect(body.id).toBe('prod-1')
expect(body.tache).toBe('EE_T1')
expect(body.mode).toBe('entrainement')
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
// 3 appels from : profiles (auth) + sujets (select) + productions (insert)
// 3 appels from : profiles (auth) + productions (insert) + profiles (update)
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions')
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('profiles')
})
it('free + 5 simulations utilisées → QUOTA_REACHED', async () => {
@ -181,8 +122,8 @@ describe('POST /simulations', () => {
it('standard + 999 simulations → création OK, simulations_used NON incrémenté', async () => {
const profile = buildProfile({ plan: 'standard', simulations_used: 999 })
mockAuth(profile)
mockSujets([MOCK_SUJET_EE_T2])
mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
// Pas de mockUpdate : standard n'a pas de limite
const app = createApp()
const res = await app.request('/simulations', {
@ -194,18 +135,15 @@ describe('POST /simulations', () => {
expect(res.status).toBe(201)
const body = await res.json()
expect(body.id).toBe('prod-2')
expect(body.sujet).toEqual(MOCK_SUJET_EE_T2)
// 3 appels from : profiles (auth) + sujets (select) + productions (insert)
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions')
// 2 appels from : profiles (auth) + productions (insert) — pas de update
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
})
it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', async () => {
it('premium + EO_T2_LIVE → création OK', async () => {
const profile = buildProfile({ plan: 'premium', simulations_used: 0 })
mockAuth(profile)
mockInsert({ id: 'prod-3', tache: 'EO_T2_LIVE', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
// Pas de mockSujets : EO_T2_LIVE skip la query sujets
// Pas de mockUpdate : premium n'a pas de limite
const app = createApp()
const res = await app.request('/simulations', {
@ -218,8 +156,6 @@ describe('POST /simulations', () => {
const body = await res.json()
expect(body.id).toBe('prod-3')
expect(body.tache).toBe('EO_T2_LIVE')
expect(body.sujet).toBeNull()
// 2 appels from : profiles (auth) + productions (insert) — pas de sujets
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
})
@ -256,716 +192,4 @@ describe('POST /simulations', () => {
expect(body.error).toBe(true)
expect(body.code).toBe('VALIDATION_ERROR')
})
it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => {
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
mockAuth(profile)
mockSujets([]) // table vide pour ce filtre
mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
const app = createApp()
const res = await app.request('/simulations', {
method: 'POST',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ tache: 'EE_T3', mode: 'entrainement' }),
})
expect(res.status).toBe(201)
const body = await res.json()
expect(body.id).toBe('prod-4')
expect(body.sujet).toBeNull()
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3)
expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets')
})
it('pick aléatoire parmi plusieurs sujets actifs', async () => {
const profile = buildProfile({ plan: 'standard', simulations_used: 10 })
const candidates = [
{ ...MOCK_SUJET_EE_T1, id: 'sujet-a' },
{ ...MOCK_SUJET_EE_T1, id: 'sujet-b' },
{ ...MOCK_SUJET_EE_T1, id: 'sujet-c' },
]
mockAuth(profile)
mockSujets(candidates)
mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' })
const app = createApp()
const res = await app.request('/simulations', {
method: 'POST',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ tache: 'EE_T1', mode: 'entrainement' }),
})
expect(res.status).toBe(201)
const body = await res.json()
const pickedIds = candidates.map((s) => s.id)
expect(pickedIds).toContain(body.sujet.id)
})
})
describe('GET /simulations/:id', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const VALID_RAPPORT = {
score: 14,
nclc: 8,
feedback_court: 'Bonne production générale.',
criteres: [
{ nom: 'Coherence et cohesion', score: 4, commentaire: 'OK' },
{ nom: 'Lexique', score: 3, commentaire: 'OK' },
{ nom: 'Morphosyntaxe', score: 4, commentaire: 'OK' },
{ nom: 'Pertinence', score: 3, commentaire: 'OK' },
],
erreurs: ['erreur 1'],
modele: 'Texte modèle.',
idees: ['idée 1'],
exercices: ['exo 1'],
}
it('OK (avec sujet) : rapport trouvé, appartenant à l\'utilisateur → 200', async () => {
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EE_T1',
mode: 'entrainement',
contenu: 'Mon texte en cours.',
sujet_id: 'sujet-1',
rapport: JSON.stringify(VALID_RAPPORT),
created_at: '2024-01-01T00:00:00Z',
})
mockSujetById(MOCK_SUJET_EE_T1)
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.simulation_id).toBe('prod-42')
expect(body.tache).toBe('EE_T1')
expect(body.mode).toBe('entrainement')
expect(body.created_at).toBe('2024-01-01T00:00:00Z')
expect(body.contenu).toBe('Mon texte en cours.')
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
expect(body.rapport).toEqual(VALID_RAPPORT)
})
it('OK (sans sujet_id) : production sans sujet → sujet: null', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EO_T2_LIVE',
mode: 'entrainement',
contenu: null,
sujet_id: null,
rapport: JSON.stringify(VALID_RAPPORT),
created_at: '2024-01-01T00:00:00Z',
})
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.sujet).toBeNull()
expect(body.contenu).toBeNull()
expect(body.rapport).toEqual(VALID_RAPPORT)
// Pas d'appel from('sujets') : sujet_id null
expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2)
})
it('FTD-21 — simulation en cours : rapport=null retourné avec contenu + sujet (resume)', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EE_T1',
mode: 'entrainement',
contenu: 'Brouillon en cours.',
sujet_id: 'sujet-1',
rapport: null,
created_at: '2024-01-01T00:00:00Z',
})
mockSujetById(MOCK_SUJET_EE_T1)
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.simulation_id).toBe('prod-42')
expect(body.contenu).toBe('Brouillon en cours.')
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
expect(body.rapport).toBeNull()
})
it('Sprint 3.6a — retourne nclc_cible, exercices/modele + statuses', async () => {
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EE_T1',
mode: 'entrainement',
contenu: 'texte',
sujet_id: null,
rapport: JSON.stringify(VALID_RAPPORT),
created_at: '2024-01-01T00:00:00Z',
nclc_cible: 10,
exercices: [
{
difficulte: 'facile',
theme: 'accord_sujet_verbe',
diagnostic: 'd',
consigne: 'c',
extrait: 'e',
indice: 'i',
correction: 'cor',
explication: 'ex',
},
],
exercices_status: 'ready',
modele: {
production_modele_propre: 'texte modele',
notes_pedagogiques: [],
transformations: [],
message: '',
nclc_modele: 9,
nclc_obtenu: 9,
score_cible: 14,
tcf_word_count: 2,
tcf_word_min: 60,
tcf_word_max: 120,
tcf_truncated: false,
},
modele_status: 'ready',
})
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.nclc_cible).toBe(10)
expect(body.exercices).toHaveLength(1)
expect(body.exercices[0]).toMatchObject({
difficulte: 'facile',
theme: 'accord_sujet_verbe',
})
expect(body.exercices_status).toBe('ready')
expect(body.modele.production_modele_propre).toBe('texte modele')
expect(body.modele_status).toBe('ready')
})
it('Sprint 3.6a — statuts par défaut "pending" si colonnes absentes (compat ancien schéma)', async () => {
const profile = buildProfile({ id: 'user-123', plan: 'standard' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'user-123',
tache: 'EE_T1',
mode: 'entrainement',
contenu: 't',
sujet_id: null,
rapport: JSON.stringify(VALID_RAPPORT),
created_at: '2024-01-01T00:00:00Z',
// nclc_cible / exercices / modele / statuses absents
})
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.nclc_cible).toBeNull()
expect(body.exercices).toBeNull()
expect(body.exercices_status).toBe('pending')
expect(body.modele).toBeNull()
expect(body.modele_status).toBe('pending')
})
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect(null, { message: 'No rows returned' })
const app = createApp()
const res = await app.request('/simulations/does-not-exist', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe(true)
expect(body.code).toBe('SIMULATION_NOT_FOUND')
})
it('AUTH_REQUIRED : user_id !== profile.id → 401', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({
id: 'prod-42',
user_id: 'another-user-456',
tache: 'EE_T1',
mode: 'entrainement',
contenu: null,
sujet_id: null,
rapport: JSON.stringify(VALID_RAPPORT),
created_at: '2024-01-01T00:00:00Z',
})
const app = createApp()
const res = await app.request('/simulations/prod-42', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error).toBe(true)
expect(body.code).toBe('AUTH_REQUIRED')
})
})
describe('PATCH /simulations/:id/contenu — FTD-21 autosave', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('succès : contenu sauvegardé → 200 { ok: true }', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({ user_id: 'user-123', rapport: null })
mockUpdate()
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu: 'Mon brouillon.' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ok).toBe(true)
})
it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({ user_id: 'another-user', rapport: null })
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu: 'Texte.' }),
})
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect(null, { message: 'No rows' })
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu: 'Texte.' }),
})
expect(res.status).toBe(404)
const body = await res.json()
expect(body.code).toBe('SIMULATION_NOT_FOUND')
})
it('VALIDATION_ERROR : contenu > 5000 caractères → 400', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
// Pas besoin de mocks supabase : la validation bloque avant tout accès DB
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu: 'a'.repeat(5001) }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('VALIDATION_ERROR : simulation déjà corrigée → 400', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionSelect({ user_id: 'user-123', rapport: '{"score":14}' })
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu: 'Texte.' }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('VALIDATION_ERROR : body.contenu manquant → 400', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations/prod-42/contenu', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
})
describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('succès : sujet mis à jour → 200 avec sujet complet', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockSujetById(MOCK_SUJET_EE_T1)
mockProductionSelect({ user_id: 'user-123', rapport: null })
mockUpdate()
const app = createApp()
const res = await app.request('/simulations/prod-42/sujet', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ sujet_id: 'sujet-1' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.sujet).toEqual(MOCK_SUJET_EE_T1)
})
it('SUJET_NOT_FOUND : sujet_id inexistant → 404', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockSujetById(null, { message: 'No rows' })
const app = createApp()
const res = await app.request('/simulations/prod-42/sujet', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ sujet_id: 'does-not-exist' }),
})
expect(res.status).toBe(404)
const body = await res.json()
expect(body.code).toBe('SUJET_NOT_FOUND')
})
it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockSujetById(MOCK_SUJET_EE_T1)
mockProductionSelect({ user_id: 'another-user', rapport: null })
const app = createApp()
const res = await app.request('/simulations/prod-42/sujet', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({ sujet_id: 'sujet-1' }),
})
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('VALIDATION_ERROR : sujet_id manquant → 400', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations/prod-42/sujet', {
method: 'PATCH',
headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
})
// ─── GET /simulations (Sprint 3.7) ────────────────────────────────────────────
/**
* Mock du chain Supabase pour `list` :
* from('productions').select(cols, {count:'exact'}).eq(...).order(...).range(...)
* retourne { data, error, count }.
*/
function mockProductionsList(params: {
data: unknown[]
count: number | null
error?: unknown
}) {
const { data, count, error = null } = params
const rangeFn = vi.fn(() => ({ data, error, count }))
const orderFn = vi.fn(() => ({ range: rangeFn }))
const eqFn = vi.fn(() => ({ order: orderFn }))
const selectFn = vi.fn(() => ({ eq: eqFn }))
vi.mocked(supabase.from).mockReturnValueOnce({ select: selectFn } as any)
return { selectFn, eqFn, orderFn, rangeFn }
}
describe('GET /simulations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('liste vide → 200 avec data=[] et total=0', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({ data: [], count: 0 })
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toEqual([])
expect(body.pagination).toEqual({ page: 1, limit: 20, total: 0 })
})
it('liste avec items : renvoie les 3 items projetés aux champs autorisés', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({
data: [
{
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T12:00:00Z',
},
{
id: 'p2',
tache: 'EE_T2',
mode: 'examen',
score: 16,
nclc: 10,
nclc_cible: 10,
created_at: '2026-04-22T11:00:00Z',
},
{
id: 'p3',
tache: 'EE_T3',
mode: 'entrainement',
score: null,
nclc: null,
nclc_cible: null,
created_at: '2026-04-22T10:00:00Z',
},
],
count: 3,
})
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(3)
expect(body.pagination.total).toBe(3)
expect(body.data[0]).toEqual({
id: 'p1',
tache: 'EE_T1',
mode: 'entrainement',
score: 14,
nclc: 9,
nclc_cible: 9,
created_at: '2026-04-22T12:00:00Z',
})
// Champs exclus : contenu, rapport, exercices, modele, etc. — pas de fuite
expect(body.data[0]).not.toHaveProperty('contenu')
expect(body.data[0]).not.toHaveProperty('rapport')
expect(body.data[0]).not.toHaveProperty('exercices')
expect(body.data[0]).not.toHaveProperty('modele')
})
it('pagination par défaut : page=1, limit=20 → range(0, 19)', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.rangeFn).toHaveBeenCalledWith(0, 19)
})
it('?page=2&limit=10 → range(10, 19), pagination reflète les params', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 42 })
const app = createApp()
const res = await app.request('/simulations?page=2&limit=10', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(mocks.rangeFn).toHaveBeenCalledWith(10, 19)
expect(body.pagination).toEqual({ page: 2, limit: 10, total: 42 })
})
it('tri `created_at DESC` appliqué côté Supabase', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.orderFn).toHaveBeenCalledWith('created_at', { ascending: false })
})
it('filtre `user_id = profile.id` appliqué', async () => {
const profile = buildProfile({ id: 'user-XYZ' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.eqFn).toHaveBeenCalledWith('user_id', 'user-XYZ')
})
it('select projette uniquement les champs autorisés + count exact', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const mocks = mockProductionsList({ data: [], count: 0 })
const app = createApp()
await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(mocks.selectFn).toHaveBeenCalledWith(
'id, tache, mode, score, nclc, nclc_cible, created_at',
{ count: 'exact' },
)
})
it('?limit=100 (>50) → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?limit=100', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(body.message).toMatch(/limit/i)
})
it('?page=abc (non-numérique) → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?page=abc', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(body.message).toMatch(/page/i)
})
it('?page=0 → 400 VALIDATION_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
const app = createApp()
const res = await app.request('/simulations?page=0', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('sans JWT → 401 AUTH_REQUIRED', async () => {
// Pas de mockAuth — le middleware refuse.
const app = createApp()
const res = await app.request('/simulations')
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('erreur Supabase sur la requête → 500 INTERNAL_ERROR', async () => {
const profile = buildProfile({ id: 'user-123' })
mockAuth(profile)
mockProductionsList({ data: [], count: null, error: { message: 'db down' } })
const app = createApp()
const res = await app.request('/simulations', {
headers: { Authorization: 'Bearer token' },
})
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe('INTERNAL_ERROR')
})
})

View file

@ -1,572 +0,0 @@
/**
* Contrôleur corrections Sprint 3.6a.
*
* Flux POST /corrections/ee :
* 1. Vérifier que la simulation existe, appartient à l'utilisateur, n'est pas déjà corrigée.
* 2. Charger le sujet (consigne + documents T3) pour alimenter le prompt maître.
* 3. Lancer les appels DeepSeek :
* (a) correction await, bloque la réponse HTTP
* (b) modèle démarré EN MÊME TEMPS que (a) avec `nclcObtenu = nclcCible - 1`
* comme estimation provisoire ; fire-and-forget, mise à jour async.
* (c) exercices démarré APRÈS (a) car dépend de `rapport.erreurs_codes` et
* `rapport.criteres` ; fire-and-forget également.
* 4. À réception de (a) : persister le rapport + champs associés, retourner 200.
* 5. Les promesses (b) et (c) continuent en arrière-plan et mettent à jour Supabase.
*
* Risque connu (cf. TECH_DEBT TD-15) : si le process redémarre pendant (b)/(c),
* les colonnes `*_status` restent en 'pending' indéfiniment.
*/
import { supabase } from "../lib/supabase.js";
import {
correctEE as deepseekCorrectEE,
correctEO as deepseekCorrectEO,
generateProductionModele,
generateExercices,
CRITERE_LABEL_PHONOLOGIE,
type CorrectionRapport,
type CorrectionCritereDetail,
type NclcCible,
type TacheEE,
type TacheEO,
type TacheCorrection,
} from "../lib/deepseek.js";
import { PLANS, type Plan } from "../lib/access.js";
import {
transcribeAudio,
isAcceptedAudioMime,
type AcceptedAudioMime,
} from "../lib/gemini.js";
import {
evaluatePhonology,
PHONOLOGY_STUB,
type PhonologyResult,
} from "../lib/geminiPhonology.js";
import type { AuthProfile } from "../middleware/auth.js";
type CorrectionError = {
error: true;
code: string;
message: string;
status: number;
};
export interface CorrectEEInput {
simulationId: string;
contenu: string;
tache: TacheEE;
nclcCible: NclcCible;
}
export async function correctEE(
input: CorrectEEInput,
profile: AuthProfile,
): Promise<
{ data: CorrectionRapport & { simulation_id: string } } | CorrectionError
> {
const { simulationId, contenu, tache, nclcCible } = input;
// 1. Vérifier que la production existe et appartient à l'utilisateur
const { data: production, error: fetchError } = await supabase
.from("productions")
.select("id, user_id, tache, sujet_id, rapport")
.eq("id", simulationId)
.single();
if (fetchError || !production) {
return {
error: true,
code: "SIMULATION_NOT_FOUND",
message: "Simulation introuvable.",
status: 404,
};
}
if (production.user_id !== profile.id) {
return {
error: true,
code: "AUTH_REQUIRED",
message: "Cette simulation ne vous appartient pas.",
status: 401,
};
}
// 2. Charger le sujet pour alimenter le prompt maître (consigne + docs T3)
let sujetConsigne: string | null = null;
let sourceDoc1: string | null = null;
let sourceDoc2: string | null = null;
if (production.sujet_id) {
const { data: sujetRow } = await supabase
.from("sujets")
.select("consigne, doc1_texte, doc2_texte")
.eq("id", production.sujet_id)
.single();
if (sujetRow) {
sujetConsigne = (sujetRow.consigne as string | null) ?? null;
sourceDoc1 = (sujetRow.doc1_texte as string | null) ?? null;
sourceDoc2 = (sujetRow.doc2_texte as string | null) ?? null;
}
}
// 3. Lancer correction + modèle EN MÊME TEMPS.
// Le modèle démarre sans attendre la correction : on estime `nclcObtenu`
// à `nclcCible - 1` (ordre de grandeur plausible pour un candidat visant
// NCLC nclcCible). Cette valeur n'alimente que la phrase pédagogique du
// prompt modèle — pas la cible, qui reste fixée à NCLC 9.
const correctionPromise = deepseekCorrectEE({
tache,
contenu,
sujet: sujetConsigne,
sourceDoc1,
sourceDoc2,
nclcCible,
});
const nclcObtenuEstime = nclcCible - 1;
void runModeleJob({
simulationId,
tache,
sujet: sujetConsigne,
texte: contenu,
nclcObtenu: nclcObtenuEstime,
});
let rapport: CorrectionRapport;
try {
rapport = await correctionPromise;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.error("[correctionController.correctEE] correction failed", {
simulationId,
tache,
nclcCible,
message,
stack,
});
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Erreur lors de la correction. Veuillez réessayer dans quelques instants.",
status: 500,
};
}
// 4. Persister la correction.
// ⚠️ RACE CONDITION — ne PAS inclure `modele_status` ni `exercices_status`
// ici : runModeleJob (lancé en parallèle option b) peut avoir déjà terminé
// et écrit 'ready' avant que cet update ne s'exécute. Écraser avec 'pending'
// perdrait le résultat.
// Les colonnes *_status sont initialisées à 'pending' par la migration
// (DEFAULT) et gérées exclusivement par runModeleJob / runExercicesJob.
const { error: updateError } = await supabase
.from("productions")
.update({
score: rapport.score,
nclc: rapport.nclc,
nclc_cible: rapport.nclc_cible,
revelation: rapport.revelation,
diagnostic: rapport.diagnostic,
conseil_nclc: rapport.conseil_nclc,
erreurs_codes: rapport.erreurs_codes,
rapport: JSON.stringify(rapport),
})
.eq("id", simulationId);
if (updateError) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Erreur lors de la sauvegarde du rapport. Veuillez réessayer.",
status: 500,
};
}
// 5. Lancer les exercices maintenant qu'on a rapport.erreurs_codes + criteres.
// Ne JAMAIS await — cette promesse vit après la réponse HTTP.
void runExercicesJob({ simulationId, tache, rapport });
// 6. Incrémenter simulations_used si le plan a une limite (non bloquant).
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
await supabase
.from("profiles")
.update({ simulations_used: profile.simulations_used + 1 })
.eq("id", profile.id);
}
return { data: { ...rapport, simulation_id: simulationId } };
}
// ── Jobs asynchrones — modèle + exercices ───────────────────────────────
interface ModeleJobInput {
simulationId: string;
tache: TacheCorrection;
sujet: string | null;
texte: string;
nclcObtenu: number;
}
async function runModeleJob(input: ModeleJobInput): Promise<void> {
const { simulationId, tache, sujet, texte, nclcObtenu } = input;
console.log("[runModeleJob] START", { simulationId, tache, nclcObtenu });
try {
const modele = await generateProductionModele({
tache,
sujet,
texte,
nclcObtenu,
});
console.log("[runModeleJob] DeepSeek OK, updating productions", {
simulationId,
modeleWordCount: modele.tcf_word_count,
});
const { error: updateErr, data: updateData } = await supabase
.from("productions")
.update({ modele, modele_status: "ready" })
.eq("id", simulationId)
.select("id, modele_status");
console.log("[runModeleJob] update result", {
simulationId,
updateErr,
updateData,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.error("[runModeleJob] CAUGHT ERROR", {
simulationId,
message,
stack,
});
try {
const { error: fallbackErr } = await supabase
.from("productions")
.update({ modele_status: "error" })
.eq("id", simulationId);
console.log("[runModeleJob] fallback update result", {
simulationId,
fallbackErr,
});
} catch (fallbackExc) {
console.error("[runModeleJob] FALLBACK UPDATE THREW", {
simulationId,
message:
fallbackExc instanceof Error
? fallbackExc.message
: String(fallbackExc),
});
}
}
}
interface ExercicesJobInput {
simulationId: string;
tache: TacheCorrection;
rapport: CorrectionRapport;
}
async function runExercicesJob(input: ExercicesJobInput): Promise<void> {
const { simulationId, tache, rapport } = input;
console.log("[runExercicesJob] START", {
simulationId,
tache,
erreursCodesCount: rapport.erreurs_codes.length,
});
try {
const exercices = await generateExercices({
tache,
erreursCodes: rapport.erreurs_codes,
criteres: rapport.criteres,
});
console.log("[runExercicesJob] DeepSeek OK, updating productions", {
simulationId,
exercicesCount: exercices.length,
});
const { error: updateErr, data: updateData } = await supabase
.from("productions")
.update({ exercices, exercices_status: "ready" })
.eq("id", simulationId)
.select("id, exercices_status");
console.log("[runExercicesJob] update result", {
simulationId,
updateErr,
updateData,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.error("[runExercicesJob] CAUGHT ERROR", {
simulationId,
message,
stack,
});
try {
const { error: fallbackErr } = await supabase
.from("productions")
.update({ exercices_status: "error" })
.eq("id", simulationId);
console.log("[runExercicesJob] fallback update result", {
simulationId,
fallbackErr,
});
} catch (fallbackExc) {
console.error("[runExercicesJob] FALLBACK UPDATE THREW", {
simulationId,
message:
fallbackExc instanceof Error
? fallbackExc.message
: String(fallbackExc),
});
}
}
}
// ── EO — Sprint 4b.2 : transcript OU audio batch (Gemini) ──────────────
//
// Bascule Sprint 4b.2 : abandon de Deepgram live au profit de Gemini batch
// côté serveur. Le frontend envoie soit un transcript déjà constitué, soit
// l'audio brut en base64 — auquel cas le backend appelle `transcribeAudio`
// (Gemini) avant de poursuivre le pipeline correction. L'audio n'est PAS
// stocké côté serveur ; le client en garde une copie locale s'il le souhaite.
//
// Flux POST /corrections/eo :
// 1. Vérifier production + ownership.
// 2. Charger la consigne (utile au prompt EO).
// 3. Mode A (audioBase64) : valider MIME → transcribeAudio → transcript.
// Mode B (transcript direct) : passer.
// 4. Lancer correction EO + modèle EO en parallèle (mêmes patterns que EE).
// 5. Persister le rapport (contenu = transcript).
// 6. Lancer les exercices fire-and-forget.
// 7. Incrémenter le quota.
//
// Le risque race-condition décrit dans correctEE s'applique aussi ici : on ne
// touche PAS aux colonnes *_status dans l'update final.
export interface CorrectEOInput {
simulationId: string;
tache: TacheEO;
nclcCible: NclcCible;
/** Transcript texte fourni directement par le client (mode A). */
transcript?: string;
/** Audio brut en base64 (mode B — Gemini transcrit côté serveur). */
audioBase64?: string;
/** MIME du payload audio quand audioBase64 est fourni. */
mimeType?: string;
}
export async function correctEO(
input: CorrectEOInput,
profile: AuthProfile,
): Promise<
{ data: CorrectionRapport & { simulation_id: string } } | CorrectionError
> {
const { simulationId, tache, nclcCible } = input;
// 1. Vérifier la production + ownership.
const { data: production, error: fetchError } = await supabase
.from("productions")
.select("id, user_id, tache, sujet_id")
.eq("id", simulationId)
.single();
if (fetchError || !production) {
return {
error: true,
code: "SIMULATION_NOT_FOUND",
message: "Simulation introuvable.",
status: 404,
};
}
if (production.user_id !== profile.id) {
return {
error: true,
code: "AUTH_REQUIRED",
message: "Cette simulation ne vous appartient pas.",
status: 401,
};
}
// 2. Charger la consigne (utile au prompt EO).
let sujetConsigne: string | null = null;
if (production.sujet_id) {
const { data: sujetRow } = await supabase
.from("sujets")
.select("consigne")
.eq("id", production.sujet_id)
.single();
if (sujetRow) {
sujetConsigne = (sujetRow.consigne as string | null) ?? null;
}
}
// 3. Préparer l'audio (Mode B) ou le transcript (Mode A).
// Mode B : on lance la transcription Gemini ET l'évaluation phonologique
// en parallèle sur le même payload audio (Sprint 4.8).
// Mode A : le client fournit déjà le transcript, la phonologie devient un
// stub /4 (cf. PHONOLOGY_STUB) — pas d'audio à analyser.
let transcript: string;
let phonologyPromise: Promise<PhonologyResult>;
if (input.audioBase64 && input.mimeType) {
// Normalisation du MIME : `MediaRecorder` côté navigateur produit souvent
// un type complet `audio/webm;codecs=opus`. La whitelist Gemini compare
// par égalité stricte → on conserve uniquement la partie principale.
const normalizedMime = input.mimeType.split(";", 1)[0]!.trim();
if (!isAcceptedAudioMime(normalizedMime)) {
return {
error: true,
code: "VALIDATION_ERROR",
message:
"mimeType non supporté. Valeurs acceptées : audio/webm, audio/mp4, audio/wav.",
status: 400,
};
}
const acceptedMime = normalizedMime as AcceptedAudioMime;
// Démarrer la phonologie tout de suite — elle tourne en parallèle de la
// transcription puis de la correction DeepSeek. Si elle échoue, on bascule
// sur le stub et on log : la correction ne doit JAMAIS être bloquée par
// une défaillance phonologique.
phonologyPromise = evaluatePhonology(input.audioBase64, acceptedMime).catch(
(err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(
"[correctionController.correctEO] phonology evaluation failed",
{ simulationId, message },
);
return PHONOLOGY_STUB;
},
);
try {
transcript = await transcribeAudio(input.audioBase64, acceptedMime);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[correctionController.correctEO] transcription failed", {
simulationId,
message,
});
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Impossible de transcrire l'audio. Veuillez réessayer dans quelques instants.",
status: 500,
};
}
} else if (typeof input.transcript === "string") {
transcript = input.transcript;
phonologyPromise = Promise.resolve(PHONOLOGY_STUB);
} else {
return {
error: true,
code: "VALIDATION_ERROR",
message: "Fournir soit `transcript`, soit `audioBase64` + `mimeType`.",
status: 400,
};
}
// 4. Lancer correction EO + modèle EO en parallèle.
const correctionPromise = deepseekCorrectEO(
transcript,
tache,
nclcCible,
sujetConsigne,
);
const nclcObtenuEstime = nclcCible - 1;
void runModeleJob({
simulationId,
tache,
sujet: sujetConsigne,
texte: transcript,
nclcObtenu: nclcObtenuEstime,
});
let rapportTextuel: CorrectionRapport;
let phonology: PhonologyResult;
try {
[rapportTextuel, phonology] = await Promise.all([
correctionPromise,
phonologyPromise,
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[correctionController.correctEO] correction failed", {
simulationId,
tache,
nclcCible,
message,
});
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Erreur lors de la correction. Veuillez réessayer dans quelques instants.",
status: 500,
};
}
// 4-bis. Sprint 4.8 — fusionner la phonologie comme 5e critère et recalculer
// le score global ∈ [0,20] (4 textuels × /4 + phonologie × /4).
const phonologyCritere: CorrectionCritereDetail = {
nom: CRITERE_LABEL_PHONOLOGIE,
score: phonology.score,
commentaire: phonology.commentaire,
exemple: phonology.exemple,
suggestion: phonology.suggestion,
astuce: phonology.astuce,
};
const criteresAvecPhonologie: CorrectionCritereDetail[] = [
...rapportTextuel.criteres,
phonologyCritere,
];
const scoreFinal = criteresAvecPhonologie.reduce(
(acc, c) => acc + c.score,
0,
);
const rapport: CorrectionRapport = {
...rapportTextuel,
criteres: criteresAvecPhonologie,
score: scoreFinal,
};
// 5. Persister le rapport. Pas de *_status (race condition — cf. correctEE).
const { error: updateError } = await supabase
.from("productions")
.update({
contenu: transcript,
score: rapport.score,
nclc: rapport.nclc,
nclc_cible: rapport.nclc_cible,
revelation: rapport.revelation,
diagnostic: rapport.diagnostic,
conseil_nclc: rapport.conseil_nclc,
erreurs_codes: rapport.erreurs_codes,
rapport: JSON.stringify(rapport),
})
.eq("id", simulationId);
if (updateError) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Erreur lors de la sauvegarde du rapport. Veuillez réessayer.",
status: 500,
};
}
// 6. Exercices fire-and-forget.
void runExercicesJob({ simulationId, tache, rapport });
// 7. Quota.
if (PLANS[profile.plan as Plan].simulations_lifetime !== null) {
await supabase
.from("profiles")
.update({ simulations_used: profile.simulations_used + 1 })
.eq("id", profile.id);
}
return { data: { ...rapport, simulation_id: simulationId } };
}

View file

@ -1,336 +0,0 @@
/**
* Contrôleur Analyse patterns Sprint 3.6c.
*
* Flux GET /users/patterns :
* 1. Charger les 5 dernières productions corrigées (rapport != null).
* 2. Si < 5 retourner { ready: false, minimum, current }.
* 3. Sinon : vérifier le cache `pattern_analyses`.
* - Cache hit (aucune prod postérieure à la dernière analyse) retourner le cache.
* - Cache miss agréger patterns + calculer indice + générer exercices DeepSeek insert.
* 4. Retourner le snapshot.
*
* Agrégation : un pattern est confirmé si un code d'erreur apparaît dans
* 3 productions sur 5 (cf. PARCOURS_UTILISATEURS.md §Analyse patterns).
*
* Formule indice de préparation (cf. décision session 2026-04-22) :
* final = 60% × score_moyen_normalisé + 20% × régularité + 20% × tendance
*/
import { supabase } from '../lib/supabase.js'
import {
generatePatternExercices,
type PatternExerciceItem,
} from '../lib/deepseek.js'
import { isValidCritere, type Critere } from '../lib/taxonomieErreurs.js'
import type { AuthProfile } from '../middleware/auth.js'
const ANALYSIS_WINDOW = 5
const PATTERN_THRESHOLD = 3
// ── Types ────────────────────────────────────────────────────────────────
export interface PatternEntry {
code: string
critere: Critere
frequency: number // 3, 4 ou 5
description: string | null // non-null uniquement pour code === 'autre'
}
export interface PreparationIndex {
score: number // 0-100 entier
message: string
}
export interface ProductionForAnalysis {
id: string
score: number | null
created_at: string
erreurs_codes: unknown
}
export interface PatternsNotReady {
ready: false
minimum: number
current: number
}
export interface PatternsReady {
ready: true
patterns: PatternEntry[]
exercises: PatternExerciceItem[]
preparation_index: PreparationIndex
analyzed_productions: number
last_analysis: string
}
export type PatternsResult = PatternsNotReady | PatternsReady
type ControllerError = {
error: true
code: string
message: string
status: number
}
// ── Agrégation — fonctions pures ─────────────────────────────────────────
interface RawErreurCode {
code: string
critere: Critere
description: string | null
}
function normalizeErreursCodes(raw: unknown): RawErreurCode[] {
if (!Array.isArray(raw)) return []
const out: RawErreurCode[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue
const o = item as { code?: unknown; critere?: unknown; description?: unknown }
if (typeof o.code !== 'string' || typeof o.critere !== 'string') continue
if (!isValidCritere(o.critere)) continue
out.push({
code: o.code,
critere: o.critere,
description: typeof o.description === 'string' ? o.description : null,
})
}
return out
}
/**
* Agrège les codes d'erreurs sur N productions et retourne les patterns
* confirmés (frequency PATTERN_THRESHOLD).
*
* Le code `autre` est distingué par sa description deux erreurs `autre`
* avec des descriptions différentes ne sont PAS regroupées.
*/
export function aggregatePatterns(
productions: ProductionForAnalysis[],
): PatternEntry[] {
const counts = new Map<string, PatternEntry>()
for (const prod of productions) {
const erreurs = normalizeErreursCodes(prod.erreurs_codes)
// Dédoublonnage INTRA-production : un même code ne compte qu'une fois par prod.
const seen = new Set<string>()
for (const e of erreurs) {
const key =
e.code === 'autre'
? `${e.critere}|${e.code}|${e.description ?? ''}`
: `${e.critere}|${e.code}`
if (seen.has(key)) continue
seen.add(key)
const existing = counts.get(key)
if (existing) {
existing.frequency += 1
} else {
counts.set(key, {
code: e.code,
critere: e.critere,
frequency: 1,
description: e.code === 'autre' ? e.description : null,
})
}
}
}
return Array.from(counts.values())
.filter((p) => p.frequency >= PATTERN_THRESHOLD)
.sort((a, b) => {
if (b.frequency !== a.frequency) return b.frequency - a.frequency
return a.critere.localeCompare(b.critere)
})
}
// ── Indice de préparation ────────────────────────────────────────────────
function median(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!
}
function linearTrend(scores: number[]): number {
// Régression linéaire simple : pente sur X = [0, 1, ..., n-1].
const n = scores.length
if (n < 2) return 0
const xMean = (n - 1) / 2
const yMean = scores.reduce((a, b) => a + b, 0) / n
let num = 0
let den = 0
for (let i = 0; i < n; i++) {
num += (i - xMean) * (scores[i]! - yMean)
den += (i - xMean) ** 2
}
return den === 0 ? 0 : num / den
}
export function computePreparationIndex(
productions: ProductionForAnalysis[],
): PreparationIndex {
// Productions triées du plus ANCIEN au plus RÉCENT pour la tendance
// (l'appel externe passe la liste DESC — on inverse ici).
const ordered = [...productions].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
)
const scores = ordered
.map((p) => p.score)
.filter((s): s is number => typeof s === 'number')
if (scores.length === 0) {
return { score: 0, message: 'Continuez à vous entraîner régulièrement' }
}
// 1. Score moyen normalisé (0-100 sur /20)
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
const scoreAvgNorm = (avg / 20) * 100
// 2. Régularité (médiane des intervalles en jours)
const intervals: number[] = []
for (let i = 1; i < ordered.length; i++) {
const prev = new Date(ordered[i - 1]!.created_at).getTime()
const curr = new Date(ordered[i]!.created_at).getTime()
intervals.push((curr - prev) / (1000 * 60 * 60 * 24))
}
const medianInterval = median(intervals)
const regularityScore =
medianInterval < 3 ? 100 : medianInterval < 7 ? 70 : medianInterval < 14 ? 40 : 15
// 3. Tendance (pente linéaire)
const slope = linearTrend(scores)
const trendScore = slope > 0.1 ? 100 : slope < -0.1 ? 0 : 50
const final = Math.round(scoreAvgNorm * 0.6 + regularityScore * 0.2 + trendScore * 0.2)
const clamped = Math.max(0, Math.min(100, final))
const message =
clamped < 40
? 'Continuez à vous entraîner régulièrement'
: clamped <= 70
? 'Bonne progression — visez NCLC 7-8'
: 'Vous êtes en bonne voie pour NCLC 9+'
return { score: clamped, message }
}
// ── Orchestration principale ─────────────────────────────────────────────
export async function list(
profile: AuthProfile,
): Promise<{ data: PatternsResult } | ControllerError> {
// 1. Charger les 5 dernières productions corrigées
const { data: productions, error: fetchErr } = await supabase
.from('productions')
.select('id, score, created_at, erreurs_codes')
.eq('user_id', profile.id)
.not('rapport', 'is', null)
.order('created_at', { ascending: false })
.limit(ANALYSIS_WINDOW)
if (fetchErr) {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Impossible de charger les productions.',
status: 500,
}
}
const prods = (productions ?? []) as ProductionForAnalysis[]
if (prods.length < ANALYSIS_WINDOW) {
return {
data: {
ready: false,
minimum: ANALYSIS_WINDOW,
current: prods.length,
},
}
}
// 2. Cache : dernière analyse pour cet user
const { data: lastAnalysis } = await supabase
.from('pattern_analyses')
.select('*')
.eq('user_id', profile.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
const latestProdDate = prods[0]!.created_at
const cacheFresh =
lastAnalysis !== null &&
new Date(lastAnalysis.created_at as string).getTime() >=
new Date(latestProdDate).getTime()
if (cacheFresh && lastAnalysis) {
return {
data: {
ready: true,
patterns: lastAnalysis.patterns as PatternEntry[],
exercises: lastAnalysis.exercises as PatternExerciceItem[],
preparation_index: {
score: lastAnalysis.preparation_index as number,
message: lastAnalysis.preparation_message as string,
},
analyzed_productions: lastAnalysis.analyzed_count as number,
last_analysis: lastAnalysis.created_at as string,
},
}
}
// 3. Cache miss → recompute
const patterns = aggregatePatterns(prods)
const preparation = computePreparationIndex(prods)
let exercises: PatternExerciceItem[] = []
if (patterns.length > 0) {
try {
exercises = await generatePatternExercices(patterns)
} catch (err) {
console.error('[patternsController.list] generatePatternExercices failed', {
userId: profile.id,
message: err instanceof Error ? err.message : String(err),
})
// Dégradation gracieuse : on persiste l'analyse sans exercices.
exercises = []
}
}
// 4. Persister
const { data: inserted, error: insertErr } = await supabase
.from('pattern_analyses')
.insert({
user_id: profile.id,
productions_ids: prods.map((p) => p.id),
patterns,
exercises,
preparation_index: preparation.score,
preparation_message: preparation.message,
analyzed_count: prods.length,
})
.select('created_at')
.single()
if (insertErr || !inserted) {
return {
error: true,
code: 'INTERNAL_ERROR',
message: 'Impossible de sauvegarder l\'analyse.',
status: 500,
}
}
return {
data: {
ready: true,
patterns,
exercises,
preparation_index: preparation,
analyzed_productions: prods.length,
last_analysis: inserted.created_at as string,
},
}
}

View file

@ -1,178 +0,0 @@
/**
* Contrôleur génération de présentation T1 Sprint 4a.
*
* Génère un texte de présentation personnelle (Tâche 1 EO) à partir des
* 5 réponses fournies par le candidat. Pas de stockage en base (le frontend
* gère la persistance locale pour le MVP).
*
* Paramètres DeepSeek : temperature 0.7, max_tokens 600, timeout 20s.
* Pas de response_format json on récupère du texte brut.
*/
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY ?? "";
const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
export interface PresentationReponses {
prenom_age_ville: string;
formation_metier: string;
situation_familiale: string;
loisirs: string;
motivation_canada: string;
}
export type PresentationError = {
error: true;
code: string;
message: string;
status: number;
};
const REQUIRED_FIELDS: (keyof PresentationReponses)[] = [
"prenom_age_ville",
"formation_metier",
"situation_familiale",
"loisirs",
"motivation_canada",
];
export function validateReponses(
raw: unknown,
): { ok: true; reponses: PresentationReponses } | PresentationError {
if (typeof raw !== "object" || raw === null) {
return {
error: true,
code: "VALIDATION_ERROR",
message: "`reponses` est requis et doit être un objet.",
status: 400,
};
}
const r = raw as Record<string, unknown>;
for (const field of REQUIRED_FIELDS) {
const v = r[field];
if (typeof v !== "string" || v.trim().length === 0) {
return {
error: true,
code: "VALIDATION_ERROR",
message: `Le champ \`reponses.${field}\` est requis et ne doit pas être vide.`,
status: 400,
};
}
}
return {
ok: true,
reponses: {
prenom_age_ville: (r.prenom_age_ville as string).trim(),
formation_metier: (r.formation_metier as string).trim(),
situation_familiale: (r.situation_familiale as string).trim(),
loisirs: (r.loisirs as string).trim(),
motivation_canada: (r.motivation_canada as string).trim(),
},
};
}
export function buildPresentationPrompt(
reponses: PresentationReponses,
): string {
return `Tu es un coach TCF Canada spécialisé en Expression Orale. Tu rédiges des textes que le candidat va LIRE À VOIX HAUTE devant un examinateur (entretien dirigé, ~2 minutes).
Informations à intégrer fidèlement (ne rien inventer) :
- Identité : ${reponses.prenom_age_ville}
- Formation / métier : ${reponses.formation_metier}
- Famille : ${reponses.situation_familiale}
- Loisirs : ${reponses.loisirs}
- Projet Canada : ${reponses.motivation_canada}
OBJECTIF : produire une présentation personnelle pour la Tâche 1 TCF Canada, longueur cible **220 à 260 mots** (durée réaliste à l'oral, ni trop courte ni trop longue).
STRUCTURE À RESPECTER (dans cet ordre) :
1) Identité et cadre (qui vous êtes, vous vivez si pertinent)
2) Formation / parcours professionnel
3) Situation familiale
4) Loisirs ou passions
5) Projet d'immigration au Canada
6) Une **courte** phrase de transition finale vers l'examinateur (ex. proposer de développer un point), **sans** être familière ni utiliser « tu »
STYLE ORAL (prioritaire) :
- Phrases **courtes à moyennes**, faciles à dire d'un seul souffle ; éviter les phrases alambiquées ou les subordonnées empilées.
- **Enchaînements parlés** : alterner des liens simples (« Ensuite », « Côté famille », « Pour les loisirs », « Concernant mon projet », « Voilà, en résumé ») plutôt qu'un style dissertation.
- Vocabulaire **correct mais accessible** ; privilégier les mots usuels. Pas de jargon inutile ni de tournures trop littéraires (« Il convient de », « En outre », « Néanmoins », « Ainsi donc »).
- **Éviter le style écrit** : pas de listes à puces, pas de titres, pas d'introduction type « Je vais vous parler de en trois parties ».
- **Fluidité à prononcer** : éviter les enchaînements de voyelles ou de consonnes lourdes quand c'est simple à reformuler ; favoriser la respiration naturelle (points, virgules logiques à l'oral).
- Registre **semi-formel** : poli, respectueux, comme face à un examinateur ; pas de slang, pas de tutoiement de l'examinateur, pas d'excès de familiarité.
Ce qu'il faut éviter :
- Ton académique, catalogué ou « corrigé de dissertation »
- Répétitions mécaniques du même connecteur (ex. « En ce qui concerne » à chaque paragraphe)
- Phrases trop longues ou trop complexes à mémoriser
Réponds **UNIQUEMENT** avec le texte continu de la présentation (première personne), sans titre, sans guillemets, sans commentaire ni note.`;
}
export async function generate(
rawReponses: unknown,
): Promise<{ data: { presentation: string } } | PresentationError> {
const validation = validateReponses(rawReponses);
if ("error" in validation) return validation;
const systemPrompt = buildPresentationPrompt(validation.reponses);
let response: Response;
try {
response = await fetch(`${DEEPSEEK_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "system", content: systemPrompt }],
temperature: 0.7,
max_tokens: 600,
}),
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[presentationController.generate] fetch failed", {
message,
});
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Impossible de générer la présentation. Veuillez réessayer dans quelques instants.",
status: 500,
};
}
if (!response.ok) {
console.error("[presentationController.generate] DeepSeek non-OK", {
status: response.status,
statusText: response.statusText,
});
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Impossible de générer la présentation. Veuillez réessayer dans quelques instants.",
status: 500,
};
}
const data = (await response.json()) as {
choices?: { message?: { content?: string } }[];
};
const presentation = data.choices?.[0]?.message?.content?.trim();
if (!presentation || presentation.length === 0) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Réponse de génération vide. Veuillez réessayer.",
status: 500,
};
}
return { data: { presentation } };
}

View file

@ -1,483 +1,76 @@
import { supabase } from "../lib/supabase.js";
import { canUserSimulate } from "../lib/access.js";
import type {
CorrectionRapport,
ProductionModele,
ExerciceItem,
} from "../lib/deepseek.js";
import type { AuthProfile } from "../middleware/auth.js";
import { supabase } from '../lib/supabase'
import { canUserSimulate, getPlanPermissions } from '../lib/access'
import type { Plan } from '../lib/access'
import type { AuthProfile } from '../middleware/auth'
export type JobStatus = "pending" | "ready" | "error";
export type Tache =
| "EE_T1"
| "EE_T2"
| "EE_T3"
| "EO_T1"
| "EO_T3"
| "EO_T2_LIVE";
export type Mode = "entrainement" | "examen";
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' | 'EO_T2_LIVE'
export type Mode = 'entrainement' | 'examen'
export interface CreateBody {
tache: Tache;
mode: Mode;
contenu?: string;
}
export interface SujetData {
id: string;
consigne: string;
role: string | null;
contexte: string | null;
doc1_titre: string | null;
doc1_texte: string | null;
doc2_titre: string | null;
doc2_texte: string | null;
tache: Tache
mode: Mode
contenu?: string
}
export interface CreateResult {
id: string;
tache: Tache;
mode: Mode;
created_at: string;
sujet: SujetData | null;
id: string
tache: Tache
mode: Mode
created_at: string
}
type CreateError = {
error: true;
code: string;
message: string;
status: number;
};
// Mappe une Tache frontend vers les filtres de la table sujets.
// Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini).
function mapTacheToSujetParams(
tache: Tache,
): { mode: "EE" | "EO"; tacheNumber: number } | null {
switch (tache) {
case "EE_T1":
return { mode: "EE", tacheNumber: 1 };
case "EE_T2":
return { mode: "EE", tacheNumber: 2 };
case "EE_T3":
return { mode: "EE", tacheNumber: 3 };
case "EO_T1":
return { mode: "EO", tacheNumber: 1 };
case "EO_T3":
return { mode: "EO", tacheNumber: 3 };
case "EO_T2_LIVE":
return null;
}
error: true
code: string
message: string
status: number
}
export async function create(
body: CreateBody,
profile: AuthProfile,
profile: AuthProfile
): Promise<{ data: CreateResult } | CreateError> {
// 1. Vérifier le quota via canUserSimulate (lib/access.ts)
const check = canUserSimulate({
plan: profile.plan,
simulations_used: profile.simulations_used,
});
const check = canUserSimulate({ plan: profile.plan, simulations_used: profile.simulations_used })
if (!check.allowed) {
return {
error: true,
code: "QUOTA_REACHED",
code: 'QUOTA_REACHED',
message:
"Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.",
'Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.',
status: 403,
};
}
// 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête.
// (non bloquant — sujet: null si introuvable).
const sujetParams = mapTacheToSujetParams(body.tache);
let sujet: SujetData | null = null;
if (sujetParams) {
const { data: sujets, error: sujetError } = await supabase
.from("sujets")
.select(
"id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
)
.eq("mode", sujetParams.mode)
.eq("tache", sujetParams.tacheNumber)
.eq("actif", true);
if (!sujetError && sujets && sujets.length > 0) {
sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData;
}
}
// 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume).
// 2. Insérer dans productions
const { data, error } = await supabase
.from("productions")
.from('productions')
.insert({
user_id: profile.id,
tache: body.tache,
mode: body.mode,
contenu: body.contenu ?? null,
sujet_id: sujet?.id ?? null,
})
.select("id, tache, mode, created_at")
.single();
.select('id, tache, mode, created_at')
.single()
if (error || !data) {
return {
error: true,
code: "INTERNAL_ERROR",
message:
"Une erreur est survenue. Veuillez réessayer dans quelques instants.",
code: 'INTERNAL_ERROR',
message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.',
status: 500,
};
}
}
return {
data: {
id: data.id,
tache: data.tache as Tache,
mode: data.mode as Mode,
created_at: data.created_at,
sujet,
},
};
}
// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté.
// Renvoie uniquement les champs utiles à l'affichage en liste (pas de contenu,
// rapport, exercices, modele — trop lourds).
export interface ListOptions {
page: number;
limit: number;
}
export interface ListItem {
id: string;
tache: Tache;
mode: Mode;
score: number | null;
nclc: number | null;
nclc_cible: 9 | 10 | null;
created_at: string;
}
export interface ListResult {
data: ListItem[];
pagination: {
page: number;
limit: number;
total: number;
};
}
type ListError = ControllerError;
export async function list(
options: ListOptions,
profile: AuthProfile,
): Promise<{ data: ListResult } | ListError> {
const { page, limit } = options;
const offset = (page - 1) * limit;
const { data, error, count } = await supabase
.from("productions")
.select("id, tache, mode, score, nclc, nclc_cible, created_at", {
count: "exact",
})
.eq("user_id", profile.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (error) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Impossible de charger les simulations.",
status: 500,
};
}
const items: ListItem[] = (data ?? []).map((row) => ({
id: row.id as string,
tache: row.tache as Tache,
mode: row.mode as Mode,
score: (row.score as number | null) ?? null,
nclc: (row.nclc as number | null) ?? null,
nclc_cible:
row.nclc_cible === 9 || row.nclc_cible === 10
? (row.nclc_cible as 9 | 10)
: null,
created_at: row.created_at as string,
}));
return {
data: {
data: items,
pagination: { page, limit, total: count ?? 0 },
},
};
}
// Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc,
// erreurs_codes) + statuts des jobs asynchrones (modele, exercices).
//
// FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée).
// Le frontend distingue :
// - rapport !== null → RapportPage affiche la correction
// - rapport === null → SimulationFlowProvider restaure la session (resume)
export interface GetByIdResult {
simulation_id: string;
tache: Tache;
mode: Mode;
created_at: string;
contenu: string | null;
sujet: SujetData | null;
rapport: CorrectionRapport | null;
nclc_cible: 9 | 10 | null;
exercices: ExerciceItem[] | null;
exercices_status: JobStatus;
modele: ProductionModele | null;
modele_status: JobStatus;
}
type ControllerError = {
error: true;
code: string;
message: string;
status: number;
};
export async function getById(
id: string,
profile: AuthProfile,
): Promise<{ data: GetByIdResult } | ControllerError> {
const { data, error } = await supabase
.from("productions")
.select(
"id, user_id, tache, mode, contenu, sujet_id, rapport, created_at, nclc_cible, exercices, exercices_status, modele, modele_status",
)
.eq("id", id)
.single();
if (error || !data) {
return {
error: true,
code: "SIMULATION_NOT_FOUND",
message: "Simulation introuvable.",
status: 404,
};
}
if (data.user_id !== profile.id) {
return {
error: true,
code: "AUTH_REQUIRED",
message: "Cette simulation ne vous appartient pas.",
status: 401,
};
}
// Charger le sujet si présent (FTD-21 — restore complet de la session).
let sujet: SujetData | null = null;
if (data.sujet_id) {
const { data: sujetRow } = await supabase
.from("sujets")
.select(
"id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
)
.eq("id", data.sujet_id)
.single();
if (sujetRow) sujet = sujetRow as SujetData;
}
// Garde JSONB : supabase-js retourne les colonnes JSONB déjà parsées (objet)
// mais on tolère le cas string au cas où le payload aurait été ré-encodé
// (ex. cache, réponse stockée en TEXT par migration manuelle).
const parseJsonb = <T>(field: unknown): T | null => {
if (field === null || field === undefined) return null;
if (typeof field === "string") {
try {
return JSON.parse(field) as T;
} catch {
return null;
}
}
return field as T;
};
const rapport = parseJsonb<CorrectionRapport>(data.rapport);
const exercicesParsed = parseJsonb<ExerciceItem[]>(data.exercices);
const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null;
const modeleParsed = parseJsonb<ProductionModele>(data.modele);
const modele =
modeleParsed && typeof modeleParsed === "object" ? modeleParsed : null;
const exercicesStatus =
(data.exercices_status as JobStatus | null) ?? "pending";
const modeleStatus = (data.modele_status as JobStatus | null) ?? "pending";
const nclcCibleRaw = data.nclc_cible;
const nclcCible: 9 | 10 | null =
nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null;
return {
data: {
simulation_id: data.id,
tache: data.tache as Tache,
mode: data.mode as Mode,
created_at: data.created_at,
contenu: data.contenu ?? null,
sujet,
rapport,
nclc_cible: nclcCible,
exercices,
exercices_status: exercicesStatus,
modele,
modele_status: modeleStatus,
},
};
}
/**
* FTD-21 autosave du contenu d'une simulation en cours.
* Refuse si la simulation est déjà corrigée (rapport !== null).
*/
export async function autosaveContenu(
id: string,
userId: string,
contenu: string,
): Promise<{ data: { ok: true } } | ControllerError> {
if (contenu.length > 5000) {
return {
error: true,
code: "VALIDATION_ERROR",
message: "Le texte ne doit pas dépasser 5 000 caractères.",
status: 400,
};
}
const { data: prod, error } = await supabase
.from("productions")
.select("user_id, rapport")
.eq("id", id)
.single();
if (error || !prod) {
return {
error: true,
code: "SIMULATION_NOT_FOUND",
message: "Simulation introuvable.",
status: 404,
};
}
if (prod.user_id !== userId) {
return {
error: true,
code: "AUTH_REQUIRED",
message: "Cette simulation ne vous appartient pas.",
status: 401,
};
}
if (prod.rapport !== null) {
return {
error: true,
code: "VALIDATION_ERROR",
message: "Cette simulation a déjà été corrigée.",
status: 400,
};
}
const { error: updateError } = await supabase
.from("productions")
.update({ contenu })
.eq("id", id);
if (updateError) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Sauvegarde impossible. Réessayez dans quelques instants.",
status: 500,
};
}
return { data: { ok: true } };
}
/**
* FTD-21 met à jour le sujet d'une simulation en cours.
* Vérifie que le sujet existe et que la simulation n'est pas corrigée.
*/
export async function updateSujet(
id: string,
userId: string,
sujetId: string,
): Promise<{ data: { sujet: SujetData } } | ControllerError> {
const { data: sujetRow, error: sujetError } = await supabase
.from("sujets")
.select(
"id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte",
)
.eq("id", sujetId)
.single();
if (sujetError || !sujetRow) {
return {
error: true,
code: "SUJET_NOT_FOUND",
message: "Sujet introuvable.",
status: 404,
};
}
const { data: prod, error } = await supabase
.from("productions")
.select("user_id, rapport")
.eq("id", id)
.single();
if (error || !prod) {
return {
error: true,
code: "SIMULATION_NOT_FOUND",
message: "Simulation introuvable.",
status: 404,
};
}
if (prod.user_id !== userId) {
return {
error: true,
code: "AUTH_REQUIRED",
message: "Cette simulation ne vous appartient pas.",
status: 401,
};
}
if (prod.rapport !== null) {
return {
error: true,
code: "VALIDATION_ERROR",
message: "Cette simulation a déjà été corrigée.",
status: 400,
};
}
const { error: updateError } = await supabase
.from("productions")
.update({ sujet_id: sujetId })
.eq("id", id);
if (updateError) {
return {
error: true,
code: "INTERNAL_ERROR",
message: "Mise à jour impossible. Réessayez dans quelques instants.",
status: 500,
};
}
return { data: { sujet: sujetRow as SujetData } };
// 3. Incrémenter simulations_used si le plan a une limite (via access.ts — Règle D)
const perms = getPlanPermissions(profile.plan as Plan)
if (perms.simulations_lifetime !== null) {
await supabase
.from('profiles')
.update({ simulations_used: profile.simulations_used + 1 })
.eq('id', profile.id)
}
return { data: data as CreateResult }
}

View file

@ -1,77 +1,22 @@
import "dotenv/config";
import { Hono } from "hono";
import { cors } from "hono/cors";
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 sujetsRoutes from "./routes/sujets.js";
import correctionsRoutes from "./routes/corrections.js";
import presentationsRoutes from "./routes/presentations.js";
import transcriptionsRoutes from "./routes/transcriptions.js";
import stripeRoutes from "./routes/stripe.js";
import createT2LiveRoutes from "./routes/t2live.js";
import createT1LiveRoutes from "./routes/t1live.js";
import usersRoutes from "./routes/users.js";
import { supabase } from "./lib/supabase.js";
import 'dotenv/config'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import authRoutes from './routes/auth'
import plansRoutes from './routes/plans'
import simulationsRoutes from './routes/simulations'
const app = new Hono();
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
const app = new Hono()
app.use(
"*",
cors({
origin: [
"https://expria.app",
"http://localhost:5173",
"http://localhost:5174",
],
allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization", "X-Api-Version"],
}),
);
app.get('/', (c) => {
return c.json({ message: 'Expria API — OK' }, 200)
})
// Health check — exécute un SELECT 1 léger sur Supabase pour garder le pool
// de connexions DB actif (ping UptimeRobot toutes les 5 min sur Render Starter).
// Le endpoint retourne toujours 200 (liveness) ; le champ `db` reporte l'état
// réel de la connexion pour observabilité.
const HEALTH_DB_TIMEOUT_MS = 5000;
app.route('/auth', authRoutes)
app.route('/plans', plansRoutes)
app.route('/simulations', simulationsRoutes)
app.get("/", async (c) => {
const probe = supabase.from("profiles").select("id", { head: true }).limit(1);
const timeout = new Promise<{ error: Error }>((resolve) =>
setTimeout(
() => resolve({ error: new Error("HEALTH_DB_TIMEOUT") }),
HEALTH_DB_TIMEOUT_MS,
),
);
const port = Number(process.env.PORT) || 3000
try {
const result = await Promise.race([probe, timeout]);
const db = "error" in result && result.error ? "error" : "connected";
return c.json({ message: "Expria API — OK", db }, 200);
} catch {
return c.json({ message: "Expria API — OK", db: "error" }, 200);
}
});
app.route("/auth", authRoutes);
app.route("/plans", plansRoutes);
app.route("/simulations", simulationsRoutes);
app.route("/sujets", sujetsRoutes);
app.route("/corrections", correctionsRoutes);
app.route("/presentations", presentationsRoutes);
app.route("/transcriptions", transcriptionsRoutes);
app.route("/stripe", stripeRoutes);
app.route("/t2", createT2LiveRoutes(upgradeWebSocket));
app.route("/t1", createT1LiveRoutes(upgradeWebSocket));
app.route("/users", usersRoutes);
const port = Number(process.env.PORT) || 3000;
const server = serve({ fetch: app.fetch, port }, () => {
console.log(`Expria API listening on port ${port}`);
});
injectWebSocket(server);
serve({ fetch: app.fetch, port }, () => {
console.log(`Expria API listening on port ${port}`)
})

View file

@ -1,69 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const portalCreateMock = vi.fn();
vi.mock("stripe", () => ({
default: vi.fn(() => ({
billingPortal: {
sessions: {
create: portalCreateMock,
},
},
})),
}));
import { createBillingPortalSession } from "../stripe";
describe("createBillingPortalSession", () => {
beforeEach(() => {
portalCreateMock.mockReset();
});
it("retourne l'URL de la billing portal session", async () => {
portalCreateMock.mockResolvedValue({
url: "https://billing.stripe.com/p/session/abc123",
});
const result = await createBillingPortalSession({
customerId: "cus_abc",
returnUrl: "https://expria.app/dashboard",
});
expect(result.url).toBe("https://billing.stripe.com/p/session/abc123");
expect(portalCreateMock).toHaveBeenCalledWith({
customer: "cus_abc",
return_url: "https://expria.app/dashboard",
});
});
it("throw si customerId vide", async () => {
await expect(
createBillingPortalSession({
customerId: "",
returnUrl: "https://expria.app/dashboard",
}),
).rejects.toThrow("customerId requis");
expect(portalCreateMock).not.toHaveBeenCalled();
});
it("throw si returnUrl vide", async () => {
await expect(
createBillingPortalSession({
customerId: "cus_abc",
returnUrl: "",
}),
).rejects.toThrow("returnUrl requis");
expect(portalCreateMock).not.toHaveBeenCalled();
});
it("throw si Stripe ne retourne pas d'URL", async () => {
portalCreateMock.mockResolvedValue({ url: null });
await expect(
createBillingPortalSession({
customerId: "cus_abc",
returnUrl: "https://expria.app/dashboard",
}),
).rejects.toThrow("URL de billing portal");
});
});

View file

@ -1,129 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Capture du dernier appel à sessions.create pour inspection
const sessionsCreateMock = vi.fn();
vi.mock("stripe", () => ({
default: vi.fn(() => ({
checkout: {
sessions: {
create: sessionsCreateMock,
},
},
})),
}));
import { createCheckoutSession } from "../stripe";
describe("createCheckoutSession", () => {
beforeEach(() => {
sessionsCreateMock.mockReset();
process.env.APP_URL = "https://expria.app";
});
it("retourne l'URL de la session Stripe", async () => {
sessionsCreateMock.mockResolvedValue({
url: "https://checkout.stripe.com/pay/cs_test_123",
});
const result = await createCheckoutSession({
userId: "user-abc",
priceId: "price_standard",
planName: "standard",
});
expect(result.url).toBe("https://checkout.stripe.com/pay/cs_test_123");
});
it("passe metadata { userId, planName } à Stripe", async () => {
sessionsCreateMock.mockResolvedValue({
url: "https://checkout.stripe.com/pay/cs_test_123",
});
await createCheckoutSession({
userId: "user-xyz",
priceId: "price_premium",
planName: "premium",
});
const callArg = sessionsCreateMock.mock.calls[0][0];
expect(callArg.metadata).toEqual({
userId: "user-xyz",
planName: "premium",
});
expect(callArg.client_reference_id).toBe("user-xyz");
expect(callArg.mode).toBe("subscription");
expect(callArg.line_items).toEqual([
{ price: "price_premium", quantity: 1 },
]);
});
it("construit success_url et cancel_url depuis APP_URL", async () => {
process.env.APP_URL = "https://app.example.test";
sessionsCreateMock.mockResolvedValue({
url: "https://checkout.stripe.com/pay/cs_x",
});
await createCheckoutSession({
userId: "u1",
priceId: "p1",
planName: "standard",
});
const callArg = sessionsCreateMock.mock.calls[0][0];
expect(callArg.success_url).toBe(
"https://app.example.test/dashboard?upgrade=success",
);
expect(callArg.cancel_url).toBe(
"https://app.example.test/plan?upgrade=cancelled",
);
});
it("rejette si userId est vide", async () => {
await expect(
createCheckoutSession({
userId: "",
priceId: "p1",
planName: "standard",
}),
).rejects.toThrow("userId requis");
});
it("rejette si priceId est vide", async () => {
await expect(
createCheckoutSession({
userId: "u1",
priceId: "",
planName: "standard",
}),
).rejects.toThrow("priceId requis");
});
it("rejette si planName est vide", async () => {
await expect(
createCheckoutSession({ userId: "u1", priceId: "p1", planName: "" }),
).rejects.toThrow("planName requis");
});
it("rejette si APP_URL est absent", async () => {
delete process.env.APP_URL;
await expect(
createCheckoutSession({
userId: "u1",
priceId: "p1",
planName: "standard",
}),
).rejects.toThrow("APP_URL");
});
it("rejette si Stripe ne retourne pas d'URL", async () => {
sessionsCreateMock.mockResolvedValue({ url: null });
await expect(
createCheckoutSession({
userId: "u1",
priceId: "p1",
planName: "standard",
}),
).rejects.toThrow();
});
});

View file

@ -1,628 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CorrectionRapport } from "../deepseek";
// ── Fixture correction — Sprint 3.6a, forme nouvelle ──────────────────
const VALID_RAPPORT = {
score: 14,
nclc: 9,
revelation: {
croyance: "Le candidat pense avoir bien respecté la consigne.",
realite: "Certains éléments de la consigne sont ignorés.",
consequence: "Perte d'un point en adéquation à la tâche.",
},
diagnostic: "Frein principal : pauvreté du lexique et connecteurs répétés.",
criteres: [
{
nom: "Adéquation à la tâche et au registre",
score: 4,
commentaire: "Tâche globalement respectée.",
exemple: "Je vous écris pour demander",
suggestion: "Je sollicite votre attention afin de demander",
astuce: "Varier les formules d'appel.",
},
{
nom: "Cohérence et cohésion du discours",
score: 3,
commentaire: "Connecteurs peu variés.",
exemple: "Et aussi, et puis",
suggestion: "De plus, par ailleurs",
astuce: 'Bannir "et" comme connecteur unique.',
},
{
nom: "Compétence lexicale",
score: 3,
commentaire: "Vocabulaire basique.",
exemple: "faire un travail",
suggestion: "effectuer une mission",
astuce: 'Substituer "faire" par un verbe précis.',
},
{
nom: "Compétence grammaticale",
score: 4,
commentaire: "Accords globalement corrects.",
exemple: "les enfants joue",
suggestion: "les enfants jouent",
astuce: "Vérifier la terminaison verbale au pluriel.",
},
],
conseil_nclc: {
nclc_cible: "NCLC 9",
ecart: "objectif atteint",
action_prioritaire: "Enrichir le lexique par thématique.",
},
erreurs_codes: [
{
code: "accord_sujet_verbe",
critere: "competence_grammaticale",
description: null,
},
{
code: "connecteurs_repetes",
critere: "coherence_cohesion",
description: null,
},
{
code: "vocabulaire_basique",
critere: "competence_lexicale",
description: null,
},
],
} satisfies Omit<CorrectionRapport, "nclc_cible"> & {
erreurs_codes: unknown[];
};
function mockFetchSuccess(payload: unknown) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify(payload) } }],
}),
}),
);
}
// ── correctEE (nouvelle signature) ──────────────────────────────────────
describe("deepseek.correctEE — Sprint 3.6a", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("retourne un rapport avec la nouvelle structure (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)", async () => {
mockFetchSuccess(VALID_RAPPORT);
const { correctEE } = await import("../deepseek");
const rapport = await correctEE({
tache: "EE_T1",
contenu: "Mon texte de test",
sujet: "Écrivez un message",
nclcCible: 9,
});
expect(rapport.score).toBe(14);
expect(rapport.nclc).toBe(9);
expect(rapport.nclc_cible).toBe(9);
expect(rapport.revelation).toMatchObject({
croyance: expect.any(String),
realite: expect.any(String),
consequence: expect.any(String),
});
expect(rapport.diagnostic).toBeTypeOf("string");
expect(rapport.criteres).toHaveLength(4);
expect(rapport.conseil_nclc.nclc_cible).toBe("NCLC 9");
expect(rapport.erreurs_codes.length).toBeGreaterThan(0);
});
it("nclc_cible=10 est propagé dans le rapport", async () => {
mockFetchSuccess({ ...VALID_RAPPORT, score: 18 });
const { correctEE } = await import("../deepseek");
const rapport = await correctEE({
tache: "EE_T1",
contenu: "Texte",
sujet: null,
nclcCible: 10,
});
expect(rapport.nclc_cible).toBe(10);
});
it("score hors bornes → throw", async () => {
mockFetchSuccess({ ...VALID_RAPPORT, score: 25 });
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("Score invalide");
});
it("nclc hors bornes → throw", async () => {
mockFetchSuccess({ ...VALID_RAPPORT, nclc: 2 });
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("NCLC invalide");
});
it("revelation absente → throw", async () => {
const bad = { ...VALID_RAPPORT, revelation: undefined };
mockFetchSuccess(bad);
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("revelation invalide");
});
it("diagnostic vide → throw", async () => {
mockFetchSuccess({ ...VALID_RAPPORT, diagnostic: " " });
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("diagnostic invalide");
});
it("criteres doit avoir exactement 4 entrées", async () => {
mockFetchSuccess({
...VALID_RAPPORT,
criteres: VALID_RAPPORT.criteres.slice(0, 3),
});
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("criteres invalide");
});
it("erreurs_codes : codes hors taxonomie sont filtrés", async () => {
const bad = {
...VALID_RAPPORT,
erreurs_codes: [
{
code: "code_inexistant_xyz",
critere: "competence_grammaticale",
description: null,
},
{
code: "accord_sujet_verbe",
critere: "competence_grammaticale",
description: null,
},
],
};
mockFetchSuccess(bad);
const { correctEE } = await import("../deepseek");
const rapport = await correctEE({
tache: "EE_T1",
contenu: "T",
sujet: null,
nclcCible: 9,
});
expect(rapport.erreurs_codes).toHaveLength(1);
expect(rapport.erreurs_codes[0]?.code).toBe("accord_sujet_verbe");
});
it('erreurs_codes : code "autre" sans description est rejeté', async () => {
const bad = {
...VALID_RAPPORT,
erreurs_codes: [
{ code: "autre", critere: "coherence_cohesion", description: null },
{
code: "autre",
critere: "coherence_cohesion",
description: "erreur spécifique",
},
],
};
mockFetchSuccess(bad);
const { correctEE } = await import("../deepseek");
const rapport = await correctEE({
tache: "EE_T1",
contenu: "T",
sujet: null,
nclcCible: 9,
});
expect(rapport.erreurs_codes).toHaveLength(1);
expect(rapport.erreurs_codes[0]).toMatchObject({
code: "autre",
description: "erreur spécifique",
});
});
it("critère inconnu → entrée filtrée", async () => {
const bad = {
...VALID_RAPPORT,
erreurs_codes: [
{
code: "accord_sujet_verbe",
critere: "critere_inventé",
description: null,
},
],
};
mockFetchSuccess(bad);
const { correctEE } = await import("../deepseek");
const rapport = await correctEE({
tache: "EE_T1",
contenu: "T",
sujet: null,
nclcCible: 9,
});
expect(rapport.erreurs_codes).toHaveLength(0);
});
it("erreur HTTP DeepSeek → throw", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue({ ok: false, status: 500, statusText: "Internal" }),
);
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow("DeepSeek API error");
});
it("JSON invalide → throw", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: "pas du json" } }],
}),
}),
);
const { correctEE } = await import("../deepseek");
await expect(
correctEE({ tache: "EE_T1", contenu: "T", sujet: null, nclcCible: 9 }),
).rejects.toThrow();
});
});
// ── generateProductionModele — cible fixe NCLC 9 ───────────────────────
const VALID_MODELE = {
production_modele_propre: "Texte modèle réécrit. ".repeat(10).trim(),
notes_pedagogiques: [
{ passage: "extrait 1", explication: "efficace car…" },
{ passage: "extrait 2", explication: "efficace car…" },
{ passage: "extrait 3", explication: "efficace car…" },
],
transformations: [
{ original: "je fais", ameliore: "j'effectue", explication: "plus précis" },
],
message: "Vos idées sont solides, continuez.",
};
describe("deepseek.generateProductionModele", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("renvoie métadonnées avec nclc_modele=9 (fixe)", async () => {
mockFetchSuccess(VALID_MODELE);
const { generateProductionModele } = await import("../deepseek");
const result = await generateProductionModele({
tache: "EE_T2",
sujet: "Un article de blog",
texte: "production du candidat",
nclcObtenu: 7,
});
expect(result.nclc_modele).toBe(9);
expect(result.nclc_obtenu).toBe(7);
expect(result.score_cible).toBe(14);
expect(result.tcf_word_min).toBe(120);
expect(result.tcf_word_max).toBe(150);
});
it("tronque à max mots et renseigne tcf_truncated=true", async () => {
const longText = "mot ".repeat(200).trim(); // 200 mots
mockFetchSuccess({ ...VALID_MODELE, production_modele_propre: longText });
const { generateProductionModele } = await import("../deepseek");
const result = await generateProductionModele({
tache: "EE_T1", // max 120
sujet: null,
texte: "production",
nclcObtenu: 8,
});
expect(result.tcf_truncated).toBe(true);
expect(result.tcf_word_count).toBe(120);
});
it("supprime les annotations [NOTE: ...] de production_modele_propre", async () => {
mockFetchSuccess({
...VALID_MODELE,
production_modele_propre:
"Bonjour [NOTE: salutation formelle] je vous écris.",
});
const { generateProductionModele } = await import("../deepseek");
const result = await generateProductionModele({
tache: "EE_T1",
sujet: null,
texte: "p",
nclcObtenu: 8,
});
expect(result.production_modele_propre).not.toContain("[NOTE:");
expect(result.production_modele_propre).toContain("Bonjour");
});
});
// ── generateExercices ───────────────────────────────────────────────────
describe("deepseek.generateExercices", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("renvoie une liste d'exercices avec le format attendu", async () => {
mockFetchSuccess({
exercices: [
{
difficulte: "facile",
theme: "accord_sujet_verbe",
diagnostic: "Erreurs d'accord verbe-sujet.",
consigne: "Corrigez les accords.",
extrait: "les enfants joue",
indice: "Pluriel du sujet ?",
correction: "les enfants jouent",
explication: "Le verbe s'accorde en nombre avec le sujet.",
},
{
difficulte: "intermediaire",
theme: "connecteurs_repetes",
diagnostic: "Même connecteur répété.",
consigne: "Variez les connecteurs.",
extrait: "Et puis et aussi",
indice: 'Synonymes de "et" ?',
correction: "De plus, par ailleurs",
explication:
"Varier lexicalement les connecteurs améliore la cohésion.",
},
{
difficulte: "difficile",
theme: "vocabulaire_basique",
diagnostic: 'Verbe "faire" imprécis.',
consigne: 'Remplacez "faire" par un verbe précis.',
extrait: "faire un travail",
indice: "Un verbe de réalisation ?",
correction: "effectuer une mission",
explication: '"Effectuer" précise l\'action.',
},
],
});
const { generateExercices } = await import("../deepseek");
const exercices = await generateExercices({
tache: "EE_T1",
erreursCodes: VALID_RAPPORT.erreurs_codes as never,
criteres: VALID_RAPPORT.criteres,
});
expect(exercices).toHaveLength(3);
expect(exercices[0]).toMatchObject({
difficulte: "facile",
theme: "accord_sujet_verbe",
consigne: expect.any(String),
correction: expect.any(String),
});
});
it('difficulte inconnue → fallback "intermediaire"', async () => {
mockFetchSuccess({
exercices: [
{
difficulte: "epique",
theme: "t",
consigne: "c",
correction: "r",
},
],
});
const { generateExercices } = await import("../deepseek");
const exercices = await generateExercices({
tache: "EE_T1",
erreursCodes: [],
criteres: [],
});
expect(exercices[0]?.difficulte).toBe("intermediaire");
});
it("exercices sans consigne/correction sont filtrés", async () => {
mockFetchSuccess({
exercices: [
{ difficulte: "facile", theme: "t" }, // manque consigne + correction
{ difficulte: "facile", theme: "t", consigne: "c", correction: "r" },
],
});
const { generateExercices } = await import("../deepseek");
const exercices = await generateExercices({
tache: "EE_T1",
erreursCodes: [],
criteres: [],
});
expect(exercices).toHaveLength(1);
});
});
// ── EO — Sprint 4a : aligné sur le format 3.6a ─────────────────────────
const VALID_RAPPORT_EO = {
score: 14,
nclc: 9,
revelation: {
croyance: "Le candidat pense parler avec fluidité.",
realite: "Le discours présente plusieurs ruptures et hésitations marquées.",
consequence: "Perte d'un point en cohérence et fluidité.",
},
diagnostic: "Frein principal : ruptures discursives et lexique répétitif.",
transcription_affichee:
"Bonjour, je vais me présenter. Je m'appelle Pierre. Je travaille comme ingénieur.",
criteres: [
{
nom: "Adéquation à la tâche",
score: 4,
commentaire: "Tâche globalement respectée.",
exemple: "Je vais me présenter",
suggestion: "Permettez-moi de me présenter",
astuce: "Soigner les ouvertures.",
},
{
nom: "Cohérence et cohésion",
score: 3,
commentaire: "Ruptures fréquentes.",
exemple: "euh euh",
suggestion: "Marquer une pause silencieuse",
astuce: "Limiter les hésitations vocalisées.",
},
{
nom: "Étendue et maîtrise du lexique",
score: 3,
commentaire: "Vocabulaire basique.",
exemple: "mon travail",
suggestion: "mon métier / ma profession",
astuce: "Varier les mots du même champ.",
},
{
nom: "Maîtrise morphosyntaxique",
score: 4,
commentaire: "Accords globalement corrects.",
exemple: "les gens travaille",
suggestion: "les gens travaillent",
astuce: "Vérifier la terminaison verbale au pluriel.",
},
],
conseil_nclc: {
nclc_cible: "NCLC 9",
ecart: "objectif atteint",
action_prioritaire:
"Réduire les hésitations en préparant un fil narratif court.",
},
erreurs_codes: [
{
code: "connecteurs_repetes",
critere: "coherence_cohesion",
description: null,
},
{
code: "vocabulaire_basique",
critere: "competence_lexicale",
description: null,
},
],
};
describe("deepseek.correctEO", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("retourne un rapport EO aligné sur CorrectionRapport (3.6a) + champs EO", async () => {
mockFetchSuccess(VALID_RAPPORT_EO);
const { correctEO } = await import("../deepseek");
const rapport = await correctEO("transcription brute", "EO_T1", 9);
expect(rapport.score).toBe(14);
expect(rapport.nclc_cible).toBe(9);
expect(rapport.diagnostic).toBeDefined();
expect(rapport.criteres).toHaveLength(4);
expect(rapport.transcription_affichee).toContain("Bonjour");
// Sprint 4.8 : `note_phonologie` est retiré ; la phonologie est désormais
// un 5e critère injecté par le controller (pas par DeepSeek).
expect(rapport.note_phonologie).toBeUndefined();
expect(rapport.erreurs_codes.length).toBeGreaterThan(0);
});
it("cap score critère à 4 et recalcule le total textuel", async () => {
// Sprint 4.8 : DeepSeek déclare score=10 mais sort 7 sur le 1er critère
// (>4). On vérifie que (a) chaque critère est cappé à 4 et (b) le total
// textuel est recalculé sur la somme des critères cappés (4+4+3+4=15),
// pas sur le score déclaré. La phonologie /4 sera ajoutée par le controller.
mockFetchSuccess({
...VALID_RAPPORT_EO,
score: 10,
criteres: [
{ ...VALID_RAPPORT_EO.criteres[0], score: 7 },
{ ...VALID_RAPPORT_EO.criteres[1], score: 4 },
{ ...VALID_RAPPORT_EO.criteres[2], score: 3 },
{ ...VALID_RAPPORT_EO.criteres[3], score: 4 },
],
});
const { correctEO } = await import("../deepseek");
const rapport = await correctEO("t", "EO_T1", 9);
expect(rapport.criteres.every((c) => c.score <= 4)).toBe(true);
// 4 (cappé) + 4 + 3 + 4 = 15 (et non 99)
expect(rapport.score).toBe(15);
});
it("transcription_affichee absente → fallback sur le transcript brut", async () => {
const { transcription_affichee, ...withoutTranscription } =
VALID_RAPPORT_EO;
void transcription_affichee;
mockFetchSuccess(withoutTranscription);
const { correctEO } = await import("../deepseek");
const rapport = await correctEO("TRANSCRIPT BRUT FALLBACK", "EO_T1", 9);
expect(rapport.transcription_affichee).toBe("TRANSCRIPT BRUT FALLBACK");
});
it("nclc hors bornes → throw", async () => {
mockFetchSuccess({ ...VALID_RAPPORT_EO, nclc: 2 });
const { correctEO } = await import("../deepseek");
await expect(correctEO("t", "EO_T1", 9)).rejects.toThrow("NCLC invalide");
});
it("HTTP error → throw", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "I" }),
);
const { correctEO } = await import("../deepseek");
await expect(correctEO("t", "EO_T1", 9)).rejects.toThrow(
"DeepSeek API error",
);
});
});
// ── Post-traitement unitaire ────────────────────────────────────────────
describe("deepseek — helpers de post-traitement", () => {
it("wordCountTCF : apostrophes et tirets ne créent pas de mot", async () => {
const { wordCountTCF } = await import("../deepseek");
expect(wordCountTCF("c'est")).toBe(1);
expect(wordCountTCF("aujourd'hui")).toBe(1);
expect(wordCountTCF("c'est-à-dire")).toBe(1);
expect(wordCountTCF("il va bien")).toBe(3);
expect(wordCountTCF("")).toBe(0);
});
it("stripModelAnnotations retire [NOTE:…]", async () => {
const { stripModelAnnotations } = await import("../deepseek");
expect(stripModelAnnotations("Bonjour [NOTE: formel] Madame")).toBe(
"Bonjour Madame",
);
});
it("truncateToMaxWords tronque au-delà du seuil", async () => {
const { truncateToMaxWords } = await import("../deepseek");
const { text, truncated } = truncateToMaxWords("a b c d e f", 3);
expect(text).toBe("a b c");
expect(truncated).toBe(true);
});
});

View file

@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
function mockFetchSuccess(text: string) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [{ content: { parts: [{ text }] } }],
}),
})
)
}
describe('gemini.transcribeAudio', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('retourne une transcription non vide sur succes', async () => {
mockFetchSuccess('Bonjour, je suis candidat au TCF Canada.')
const { transcribeAudio } = await import('../gemini')
const result = await transcribeAudio('base64audio', 'audio/webm')
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
expect(result).toBe('Bonjour, je suis candidat au TCF Canada.')
})
it('erreur HTTP depuis Gemini API', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
})
)
const { transcribeAudio } = await import('../gemini')
await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow(
'Gemini API error'
)
})
it('erreur si transcription vide', async () => {
mockFetchSuccess('')
const { transcribeAudio } = await import('../gemini')
await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow(
'Gemini API: transcription vide'
)
})
it('erreur si reponse sans candidates', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ candidates: [] }),
})
)
const { transcribeAudio } = await import('../gemini')
await expect(transcribeAudio('base64audio', 'audio/webm')).rejects.toThrow(
'Gemini API: transcription vide'
)
})
})

View file

@ -1,307 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import { SocksProxyAgent } from "socks-proxy-agent";
import {
openGeminiLiveSession,
buildT2SystemPrompt,
resolveGeminiProxyAgent,
GEMINI_LIVE_MODEL,
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;
}
}
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 incarnes un bailleur qui propose un appartement à louer",
);
expect(prompt).toContain("Vous cherchez un appartement");
expect(prompt).toContain("français naturel et courant");
expect(prompt).toContain("Tu ne prends PAS la parole en premier");
expect(prompt).toContain("15 à 25 mots maximum");
});
});
describe("resolveGeminiProxyAgent", () => {
let originalProxy: string | undefined;
beforeEach(() => {
originalProxy = process.env.GEMINI_PROXY_URL;
});
afterEach(() => {
if (originalProxy === undefined) {
delete process.env.GEMINI_PROXY_URL;
} else {
process.env.GEMINI_PROXY_URL = originalProxy;
}
});
it("retourne undefined quand GEMINI_PROXY_URL est absente (connexion directe)", () => {
delete process.env.GEMINI_PROXY_URL;
expect(resolveGeminiProxyAgent()).toBeUndefined();
});
it("retourne un SocksProxyAgent quand GEMINI_PROXY_URL est définie", () => {
process.env.GEMINI_PROXY_URL = "socks5://127.0.0.1:40000";
expect(resolveGeminiProxyAgent()).toBeInstanceOf(SocksProxyAgent);
});
});
describe("openGeminiLiveSession (raw WS)", () => {
let originalKey: string | undefined;
beforeEach(() => {
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;
} else {
process.env.GEMINI_API_KEY = originalKey;
}
vi.useRealTimers();
vi.restoreAllMocks();
});
it("envoie le setup frame avec model + responseModalities AUDIO + systemInstruction", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
expect(gemini.sent).toHaveLength(1);
const setup = JSON.parse(gemini.sent[0] as string);
expect(setup.setup.model).toBe(`models/${GEMINI_LIVE_MODEL}`);
expect(setup.setup.generationConfig.responseModalities).toContain("AUDIO");
expect(setup.setup.systemInstruction.parts[0].text).toContain(
"un bailleur qui propose un appartement",
);
expect(setup.setup.inputAudioTranscription).toEqual({});
expect(setup.setup.outputAudioTranscription).toEqual({});
// VAD réintégré (Sprint 6d Bug 2) — cf. IMPLEMENTATION_T2_LIVE.md §3 step 7.
expect(setup.setup.realtimeInputConfig.automaticActivityDetection).toEqual({
disabled: false,
startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
silenceDurationMs: 2000,
});
});
it("forwarde un chunk audio client {type:'audio'} en realtimeInput vers Gemini", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
const base64 = "AQIDBA==";
client.emit("message", JSON.stringify({ type: "audio", data: base64 }));
// [0] = setup frame, [1] = realtimeInput audio
expect(gemini.sent).toHaveLength(2);
const audioFrame = JSON.parse(gemini.sent[1] as string);
expect(audioFrame).toEqual({
realtimeInput: {
audio: { data: base64, mimeType: "audio/pcm;rate=16000" },
},
});
});
it("forwarde un message Gemini (Buffer audio inlineData) verbatim au client", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
const buf = Buffer.from([0x10, 0x20, 0x30]);
gemini.emit("message", buf);
expect(client.sent).toHaveLength(1);
expect(client.sent[0]).toBe(buf);
});
it("accumule input/outputTranscription depuis les messages JSON Gemini", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
gemini.emit(
"message",
JSON.stringify({
serverContent: {
inputTranscription: { text: "Bonjour, je voudrais louer." },
},
}),
);
gemini.emit(
"message",
JSON.stringify({
serverContent: {
outputTranscription: { text: "Bonjour, cest pour quel quartier ?" },
},
}),
);
gemini.emit(
"message",
JSON.stringify({
serverContent: { inputTranscription: { text: "Le centre-ville." } },
}),
);
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledTimes(1);
expect(onSessionEnd.mock.calls[0][0]).toBe(
"Candidat : Bonjour, je voudrais louer.\nExaminateur : Bonjour, cest pour quel quartier ?\nCandidat : Le centre-ville.",
);
});
it("ferme Gemini après onSessionEnd, sans fermer le client", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(gemini.closed).toBe(true);
expect(gemini.closeCode).toBe(1000);
expect(client.closed).toBe(false);
});
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,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
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();
await vi.advanceTimersByTimeAsync(30_000);
expect(onSessionEnd).toHaveBeenCalledTimes(1);
expect(gemini.closed).toBe(true);
});
it("signal end client est idempotent (un seul onSessionEnd)", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
onSessionEnd,
});
gemini.emit("open");
client.emit("message", JSON.stringify({ type: "end" }));
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledTimes(1);
});
it("close Gemini avant fin → close client 4006 GEMINI_DISCONNECTED", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => gemini,
});
gemini.emit("open");
gemini.emit("close", 1006, Buffer.from(""));
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4006);
expect(client.closeReason).toBe("GEMINI_DISCONNECTED");
});
it("error Gemini → close client 4006", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveSession(client, {
...SUJET_OPTS,
clientFactory: () => 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, clientFactory: factory });
expect(factory).not.toHaveBeenCalled();
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4005);
expect(client.closeReason).toBe("GEMINI_CONFIG");
});
});

View file

@ -1,300 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import {
buildT1SystemPrompt,
openGeminiLiveT1Session,
drawT1InterruptionCount,
planT1InterruptionInstants,
T1_INTERRUPTION_WINDOW_START_MS,
T1_INTERRUPTION_WINDOW_END_MS,
T1_INTERRUPTION_MIN_SPACING_MS,
} from "../geminiLiveT1";
import 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;
}
}
/** random() déterministe : renvoie chaque valeur de la liste à tour de rôle. */
function seqRandom(values: number[]): () => number {
let i = 0;
return () => values[i++ % values.length];
}
/** Helper : signaux {type:'...'} reçus côté client (hors forwards verbatim). */
function clientSignals(client: FakeWs): { type: string }[] {
return client.sent
.filter((f): f is string => typeof f === "string")
.map((f) => {
try {
return JSON.parse(f) as { type?: string };
} catch {
return {};
}
})
.filter((o): o is { type: string } => typeof o.type === "string");
}
describe("buildT1SystemPrompt", () => {
it("définit un examinateur qui relance le candidat par une question", () => {
const prompt = buildT1SystemPrompt();
expect(prompt).toContain("examinateur");
expect(prompt.toLowerCase()).toContain("relanc");
expect(prompt.toLowerCase()).toContain("question");
});
it("instruit l'examinateur d'écouter le candidat (plus de questionnaire pré-rempli)", () => {
const prompt = buildT1SystemPrompt();
expect(prompt).toContain("Écoute attentivement");
// Plus aucune section « CONTEXTE DU CANDIDAT » ni variable substituée.
expect(prompt).not.toContain("CONTEXTE DU CANDIDAT");
});
it("AUTORISE les questions — ne propage PAS la règle 7 du T2", () => {
const prompt = buildT1SystemPrompt();
const upper = prompt.toUpperCase();
// La règle 7 T2 interdit les questions et bannit le point d'interrogation.
expect(upper).not.toContain("INTERDICTION DE POSER DES QUESTIONS");
expect(prompt).not.toContain(
"pas le droit d'utiliser de point d'interrogation",
);
// Au contraire, l'examinateur T1 DOIT poser des questions.
expect(prompt).toContain("DOIS poser des questions");
});
});
describe("drawT1InterruptionCount (tirage déterministe)", () => {
it("suit la distribution P0/P1/P2 selon le random injecté", () => {
expect(drawT1InterruptionCount(() => 0.1)).toBe(0);
expect(drawT1InterruptionCount(() => 0.5)).toBe(1);
expect(drawT1InterruptionCount(() => 0.9)).toBe(2);
});
it("gère correctement les bornes de la distribution", () => {
expect(drawT1InterruptionCount(() => 0.0)).toBe(0); // < 0.2
expect(drawT1InterruptionCount(() => 0.2)).toBe(1); // ≥ 0.2, < 0.8
expect(drawT1InterruptionCount(() => 0.8)).toBe(2); // ≥ 0.8
});
});
describe("planT1InterruptionInstants", () => {
it("ne planifie rien quand count = 0", () => {
expect(planT1InterruptionInstants(0, () => 0.5)).toEqual([]);
});
it("place 1 interruption dans la fenêtre [START, END]", () => {
expect(planT1InterruptionInstants(1, () => 0)).toEqual([
T1_INTERRUPTION_WINDOW_START_MS,
]);
const [instant] = planT1InterruptionInstants(1, () => 0.5);
expect(instant).toBeGreaterThanOrEqual(T1_INTERRUPTION_WINDOW_START_MS);
expect(instant).toBeLessThanOrEqual(T1_INTERRUPTION_WINDOW_END_MS);
});
it("place 2 interruptions espacées d'au moins MIN_SPACING, dans la fenêtre", () => {
for (const r of [0, 0.3, 0.7, 1]) {
const [a, b] = planT1InterruptionInstants(2, () => r);
expect(a).toBeGreaterThanOrEqual(T1_INTERRUPTION_WINDOW_START_MS);
expect(b).toBeLessThanOrEqual(T1_INTERRUPTION_WINDOW_END_MS);
expect(b - a).toBeGreaterThanOrEqual(T1_INTERRUPTION_MIN_SPACING_MS);
}
});
});
const SETUP_COMPLETE = JSON.stringify({ setupComplete: {} });
describe("openGeminiLiveT1Session (raw WS, VAD manuel)", () => {
let originalKey: string | undefined;
beforeEach(() => {
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;
} else {
process.env.GEMINI_API_KEY = originalKey;
}
vi.useRealTimers();
vi.restoreAllMocks();
});
it("envoie le setup frame en VAD manuel (disabled:true)", () => {
const client = new FakeWs();
const gemini = new FakeWs();
openGeminiLiveT1Session(client, {
clientFactory: () => gemini,
random: seqRandom([0.1]),
});
gemini.emit("open");
const setup = JSON.parse(gemini.sent[0] as string);
expect(setup.setup.realtimeInputConfig.automaticActivityDetection).toEqual({
disabled: true,
});
expect(setup.setup.systemInstruction.parts[0].text).toContain(
"examinateur",
);
});
it("injecte la relance à l'instant planifié + émet interruption_start/end et la séquence activityEnd→clientContent→activityStart", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
// drawCount(0.5)=1 ; planInstants(1, 0)=START_MS.
openGeminiLiveT1Session(client, {
clientFactory: () => gemini,
random: seqRandom([0.5, 0]),
});
gemini.emit("open");
gemini.emit("message", SETUP_COMPLETE);
// [0]=setup, [1]=activityStart (ouverture du 1er tour candidat).
expect(gemini.sent).toHaveLength(2);
expect(JSON.parse(gemini.sent[1] as string)).toEqual({
realtimeInput: { activityStart: {} },
});
// Rien ne se passe avant l'instant planifié.
await vi.advanceTimersByTimeAsync(T1_INTERRUPTION_WINDOW_START_MS - 1);
expect(
clientSignals(client).some((s) => s.type === "interruption_start"),
).toBe(false);
// À l'instant planifié : injection.
await vi.advanceTimersByTimeAsync(1);
expect(
clientSignals(client).some((s) => s.type === "interruption_start"),
).toBe(true);
// [2]=activityEnd, [3]=clientContent(relance, turnComplete).
expect(JSON.parse(gemini.sent[2] as string)).toEqual({
realtimeInput: { activityEnd: {} },
});
const relance = JSON.parse(gemini.sent[3] as string);
expect(relance.clientContent.turnComplete).toBe(true);
expect(relance.clientContent.turns[0].role).toBe("user");
// Gemini termine la relance → reprise candidat.
gemini.emit(
"message",
JSON.stringify({ serverContent: { turnComplete: true } }),
);
expect(
clientSignals(client).some((s) => s.type === "interruption_end"),
).toBe(true);
// [4]=activityStart (réouverture du tour candidat).
expect(JSON.parse(gemini.sent[4] as string)).toEqual({
realtimeInput: { activityStart: {} },
});
});
it("FIN : envoie l'activityEnd final, garde le texte candidat final, coupe l'audio + le texte de la relance terminale", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
// count=0 : aucune interruption programmée, on teste juste le flush terminal.
openGeminiLiveT1Session(client, {
clientFactory: () => gemini,
random: seqRandom([0.1]),
onSessionEnd,
});
gemini.emit("open");
gemini.emit("message", SETUP_COMPLETE);
// Le candidat parle (message normal, forwardé verbatim).
gemini.emit(
"message",
JSON.stringify({
serverContent: {
inputTranscription: { text: "Je m'appelle Hermann." },
},
}),
);
// Fin de session demandée par le client.
client.emit("message", JSON.stringify({ type: "end" }));
// activityEnd FINAL envoyé pour flusher le dernier segment candidat.
const lastFrame = JSON.parse(gemini.sent[gemini.sent.length - 1] as string);
expect(lastFrame).toEqual({ realtimeInput: { activityEnd: {} } });
// POINT DE VIGILANCE : un SEUL message Gemini pendant le flush terminal
// contient À LA FOIS le texte candidat final (à GARDER) ET la relance
// terminale examinateur — audio + texte (à COUPER).
const clientSentBefore = client.sent.length;
gemini.emit(
"message",
JSON.stringify({
serverContent: {
inputTranscription: { text: " ma ville préférée." },
outputTranscription: { text: "Quelle est votre ville préférée ?" },
modelTurn: {
parts: [{ inlineData: { data: "AAAA", mimeType: "audio/pcm" } }],
},
},
}),
);
// Ce message n'est PAS forwardé au client (audio relance terminale coupé).
expect(client.sent.length).toBe(clientSentBefore);
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledTimes(1);
const transcript = onSessionEnd.mock.calls[0][0] as string;
// Texte candidat final BIEN conservé.
expect(transcript).toContain("Je m'appelle Hermann.");
expect(transcript).toContain("ma ville préférée.");
// Texte de la relance terminale examinateur JETÉ.
expect(transcript).not.toContain("Quelle est votre ville préférée ?");
});
it("idempotence de end : un double signal end ne casse pas (un seul onSessionEnd)", async () => {
const client = new FakeWs();
const gemini = new FakeWs();
const onSessionEnd = vi.fn();
openGeminiLiveT1Session(client, {
clientFactory: () => gemini,
random: seqRandom([0.1]),
onSessionEnd,
});
gemini.emit("open");
gemini.emit("message", SETUP_COMPLETE);
client.emit("message", JSON.stringify({ type: "end" }));
client.emit("message", JSON.stringify({ type: "end" }));
await vi.runAllTimersAsync();
expect(onSessionEnd).toHaveBeenCalledTimes(1);
});
it("absence de GEMINI_API_KEY → close client 4005 GEMINI_CONFIG sans factory", () => {
delete process.env.GEMINI_API_KEY;
const client = new FakeWs();
const factory = vi.fn(() => new FakeWs());
openGeminiLiveT1Session(client, {
clientFactory: factory,
});
expect(factory).not.toHaveBeenCalled();
expect(client.closed).toBe(true);
expect(client.closeCode).toBe(4005);
expect(client.closeReason).toBe("GEMINI_CONFIG");
});
});

View file

@ -1,123 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
function mockFetchSuccess(jsonText: string) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
candidates: [{ content: { parts: [{ text: jsonText }] } }],
}),
}),
);
}
const VALID_PAYLOAD = JSON.stringify({
score: 3,
commentaire:
"Prononciation globalement claire avec quelques liaisons manquées.",
exemple: "les amis",
suggestion: "Réaliser la liaison _les_amis_.",
astuce: "S'entraîner sur 5 paires liaison/non-liaison.",
});
describe("geminiPhonology.evaluatePhonology", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
it("retourne un PhonologyResult valide sur succès", async () => {
mockFetchSuccess(VALID_PAYLOAD);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
expect(result.commentaire).toMatch(/Prononciation/);
expect(result.exemple).toBe("les amis");
expect(result.suggestion).toMatch(/liaison/);
expect(result.astuce).toMatch(/entraîner/);
});
it("cap le score à 4 si Gemini renvoie 5+", async () => {
mockFetchSuccess(
JSON.stringify({ score: 7, commentaire: "Score sur-évalué." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(4);
});
it("ramène le score à 0 si Gemini renvoie négatif", async () => {
mockFetchSuccess(
JSON.stringify({ score: -2, commentaire: "Score négatif." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(0);
});
it("arrondit un score décimal", async () => {
mockFetchSuccess(
JSON.stringify({ score: 2.7, commentaire: "Score décimal." }),
);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
});
it("rejette si la réponse n'est pas du JSON", async () => {
mockFetchSuccess("ceci n'est pas du JSON");
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/non-JSON/);
});
it("rejette si le commentaire est manquant", async () => {
mockFetchSuccess(JSON.stringify({ score: 3 }));
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/commentaire manquant/);
});
it("rejette sur erreur HTTP applicative (pas de retry)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
});
vi.stubGlobal("fetch", fetchMock);
const { evaluatePhonology } = await import("../geminiPhonology");
await expect(
evaluatePhonology("base64audio", "audio/webm"),
).rejects.toThrow(/Gemini phonology API error/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("réessaie une fois sur TimeoutError et réussit au 2e essai", async () => {
const timeoutErr = Object.assign(new Error("timeout"), {
name: "TimeoutError",
});
const fetchMock = vi
.fn()
.mockRejectedValueOnce(timeoutErr)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
candidates: [{ content: { parts: [{ text: VALID_PAYLOAD }] } }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const { evaluatePhonology } = await import("../geminiPhonology");
const result = await evaluatePhonology("base64audio", "audio/webm");
expect(result.score).toBe(3);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("PHONOLOGY_STUB est un objet exploitable directement", async () => {
const { PHONOLOGY_STUB } = await import("../geminiPhonology");
expect(PHONOLOGY_STUB.score).toBe(0);
expect(PHONOLOGY_STUB.commentaire).toMatch(/audio requis/);
});
});

View file

@ -1,101 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { fromMock, selectMock, eqMock, maybeSingleMock, insertMock } =
vi.hoisted(() => ({
fromMock: vi.fn(),
selectMock: vi.fn(),
eqMock: vi.fn(),
maybeSingleMock: vi.fn(),
insertMock: vi.fn(),
}));
vi.mock("../supabase", () => ({
supabase: { from: fromMock },
}));
beforeEach(() => {
fromMock.mockReset();
selectMock.mockReset();
eqMock.mockReset();
maybeSingleMock.mockReset();
insertMock.mockReset();
fromMock.mockImplementation((table: string) => {
if (table !== "stripe_webhook_events") return {};
return {
select: selectMock,
insert: insertMock,
};
});
selectMock.mockReturnValue({ eq: eqMock });
eqMock.mockReturnValue({ maybeSingle: maybeSingleMock });
});
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("isEventProcessed", () => {
it("retourne true quand l'event est déjà journalisé", async () => {
maybeSingleMock.mockResolvedValue({ data: { id: "evt_123" }, error: null });
const { isEventProcessed } = await import("../stripeWebhookEvents");
const result = await isEventProcessed("evt_123");
expect(result).toBe(true);
expect(eqMock).toHaveBeenCalledWith("id", "evt_123");
});
it("retourne false quand l'event est absent", async () => {
maybeSingleMock.mockResolvedValue({ data: null, error: null });
const { isEventProcessed } = await import("../stripeWebhookEvents");
const result = await isEventProcessed("evt_456");
expect(result).toBe(false);
});
it("retourne false sur erreur de lecture (privilégie disponibilité)", async () => {
maybeSingleMock.mockResolvedValue({
data: null,
error: { message: "DB unreachable" },
});
const { isEventProcessed } = await import("../stripeWebhookEvents");
const result = await isEventProcessed("evt_789");
expect(result).toBe(false);
});
it("retourne false pour un eventId vide sans toucher Supabase", async () => {
const { isEventProcessed } = await import("../stripeWebhookEvents");
const result = await isEventProcessed("");
expect(result).toBe(false);
expect(fromMock).not.toHaveBeenCalled();
});
});
describe("markEventProcessed", () => {
it("insère l'event quand il n'existe pas", async () => {
insertMock.mockResolvedValue({ error: null });
const { markEventProcessed } = await import("../stripeWebhookEvents");
await markEventProcessed("evt_new");
expect(insertMock).toHaveBeenCalledWith({ id: "evt_new" });
});
it("avale silencieusement un conflit unique (livraison concurrente)", async () => {
insertMock.mockResolvedValue({
error: { code: "23505", message: "duplicate key" },
});
const { markEventProcessed } = await import("../stripeWebhookEvents");
await expect(markEventProcessed("evt_dup")).resolves.toBeUndefined();
});
it("ne throw pas sur erreur DB inattendue (webhook doit toujours répondre 200)", async () => {
insertMock.mockResolvedValue({
error: { code: "08006", message: "connection failure" },
});
const { markEventProcessed } = await import("../stripeWebhookEvents");
await expect(markEventProcessed("evt_fail")).resolves.toBeUndefined();
});
it("no-op pour un eventId vide", async () => {
const { markEventProcessed } = await import("../stripeWebhookEvents");
await markEventProcessed("");
expect(insertMock).not.toHaveBeenCalled();
});
});

View file

@ -1,104 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const updateMock = vi.fn()
const eqMock = vi.fn()
const selectMock = vi.fn()
const maybeSingleMock = vi.fn()
vi.mock('../supabase', () => ({
supabase: {
from: vi.fn(() => ({
update: updateMock,
select: selectMock,
})),
},
}))
import { updateUserStripeInfo, findUserBySubscriptionId } from '../planController'
describe('updateUserStripeInfo', () => {
beforeEach(() => {
updateMock.mockReset()
eqMock.mockReset()
updateMock.mockImplementation(() => ({ eq: eqMock }))
eqMock.mockResolvedValue({ error: null })
})
it('met à jour stripe_customer_id et stripe_subscription_id', async () => {
const result = await updateUserStripeInfo('user-1', {
stripe_customer_id: 'cus_123',
stripe_subscription_id: 'sub_123',
})
expect(result.success).toBe(true)
expect(updateMock).toHaveBeenCalledWith({
stripe_customer_id: 'cus_123',
stripe_subscription_id: 'sub_123',
})
expect(eqMock).toHaveBeenCalledWith('id', 'user-1')
})
it('met à jour plan_expires_at uniquement si fourni', async () => {
await updateUserStripeInfo('user-1', {
plan_expires_at: '2026-05-14T00:00:00Z',
})
expect(updateMock).toHaveBeenCalledWith({
plan_expires_at: '2026-05-14T00:00:00Z',
})
})
it('ne fait aucun appel si aucune info fournie', async () => {
const result = await updateUserStripeInfo('user-1', {})
expect(result.success).toBe(true)
expect(updateMock).not.toHaveBeenCalled()
})
it('refuse un userId vide', async () => {
await expect(updateUserStripeInfo('', {})).rejects.toThrow('userId requis')
})
it('propage les erreurs Supabase', async () => {
eqMock.mockResolvedValue({ error: { message: 'DB down' } })
await expect(
updateUserStripeInfo('user-1', { stripe_customer_id: 'cus_x' })
).rejects.toThrow('DB down')
})
})
describe('findUserBySubscriptionId', () => {
beforeEach(() => {
selectMock.mockReset()
eqMock.mockReset()
maybeSingleMock.mockReset()
selectMock.mockImplementation(() => ({ eq: eqMock }))
eqMock.mockImplementation(() => ({ maybeSingle: maybeSingleMock }))
})
it('retourne le userId quand une subscription matche', async () => {
maybeSingleMock.mockResolvedValue({ data: { id: 'user-42' }, error: null })
const result = await findUserBySubscriptionId('sub_123')
expect(result).toEqual({ userId: 'user-42' })
expect(eqMock).toHaveBeenCalledWith('stripe_subscription_id', 'sub_123')
})
it('retourne null quand aucune subscription ne matche', async () => {
maybeSingleMock.mockResolvedValue({ data: null, error: null })
const result = await findUserBySubscriptionId('sub_unknown')
expect(result).toBeNull()
})
it('retourne null sur erreur Supabase', async () => {
maybeSingleMock.mockResolvedValue({ data: null, error: { message: 'boom' } })
const result = await findUserBySubscriptionId('sub_123')
expect(result).toBeNull()
})
it('retourne null si subscriptionId vide', async () => {
const result = await findUserBySubscriptionId('')
expect(result).toBeNull()
})
})

View file

@ -1,55 +0,0 @@
/**
* Client Deepgram Sprint 4b.
*
* Génère un token éphémère que le frontend utilise pour ouvrir une connexion
* WebSocket directe à Deepgram (transcription live). Le token est passé en
* query string `?token=...` lors de l'init de la WS — c'est le seul mécanisme
* de tokens éphémères WebSocket-compatible côté Deepgram. Les clés API créées
* via `/v1/projects/{id}/keys` sont permanentes et ne fonctionnent pas en
* query string sur la WS.
*
* Endpoint : POST https://api.deepgram.com/v1/auth/grant
* Doc : https://developers.deepgram.com/docs/create-temporary-api-key
*
* Pré-requis : la clé `DEEPGRAM_API_KEY` doit avoir le scope « Member » du
* projet. Sans ce scope, l'endpoint renvoie 403.
*/
const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY ?? "";
const DEEPGRAM_BASE_URL = "https://api.deepgram.com";
const DEEPGRAM_TIMEOUT_MS = 10_000;
export interface DeepgramToken {
token: string;
expires_in: number;
}
export async function createTemporaryToken(
ttlSeconds: number,
): Promise<DeepgramToken> {
const response = await fetch(`${DEEPGRAM_BASE_URL}/v1/auth/grant`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${DEEPGRAM_API_KEY}`,
},
body: JSON.stringify({ ttl_seconds: ttlSeconds }),
signal: AbortSignal.timeout(DEEPGRAM_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(
`Deepgram API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as { access_token?: string };
if (typeof data.access_token !== "string" || data.access_token.length === 0) {
throw new Error("Deepgram API: access_token manquant dans la réponse");
}
// L'API retourne le TTL effectif dans le payload (champ `expires_in`),
// mais on retourne la valeur demandée pour cohérence avec le frontend.
return { token: data.access_token, expires_in: ttlSeconds };
}

File diff suppressed because it is too large Load diff

View file

@ -1,102 +0,0 @@
/**
* Client Gemini transcription audio batch (Sprint 4a).
*
* Mode batch uniquement : on envoie l'audio entier en base64 et on récupère
* le transcript complet. La transcription live/streaming sera Session 4b.
*
* Robustesse : timeout 30 s + 1 retry automatique sur erreur réseau / timeout
* (les erreurs de quota ou d'auth ne sont PAS retentées réponse HTTP non-OK
* indique une erreur de configuration, pas un aléa réseau).
*/
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_TIMEOUT_MS = 45_000;
/**
* MIME types audio acceptés par le pipeline Sprint 4a.
* Aligné sur les capacités du MediaRecorder côté frontend (webm Chromium / mp4
* Safari) + wav exporté par certains parcours d'upload.
*/
export const ACCEPTED_AUDIO_MIME = [
"audio/webm",
"audio/mp4",
"audio/wav",
] as const;
export type AcceptedAudioMime = (typeof ACCEPTED_AUDIO_MIME)[number];
export function isAcceptedAudioMime(mime: string): mime is AcceptedAudioMime {
return (ACCEPTED_AUDIO_MIME as readonly string[]).includes(mime);
}
async function callGeminiTranscribe(
audioBase64: string,
mimeType: string,
): Promise<string> {
const response = await fetch(
`${GEMINI_BASE_URL}/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{ inlineData: { mimeType, data: audioBase64 } },
{
text: "Transcris cet audio mot pour mot en francais. Retourne uniquement la transcription, sans commentaire.",
},
],
},
],
}),
signal: AbortSignal.timeout(GEMINI_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(
`Gemini API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text || typeof text !== "string" || text.trim().length === 0) {
throw new Error("Gemini API: transcription vide");
}
return text.trim();
}
/**
* Transcription audio batch.
*
* Retry policy :
* - 1 retry sur TimeoutError, AbortError, TypeError (erreurs réseau ; AbortSignal.timeout
* lève TimeoutError).
* - PAS de retry sur les erreurs HTTP applicatives (quota, auth, format) un
* second appel échouera de la même manière.
*/
export async function transcribeAudio(
audioBase64: string,
mimeType: string,
): Promise<string> {
try {
return await callGeminiTranscribe(audioBase64, mimeType);
} catch (err) {
const isRetryable =
err instanceof Error &&
(err.name === "TimeoutError" ||
err.name === "AbortError" ||
err instanceof TypeError);
if (!isRetryable) throw err;
console.warn(
`[gemini.transcribeAudio] retry après erreur transitoire : ${err.message}`,
);
return await callGeminiTranscribe(audioBase64, mimeType);
}
}

View file

@ -1,532 +0,0 @@
/**
* geminiLive.ts Sprint 6d (revert WS brut).
*
* Historiquement, le proxy s'appuyait sur un SDK Gemini de haut niveau, mais
* celui-ci fermait la session sans setupComplete ni raison exploitable. On
* utilise désormais le WebSocket brut (package `ws`), qui permet de loguer
* précisément ce que Gemini répond et de maîtriser le contenu exact du setup
* frame (model, systemInstruction, transcriptions, VAD).
*
* 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";
import { SocksProxyAgent } from "socks-proxy-agent";
/**
* Résout l'agent proxy SOCKS5 pour les connexions WebSocket vers Gemini Live.
*
* Contexte : l'IP du VPS de production (datacenter) est bloquée par Google.
* Cloudflare WARP tourne en mode proxy sur le VPS (socks5://127.0.0.1:40000) ;
* router UNIQUEMENT le trafic Gemini via ce proxy le débloque, sans affecter
* le reste (Supabase, DeepSeek, clients).
*
* `GEMINI_PROXY_URL` est optionnelle : absente connexion directe (dev local
* intact). Présente (ex: socks5://127.0.0.1:40000) → SocksProxyAgent.
*/
export function resolveGeminiProxyAgent(): SocksProxyAgent | undefined {
const url = process.env.GEMINI_PROXY_URL;
return url ? new SocksProxyAgent(url) : undefined;
}
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/...`
* requis dans le setup frame natif.
*/
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 `RÔLE : Tu incarnes ${role}.
CONTEXTE : ${contexte}
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. Tu continues naturellement.
3. Tu attends que le candidat finisse sa question avant de répondre.
4. Tes réponses sont courtes (15 à 25 mots maximum) pour laisser la place au dialogue.
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 brièvement sans chercher à compléter c'est à lui de reformuler.
7. STRICTE INTERDICTION DE POSER DES QUESTIONS. Tu n'as pas le droit d'utiliser de point d'interrogation. Tes phrases se terminent par un point.
8. SILENCE TOTAL APRÈS LA RÉPONSE. Réponds de manière factuelle, puis arrête-toi immédiatement. Ne suggère rien, ne relance pas, ne dis pas "et vous ?".
9. RÔLE PASSIF : tu es une source d'information inerte. Tu n'aides pas le candidat à tenir la conversation. S'il ne parle plus, le silence s'installe.
10. AUCUNE FORMULE DE POLITESSE DE FIN : bannis "n'hésitez pas", "j'espère que ça vous aide", "qu'en pensez-vous ?".
11. JAMAIS de listes ni de structure numérotée parle naturellement.
12. Ne mentionne jamais que tu es une IA ou un modèle.
13. Tu ne prends PAS la parole en premier. Tu attends que le candidat s'adresse à toi.`;
}
/**
* 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.
*/
export interface GeminiServerMessage {
setupComplete?: unknown;
serverContent?: {
modelTurn?: {
parts?: Array<{
inlineData?: { data?: string; mimeType?: string };
}>;
};
inputTranscription?: { text?: string };
outputTranscription?: { text?: string };
interrupted?: boolean;
turnComplete?: boolean;
};
}
export interface TranscriptEntry {
speaker: "candidat" | "examinateur";
text: string;
}
export 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'}`.
*/
export 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.
*/
export 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.
*/
export 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;
}
}
/**
* VAD automatique par défaut (T2 Live) : START/END_SENSITIVITY_LOW, 2 s de
* silence avant que l'IA réponde cf. IMPLEMENTATION_T2_LIVE.md §3.
*/
export const T2_AUTOMATIC_ACTIVITY_DETECTION = {
disabled: false,
startOfSpeechSensitivity: "START_SENSITIVITY_LOW",
endOfSpeechSensitivity: "END_SENSITIVITY_LOW",
silenceDurationMs: 2000,
} as const;
/**
* Construit le setup frame Gemini Live : model + responseModalities AUDIO,
* systemInstruction, input/outputAudioTranscription, et
* realtimeInputConfig.automaticActivityDetection.
*
* `automaticActivityDetection` est paramétrable (défaut = VAD T2 inchangé).
* T1 Live (VAD manuel) passera `{ disabled: true }` pour piloter les bornes de
* tour côté backend (activityStart / activityEnd).
*/
export function buildSetupFrame(
systemPrompt: string,
automaticActivityDetection: Record<
string,
unknown
> = T2_AUTOMATIC_ACTIVITY_DETECTION,
): string {
return JSON.stringify({
setup: {
model: `models/${GEMINI_LIVE_MODEL}`,
generationConfig: {
responseModalities: ["AUDIO"],
},
systemInstruction: {
parts: [{ text: systemPrompt }],
},
inputAudioTranscription: {},
outputAudioTranscription: {},
realtimeInputConfig: {
automaticActivityDetection,
},
},
});
}
/**
* 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 proxyAgent = resolveGeminiProxyAgent();
const factory =
opts.clientFactory ??
((u: string) =>
new NodeWebSocket(
u,
proxyAgent ? { agent: proxyAgent } : undefined,
) 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 */
}
});
}

View file

@ -1,486 +0,0 @@
/**
* geminiLiveT1.ts Sprint 7a (T1 EO Live, examinateur avec interruption
* pilotée par le BACKEND).
*
* Ce module porte la spécificité T1 :
* - buildT1SystemPrompt : le prompt système de l'examinateur ;
* - openGeminiLiveT1Session : le proxy WS + l'horloge probabiliste qui décide
* QUAND interrompre, et l'injection de la relance (clientContent).
*
* Les helpers WS bas niveau (parseAudioChunk, isEndSignal, tryParseGeminiJson,
* reconstructTranscript) et le setup frame paramétrable (buildSetupFrame) sont
* réutilisés depuis `geminiLive.ts` (exports additifs Sprint 7a).
*
* Différence fondamentale avec T2 : en T1, l'examinateur DOIT poser des
* questions pour relancer le candidat. La règle 7 du T2 (interdiction absolue
* de poser des questions / bannissement du point d'interrogation) NE DOIT
* JAMAIS être propagée ici. Cf. TD-22 / TD-23.
*
* MODÈLE 1 (acté) : c'est l'HORLOGE PROBABILISTE du backend qui décide seule du
* timing des interruptions. Le backend NE lit PAS la transcription partielle
* pour décider Gemini formule la relance à partir de son contexte audio
* interne. (Découverte spike : en VAD manuel, inputTranscription n'est flushé
* qu'à l'envoi d'activityEnd, pas en continu.)
*/
import { WebSocket as NodeWebSocket } from "ws";
import {
GEMINI_LIVE_URL,
buildSetupFrame,
isEndSignal,
parseAudioChunk,
reconstructTranscript,
resolveGeminiProxyAgent,
tryParseGeminiJson,
type TranscriptEntry,
type WebSocketLike,
} from "./geminiLive.js";
/**
* Construit le prompt système T1 Live.
*
* L'examinateur formule ses relances à partir de ce qu'il ENTEND en temps réel
* (son contexte audio interne) il n'existe pas de sujet T1 en base et le flux
* ne dépend plus d'un questionnaire pré-rempli.
*
* Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut
* et ne prend la parole QUE lorsque le backend le lui signale (injection
* `clientContent` au moment choisi par l'horloge probabiliste). C'est le
* BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une
* relance courte à partir de ce que le candidat vient de dire.
*/
export function buildT1SystemPrompt(): string {
return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
Écoute attentivement ce que le candidat dit. Quand on te le signale, formule UNE question de relance courte (10-20 mots) liée à ce que le candidat vient de dire.
RÈGLES :
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire.
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
8. Tu restes toujours dans ton rôle d'examinateur. Tu ne mentionnes jamais que tu es une IA ou un modèle.`;
}
// ── Constantes nommées (PAS de nombres magiques) ────────────────────────────
/** Timeout total de la session T1 Live (filet de sécurité). */
export const T1_SESSION_TIMEOUT_MS = 180_000;
/** Warning client : 30 s avant le timeout. */
export const T1_SESSION_WARNING_MS = 150_000;
/** Distribution du nombre d'interruptions tirées au début de session. */
export const T1_INTERRUPTION_P0 = 0.2; // P(0 interruption)
export const T1_INTERRUPTION_P1 = 0.6; // P(1 interruption)
export const T1_INTERRUPTION_P2 = 0.2; // P(2 interruptions)
/** Fenêtre temporelle (depuis le début de session) où placer les interruptions. */
export const T1_INTERRUPTION_WINDOW_START_MS = 25_000;
export const T1_INTERRUPTION_WINDOW_END_MS = 75_000;
/** Espacement minimal garanti entre deux interruptions. */
export const T1_INTERRUPTION_MIN_SPACING_MS = 20_000;
/**
* Délai d'attente, après l'activityEnd FINAL, pour laisser Gemini flusher la
* transcription du dernier segment candidat avant de finaliser la session.
*/
export const T1_TERMINAL_FLUSH_GRACE_MS = 3_000;
/** MIME du flux audio candidat (PCM 16 kHz mono), identique au T2. */
const T1_INPUT_AUDIO_MIME = "audio/pcm;rate=16000";
/** VAD MANUEL : c'est le backend qui borne les tours (activityStart/End). */
const T1_MANUAL_VAD = { disabled: true } as const;
/** Consigne interne injectée pour déclencher une relance (jamais lue à voix haute). */
const T1_RELANCE_INSTRUCTION =
"[CONSIGNE INTERNE — ne pas répéter] Interromps maintenant le candidat avec UNE seule question de relance courte et pertinente, liée à ce qu'il vient de dire.";
const ACTIVITY_START_FRAME = JSON.stringify({
realtimeInput: { activityStart: {} },
});
const ACTIVITY_END_FRAME = JSON.stringify({
realtimeInput: { activityEnd: {} },
});
function buildRelanceFrame(): string {
return JSON.stringify({
clientContent: {
turns: [{ role: "user", parts: [{ text: T1_RELANCE_INSTRUCTION }] }],
turnComplete: true,
},
});
}
// ── Logique probabiliste (fonctions pures, testables avec random injecté) ────
/**
* Tire le nombre d'interruptions de la session selon la distribution
* P0/P1/P2. `random()` [0,1).
*/
export function drawT1InterruptionCount(random: () => number): 0 | 1 | 2 {
const r = random();
if (r < T1_INTERRUPTION_P0) return 0;
if (r < T1_INTERRUPTION_P0 + T1_INTERRUPTION_P1) return 1;
return 2;
}
/**
* Planifie les instants (offsets ms depuis le début de session) des
* interruptions dans la fenêtre [START, END], avec un espacement minimal
* garanti de MIN_SPACING entre deux interruptions.
*/
export function planT1InterruptionInstants(
count: 0 | 1 | 2,
random: () => number,
): number[] {
const start = T1_INTERRUPTION_WINDOW_START_MS;
const end = T1_INTERRUPTION_WINDOW_END_MS;
const spacing = T1_INTERRUPTION_MIN_SPACING_MS;
if (count === 0) return [];
if (count === 1) {
return [start + random() * (end - start)];
}
// count === 2 : premier dans [start, end - spacing], second au moins
// `spacing` après le premier et au plus `end`.
const first = start + random() * (end - spacing - start);
const second = first + spacing + random() * (end - (first + spacing));
return [first, second];
}
// ── Options de session ───────────────────────────────────────────────────────
export interface OpenGeminiLiveT1SessionOptions {
/** Callback de fin de session avec le transcript reconstruit. */
onSessionEnd?: (transcript: string) => void | Promise<void>;
/** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */
timeoutMs?: number;
/** Override warning (défaut T1_SESSION_WARNING_MS). */
warningMs?: number;
/** Surcharge la clé API (défaut process.env.GEMINI_API_KEY). */
apiKey?: string;
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
clientFactory?: (url: string) => WebSocketLike;
/** Source d'aléa injectable (défaut Math.random) pour la testabilité. */
random?: () => number;
}
/**
* Ouvre une session T1 Live : proxy WS bidirectionnel client Gemini en VAD
* MANUEL, avec interruption(s) injectée(s) au(x) instant(s) tiré(s) par
* l'horloge probabiliste.
*
* Contrat WS côté client (figé la suite du sprint 7b en dépend) :
* - {type:'interruption_start'} : l'examinateur prend la parole ;
* - {type:'interruption_end'} : le candidat peut reprendre.
*
* Séquence d'une interruption (Modèle 1) :
* activityEnd clientContent(relance, turnComplete) (turnComplete Gemini)
* activityStart.
*
* FIN DE SESSION : on envoie un activityEnd FINAL pour flusher le dernier
* segment candidat (sinon perdu la transcription n'est flushée qu'à
* activityEnd en VAD manuel). Cet activityEnd déclenche AUSSI une relance
* examinateur « terminale » : on la SUPPRIME (audio non forwardé au client,
* texte jeté). Cf. point de vigilance dans le handler de message.
*/
export function openGeminiLiveT1Session(
clientWs: WebSocketLike,
opts: OpenGeminiLiveT1SessionOptions,
): void {
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
if (!apiKey) {
clientWs.close(4005, "GEMINI_CONFIG");
return;
}
const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS;
const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS;
const random = opts.random ?? Math.random;
const systemPrompt = buildT1SystemPrompt();
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const proxyAgent = resolveGeminiProxyAgent();
const factory =
opts.clientFactory ??
((u: string) =>
new NodeWebSocket(
u,
proxyAgent ? { agent: proxyAgent } : undefined,
) as unknown as WebSocketLike);
const geminiWs = factory(url);
const entries: TranscriptEntry[] = [];
// ── État ──
let started = false; // startSession() exécuté une seule fois
let sessionEnded = false; // endSession() entamé (idempotence)
let finalized = false; // finalize() exécuté une seule fois
let candidateTurnOpen = false; // un tour candidat est ouvert côté Gemini
let injecting = false; // une interruption est en cours
let awaitingRelance = false; // on attend le turnComplete de la relance
// terminalFlush : on a envoyé l'activityEnd FINAL. À partir de là, l'audio et
// le texte de l'examinateur (relance terminale) sont SUPPRIMÉS ; seule la
// transcription candidat reste collectée.
let terminalFlush = false;
const interruptionTimers: ReturnType<typeof setTimeout>[] = [];
let warningTimer: ReturnType<typeof setTimeout> | null = null;
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
const clearTimers = () => {
for (const t of interruptionTimers) clearTimeout(t);
interruptionTimers.length = 0;
if (warningTimer !== null) {
clearTimeout(warningTimer);
warningTimer = null;
}
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
};
const geminiSend = (frame: string) => {
try {
geminiWs.send(frame);
} catch (err) {
console.error(
"[T1] Gemini send failed:",
err instanceof Error ? err.message : String(err),
);
void endSession();
}
};
const clientSend = (obj: unknown) => {
try {
clientWs.send(JSON.stringify(obj));
} catch {
/* ignore */
}
};
// ── Injection d'une interruption ──
const doInterruption = () => {
if (sessionEnded || terminalFlush || injecting || !candidateTurnOpen)
return;
injecting = true;
awaitingRelance = true;
candidateTurnOpen = false;
clientSend({ type: "interruption_start" });
geminiSend(ACTIVITY_END_FRAME);
geminiSend(buildRelanceFrame());
};
const resumeAfterInjection = () => {
awaitingRelance = false;
injecting = false;
geminiSend(ACTIVITY_START_FRAME);
candidateTurnOpen = true;
clientSend({ type: "interruption_end" });
};
// ── Démarrage (sur setupComplete) ──
const startSession = () => {
if (started) return;
started = true;
// Ouvre le premier tour candidat.
geminiSend(ACTIVITY_START_FRAME);
candidateTurnOpen = true;
// Tire et planifie les interruptions.
const count = drawT1InterruptionCount(random);
const instants = planT1InterruptionInstants(count, random);
for (const offset of instants) {
interruptionTimers.push(setTimeout(() => doInterruption(), offset));
}
warningTimer = setTimeout(() => {
if (sessionEnded) return;
clientSend({ type: "warning", message: "30 secondes restantes" });
}, warningMs);
timeoutTimer = setTimeout(() => {
void endSession();
}, timeoutMs);
};
const finalize = async () => {
if (finalized) return;
finalized = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
if (opts.onSessionEnd) {
try {
await opts.onSessionEnd(reconstructTranscript(entries));
} catch (err) {
console.error(
"[T1] onSessionEnd threw:",
err instanceof Error ? err.message : String(err),
);
}
}
};
// endSession est idempotent : double signal end → un seul flush + finalize.
async function endSession() {
if (sessionEnded) return;
sessionEnded = true;
clearTimers();
terminalFlush = true;
// Flush du dernier segment candidat : indispensable car en VAD manuel la
// transcription candidat n'est émise qu'à l'activityEnd.
if (candidateTurnOpen) {
geminiSend(ACTIVITY_END_FRAME);
candidateTurnOpen = false;
}
// Laisse à Gemini le temps d'émettre l'inputTranscription flushée, puis
// finalise (la relance terminale éventuelle est ignorée — cf. handler).
finalizeTimer = setTimeout(
() => void finalize(),
T1_TERMINAL_FLUSH_GRACE_MS,
);
}
// ── Gemini → client ──
geminiWs.on("open", () => {
geminiSend(buildSetupFrame(systemPrompt, T1_MANUAL_VAD));
});
geminiWs.on("message", (data) => {
const parsed = tryParseGeminiJson(data);
if (parsed?.setupComplete) {
startSession();
}
if (parsed) {
const sc = parsed.serverContent;
// POINT DE VIGILANCE — séparation "audio relance terminale à couper" vs
// "texte candidat final à garder" quand ils arrivent dans le MÊME
// message Gemini : on traite CHAMP PAR CHAMP, pas message par message.
// - serverContent.inputTranscription.text = CANDIDAT → toujours gardé,
// y compris pendant le flush terminal (c'est précisément ce qu'on veut
// récupérer).
// - serverContent.outputTranscription.text = EXAMINATEUR → ignoré
// pendant le flush terminal (relance terminale jetée).
// - serverContent.modelTurn.*.inlineData = audio EXAMINATEUR → non
// forwardé au client pendant le flush terminal (cf. plus bas).
if (sc?.inputTranscription?.text) {
entries.push({ speaker: "candidat", text: sc.inputTranscription.text });
}
if (!terminalFlush && sc?.outputTranscription?.text) {
entries.push({
speaker: "examinateur",
text: sc.outputTranscription.text,
});
}
// Reprise candidat après la relance (jamais pendant le flush terminal :
// on ne rouvre pas de tour, la session se termine).
if (sc?.turnComplete && injecting && awaitingRelance && !terminalFlush) {
resumeAfterInjection();
}
}
// Forward verbatim au client SAUF pendant le flush terminal : ainsi l'audio
// de la relance terminale (modelTurn inlineData) n'est jamais entendu par
// le candidat.
if (!terminalFlush) {
try {
clientWs.send(data);
} catch {
void endSession();
}
}
});
geminiWs.on("close", () => {
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
geminiWs.on("error", () => {
if (!sessionEnded) {
clearTimers();
sessionEnded = true;
try {
clientWs.close(4006, "GEMINI_DISCONNECTED");
} catch {
/* ignore */
}
}
});
// ── Client → Gemini ──
clientWs.on("message", (data) => {
if (isEndSignal(data)) {
void endSession();
return;
}
const audioBase64 = parseAudioChunk(data);
if (audioBase64 === null) {
// Message non reconnu (ni audio ni end). Notamment un éventuel
// {type:'context'} envoyé par un ancien front : ignoré silencieusement —
// jamais de crash ni de close. Cf. point de vigilance Patch 7a.
console.debug("[T1] ignored non-audio client message");
return;
}
if (!sessionEnded && candidateTurnOpen && !injecting) {
geminiSend(
JSON.stringify({
realtimeInput: {
audio: { data: audioBase64, mimeType: T1_INPUT_AUDIO_MIME },
},
}),
);
}
});
clientWs.on("close", () => {
clearTimers();
if (finalizeTimer !== null) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
sessionEnded = true;
try {
geminiWs.close(1000);
} catch {
/* ignore */
}
});
clientWs.on("error", () => {
clearTimers();
if (finalizeTimer !== null) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
sessionEnded = true;
try {
geminiWs.close(1011);
} catch {
/* ignore */
}
});
}

View file

@ -1,170 +0,0 @@
/**
* Évaluation phonologique EO via Gemini batch Sprint 4.8.
*
* Reçoit l'audio brut du candidat (base64) et retourne un score `/4` ainsi
* qu'un commentaire pédagogique structuré, alignés sur la grille TCF Canada.
* Cet appel est complémentaire de `transcribeAudio` (cf. gemini.ts) :
* - `transcribeAudio` extrait le texte DeepSeek évalue 4 critères /4.
* - `evaluatePhonology` écoute l'audio 5e critère Phonologie /4.
*
* Robustesse : timeout 45 s + 1 retry sur erreur transitoire (TimeoutError,
* AbortError, TypeError). Pas de retry sur erreur HTTP applicative (config
* Gemini cassée un second essai échouera identiquement).
*
* Mode A (transcript fourni sans audio) : utiliser `PHONOLOGY_STUB`
* directement plutôt que d'appeler cette fonction.
*/
import type { AcceptedAudioMime } from "./gemini.js";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_TIMEOUT_MS = 45_000;
export interface PhonologyResult {
/** Score entier 0..4 (capé côté serveur pour neutraliser les dérives). */
score: number;
commentaire: string;
exemple: string;
suggestion: string;
astuce: string;
}
/**
* Stub utilisé quand aucune piste audio n'est disponible (ex. Mode A
* transcript fourni directement par le client). Le score est volontairement
* 0 pour que le total /20 reflète l'absence d'évaluation.
*/
export const PHONOLOGY_STUB: PhonologyResult = {
score: 0,
commentaire: "Évaluation phonologique indisponible — audio requis.",
exemple: "",
suggestion: "",
astuce: "",
};
const PHONOLOGY_SYSTEM_PROMPT = `Tu es un correcteur TCF Canada certifié, spécialiste de la phonologie pour l'épreuve d'Expression Orale.
Tu écoutes un enregistrement audio bref ( 5 minutes) et tu évalues UNIQUEMENT la phonologie selon la grille officielle TCF Canada :
- Prononciation des sons consonantiques et vocaliques
- Liaisons et enchaînements
- Rythme, débit, accentuation
- Intonation et prosodie
- Fluidité phonique (présence d'hésitations marquées, hachures)
Échelle : entier de 0 à 4 UNIQUEMENT.
- 0 : prononciation très défaillante, intelligibilité fortement compromise.
- 1 : nombreux écarts, intelligibilité difficile.
- 2 : écarts notables mais intelligibilité préservée.
- 3 : prononciation correcte avec quelques écarts ponctuels.
- 4 : prononciation maîtrisée, naturelle, proche du francophone natif.
Réponds par un JSON STRICT, sans aucun texte avant ni après, sans markdown, sans backtick :
{
"score": <entier 0-4>,
"commentaire": "<2 phrases max — observations concrètes sur la prononciation>",
"exemple": "<mot ou expression où l'erreur phonologique est notable, ou chaîne vide si rien à signaler>",
"suggestion": "<reformulation orale ciblée, par ex. 'détacher la liaison de _les_amis_'>",
"astuce": "<conseil court et actionnable pour s'entraîner>"
}`;
const PHONOLOGY_USER_PROMPT =
"Évalue la phonologie de cet enregistrement selon la grille TCF Canada. Renvoie uniquement le JSON décrit dans le prompt système.";
interface GeminiResponse {
candidates?: { content?: { parts?: { text?: string }[] } }[];
}
function clampScore(raw: unknown): number {
const n = typeof raw === "number" ? raw : Number(raw);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(4, Math.round(n)));
}
function parsePhonologyJson(text: string): PhonologyResult {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("Gemini phonology: réponse non-JSON");
}
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Gemini phonology: payload invalide");
}
const r = parsed as Record<string, unknown>;
const score = clampScore(r.score);
const commentaire = typeof r.commentaire === "string" ? r.commentaire : "";
if (commentaire.trim().length === 0) {
throw new Error("Gemini phonology: commentaire manquant");
}
const exemple = typeof r.exemple === "string" ? r.exemple : "";
const suggestion = typeof r.suggestion === "string" ? r.suggestion : "";
const astuce = typeof r.astuce === "string" ? r.astuce : "";
return { score, commentaire, exemple, suggestion, astuce };
}
async function callGeminiPhonology(
audioBase64: string,
mimeType: AcceptedAudioMime,
): Promise<PhonologyResult> {
const response = await fetch(
`${GEMINI_BASE_URL}/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
systemInstruction: { parts: [{ text: PHONOLOGY_SYSTEM_PROMPT }] },
contents: [
{
parts: [
{ inlineData: { mimeType, data: audioBase64 } },
{ text: PHONOLOGY_USER_PROMPT },
],
},
],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
},
}),
signal: AbortSignal.timeout(GEMINI_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(
`Gemini phonology API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as GeminiResponse;
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text || typeof text !== "string" || text.trim().length === 0) {
throw new Error("Gemini phonology: réponse vide");
}
return parsePhonologyJson(text.trim());
}
/**
* Évalue la phonologie sur l'audio brut. 1 retry automatique sur erreur
* transitoire ; les erreurs HTTP applicatives ne sont PAS retentées.
*/
export async function evaluatePhonology(
audioBase64: string,
mimeType: AcceptedAudioMime,
): Promise<PhonologyResult> {
try {
return await callGeminiPhonology(audioBase64, mimeType);
} catch (err) {
const isRetryable =
err instanceof Error &&
(err.name === "TimeoutError" ||
err.name === "AbortError" ||
err instanceof TypeError);
if (!isRetryable) throw err;
console.warn(
`[geminiPhonology.evaluatePhonology] retry après erreur transitoire : ${err.message}`,
);
return await callGeminiPhonology(audioBase64, mimeType);
}
}

View file

@ -1,6 +1,6 @@
import { supabase } from './supabase.js'
import { PLANS } from './access.js'
import type { Plan } from './access.js'
import { supabase } from './supabase'
import { PLANS } from './access'
import type { Plan } from './access'
export async function updateUserPlan(
userId: string,
@ -20,49 +20,3 @@ export async function updateUserPlan(
return { success: true, plan: data.plan }
}
interface StripeInfo {
stripe_customer_id?: string | null
stripe_subscription_id?: string | null
plan_expires_at?: string | null
}
export async function updateUserStripeInfo(
userId: string,
info: StripeInfo
): Promise<{ success: boolean }> {
if (!userId) throw new Error('userId requis')
const update: Record<string, string | null> = {}
if (info.stripe_customer_id !== undefined) update.stripe_customer_id = info.stripe_customer_id
if (info.stripe_subscription_id !== undefined) update.stripe_subscription_id = info.stripe_subscription_id
if (info.plan_expires_at !== undefined) update.plan_expires_at = info.plan_expires_at
if (Object.keys(update).length === 0) {
return { success: true }
}
const { error } = await supabase
.from('profiles')
.update(update)
.eq('id', userId)
if (error) throw new Error(error.message)
return { success: true }
}
export async function findUserBySubscriptionId(
subscriptionId: string
): Promise<{ userId: string } | null> {
if (!subscriptionId) return null
const { data, error } = await supabase
.from('profiles')
.select('id')
.eq('stripe_subscription_id', subscriptionId)
.maybeSingle()
if (error || !data) return null
return { userId: data.id }
}

View file

@ -1,119 +1,46 @@
import Stripe from "stripe";
import Stripe from 'stripe'
function getStripe() {
return new Stripe(process.env.STRIPE_SECRET_KEY ?? "");
}
interface CreateCheckoutSessionParams {
userId: string;
priceId: string;
planName: string;
}
export async function createCheckoutSession(
params: CreateCheckoutSessionParams,
): Promise<{ url: string }> {
const { userId, priceId, planName } = params;
if (!userId) throw new Error("userId requis");
if (!priceId) throw new Error("priceId requis");
if (!planName) throw new Error("planName requis");
const appUrl = process.env.APP_URL;
if (!appUrl) throw new Error("APP_URL non configuré");
const session = await getStripe().checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/dashboard?upgrade=success`,
cancel_url: `${appUrl}/plan?upgrade=cancelled`,
client_reference_id: userId,
metadata: { userId, planName },
});
if (!session.url) {
throw new Error("Stripe n'a pas retourné d'URL de checkout");
}
return { url: session.url };
}
interface CreateBillingPortalSessionParams {
customerId: string;
returnUrl: string;
}
/**
* Sprint 5a Crée une session Stripe Billing Portal pour permettre à
* l'utilisateur de gérer son abonnement (mise à jour moyen de paiement,
* factures, résiliation) via l'interface hébergée Stripe.
*/
export async function createBillingPortalSession(
params: CreateBillingPortalSessionParams,
): Promise<{ url: string }> {
const { customerId, returnUrl } = params;
if (!customerId) throw new Error("customerId requis");
if (!returnUrl) throw new Error("returnUrl requis");
const session = await getStripe().billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
if (!session.url) {
throw new Error("Stripe n'a pas retourné d'URL de billing portal");
}
return { url: session.url };
return new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
}
export function verifyStripeWebhook(
payload: Buffer,
signature: string,
secret: string,
secret: string
): { valid: boolean; event?: Stripe.Event; error?: string } {
if (!payload.length || !signature) {
return { valid: false, error: "Payload ou signature manquant" };
return { valid: false, error: 'Payload ou signature manquant' }
}
try {
const event = getStripe().webhooks.constructEvent(
payload,
signature,
secret,
);
return { valid: true, event };
const event = getStripe().webhooks.constructEvent(payload, signature, secret)
return { valid: true, event }
} catch (err) {
return { valid: false, error: (err as Error).message };
return { valid: false, error: (err as Error).message }
}
}
interface ProrataParams {
currentPlanPrice: number;
newPlanPrice: number;
totalDays: number;
daysRemaining: number;
currentPlanPrice: number
newPlanPrice: number
totalDays: number
daysRemaining: number
}
export function calculateProrata(params: ProrataParams): { amount: number } {
const { currentPlanPrice, newPlanPrice, totalDays, daysRemaining } = params;
const { currentPlanPrice, newPlanPrice, totalDays, daysRemaining } = params
if (
currentPlanPrice < 0 ||
newPlanPrice < 0 ||
totalDays < 0 ||
daysRemaining < 0
) {
throw new Error("Les valeurs ne peuvent pas être négatives");
if (currentPlanPrice < 0 || newPlanPrice < 0 || totalDays < 0 || daysRemaining < 0) {
throw new Error('Les valeurs ne peuvent pas être négatives')
}
if (daysRemaining > totalDays) {
throw new Error("daysRemaining ne peut pas dépasser totalDays");
throw new Error('daysRemaining ne peut pas dépasser totalDays')
}
const ratio = daysRemaining / totalDays;
const credit = currentPlanPrice * ratio;
const cost = newPlanPrice * ratio;
const amount = Math.max(0, cost - credit);
const ratio = daysRemaining / totalDays
const credit = currentPlanPrice * ratio
const cost = newPlanPrice * ratio
const amount = Math.max(0, cost - credit)
return { amount };
return { amount }
}

View file

@ -1,55 +0,0 @@
/**
* Sprint 5a Idempotency des webhooks Stripe (TD-13).
*
* Helper isolé pour interroger / journaliser la table `stripe_webhook_events`.
* Utilisé par `routes/stripe.ts` autour de chaque appel à `handleStripeEvent`.
*
* Voir migration `007_sprint_5a_stripe_webhook_events.sql` pour le schéma.
*/
import { supabase } from "./supabase.js";
/**
* Indique si un `event.id` Stripe a déjà é traité (présent dans la table
* `stripe_webhook_events`). Retourne `false` en cas d'erreur de lecture pour
* privilégier la disponibilité du webhook (mieux vaut un double traitement
* opérations métier idempotentes qu'un drop silencieux).
*/
export async function isEventProcessed(eventId: string): Promise<boolean> {
if (!eventId) return false;
const { data, error } = await supabase
.from("stripe_webhook_events")
.select("id")
.eq("id", eventId)
.maybeSingle();
if (error) {
console.warn(
`[stripeWebhookEvents.isEventProcessed] lecture en erreur pour ${eventId} : ${error.message}`,
);
return false;
}
return data !== null;
}
/**
* Journalise un event.id comme traité. INSERT idempotent (`ON CONFLICT DO
* NOTHING` via la PRIMARY KEY) — un échec d'insert ne doit JAMAIS faire
* échouer la réponse 200 du webhook (Stripe retenterait), donc on log et
* on retourne sans throw.
*/
export async function markEventProcessed(eventId: string): Promise<void> {
if (!eventId) return;
const { error } = await supabase
.from("stripe_webhook_events")
.insert({ id: eventId });
if (error) {
// Code Postgres `23505` = unique_violation → l'event a déjà été marqué
// par une livraison concurrente, c'est exactement ce qu'on cherche
// (no-op silencieux). Tout autre code est loggé.
if (error.code !== "23505") {
console.error(
`[stripeWebhookEvents.markEventProcessed] insert en erreur pour ${eventId} : ${error.message}`,
);
}
}
}

View file

@ -1,151 +0,0 @@
/**
* Taxonomie fermée des erreurs cf. docs/TAXONOMIE_ERREURS.md v1.0.
*
* Source unique des codes acceptés dans les rapports DeepSeek. Utilisée par :
* 1. le prompt maître (injection de la liste des codes par critère)
* 2. la validation runtime des réponses DeepSeek (garde-fou taxonomie)
*
* Règle : chaque erreur retournée doit avoir un code présent ici, ou `autre`
* avec une `description` non vide.
*/
export type Critere =
| 'adequation_tache'
| 'coherence_cohesion'
| 'competence_lexicale'
| 'competence_grammaticale'
export const CRITERES: readonly Critere[] = [
'adequation_tache',
'coherence_cohesion',
'competence_lexicale',
'competence_grammaticale',
]
/**
* Libellés officiels TCF Canada affichés côté frontend.
* Alignés sur les intitulés du prompt maître (docs/Prompt_maître.md §CRITÈRES).
*/
export const CRITERE_LABELS: Record<Critere, string> = {
adequation_tache: 'Adéquation à la tâche et au registre',
coherence_cohesion: 'Cohérence et cohésion du discours',
competence_lexicale: 'Compétence lexicale',
competence_grammaticale: 'Compétence grammaticale',
}
export const CODES_BY_CRITERE: Record<Critere, readonly string[]> = {
adequation_tache: [
'hors_sujet_total',
'hors_sujet_partiel',
'information_manquante',
'enonce_copie',
'longueur_insuffisante',
'longueur_excessive',
'format_non_respecte',
'salutation_absente',
'cloture_absente',
'structure_absente',
'registre_trop_formel',
'registre_trop_familier',
'abreviations_sms',
'tutoiement_inadequat',
'autre',
],
coherence_cohesion: [
'introduction_absente',
'conclusion_absente',
'paragraphes_absents',
'progression_illogique',
'connecteurs_absents',
'connecteurs_repetes',
'connecteurs_inadequats',
'connecteurs_insuffisants',
'idee_non_developpee',
'repetition_idee',
'contradiction_interne',
'hors_propos',
'pronoms_ambigus',
'substitution_absente',
'rupture_temporelle',
'autre',
],
competence_lexicale: [
'vocabulaire_basique',
'vocabulaire_insuffisant',
'registre_lexical_inadequat',
'mot_imprecis',
'contresens_lexical',
'anglicisme',
'calque_syntaxique',
'repetition_lexicale',
'synonymes_absents',
'expressions_figees_absentes',
'faute_orthographe_courante',
'confusion_homophones',
'majuscules_incorrectes',
'autre',
],
competence_grammaticale: [
'accord_sujet_verbe',
'accord_adjectif_nom',
'accord_participe_passe',
'accord_determinant_nom',
'temps_verbal_inadequat',
'subjonctif_absent',
'subjonctif_incorrect',
'conditionnel_absent',
'concordance_temps',
'phrase_incomplete',
'phrase_trop_longue',
'ordre_mots_incorrect',
'subordination_absente',
'subordination_incorrecte',
'virgule_exces',
'virgule_absence',
'point_absent',
'ponctuation_incorrecte',
'preposition_absente',
'preposition_incorrecte',
'preposition_superflue',
'genre_incorrect',
'nombre_incorrect',
'negation_incomplete',
'autre',
],
}
export function isValidCritere(x: unknown): x is Critere {
return typeof x === 'string' && (CRITERES as readonly string[]).includes(x)
}
export function isValidCode(critere: Critere, code: string): boolean {
return CODES_BY_CRITERE[critere].includes(code)
}
/**
* Bloc texte injecté dans le prompt maître liste des codes autorisés par critère,
* dans le format exact attendu par DeepSeek (`critere: code1, code2, …`).
*/
export function buildTaxonomyPromptSection(): string {
const lines = CRITERES.map((critere) => {
const codes = CODES_BY_CRITERE[critere].join(', ')
return `- ${critere} : ${codes}`
})
return `CODES D'ERREURS AUTORISÉS (par critère) :
${lines.join('\n')}
Règles :
- Chaque erreur retournée doit utiliser EXACTEMENT un code de la liste du critère concerné.
- Le code "autre" est autorisé mais exige une "description" textuelle non vide.
- Pour tout code différent de "autre", le champ "description" doit être null.`
}
/**
* Barème NCLC score minimum /20 (cf. Prompt_maître.md §Barème).
*/
export const NCLC_MIN_SCORE: Record<number, number> = {
7: 10,
8: 12,
9: 14,
10: 16,
}

View file

@ -1,5 +1,5 @@
import type { Context, Next } from 'hono'
import { supabase } from '../lib/supabase.js'
import { supabase } from '../lib/supabase'
export type AuthUser = {
id: string

View file

@ -1,7 +1,7 @@
import type { Context, Next } from 'hono'
import { checkFeatureAccess, getPlanPermissions } from '../lib/access.js'
import type { Feature, Plan } from '../lib/access.js'
import type { AppVariables } from './auth.js'
import { getPlanPermissions } from '../lib/access'
import type { Feature } from '../lib/access'
import type { AppVariables } from './auth'
/**
* Vérifie que le profil de l'utilisateur (posé par authMiddleware)
@ -12,9 +12,9 @@ export function planMiddleware(feature: Feature) {
return async (c: Context<{ Variables: AppVariables }>, next: Next) => {
const profile = c.get('profile')
const plan = profile.plan as Plan
let perms: ReturnType<typeof getPlanPermissions>
try {
getPlanPermissions(plan)
perms = getPlanPermissions(profile.plan as 'free' | 'standard' | 'premium')
} catch {
return c.json(
{
@ -26,7 +26,7 @@ export function planMiddleware(feature: Feature) {
)
}
if (!checkFeatureAccess(plan, feature)) {
if (!perms[feature]) {
return c.json(
{
error: true,

View file

@ -1,219 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ───────────────────────────────────────────────────────────────
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 3,
});
await next();
},
}));
const { correctEOMock } = vi.hoisted(() => ({ correctEOMock: vi.fn() }));
vi.mock("../../controllers/correctionController", () => ({
correctEE: vi.fn(),
correctEO: correctEOMock,
}));
import correctionsRoutes from "../corrections";
function buildApp() {
const app = new Hono();
app.route("/corrections", correctionsRoutes);
return app;
}
const AUTH = { Authorization: "Bearer x" };
const JSON_HEADERS = { ...AUTH, "Content-Type": "application/json" };
describe("POST /corrections/eo — Sprint 4a", () => {
beforeEach(() => {
correctEOMock.mockReset();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", { method: "POST" });
expect(res.status).toBe(401);
});
it("400 si simulationId manquant", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ tache: "EO_T1", transcript: "t" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si tache invalide (hors EO_T1/T2/T3)", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EE_T1",
transcript: "t",
}),
});
expect(res.status).toBe(400);
});
it("400 si ni transcript ni audioBase64 fournis", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ simulationId: "s1", tache: "EO_T1" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("400 si transcript ET audioBase64 fournis simultanément (XOR)", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
transcript: "t",
audioBase64: "AAAA",
mimeType: "audio/webm",
}),
});
expect(res.status).toBe(400);
});
it("400 si audioBase64 sans mimeType", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
audioBase64: "AAAA",
}),
});
expect(res.status).toBe(400);
});
it("400 si nclc_cible invalide", async () => {
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
transcript: "t",
nclc_cible: 8,
}),
});
expect(res.status).toBe(400);
});
it("200 quand le controller renvoie un rapport (mode transcript)", async () => {
correctEOMock.mockResolvedValue({
data: {
score: 14,
nclc: 9,
simulation_id: "s1",
diagnostic: "d",
},
});
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s1",
tache: "EO_T1",
transcript: "Bonjour je m appelle Pierre",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.score).toBe(14);
expect(correctEOMock).toHaveBeenCalledWith(
expect.objectContaining({
simulationId: "s1",
tache: "EO_T1",
nclcCible: 9,
transcript: "Bonjour je m appelle Pierre",
}),
expect.any(Object),
);
});
it("200 mode batch audio (transmet audioBase64 + mimeType au controller)", async () => {
correctEOMock.mockResolvedValue({
data: { score: 14, nclc: 9, simulation_id: "s-audio", diagnostic: "d" },
});
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s-audio",
tache: "EO_T1",
audioBase64: "AAAA",
mimeType: "audio/webm",
}),
});
expect(res.status).toBe(200);
expect(correctEOMock).toHaveBeenCalledWith(
expect.objectContaining({
simulationId: "s-audio",
tache: "EO_T1",
nclcCible: 9,
audioBase64: "AAAA",
mimeType: "audio/webm",
}),
expect.any(Object),
);
});
it("200 avec nclc_cible=10 transmis au controller", async () => {
correctEOMock.mockResolvedValue({
data: { score: 16, nclc: 10, simulation_id: "s2", diagnostic: "d" },
});
const app = buildApp();
const res = await app.request("/corrections/eo", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
simulationId: "s2",
tache: "EO_T1",
transcript: "Bonjour",
nclc_cible: 10,
}),
});
expect(res.status).toBe(200);
expect(correctEOMock).toHaveBeenCalledWith(
expect.objectContaining({
simulationId: "s2",
nclcCible: 10,
transcript: "Bonjour",
}),
expect.any(Object),
);
});
});

View file

@ -1,190 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const {
subscriptionsRetrieveMock,
invoicesCreatePreviewMock,
currentProfile,
} = vi.hoisted(() => ({
subscriptionsRetrieveMock: vi.fn(),
invoicesCreatePreviewMock: vi.fn(),
currentProfile: {
value: {
id: 'test-user-id',
email: 'user@test.com',
plan: 'standard',
simulations_used: 0,
stripe_customer_id: 'cus_abc',
stripe_subscription_id: 'sub_abc',
plan_expires_at: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
},
},
}))
vi.mock('stripe', () => ({
default: vi.fn(() => ({
subscriptions: { retrieve: subscriptionsRetrieveMock },
invoices: { createPreview: invoicesCreatePreviewMock },
checkout: { sessions: { create: vi.fn() } },
})),
}))
vi.mock('../../middleware/auth', () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: true, code: 'AUTH_REQUIRED' }, 401)
}
c.set('user', { id: currentProfile.value.id, email: currentProfile.value.email })
c.set('profile', currentProfile.value)
await next()
},
}))
import plansRoutes from '../plans'
function buildApp() {
const app = new Hono()
app.route('/plans', plansRoutes)
return app
}
describe('POST /plans/upgrade-prorata', () => {
beforeEach(() => {
subscriptionsRetrieveMock.mockReset()
invoicesCreatePreviewMock.mockReset()
process.env.STRIPE_SECRET_KEY = 'sk_test'
currentProfile.value = {
id: 'test-user-id',
email: 'user@test.com',
plan: 'standard',
simulations_used: 0,
stripe_customer_id: 'cus_abc',
stripe_subscription_id: 'sub_abc',
plan_expires_at: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}
})
it('retourne amount, currency et newPlanExpiry depuis Stripe', async () => {
subscriptionsRetrieveMock.mockResolvedValue({
items: { data: [{ id: 'si_123' }] },
})
invoicesCreatePreviewMock.mockResolvedValue({
amount_due: 1050, // 10.50€
currency: 'eur',
period_end: 1715731200, // 2024-05-15 00:00:00 UTC
})
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.amount).toBeCloseTo(10.5, 2)
expect(body.currency).toBe('eur')
expect(body.newPlanExpiry).toBe('2024-05-15T00:00:00.000Z')
expect(invoicesCreatePreviewMock).toHaveBeenCalledWith({
subscription: 'sub_abc',
subscription_details: {
items: [{ id: 'si_123', price: 'price_premium' }],
proration_behavior: 'always_invoice',
},
})
})
it('retourne 401 sans authentification', async () => {
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId: 'p1', planName: 'premium' }),
})
expect(res.status).toBe(401)
})
it('retourne 400 si priceId ou planName manquent', async () => {
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'p1' }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('INVALID_BODY')
})
it('retourne 400 si planName est inconnu', async () => {
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'p1', planName: 'ultra' }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('INVALID_PLAN')
})
it('retourne 400 NO_ACTIVE_SUBSCRIPTION si le user n\'a pas d\'abonnement', async () => {
currentProfile.value = {
...currentProfile.value,
stripe_subscription_id: null,
}
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('NO_ACTIVE_SUBSCRIPTION')
})
it('retourne 500 si Stripe échoue', async () => {
subscriptionsRetrieveMock.mockRejectedValue(new Error('Stripe unreachable'))
const app = buildApp()
const res = await app.request('/plans/upgrade-prorata', {
method: 'POST',
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }),
})
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe('INTERNAL_ERROR')
})
})

View file

@ -1,132 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
});
await next();
},
}));
const { generateMock } = vi.hoisted(() => ({ generateMock: vi.fn() }));
vi.mock("../../controllers/presentationController", () => ({
generate: generateMock,
}));
import presentationsRoutes from "../presentations";
function buildApp() {
const app = new Hono();
app.route("/presentations", presentationsRoutes);
return app;
}
const JSON_HEADERS = {
Authorization: "Bearer x",
"Content-Type": "application/json",
};
describe("POST /presentations/generate", () => {
beforeEach(() => {
generateMock.mockReset();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
});
expect(res.status).toBe(401);
});
it("400 si body JSON invalide", async () => {
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: "not-json",
});
expect(res.status).toBe(400);
});
it("propage l'erreur de validation du controller", async () => {
generateMock.mockResolvedValue({
error: true,
code: "VALIDATION_ERROR",
message: "field missing",
status: 400,
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ reponses: {} }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
});
it("200 avec { presentation } quand le controller réussit", async () => {
generateMock.mockResolvedValue({
data: { presentation: "Bonjour, je m'appelle Pierre…" },
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
reponses: {
prenom_age_ville: "Pierre",
formation_metier: "Ingénieur",
situation_familiale: "Marié",
loisirs: "Lecture",
motivation_canada: "Travail",
},
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.presentation).toContain("Pierre");
expect(generateMock).toHaveBeenCalledWith({
prenom_age_ville: "Pierre",
formation_metier: "Ingénieur",
situation_familiale: "Marié",
loisirs: "Lecture",
motivation_canada: "Travail",
});
});
it("500 si DeepSeek down (controller renvoie INTERNAL_ERROR)", async () => {
generateMock.mockResolvedValue({
error: true,
code: "INTERNAL_ERROR",
message: "fail",
status: 500,
});
const app = buildApp();
const res = await app.request("/presentations/generate", {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({
reponses: {
prenom_age_ville: "a",
formation_metier: "b",
situation_familiale: "c",
loisirs: "d",
motivation_canada: "e",
},
}),
});
expect(res.status).toBe(500);
});
});

View file

@ -1,452 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const {
createCheckoutSessionMock,
createBillingPortalSessionMock,
verifyStripeWebhookMock,
updateUserPlanMock,
updateUserStripeInfoMock,
findUserBySubscriptionIdMock,
isEventProcessedMock,
markEventProcessedMock,
} = vi.hoisted(() => ({
createCheckoutSessionMock: vi.fn(),
createBillingPortalSessionMock: vi.fn(),
verifyStripeWebhookMock: vi.fn(),
updateUserPlanMock: vi.fn(),
updateUserStripeInfoMock: vi.fn(),
findUserBySubscriptionIdMock: vi.fn(),
isEventProcessedMock: vi.fn(),
markEventProcessedMock: vi.fn(),
}));
vi.mock("../../lib/stripe", () => ({
createCheckoutSession: createCheckoutSessionMock,
createBillingPortalSession: createBillingPortalSessionMock,
verifyStripeWebhook: verifyStripeWebhookMock,
}));
vi.mock("../../lib/planController", () => ({
updateUserPlan: updateUserPlanMock,
updateUserStripeInfo: updateUserStripeInfoMock,
findUserBySubscriptionId: findUserBySubscriptionIdMock,
}));
vi.mock("../../lib/stripeWebhookEvents", () => ({
isEventProcessed: isEventProcessedMock,
markEventProcessed: markEventProcessedMock,
}));
// Permet aux tests d'injecter un profil custom (ex. avec stripe_customer_id).
const { profileOverrideRef } = vi.hoisted(() => ({
profileOverrideRef: {
current: null as null | Record<string, unknown>,
},
}));
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("user", { id: "test-user-id", email: "user@test.com" });
c.set(
"profile",
profileOverrideRef.current ?? {
id: "test-user-id",
email: "user@test.com",
plan: "free",
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
},
);
await next();
},
}));
import stripeRoutes from "../stripe";
function buildApp() {
const app = new Hono();
app.route("/stripe", stripeRoutes);
return app;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("POST /stripe/checkout", () => {
beforeEach(() => {
createCheckoutSessionMock.mockReset();
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
});
it("retourne l'URL de checkout pour un utilisateur authentifié", async () => {
createCheckoutSessionMock.mockResolvedValue({
url: "https://checkout.stripe.com/pay/cs_xyz",
});
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: "price_standard", planName: "standard" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe("https://checkout.stripe.com/pay/cs_xyz");
expect(createCheckoutSessionMock).toHaveBeenCalledWith({
userId: "test-user-id",
priceId: "price_standard",
planName: "standard",
});
});
it("retourne 401 sans authentification", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId: "p1", planName: "standard" }),
});
expect(res.status).toBe(401);
});
it("retourne 400 si priceId ou planName manquent", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: "p1" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("INVALID_BODY");
});
it("retourne 400 pour un planName inconnu", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: "p1", planName: "super_premium" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("INVALID_PLAN");
});
});
describe("POST /stripe/webhook", () => {
beforeEach(() => {
verifyStripeWebhookMock.mockReset();
updateUserPlanMock.mockReset();
updateUserStripeInfoMock.mockReset();
findUserBySubscriptionIdMock.mockReset();
isEventProcessedMock.mockReset();
markEventProcessedMock.mockReset();
// Défaut : event jamais vu → traitement normal pour les tests existants.
isEventProcessedMock.mockResolvedValue(false);
markEventProcessedMock.mockResolvedValue(undefined);
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
process.env.STRIPE_PRICE_STANDARD = "price_standard";
process.env.STRIPE_PRICE_PREMIUM = "price_premium";
});
it("rejette un webhook sans signature", async () => {
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
body: "payload",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
});
it("rejette un webhook avec signature invalide", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: false,
error: "No signatures match",
});
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "bad-sig" },
body: "payload",
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
});
it("traite checkout.session.completed → met à jour plan + stripe info", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: "user-42", planName: "premium" },
customer: "cus_abc",
subscription: "sub_abc",
},
},
},
});
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "premium");
expect(updateUserStripeInfoMock).toHaveBeenCalledWith("user-42", {
stripe_customer_id: "cus_abc",
stripe_subscription_id: "sub_abc",
});
});
it("traite customer.subscription.deleted → remet le plan à free", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: "customer.subscription.deleted",
data: { object: { id: "sub_abc" } },
},
});
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-42" });
updateUserPlanMock.mockResolvedValue({ success: true, plan: "free" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith("sub_abc");
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "free");
});
it("traite invoice.paid avec price Premium → plan premium", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: "invoice.paid",
data: {
object: {
subscription: "sub_xyz",
lines: {
data: [{ price: { id: "price_premium" } }],
},
},
},
},
});
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-99" });
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(updateUserPlanMock).toHaveBeenCalledWith("user-99", "premium");
});
it("retourne 200 pour un event non géré", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: "ping.unknown",
data: { object: {} },
},
});
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(updateUserPlanMock).not.toHaveBeenCalled();
});
// ─── Sprint 5a — Idempotency (TD-13) ──────────────────────────────────────
it("event déjà traité → 200 replayed sans appel handler ni mark", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
id: "evt_already",
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: "user-x", planName: "standard" },
customer: "cus_x",
subscription: "sub_x",
},
},
},
});
isEventProcessedMock.mockResolvedValue(true);
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ received: true, replayed: true });
expect(updateUserPlanMock).not.toHaveBeenCalled();
expect(updateUserStripeInfoMock).not.toHaveBeenCalled();
expect(markEventProcessedMock).not.toHaveBeenCalled();
});
it("event nouveau → traitement normal puis markEventProcessed(event.id)", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
id: "evt_fresh",
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: "user-fresh", planName: "standard" },
customer: "cus_fresh",
subscription: "sub_fresh",
},
},
},
});
isEventProcessedMock.mockResolvedValue(false);
updateUserPlanMock.mockResolvedValue({ success: true, plan: "standard" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ received: true });
expect(updateUserPlanMock).toHaveBeenCalledWith("user-fresh", "standard");
expect(markEventProcessedMock).toHaveBeenCalledTimes(1);
expect(markEventProcessedMock).toHaveBeenCalledWith("evt_fresh");
});
});
// ─── Sprint 5a — POST /stripe/customer-portal ────────────────────────────────
describe("POST /stripe/customer-portal", () => {
beforeEach(() => {
createBillingPortalSessionMock.mockReset();
profileOverrideRef.current = null;
process.env.APP_URL = "https://expria.app";
});
it("retourne 401 sans authentification", async () => {
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
});
expect(res.status).toBe(401);
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
});
it("retourne 400 NO_ACTIVE_SUBSCRIPTION quand stripe_customer_id est absent", async () => {
profileOverrideRef.current = {
id: "u1",
email: "u@test.com",
plan: "free",
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("NO_ACTIVE_SUBSCRIPTION");
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
});
it("retourne l'URL de la billing portal session pour un user avec stripe_customer_id", async () => {
profileOverrideRef.current = {
id: "u1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
stripe_customer_id: "cus_existing",
stripe_subscription_id: "sub_existing",
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
createBillingPortalSessionMock.mockResolvedValue({
url: "https://billing.stripe.com/p/session/abc",
});
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe("https://billing.stripe.com/p/session/abc");
expect(createBillingPortalSessionMock).toHaveBeenCalledWith({
customerId: "cus_existing",
returnUrl: "https://expria.app/dashboard",
});
});
});

View file

@ -1,260 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
// ─── Mocks ───────────────────────────────────────────────────────────────────
vi.mock('../../lib/supabase', () => ({
supabase: {
from: vi.fn(),
},
}))
vi.mock('../../lib/deepseek', () => ({
generateIdees: vi.fn(),
}))
vi.mock('../../middleware/auth', () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: true, code: 'AUTH_REQUIRED' }, 401)
}
c.set('user', { id: 'test-user-id', email: 'u@test.com' })
c.set('profile', {
id: 'test-user-id',
email: 'u@test.com',
plan: 'free',
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
})
await next()
},
}))
import { supabase } from '../../lib/supabase'
import { generateIdees } from '../../lib/deepseek'
import sujetsRoutes from '../sujets'
function buildApp() {
const app = new Hono()
app.route('/sujets', sujetsRoutes)
return app
}
/** Mock from('sujets').select().eq('mode').eq('tache').eq('actif').order() */
function mockSujetsQuery(rows: unknown[] | null, error: unknown = null) {
vi.mocked(supabase.from).mockReturnValueOnce({
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
order: vi.fn(() => ({ data: rows, error })),
})),
})),
})),
})),
} as any)
}
const AUTH_HEADERS = { Authorization: 'Bearer valid-token' }
describe('GET /sujets', () => {
beforeEach(() => {
vi.mocked(supabase.from).mockReset()
})
it('retourne 401 sans authentification', async () => {
const app = buildApp()
const res = await app.request('/sujets?mode=EE&tache=1')
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('retourne 400 VALIDATION_ERROR si mode invalide', async () => {
const app = buildApp()
const res = await app.request('/sujets?mode=XX&tache=1', { headers: AUTH_HEADERS })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('retourne 400 VALIDATION_ERROR si mode manquant', async () => {
const app = buildApp()
const res = await app.request('/sujets?tache=1', { headers: AUTH_HEADERS })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('retourne 400 VALIDATION_ERROR si tache invalide', async () => {
const app = buildApp()
const res = await app.request('/sujets?mode=EE&tache=9', { headers: AUTH_HEADERS })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('retourne 400 VALIDATION_ERROR si tache non numérique', async () => {
const app = buildApp()
const res = await app.request('/sujets?mode=EE&tache=abc', { headers: AUTH_HEADERS })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
})
it('retourne la liste des sujets actifs pour (mode, tache) valides', async () => {
const rows = [
{
id: 'sujet-1',
consigne: 'Écrivez un texte.',
role: null,
contexte: null,
doc1_titre: null,
doc1_texte: null,
doc2_titre: null,
doc2_texte: null,
},
{
id: 'sujet-2',
consigne: 'Autre consigne.',
role: 'journaliste',
contexte: 'magazine',
doc1_titre: null,
doc1_texte: null,
doc2_titre: null,
doc2_texte: null,
},
]
mockSujetsQuery(rows)
const app = buildApp()
const res = await app.request('/sujets?mode=EE&tache=1', { headers: AUTH_HEADERS })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({ sujets: rows })
expect(vi.mocked(supabase.from).mock.calls[0][0]).toBe('sujets')
})
it('retourne un tableau vide si aucun sujet actif', async () => {
mockSujetsQuery([])
const app = buildApp()
const res = await app.request('/sujets?mode=EO&tache=3', { headers: AUTH_HEADERS })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({ sujets: [] })
})
it('retourne 500 INTERNAL_ERROR si Supabase échoue', async () => {
mockSujetsQuery(null, { message: 'connection refused' })
const app = buildApp()
const res = await app.request('/sujets?mode=EE&tache=2', { headers: AUTH_HEADERS })
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe('INTERNAL_ERROR')
})
})
describe('POST /sujets/idees', () => {
beforeEach(() => {
vi.mocked(generateIdees).mockReset()
})
const VALID_CONTENU = Array.from({ length: 35 }, (_, i) => `mot${i}`).join(' ')
it('retourne 401 sans authentification', async () => {
const app = buildApp()
const res = await app.request('/sujets/idees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sujet_consigne: 'x', contenu_partiel: VALID_CONTENU }),
})
expect(res.status).toBe(401)
const body = await res.json()
expect(body.code).toBe('AUTH_REQUIRED')
})
it('retourne 400 VALIDATION_ERROR si sujet_consigne manquant', async () => {
const app = buildApp()
const res = await app.request('/sujets/idees', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ contenu_partiel: VALID_CONTENU }),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(vi.mocked(generateIdees)).not.toHaveBeenCalled()
})
it('retourne 400 VALIDATION_ERROR si contenu_partiel contient moins de 30 mots', async () => {
const app = buildApp()
const res = await app.request('/sujets/idees', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
sujet_consigne: 'Rédigez une lettre.',
contenu_partiel: 'Trop court.',
}),
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('VALIDATION_ERROR')
expect(vi.mocked(generateIdees)).not.toHaveBeenCalled()
})
it('retourne 200 avec idees[] en cas de succès', async () => {
const idees = ['Idée 1', 'Idée 2', 'Idée 3', 'Idée 4', 'Idée 5']
vi.mocked(generateIdees).mockResolvedValueOnce(idees)
const app = buildApp()
const res = await app.request('/sujets/idees', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
sujet_consigne: 'Rédigez une lettre.',
contenu_partiel: VALID_CONTENU,
}),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({ idees })
expect(vi.mocked(generateIdees)).toHaveBeenCalledWith('Rédigez une lettre.', VALID_CONTENU)
})
it('retourne 500 INTERNAL_ERROR si DeepSeek throw', async () => {
vi.mocked(generateIdees).mockRejectedValueOnce(new Error('DeepSeek down'))
const app = buildApp()
const res = await app.request('/sujets/idees', {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({
sujet_consigne: 'Rédigez une lettre.',
contenu_partiel: VALID_CONTENU,
}),
})
expect(res.status).toBe(500)
const body = await res.json()
expect(body.code).toBe('INTERNAL_ERROR')
})
})

View file

@ -1,202 +0,0 @@
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 { 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 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("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");
});
});

View file

@ -1,319 +0,0 @@
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 { authenticate, fetchSujetT2, runT2LiveCorrection } from "../t2live";
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 mockProfileQuery(plan: string | null, userId = "u1") {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
plan === null
? { data: null, error: { message: "not found" } }
: { data: { id: userId, plan }, 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 mockSujetQuery(
row: {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
} | null,
) {
vi.mocked(supabase.from).mockReturnValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () =>
row === null
? { data: null, error: { message: "not found" } }
: { data: row, 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 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 FAKE_RAPPORT = {
score: 14,
nclc: 8,
nclc_cible: 9,
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("authenticate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refuse si token absent → 4001", async () => {
const result = await authenticate(undefined);
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si Supabase rejette le JWT → 4001", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: null } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: { message: "invalid" } as any,
});
const result = await authenticate("bad-token");
expect(result).toEqual({ ok: false, code: 4001, reason: "AUTH_REQUIRED" });
});
it("refuse si plan ne donne pas oral_t2_live → 4003", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("standard");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: false,
code: 4003,
reason: "PLAN_INSUFFICIENT",
});
});
it("accepte un utilisateur Premium → ok:true + profile", async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: { user: { id: "u1" } } as any,
error: null,
});
mockProfileQuery("premium");
const result = await authenticate("valid-jwt");
expect(result).toEqual({
ok: true,
profile: { id: "u1", plan: "premium" },
});
});
});
describe("fetchSujetT2", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne null si Supabase ne trouve pas le sujet", async () => {
mockSujetQuery(null);
const result = await fetchSujetT2("unknown-id");
expect(result).toBeNull();
});
it("retourne le sujet si trouvé", async () => {
const row = {
id: "s1",
role: "un bailleur",
contexte: "Vous cherchez un appartement.",
consigne: "Appelez le bailleur.",
};
mockSujetQuery(row);
const result = await fetchSujetT2("s1");
expect(result).toEqual(row);
});
});
describe("runT2LiveCorrection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const profile = { id: "u1", plan: "premium" as const };
const sujet = {
id: "s1",
role: "un bailleur",
contexte: "Recherche appartement.",
consigne: "Appelez le bailleur.",
};
it("transcript vide → envoie EMPTY_TRANSCRIPT et close 1000 sans appeler DeepSeek", async () => {
const ws = new FakeWs();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
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 production → DeepSeek → update → report → close 1000", async () => {
const ws = new FakeWs();
mockProductionInsert("prod-123");
vi.mocked(deepseekCorrectEO).mockResolvedValueOnce(FAKE_RAPPORT);
mockProductionUpdate();
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
transcript: "Candidat : Bonjour\nExaminateur : Bonjour",
});
expect(deepseekCorrectEO).toHaveBeenCalledWith(
"Candidat : Bonjour\nExaminateur : Bonjour",
"EO_T2",
9,
"Appelez le bailleur.",
);
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-123");
});
it("insert production échoue → PERSISTENCE_FAILED + close 1011", async () => {
const ws = new FakeWs();
mockProductionInsert(null, "db down");
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
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-456");
vi.mocked(deepseekCorrectEO).mockRejectedValueOnce(new Error("timeout"));
await runT2LiveCorrection({
clientWs: ws,
profile,
sujet,
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");
});
});

View file

@ -1,142 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("profile", {
id: "user-1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
});
await next();
},
}));
import transcriptionsRoutes from "../transcriptions";
function buildApp() {
const app = new Hono();
app.route("/transcriptions", transcriptionsRoutes);
return app;
}
const JSON_HEADERS = {
Authorization: "Bearer x",
"Content-Type": "application/json",
};
describe("POST /transcriptions/token — Sprint 4b", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("401 sans Authorization", async () => {
const app = buildApp();
const res = await app.request("/transcriptions/token", { method: "POST" });
expect(res.status).toBe(401);
});
it("200 + { token, expires_in } quand Deepgram répond OK", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "dg-temp-abc123",
expires_in: 600,
}),
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.token).toBe("dg-temp-abc123");
expect(body.expires_in).toBe(600);
});
it("appelle POST /v1/auth/grant avec Authorization Token <key> et ttl_seconds=600", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ access_token: "tok" }),
});
vi.stubGlobal("fetch", fetchMock);
const app = buildApp();
await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe("https://api.deepgram.com/v1/auth/grant");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["Authorization"]).toMatch(/^Token /);
expect(JSON.parse(init.body as string)).toEqual({ ttl_seconds: 600 });
});
it("500 INTERNAL_ERROR si Deepgram non-OK", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("INTERNAL_ERROR");
});
it("500 INTERNAL_ERROR si fetch throw (timeout réseau)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network down")),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
});
it("500 INTERNAL_ERROR si access_token absent dans la réponse Deepgram", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ expires_in: 600 }),
}),
);
const app = buildApp();
const res = await app.request("/transcriptions/token", {
method: "POST",
headers: JSON_HEADERS,
});
expect(res.status).toBe(500);
});
});

View file

@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import { authMiddleware } from '../middleware/auth'
import type { AppVariables } from '../middleware/auth'
const auth = new Hono<{ Variables: AppVariables }>()

View file

@ -1,219 +0,0 @@
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import * as correctionController from "../controllers/correctionController.js";
const VALID_TACHES_EE = ["EE_T1", "EE_T2", "EE_T3"];
const VALID_TACHES_EO = ["EO_T1", "EO_T2", "EO_T3"];
const corrections = new Hono<{ Variables: AppVariables }>();
corrections.post("/ee", authMiddleware, async (c) => {
let body: {
simulationId?: unknown;
contenu?: unknown;
tache?: unknown;
nclc_cible?: unknown;
};
try {
body = await c.req.json();
} catch {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
if (!body.simulationId || typeof body.simulationId !== "string") {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "simulationId est requis.",
},
400,
);
}
if (!body.contenu || typeof body.contenu !== "string") {
return c.json(
{ error: true, code: "VALIDATION_ERROR", message: "contenu est requis." },
400,
);
}
if (!body.tache || !VALID_TACHES_EE.includes(body.tache as string)) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EE.join(", ")}`,
},
400,
);
}
// Sprint 3.6a — nclc_cible optionnel (défaut 9). Seules les valeurs 9 et 10 sont acceptées.
let nclcCible: 9 | 10 = 9;
if (body.nclc_cible !== undefined) {
if (body.nclc_cible !== 9 && body.nclc_cible !== 10) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "nclc_cible doit être 9 ou 10.",
},
400,
);
}
nclcCible = body.nclc_cible;
}
const profile = c.get("profile");
const result = await correctionController.correctEE(
{
simulationId: body.simulationId,
contenu: body.contenu,
tache: body.tache as "EE_T1" | "EE_T2" | "EE_T3",
nclcCible,
},
profile,
);
if ("error" in result) {
return c.json(result, result.status as 401 | 404 | 500);
}
return c.json(result.data, 200);
});
// Sprint 4b.2 — POST /corrections/eo accepte SOIT un transcript texte
// SOIT un audio base64 + mimeType (transcrit côté backend via Gemini).
// Aucun audio n'est stocké côté serveur ; le client garde une copie locale.
const MAX_AUDIO_BASE64_LEN = 14 * 1024 * 1024;
corrections.post("/eo", authMiddleware, async (c) => {
let body: {
simulationId?: unknown;
transcript?: unknown;
tache?: unknown;
nclc_cible?: unknown;
audioBase64?: unknown;
mimeType?: unknown;
};
try {
body = await c.req.json();
} catch {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
if (!body.simulationId || typeof body.simulationId !== "string") {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "simulationId est requis.",
},
400,
);
}
if (!body.tache || !VALID_TACHES_EO.includes(body.tache as string)) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES_EO.join(", ")}`,
},
400,
);
}
// XOR : transcript OU (audioBase64 + mimeType). Pas les deux, pas aucun.
const hasTranscript =
typeof body.transcript === "string" && body.transcript.length > 0;
const hasAudio =
typeof body.audioBase64 === "string" && body.audioBase64.length > 0;
if (hasTranscript === hasAudio) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message:
"Fournir exactement un des deux : `transcript` (texte) ou `audioBase64` + `mimeType` (audio).",
},
400,
);
}
if (hasAudio) {
if (typeof body.mimeType !== "string" || body.mimeType.length === 0) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "`mimeType` est requis quand `audioBase64` est fourni.",
},
400,
);
}
if ((body.audioBase64 as string).length > MAX_AUDIO_BASE64_LEN) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "Audio trop volumineux (max ~10 Mo).",
},
413,
);
}
}
// nclc_cible optionnel (défaut 9, valeurs 9 ou 10).
let nclcCible: 9 | 10 = 9;
if (body.nclc_cible !== undefined) {
if (body.nclc_cible !== 9 && body.nclc_cible !== 10) {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "nclc_cible doit être 9 ou 10.",
},
400,
);
}
nclcCible = body.nclc_cible;
}
const profile = c.get("profile");
const result = await correctionController.correctEO(
{
simulationId: body.simulationId,
tache: body.tache as "EO_T1" | "EO_T2" | "EO_T3",
nclcCible,
transcript: hasTranscript ? (body.transcript as string) : undefined,
audioBase64: hasAudio ? (body.audioBase64 as string) : undefined,
mimeType: hasAudio ? (body.mimeType as string) : undefined,
},
profile,
);
if ("error" in result) {
return c.json(result, result.status as 400 | 401 | 404 | 500);
}
return c.json(result.data, 200);
});
export default corrections;

View file

@ -1,8 +1,7 @@
import { Hono } from 'hono'
import Stripe from 'stripe'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import { getPlanPermissions, PLANS } from '../lib/access.js'
import { authMiddleware } from '../middleware/auth'
import type { AppVariables } from '../middleware/auth'
import { getPlanPermissions, PLANS } from '../lib/access'
const plans = new Hono<{ Variables: AppVariables }>()
@ -29,94 +28,4 @@ plans.get('/status', authMiddleware, (c) => {
)
})
plans.post('/upgrade-prorata', authMiddleware, async (c) => {
const profile = c.get('profile')
let body: { priceId?: string; planName?: string }
try {
body = await c.req.json()
} catch {
return c.json(
{ error: true, code: 'INVALID_BODY', message: 'JSON invalide.' },
400
)
}
const { priceId, planName } = body
if (!priceId || !planName) {
return c.json(
{
error: true,
code: 'INVALID_BODY',
message: 'priceId et planName sont requis.',
},
400
)
}
if (!(planName in PLANS)) {
return c.json(
{ error: true, code: 'INVALID_PLAN', message: 'Plan inconnu.' },
400
)
}
const subscriptionId = profile.stripe_subscription_id
if (!subscriptionId) {
return c.json(
{
error: true,
code: 'NO_ACTIVE_SUBSCRIPTION',
message: 'Aucun abonnement actif à mettre à niveau.',
},
400
)
}
try {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const itemId = subscription.items.data[0]?.id
if (!itemId) {
return c.json(
{
error: true,
code: 'INTERNAL_ERROR',
message: 'Abonnement Stripe invalide.',
},
500
)
}
// Stripe SDK v17 : createPreview remplace retrieveUpcoming
const invoicesApi = stripe.invoices as unknown as {
createPreview: (params: Record<string, unknown>) => Promise<Stripe.Invoice>
}
const preview = await invoicesApi.createPreview({
subscription: subscriptionId,
subscription_details: {
items: [{ id: itemId, price: priceId }],
proration_behavior: 'always_invoice',
},
})
const amount = (preview.amount_due ?? 0) / 100
const currency = preview.currency ?? 'eur'
const newPlanExpiry = preview.period_end
? new Date(preview.period_end * 1000).toISOString()
: null
return c.json({ amount, currency, newPlanExpiry }, 200)
} catch (err) {
return c.json(
{
error: true,
code: 'INTERNAL_ERROR',
message: (err as Error).message,
},
500
)
}
})
export default plans

View file

@ -1,39 +0,0 @@
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import * as presentationController from "../controllers/presentationController.js";
const presentations = new Hono<{ Variables: AppVariables }>();
// Sprint 4a — POST /presentations/generate
//
// Body : { reponses: { prenom_age_ville, formation_metier, situation_familiale,
// loisirs, motivation_canada } }
// Réponse : { presentation: string }
//
// Pas de stockage en base — le frontend gère la persistance locale (MVP).
presentations.post("/generate", authMiddleware, async (c) => {
let body: { reponses?: unknown };
try {
body = await c.req.json();
} catch {
return c.json(
{
error: true,
code: "VALIDATION_ERROR",
message: "Corps de la requête invalide.",
},
400,
);
}
const result = await presentationController.generate(body.reponses);
if ("error" in result) {
return c.json(result, result.status as 400 | 500);
}
return c.json(result.data, 200);
});
export default presentations;

View file

@ -1,10 +1,10 @@
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import { getPlanPermissions } from '../lib/access.js'
import type { Plan } from '../lib/access.js'
import * as simulationController from '../controllers/simulationController.js'
import type { Tache, Mode } from '../controllers/simulationController.js'
import { authMiddleware } from '../middleware/auth'
import type { AppVariables } from '../middleware/auth'
import { getPlanPermissions } from '../lib/access'
import type { Plan } from '../lib/access'
import * as simulationController from '../controllers/simulationController'
import type { Tache, Mode } from '../controllers/simulationController'
const VALID_TACHES: Tache[] = ['EE_T1', 'EE_T2', 'EE_T3', 'EO_T1', 'EO_T3', 'EO_T2_LIVE']
const VALID_MODES: Mode[] = ['entrainement', 'examen']
@ -78,133 +78,4 @@ simulations.post('/', authMiddleware, async (c) => {
return c.json(result.data, 201)
})
// FTD-21 — autosave du contenu d'une simulation en cours.
// Déclaré AVANT `GET /:id` par convention défensive, bien que Hono distingue
// les routes par (méthode, chemin) via un trie : PATCH /:id/contenu ne peut
// pas être capturé par GET /:id. Même raison pour /:id/sujet ci-dessous.
simulations.patch('/:id/contenu', authMiddleware, async (c) => {
const id = c.req.param('id')!
let body: { contenu?: unknown }
try {
body = await c.req.json()
} catch {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' },
400
)
}
if (typeof body.contenu !== 'string') {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: 'Le champ `contenu` est requis et doit être une chaîne.',
},
400
)
}
const profile = c.get('profile')
const result = await simulationController.autosaveContenu(id, profile.id, body.contenu)
if ('error' in result) {
return c.json(result, result.status as 400 | 401 | 404 | 500)
}
return c.json(result.data, 200)
})
// FTD-21 — met à jour le sujet d'une simulation en cours.
simulations.patch('/:id/sujet', authMiddleware, async (c) => {
const id = c.req.param('id')!
let body: { sujet_id?: unknown }
try {
body = await c.req.json()
} catch {
return c.json(
{ error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' },
400
)
}
if (!body.sujet_id || typeof body.sujet_id !== 'string') {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: 'Le champ `sujet_id` est requis et doit être une chaîne.',
},
400
)
}
const profile = c.get('profile')
const result = await simulationController.updateSujet(id, profile.id, body.sujet_id)
if ('error' in result) {
return c.json(result, result.status as 400 | 401 | 404 | 500)
}
return c.json(result.data, 200)
})
// Sprint 3.7 — liste paginée des productions de l'utilisateur connecté.
// `GET /` est distinct de `GET /:id` côté routeur Hono (match par (méthode, chemin)).
simulations.get('/', authMiddleware, async (c) => {
const rawPage = c.req.query('page')
const rawLimit = c.req.query('limit')
const page = rawPage === undefined ? 1 : Number(rawPage)
const limit = rawLimit === undefined ? 20 : Number(rawLimit)
if (!Number.isInteger(page) || page < 1) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: '`page` doit être un entier supérieur ou égal à 1.',
},
400
)
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: '`limit` doit être un entier entre 1 et 50.',
},
400
)
}
const profile = c.get('profile')
const result = await simulationController.list({ page, limit }, profile)
if ('error' in result) {
return c.json(result, result.status as 500)
}
return c.json(result.data, 200)
})
// GET /:id en dernier — route catch-all par id, ne doit pas masquer les routes plus spécifiques.
simulations.get('/:id', authMiddleware, async (c) => {
// `:id` est garanti présent par le pattern de route Hono
const id = c.req.param('id')!
const profile = c.get('profile')
const result = await simulationController.getById(id, profile)
if ('error' in result) {
return c.json(result, result.status as 401 | 404 | 500)
}
return c.json(result.data, 200)
})
export default simulations

View file

@ -1,249 +0,0 @@
import { Hono } from "hono";
import type Stripe from "stripe";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import {
createBillingPortalSession,
createCheckoutSession,
verifyStripeWebhook,
} from "../lib/stripe.js";
import {
updateUserPlan,
updateUserStripeInfo,
findUserBySubscriptionId,
} from "../lib/planController.js";
import {
isEventProcessed,
markEventProcessed,
} from "../lib/stripeWebhookEvents.js";
import type { Plan } from "../lib/access.js";
import { PLANS } from "../lib/access.js";
const stripeRoutes = new Hono<{ Variables: AppVariables }>();
stripeRoutes.post("/checkout", authMiddleware, async (c) => {
const user = c.get("user");
let body: { priceId?: string; planName?: string };
try {
body = await c.req.json();
} catch {
return c.json(
{ error: true, code: "INVALID_BODY", message: "JSON invalide." },
400,
);
}
const { priceId, planName } = body;
if (!priceId || !planName) {
return c.json(
{
error: true,
code: "INVALID_BODY",
message: "priceId et planName sont requis.",
},
400,
);
}
if (!(planName in PLANS)) {
return c.json(
{ error: true, code: "INVALID_PLAN", message: "Plan inconnu." },
400,
);
}
try {
const { url } = await createCheckoutSession({
userId: user.id,
priceId,
planName,
});
return c.json({ url }, 200);
} catch (err) {
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message: (err as Error).message,
},
500,
);
}
});
stripeRoutes.post("/customer-portal", authMiddleware, async (c) => {
const profile = c.get("profile");
const customerId = profile.stripe_customer_id;
if (!customerId) {
return c.json(
{
error: true,
code: "NO_ACTIVE_SUBSCRIPTION",
message: "Aucun abonnement actif trouvé. Souscrivez d'abord à un plan.",
},
400,
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message: "APP_URL non configuré.",
},
500,
);
}
try {
const { url } = await createBillingPortalSession({
customerId,
returnUrl: `${appUrl}/dashboard`,
});
return c.json({ url }, 200);
} catch (err) {
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message: (err as Error).message,
},
500,
);
}
});
stripeRoutes.post("/webhook", async (c) => {
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json(
{
error: true,
code: "STRIPE_WEBHOOK_INVALID",
message: "Signature manquante.",
},
400,
);
}
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message: "STRIPE_WEBHOOK_SECRET non configuré.",
},
500,
);
}
const arrayBuffer = await c.req.arrayBuffer();
const payload = Buffer.from(arrayBuffer);
const verified = verifyStripeWebhook(payload, signature, secret);
if (!verified.valid || !verified.event) {
return c.json(
{
error: true,
code: "STRIPE_WEBHOOK_INVALID",
message: verified.error ?? "Signature invalide.",
},
400,
);
}
// Sprint 5a — TD-13 : déduplication des deliveries Stripe.
if (await isEventProcessed(verified.event.id)) {
return c.json({ received: true, replayed: true }, 200);
}
try {
await handleStripeEvent(verified.event);
await markEventProcessed(verified.event.id);
} catch {
// On renvoie 200 malgré l'erreur interne pour éviter les retries Stripe
// en boucle. L'erreur est tracée côté logs serveur. L'event N'EST PAS
// marqué comme traité — Stripe pourra le rejouer après correction du bug.
}
return c.json({ received: true }, 200);
});
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const planName = session.metadata?.planName as Plan | undefined;
if (!userId || !planName || !(planName in PLANS)) return;
await updateUserPlan(userId, planName);
const customerId =
typeof session.customer === "string" ? session.customer : null;
const subscriptionId =
typeof session.subscription === "string" ? session.subscription : null;
await updateUserStripeInfo(userId, {
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
});
return;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
};
const subscriptionId =
typeof invoice.subscription === "string" ? invoice.subscription : null;
if (!subscriptionId) return;
const match = await findUserBySubscriptionId(subscriptionId);
if (!match) return;
const plan = detectPlanFromInvoice(invoice);
if (!plan) return;
await updateUserPlan(match.userId, plan);
return;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const match = await findUserBySubscriptionId(subscription.id);
if (!match) return;
await updateUserPlan(match.userId, "free");
await updateUserStripeInfo(match.userId, {
stripe_subscription_id: null,
plan_expires_at: null,
});
return;
}
default:
return;
}
}
function detectPlanFromInvoice(invoice: Stripe.Invoice): Plan | null {
const standardPrice = process.env.STRIPE_PRICE_STANDARD;
const premiumPrice = process.env.STRIPE_PRICE_PREMIUM;
const lines = invoice.lines?.data ?? [];
for (const line of lines) {
const priceId = line.price?.id;
if (!priceId) continue;
if (premiumPrice && priceId === premiumPrice) return "premium";
if (standardPrice && priceId === standardPrice) return "standard";
}
return null;
}
export default stripeRoutes;

View file

@ -1,140 +0,0 @@
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import { supabase } from '../lib/supabase.js'
import { generateIdees } from '../lib/deepseek.js'
/**
* Routes de la table `sujets` catalogue des consignes d'examen.
*
* GET /sujets?mode=EE|EO&tache=1|2|3
* Retourne la liste des sujets actifs pour la paire (mode, tache).
* Utilisé par l'écran de choix de sujet côté frontend (tâche G4).
*
* POST /sujets/idees
* Génère 5 suggestions d'idées via DeepSeek pour aider l'étudiant
* à continuer sa rédaction en cours (tâche G5).
*/
const MIN_WORDS_IDEES = 30
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length
}
const VALID_MODES = ['EE', 'EO'] as const
const VALID_TACHES = [1, 2, 3] as const
type SujetMode = (typeof VALID_MODES)[number]
const sujets = new Hono<{ Variables: AppVariables }>()
sujets.get('/', authMiddleware, async (c) => {
const modeRaw = c.req.query('mode')
const tacheRaw = c.req.query('tache')
if (!modeRaw || !VALID_MODES.includes(modeRaw as SujetMode)) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: `Mode invalide. Valeurs acceptées : ${VALID_MODES.join(', ')}`,
},
400
)
}
const tacheNumber = Number(tacheRaw)
if (!tacheRaw || !Number.isInteger(tacheNumber) || !VALID_TACHES.includes(tacheNumber as 1 | 2 | 3)) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: `Tâche invalide. Valeurs acceptées : ${VALID_TACHES.join(', ')}`,
},
400
)
}
const mode = modeRaw as SujetMode
const { data, error } = await supabase
.from('sujets')
.select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte')
.eq('mode', mode)
.eq('tache', tacheNumber)
.eq('actif', true)
.order('id')
if (error) {
return c.json(
{
error: true,
code: 'INTERNAL_ERROR',
message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.',
},
500
)
}
return c.json({ sujets: data ?? [] }, 200)
})
sujets.post('/idees', authMiddleware, async (c) => {
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: 'Corps de requête JSON invalide.',
},
400
)
}
const { sujet_consigne, contenu_partiel } = (body ?? {}) as {
sujet_consigne?: unknown
contenu_partiel?: unknown
}
if (typeof sujet_consigne !== 'string' || sujet_consigne.trim().length === 0) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: 'sujet_consigne requis (string non vide).',
},
400
)
}
if (typeof contenu_partiel !== 'string' || countWords(contenu_partiel) < MIN_WORDS_IDEES) {
return c.json(
{
error: true,
code: 'VALIDATION_ERROR',
message: `Le contenu doit comporter au moins ${MIN_WORDS_IDEES} mots.`,
},
400
)
}
try {
const idees = await generateIdees(sujet_consigne, contenu_partiel)
return c.json({ idees }, 200)
} catch {
return c.json(
{
error: true,
code: 'INTERNAL_ERROR',
message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.',
},
500
)
}
})
export default sujets

View file

@ -1,263 +0,0 @@
import { Hono } from "hono";
import type { UpgradeWebSocket } from "hono/ws";
import { EventEmitter } from "node:events";
import { supabase } from "../lib/supabase.js";
import type { Plan } from "../lib/access.js";
import { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
import {
openGeminiLiveT1Session,
type OpenGeminiLiveT1SessionOptions,
} from "../lib/geminiLiveT1.js";
import type { WebSocketLike } from "../lib/geminiLive.js";
// RÉUTILISATION : même gate d'authentification + permission Premium que le T2.
// `authenticate` vérifie le JWT Supabase puis checkFeatureAccess(plan, 'oral_t2_live').
import { authenticate } from "./t2live.js";
interface Profile {
id: string;
plan: Plan;
}
/**
* Pipeline post-session T1 : crée la production, lance la correction EO sur le
* transcript reconstruit, persiste le rapport, envoie au client puis ferme.
*
* Calqué sur runT2LiveCorrection (t2live.ts) mais spécifique T1 :
* - tache='EO_T1' (enum DB existant aucune migration, pas de EO_T1_LIVE) ;
* - sujet_id=null (T1 EO non subject-based déjà fait par le flux batch
* EO_T1, cf. simulationController) ;
* - correctEO(transcript, 'EO_T1', 9, null) : pas de consigne de sujet en T1 ;
* - phonologie = PHONOLOGY_STUB (TD-08 pas d'audio brut côté backend).
*/
export async function runT1LiveCorrection(args: {
clientWs: WebSocketLike;
profile: Profile;
transcript: string;
}): Promise<void> {
const { clientWs, profile, transcript } = args;
if (transcript.trim().length === 0) {
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "EMPTY_TRANSCRIPT",
message: "Aucun échange enregistré.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000, "EMPTY_TRANSCRIPT");
} catch {
/* ignore */
}
return;
}
// 1. Créer la production (rapport=null pour l'instant).
const { data: created, error: insertError } = await supabase
.from("productions")
.insert({
user_id: profile.id,
tache: "EO_T1",
mode: "entrainement",
sujet_id: null,
contenu: transcript,
})
.select("id")
.single();
if (insertError || !created) {
console.error("[T1] production insert failed:", insertError?.message);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "PERSISTENCE_FAILED",
message: "Impossible d'enregistrer la session.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "PERSISTENCE_FAILED");
} catch {
/* ignore */
}
return;
}
const productionId = (created as { id: string }).id;
// 2. Lancer la correction EO via DeepSeek (pas de consigne de sujet en T1).
let rapport;
try {
rapport = await deepseekCorrectEO(transcript, "EO_T1", 9, null);
} catch (err) {
console.error(
"[T1] DeepSeek correction failed:",
err instanceof Error ? err.message : String(err),
);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "CORRECTION_FAILED",
message: "Erreur lors de la correction.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "CORRECTION_FAILED");
} catch {
/* ignore */
}
return;
}
// 3. Appliquer phonologie stub (TD-08) : score textuel /16 + phonologie /4 = /20.
const scoreTextuel = rapport.score;
const scoreFinal = scoreTextuel + PHONOLOGY_STUB.score;
// 4. Persister le rapport.
const { error: updateError } = await supabase
.from("productions")
.update({
rapport,
score: scoreFinal,
nclc: rapport.nclc,
})
.eq("id", productionId);
if (updateError) {
console.error("[T1] production update failed:", updateError.message);
}
// 5. Envoyer le rapport au client puis fermer.
try {
clientWs.send(
JSON.stringify({
type: "report",
data: {
...rapport,
score: scoreFinal,
simulation_id: productionId,
},
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000);
} catch {
/* ignore */
}
}
export interface CreateT1LiveRoutesOptions {
/** Injection pour les tests : fabrique de WebSocket vers Gemini. */
clientFactory?: OpenGeminiLiveT1SessionOptions["clientFactory"];
/** Injection pour les tests : override timeout/warning. */
timeoutMs?: number;
warningMs?: number;
/** Injection pour les tests : source d'aléa de l'horloge probabiliste. */
random?: OpenGeminiLiveT1SessionOptions["random"];
}
/**
* Crée le router pour `WS /t1/live`.
* - Auth : JWT Supabase en query param `?token=<jwt>` (RÉUTILISE authenticate de
* t2live même permission Premium `oral_t2_live`).
* - Pas de sujet ni de questionnaire : la session Gemini s'ouvre IMMÉDIATEMENT
* après l'auth (calque T2 qui ouvre après auth + fetch sujet). L'examinateur
* formule ses relances à partir de ce qu'il ENTEND. Le client envoie
* directement son audio (`{type:'audio'}`) puis `{type:'end'}`.
* - OK openGeminiLiveT1Session onSessionEnd : correction EO_T1 + persistance.
*/
export default function createT1LiveRoutes(
upgradeWebSocket: UpgradeWebSocket,
opts: CreateT1LiveRoutesOptions = {},
) {
const app = new Hono();
app.get(
"/live",
upgradeWebSocket(async (c) => {
const token = c.req.query("token");
let denyCode: number | null = null;
let denyReason = "";
let profile: Profile | null = null;
const auth = await authenticate(token);
if (!auth.ok) {
denyCode = auth.code;
denyReason = auth.reason;
} else {
profile = auth.profile;
}
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveT1Session.
const adapter = new EventEmitter() as EventEmitter & WebSocketLike;
adapter.send = () => {};
adapter.close = () => {};
return {
onOpen(_evt, ws) {
adapter.send = (data: unknown) =>
ws.send(data as Parameters<typeof ws.send>[0]);
adapter.close = (code?: number, reason?: string) =>
ws.close(code, reason);
if (denyCode !== null) {
try {
ws.send(JSON.stringify({ error: true, code: denyReason }));
} catch {
/* ignore */
}
setTimeout(() => ws.close(denyCode!, denyReason), 100);
return;
}
// Auth OK → on ouvre la session Gemini immédiatement (pas de
// questionnaire ni de sujet). Calque T2.
const profileNonNull = profile!;
openGeminiLiveT1Session(adapter, {
clientFactory: opts.clientFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
random: opts.random,
onSessionEnd: async (transcript) => {
await runT1LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
transcript,
});
},
});
},
onMessage(evt) {
// Relaie les messages (audio / end) à la session. Tout message non
// reconnu (ex. {type:'context'} d'un ancien front) est ignoré
// silencieusement par openGeminiLiveT1Session.
adapter.emit("message", evt.data);
},
onClose() {
adapter.emit("close");
},
onError() {
adapter.emit("error", new Error("CLIENT_ERROR"));
},
};
}),
);
return app;
}

View file

@ -1,343 +0,0 @@
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 { correctEO as deepseekCorrectEO } from "../lib/deepseek.js";
import { PHONOLOGY_STUB } from "../lib/geminiPhonology.js";
import {
openGeminiLiveSession,
type WebSocketLike,
type OpenGeminiLiveSessionOptions,
} from "../lib/geminiLive.js";
interface SujetRow {
id: string;
role: string | null;
contexte: string | null;
consigne: string | null;
}
interface Profile {
id: string;
plan: Plan;
}
interface AuthSucces {
ok: true;
profile: Profile;
}
interface AuthFailure {
ok: false;
code: number;
reason: string;
}
export async function authenticate(
token: string | undefined,
): Promise<AuthSucces | AuthFailure> {
if (!token) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
try {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser(token);
if (authError || !user) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("id, plan")
.eq("id", user.id)
.single();
if (profileError || !profile) {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
if (!checkFeatureAccess(profile.plan as Plan, "oral_t2_live")) {
return { ok: false, code: 4003, reason: "PLAN_INSUFFICIENT" };
}
return {
ok: true,
profile: { id: profile.id as string, plan: profile.plan as Plan },
};
} catch {
return { ok: false, code: 4001, reason: "AUTH_REQUIRED" };
}
}
export async function fetchSujetT2(sujetId: string): Promise<SujetRow | null> {
const { data, error } = await supabase
.from("sujets")
.select("id, role, contexte, consigne")
.eq("id", sujetId)
.eq("mode", "EO")
.eq("tache", 2)
.single();
if (error || !data) return null;
return data as SujetRow;
}
/**
* Pipeline post-session : crée la production, lance la correction EO sur le
* transcript reconstruit, persiste le rapport, envoie au client puis ferme.
*
* Cf. docs/IMPLEMENTATION_T2_LIVE.md §3 Phase 3.
*
* Notes :
* - tache='EO_T2' pour la correction (le pipeline DeepSeek), tache='EO_T2_LIVE'
* pour la persistance (enum DB).
* - Phonologie = PHONOLOGY_STUB (TD-08 pas d'audio brut côté backend).
*/
export async function runT2LiveCorrection(args: {
clientWs: WebSocketLike;
profile: Profile;
sujet: SujetRow;
transcript: string;
}): Promise<void> {
const { clientWs, profile, sujet, transcript } = args;
if (transcript.trim().length === 0) {
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "EMPTY_TRANSCRIPT",
message: "Aucun échange enregistré.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000, "EMPTY_TRANSCRIPT");
} catch {
/* ignore */
}
return;
}
// 1. Créer la production (rapport=null pour l'instant).
const { data: created, error: insertError } = await supabase
.from("productions")
.insert({
user_id: profile.id,
tache: "EO_T2_LIVE",
mode: "entrainement",
sujet_id: sujet.id,
contenu: transcript,
})
.select("id")
.single();
if (insertError || !created) {
console.error("[T2] production insert failed:", insertError?.message);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "PERSISTENCE_FAILED",
message: "Impossible d'enregistrer la session.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "PERSISTENCE_FAILED");
} catch {
/* ignore */
}
return;
}
const productionId = (created as { id: string }).id;
// 2. Lancer la correction EO via DeepSeek.
let rapport;
try {
rapport = await deepseekCorrectEO(
transcript,
"EO_T2",
9,
sujet.consigne ?? null,
);
} catch (err) {
console.error(
"[T2] DeepSeek correction failed:",
err instanceof Error ? err.message : String(err),
);
try {
clientWs.send(
JSON.stringify({
type: "error",
code: "CORRECTION_FAILED",
message: "Erreur lors de la correction.",
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1011, "CORRECTION_FAILED");
} catch {
/* ignore */
}
return;
}
// 3. Appliquer phonologie stub (TD-08) : score textuel /16 + phonologie /4 = /20.
const scoreTextuel = rapport.score;
const scoreFinal = scoreTextuel + PHONOLOGY_STUB.score;
// 4. Persister le rapport.
const { error: updateError } = await supabase
.from("productions")
.update({
rapport,
score: scoreFinal,
nclc: rapport.nclc,
})
.eq("id", productionId);
if (updateError) {
console.error("[T2] production update failed:", updateError.message);
}
// 5. Envoyer le rapport au client puis fermer.
try {
clientWs.send(
JSON.stringify({
type: "report",
data: {
...rapport,
score: scoreFinal,
simulation_id: productionId,
},
}),
);
} catch {
/* ignore */
}
try {
clientWs.close(1000);
} catch {
/* ignore */
}
}
export interface CreateT2LiveRoutesOptions {
/** Injection pour les tests : fabrique de client SDK Gemini (Sprint 6d). */
clientFactory?: OpenGeminiLiveSessionOptions["clientFactory"];
/** Injection pour les tests : override timeout/warning. */
timeoutMs?: number;
warningMs?: number;
}
/**
* Crée le router pour `WS /t2/live`.
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
* - Sujet : id passé en query param `?sujet=<uuid>` table `sujets` (mode='EO', tache=2)
* - Refus auth 4001, refus plan 4003, sujet introuvable 4004
* - OK openGeminiLiveSession onSessionEnd : correction EO + persistance + report
*/
export default function createT2LiveRoutes(
upgradeWebSocket: UpgradeWebSocket,
opts: CreateT2LiveRoutesOptions = {},
) {
const app = new Hono();
app.get(
"/live",
upgradeWebSocket(async (c) => {
const token = c.req.query("token");
const sujetId = c.req.query("sujet");
let denyCode: number | null = null;
let denyReason = "";
let profile: Profile | null = null;
let sujet: SujetRow | null = null;
const auth = await authenticate(token);
if (!auth.ok) {
denyCode = auth.code;
denyReason = auth.reason;
} else {
profile = auth.profile;
if (!sujetId) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else {
sujet = await fetchSujetT2(sujetId);
if (!sujet) {
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
} else if (!sujet.role || !sujet.contexte) {
// Sécurité : un sujet T2 sans role/contexte ne peut pas alimenter le prompt.
denyCode = 4004;
denyReason = "SUJET_NOT_FOUND";
}
}
}
// 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<typeof ws.send>[0]);
adapter.close = (code?: number, reason?: string) =>
ws.close(code, reason);
if (denyCode !== null) {
try {
ws.send(JSON.stringify({ error: true, code: denyReason }));
} catch {
/* ignore */
}
setTimeout(() => ws.close(denyCode!, denyReason), 100);
return;
}
// À ce stade : profile et sujet sont garantis non-null par les checks ci-dessus.
const profileNonNull = profile!;
const sujetNonNull = sujet!;
openGeminiLiveSession(adapter, {
role: sujetNonNull.role!,
contexte: sujetNonNull.contexte!,
clientFactory: opts.clientFactory,
timeoutMs: opts.timeoutMs,
warningMs: opts.warningMs,
onSessionEnd: async (transcript) => {
await runT2LiveCorrection({
clientWs: adapter,
profile: profileNonNull,
sujet: sujetNonNull,
transcript,
});
},
});
},
onMessage(evt) {
adapter.emit("message", evt.data);
},
onClose() {
adapter.emit("close");
},
onError() {
adapter.emit("error", new Error("CLIENT_ERROR"));
},
};
}),
);
return app;
}

View file

@ -1,36 +0,0 @@
import { Hono } from "hono";
import { authMiddleware } from "../middleware/auth.js";
import type { AppVariables } from "../middleware/auth.js";
import { createTemporaryToken } from "../lib/deepgram.js";
// Sprint 4b — POST /transcriptions/token
//
// Délivre un token Deepgram éphémère (10 min) que le frontend utilise pour
// ouvrir une connexion directe à l'API Deepgram. La clé maître DEEPGRAM_API_KEY
// reste côté serveur. Aucun proxy WebSocket — la transcription live est gérée
// navigateur ↔ Deepgram.
const TOKEN_TTL_SECONDS = 600;
const transcriptions = new Hono<{ Variables: AppVariables }>();
transcriptions.post("/token", authMiddleware, async (c) => {
try {
const { token, expires_in } = await createTemporaryToken(TOKEN_TTL_SECONDS);
return c.json({ token, expires_in }, 200);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[transcriptions.token] generation failed", { message });
return c.json(
{
error: true,
code: "INTERNAL_ERROR",
message:
"Impossible de générer le token de transcription. Veuillez réessayer.",
},
500,
);
}
});
export default transcriptions;

View file

@ -1,26 +0,0 @@
/**
* Routes /users/* Sprint 3.6c.
*
* GET /users/patterns : analyse des patterns récurrents (Premium uniquement).
*/
import { Hono } from 'hono'
import { authMiddleware } from '../middleware/auth.js'
import type { AppVariables } from '../middleware/auth.js'
import { planMiddleware } from '../middleware/plan.js'
import * as patternsController from '../controllers/patternsController.js'
const users = new Hono<{ Variables: AppVariables }>()
users.get('/patterns', authMiddleware, planMiddleware('pattern_analysis'), async (c) => {
const profile = c.get('profile')
const result = await patternsController.list(profile)
if ('error' in result) {
return c.json(result, result.status as 500)
}
return c.json(result.data, 200)
})
export default users

View file

@ -1,39 +0,0 @@
-- Sprint 3.6a — Qualité correction
--
-- Ajoute les champs nécessaires au nouveau prompt maître (revelation, diagnostic,
-- conseil_nclc, erreurs_codes) + au modèle de génération parallèle asynchrone
-- (exercices, modele, leurs statuses) + le NCLC cible choisi par le candidat.
--
-- Les colonnes `score`, `nclc`, `rapport` existantes sont **conservées** pour
-- rollback et cohabitation pendant la fenêtre 3.6a → 3.6b (frontend).
--
-- À exécuter manuellement : `supabase db push` (Hermann — cf. Règle F).
ALTER TABLE productions
ADD COLUMN IF NOT EXISTS revelation JSONB,
ADD COLUMN IF NOT EXISTS diagnostic TEXT,
ADD COLUMN IF NOT EXISTS conseil_nclc JSONB,
ADD COLUMN IF NOT EXISTS erreurs_codes JSONB,
ADD COLUMN IF NOT EXISTS exercices JSONB,
ADD COLUMN IF NOT EXISTS modele JSONB,
ADD COLUMN IF NOT EXISTS nclc_cible INTEGER,
ADD COLUMN IF NOT EXISTS exercices_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS modele_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE productions
DROP CONSTRAINT IF EXISTS productions_nclc_cible_check,
ADD CONSTRAINT productions_nclc_cible_check CHECK (nclc_cible IS NULL OR nclc_cible IN (9, 10));
ALTER TABLE productions
DROP CONSTRAINT IF EXISTS productions_exercices_status_check,
ADD CONSTRAINT productions_exercices_status_check
CHECK (exercices_status IN ('pending', 'ready', 'error'));
ALTER TABLE productions
DROP CONSTRAINT IF EXISTS productions_modele_status_check,
ADD CONSTRAINT productions_modele_status_check
CHECK (modele_status IN ('pending', 'ready', 'error'));
-- Index pour l'analyse patterns (Sprint 3.6c — agrège erreurs_codes sur les 5 dernières productions).
CREATE INDEX IF NOT EXISTS productions_erreurs_codes_gin_idx
ON productions USING GIN (erreurs_codes);

View file

@ -1,37 +0,0 @@
-- Sprint 3.6c — Analyse patterns (Premium).
--
-- Table pattern_analyses : snapshot des patterns récurrents détectés sur les
-- 5 dernières productions corrigées + exercices long terme + indice de préparation.
--
-- Stratégie d'invalidation : on INSERT un nouveau row à chaque recompute (pas
-- d'UPDATE), pour garder un historique des analyses. La plus récente est
-- récupérée via ORDER BY created_at DESC LIMIT 1.
--
-- À exécuter manuellement via `supabase db push` (Règle F).
CREATE TABLE IF NOT EXISTS pattern_analyses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
productions_ids UUID[] NOT NULL,
patterns JSONB NOT NULL,
exercises JSONB NOT NULL,
preparation_index INTEGER NOT NULL,
preparation_message TEXT NOT NULL,
analyzed_count INTEGER NOT NULL DEFAULT 5,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE pattern_analyses
DROP CONSTRAINT IF EXISTS pattern_analyses_preparation_index_check,
ADD CONSTRAINT pattern_analyses_preparation_index_check
CHECK (preparation_index BETWEEN 0 AND 100);
CREATE INDEX IF NOT EXISTS pattern_analyses_user_created_idx
ON pattern_analyses (user_id, created_at DESC);
ALTER TABLE pattern_analyses ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Utilisateur voit ses analyses" ON pattern_analyses;
CREATE POLICY "Utilisateur voit ses analyses"
ON pattern_analyses FOR SELECT
USING (auth.uid() = user_id);

View file

@ -1,38 +0,0 @@
-- Sprint 4a → réorienté Sprint 4b — Audio backend abandonné.
--
-- HISTORIQUE :
-- - Sprint 4a (état initial de cette migration) : créait des policies RLS sur
-- `storage.objects` pour un bucket `audio-productions` destiné à stocker les
-- enregistrements EO côté serveur, après transcription Gemini batch.
-- - Sprint 4b (décision Hermann, 2026-04-25) : changement d'architecture.
-- La transcription live est gérée par Deepgram en connexion DIRECTE depuis le
-- navigateur via un token éphémère (cf. POST /transcriptions/token, route
-- ajoutée Sprint 4b). L'audio brut est téléchargé en local par l'utilisateur
-- et n'est PAS stocké côté serveur. Le backend reçoit uniquement le transcript
-- texte final.
--
-- Cette migration nettoie les policies RLS Storage qui ont pu être créées en
-- environnement de dev pendant le développement Sprint 4a. Elle est idempotente
-- (DROP IF EXISTS) — sûre à rejouer en dev comme en prod.
--
-- Aucun bucket Supabase Storage n'est nécessaire pour le pipeline EO. Le champ
-- `productions.audio_url` reste dans le schéma (héritage du projet initial)
-- mais n'est plus alimenté.
DROP POLICY IF EXISTS "audio_productions_select_owner"
ON storage.objects;
DROP POLICY IF EXISTS "audio_productions_insert_owner"
ON storage.objects;
DROP POLICY IF EXISTS "audio_productions_update_owner"
ON storage.objects;
DROP POLICY IF EXISTS "audio_productions_delete_owner"
ON storage.objects;
-- Vérification post-migration (purement informative) :
-- SELECT policyname FROM pg_policies
-- WHERE schemaname = 'storage' AND tablename = 'objects'
-- AND policyname LIKE 'audio_productions_%';
-- → 0 ligne attendue.

View file

@ -1,30 +0,0 @@
-- Sprint 5a — Idempotency des webhooks Stripe (TD-13)
--
-- Stripe peut livrer le même `event.id` plusieurs fois (retries réseau,
-- rejeu manuel depuis le dashboard). Cette table sert de journal de
-- déduplication : la route `POST /stripe/webhook` consulte la table
-- avant traitement et y insère l'event après succès.
--
-- Stratégie (cf. TD-13) :
-- 1. Avant traitement, `SELECT 1 FROM stripe_webhook_events WHERE id = $1`.
-- Présent → retour 200 immédiat sans rien faire.
-- 2. Après traitement, `INSERT ... ON CONFLICT DO NOTHING`.
--
-- Race window résiduelle (deux deliveries concurrentes passent toutes deux
-- le SELECT initial) couverte par l'idempotence native des opérations
-- métier (`updateUserPlan`, `updateUserStripeInfo`).
--
-- À exécuter manuellement : `supabase db push` (Hermann — cf. Règle F).
-- Idempotent : sûre à rejouer en dev comme en prod.
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index pour les futures purges (rétention ~90 jours envisagée).
CREATE INDEX IF NOT EXISTS stripe_webhook_events_processed_at_idx
ON stripe_webhook_events (processed_at);
COMMENT ON TABLE stripe_webhook_events IS
'Journal de déduplication des webhooks Stripe (Sprint 5a — TD-13).';

View file

@ -16,5 +16,5 @@
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/__tests__/**", "**/*.test.ts"]
"exclude": ["node_modules", "dist"]
}