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:
parent
71c1ad3018
commit
d1c8b548bb
34 changed files with 3255 additions and 70 deletions
59
src/entities/presentation/__tests__/api.test.ts
Normal file
59
src/entities/presentation/__tests__/api.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
23
src/entities/presentation/api.ts
Normal file
23
src/entities/presentation/api.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
23
src/entities/presentation/types.ts
Normal file
23
src/entities/presentation/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
49
src/entities/transcription/__tests__/api.test.ts
Normal file
49
src/entities/transcription/__tests__/api.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
21
src/entities/transcription/api.ts
Normal file
21
src/entities/transcription/api.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
15
src/entities/transcription/types.ts
Normal file
15
src/entities/transcription/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue