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"]
| Sortie | Contenu | Données personnelles | Pourquoi |
|---|---|---|---|
journal.jsonl | début, transcriptions, outils et résultats, latences, jetons, fin | nom ; numéro masqué 06 •• •• •• 78 | rejouer un incident sans réécouter l'appel |
transcription.txt | le dialogue | idem | lecture humaine rapide |
enregistrement.wav | l'audio des deux voix | voix de l'appelant | désactivé par défaut ; exige une information préalable |
agenda.db | créneau, nom, numéro, motif | numéro en clair, motif de consultation | le 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.
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 = TrueAprè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 en audio, sortie 4,50 en audio).
| Appel (2026-10-15) | Durée | Jetons facturés | Coût |
|---|---|---|---|
| réservation | 91 s | 30 564 | 0,060 $ |
| créneau indisponible | 77 s | 28 853 | 0,056 $ |
| hors périmètre | 74 s | 12 110 | 0,030 $ |
| demande d'un humain | 26 s | 6 722 | 0,013 $ |
Ordre de grandeur pour un cabinet qui confierait 40 appels par jour à l'assistant, à 0,04 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
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ôme | Cause | Parade |
|---|---|---|
| Un numéro complet dans un ticket d'incident | journal copié tel quel | masquage à 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 refus | enregistrement 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évu | coût estimé à partir de la durée, pas des relevés | somme des relevés usage_metadata, par modalité |
make up échoue : env file …/.env not found | make setup pas lancé | make setup copie .env.example en .env |
make up échoue : port déjà utilisé | un autre service écoute sur 8080 | PORT=8095 dans .env (seul le port publié change) |
| Le disque se remplit | enregistrement.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
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 RGPD impose une durée de conservation proportionnée à la finalité ; « au cas où » n'en est pas une, et la CNIL fixe six mois au plus pour l'enregistrement des appels, sauf texte contraire. L'annonce informe, elle ne rend pas licite une conservation illimitée. On ne masque pas une voix dans un fichier audio comme un numéro dans un texte : la voix elle-même est une donnée personnelle.
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 ?
En europe-west1, gemini-3.8-live renvoie une erreur 1008 (modèle introuvable) : changer la région seule casse l'assistant. Un autre modèle, c'est une autre façon de suivre la consigne, d'appeler les outils et de découper les tours : dans ce projet, il a fallu régler SILENCE_MS pour repasser les quatre scénarios. L'API Gemini par clé ne permet pas de choisir la région de traitement comme le fait une région Vertex AI.
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.jsonlappel_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, puismake downetmake clean