feat(eo): complete EO simulation flow (T1 + T3) with Gemini transcription

- Gemini batch transcription (no Deepgram live)
- blobToBase64 helper (shared/lib/audio.ts)
- AudioRecorder: remove onChunk, add maxSeconds/onMaxReached auto-submit
- Timer stops at maxSeconds and triggers auto-submission
- EnregistrementEOPage: audioBase64 to backend, fix race condition step=done
- SimulationFlowProvider: submitEoAudio(audioBase64, mimeType, nclcCible)
- MIME normalization (strip codec params)
- Split CORRECTION_EE_TIMEOUT_MS (60s) / CORRECTION_EO_TIMEOUT_MS (120s)
- PresentationGenereeT1Page: localStorage persistence

Typecheck: OK · Tests: 159/159 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 08:28:51 +03:00
parent 71c1ad3018
commit d1c8b548bb
34 changed files with 3255 additions and 70 deletions

View file

@ -0,0 +1,59 @@
/**
* Tests du domaine `presentation` Sprint 4c-2.
*
* Valide :
* - succès : retourne { presentation }
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { generatePresentation } from '../api'
import type { PresentationReponses } from '../types'
const mocked = vi.mocked(apiFetch)
const VALID_REPONSES: PresentationReponses = {
prenom_age_ville: 'Marie, 32 ans, Douala',
formation_metier: 'Master en gestion, comptable',
situation_familiale: 'Mariée, deux enfants',
loisirs: 'Lecture, cuisine',
motivation_canada: 'Opportunités, départ 2025',
}
describe('generatePresentation', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne la présentation générée et appelle le bon endpoint', async () => {
mocked.mockResolvedValueOnce({ presentation: 'Bonjour, je m appelle Marie...' })
const result = await generatePresentation(VALID_REPONSES)
expect(result.presentation).toContain('Marie')
expect(mocked).toHaveBeenCalledWith('/presentations/generate', {
method: 'POST',
body: { reponses: VALID_REPONSES },
timeoutMs: 25_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'INTERNAL_ERROR',
message: 'DeepSeek down',
})
await expect(generatePresentation(VALID_REPONSES)).rejects.toMatchObject({
code: 'INTERNAL_ERROR',
})
})
})

View file

@ -0,0 +1,23 @@
/**
* Appels API du domaine `presentation` Sprint 4c-2.
*
* `POST /presentations/generate` : timeout 25 s (DeepSeek peut mettre 10-20 s),
* retry désactivé volontairement un POST non-idempotent qui consomme un
* appel DeepSeek ne doit pas être rejoué silencieusement sur erreur réseau.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { PresentationGenerated, PresentationReponses } from './types'
const GENERATE_TIMEOUT_MS = 25_000
export function generatePresentation(
reponses: PresentationReponses,
): Promise<PresentationGenerated> {
return apiFetch<PresentationGenerated>('/presentations/generate', {
method: 'POST',
body: { reponses },
timeoutMs: GENERATE_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,23 @@
/**
* Types publics du domaine `presentation` Sprint 4c-2.
*
* Le domaine couvre la génération assistée d'un texte de présentation
* personnelle (Tâche 1 EO). Aucune persistance backend : le texte généré
* est mirroré côté client (localStorage `expria_eo_t1_presentation`) et
* porté dans le state du `SimulationFlowProvider` pour servir de
* référence pendant l'enregistrement.
*/
/** Réponses au questionnaire — alignées sur le body du backend. */
export interface PresentationReponses {
prenom_age_ville: string
formation_metier: string
situation_familiale: string
loisirs: string
motivation_canada: string
}
/** Réponse de `POST /presentations/generate`. */
export interface PresentationGenerated {
presentation: string
}

View file

@ -48,7 +48,14 @@ export function getReport(id: string): Promise<Report> {
// Sprint 3.6a — le nouveau prompt maître (taxonomie + revelation + diagnostic +
// criteres×6 champs + conseil_nclc + erreurs_codes) produit un JSON long ;
// DeepSeek met typiquement 25-45 s pour répondre. Backend abort à 55 s.
const CORRECTION_TIMEOUT_MS = 60_000
const CORRECTION_EE_TIMEOUT_MS = 60_000
// Sprint 4b.3 — EO en mode audio enchaîne Gemini transcribe (jusqu'à 60 s,
// 30 s + 1 retry de 30 s) puis DeepSeek correction (55 s côté backend).
// Pire cas serveur ≈ 115 s : on alloue 120 s côté client pour ne pas couper
// avant que la mutation aboutisse (le rapport apparaissait sinon dans
// l'historique sans navigation vers /rapport/:id).
const CORRECTION_EO_TIMEOUT_MS = 120_000
/** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`.
* Payload : { simulationId, contenu, tache }
@ -57,7 +64,7 @@ export function correctEe(payload: CorrectEePayload): Promise<Report> {
return apiFetch<Report>('/corrections/ee', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_TIMEOUT_MS,
timeoutMs: CORRECTION_EE_TIMEOUT_MS,
})
}
@ -69,7 +76,7 @@ export function correctEo(payload: CorrectEoPayload): Promise<Report> {
return apiFetch<Report>('/corrections/eo', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_TIMEOUT_MS,
timeoutMs: CORRECTION_EO_TIMEOUT_MS,
})
}

View file

@ -127,10 +127,23 @@ export interface CorrectEePayload {
* Corps de `POST /corrections/eo`.
* transcript : transcription audio envoyée au backend (implémenté Sprint 4).
*/
/**
* Corps de `POST /corrections/eo`.
*
* Modes (XOR exactement un des deux) :
* - `transcript` (Sprint 4) : transcription texte fournie directement par le client.
* - `audioBase64` + `mimeType` (Sprint 4b.2) : audio brut, le backend transcrit
* via Gemini batch puis poursuit le pipeline correction.
*/
export interface CorrectEoPayload {
simulationId: string
transcript: string
tache: string
/** Sprint 4a backend — cible NCLC (9 par défaut, 10 pour viser plus haut). */
nclc_cible?: 9 | 10
transcript?: string
audioBase64?: string
/** MIME du payload audio (audio/webm | audio/mp4 | audio/wav). */
mimeType?: string
}
/**

View file

@ -0,0 +1,49 @@
/**
* Tests du domaine `transcription` Sprint 4c-1.
*
* Valide :
* - succès : retourne le token et expires_in
* - erreur : ApiError propagée par apiFetch
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@/shared/lib/api-client', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '@/shared/lib/api-client'
import { requestDeepgramToken } from '../api'
const mocked = vi.mocked(apiFetch)
describe('requestDeepgramToken', () => {
beforeEach(() => {
mocked.mockReset()
})
it('retourne le token et expires_in en cas de succès', async () => {
mocked.mockResolvedValueOnce({ token: 'dg-temp-abc', expires_in: 600 })
const result = await requestDeepgramToken()
expect(result).toEqual({ token: 'dg-temp-abc', expires_in: 600 })
expect(mocked).toHaveBeenCalledWith('/transcriptions/token', {
method: 'POST',
timeoutMs: 10_000,
retry: { max: 0, baseDelayMs: 0 },
})
})
it('propage les ApiError du backend', async () => {
mocked.mockRejectedValueOnce({
error: true,
code: 'AUTH_REQUIRED',
message: 'Auth required',
})
await expect(requestDeepgramToken()).rejects.toMatchObject({
code: 'AUTH_REQUIRED',
})
})
})

View file

@ -0,0 +1,21 @@
/**
* Appels API du domaine `transcription`.
*
* `POST /transcriptions/token` : timeout 10 s, retry désactivé.
* Le retry est désactivé volontairement : un POST non-idempotent qui
* consomme un crédit Deepgram à chaque appel ne doit pas être rejoué
* silencieusement en cas d'erreur réseau transitoire.
*/
import { apiFetch } from '@/shared/lib/api-client'
import type { TranscriptionToken } from './types'
const TOKEN_TIMEOUT_MS = 10_000
export function requestDeepgramToken(): Promise<TranscriptionToken> {
return apiFetch<TranscriptionToken>('/transcriptions/token', {
method: 'POST',
timeoutMs: TOKEN_TIMEOUT_MS,
retry: { max: 0, baseDelayMs: 0 },
})
}

View file

@ -0,0 +1,15 @@
/**
* Types publics du domaine `transcription`.
*
* Le frontend obtient un token Deepgram éphémère via le backend
* (`POST /transcriptions/token`) puis ouvre une connexion WebSocket
* directe vers Deepgram pour la transcription live. La clé maître
* Deepgram reste côté backend (cf. SECURITY.md).
*/
export interface TranscriptionToken {
/** JWT éphémère Deepgram (durée de vie ~10 min). */
token: string
/** Durée de validité du token, en secondes. */
expires_in: number
}