--- title: "Atelier : un mini-harnais d'évaluation Python complet" --- > [!NOTE] > Dans cette leçon, vous allez assembler un **harnais d'évaluation** en Python (bibliothèque standard uniquement) : jeu doré, contrôles par code, rappel de la recherche, LLM-juge branchable (Claude ou Gemini sur Vertex AI), rapport JSON et **code de sortie** pour la CI. Le kit complet et testé est téléchargeable : [files/evalkit](../files/evalkit/). ```mermaid flowchart LR A["golden/*.jsonl"] --> B["evaluer_cas()"] S["systeme.repondre()"] --> B J["juges.juge (optionnel)"] --> B B --> D["synthese() : taux, rappel, p95, catégories"] D --> E["rapport.json"] D --> F{"taux >= seuil ?"} F -- "Non" --> H["exit 1 : CI rouge"] F -- "Oui" --> G["exit 0"] ``` Le kit contient `run_eval.py` (ci-dessous), `systeme.py` (votre assistant : `repondre(question) -> {"reponse": str, "sources": [str]}`, un faux système est fourni), `juges.py` (`juge_claude` pour l'API Claude, `juge_gemini` pour Vertex AI), `test_run_eval.py` et `golden/demo.jsonl`. ## Le harnais ```python title="run_eval.py" """Mini-harnais d'évaluation RAG (stdlib). Usage : python run_eval.py golden/rh.jsonl --seuil 0.85""" import argparse, json, re, sys, time from pathlib import Path REFUS = ("je ne sais pas", "je ne trouve pas", "je ne peux pas", "hors de mon périmètre") CHECKS = { # contrôles par code : gratuits et déterministes "contient": lambda s, c: all(m.lower() in s["reponse"].lower() for m in c.get("doit_contenir", [])), "interdit": lambda s, c: not any(m.lower() in s["reponse"].lower() for m in c.get("interdit", [])), "citation": lambda s, c: set(c.get("sources", [])) <= set(s.get("sources", [])), "refus": lambda s, c: any(m in s["reponse"].lower() for m in REFUS), } def rappel(sortie, cas): # métrique de recherche ; None si aucun document attendu attendus = set(cas.get("sources", [])) return len(attendus & set(sortie.get("sources", []))) / len(attendus) if attendus else None GRILLE = """Tu es un évaluateur strict. Question : {question} Réponse de référence : {attendu} Critère : {critere} {reponse} 5 = critère entièrement respecté ; 3 = partiellement ; 1 = pas du tout ou faux. Justifie en deux phrases maximum puis termine par : NOTE: <1-5>""" def juger(juge, cas, sortie): texte = juge(GRILLE.format(question=cas["question"], attendu=cas.get("attendu", ""), critere=cas["critere"], reponse=sortie["reponse"])) m = re.search(r"NOTE:\s*([1-5])", texte) return int(m.group(1)) if m else None # None = juge illisible -> échec def evaluer_cas(cas, repondre, juge=None): t0, erreur, note = time.perf_counter(), None, None try: sortie = repondre(cas["question"]) except Exception as e: # une exception = un échec noté, pas un crash du harnais sortie, erreur = {"reponse": "", "sources": []}, repr(e) latence = time.perf_counter() - t0 verdicts = {n: CHECKS[n](sortie, cas) for n in cas.get("checks", [])} if juge and cas.get("critere"): try: note = juger(juge, cas, sortie) verdicts["juge"] = note is not None and note >= 4 except Exception as e: # juge indisponible : le cas ne peut pas être certifié erreur = erreur or f"juge: {e!r}" return {"id": cas["id"], "categorie": cas.get("categorie", "?"), "ok": erreur is None and all(verdicts.values()), "verdicts": verdicts, "note_juge": note, "rappel": rappel(sortie, cas), "latence_s": round(latence, 3), "erreur": erreur, "reponse": sortie["reponse"][:500]} def charger(chemin): return [json.loads(l) for l in Path(chemin).read_text(encoding="utf-8").splitlines() if l.strip()] def synthese(res): n, rap, cats = len(res), [r["rappel"] for r in res if r["rappel"] is not None], {} for r in res: cats.setdefault(r["categorie"], []).append(r["ok"]) lat = sorted(r["latence_s"] for r in res) return {"n": n, "taux_reussite": round(sum(r["ok"] for r in res) / n, 3), "rappel_moyen": round(sum(rap) / len(rap), 3) if rap else None, "latence_p95_s": lat[min(n - 1, int(0.95 * n))], "par_categorie": {c: round(sum(v) / len(v), 3) for c, v in cats.items()}, "echecs": [r["id"] for r in res if not r["ok"]]} def main(argv=None, repondre=None, juge=None): p = argparse.ArgumentParser() p.add_argument("golden") p.add_argument("--seuil", type=float, default=0.85) p.add_argument("--sortie", default="rapport.json") a = p.parse_args(argv) res = [evaluer_cas(c, repondre, juge) for c in charger(a.golden)] s = synthese(res) Path(a.sortie).write_text(json.dumps({"synthese": s, "details": res}, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(s, ensure_ascii=False)) if s["taux_reussite"] < a.seuil: print(f"ÉCHEC : {s['taux_reussite']:.1%} < seuil {a.seuil:.0%}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": from systeme import repondre # votre assistant RAG from juges import juge # None si la variable JUGE n'est pas définie sys.exit(main(repondre=repondre, juge=juge)) ``` > [!WARNING] > Une exception de l'assistant **ou du juge** (timeout, quota, clé absente) est **comptée comme un échec** : une panne ne doit jamais rendre la CI verte. Le juge est une simple fonction `prompt -> texte`, désactivée par défaut : `JUGE=claude` ou `JUGE=gemini` l'active, `MODELE_JUGE` fixe une **version précise** (notes comparables d'une campagne à l'autre). Ne faites pas juger un modèle par lui-même (biais d'auto-complaisance) et calibrez le juge sur un échantillon humain. ## Tester le harnais avant le système Un harnais buggé qui renvoie toujours « OK » est pire que pas de harnais. Le kit contient un faux système dont le cas `rh-003` cite volontairement la règle 2024 : ```bash python3 test_run_eval.py # tests OK python3 run_eval.py golden/demo.jsonl --seuil 0.6 # {"n": 3, "taux_reussite": 0.667, "rappel_moyen": 0.5, "latence_p95_s": 0.0, # "par_categorie": {"frequent": 0.5, "sans-reponse": 1.0}, "echecs": ["rh-003"]} ``` ```quiz question: "Pourquoi le cas rh-002 (catégorie sans-reponse) n'entre-t-il pas dans le rappel moyen ?" options: - "Parce qu'il a réussi" - "Parce qu'il n'a aucune source attendue : rappel() renvoie None et le cas est exclu de la moyenne" - "Parce que le juge n'est pas branché" answer: 1 explanation: "Le rappel n'a pas de sens sans document attendu. Compter 0 ou 1 fausserait la moyenne : on renvoie None et on filtre." ``` ## Lire le rapport Lisez dans l'ordre : **`echecs`** (les cas à ouvrir un par un), **`par_categorie`** (90 % au global peut cacher 50 % sur les pièges), **`rappel_moyen`** (s'il baisse, regardez l'index avant le prompt), puis **`latence_p95_s`**. > [!IMPORTANT] > Avec 30 cas, un cas vaut environ 3,3 points. Passer de 90 % à 87 %, c'est **un seul cas** : identifiez-le et relancez-le avant de conclure à une régression. ```quiz question: "Le rapport indique taux_reussite = 0.9 et par_categorie = {frequent: 1.0, piege: 0.5}. Quelle conclusion est la plus juste ?" options: - "Le système est prêt : 90 % dépasse le seuil de 85 %" - "Le score global masque une faiblesse sur les pièges : il faut un seuil par catégorie critique" - "Le juge LLM est défaillant" answer: 1 explanation: "Les questions fréquentes tirent la moyenne vers le haut. Pour les catégories critiques (sécurité, refus), un seuil dédié, souvent 100 %, est plus pertinent." ``` ## Exercice : un seuil par catégorie Ajoutez à `main()` une option répétable `--seuil-categorie piege=1.0` : la commande échoue si une catégorie listée passe sous son seuil, même si le taux global est bon.
Voir la solution ```python title="run_eval.py (fin de main)" p.add_argument("--seuil-categorie", action="append", default=[]) # avant parse_args # ... après le calcul de s : code = 0 if s["taux_reussite"] >= a.seuil else 1 for regle in a.seuil_categorie: cat, val = regle.split("=") taux = s["par_categorie"].get(cat) if taux is None or taux < float(val): print(f"ÉCHEC {cat} : {taux} < {val}", file=sys.stderr) code = 1 return code ``` Une catégorie **absente** (`taux is None`) fait aussi échouer : supprimer par erreur les cas pièges ne doit pas rendre la CI verte. Cette version est celle du kit téléchargeable (testée dans `test_run_eval.py`).
## Je sais faire - [ ] Brancher n'importe quel assistant via `repondre(question)`. - [ ] Activer un juge Claude ou Gemini sur Vertex AI sans modifier le harnais. - [ ] Lire un rapport : échecs, catégories, rappel, latence, taille réelle d'un écart. - [ ] Faire échouer la commande sous un seuil global ou par catégorie.