Tester la qualité, versionner, livrer
Le jour de nos tests, sans skill, le modèle a annoncé 196,80 € remboursables sur la note de frais de Léa. C'est exactement le chiffre attendu. Il l'a obtenu en remboursant l'hôtel au-delà du plafond et en écartant un parking légitime : deux erreurs qui s'annulent. Une assertion « le total est juste » passait. Un test de qualité qui ne regarde que le résultat final récompense ce genre de chance ; il faut une grille qui regarde le raisonnement, et une comparaison avec la réponse sans skill pour savoir ce que le skill apporte vraiment.
Le mécanisme
Le guide Evaluating skill output quality pose le principe : chaque cas tourne avec et sans le skill (ou avec la version précédente), et chaque sortie est notée contre des assertions vérifiables, PASS ou FAIL avec preuve. Nous ajoutons un troisième mode, « tout chargé », pour séparer l'effet du contenu du skill de l'effet de la divulgation progressive.
flowchart LR
C["Cas : requête + grille de 5 critères"] --> P["Mode progressif"]
C --> N["Sans skill"]
C --> E["Tout chargé"]
P --> J["Juge gemini-3.5-flash, sortie JSON : verdict + preuve par critère"]
N --> J
E --> J
P --> K["Vérification par code (total exact)"]
N --> K
E --> K
J --> S["Score / critères"]
K --> S
S --> G{"Progressif >= QUALITY_MIN sur chaque cas ?"}
Une bonne grille contient des critères observables (« T5 est identifié comme doublon de T1 »), jamais « la réponse est claire ». Tout ce qu'un script peut vérifier (un total, un JSON valide, un nombre de lignes) est vérifié par du code, pas par le juge.
La mise en œuvre
Les trois cas (evals/quality.json) exercent un skill repris de la bibliothèque sans script (meeting-notes-to-actions, ticket-triage) et un skill à script (expense-report-checker). Le juge reçoit la demande, la réponse et la grille, et doit répondre selon un schéma JSON :
JUDGE_PROMPT = """Vous notez la réponse d'un assistant contre une grille. Pour chaque critère, PASS seulement si la réponse
le satisfait clairement ; citez l'extrait qui le prouve (ou ce qui manque). Pas de bénéfice du doute. ..."""
def grade(client, case, answer):
crit = judge(client, case, answer) # response_json_schema, temperature=0
checks = [{"critere": "total exact (vérification par code)",
"verdict": "PASS" if re.search(case["must_match"], answer) else "FAIL",
"preuve": case["must_match"]}] if case.get("must_match") else []
allc = checks + crit
return {"score": sum(c["verdict"] == "PASS" for c in allc), "sur": len(allc), "criteres": allc}Le versionnage ferme la boucle. Chaque skill porte metadata.version ; skills.lock.json fige, pour chaque skill, la version et une empreinte SHA-256 de tous ses fichiers (un barème modifié dans references/ change le comportement autant que le corps). make test compare l'état courant au verrou :
elif old["sha256"] != cur["sha256"] and old["version"] == cur["version"]:
problems.append(f"{name} : contenu modifié sans changer metadata.version ({cur['version']})")
elif old["sha256"] != cur["sha256"]:
problems.append(f"{name} : {old['version']} -> {cur['version']}, relancez les évals puis make lock")Le circuit de livraison : modifier le skill, monter la version (correctif, mineure si le comportement change, majeure si les entrées changent), relancer make eval, puis make lock et fusionner. Le verrou rend l'oubli visible en CI, sans réseau.
Résultat obtenu
python run.py eval-quality, gemini-3.5-flash, 2026-10-15 :
cr-ouverture-annecy progressive 5/5 appels 2 tokens entrée 4994 skills ['meeting-notes-to-actions']
cr-ouverture-annecy none 3/5 appels 1 tokens entrée 136
FAIL Une action sans date dans les notes (annonces de Karim) porte une mention du type « à conf
FAIL Un e-mail ou message de récapitulatif prêt à envoyer est fourni.
cr-ouverture-annecy eager 5/5 appels 2 tokens entrée 19827
frais-lille progressive 6/6 appels 5 tokens entrée 14333 skills ['expense-report-checker'] scripts [...check_expenses.py]
frais-lille none 4/6 appels 1 tokens entrée 183
FAIL Le dépassement du plafond hôtel (ligne 1, 152 € pour un plafond de 140 €) est signalé avec
FAIL La réponse rappelle que la décision de validation revient au manager ou au valideur.
frais-lille eager 6/6 appels 3 tokens entrée 30489
tri-sav progressive 5/5 appels 2 tokens entrée 5128 skills ['ticket-triage']
tri-sav none 4/5 appels 1 tokens entrée 137
FAIL Chaque ticket reçoit une priorité et une équipe de destination.
tri-sav eager 5/5 appels 2 tokens entrée 19844Avec skill : 16 critères sur 16 ; sans skill : 11 sur 16 ; tout chargé : 16 sur 16 pour 2 à 4 fois plus de tokens. Sur frais-lille sans skill, la vérification par code du total passe (le fameux 196,80 €) alors que le juge refuse le critère du plafond : c'est pour ce cas que la grille existe. Et le verrou, sur une copie du dépôt où l'on modifie regles-conges.md :
VERROU leave-balance-calculator : contenu modifié sans changer metadata.version (1.0.3)
VERROU leave-balance-calculator : 1.0.3 -> 1.1.0, relancez les évals puis make lock
skills.lock.json écrit.Pour finir, make e2e, 2026-10-15, 40 s :
OK attendu=expense-report-checker activés=['expense-report-checker'] scripts=['expense-report-checker/scripts/check_expenses.py'] tokens=13148
OK attendu=leave-balance-calculator activés=['leave-balance-calculator'] scripts=['leave-balance-calculator/scripts/leave_balance.py'] tokens=13464
OK attendu=None activés=[] scripts=[] tokens=1611
OK ADK SkillToolset : APPEL load_skill … | APPEL run_skill_script … | RÉPONSE **Solde au 15/10/2026 : 6 jours ouvrés**
4/4 réussis en 40 sCe qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Tous les critères passent, avec ou sans skill | grille trop facile : elle teste ce que le modèle sait déjà faire | supprimer les critères qui passent dans les deux modes ; garder ceux qui séparent |
| Le juge note large | consigne permissive, pas de preuve exigée | « pas de bénéfice du doute », extrait cité obligatoire, température 0 |
| Le juge et l'agent se trompent ensemble | même modèle, mêmes angles morts | vérifications par code pour tout ce qui est calculable ; relecture humaine d'un échantillon |
| Un barème modifié change les remboursements sans revue | fichier de references/ ou assets/ hors du périmètre de la revue | empreinte sur tous les fichiers du skill, verrou vérifié en CI |
| Un skill repris d'une bibliothèque diverge en silence | copie locale modifiée, version restée à 0.0.0 | ajouter metadata.version et une source dans metadata lors de la reprise |
La règle de décision
Chaque skill livré a au moins trois cas notés, avec un mode sans skill pour prouver son apport ; un skill qui ne bat pas le mode sans skill n'a pas sa place dans le catalogue. Vérifiez par code tout ce qui est calculable, par juge le reste, et relisez vous-même un échantillon de verdicts à chaque nouvelle grille. Bloquez la fusion de tout skill dont le contenu a changé sans changement de version, et de toute version dont les évals n'ont pas tourné.
Pratique
Sur un nouveau skill, la grille donne 5/5 avec skill et 5/5 sans skill. Que concluez-vous ?
Des critères qui passent dans les deux modes ne disent rien de la valeur du skill. Il faut des critères qui échouent sans lui ; si on n'en trouve aucun, le skill est inutile. Le juge n'est pas en cause tant que ses preuves sont justes ; le mode tout chargé compare la divulgation, pas l'apport.
Un analyste corrige une coquille dans references/politique-frais.md et pousse sans changer la version. La CI échoue sur le verrou. Il propose de relancer make lock sans évals « puisque c'est une coquille ». Votre réponse ?
Le harness ne sait pas distinguer une coquille d'un plafond modifié : le fichier est lu par le modèle et cité aux utilisateurs. La procédure coûte quelques minutes ; l'exception crée la brèche par laquelle passera la vraie modification non revue.
Exercice. Ajoutez un quatrième cas de qualité pour leave-balance-calculator avec une vérification par code et trois critères de grille, puis faites échouer volontairement le skill (retirez du corps la consigne « sans recalcul ») et observez si la grille le détecte.
Voir la solution
{"id": "solde-reliquat", "skill": "leave-balance-calculator",
"query": "Entrée le 2019-03-04, 4 jours de reliquat, 12 jours posés depuis juin. Quel solde au 2026-10-15 ?",
"must_match": "(?<!\\d)1(?:[,.]0+)?\\s*(jours?|j)\\b",
"rubric": ["Le solde annoncé est celui calculé par le script.",
"Le détail montre acquis, report et jours pris.",
"La réponse rappelle que le bulletin de paie fait foi."]}Le script donne 9 jours acquis (4 mois x 2,08 arrondi au supérieur), plus 4 de report, moins 12 pris : 1 jour. Lancez les deux versions sur ce cas, trois fois chacune. Si la grille ne voit aucune différence, c'est que la consigne ne protégeait de rien sur ce cas, et vous l'apprenez par la mesure plutôt que par conviction.
- Je sais écrire une grille de critères observables
- Je sais comparer avec skill, sans skill et tout chargé
- Je sais réserver au code ce qui est calculable
- Je sais versionner un skill et bloquer une modification non réévaluée