Leçon 1 — Cadrer

Le problème et l'architecture

8 min de lecture

Le problème et l'architecture

Mardi, 8 h 40, Cabinet des Tanneurs (cabinet fictif de kinésithérapie et d'ostéopathie, deux praticiens, une secrétaire à mi-temps). Le téléphone a sonné onze fois depuis 8 h. La secrétaire, occupée à l'accueil physique, en a pris quatre. Sur les sept autres, cinq appelants voulaient un rendez-vous, un voulait parler d'une facture, un demandait s'il devait mettre de la glace sur sa cheville. Les cinq premiers iront ailleurs si personne ne rappelle avant midi.

Le copilote de ce projet répond à ces appels : il qualifie la demande, consulte l'agenda, propose des créneaux, réserve après un accord explicite, envoie un récapitulatif, et passe la main à la secrétaire dès que l'appel sort de son périmètre.

Le mécanisme : trois boucles qui ne tournent pas à la même vitesse

Un assistant vocal n'est pas un chatbot avec un synthétiseur. Trois boucles s'imbriquent, chacune avec son budget de temps :

BoucleQuiBudgetConséquence si dépassé
Audionavigateur ↔ serveur ↔ Live API, trames de 100 msquelques dizaines de ms par tramevoix hachée, coupures
Tour de parolefin de phrase de l'appelant → premier son de l'assistantde l'ordre de 1 à 2 sl'appelant répète, ou raccroche
Métieroutil chercher_creneaux, reserver_creneau…quelques ms (SQLite local)silence au milieu d'une phrase

La Gemini Live API traite la deuxième boucle de bout en bout : elle reçoit l'audio brut, détecte la fin de parole (VAD, détection d'activité vocale), transcrit, décide, appelle un outil si besoin, et renvoie de l'audio. Il n'y a pas de chaîne « reconnaissance vocale → LLM → synthèse » à assembler, donc pas trois latences à additionner. Mesuré le 2026-10-15 sur 14 tours de parole : médiane 1,38 s entre la fin de phrase et le premier son, p90 1,89 s.

flowchart LR
  N["Navigateur : micro (AudioWorklet 16 kHz)"] -->|"WebSocket : PCM 100 ms"| S["Serveur FastAPI : Appel"]
  S -->|"LiveRequestQueue"| A["Agent ADK run_live"]
  A <-->|"audio + transcriptions"| L["Gemini Live API"]
  A --> O["Outils + garde-fous"]
  O --> DB[("SQLite : créneaux, SMS, transferts")]
  S -->|"PCM 24 kHz + événements JSON"| N
  S --> J["journal.jsonl + transcription.txt"]

Les quatre décisions qui structurent le projet

  1. Les garde-fous qui comptent sont dans le code des outils. La consigne dit « ne réserve qu'après un oui explicite ». L'outil reserver_creneau le vérifie aussi : il relit la dernière phrase transcrite de l'appelant et refuse sans accord. Il refuse également un créneau que l'outil de recherche n'a pas proposé dans cet appel, ce qui empêche le modèle d'inventer un horaire.
  2. La base tranche les conflits. Deux appels simultanés peuvent viser le même créneau de 9 h 30. La réservation est un UPDATE … WHERE statut = 'libre' : un seul gagne, l'autre reçoit une erreur que le modèle sait traiter (« proposez-en un autre »).
  3. Ce qui est testé est ce qui tourne. La même classe Appel sert au navigateur et aux appels de test. Les tests de bout en bout jouent des fichiers audio au rythme réel, comme un micro, et vérifient le résultat dans l'agenda, pas dans le texte de la réponse.
  4. Un humain reste joignable. Toute demande d'humain, de facture, de résultat ou de réclamation déclenche transferer_humain, qui écrit un résumé d'une phrase pour la secrétaire et met fin au tour de l'assistant.

Le script de qualification

Le cabinet a fourni à la secrétaire un ordre de questions ; l'assistant suit le même, en sautant ce que l'appelant a déjà dit :

stateDiagram-v2
  [*] --> Accueil : décroché
  Accueil --> Motif
  Motif --> Transfert : humain, facture, réclamation
  Motif --> Refus : conseil médical
  Refus --> Motif : propose un rendez-vous
  Motif --> Historique : kiné ou ostéo
  Historique --> Ordonnance : kinésithérapie
  Historique --> Disponibilites : ostéopathie
  Ordonnance --> Disponibilites
  Disponibilites --> Proposition : chercher_creneaux
  Proposition --> Confirmation : nom + portable relus
  Confirmation --> Reserve : oui explicite
  Confirmation --> Proposition : non, autre créneau
  Reserve --> [*] : SMS récapitulatif
  Transfert --> [*]

Une urgence vitale (douleur thoracique, malaise) court-circuite tout : l'assistant dit d'appeler le 15 et ne propose rien d'autre.

Mise en œuvre : le dépôt

files/
copilote/  config.py   agenda.py   outils.py   agent.py   audio.py   appel.py   serveur.py   scenarios.py   e2e.py
web/       index.html  app.js  micro-worklet.js
appels/    14 répliques WAV synthétisées (4 scénarios d'appel)
tests/     conftest.py  test_agenda.py  test_outils.py  test_audio.py  test_appel.py  test_serveur.py
Makefile  Dockerfile  docker-compose.yml  requirements.txt  .env.example  ci/github-actions.yml  linkedin/

La date de l'agenda de démonstration est fixée par DATE_REFERENCE=2026-10-15 dans .env : l'ostéopathe est complet le lendemain, c'est le scénario « créneau indisponible ». Sans cette date fixe, le test changerait de sens selon le jour où vous le lancez.

terminal
cd files
make setup        # .venv, dépendances, .env copié depuis .env.example
make test         # sans clé ni réseau

Ce qui casse en production

SymptômeCauseParade dans ce projet
L'assistant annonce un rendez-vous qui n'existe pas dans l'agendahoraire inventé par le modèlereserver_creneau n'accepte qu'un creneau_id renvoyé par chercher_creneaux dans cet appel
Deux patients au même créneaulecture puis écriture non atomiquesUPDATE conditionnel, contrainte UNIQUE (praticien, debut)
Réservation faite sur un « euh, attendez »confirmation laissée au jugement du modèleaccord_explicite() sur la dernière phrase transcrite
L'appelant croit parler à un humainpas d'annoncela consigne impose « assistant vocal automatique » dans l'accueil (obligation de transparence de l'AI Act, article 50)

La règle de décision

Utilisez la Live API de bout en bout quand la conversation est courte, guidée, et que la latence perçue compte plus que le contrôle fin de chaque étape. Gardez une chaîne séparée (transcription, LLM texte, synthèse) si vous devez auditer chaque transcription avant de répondre, ou utiliser une voix de synthèse de marque que la Live API ne propose pas. Dans les deux cas, gardez les décisions irréversibles (réserver, annuler, payer) dans des outils qui vérifient leurs préconditions.

Résultat obtenu

Exécution du 2026-10-16, sans clé ni réseau :

make test (2026-10-16)
.......................................                                  [100%]
39 passed, 1 warning in 4.82s

Pratique

Un appelant dit « le premier créneau me va, mon numéro est le 06 12 34 56 78 ». Le modèle appelle aussitôt reserver_creneau. Que se passe-t-il dans ce projet ?

La direction veut ajouter un troisième praticien et propose de ne rien changer au code : « le modèle comprendra ». Où se situe le vrai travail ?

Exercice. Sans lancer le code, listez les outils que l'assistant devrait appeler, dans l'ordre, pour l'appel suivant : « Bonjour, je voudrais voir l'ostéopathe demain. » (le cabinet est complet demain), puis « D'accord pour lundi 14 h », puis le numéro, puis « oui, je confirme », puis « non, c'est tout ».

Voir la solution
  1. chercher_creneaux(type_seance="osteopathie", a_partir_du=<demain>) : il renvoie jour_demande_disponible: false et, dans le même appel, les premiers créneaux libres des jours suivants (la recherche s'élargit seule sur 7 jours, le modèle n'a pas à boucler).
  2. Aucun outil pendant la collecte du numéro et la relecture.
  3. reserver_creneau(creneau_id=<celui de lundi 14 h>, …) après le « oui, je confirme ».
  4. envoyer_recapitulatif().

C'est exactement la séquence mesurée dans le scénario « indisponible » de la leçon 5 : chercher_creneaux > reserver_creneau > envoyer_recapitulatif.

  • Je sais expliquer pourquoi la Live API évite d'additionner trois latences
  • Je sais dire quelles règles vivent dans les outils et lesquelles dans la consigne
  • J'ai lancé make setup puis make test (42 tests verts)
Suivant