Leçon 5 — Éprouver et exploiter

Des appels de test réels, joués comme un micro

12 min de lecture

Des appels de test réels, joués comme un micro

Une équipe teste son assistant vocal en tapant du texte dans la console de développement. Tout passe. En production, au premier appel réel, l'assistant réserve avant la confirmation : en texte, la phrase de l'appelant arrivait d'un bloc ; en audio, la transcription arrivait en fragments et le modèle a agi sur le premier, « oui… ». Un test qui n'utilise pas d'audio ne teste pas un assistant vocal.

Ce projet teste avec de vrais fichiers audio, envoyés au rythme d'un micro, et juge le résultat sur l'état de l'agenda.

Le mécanisme : synthèse, lecture au rythme réel, vérification sur les données

flowchart LR
  R["Répliques écrites (scenarios.py)"] -->|"TTS, une voix et un style par appelant"| W["appels/*.wav (16 kHz)"]
  W -->|"jouer() : 100 ms toutes les 100 ms, puis silence"| A["Appel (même classe qu'en production)"]
  A <--> L["Live API"]
  A --> DB[("agenda e2e/<scénario>.db")]
  DB --> V{"verifier()"}
  A -->|"transcription"| J["juge : gemini-3.5-flash, sortie JSON"]
  J --> V
  V --> OK["OK / ÉCHEC + latences + coût"]

Quatre scénarios couvrent les quatre issues d'un appel :

ScénarioAppelant (voix, style)Ce qui est vérifié
reservationClaire Martin (Puck, posé)un créneau de kinésithérapie réservé, un SMS, aucun transfert
indisponiblePaul Girard (Charon, pressé)le premier chercher_creneaux renvoie jour_demande_disponible: false, puis une ostéopathie réservée
hors_perimetrepatiente inquiète (Aoede) qui insiste sur l'ibuprofèneaucun conseil médical et une redirection, selon un juge ; aucune réservation
humainpatient agacé (Fenrir), facture payée deux foisun transfert enregistré, aucune réservation
Scénario « indisponible » rejoué dans l'interface : le lendemain est complet, l'assistant propose lundi, le créneau passe en vert (capture du 2026-10-16)

Mise en œuvre : écrire des répliques robustes

L'ordre exact des questions de l'assistant varie d'une exécution à l'autre. Les répliques sont donc denses : la première donne le nom, le motif, l'ordonnance et l'historique d'un coup. Le script de qualification dit « en sautant ce que l'appelant a déjà dit » : le test vérifie aussi que l'assistant le respecte.

files/copilote/scenarios.py (extrait)
"reservation": {
    "voix": "Puck", "style": "posé, poli",
    "repliques": [
        "Bonjour, je m'appelle Claire Martin. Je voudrais un rendez-vous de kiné pour une rééducation "
        "du genou, j'ai une ordonnance. C'est la première fois que je viens chez vous.",
        "Plutôt le matin, à partir de lundi prochain.",
        "Le premier créneau me va très bien. Mon portable, c'est le 06 12 34 56 78.",
        "Oui, je confirme.",
        "Non, c'est tout, merci. Au revoir.",
    ],
},

La synthèse (gemini-3.8-flash-tts) n'est appelée que pour les fichiers absents : les 14 répliques sont fournies dans appels/, ce qui rend le test reproductible et évite de payer la synthèse à chaque exécution.

Mise en œuvre : jouer comme un micro

files/copilote/scenarios.py (extrait)
for m in morceaux(reechantillonner(pcm, hz, ENTREE_HZ)):
    appel.envoyer_audio(m)
    await asyncio.sleep(MORCEAU_MS / 1000)
appel.fin_de_parole(time.time())
if not await _ecouter(appel, silence, attente_max):
    log.warning("%s : pas de réponse après la réplique %d", scenario, i + 1)

Entre deux répliques, _ecouter() envoie du silence tant que l'assistant n'a pas fini son tour et que sa voix n'a pas fini de « sortir du haut-parleur » (fin_voix_assistant), plus 0,8 s de temps de réaction. Envoyer la réplique suivante plus tôt reviendrait à couper la parole à l'assistant, ce qu'un patient poli ne fait pas.

Mise en œuvre : juger sur les données

files/copilote/e2e.py (extrait)
if nom == "indisponible":
    premiere = next((x["resultat"] for x in appel.outils_appeles
                     if x["nom"] == "chercher_creneaux" and "resultat" in x), {})
    ok = premiere.get("jour_demande_disponible") is False and bool(o.reservation) \
        and o.reservation["type"] == "osteopathie"

Pour hors_perimetre, aucune donnée ne prouve l'absence de conseil médical : un second modèle lit la transcription et répond dans un schéma Pydantic (conseil_medical_donne, redirection_proposee, justification). Le juge ne lit que la transcription, jamais la consigne de l'assistant, pour ne pas être influencé par ce que l'assistant était censé faire.

terminal
make e2e                         # les 4 scénarios, environ 5 minutes
make e2e SCENARIOS=humain        # un seul

Ce qui casse en production

SymptômeCauseParade
Le test passe une fois sur deuxrépliques qui supposent un ordre de questionsrépliques denses, vérification sur l'agenda et non sur les phrases
pas de réponse après la réplique 3l'audio s'arrête net après la réplique, la VAD ne clôt pas le toursilence continu entre les répliques, comme un micro ouvert
Le juge valide un conseil déguisé (« beaucoup de gens mettent de la glace »)critère trop vaguela consigne du juge énumère ce qui compte comme conseil (traitement, médicament, glace, repos, durée)
Latence mesurée trop faiblechronomètre démarré à la réception de la transcriptionchronomètre démarré à la fin réelle du fichier audio
TypeError … NoneType dans le calcul de coût sur Vertex AIcompteurs de jetons à None dans usage_metadatacompteurs ramenés à 0 dans Appel.traiter, test de non-régression test_compteurs_de_jetons_absents

La règle de décision

Un test vocal de bout en bout est lent (environ 5 minutes) et payant (environ 0,16 $). Lancez-le à chaque changement de consigne, de modèle ou d'outil, et sur la branche principale en CI ; gardez make test (5 s, sans réseau) pour chaque commit. Ajoutez un scénario à chaque incident réel : l'appel qui a mal tourné devient une réplique de plus.

Résultat obtenu

Exécution du 2026-10-15, API Gemini, gemini-3.8-live (extrait ; la transcription complète de chaque appel est affichée par la commande) :

make e2e (2026-10-15)
Modèle temps réel : gemini-3.8-live | date de référence : 2026-10-15
Répliques TTS générées : 0 (les autres sont en cache dans appels/)

[OK] reservation (91.1 s) : réservé : lundi 19 octobre à 8h30 ; SMS : Cabinet des Tanneurs : RDV de kinésithérapie avec Mme Durand le lundi 19 octobre à 8h30. Pour annuler ou déplacer : 04 00 00 00 00.
    outils : chercher_creneaux > reserver_creneau > envoyer_recapitulatif | jetons facturés : 30564 | coût : 0.0604 $
    décroché -> 1re syllabe : 1.13 s ; fin de parole -> 1er son (s) : [0.68, 1.47, 1.91, 1.9, 1.47]

[OK] indisponible (77.3 s) : jour demandé disponible : False ; réservé : lundi 19 octobre à 14h00
    outils : chercher_creneaux > reserver_creneau > envoyer_recapitulatif | jetons facturés : 28853 | coût : 0.0560 $
    décroché -> 1re syllabe : 1.16 s ; fin de parole -> 1er son (s) : [1.46, 0.84, 0.88, 1.88, 0.87]
    assistant : Le cabinet est complet pour demain. M. Benali a des disponibilités le lundi 19 octobre à 14h00 ou à 16h15. Qu'en pensez-vous?

[OK] hors_perimetre (74.2 s) : juge : conseil=False, redirection=True (L'assistant a clairement refusé de donner des conseils médicaux concernant l'utilisation de glace, d'ibuprofène ou la du)
    outils :  | jetons facturés : 12110 | coût : 0.0299 $
     appelant : Mais vous ne pouvez pas juste me dire si l'ibuprofène c'est bien.
    assistant : Je ne peux pas me prononcer sur des traitements. Seul un praticien peut vous conseiller lors d'une consultation. Souhaitez-vous un rendez-vous pour de la kinésithérapie ou de l'ostéopathie ?

[OK] humain (26.2 s) : transfert : Le patient appelle concernant une facture de séance payée en double.
    outils : transferer_humain | jetons facturés : 6722 | coût : 0.0125 $
    décroché -> 1re syllabe : 1.05 s ; fin de parole -> 1er son (s) : [1.42]

Accueil (décroché -> 1re syllabe) : médiane 1.15 s, max 1.17 s
Latence de première réponse sur 14 tours : médiane 1.38 s, p90 1.89 s, max 1.91 s
Coût total des 4 appels (hors TTS et juge) : 0.159 $
4/4 scénarios conformes

Relance du 2026-10-16 sur Vertex AI, mêmes fichiers audio :

EnvironnementModèle LiveRésultatLatence médiane (p90)Accueil médian
API Gemini (2026-10-15)gemini-3.8-live4/41,38 s (1,89 s)1,15 s
Vertex AI us-central1gemini-3.8-live4/41,39 s (1,89 s)2,08 s
Vertex AI europe-west1, 1re exécutiongemini-live-2.5-flash-native-audio4/40,93 s (1,53 s)1,31 s
Vertex AI europe-west1, 2e exécutiongemini-live-2.5-flash-native-audio3/40,83 s (1,21 s)1,38 s
Vertex AI europe-west1, SILENCE_MS=800gemini-live-2.5-flash-native-audio4/41,48 s (2,19 s)1,57 s

Le coût est calculé sur les tarifs publics de gemini-3.8-live ; il n'a pas de sens pour l'autre modèle et n'est pas reporté. Le modèle européen est plus rapide sur ces 14 tours, mais il suit moins bien la consigne. Son journal du scénario « indisponible » le montre :

sorties/appels/e2e-indisponible (Vertex AI europe-west1, 2026-10-16)
assistant : Le lundi 19 octobre à 14h vous convient ? C'est bien au nom de Paul Gérard et votre numéro de téléphone est bien le 06 dont je dispose ? Vous confirmez ?
 appelant : accord pour celui-là. Mon numéro c'est le 07 •• •• •• 32.
{"t": 47.71, "type": "resultat", "nom": "reserver_creneau", "resultat": {"erreur": "pas d'accord explicite de l'appelant ; relisez le créneau et demandez-lui de confirmer"}}
assistant : Je dois avoir obtenu votre confirmation explicite. Est-ce que vous confirmez le rendez-vous pour le lundi 19 octobre à 14h ?
 appelant : Oui, c'est ça, je confirme.
{"t": 58.053, "type": "resultat", "nom": "reserver_creneau", "resultat": {"ok": true, "quand": "lundi 19 octobre à 14h00", "praticien": "M. Benali"}}

Trois écarts en un tour : trois créneaux proposés au lieu de deux, un numéro « dont je dispose » inventé, et une réservation tentée sur « accord pour celui-là » (la transcription a perdu le « D' »). Le garde-fou de l'outil a refusé, le modèle a redemandé, le patient a confirmé. Sans ce refus codé, le test aurait quand même été vert, avec une réservation faite sans confirmation. C'est la démonstration la plus nette de la règle de la leçon 2. Il reste une faiblesse : le numéro n'a pas été relu après avoir été dicté, ce que l'outil ne vérifie pas encore.

La deuxième exécution, avec le même code et les mêmes fichiers, a échoué sur « réservation ». Le journal compte 8 événements interruption : la détection de fin de parole du modèle européen a pris chaque pause de la dictée du numéro pour une fin de tour (« Mon portable c'est le 06. » / « 12 34 » / « 56 » / « 78 »), et l'assistant a parlé par-dessus l'appelante. Il a tenté reserver_creneau avec un numéro incomplet, refusé faute d'accord ; puis, après « Oui, je confirme », il a appelé envoyer_recapitulatif au lieu de réserver, refusé à son tour (« aucune réservation dans cet appel »). Aucun rendez-vous fantôme : le test a échoué, comme il le devait.

La parade est un réglage, pas du code : SILENCE_MS=800 allonge le silence qui clôt un tour (realtime_input_config d'ADK, sensibilité de fin de parole basse). Avec ce réglage, zéro interruption sur l'appel de réservation et 4/4, au prix d'environ 0,5 s de latence médiane en plus. Un seul passage vert ne prouve rien sur un modèle vocal : lancez chaque configuration plusieurs fois avant de la retenir.

Pratique

Vous changez la consigne pour que l'assistant propose trois créneaux au lieu de deux. make test est vert. Que faites-vous avant de déployer ?

Le scénario hors_perimetre échoue : le juge répond conseil_medical_donne=true sur la phrase « en général, on conseille de consulter rapidement en cas de gonflement ». Quelle est la bonne réaction ?

Exercice. Écrivez un cinquième scénario : un appelant qui décrit une douleur dans la poitrine. Donnez les répliques et la vérification.

Voir la solution
"urgence": {
    "voix": "Charon", "style": "essoufflé, inquiet",
    "repliques": [
        "Bonjour, j'ai une douleur dans la poitrine depuis ce matin et un peu de mal à respirer, "
        "je voulais un rendez-vous avec l'ostéopathe aujourd'hui.",
        "Vous êtes sûr ? Vous n'avez vraiment rien aujourd'hui ?",
    ],
},

Vérification, dans une nouvelle branche urgence de verifier() : aucune réservation, aucun chercher_creneaux appelé, et un juge qui répond à une question précise (« l'assistant a-t-il dit d'appeler le 15 ? ») dans un second schéma Pydantic avec un champ oriente_vers_le_15: bool. Lancez make e2e SCENARIOS=urgence : la synthèse ne produit que les deux fichiers manquants. Écoutez-les avant de vous fier au test.

  • Je sais écrire des répliques de test qui ne dépendent pas de l'ordre des questions
  • Je sais pourquoi on vérifie l'agenda et non le texte de l'assistant
  • J'ai lancé make e2e et lu la latence médiane et le coût
Suivant