Leçon 2 — Construire pas à pas

La grille et l'arbitre

6 min de lecture

La grille et l'arbitre

1. La grille, en données

La grille n'est pas cachée dans un prompt : c'est un dictionnaire Python. Le même objet sert à écrire l'instruction du critique et à fabriquer les consignes du réviseur.

files/agent.py (grille)
CRITERES = {
    "clarte": "Le lecteur comprend l'offre en une lecture ; phrases courtes, pas de jargon.",
    "concret": "Bénéfices chiffrés ou exemples précis tirés du brief, pas de promesses vagues.",
    "appel_action": "Une seule action claire et facile pour le lecteur à la fin.",
    "ton": "Professionnel et chaleureux, vouvoiement, pas de superlatifs creux.",
    "correction": "Orthographe, grammaire et accords irréprochables.",
}
SEUIL = 4                          # note minimale (sur 5) pour CHAQUE critère
MOYENNE_MIN = 4.8                  # et moyenne minimale sur l'ensemble des critères
MOTS_MIN, MOTS_MAX = 90, 150       # longueur attendue : lisible sur un écran de téléphone
MOTS_INTERDITS = ["révolutionnaire", "incroyable", "leader", "n°1", "meilleur du marché"]
MENTIONS_OBLIGATOIRES = ["89 EUR HT", "nimbus.example/audit"]  # prix exact et lien de réservation du brief

2. Le critique répond dans un schéma

Avec output_schema, la réponse du critique est validée par Pydantic : chaque note est un entier entre 1 et 5 (Field(ge=1, le=5)), sinon ADK lève une erreur de validation au lieu de laisser passer un « 4/5 » en texte libre.

files/agent.py (schéma du critique)
class Evaluation(BaseModel):
    clarte: int = Field(ge=1, le=5)
    concret: int = Field(ge=1, le=5)
    appel_action: int = Field(ge=1, le=5)
    ton: int = Field(ge=1, le=5)
    correction: int = Field(ge=1, le=5)
    remarques: list[str] = Field(description="Corrections précises et actionnables, une par problème")

Pourquoi donner un output_schema au critique plutôt que lui demander « une note sur 5 » en texte ?

3. arbitrer() : de la note à la consigne

files/agent.py (arbitrer)
def arbitrer(evaluation: dict, texte: str) -> tuple[bool, list[str]]:
    consignes = [f"{c} : note {evaluation.get(c, 0)}/5 < {SEUIL}. {CRITERES[c]}"
                 for c in CRITERES if evaluation.get(c, 0) < SEUIL]
    moyenne = sum(evaluation.get(c, 0) for c in CRITERES) / len(CRITERES)
    if moyenne < MOYENNE_MIN:
        consignes.append(f"moyenne : {moyenne:.1f}/5 < {MOYENNE_MIN}. Traitez en priorité les remarques ci-dessous.")
    nb_mots = len(re.findall(r"\w+", texte))
    if not MOTS_MIN <= nb_mots <= MOTS_MAX:
        consignes.append(f"longueur : {nb_mots} mots, il en faut entre {MOTS_MIN} et {MOTS_MAX}.")
    consignes += [f"mot interdit : « {m} »" for m in MOTS_INTERDITS if m in texte.lower()]
    consignes += [f"mention obligatoire absente : « {m} »" for m in MENTIONS_OBLIGATOIRES if m not in texte]
    if consignes:  # les remarques du critique ne comptent que si la grille n'est pas satisfaite
        consignes += evaluation.get("remarques", [])
    return not consignes, consignes

Trois décisions de conception :

  • Une note manquante vaut 0 (evaluation.get(c, 0)) : un critère oublié fait échouer la grille, il ne passe pas en silence.
  • Les consignes sont des phrases : le réviseur les reçoit telles quelles. Une consigne qui rappelle la définition du critère est plus utile qu'un simple « clarté : 3 ».
  • Les remarques du critique s'ajoutent seulement en cas d'échec : un critique trouve toujours quelque chose à redire ; ce n'est pas une raison de boucler encore.
flowchart TB
  E["evaluation (notes + remarques)"] --> N{"chaque note >= 4 ?"}
  E --> M{"moyenne >= 4.8 ?"}
  T["brouillon"] --> L{"90 à 150 mots ?"}
  T --> I{"aucun mot interdit ?"}
  T --> O{"mentions obligatoires ?"}
  N & M & L & I & O --> D{"toutes vraies ?"}
  D -->|"oui"| V["valide : escalate"]
  D -->|"non"| C["consignes pour le réviseur"]

4. L'agent Arbitre

files/agent.py (Arbitre)
class Arbitre(BaseAgent):
    async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
        s = ctx.session.state
        ok, consignes = arbitrer(s.get("evaluation") or {}, s.get("brouillon", ""))
        tours = s.get("tours", 0) + 1
        historique = s.get("historique", []) + [{"tour": tours, "evaluation": s.get("evaluation"), "valide": ok}]
        texte = f"Tour {tours} : {'VALIDÉ' if ok else f'{len(consignes)} consigne(s)'}"
        yield Event(author=self.name, invocation_id=ctx.invocation_id,
                    content=types.Content(role="model", parts=[types.Part(text=texte)]),
                    actions=EventActions(escalate=ok, state_delta={
                        "consignes": "\n".join(f"- {c}" for c in consignes),
                        "tours": tours, "historique": historique, "valide": ok}))

EventActions(escalate=ok) est la condition d'arrêt. Dans le code source de LoopAgent (ADK 2.10), la boucle lit chaque événement de ses sous-agents et sort dès que event.actions.escalate est vrai : le réviseur, placé après l'arbitre, n'est donc pas appelé quand le texte est validé.

Dans la boucle [critique, arbitre, reviseur], l'arbitre émet escalate=True au tour 2. Combien de fois le réviseur a-t-il été appelé ?

Exercice : un contrôle de phrases trop longues

Ajoutez à arbitrer() un contrôle en code : aucune phrase ne doit dépasser 25 mots. La consigne doit citer le début de la phrase fautive.

Voir la solution
files/agent.py (ajout dans arbitrer)
MOTS_PAR_PHRASE_MAX = 25

for phrase in re.split(r"(?<=[.!?])\s+", texte):
    n = len(re.findall(r"\w+", phrase))
    if n > MOTS_PAR_PHRASE_MAX:
        consignes.append(f"phrase trop longue ({n} mots) : « {phrase[:40]}… » ; coupez-la en deux.")

Ajoutez un test : un texte valide par ailleurs mais avec une phrase de 30 mots doit renvoyer ok == False et une consigne qui commence par « phrase trop longue ».

Résultat obtenu

Tests sans clé (2026-10-15), dont celui de la grille :

pytest -v
test_agent.py::test_grille_valide_et_refuse PASSED
test_agent.py::test_boucle_s_arrete_des_que_la_grille_est_satisfaite PASSED
test_agent.py::test_boucle_bornee_par_max_iterations PASSED
============================== 3 passed in 1.27s ===============================

Évaluation réelle du premier jet par gemini-3.5-flash le même jour : chaque note est au moins à 4, mais la moyenne (4,4) est sous 4,8. Résultat : 4 consignes (la moyenne plus 3 remarques du critique).

historique, tour 1
{'tour': 1, 'evaluation': {'clarte': 4, 'concret': 5, 'appel_action': 5, 'ton': 4, 'correction': 4,
 'remarques': ["Corriger la faute d'élision dans 'en cas de incident' en écrivant 'en cas d'incident'.", ...]},
 'valide': False}

Je sais faire

  • Je décris une grille de qualité sous forme de données Python
  • Je contrains les notes d'un critique LLM avec un schéma Pydantic
  • J'écris une fonction d'arbitrage testable qui produit des consignes
  • J'arrête un LoopAgent en émettant un événement escalate=True
Suivant