L'agenda SQLite et les outils gardés
Un vendredi, un assistant vocal d'un autre projet a confirmé à une patiente « mardi à 10 h 15 ». Le cabinet ne prend des rendez-vous qu'aux demi-heures : le modèle avait arrondi à sa façon un créneau entendu deux tours plus tôt. La patiente est venue, la salle était occupée. Le problème n'était pas la qualité du modèle, mais un outil qui acceptait n'importe quelle heure.
Cette leçon construit l'agenda et les quatre outils de l'assistant (un cinquième s'ajoute quand l'appel est enregistré, leçon 6) de sorte que ce genre d'erreur soit impossible, pas seulement improbable.
Le mécanisme : un outil par décision, une précondition par outil
flowchart TD
C["chercher_creneaux(type, date, moment)"] -->|"ids ajoutés à proposes"| P{"reserver_creneau(id, nom, tel, motif)"}
P -->|"id hors proposes"| E1["erreur : non proposé"]
P -->|"pas d'accord explicite"| E2["erreur : faites confirmer"]
P -->|"numéro invalide"| E3["erreur : faites répéter"]
P -->|"UPDATE ... WHERE statut = 'libre' : 0 ligne"| E4["erreur : pris entre-temps"]
P -->|"1 ligne"| R["réservé"] --> M["envoyer_recapitulatif()"]
T["transferer_humain(motif, resume)"] --> F["fin du tour de l'assistant"]
Chaque erreur est un texte destiné au modèle : elle dit quoi faire ensuite. Un modèle qui reçoit « ce créneau vient d'être pris ; proposez-en un autre » enchaîne correctement ; un modèle qui reçoit une exception Python improvise.
Mise en œuvre : l'agenda
Le schéma tient en trois tables. La réservation est une seule instruction SQL dont la condition porte la règle métier :
def reserver(self, creneau_id: int, patient: str, telephone: str, motif: str, appel_id: str) -> bool:
cur = self.db.execute(
"UPDATE creneaux SET statut = 'reserve', patient = ?, telephone = ?, motif = ?, appel_id = ?, "
"reserve_le = ? WHERE id = ? AND statut = 'libre'",
(patient, telephone, motif, appel_id, _maintenant(), creneau_id))
return cur.rowcount == 1Deux détails comptent :
check_same_thread=Falseà l'ouverture : ADK exécute les outils synchrones dans un pool de threads, la connexion est donc partagée entre threads. SQLite sérialise les écritures ; en mode WAL, les lectures de l'interface ne bloquent pas la réservation.initialiser()génère dix jours ouvrés de créneaux avec une graine fixe, et rend l'ostéopathe complet le lendemain deDATE_REFERENCE. Les tests et les appels de démonstration voient donc toujours le même agenda.
Mise en œuvre : les outils
Un objet Outils est créé par appel. Il porte l'état qui sert aux garde-fous : les créneaux proposés, la dernière phrase de l'appelant, la réservation en cours. La docstring de chaque méthode est la description que voit le modèle.
def reserver_creneau(self, creneau_id: int, nom_patient: str, telephone: str, motif: str) -> dict:
"""Réserve un créneau proposé. À appeler UNIQUEMENT après avoir relu le créneau, le nom et le
numéro à l'appelant et obtenu son accord explicite (« oui, je confirme »)."""
if creneau_id not in self.proposes:
return {"erreur": "ce créneau n'a pas été proposé dans cet appel ; utilisez chercher_creneaux"}
if not accord_explicite(self.derniere_phrase_appelant):
return {"erreur": "pas d'accord explicite de l'appelant ; relisez le créneau et demandez-lui de confirmer"}
tel = normaliser_telephone(telephone)
if not tel:
return {"erreur": "numéro de téléphone invalide ; faites-le répéter chiffre par chiffre"}L'accord est détecté par deux expressions régulières : un marqueur d'accord (« oui », « je confirme », « d'accord »…) et aucun marqueur de réserve (« non », « pas », « attendez »). « Oui mais pas mardi » est donc traité comme un refus. C'est un choix prudent, signalé dans le code par un commentaire ponytail: : une reconfirmation de trop coûte deux secondes, une réservation non voulue coûte un créneau et la confiance du patient.
chercher_creneaux corrige deux erreurs fréquentes du modèle avant qu'elles n'atteignent la base : une date passée est ramenée au lendemain, et un jour complet déclenche une recherche élargie sur sept jours dans le même appel d'outil. Sans cela, le modèle appelle l'outil jour après jour, et chaque appel ajoute un aller-retour audible.
Les numéros de téléphone ne sortent jamais en clair des journaux : masquer("0612345678") donne 06 •• •• •• 78. Ils sont stockés en clair dans l'agenda seulement, parce que le cabinet en a besoin pour rappeler.
.venv/bin/python -m pytest -q tests/test_agenda.py tests/test_outils.pyCe qui casse en production
| Symptôme | Diagnostic | Parade |
|---|---|---|
sqlite3.ProgrammingError: SQLite objects created in a thread… au premier appel d'outil | ADK appelle l'outil depuis un thread du pool | check_same_thread=False, une connexion par processus |
database is locked sous charge | journal SQLite par défaut, lectures longues | PRAGMA journal_mode=WAL ; au-delà d'un cabinet, passez à PostgreSQL avec le même UPDATE conditionnel |
Le modèle appelle chercher_creneaux six fois de suite | jour complet, l'outil renvoie une liste vide | élargir la recherche dans l'outil et renvoyer jour_demande_disponible: false |
| Un rendez-vous pris « hier » | le modèle a mal calculé « lundi prochain » | date plancher au lendemain de aujourd_hui, date ISO dans la consigne |
La règle de décision
Mettez dans le code toute règle dont la violation a un coût réel (rendez-vous fantôme, double réservation, donnée personnelle exposée). Laissez dans la consigne ce qui relève du ton et de l'ordre des questions. Si vous hésitez, demandez-vous si un test automatique peut vérifier la règle : si oui, elle a sa place dans un outil.
Résultat obtenu
Exécution du 2026-10-16 :
tests/test_agenda.py::test_reservation_atomique PASSED
tests/test_outils.py::test_accord_explicite[Oui mais pas mardi-False] PASSED
tests/test_outils.py::test_reservation_refusee_si_creneau_non_propose PASSED
tests/test_outils.py::test_reservation_refusee_sans_accord_explicite PASSED
tests/test_outils.py::test_creneau_pris_entre_temps PASSED
tests/test_outils.py::test_jour_complet_elargit_la_recherche PASSED
tests/test_outils.py::test_refus_d_enregistrement PASSED
...
============================== 30 passed in 0.72s ==============================Pratique
Deux appels arrivent en même temps. Les deux assistants proposent lundi 9 h 30 et les deux patients disent « oui, je confirme » à une seconde d'intervalle. Que se passe-t-il ?
La condition WHERE statut = 'libre' est évaluée au moment de l'écriture, par la base. rowcount vaut 0 pour le perdant, ce qui devient une erreur lisible par le modèle ; l'identifiant est aussi retiré de proposes. Vérifier en Python puis écrire laisserait une fenêtre où les deux passent.
En test, un patient dit « oui » puis enchaîne « attendez, plutôt mardi » dans la même phrase transcrite. Le modèle appelle reserver_creneau. Quel est le comportement attendu, et pourquoi ?
accord_explicite exige un marqueur d'accord et l'absence de tout marqueur de réserve. Le doute coûte une reconfirmation ; l'erreur inverse coûte un créneau perdu et un patient mécontent. La troisième option suppose que le modèle a changé de créneau_id, ce que rien ne garantit.
Exercice. Le cabinet veut interdire la réservation d'un premier rendez-vous de kinésithérapie sans ordonnance. Où l'écrivez-vous, et comment le testez-vous ?
Voir la solution
Ajoutez un paramètre ordonnance: bool à reserver_creneau (le modèle le renseigne à partir de la question 3 du script), et un refus explicite dans l'outil :
if type_ == "kinesitherapie" and not ordonnance:
return {"erreur": "pas d'ordonnance : proposez de rappeler avec l'ordonnance, ou transférez à la secrétaire"}Testez-le comme les autres refus, dans tests/test_outils.py, avec un appel sans ordonnance qui doit renvoyer erreur et laisser outils.reservation à None. Ajoutez la règle dans la consigne aussi, pour que le modèle pose la question au bon moment, mais c'est l'outil qui garantit la règle.
- Je sais écrire une réservation atomique sans verrou applicatif
- Je sais rédiger un message d'erreur d'outil qui dit au modèle quoi faire ensuite
- J'ai lancé les tests de l'agenda et des outils (30 verts)