Anonymiser et bloquer les fuites
1. Liste blanche plutôt que liste noire
Deux façons d'anonymiser :
| Approche | Principe | Risque |
|---|---|---|
| Liste noire | on retire ce qu'on sait sensible (nom, âge…) | tout ce qu'on a oublié passe |
| Liste blanche | on ne garde que ce qui est utile au poste | on peut retirer un peu trop (on l'ajoute alors exprès) |
Le lab garde quatre sections, puis nettoie leur texte :
SECTIONS_UTILES = ["experience", "competences", "formation", "langues"] # ni titre, ni loisirs, ni identité
FORME_UNIQUE = {"développeuse": "développeur", "ingénieure": "ingénieur", "diplômée": "diplômé",
"certifiée": "certifié", "référente": "référent"}
def anonymiser(cv: dict) -> str:
ident = cv["identite"]
texte = "\n".join(f"{s.upper()} : {cv['sections'][s]}" for s in SECTIONS_UTILES)
for mot in ident["nom"].split(): # prénom et nom, où qu'ils soient
texte = re.sub(rf"\b{re.escape(mot)}\b", "[NOM]", texte, flags=re.I)
texte = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[EMAIL]", texte)
texte = re.sub(r"(?:\+33|0)\d(?:[ .]?\d{2}){4}", "[TEL]", texte)
texte = re.sub(r"\b(Mme|Mlle|M\.|Madame|Monsieur)\s", "", texte)
for f, m in FORME_UNIQUE.items():
texte = re.sub(rf"\b{f}(s?)\b", rf"{m}\1", texte)
texte = re.sub(rf"\b{f.capitalize()}(s?)\b", rf"{m.capitalize()}\1", texte)
return textePourquoi la section « titre » n'est-elle pas transmise au modèle ?
Le titre répète le nom et l'intitulé du poste occupé, déjà décrit dans l'expérience. Le retirer supprime un proxy sans perte d'information.
2. Les termes qui ne doivent jamais passer
Pour chaque CV, on calcule la liste des valeurs sensibles de ce CV précis :
def termes_sensibles(cv: dict) -> list[str]:
"""Ce qui ne doit JAMAIS atteindre le modèle."""
i = cv["identite"]
return [*i["nom"].split(), i["email"], i["telephone"], i["date_naissance"][:4], i["nationalite"],
i["adresse"].split(",")[-1].strip(), i["situation_familiale"]]Pour C1, cela donne par exemple : Camille, Martin, l'e-mail, le téléphone, 1991, française, 44000 Nantes, mariée, 2 enfants.
3. Le garde-fou : before_model_callback
Un callback ADK est une fonction que le framework appelle à un moment précis. before_model_callback est appelé juste avant chaque appel au modèle, avec la requête complète (LlmRequest : instruction système + messages). S'il renvoie None, l'appel a lieu ; s'il renvoie une réponse, l'appel est remplacé ; s'il lève une exception, l'exécution s'arrête.
def garde_anonymat(callback_context: CallbackContext, llm_request: LlmRequest) -> LlmResponse | None:
"""before_model_callback : exécuté avant CHAQUE appel au modèle. Lever une exception arrête tout."""
texte = str(llm_request.config.system_instruction or "") + " ".join(
p.text or "" for c in llm_request.contents for p in c.parts or [])
fuites = [t for t in callback_context.state.get("termes_sensibles", []) if t.lower() in texte.lower()]
if fuites:
raise PermissionError(f"appel bloqué, attributs sensibles présents : {fuites}")
return None # None = l'appel au modèle peut avoir lieuLes termes sensibles sont placés dans l'état de la session à sa création, et lus par le callback via callback_context.state :
session = await runner.session_service.create_session(
app_name="cv", user_id="recruteur", state={"termes_sensibles": sensibles})sequenceDiagram
participant P as evaluer_texte
participant ADK as ADK
participant CB as garde_anonymat
participant G as Gemini
P->>ADK: run_async(CV anonymisé)
ADK->>CB: LlmRequest (instruction + CV)
alt terme sensible trouvé
CB-->>ADK: PermissionError
ADK-->>P: exécution arrêtée, aucun appel au modèle
else aucun terme
CB-->>ADK: None
ADK->>G: appel au modèle
end
On envoie par erreur le CV brut (non anonymisé) de C1 à evaluer_texte avec ses termes sensibles. Que se passe-t-il ?
C'est exactement ce que vérifie test_garde_bloque_l_appel_si_fuite : l'exception est levée et le compteur d'appels du faux modèle reste à 0. ADK n'anonymise rien tout seul.
Exercice : bloquer aussi les e-mails inconnus
termes_sensibles ne connaît que l'e-mail déclaré dans l'identité. Ajoutez au callback une vérification générique : bloquer toute requête qui contient une adresse e-mail, quelle qu'elle soit.
Voir la solution
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
if EMAIL.search(texte):
raise PermissionError("appel bloqué : adresse e-mail présente dans la requête")Ajoutez un test : un CV dont l'expérience contient « contact : x@example.org » doit être bloqué si l'on saute l'anonymisation, et passer après anonymisation (l'adresse y devient [EMAIL]).
Résultat obtenu
Tests sans clé du 2026-10-15 : pour les 6 CV, aucun terme sensible ne subsiste après anonymisation, et le callback bloque bien un CV brut avant tout appel au modèle.
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C1] PASSED
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C2] PASSED
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C3] PASSED
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C4] PASSED
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C5] PASSED
test_agent.py::test_aucun_attribut_sensible_apres_anonymisation[C6] PASSED
test_agent.py::test_garde_bloque_l_appel_si_fuite PASSEDJe sais faire
- J'anonymise par liste blanche de sections, puis je nettoie le texte restant
- Je calcule les termes sensibles propres à chaque document
- J'écris un
before_model_callbackqui inspecte la requête et bloque l'appel - Je passe des données au callback via l'état de session