Commit 9f68eda9 authored by Kourser's avatar Kourser
Browse files

Qui a dit quoi, et pas seulement ce qui a été dit

Les réunions s'enregistraient sans se transcrire. Elles se transcrivent
maintenant, si l'exploitant a branché un moteur — et le moteur est à lui.

Kastell ne transcrit rien lui-même et ne veut pas savoir comment. Il parle
« /v1/audio/transcriptions », le dialecte que servent déjà whisper.cpp,
faster-whisper et les enveloppes autour de Parakeet. Changer de modèle est
une variable, pas une recompilation : une cellule qui travaille en breton
et une autre en anglais n'ont pas le même besoin, et ce n'est pas à
l'éditeur d'arbitrer. Sans moteur, l'enregistrement est marqué « non
transcrite » et le produit le dit.

Une piste audio par participant, et non le composite. Le composite mélange
les voix : transcrit, il donne un mur de texte sans locuteurs. Or « qui a
dit quoi » est ce qu'on relit six mois plus tard pour comprendre une
décision. Le serveur média enregistre chaque personne à part, le nom vient
de la participation, et aucun modèle de diarisation n'a l'occasion de se
tromper sur qui a demandé d'isoler le réseau. Qui arrive en cours
d'enregistrement reçoit sa piste aussi — c'est souvent le renfort qu'on
avait appelé pour trancher.

Le verbatim n'entre PAS au journal, et c'est la décision qui structure tout
le reste. Le journal est scellé ; une transcription doit disparaître avec
l'enregistrement dont elle sort. Y écrire le texte le rendrait
indestructible, et supprimer la vidéo en gardant les paroles serait pire
que ne rien supprimer. Le journal retient donc qu'elle a existé, par quel
moteur, combien de passages. Une seule entrée de main courante l'annonce —
et rappelle qu'elle ne fait pas foi.

Ce que j'ai vérifié plutôt que supposé
  Le contrat, contre un vrai serveur : image tirée, modèle installé, une
  phrase française de synthèse transcrite mot pour mot, langue détectée
  « fr », segments horodatés. Environ deux fois le temps réel sur CPU avec
  le modèle « small ». La forme JSON est bien celle que l'adaptateur lit.

  Le reste, contre un serveur factice écrit dans la suite — même parti que
  la boîte SMTP : pour savoir ce qu'un adaptateur envoie, il faut le lire
  là où un vrai serveur le lirait. Il atteste qu'on réclame « verbose_json »
  et non le texte nu ; sans horodatage, plus de saut dans la vidéo et plus
  de recousage entre les voix.

Vingt garanties neuves, éprouvées en cassant chacune : le tri
chronologique retiré rend « 12000,4000,20000 » ; la suppression désarmée
laisse trois segments ; le format court se voit ; et recopier le verbatim
en main courante — le geste bien intentionné, celui qu'on ferait « pour la
traçabilité » — fait tomber le contrôle de fuite. Ce dernier ne regardait
d'abord que les événements de transcription et aurait laissé passer
exactement ce cas ; il regarde désormais tout le journal.

Deux défauts trouvés en éprouvant. La relance ne reprenait que les échecs,
si bien qu'elle était muette sur une transcription réussie — et le contrôle
qui croyait l'éprouver recomptait les anciens segments. Et le contrôle
« le relais ne monte aucun volume » s'ancrait sur « volumes:\n  base: » :
ajouter un volume avant faisait accuser le relais d'un tort qui n'était pas
le sien.

1204 garanties.

Ce que je n'ai pas éprouvé : un vrai appel à plusieurs voix de bout en
bout. Il faudrait deux participants qui parlent, ce que je ne sais pas
produire ici. La mécanique des pistes est vérifiée contre l'adaptateur
d'essai ; leur production par le vrai serveur média ne l'est pas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent fa743269
Loading
Loading
Loading
Loading
+24 −0
Changes for .env.example: 24 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -179,6 +179,30 @@ LIVEKIT_WS_INTERNAL=ws://livekit:7880
# EN   An empty CLAMAV_HOST disables it: documents are then marked "not
# EN   scanned", never "clean". On first start ClamAV downloads its signatures
# EN   — a few minutes during which uploads remain possible, but unscanned.
# ── Transcription des réunions / Meeting transcription ─────────────────────
#
# L'adresse d'un serveur parlant « /v1/audio/transcriptions ». Vide, les
# réunions s'enregistrent sans être transcrites, et le produit le dit.
#
# Sous le profil « transcription », la composition en lève un :
#   TRANSCRIPTION_URL=http://transcription:8000
#
# Le moteur est à vous. L'image se change par TRANSCRIPTION_IMAGE — whisper.cpp,
# faster-whisper, une enveloppe autour de Parakeet — et le modèle par
# TRANSCRIPTION_MODEL. Kastell ne connaît que le dialecte, pas le moteur.
#
# Le modèle se télécharge à la première demande, une fois :
#   docker compose -f docker/compose.yaml --env-file .env exec transcription \
#     curl -fsS -X POST "http://127.0.0.1:8000/v1/models/$(printf %s "$TRANSCRIPTION_MODEL" | sed 's|/|%2F|')"
#
# EN — Address of a server speaking "/v1/audio/transcriptions". Empty: meetings
# EN   are recorded but not transcribed, and the product says so. The engine is
# EN   yours: change TRANSCRIPTION_IMAGE and TRANSCRIPTION_MODEL. Kastell knows
# EN   the dialect, not the engine.
TRANSCRIPTION_URL=
TRANSCRIPTION_MODEL=Systran/faster-whisper-small
TRANSCRIPTION_KEY=

CLAMAV_HOST=clamav
CLAMAV_PORT=3310

+46 −0
Changes for apps/api/src/adaptateurs/media.ts: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -42,6 +42,13 @@ export interface DemandeEnregistrement {
  cleObjet: string;
}

export interface DemandePiste {
  salle: string;
  /** L'identité chez le fournisseur, c'est-à-dire l'identifiant de compte. */
  identite: string;
  cleObjet: string;
}

export interface ParticipantMedia {
  identite: string;
  nomAffiche: string;
@@ -57,6 +64,15 @@ export interface Media {
  participants(nom: string): Promise<ParticipantMedia[]>;
  jeton(d: DemandeJeton): Promise<Jeton>;
  demarrerEnregistrement(d: DemandeEnregistrement): Promise<Enregistrement>;
  /**
   * Une piste audio par participant, à côté du composite.
   *
   * Le composite mélange les voix : transcrit, il donne un mur de texte sans
   * locuteurs. Or « qui a dit quoi » est ce qu'on relit après coup. Une piste
   * par personne rend les noms exacts, sans modèle de diarisation à faire
   * mentir sur qui a dit d'isoler le réseau.
   */
  demarrerPisteParticipant(d: DemandePiste): Promise<Enregistrement>;
  arreterEnregistrement(tacheId: string): Promise<void>;
}

@@ -196,6 +212,29 @@ export function mediaLiveKit(c: ConfigurationMedia): Media {
      return { tacheId: info.egressId, cleObjet: d.cleObjet };
    },

    async demarrerPisteParticipant(d) {
      // OGG plutôt que MP4 : on ne veut que la voix. La vidéo de chacun pèse
      // lourd, ne sert pas à la transcription, et le composite la porte déjà.
      const sortie = new EncodedFileOutput({
        fileType: EncodedFileType.OGG,
        filepath: d.cleObjet,
        output: {
          case: "s3",
          value: {
            accessKey: c.stockage.acces,
            secret: c.stockage.secret,
            bucket: c.stockage.seau,
            region: c.stockage.region,
            ...(c.stockage.endpoint ? { endpoint: c.stockage.endpoint } : {}),
            forcePathStyle: true,
          },
        },
      });
      const info = await egress.startParticipantEgress(d.salle, d.identite,
        { file: sortie });
      return { tacheId: info.egressId, cleObjet: d.cleObjet };
    },

    async arreterEnregistrement(tacheId) {
      await egress.stopEgress(tacheId);
    },
@@ -233,6 +272,13 @@ export function mediaEssai(): Media & { finirEnregistrement(tacheId: string): vo
      taches.set(tacheId, { salle: d.salle, cleObjet: d.cleObjet, fini: false });
      return { tacheId, cleObjet: d.cleObjet };
    },

    async demarrerPisteParticipant(d) {
      compteur += 1;
      const tacheId = `essai-piste-${compteur}`;
      taches.set(tacheId, { salle: d.salle, cleObjet: d.cleObjet, fini: false });
      return { tacheId, cleObjet: d.cleObjet };
    },
    async arreterEnregistrement(tacheId) {
      const t = taches.get(tacheId);
      if (t) t.fini = true;
+158 −0
Changes for apps/api/src/adaptateurs/transcription.ts: 158 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Transcription des réunions enregistrées (EF-806).
 *
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  Kastell ne transcrit rien lui-même, et ne veut pas savoir comment.      ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * Le contrat est celui que la plupart des serveurs de reconnaissance vocale
 * auto-hébergeables exposent déjà — « POST /v1/audio/transcriptions », un
 * fichier en multipart, du JSON horodaté en retour. whisper.cpp le sert,
 * faster-whisper aussi, et les enveloppes autour de Parakeet également.
 *
 * Ce choix n'est pas de la commodité : il fait du moteur une décision
 * d'exploitation. Changer de modèle, c'est changer une image et une variable —
 * pas recompiler Kastell. Une cellule qui travaille en breton et une autre qui
 * travaille en anglais n'ont pas le même besoin, et ce n'est pas à l'éditeur
 * d'arbitrer.
 *
 * Sans moteur configuré, l'enregistrement est marqué « non transcrite » et le
 * produit le dit. Un texte absent qu'on annonce vaut mieux qu'un texte absent
 * qu'on laisse chercher.
 */

export interface Segment {
  /** Millisecondes depuis le début de la piste, pas une heure absolue. */
  debutMs: number;
  finMs: number;
  texte: string;
}

export interface Transcrit {
  segments: Segment[];
  /** Ce que le moteur dit avoir entendu, quand il le dit. */
  langue: string | null;
}

export interface Transcription {
  readonly nom: string;
  readonly modele: string;
  /**
   * `langue` est un indice, pas un ordre : la plupart des moteurs détectent
   * seuls, et se trompent moins quand on les oriente. Une réunion de crise
   * mélange volontiers deux langues, et forcer la mauvaise coûte tout le texte.
   */
  transcrire(audio: Buffer, nomFichier: string, langue?: string | undefined): Promise<Transcrit>;
}

export class TranscriptionIndisponible extends Error {
  constructor(message: string) {
    super(message);
    this.name = "TranscriptionIndisponible";
  }
}

export const transcriptionInactive: Transcription = {
  nom: "inactif",
  modele: "aucun",
  async transcrire() {
    throw new TranscriptionIndisponible(
      "Aucun moteur de transcription n'est configuré sur cette instance.",
    );
  },
};

export interface ConfigurationTranscription {
  /** L'adresse du serveur, sans le chemin : « http://transcription:8000 ». */
  url: string;
  modele: string;
  /** Jeton, si le serveur en réclame un. La plupart des locaux n'en veulent pas. */
  cle?: string | undefined;
  /** Au-delà, on abandonne : une heure de réunion peut prendre longtemps. */
  delaiMs?: number | undefined;
}

/**
 * Un serveur parlant le dialecte « /v1/audio/transcriptions ».
 *
 * On demande « verbose_json » : le format court ne rend qu'un pavé de texte,
 * sans horodatage. Or c'est l'horodatage qui permet de sauter au bon endroit
 * dans la vidéo, et de recoudre les pistes de plusieurs personnes dans
 * l'ordre où elles ont parlé. Sans lui, la transcription n'est qu'un mur.
 */
export function transcriptionOpenAI(c: ConfigurationTranscription): Transcription {
  const base = c.url.replace(/\/+$/, "");
  const delai = c.delaiMs ?? 30 * 60_000;

  return {
    nom: "openai-compatible",
    modele: c.modele,

    async transcrire(audio, nomFichier, langue) {
      const corps = new FormData();
      corps.append("file", new Blob([new Uint8Array(audio)]), nomFichier);
      corps.append("model", c.modele);
      corps.append("response_format", "verbose_json");
      if (langue) corps.append("language", langue);

      const abandon = AbortSignal.timeout(delai);
      let reponse: Response;
      try {
        reponse = await fetch(`${base}/v1/audio/transcriptions`, {
          method: "POST", body: corps, signal: abandon,
          headers: c.cle ? { authorization: `Bearer ${c.cle}` } : {},
        });
      } catch (e) {
        // Le moteur peut être long à charger son modèle au premier appel : on
        // dit lequel des deux a lâché, parce qu'on ne les répare pas pareil.
        throw new TranscriptionIndisponible(
          abandon.aborted
            ? `Le moteur n'a pas répondu en ${Math.round(delai / 60_000)} minutes.`
            : `Moteur de transcription injoignable : ${
              e instanceof Error ? e.message : String(e)}`,
        );
      }
      if (!reponse.ok) {
        throw new TranscriptionIndisponible(
          `Le moteur a refusé la piste (${reponse.status}) : `
          + `${(await reponse.text()).slice(0, 200)}`,
        );
      }

      const rendu = await reponse.json() as {
        text?: string; language?: string;
        segments?: { start?: number; end?: number; text?: string }[];
      };

      // Un moteur qui ne rend pas de segments a quand même transcrit : plutôt
      // que de perdre le texte, on en fait un segment unique. Il perd son
      // découpage, pas son contenu.
      const bruts = rendu.segments ?? [];
      const segments: Segment[] = bruts.length > 0
        ? bruts.map((s) => ({
          debutMs: Math.max(0, Math.round((s.start ?? 0) * 1000)),
          finMs: Math.max(0, Math.round((s.end ?? s.start ?? 0) * 1000)),
          texte: (s.text ?? "").trim(),
        })).filter((s) => s.texte.length > 0)
        : (rendu.text ?? "").trim().length > 0
          ? [{ debutMs: 0, finMs: 0, texte: rendu.text!.trim() }]
          : [];

      return { segments, langue: rendu.language ?? null };
    },
  };
}

let courant: Transcription = transcriptionInactive;

export function definirTranscription(t: Transcription): void {
  courant = t;
}

export function transcription(): Transcription {
  return courant;
}

export function transcriptionConfiguree(): boolean {
  return courant !== transcriptionInactive;
}
+90 −0
Changes for apps/api/src/db/migrations/0038_transcription.sql: 90 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Transcription des réunions enregistrées (EF-806).
--
-- Ce qu'elle est, et ce qu'elle n'est pas
--   Une aide à la relecture, pas un registre. La main courante reste le
--   journal des décisions, tenu par des humains et scellé ; une transcription
--   est produite par une machine qui se trompe. Les confondre reviendrait à
--   faire dire au journal des choses que personne n'a validées — et le journal
--   ne vaut que parce que personne ne peut lui en faire dire.
--
--   Une seule entrée de main courante annonce qu'elle existe, comme pour
--   l'enregistrement lui-même. Le texte, lui, vit ici.
--
-- Pourquoi une piste par participant
--   L'enregistrement composite mélange les voix : une transcription de ce
--   fichier donne un mur de texte sans locuteurs. Or « qui a dit quoi » est
--   ce qu'on relit après coup, pour comprendre une décision. Le serveur média
--   sait enregistrer chaque participant à part ; le nom vient alors de la
--   participation, sans modèle de diarisation à faire mentir.
-- ═══════════════════════════════════════════════════════════════════════════

-- Une piste audio par participant, à côté du composite vidéo.
create table enregistrement_piste (
  crise_id          uuid        not null references crise(id) on delete restrict,
  enregistrement_id uuid        not null,
  id                uuid        not null,
  -- Nul si la personne n'a pas de compte : le nom affiché fait alors foi.
  compte_id         uuid        references compte(id) on delete set null,
  nom_affiche       text        not null,
  -- Identifiant de la tâche chez le fournisseur, et clé de l'objet produit.
  tache_fournisseur text,
  cle_objet         text        not null,
  statut            text        not null default 'en_cours'
                      check (statut in ('en_cours','disponible','echec')),
  motif_echec       text,
  cree_at           timestamptz not null default now(),
  primary key (crise_id, id),
  foreign key (crise_id, enregistrement_id)
    references enregistrement (crise_id, id) on delete cascade
);
create index piste_enregistrement on enregistrement_piste (crise_id, enregistrement_id);

create table transcription (
  crise_id          uuid        not null references crise(id) on delete restrict,
  id                uuid        not null,
  enregistrement_id uuid        not null,
  -- « non_transcrite » n'est pas un échec : c'est une instance sans moteur, et
  -- le produit doit le dire plutôt que de laisser croire à un oubli.
  statut            text        not null default 'en_cours'
                      check (statut in ('en_cours','disponible','echec','non_transcrite')),
  -- Ce qui a produit ce texte. Une transcription qu'on relit dans six mois ne
  -- vaut que si l'on sait de quel moteur et de quel modèle elle sort : ils ne
  -- se trompent pas de la même façon, et on ne les corrige pas pareil.
  moteur            text,
  modele            text,
  langue            text,
  demarre_at        timestamptz not null default now(),
  fini_at           timestamptz,
  motif_echec       text,
  -- Le document lisible déposé dans « Enregistrements », s'il a pu l'être.
  document_id       uuid,
  primary key (crise_id, id),
  foreign key (crise_id, enregistrement_id)
    references enregistrement (crise_id, id) on delete cascade
);
-- Une seule transcription par enregistrement : la refaire remplace, elle ne
-- s'empile pas. Deux versions d'un même verbatim, c'est deux vérités.
create unique index transcription_par_enregistrement
  on transcription (crise_id, enregistrement_id);

create table transcription_segment (
  crise_id          uuid        not null,
  transcription_id  uuid        not null,
  ordre             integer     not null,
  locuteur_compte_id uuid       references compte(id) on delete set null,
  locuteur_nom      text        not null,
  -- Millisecondes depuis le début de l'enregistrement, pas une heure absolue :
  -- c'est ainsi qu'on saute au bon endroit dans la vidéo.
  debut_ms          integer     not null,
  fin_ms            integer     not null,
  texte             text        not null,
  primary key (crise_id, transcription_id, ordre),
  foreign key (crise_id, transcription_id)
    references transcription (crise_id, id) on delete cascade
);

-- La recherche plein texte sans dictionnaire de langue : le dossier peut être
-- en français comme en anglais, et « simple » ne prétend pas trancher.
create index transcription_segment_texte
  on transcription_segment using gin (to_tsvector('simple', texte));
+417 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading