Commit 7d217c0c authored by Kourser's avatar Kourser
Browse files

Le moteur de transcription était livré, l'installateur ne le disait pas

La composition portait un service de transcription sous son profil, les
variables existaient, la documentation aussi — et « installer.sh » ne
contenait pas une fois le mot. Il recopiait « TRANSCRIPTION_URL= » depuis
.env.example avec son commentaire, ce qui se lit comme « fournissez l'adresse
d'un service extérieur ». Qui voulait une instance entièrement autonome
n'avait aucun moyen de savoir que le moteur était déjà là, à un profil près.

L'installateur pose désormais la question, sur le modèle exact de celle du
serveur média : moteur livré par la composition, moteur exploité ailleurs, ou
aucune transcription. L'autonomie est le premier choix et le défaut. Elle
joint le moteur par « http://transcription:8000 » — le nom du service dans la
composition, pas une adresse publique : rien de ce qui entend la cellule ne
sort de la machine.

── Parakeet ne marche pas avec le moteur livré

C'est la découverte de cette passe, et elle contredit ce que le produit
promettait. L'image épinglée a été lancée et son registre interrogé plutôt que
cru sur parole : 560 modèles de reconnaissance vocale, aucun Parakeet. C'est
un faster-whisper ; Parakeet demande NVIDIA NeMo, donc une autre image.

Le premier jet de cette même passe écrivait « un modèle Parakeet se met ici de
la même façon », et sa garantie était verte — elle vérifiait qu'un champ libre
acceptait la chaîne, pas que le moteur la servait. L'exploitant l'aurait
découvert à la première réunion transcrite, c'est-à-dire en crise.

Parakeet est maintenant nommé là où il fonctionne : la voie « moteur que vous
exploitez déjà ». L'installateur, .env.example et la composition disent que
l'image livrée ne sert que du faster-whisper.

── Cinq modèles nommés, et de quoi vérifier qu'ils existent

Le champ libre devient un menu : small pour tourner partout, medium pour les
voix qui se coupent, large-v3-turbo pour qui a une carte graphique, un whisper
entraîné sur du français seul — les noms propres et les sigles sont ce qu'une
transcription rate en premier et ce qu'on relit d'abord —, tiny pour une
machine modeste. Le sixième choix reste la saisie libre.

docker/transcription-modeles.txt relève les 560 identifiants que sert l'image,
obtenus d'elle et non d'une documentation, avec en en-tête la commande et le
condensat dont ils sortent. La garantie vérifie que chaque modèle offert y
figure : changer TRANSCRIPTION_IMAGE demande de refaire ce relevé, et l'en-tête
le dit.

── Deux corrections que la question a rendues nécessaires

« --profile » ne prend qu'une valeur, et le script n'en gérait qu'un seul.
Deux profils demandent deux drapeaux, sans quoi « mobile transcription » aurait
été passé comme un nom de profil unique, inexistant.

Le contrôle du plan de démarrage s'arrêtait au premier « fi » et n'exécutait
que la moitié du bloc — vert sur un shell qui ne fait rien, ce qui est le pire
des résultats possibles.

Les garanties n'inspectent pas le texte du script : elles exécutent ses blocs
avec des réponses imposées et lisent ce qu'il décide. C'est là qu'était le
défaut à attraper — un mode écrit dans .env que personne ne relit ne lève aucun
service, et l'exploitant croit avoir un moteur.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 9108fa7b
Loading
Loading
Loading
Loading
Loading
+11 −0
Changes for .env.example: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -191,6 +191,10 @@ LIVEKIT_WS_INTERNAL=ws://livekit:7880
# faster-whisper, une enveloppe autour de Parakeet — et le modèle par
# TRANSCRIPTION_MODEL. Kastell ne connaît que le dialecte, pas le moteur.
#
# L'image livrée par défaut est un faster-whisper : elle ne sert pas Parakeet,
# qui demande NVIDIA NeMo. Les modèles qu'elle connaît sont relevés dans
# docker/transcription-modeles.txt.
#
# 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|')"
@@ -199,6 +203,13 @@ LIVEKIT_WS_INTERNAL=ws://livekit:7880
# 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.
# « livre » lève le moteur de la composition et lui parle par le réseau de
# celle-ci ; « externe » s'adresse à un moteur que vous exploitez déjà. Vide,
# les réunions s'enregistrent sans être transcrites.
#
# EN — "livre" starts the composition's own engine; "externe" talks to one you
# EN   already run. Empty: recorded, not transcribed.
TRANSCRIPTION_MODE=
TRANSCRIPTION_URL=
TRANSCRIPTION_MODEL=Systran/faster-whisper-small
TRANSCRIPTION_KEY=
+156 −0
Changes for apps/api/src/verification/installateur.ts: 156 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -104,4 +104,160 @@ export async function verifierInstallateur(r: Rapport): Promise<void> {
    /^\s*external_tls:\s*true\s*$/m.test(avecTls.contenu));
  r.verifie("EF-804", "sous le nom distinct que l'exploitant a donné",
    /^\s*domain:\s*turn\.kastell\.exemple\.fr\s*$/m.test(avecTls.contenu));

  // ═════════════════════════════════════════════════════════════════════════
  r.titre("Ce que l'installateur propose pour la transcription");

  /**
   * Une instance autonome, ou aucune.
   *
   * Le nécessaire existait — un service dans la composition, les variables,
   * la documentation — et l'installateur n'en disait pas un mot. Il recopiait
   * « TRANSCRIPTION_URL= » depuis « .env.example » avec son commentaire, ce
   * qui se lisait comme « fournissez l'adresse d'un service extérieur ». Un
   * exploitant qui voulait une instance entièrement autonome n'avait aucun
   * moyen de savoir que le moteur était déjà là, à un profil près.
   *
   * On éprouve donc les trois voies en jouant le bloc, et non en relisant le
   * script : c'est ce que le shell décide qui compte.
   */
  const lignes = script.split("\n");
  // « combien » : le bloc du plan enchaîne deux « if », et s'arrêter au
  // premier « fi » en aurait rendu la moitié — vert sur un shell qui ne fait
  // rien, ce qui est le pire des résultats possibles.
  const morceau = (depart: string, terminateur: string, combien = 1): string => {
    const debut = lignes.findIndex((l) => l.includes(depart));
    if (debut < 0) throw new Error(`Bloc introuvable : ${depart}`);
    let reste = combien;
    const fin = lignes.findIndex((l, i) =>
      i > debut && l === terminateur && --reste === 0);
    if (fin < 0) throw new Error(`Fin introuvable : ${depart}`);
    return lignes.slice(debut, fin + 1).join("\n");
  };

  const question = morceau("── Transcription des réunions enregistrées", "  done");
  const plan = morceau('mode_transcription="$(valeur_env TRANSCRIPTION_MODE)"', "fi", 2);

  /** Joue la question avec une réponse imposée, et rend ce qu'elle a décidé. */
  async function repondre(choix: string, suite: string[] = []): Promise<{
    mode: string; url: string; modele: string;
  }> {
    const dossier = await mkdtemp(join(tmpdir(), "kastell-transcription-"));
    const reponses = [choix, ...suite];
    const prelude = [
      "set -eu",
      "LANGUE=fr; dire() { :; }",
      `_i=0; _rep='${reponses.join("|")}'`,
      // Rend les réponses dans l'ordre, comme le ferait quelqu'un au clavier.
      "demander_b() { _i=$((_i+1)); REPONSE=\"$(printf '%s' \"$_rep\" | cut -d'|' -f$_i)\"; }",
    ].join("\n");
    await writeFile(join(dossier, "essai.sh"),
      `${prelude}\n${question}\n`
      + "printf '%s\\n%s\\n%s\\n' \"$transcription_mode\" \"$transcription_url\" "
      + "\"$transcription_modele\" > .out\n");
    await executer("sh", ["essai.sh"], { cwd: dossier });
    const [mode, url, modele] = (await readFile(join(dossier, ".out"), "utf8")).split("\n");
    return { mode: mode ?? "", url: url ?? "", modele: modele ?? "" };
  }

  const livre = await repondre("1", ["1"]);
  r.verifie("EF-806", "l'installateur propose un moteur livré, et c'est le premier choix",
    livre.mode === "livre", livre.mode || "aucun mode");
  r.verifie("EF-806", "il le joint par le réseau de la composition, sans rien exposer",
    livre.url === "http://transcription:8000", livre.url);
  r.verifie("EF-806", "et propose des modèles nommés, dont un défaut qui tourne partout",
    livre.modele === "Systran/faster-whisper-small", livre.modele);

  /**
   * Les modèles offerts, éprouvés contre ce que l'image sert vraiment.
   *
   * Le premier jet proposait « un modèle Parakeet se met ici de la même
   * façon ». C'est faux : l'image livrée est un faster-whisper, et son
   * registre — interrogé, pas supposé — n'en connaît aucun. L'exploitant
   * l'aurait découvert à la première réunion transcrite, c'est-à-dire en
   * crise.
   *
   * La liste de référence vient de l'image elle-même. Ce contrôle vaut donc
   * exactement ce que vaut ce relevé, et son en-tête dit de quel condensat il
   * sort : changer TRANSCRIPTION_IMAGE demande de le refaire.
   */
  const connus = new Set((await readFile(`${racine}docker/transcription-modeles.txt`, "utf8"))
    .split("\n").filter((l) => l.length > 0 && !l.startsWith("#")));
  const offerts: string[] = [];
  for (const choix of ["1", "2", "3", "4", "5"]) {
    offerts.push((await repondre("1", [choix])).modele);
  }
  const inconnus = offerts.filter((m) => !connus.has(m));
  r.verifie("EF-806", `les ${offerts.length} modèles proposés sont servis par l'image livrée`,
    inconnus.length === 0 && offerts.length === 5, inconnus.join(", ") || offerts.join(", "));
  // Une cellule française a un intérêt direct à un modèle entraîné sur du
  // français : les noms propres et les sigles sont ce qu'une transcription
  // rate en premier, et ce qu'on relit en priorité.
  r.verifie("EF-806", "dont un entraîné sur du français seul",
    offerts.some((m) => /french|fr-/.test(m)), offerts.join(", "));
  const saisi = await repondre("1", ["6", "Systran/faster-whisper-base"]);
  r.verifie("EF-806", "et l'on peut toujours en saisir un autre",
    saisi.modele === "Systran/faster-whisper-base", saisi.modele);

  // Ce que le moteur livré ne sait pas faire ne doit être promis nulle part.
  const bloc_livre = question.slice(question.indexOf('transcription_mode="livre"'),
    question.indexOf('transcription_mode="externe"'));
  r.verifie("EF-806", "et la voie « moteur livré » ne promet aucun Parakeet",
    !/[Pp]arakeet/.test(bloc_livre));
  r.verifie("EF-806", "quand la voie « ailleurs » dit que c'est par là qu'il passe",
    /[Pp]arakeet/.test(question.slice(question.indexOf('transcription_mode="externe"'))));

  const externe = await repondre("2", ["http://172.17.0.1:9000", "grand-modele"]);
  r.verifie("EF-806", "un moteur exploité ailleurs reste possible",
    externe.mode === "externe" && externe.url === "http://172.17.0.1:9000",
    `${externe.mode} ${externe.url}`);

  const aucune = await repondre("3");
  r.verifie("EF-806", "et l'on peut n'en vouloir aucune, sans adresse à inventer",
    aucune.mode === "" && aucune.url === "");

  // ── Le choix atteint la composition, ou il ne sert à rien ────────────────
  //
  // C'est ici que le défaut se logeait : un mode écrit dans .env que personne
  // ne lit ne lève aucun service, et l'exploitant croit avoir un moteur.
  async function lever(env: Record<string, string>): Promise<{
    services: string; profils: string;
  }> {
    const dossier = await mkdtemp(join(tmpdir(), "kastell-levee-"));
    const prelude = [
      "set -eu",
      ...Object.entries(env).map(([c, x]) => `${c}='${x}'`),
      "valeur_env() { eval \"printf '%s' \\\"\\${$1:-}\\\"\"; }",
      "services='postgres redis minio app'; profils=''",
    ].join("\n");
    await writeFile(join(dossier, "essai.sh"),
      `${prelude}\n${plan}\nprintf '%s\\n%s\\n' "$services" "$profils" > .out\n`);
    await executer("sh", ["essai.sh"], { cwd: dossier });
    const [services, profils] = (await readFile(join(dossier, ".out"), "utf8")).split("\n");
    return { services: services ?? "", profils: profils ?? "" };
  }

  const leveLivre = await lever({ TRANSCRIPTION_MODE: "livre", TRANSCRIPTION_URL: "" });
  r.verifie("EF-806", "un moteur livré lève son service et arme son profil",
    leveLivre.services.includes("transcription")
      && leveLivre.profils.includes("transcription"),
    `${leveLivre.services} —${leveLivre.profils}`);
  const leveExterne = await lever({ TRANSCRIPTION_MODE: "externe",
    TRANSCRIPTION_URL: "http://ailleurs:8000" });
  r.verifie("EF-806", "un moteur d'ailleurs n'en lève aucun",
    !leveExterne.services.includes("transcription")
      && !leveExterne.profils.includes("transcription"));
  // Un .env écrit avant que ce réglage existe porte une adresse et pas de
  // mode : le supposer « livré » lèverait un service que personne n'a demandé.
  const leveAncien = await lever({ TRANSCRIPTION_MODE: "",
    TRANSCRIPTION_URL: "http://ailleurs:8000" });
  r.verifie("EF-806", "et un .env d'avant ce réglage est lu comme « ailleurs », pas comme « ici »",
    !leveAncien.services.includes("transcription"));

  // ── Ce que le .env retient ───────────────────────────────────────────────
  for (const cle of ["TRANSCRIPTION_MODE", "TRANSCRIPTION_URL", "TRANSCRIPTION_MODEL",
    "TRANSCRIPTION_KEY"]) {
    r.verifie("EF-806", `le .env écrit porte ${cle}`,
      new RegExp(`^${cle}=\\$`, "m").test(script));
  }
}
+5 −0
Changes for docker/compose.yaml: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -276,6 +276,11 @@ services:
  # d'exploitation, qui dépend de la langue de vos cellules et de la machine
  # dont vous disposez.
  #
  # Le défaut est un moteur faster-whisper, et lui seul : il ne sert AUCUN
  # modèle Parakeet, qui demande NVIDIA NeMo — donc une autre image. Ce qu'il
  # sert est relevé dans docker/transcription-modeles.txt, obtenu de l'image
  # elle-même plutôt que d'une documentation.
  #
  # Le défaut sert un modèle Whisper sur processeur. Éprouvé sur du français :
  # le modèle « small » transcrit correctement, à peu près deux fois le temps
  # réel sur un cœur récent. Un GPU change l'ordre de grandeur ; « medium » et
+576 −0

File added.

Preview size limit exceeded, changes collapsed.

+177 −5
Changes for outils/installer.sh: 177 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -1401,6 +1401,147 @@ if [ "$ecrire_env" = "oui" ]; then
    esac
  fi


  echo
  dire "── Transcription des réunions enregistrées ────────────────────────────" \
       "── Transcription of recorded meetings ────────────────────────────────"
  echo
  dire "  Un enregistrement de deux heures ne se relit pas. Transcrit, il se" \
       "  A two-hour recording never gets re-read. Transcribed, it becomes"
  dire "  cherche — et « qui a dit quoi » se retrouve, parce que chaque" \
       "  searchable — and \"who said what\" is recoverable, because each"
  dire "  participant est enregistré sur sa propre piste." \
       "  participant is recorded on their own track."
  echo
  dire "  Le moteur peut tourner ici : c'est de la parole de crise, et elle" \
       "  The engine can run right here: this is crisis speech, and it does not"
  dire "  n'a aucune raison de sortir de la machine." \
       "  have to leave the machine."
  echo
  transcription_mode=""
  transcription_url=""
  transcription_modele="Systran/faster-whisper-small"
  transcription_cle=""
  while true; do
    dire "  1) Moteur livré par la composition — rien à installer, rien ne sort." \
         "  1) Engine shipped by the composition — nothing to install, nothing leaves."
    dire "  2) Moteur que vous exploitez déjà, ailleurs." \
         "  2) An engine you already run, elsewhere."
    dire "  3) Aucune transcription — les réunions s'enregistrent, sans texte." \
         "  3) No transcription — meetings are recorded, without text."
    echo
    demander_b "Votre choix" "Your choice" "1"
    case "$REPONSE" in
      1)
        transcription_mode="livre"
        # Le nom du service dans la composition, et non une adresse publique :
        # le moteur n'est joignable que depuis le réseau de la composition, ce
        # qui est exactement ce qu'on veut d'un service qui entend tout.
        transcription_url="http://transcription:8000"
        echo
        dire "  Le modèle. Tous transcrivent le français ; ils diffèrent par ce" \
             "  The model. All of them transcribe French; they differ in what"
        dire "  qu'ils demandent à la machine et par ce qu'ils rendent d'un" \
             "  they ask of the machine and in what they make of a noisy room."
        dire "  local bruyant." \
             "  "
        echo
        while true; do
          dire "  1) small — deux fois le temps réel sur un processeur ordinaire." \
               "  1) small — twice real time on an ordinary CPU. The safe choice."
          dire "     Le choix sûr : il tourne partout." \
               "     It runs anywhere."
          dire "  2) medium — plus juste sur les voix qui se coupent, plus lent." \
               "  2) medium — better on people talking over each other, slower."
          dire "  3) large-v3-turbo — la qualité des grands modèles, sans leur" \
               "  3) large-v3-turbo — large-model quality without their slowness."
          dire "     lenteur. Une carte graphique est vivement conseillée." \
               "     A graphics card is strongly advised."
          dire "  4) whisper français — entraîné sur du français seul. Meilleur" \
               "  4) French whisper — trained on French alone. Better on names"
          dire "     sur les noms propres et les sigles, inutile ailleurs." \
               "     and acronyms, useless for anything else."
          dire "  5) tiny — pour une machine modeste ou un simple essai." \
               "  5) tiny — for a modest machine, or just to try."
          dire "  6) Un autre — vous saisissez son identifiant." \
               "  6) Another one — you type its identifier."
          echo
          demander_b "Votre choix" "Your choice" "1"
          case "$REPONSE" in
            1) transcription_modele="Systran/faster-whisper-small" ;;
            2) transcription_modele="Systran/faster-whisper-medium" ;;
            3) transcription_modele="deepdml/faster-whisper-large-v3-turbo-ct2" ;;
            4) transcription_modele="bofenghuang/whisper-large-v2-cv11-french-ct2" ;;
            5) transcription_modele="Systran/faster-whisper-tiny" ;;
            6)
              echo
              dire "  Le moteur livré sert les modèles faster-whisper. Ceux qu'il" \
                   "  The shipped engine serves faster-whisper models. The ones it"
              dire "  connaît sont listés dans docker/transcription-modeles.txt." \
                   "  knows are listed in docker/transcription-modeles.txt."
              echo
              demander_b "Identifiant du modèle" "Model identifier" \
                         "Systran/faster-whisper-small"
              transcription_modele="$REPONSE"
              ;;
            *)
              dire "     Répondez de 1 à 6." \
                   "     Answer 1 to 6."
              continue
              ;;
          esac
          break
        done
        echo
        dire "  Le modèle se télécharge à la première transcription demandée." \
             "  The model downloads on the first transcription requested. That"
        dire "  Celle-là sera lente ; les suivantes non." \
             "  first one will be slow; the next ones will not."
        ;;
      2)
        transcription_mode="externe"
        echo
        dire "  L'adresse d'un serveur parlant « /v1/audio/transcriptions »." \
             "  The address of a server speaking \"/v1/audio/transcriptions\"."
        dire "  Ce que Kastell lui envoie, ce sont les voix de la cellule." \
             "  What Kastell sends it is the cell's own voices."
        echo
        dire "  C'est aussi par ici que passe Parakeet : il demande NVIDIA NeMo," \
             "  This is also the way in for Parakeet: it wants NVIDIA NeMo, that"
        dire "  donc un autre moteur que celui de la composition, qui ne sert" \
             "  is, an engine other than the composition's, which serves only"
        dire "  que des modèles faster-whisper." \
             "  faster-whisper models."
        echo
        demander_b "Adresse du service" "Service address" "http://172.17.0.1:8000"
        transcription_url="$REPONSE"
        demander_b "Modèle demandé" "Model to request" "Systran/faster-whisper-small"
        transcription_modele="$REPONSE"
        echo
        dire "  Si ce service réclame une clé, collez-la dans .env sous" \
             "  If that service wants a key, paste it into .env under"
        dire "  TRANSCRIPTION_KEY : saisie à une invite, elle resterait dans" \
             "  TRANSCRIPTION_KEY: typed at a prompt it would linger in the"
        dire "  l'historique du shell." \
             "  shell history."
        ;;
      3)
        echo
        dire "  Les enregistrements resteront lisibles et téléchargeables." \
             "  Recordings stay readable and downloadable. The product says"
        dire "  Le produit dit « non transcrite » plutôt que de laisser croire" \
             "  \"not transcribed\" rather than letting an empty page look like"
        dire "  à un oubli." \
             "  an oversight."
        ;;
      *)
        dire "     Répondez 1, 2 ou 3." \
             "     Answer 1, 2 or 3."
        continue
        ;;
    esac
    break
  done
  echo
  dire "── Analyse antivirale des dépôts (ClamAV) ─────────────────────────────" \
       "── Antivirus scanning of uploads (ClamAV) ────────────────────────────"
@@ -1593,6 +1734,16 @@ LIVEKIT_CONFIG=$livekit_config
LIVEKIT_REDIS=$livekit_redis
LIVEKIT_WS_INTERNAL=$livekit_ws_interne

# Transcription of recorded meetings. "livre" starts the composition's own
# engine and talks to it over the composition network — nothing leaves the
# machine; "externe" talks to one you already run. Empty: meetings are
# recorded but not transcribed, and the product says so rather than leaving a
# blank page that looks like a bug.
TRANSCRIPTION_MODE=$transcription_mode
TRANSCRIPTION_URL=$transcription_url
TRANSCRIPTION_MODEL=$transcription_modele
TRANSCRIPTION_KEY=$transcription_cle

# Antivirus scanning. An empty CLAMAV_HOST disables it: documents are then
# marked "not scanned", never "clean".
CLAMAV_HOST=$clamav_hote
@@ -1805,6 +1956,7 @@ fi

# Cette liste suit docker/compose.yaml : un service ajouté là doit l'être ici.
services="postgres redis minio app"
profils=""
[ -n "$(valeur_env CLAMAV_HOST)" ] && services="$services clamav"
# Le mode dit si le serveur média est le nôtre ou celui d'un autre. Un .env
# écrit avant que ce réglage existe n'en porte pas : une adresse renseignée
@@ -1820,6 +1972,19 @@ case "$(valeur_env LIVEKIT_EGRESS)" in
  *)   [ "$mode_media" = "livre" ] && services="$services egress" ;;
esac

# La transcription se lève comme le serveur média : un mode dit si le moteur
# est le nôtre. Un .env écrit avant que ce réglage existe n'en porte pas — une
# adresse renseignée valait alors « un moteur ailleurs », et c'est ce qu'on
# suppose plutôt que de lever un service que personne n'a demandé.
mode_transcription="$(valeur_env TRANSCRIPTION_MODE)"
if [ -z "$mode_transcription" ] && [ -n "$(valeur_env TRANSCRIPTION_URL)" ]; then
  mode_transcription="externe"
fi
if [ "$mode_transcription" = "livre" ]; then
  services="$services transcription"
  profils="$profils transcription"
fi

# Le relais est un profil, pas une variable : la question se pose donc ici, et
# elle se pose aussi quand on repart d'un .env conservé.
echo
@@ -1835,9 +2000,8 @@ dire " l'application lit avant de proposer de s'enrôler. Le SMS et l'appel" \
dire "  vocal restent les canaux de mobilisation de premier rang." \
     "  mobilisation channels."
echo
profil=""
if oui_non_b "Démarrer le relais de heurtoir ?" "Start the knocker relay?" "n"; then
  profil="mobile"
  profils="$profils mobile"
  services="$services relais"
  echo
  dire "  Sans la clé .p8 d'Apple, le relais démarre et refuse chaque heurtoir" \
@@ -1881,7 +2045,15 @@ case "$mode_media" in
                  "  Media      : shared server, $(valeur_env LIVEKIT_INTERNAL_URL)" ;;
  *)         dire "  Média      : désactivé" "  Media      : disabled" ;;
esac
[ -n "$profil" ] && dire "  Profil     : $profil" "  Profile    : $profil"
case "$mode_transcription" in
  livre)   dire "  Transcript.: moteur livré, $(valeur_env TRANSCRIPTION_MODEL)" \
                "  Transcript.: shipped engine, $(valeur_env TRANSCRIPTION_MODEL)" ;;
  externe) dire "  Transcript.: moteur externe, $(valeur_env TRANSCRIPTION_URL)" \
                "  Transcript.: external engine, $(valeur_env TRANSCRIPTION_URL)" ;;
  *)       dire "  Transcript.: aucune — les réunions s'enregistrent sans texte" \
                "  Transcript.: none — meetings are recorded without text" ;;
esac
[ -n "$profils" ] && dire "  Profils    :$profils" "  Profiles   :$profils"
if [ -n "$revision" ]; then
  dire "  Révision   : $revision" "  Revision   : $revision"
else
@@ -1926,7 +2098,7 @@ echo
# La commande exacte, et non une commande générique : recopiée telle quelle,
# « up -d --build » sans liste lèverait les conteneurs qu'on vient d'écarter.
commande="docker compose -f $COMPOSE --env-file .env"
[ -n "$profil" ] && commande="$commande --profile $profil"
for _p in $profils; do commande="$commande --profile $_p"; done
commande="$commande up -d --build $services"

if ! oui_non_b "Lever la composition maintenant ?" "Bring the composition up now?" "o"; then
@@ -1941,7 +2113,7 @@ fi

echo
set -- -f "$COMPOSE" --env-file .env
[ -n "$profil" ] && set -- "$@" --profile "$profil"
for _p in $profils; do set -- "$@" --profile "$_p"; done

# $services est découpé volontairement : c'est une liste de noms construite
# plus haut, jamais une saisie de l'utilisateur.