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
"""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>{reponse}</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))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 :
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"]}Pourquoi le cas rh-002 (catégorie sans-reponse) n'entre-t-il pas dans le rappel moyen ?
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.
Le rapport indique taux_reussite = 0.9 et par_categorie = {frequent: 1.0, piege: 0.5}. Quelle conclusion est la plus juste ?
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
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 codeUne 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.