Lección 6 — Éprouver et exploiter

Exploiter : journal, données personnelles, coût, nettoyage

9 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

Exploiter : journal, données personnelles, coût, nettoyage

Trois semaines après la mise en service, la secrétaire reçoit un appel : « votre robot m'a donné rendez-vous jeudi, je suis venu, personne ». Pour répondre, il faut retrouver l'appel, savoir ce que l'assistant a entendu, ce qu'il a dit, quel outil il a appelé et ce que l'outil a répondu. Le même jour, le délégué à la protection des données du groupement de cabinets demande où sont stockés les numéros de téléphone et les enregistrements. Les deux questions ont la même réponse : le journal de l'appel, et ce qu'on a choisi de ne pas y mettre.

Le mécanisme : trois sorties par appel, et rien d'autre

flowchart TD
  A["Appel"] --> J["journal.jsonl : événements horodatés, numéros masqués"]
  A --> T["transcription.txt : deux voix, numéros masqués"]
  A -->|"ENREGISTRER=1 seulement"| E["enregistrement.wav : les deux voix sur une piste"]
  A --> DB[("agenda.db : nom, numéro en clair, motif")]
  J --> D["diagnostic d'un incident"]
  J --> C["coût : somme des relevés usage"]
  DB --> S["secrétaire : rappeler, déplacer"]
SortieContenuDonnées personnellesPourquoi
journal.jsonldébut, transcriptions, outils et résultats, latences, jetons, finnom ; numéro masqué 06 •• •• •• 78rejouer un incident sans réécouter l'appel
transcription.txtle dialogueidemlecture humaine rapide
enregistrement.wavl'audio des deux voixvoix de l'appelantdésactivé par défaut ; exige une information préalable
agenda.dbcréneau, nom, numéro, motifnuméro en clair, motif de consultationle cabinet doit pouvoir rappeler

Le masquage est fait à l'écriture, pas à la lecture : masquer_texte() remplace toute suite de huit chiffres ou plus dans les transcriptions, _args_journal() masque l'argument telephone des appels d'outils. Un journal copié dans un ticket ou envoyé à un prestataire ne contient donc jamais un numéro complet.

Mise en œuvre : l'enregistrement et l'information de l'appelant

L'enregistrement audio est piloté par ENREGISTRER (0 par défaut). La CNIL demande, pour l'enregistrement des appels, une mention orale en début de conversation : existence du dispositif, finalité, possibilité de s'y opposer ; et une conservation de six mois au plus, sauf texte contraire. Le même drapeau pilote donc trois choses : l'enregistrement, l'annonce dans l'accueil, et un cinquième outil qui permet à l'appelant de refuser.

files/copilote/agent.py et outils.py (extraits)
ANNONCE = " Cet appel est enregistré pour la qualité du service ; vous pouvez vous y opposer."
REGLE_ENREGISTREMENT = "- Si l'appelant refuse l'enregistrement : appelle refuser_enregistrement immédiatement.\n"

def refuser_enregistrement(self) -> dict:
    """À appeler dès que l'appelant refuse que l'appel soit enregistré. L'appel continue sans enregistrement."""
    self.enregistrement_refuse = True

Après un refus, Appel cesse d'alimenter la piste et n'écrit pas enregistrement.wav à la fermeture, pas même le début de l'appel. Le journal et la transcription, masqués, restent : ils servent au fonctionnement du service (rappeler, diagnostiquer), pas à l'écoute. Le test test_refus_d_enregistrement_rien_n_est_ecrit le vérifie.

Le motif de consultation (« rééducation du genou ») est une donnée de santé au sens de l'article 9 du RGPD. En production, cela pèse sur deux choix : l'hébergement de l'agenda (en France, l'hébergement de données de santé pour le compte d'un tiers relève de la certification HDS) et la région du modèle. Mesuré le 2026-10-16 sur Vertex AI : gemini-3.8-live n'est pas disponible en europe-west1, gemini-live-2.5-flash-native-audio l'est. Ce dernier passe les quatre scénarios, mais pas à chaque exécution : il a tenté une réservation sans confirmation que seul l'outil a bloquée, et sa détection de fin de parole a coupé la dictée d'un numéro jusqu'au réglage SILENCE_MS=800 (leçon 5). Changer de modèle pour rester dans l'Union est possible ; le faire sans relancer les tests ne l'est pas.

Mise en œuvre : le coût d'un appel

Chaque réponse du modèle relit tout le contexte de la session : la consigne, l'audio déjà échangé, les résultats d'outils. Les relevés usage_metadata ne sont donc pas des deltas, et le coût d'un appel croît plus vite que sa durée. cout_usd() somme tous les relevés, par modalité, aux tarifs publics de gemini-3.8-live relevés le 2026-10-15 (par million de jetons : entrée 0,75 entexteet3,00en texte et 3,00 en audio, sortie 4,50 entexteet12,00en texte et 12,00 en audio).

Appel (2026-10-15)DuréeJetons facturésCoût
réservation91 s30 5640,060 $
créneau indisponible77 s28 8530,056 $
hors périmètre74 s12 1100,030 $
demande d'un humain26 s6 7220,013 $

Ordre de grandeur pour un cabinet qui confierait 40 appels par jour à l'assistant, à 0,04 l′appelenmoyenne:moinsde2l'appel en moyenne : moins de 2 par jour de modèle. Le poste de coût réel sera ailleurs : la téléphonie, l'hébergement, et le temps de la secrétaire pour relire les transferts.

Mise en œuvre : Docker, CI, nettoyage

terminal
make up           # docker compose -p lab-copilote-vocal up -d --build ; agenda dans un volume nommé
make down         # down -v --rmi local : conteneur, volume et image supprimés
make clean        # down + sorties/ (agenda, journaux, transcriptions, enregistrements) + caches
make reset        # clean + .venv ; .env est conservé

Le fichier ci/github-actions.yml lance make test à chaque poussée, et make e2e seulement sur la branche principale, avec la clé en secret : le test vocal coûte environ 0,16 $, il n'a pas sa place sur chaque branche. make cloud-up, cloud-down et cloud-status affichent un message : ce projet ne crée aucune ressource Google Cloud, il appelle seulement l'API Gemini ou Vertex AI.

Ce qui casse en production

SymptômeCauseParade
Un numéro complet dans un ticket d'incidentjournal copié tel quelmasquage à l'écriture, testé (test_un_tour_complet vérifie l'absence de 56 78)
Plainte d'un patient enregistré sans le savoir, ou malgré son refusenregistrement sans annonce, ou refus oral ignoréannonce et outil refuser_enregistrement pilotés par le même drapeau que l'enregistrement
Facture du mois trois fois plus élevée que prévucoût estimé à partir de la durée, pas des relevéssomme des relevés usage_metadata, par modalité
make up échoue : env file …/.env not foundmake setup pas lancémake setup copie .env.example en .env
make up échoue : port déjà utiliséun autre service écoute sur 8080PORT=8095 dans .env (seul le port publié change)
Le disque se remplitenregistrement.wav de chaque appel (3 à 4 Mo pour 90 s)ENREGISTRER=0 par défaut ; si activé, purge planifiée (six mois au plus selon la CNIL)

La règle de décision

Gardez dans le journal ce qui permet de rejouer un incident, et rien qui ne serve pas à cela. Activez l'enregistrement audio seulement si vous avez un usage précis (formation, litige), une durée de conservation et une annonce. Choisissez la région du modèle avant d'optimiser la latence : changer de modèle pour rester dans l'Union oblige à relancer toute la batterie de tests.

Résultat obtenu

Exécution du 2026-10-16, Docker puis nettoyage :

make up / make down (2026-10-16)
$ make up
 Container lab-copilote-vocal-copilote-1  Started
http://localhost:8095
$ curl -s localhost:8095/health
{"ok":true,"modele":"gemini-3.8-live"}
$ docker compose -p lab-copilote-vocal ps --format '{{.Name}} {{.Status}}'
lab-copilote-vocal-copilote-1 Up 18 seconds (healthy)
$ make down
$ docker ps -a --filter name=lab-copilote -q; docker volume ls --filter name=lab-copilote -q
$                                   (aucun conteneur, aucun volume)

Pratique

Le cabinet veut garder les enregistrements « au cas où », sans limite de durée. Que lui répondez-vous ?

Le délégué à la protection des données exige que tout le traitement reste dans l'Union européenne. Que changez-vous, et que relancez-vous ?

Exercice. Écrivez la requête SQL qui donne, pour la secrétaire, la liste des transferts du jour avec leur résumé, et la commande qui retrouve le journal de l'appel correspondant.

Voir la solution
sqlite3 sorties/agenda.db "SELECT appel_id, motif, resume, demande_le FROM transferts WHERE date(demande_le) = date('now', 'localtime') ORDER BY demande_le"
cat sorties/appels/<appel_id>/transcription.txt
grep '"type": "outil"' sorties/appels/<appel_id>/journal.jsonl

appel_id relie la table transferts au dossier de l'appel : c'est l'identifiant généré à l'ouverture de la session (20261015-211717-063afb par exemple). Ce lien est la raison pour laquelle chaque outil reçoit l'identifiant de l'appel.

  • Je sais dire ce qui est journalisé, ce qui est masqué, et pourquoi
  • Je sais calculer le coût d'un appel à partir des relevés de jetons
  • J'ai lancé make up, vérifié /health, puis make down et make clean