Leçon 7 — Tester et versionner

Tester la qualité, versionner, livrer

8 min de lecture

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 :

files/skillkit/evals.py
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 :

files/skillkit/spec.py
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  19844

Avec 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 s

Ce qui casse en production

SymptômeCauseParade
Tous les critères passent, avec ou sans skillgrille trop facile : elle teste ce que le modèle sait déjà fairesupprimer les critères qui passent dans les deux modes ; garder ceux qui séparent
Le juge note largeconsigne 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 ensemblemême modèle, mêmes angles mortsvérifications par code pour tout ce qui est calculable ; relecture humaine d'un échantillon
Un barème modifié change les remboursements sans revuefichier de references/ ou assets/ hors du périmètre de la revueempreinte sur tous les fichiers du skill, verrou vérifié en CI
Un skill repris d'une bibliothèque diverge en silencecopie locale modifiée, version restée à 0.0.0ajouter 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 ?

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 ?

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