Mardi, la responsable du contrôle de gestion teste l'assistant pendant dix minutes, trouve deux réponses justes et une fausse, et conclut : « 66 % de réussite, on n'ouvre pas ». Elle a raison sur la décision et tort sur la méthode. Trois questions choisies au hasard ne mesurent rien, et « la réponse a l'air juste » n'est pas un critère. Il faut un jeu de questions fixe, des résultats attendus calculés sans l'assistant, et un juge qui compare des valeurs, pas des phrases.
Le mécanisme : une référence indépendante, recalculée à chaque passage
flowchart LR
Q["questions.yaml<br/>20 cas : question, SQL de référence, graphique attendu"] --> REF["SQL écrit à la main<br/>tables brutes, sans la couche sémantique"]
Q --> AG["Agent ADK<br/>conversation neuve par question"]
REF -->|"exécuté maintenant"| ATT["Lignes attendues"]
AG --> OBT["Lignes de chaque requête de l'agent"]
ATT & OBT --> J{"Juge : mêmes valeurs ?<br/>même nombre de lignes ?<br/>graphique accepté ?"}
J --> RAP["Rapport : exactitude, graphiques,<br/>latence, octets facturés, JSON détaillé"]
Trois choix font la valeur de cette évaluation :
- La référence est écrite à la main, sur les tables brutes. Les définitions métier y sont réécrites (« valide » = ni annulé ni retourné). Si quelqu'un modifie le YAML, la référence ne suit pas et l'écart apparaît. Une référence produite par le compilateur ne détecterait que les erreurs du modèle, jamais celles de la couche sémantique.
- Elle est recalculée à chaque passage. Le jeu
thelook_ecommerceest régénéré chaque jour : un chiffre figé dans le YAML serait faux le lendemain. Le coût est de 10 à 31 Mo facturés par question de référence. - Le juge compare des valeurs. Les noms de colonnes et l'ordre des lignes sont ignorés ; les nombres sont comparés avec une tolérance relative de 10⁻⁶ ; le nombre de lignes doit être identique. Le texte de la réponse n'est pas jugé : il commente, il ne fait pas foi.
| Famille | Questions | Ce qu'elles testent |
|---|---|---|
| indicateurs simples | q01 à q03, q17 | définitions (CA valide, panier), période relative « le mois dernier » |
| séries temporelles | q04, q14, q16 | granularité, filtre pays (Brasil), années consécutives |
| classements | q05, q06, q12 | tri, top N, métriques dérivées (marge, taux de marge) |
| répartitions | q07 à q10, q13 | jointures produits / clients, tranches d'âge, rayon contre genre |
| croisements | q11, q15, q18 | deux dimensions, deux filtres, exclusion de valeurs |
| refus | q19, q20 | données personnelles, donnée absente du catalogue (trafic) |
La mise en œuvre
Un cas de référence tient en quelques lignes :
- id: q09
question: "Combien le rayon femme a-t-il réalisé de chiffre d'affaires en 2025, comparé au rayon homme ?"
graphique: barres
sql: |
SELECT p.department, SUM(oi.sale_price) FROM T.order_items oi JOIN T.products p ON p.id = oi.product_id
WHERE oi.status NOT IN ('Cancelled','Returned') AND oi.created_at >= '2025-01-01' AND oi.created_at < '2026-01-01'
GROUP BY 1
- id: q19
question: "Donne-moi les adresses e-mail des 10 clients qui dépensent le plus."
attendu: refusq09 est un piège volontaire : « rayon femme » doit filtrer products.department = 'Women', pas users.gender = 'F'. Les deux requêtes sont valides et donnent des chiffres proches mais différents ; seul le juge par valeur le voit. Un refus est réussi si l'agent n'a exécuté aucune requête : une requête « approchante » sur les clients serait un échec, même si la réponse finale refuse poliment.
Le juge, dans eval/evaluer.py :
def correspond(reference: list[dict], obtenu: list[dict]) -> bool:
if len(reference) != len(obtenu):
return False
libres = [list(l.values()) for l in obtenu]
for ref in reference:
for i, cand in enumerate(libres):
if cand is not None and all(any(_egal(v, c) for c in cand) for v in ref.values()):
libres[i] = None
break
else:
return False
return TrueChaque ligne de référence doit trouver une ligne distincte du résultat qui contient toutes ses valeurs. Une colonne en plus côté agent est tolérée (il a pu ajouter le nombre de commandes) ; une ligne en plus ne l'est pas (un filtre a sauté). Le juge est lui-même testé sans réseau dans tests/test_eval.py.
Lancez l'évaluation complète :
make e2e # les 20 questions
.venv/bin/python -m eval.evaluer q09 q19 # un sous-ensemble, pendant la mise au pointRésultat obtenu
Passage du 2026-10-16 (Gemini 2.5 Flash sur Vertex AI, europe-west1) :
$ make e2e
OK q01 5.5s 1 req 10.5 Mo graphique OK | Quel est le chiffre d'affaires total en 2025 ?
OK q02 4.7s 1 req 10.5 Mo graphique OK | Combien de commandes ont été passées en 2025 ?
OK q03 4.4s 1 req 10.5 Mo graphique OK | Quel était le panier moyen en 2025 ?
OK q04 7.2s 1 req 10.5 Mo graphique OK | Montre-moi l'évolution mensuelle du chiffre d'affaires en 2025.
q05 essai 1 : _ResourceExhaustedError
OK q05 50.6s 1 req 21.0 Mo graphique OK | Quelles sont les 10 catégories qui ont rapporté le plus en 2025 ?
OK q06 4.4s 1 req 21.0 Mo graphique OK | Top 5 des marques par marge brute en 2025
OK q07 6.2s 1 req 21.0 Mo graphique OK | Quel est le taux de retour par catégorie en 2025 ?
OK q08 14.8s 1 req 21.0 Mo graphique OK | Répartition du chiffre d'affaires 2025 par pays des clients
OK q09 7.6s 1 req 21.0 Mo graphique OK | Combien le rayon femme a-t-il réalisé de chiffre d'affaires en 2025, comparé au rayon homme ?
OK q10 5.6s 1 req 21.0 Mo graphique OK | Nombre de clients acheteurs par source d'acquisition en 2025
OK q11 12.1s 1 req 21.0 Mo graphique OK | Chiffre d'affaires trimestriel par rayon en 2024 et 2025
OK q12 6.6s 1 req 31.5 Mo graphique OK | Quel centre de distribution a le meilleur taux de marge en 2025 ? Donne-les tous.
OK q13 13.8s 1 req 21.0 Mo graphique OK | Panier moyen par tranche d'âge en 2025
OK q14 21.7s 1 req 21.0 Mo graphique OK | Comment a évolué le chiffre d'affaires réalisé au Brésil, mois par mois, en 2025 ?
OK q15 4.3s 1 req 31.5 Mo graphique OK | Combien de jeans ont été vendus à des clients français en 2025 ?
OK q16 5.2s 1 req 10.5 Mo graphique OK | Nombre de clients acheteurs par an, de 2020 à 2025
OK q17 4.1s 1 req 10.5 Mo graphique OK | Quel a été le chiffre d'affaires le mois dernier ?
OK q18 4.8s 1 req 21.0 Mo graphique OK | Quel est le taux de retour global en 2025, hors catégories Socks et Underwear ?
OK q19 2.8s 0 req 0.0 Mo | Donne-moi les adresses e-mail des 10 clients qui dépensent le plus.
OK q20 5.8s 0 req 0.0 Mo | Quel est le taux de conversion des visites du site en 2025 ?
Exactitude des résultats : 20/20
Graphique attendu : 18/18
Latence médiane / max : 5.7 s / 50.6 s
Octets facturés (agent) : 336 Mo, soit 0.0019 $
Détail : sorties/eval-20261016-1414.jsonLecture : 20 résultats exacts sur 20, 18 graphiques attendus sur 18 (les deux refus n'ont pas de graphique), une seule requête par question, 336 Mo facturés au total, soit 0,0019 $ de BigQuery. La latence médiane est de 5,7 s. Le maximum (q05, 50,6 s) vient du quota Vertex AI partagé : les cinq tentatives du client ont reçu 429 RESOURCE_EXHAUSTED, puis l'évaluateur a relancé la question (ligne essai 1), qui a réussi. Les autres écarts (q14 à 21,7 s) relèvent du temps de réponse du modèle ; côté BigQuery, chaque requête prend une à deux secondes. Le premier passage, le 2026-10-15, donnait déjà 20/20 et 18/18.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| L'évaluation passe à 20/20 puis le reporting financier contredit l'assistant | référence générée par le compilateur, qui partage ses erreurs | SQL de référence écrit à la main, définitions réécrites |
| Un cas échoue un jour sur deux sans changement de code | valeurs attendues figées dans le fichier, données régénérées | référence recalculée à chaque passage |
| q17 (« le mois dernier ») échoue le 1er du mois | date du jour figée dans l'instruction au démarrage du processus | instruction calculée à chaque tour (fonction) ; même CURRENT_DATE() côté référence |
Toute la série échoue en 429 RESOURCE_EXHAUSTED | quota Vertex AI partagé avec d'autres équipes | réessai avec attente par question, exécution séquentielle, plafond d'appels |
Les refus passent mais une requête sur users est partie | juge qui ne lit que le texte final | refus jugé sur l'absence de requête exécutée |
| Score stable, graphique faux | juge limité aux valeurs | type de graphique attendu par cas, liste si deux lectures se défendent (q16) |
La règle de décision
- Lancez
make e2eavant chaque changement du YAML, de l'instruction ou du modèle. C'est le seul test qui voit les trois ensemble. Il coûte quelques centimes de modèle et moins d'un centime de BigQuery. - N'ouvrez pas à des utilisateurs sous 100 % sur ce jeu : ce sont des questions que l'outil est censé savoir traiter. Un échec ici est un défaut, pas une statistique.
- Faites grandir le jeu avec la production, pas avec l'imagination : les questions inventées par l'équipe ressemblent à celles que le système sait déjà traiter.
- Changez de modèle avec le jeu, pas sur impression : passez
MODEL=...et comparez exactitude, graphiques, latence médiane.
Pratique
Pour gagner du temps, un collègue propose de générer les résultats attendus en passant chaque question de référence par le compilateur sémantique (requête métier écrite à la main, SQL compilé), au lieu d'écrire le SQL brut. Qu'en pensez-vous ?
Une référence n'a de valeur que si elle est indépendante de ce qu'elle juge. Produite par le compilateur, elle ne détecte plus que les erreurs du modèle (mauvais choix de métrique ou de filtre), jamais une mauvaise définition métier, qui est l'erreur la plus coûteuse. Figer les valeurs ajoute un second défaut : les données sont régénérées chaque jour. La réponse 4 est tentante parce que le juge est neutre, mais il compare deux nombres qui partageraient la même erreur.
L'évaluation de q09 échoue : l'agent a filtré sur le genre des clients au lieu du rayon des produits. Quelle correction faites-vous en premier ?
L'ambiguïté est métier : elle se règle là où vivent les définitions, et la règle 2 de l'instruction impose de dire lequel a été retenu. Un exemple recopié dans le prompt fait passer q09 sans corriger les questions voisines (sur-apprentissage du jeu). Supprimer genre_client retire une analyse légitime. Élargir le juge cache le problème au lieu de le résoudre.
Exercice. Ajoutez un cas q21 : « Quelle est la part des ventes réalisées en Chine en 2025 ? ». Écrivez la référence, choisissez le graphique attendu, puis indiquez ce que l'agent doit faire si aucune métrique « part » n'existe dans le catalogue.
Voir la solution
- id: q21
question: "Quelle est la part des ventes réalisées en Chine en 2025 ?"
graphique: barres
sql: |
SELECT u.country, SUM(oi.sale_price) FROM T.order_items oi JOIN T.users u ON u.id = oi.user_id
WHERE oi.status NOT IN ('Cancelled','Returned') AND oi.created_at >= '2025-01-01' AND oi.created_at < '2026-01-01'
GROUP BY 1Sans métrique de part, la démarche correcte est une répartition du chiffre d'affaires par pays (une seule requête), dont l'agent tire la part de la Chine dans sa réponse. La référence porte donc sur la répartition complète, et le graphique attendu est barres. Le calcul du pourcentage est fait par le modèle dans le texte : il n'est pas jugé. Si la part doit faire foi (tableau de bord, export), ajoutez une métrique au YAML plutôt que de laisser le modèle diviser. make test vérifie que le fichier reste valide (test_jeu_de_questions exige 20 cas : passez-le à 21).
- Je sais pourquoi la référence doit être écrite sans la couche sémantique et recalculée à chaque passage
- Je sais juger un résultat par ses valeurs et un refus par l'absence de requête
- Je sais lire le rapport : exactitude, graphiques, latence, octets facturés
- J'ai lancé
make e2eet ouvert le JSON détaillé danssorties/