ephia-protocol
v1.0.0
Published
Contrat universel EphiaAudioEvent — types et schémas
Downloads
130
Readme
ephia-protocol
Contrat universel EphiaAudioEvent — le cœur du système.
Rôle et consommateurs autorisés
ephia-protocol est le contrat wire interne d'Ephia : types, schemas Zod, fixtures et
changelog protocolaire. Voir docs/wire-contract.md et docs/protocol-versioning.md pour le
détail des invariants actuels (protocole v3 : segment.canonical, commandSeq,
ephiaWireServerEventSchema).
Il est consommé directement par :
@ephia/sdk(packages/ephia-sdk) — seul runtime client autorisé à interpréter le wire ;- les tests de contrat de ce package ;
- le backend Python via les fixtures JSON de
src/fixtures/.
Il n'est pas une API cliente. Les apps ne doivent pas importer les schemas wire
(ephiaWireServerEventSchema, etc.) pour parser ou appliquer la dictée directement — elles
consomment uniquement @ephia/sdk, qui réexporte les types publics nécessaires.
Note : le reste de ce README (sections « Enveloppe commune », « Événements par catégorie », exemple
transcript.segment.committed) décrit un modèle d'événements antérieur au protocole v3 actuel et n'a pas été mis à jour dans le cadre de cette PR — à corriger séparément.
Principe fondamental
Protocol > FrameworkCe package est indépendant. Il ne dépend ni du SDK, ni de React, ni de LiveKit, ni du backend.
Il contient :
- types TypeScript des événements publics
- schémas Zod pour validation runtime
- messages client
- états de session
- codes d'erreur
- version du protocole (
3wire /"3.0"REST capabilities)
À quoi sert ce dossier ?
packages/protocol est le contrat partagé du produit audio. Il évite que le backend et le SDK évoluent avec des définitions d'événements divergentes.
Concrètement :
- le backend doit émettre des payloads JSON conformes aux schémas de ce package
- le SDK importe les types pour typer ses reducers, handlers et callbacks
- le SDK valide chaque événement reçu avec
ephiaAudioEventSchema.safeParse() - les tests peuvent vérifier la compatibilité backend/SDK à partir d'une seule source de vérité
Le backend Python n'importe pas le code TypeScript directement. La contrainte est contractuelle : les noms d'événements, champs obligatoires et formats de payload doivent rester compatibles avec les schémas Zod publiés ici.
Enveloppe commune (tous les événements)
{
"protocolVersion": 3,
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"sessionId": "sess_abc",
"type": "transcript.segment.committed",
"seq": 42,
"timestampMs": 1716200000000,
"payload": { "..." },
"meta": { "provider": "voxtral-small", "latencyMs": 340 }
}seq: strictement croissant par sessioneventId: UUID uniquetimestampMs: timestamp serveursessionId: obligatoire sur tous les eventsmeta: optionnel — diagnostics, latences, modes
Règles du protocole
- Aucun
preview/stablene doit écraser un segment déjàcommitted committedinsère la version courante du texte de façon atomiquereviewingsignale une review SMART sans changer le textecorrectedremplace exactement le segmentcommittedcorrespondantreview.rejectedtermine une review sans changement de texte- Ordre événementiel déterministe par
seq
Règle d'atomicité des fusions
transcript.segment.committed est l'événement atomique d'application documentaire.
Lorsqu'un segment absorbe d'autres segments, le payload mergedWith contient les IDs absorbés. Le client doit appliquer le commit final et la suppression des absorbés dans une seule transaction visuelle atomique — jamais en deux étapes séparées.
segment.absorbed est un événement de lifecycle/debug. Il est émis après transcript.segment.committed correspondant. Il ne doit jamais provoquer de suppression visuelle avant le commit fusionné.
Événements par catégorie
Session
| Type | Payload clés | Description |
|---|---|---|
| session.started | clientType, mode, language | Session initiée |
| session.ready | roomName | Room LiveKit prête, dictée possible |
| session.stt_ready | bufferedFramesFlushed | STT initialisé (lazy init terminé) |
| session.paused | — | Session mise en pause |
| session.closed | reason | Session terminée (user_stop\|disconnect\|error\|timeout) |
| session.error | code, message, recoverable | Erreur session |
| session.pong | — | Réponse à un ping keepalive |
| session.context.reset | contextOnly, reason | Contexte backend réinitialisé |
| session.catchup.started | trackName, durationMs | Catchup audio démarré |
| session.catchup.completed | segmentCount, durationMs | Catchup terminé |
| session.catchup.failed | reason | Catchup échoué |
Audio / VAD
| Type | Payload clés | Description |
|---|---|---|
| audio.vad.start | timestampMs | Voix détectée |
| audio.vad.end | timestampMs, durationMs | Fin de voix |
Pipeline segment (dual)
| Type | Payload clés | Description |
|---|---|---|
| segment.opened | segmentId, segmentSeq, startMs | Segment audio ouvert |
| segment.audio.closed | segmentId, endMs, reason | Partie audio du segment terminée |
| segment.dropped | segmentId, reason | Segment silencieux/bruit, ignoré |
| segment.absorbed | segmentId, absorbedInto | Segment fusionné dans un autre |
Transcription — temps réel
| Type | Payload clés | Description |
|---|---|---|
| transcript.preview | segmentId, segmentSeq, text, revision | Texte en cours (peut se répéter) |
| transcript.preview.stable | segmentId, segmentSeq, text, revision | Partie stable du preview |
Transcription — cycle de vie segment
| Type | Payload clés | Description |
|---|---|---|
| transcript.segment.opened | segmentId, segmentSeq, startedAtMs | Début segment (pipeline legacy) |
| transcript.segment.processing | segmentId, durationMs | Finalisation en cours |
| transcript.segment.completed | segmentId, provider | Finalisation terminée |
| transcript.segment.ready | segmentId, text, tags | Texte finalisé (bridge visuel avant commit) |
| transcript.segment.committed | segmentId, text, tags, mergedWith? | Texte définitif — à appliquer atomiquement |
| transcript.segment.reviewing | segmentId, tags | Review SMART en cours |
| transcript.segment.corrected | segmentId, text | Correction review appliquée |
| transcript.segment.revised | segmentId, newText, oldText | Révision post-commit |
| transcript.segment.review.queued | segmentId | Review mise en attente |
| transcript.segment.review.rejected | segmentId, reason, text | Review terminée sans correction |
| transcript.segment.review.failed | segmentId, error | Erreur review |
| transcript.segment.error | segmentId, code, message, recoverable | Erreur finalisation |
Document
| Type | Payload clés | Description |
|---|---|---|
| document.patch | ops[] | Opérations structurelles sur le document |
Flux normal d'un segment
segment.opened
→ transcript.preview (texte interim, 1..N fois)
→ transcript.preview.stable (texte stable, 0..N fois)
→ segment.audio.closed (audio terminé, traitement en cours)
→ transcript.segment.committed ← appliquer atomiquement
→ [transcript.segment.reviewing
→ transcript.segment.corrected | transcript.segment.review.rejected]Exemple de payload committed
{
"protocolVersion": 3,
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"sessionId": "sess_abc",
"type": "transcript.segment.committed",
"seq": 42,
"timestampMs": 1716200000000,
"payload": {
"segmentId": "seg_12",
"segmentSeq": 12,
"text": "Le foie est de taille normale.",
"source": "voxtral-small",
"tags": [{ "tag": "anatomy", "word": "foie" }]
},
"meta": {
"provider": "voxtral-small",
"latencyMs": 820,
"audioDurationMs": 2300
}
}Messages client → backend
| Message | Envoyé via | Description |
|---|---|---|
| { type: "session.pause" } | data_channel | Mettre en pause |
| { type: "session.resume" } | data_channel | Reprendre |
| { type: "session.reset", payload: { contextOnly: true } } | data_channel | Reset contexte |
| { type: "catchup.start", payload: { catchupId, source, sampleRate, channels, bytesTotal, durationMs, clientStartedAtMs?, clientEndedAtMs?, catchupSeq? } } | data_channel | Démarrer un burst PCM16 mono 16 kHz catchup |
| { type: "catchup.chunk", payload: { catchupId, chunkIndex, offsetBytes, dataBase64 } } | data_channel | Envoyer un chunk catchup fiable et ordonné |
| { type: "catchup.end", payload: { catchupId, chunkCount, bytesTotal } } | data_channel | Finaliser le burst catchup |
| { type: "catchup.abort", payload: { catchupId, reason } } | data_channel | Annuler le burst catchup |
| ephia.session.pause | RPC | Identique pause (mode useRpcCommands) |
| ephia.session.resume | RPC | Identique resume (mode useRpcCommands) |
| ephia.session.reset | RPC | Identique reset (mode useRpcCommands) |
Usage
import {
ephiaAudioEventSchema,
type EphiaAudioEvent,
type TranscriptSegmentCommittedEvent,
PROTOCOL_VERSION, // "2.0"
} from "ephia-protocol";
// Validation runtime
const parsed = ephiaAudioEventSchema.safeParse(raw);
if (!parsed.success) {
console.warn("Invalid event:", parsed.error.issues);
return;
}
// Discrimination par type
const event: EphiaAudioEvent = parsed.data;
if (event.type === "transcript.segment.committed") {
const { segmentId, text, tags, mergedWith } = event.payload;
applyToEditor(segmentId, text, { absorbed: mergedWith ?? [] });
}