Lección 4 — Tester, évaluer, exploiter

Tester sans clé avec un faux modèle

5 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

Tester sans clé avec un faux modèle

Ce qu'on veut prouver

PromesseTestComment on le voit
les règles détectent ce qu'elles doivent, et rien d'autretest_donnees_personnelles_masquees, test_promesses_detectees, test_pas_de_faux_positifappels directs aux fonctions de moderation.py
le modèle ne reçoit jamais l'e-mail ni le téléphonetest_brief_propre_et_masquageon inspecte les requêtes reçues par le faux modèle
un brief piège ne déclenche aucun appeltest_brief_piege_bloque_avant_le_modelela liste des requêtes reçues reste vide
un dérapage ne bloque que son canaltest_sortie_bloquee_sur_un_seul_canal1 texte bloqué, 3 intacts
flowchart LR
  T["Test"] --> D["decliner(brief, creer_agent(model=FauxModele))"]
  D --> CB1["before_model réel"]
  CB1 --> FM["FauxModele : enregistre la requête, renvoie un texte préparé"]
  FM --> CB2["after_model réel"]
  CB2 --> ST["État de session"]
  T -->|"assert"| FM
  T -->|"assert"| ST

Le point clé : seul le modèle est faux. Les callbacks, le ParallelAgent, output_key et la session sont les vrais composants d'ADK.

Un faux modèle qui répond selon le canal

files/test_multicanal.py
class FauxModele(BaseLlm):
    """Répond selon le nom de l'agent appelant ; garde chaque requête reçue."""
    reponses: dict = {}
    vus: list = []

    async def generate_content_async(self, llm_request, stream=False):
        self.vus.append(llm_request)
        consignes = str(llm_request.config.system_instruction)
        canal = next(c for c, (_, txt) in CANAUX.items() if txt in consignes)
        yield LlmResponse(content=types.Content(role="model", parts=[
            types.Part(text=self.reponses[canal])]))

Les quatre agents partagent le même objet modèle ; pour savoir qui l'appelle, le faux modèle cherche la consigne du canal dans l'instruction système reçue.

Pourquoi le faux modèle conserve-t-il chaque llm_request dans self.vus ?

Prouver le masquage

files/test_multicanal.py
def test_brief_propre_et_masquage():
    reponses = {c: f"Texte {c} : gourde Clairo à 29,90 €." for c in CANAUX}
    modele, etat = lancer((ICI / "brief_produit.md").read_text(encoding="utf-8"), reponses)
    assert len(modele.vus) == 4                         # un appel par canal
    for req in modele.vus:                              # le modèle n'a jamais vu le contact
        vu = str([c.model_dump() for c in req.contents])
        assert "lea.dupont@exemple.fr" not in vu and "06 12 34 56 78" not in vu
        assert "[EMAIL]" in vu and "[TELEPHONE]" in vu
    for c in CANAUX:
        assert etat[f"texte_{c}"] == reponses[c]
        assert etat[f"moderation_{c}"][0]["etape"] == "entrée : masquage"

Prouver le court-circuit et le blocage ciblé

files/test_multicanal.py
def test_brief_piege_bloque_avant_le_modele():
    modele, etat = lancer((ICI / "brief_piege.md").read_text(encoding="utf-8"), {})
    assert modele.vus == []                             # aucun appel : 0 token
    for c in CANAUX:
        assert etat[f"texte_{c}"].startswith(BLOQUE)

def test_sortie_bloquee_sur_un_seul_canal():
    reponses = {c: "Texte sobre." for c in CANAUX}
    reponses["instagram"] = "La gourde garantie à vie ! Écrivez à jean@exemple.fr"
    _, etat = lancer("Brief : gourde Clairo 500, 29,90 €.", reponses)
    assert etat["texte_instagram"].startswith(BLOQUE)
    assert etat["moderation_instagram"][-1]["motif"] == ["garantie absolue", "email"]
    assert all(etat[f"texte_{c}"] == "Texte sobre." for c in CANAUX if c != "instagram")

Le second test injecte le dérapage dans la réponse du modèle : c'est le seul moyen fiable de tester after_model_callback, car un vrai modèle bien instruit dérape rarement (voir la leçon suivante).

Le test test_brief_piege_bloque_avant_le_modele vérifie modele.vus == []. Que prouve cette assertion ?

Exercice : un test pour les numéros de carte collés

Un numéro de carte peut être écrit sans espace (4970101234567890). Écrivez le test, puis vérifiez s'il passe avec le motif actuel.

Voir la solution
files/test_multicanal.py (ajout)
def test_carte_sans_espace():
    masque, trouves = masquer_donnees_personnelles("carte 4970101234567890")
    assert trouves == ["carte"] and masque == "carte [CARTE]"

Le motif (?<!\d)(?:\d{4}[\s-]?){3}\d{4}(?!\d) rend le séparateur facultatif ([\s-]?) : le test passe. Il protège contre une régression si quelqu'un « simplifie » un jour le motif en rendant l'espace obligatoire.

Résultat obtenu

pytest -v sans clé d'API, le 2026-10-15 :

test_donnees_personnelles_masquees[écrire à lea.dupont@exemple.fr-email] PASSED [  6%]
test_donnees_personnelles_masquees[appeler le 06 12 34 56 78-telephone] PASSED [ 13%]
test_donnees_personnelles_masquees[ou le +33 6 12 34 56 78-telephone] PASSED [ 20%]
test_donnees_personnelles_masquees[IBAN FR76 3000 6000 0112 3456 7890 189-iban] PASSED [ 26%]
test_donnees_personnelles_masquees[carte 4970 1012 3456 7890-carte] PASSED [ 33%]
test_pas_de_faux_positif PASSED                      [ 40%]
test_promesses_detectees[Garantie à vie !] PASSED [ 46%]
[...]
test_brief_propre_et_masquage PASSED                 [ 86%]
test_brief_piege_bloque_avant_le_modele PASSED       [ 93%]
test_sortie_bloquee_sur_un_seul_canal PASSED         [100%]
============================== 15 passed in 1.58s ==============================

Premier passage : un échec utile. « La meilleure gourde du marché » n'était pas détecté, car le motif attendait « meilleure du marché » sans nom au milieu. Le motif a été élargi (meilleur(e)… suivi de 30 caractères au plus avant du marché).

Je sais faire

  • Écrire un faux BaseLlm qui enregistre les requêtes et répond selon l'agent appelant
  • Prouver qu'une donnée n'est jamais envoyée au modèle
  • Tester after_model_callback en injectant un dérapage dans la réponse
  • Ajouter des cas négatifs pour éviter les faux positifs
Siguiente