Conception du pipeline¶
Une fois admis qu'il faut dé-identifier (voir Pourquoi dé-identifier ?), reste le comment. La construction ci-dessous se fait pas à pas, à partir de la première brique, détecter les données sensibles, en ajoutant une contrainte à la fois. Chaque composant du pipeline apparaît parce qu'une contrainte précédente l'a rendu nécessaire. À la fin, l'ordre des étapes et les choix techniques ne sont plus arbitraires, ils découlent du problème.
Dé-identification, pas anonymisation
piighost garde le lien entre une valeur et son token pour pouvoir la restaurer.
C'est de la dé-identification réversible. On réserve le mot anonymisation à une
suppression irréversible, par exemple avec RedactPlaceholderFactory.
Pour la vue d'ensemble
Pour la carte des couches et l'API de chaque composant, voir Architecture.
Étape 1, savoir quoi remplacer, le détecteur¶
Dé-identifier, c'est remplacer une valeur sensible par un placeholder, c'est-à-dire le token qui prend sa place dans le texte. Sur un texte libre, on ne sait pas d'avance où sont les PII ni de quel type. La première brique est donc la détection.
Deux approches classiques se complètent.
- La regex reconnaît des motifs, c'est-à-dire des chaînes de caractères qui suivent une structure fixe (IBAN, téléphone, e-mail). Efficace sur ces formats, inutilisable sur du texte non structuré comme un prénom, un nom, une date écrite ou un lieu.
- Le NER (Named Entity Recognition) est un modèle d'IA qui, sur un texte, classe les mots selon une classification décidée à l'avance (nom, prénom, lieu, organisation). Il saisit le contexte là où la regex ne voit qu'un format.
C'est le rôle du détecteur (AnyDetector). Il lit le texte et renvoie une liste de
détections, une par PII trouvée, avec sa position, son type et un score de confiance.
flowchart LR
T["Patrick habite à Paris"] --> D{{"AnyDetector"}}
D --> R1["PERSON (0,7) 0.95"]
D --> R2["LOCATION (17,22) 0.92"]
Le détecteur transforme un texte brut en détections positionnées et typées.
piighost fournit ces approches comme détecteurs interchangeables, Gliner2Detector,
SpacyDetector, TransformersDetector pour le NER, RegexDetector pour les motifs,
LLMDetector quand le contexte métier dépasse les détecteurs étroits, et
ExactMatchDetector pour les tests. On peut les combiner avec CompositeDetector, une
regex plus un NER couvrent plus de cas qu'un seul. C'est pour cela que le détecteur est
un port et non une classe figée, on injecte celui qu'on veut.
La regex ne valide aucun checksum. Un IBAN ou un numéro de carte reconnu par le motif est gardé tel quel, sans contrôle de clé de contrôle. Une valeur abîmée par un OCR reste ainsi une détection plutôt que d'être écartée par un calcul qui échoue sur le bruit. Mieux vaut une détection de trop, arbitrée plus tard, qu'une PII laissée en clair.
Étape 2, dire de quel type il s'agit, le placeholder typé¶
Avec la détection, on connaît le type de chaque PII. Le placeholder le plus simple
serait un token constant, le même pour tout, comme <<REDACT>>. On
l'enrichit avec le type, <<PERSON>> ou <<EMAIL>>.
Pourquoi est-ce utile. Parce que le modèle qui lit le texte dé-identifié a besoin du
type pour raisonner. "Contacte <<PERSON>> à
<<EMAIL>>" reste exploitable, "Contacte <<REDACT>>
à <<REDACT>>" ne l'est plus.
La placeholder factory (AnyPlaceholderFactory) décide de la forme du token. Elle prend
une entité et rend son token. C'est elle qu'on change pour passer de
<<REDACT>> à <<PERSON>>.
Étape 3, distinguer les individus, l'entité et son identité¶
Un texte peut citer deux personnes différentes. Si les deux deviennent
<<PERSON>>, le modèle ne peut plus les distinguer, et on ne peut plus
revenir en arrière sans ambiguïté. Il faut donc une identité par individu.
Patrick devient <<PERSON:1>>, Marie devient
<<PERSON:2>>. Le compteur distingue les individus du même type.
Mais une même personne apparaît souvent plusieurs fois, parfois orthographiée
différemment (Patrick, patrick). Toutes ces occurrences doivent partager le même
token. Une détection isolée ne suffit donc pas. Il faut une notion au-dessus, l'entité,
qui regroupe toutes les détections désignant la même PII.
D'où une nouvelle étape, passer des détections aux entités. C'est le linker
(AnyEntityLinker). ExactEntityLinker groupe les détections par clé canonique
(texte en minuscules, label), une entité par clé.
flowchart LR
D["détections :\nPatrick(0,7)\npatrick(30,37)\nMarie(15,20)"] --> L{{"ExactEntityLinker"}}
L --> E1["Entité PERSON 'patrick'\n+ toutes ses occurrences"]
L --> E2["Entité PERSON 'marie'"]
Le linker regroupe les détections d'une même PII en une entité, qui recevra un token unique.
C'est l'entité, pas la détection, qui reçoit un token. Toutes les occurrences d'une
entité partagent donc le même <<PERSON:1>>.
Étape 4, rattraper les occurrences ratées, l'expander¶
Le linker ne groupe que les détections qu'on lui donne. Or un NER rate des
occurrences. Il trouve Patrick dans la phrase 1, mais rate le Patrick
tout seul de la phrase 3. Si on s'arrête au linker, cette occurrence reste en clair dans
le texte dé-identifié.
Rattraper les occurrences ratées est un travail à part, celui de l'expander
(AnyDetectionExpander). WordBoundaryExpander cherche, pour chaque valeur déjà
détectée, ses autres occurrences dans le texte par recherche aux frontières de mot, et
ajoute une détection pour chacune.
On sépare l'expander du linker à dessein. Le linker regroupe, l'expander cherche. Chacun a une seule responsabilité, et l'expander reste optionnel, un jeu de détections déjà complet n'en a pas besoin.
Étape 5, arbitrer les détections qui se contredisent, le résolveur de spans¶
Dès qu'on combine des détecteurs, ou qu'un détecteur trouve plusieurs candidats sur la
même zone, des détections se chevauchent. Exemple classique, un NER propose LOCATION
sur Paris et un autre PERSON sur la même position, ou deux modèles donnent des
bornes légèrement différentes.
Si on laissait passer ces chevauchements jusqu'au remplacement, on produirait des tokens imbriqués et un texte corrompu. Il faut donc résoudre les conflits de positions avant de regrouper en entités.
C'est le résolveur de spans (AnyOverlapResolver). ConfidenceOverlapResolver groupe
les détections qui se chevauchent, puis garde dans chaque groupe la plus confiante.
L'ordre des étapes est contraint.
flowchart LR
A["détecter"] --> B["résoudre les spans"] --> C["rattraper les occurrences"] --> D["lier en entités"] --> E["résoudre les entités"] --> F["anonymiser"]
Les positions se résolvent avant le linking, les identités après.
On résout les positions tôt, sur des détections encore brutes, puis on rattrape les occurrences ratées, puis on groupe en entités, et on résout les identités en dernier (voir l'étape suivante).
Étape 6, fusionner les entités équivalentes, le résolveur d'entités¶
Après le linking, deux entités peuvent encore désigner la même personne, par exemple
Patrick et Patric (faute de frappe), ou provenir de détecteurs différents qui
partagent une détection. Les réconcilier évite de donner deux tokens à une seule
personne.
C'est le résolveur d'entités (AnyEntityResolver).
MergeEntityResolverfusionne les entités qui partagent une détection (union-find, transitif).FuzzyEntityResolverfusionne par similarité de texte (Jaro-Winkler), pour rattraper les variantes orthographiques.SeparateEntityResolverfait l'inverse, il sépare des entités qui n'auraient pas dû se confondre.
À ce stade, on a une liste d'entités propres, chacune devant recevoir un token unique et stable.
Étape 7, produire le texte, l'anonymiseur¶
L'anonymiseur (AnyAnonymizer) applique enfin le remplacement. Il demande un token à la
factory pour chaque entité, puis remplace chaque détection par son token.
Conséquence de l'étape 5, le remplacement par positions se fait de droite à gauche, pour que remplacer une zone ne décale pas les positions des zones encore à traiter. Cela suppose des spans non chevauchants, ce que l'étape 5 garantit.
Étape 8, revenir en arrière, la restauration¶
Dé-identifier ne sert que si l'on peut restaurer les vraies valeurs pour l'utilisateur.
Pour cela il faut savoir que <<PERSON:1>> valait Patrick.
La dé-identification d'un texte rend justement ce mapping, une entité par token émis.
La restauration remplace, dans un texte, chaque token connu par la valeur de son entité.
Elle ne se limite pas au texte que le pipeline a produit. Le modèle génère souvent une
réponse nouvelle contenant un token, par exemple "Bien sûr,
<<PERSON:1>> !". Cette phrase n'a jamais été produite par le pipeline,
mais comme on connaît le couple token vers valeur, on remplace le token dans n'importe
quel texte.
flowchart LR
IN["texte porteur de tokens"] --> D["deanonymize :\nremplace chaque token connu\npar la valeur de son entité"] --> OUT["texte restauré"]
La restauration remplace les tokens connus par leur valeur, dans n'importe quel texte.
La restauration n'est sans ambiguïté que si les tokens préservent l'identité. Deux
entités qui partageraient un token, comme avec <<PERSON>>, se
confondraient sur une seule valeur. C'est pourquoi le mode réversible impose une factory
qui identifie chaque entité, <<PERSON:1>> et non
<<PERSON>>.
Étape 9, la conversation, mémoire et cohérence des compteurs¶
Tout ce qui précède traite un texte, isolément. Un agent, lui, enchaîne des messages, et
le même Patrick doit garder le même <<PERSON:1>> du premier
au dernier.
Pourquoi rejouer le pipeline par message ne suffit pas¶
La tentation est de rappeler simplement anonymize sur chaque message. Mais le pipeline
mono-texte n'a aucune mémoire. Il repart de zéro à chaque appel, et le compteur
recommence à 1. Sur deux messages, on obtiendrait ceci.
Message 1 : "Patrick appelle Marie" → <<PERSON:1>> appelle <<PERSON:2>>
Message 2 : "Marie rappelle Patrick" → <<PERSON:1>> rappelle <<PERSON:2>>
Marie est <<PERSON:2>> au message 1 puis
<<PERSON:1>> au message 2. Les identités se croisent, et plus rien
n'est réversible de façon cohérente sur le fil. Une conversation porte donc un état
partagé d'un message au suivant.
La mémoire de conversation¶
ThreadAnonymizationPipeline ajoute cet état, une mémoire (AnyConversationMemory) qui
persiste, par thread, les détections de chaque message. Les tokens sont ensuite
attribués sur l'union des détections de tous les messages du thread, pas sur un message
seul. Une personne revue dans un message ultérieur retrouve donc son entité, et son
token, au lieu d'en créer un nouveau.
Message 1 : "Patrick appelle Marie" → <<PERSON:1>> appelle <<PERSON:2>>
mémoire : patrick→1, marie→2
Message 2 : "Marie rappelle Patrick" → <<PERSON:2>> rappelle <<PERSON:1>>
(réutilise la mémoire, aucun nouveau compteur)
Les règles qui en découlent¶
- Ordre figé au premier vu. Le compteur d'une entité est attribué à sa première apparition dans la conversation et ne bouge plus. Sans cette règle, une nouvelle entité tôt dans son message volerait le compteur d'une plus ancienne.
- Isolation par
thread_id. Lethread_idest obligatoire, il n'y a pas de thread partagé par défaut, pour que deux appelants ne tombent pas dans le même fil et ne fuitent pas leurs PII.forget_threadpeut tout effacer d'un fil, pour le droit à l'oubli.
Le rendu reste par message¶
Les détections d'une entité viennent de messages différents, dont les positions n'ont pas de référentiel commun. On ne peut donc pas remplacer par positions à l'échelle du thread. Les tokens sont attribués sur tout le thread, mais le rendu ne remplace que les spans du message courant, ceux dont les offsets valent dans ce message.
Étape 10, la provenance des valeurs¶
Toute valeur d'un message n'est pas de la PII à protéger. Si le modèle mentionne une personnalité publique de sa connaissance du monde, la tokeniser la lui cacherait au tour suivant, sans rien protéger de l'utilisateur.
La mémoire enregistre donc le rôle de la première occurrence de chaque valeur,
MessageRole.USER ou MessageRole.ASSISTANT. Une valeur dont la première occurrence
vient d'un message du modèle est laissée en clair, car elle n'est pas une PII
utilisateur. Le middleware règle ce comportement par EntityCreateByAssistantStrategy,
préserver, dé-identifier quand même, ou ignorer les messages du modèle.
Étape 11, pourquoi tout est asynchrone¶
Le pipeline est asynchrone de bout en bout, pour deux raisons concrètes.
- La mémoire persistante est un service externe. Un backend Redis lit et écrit sur le réseau. Le faire en asynchrone évite de bloquer pendant l'attente.
- Un serveur sert plusieurs requêtes à la fois. Une API qui héberge le pipeline traite des conversations concurrentes sur une seule boucle d'événements.
Mais l'inférence d'un modèle NER local est, elle, synchrone et lourde, des centaines de millisecondes de calcul CPU ou GPU. Appelée directement dans une coroutine, elle gèle toute la boucle, aucune autre requête ne progresse pendant ce temps. La détection modèle est donc à déporter dans un thread. Un détecteur qui appelle une API distante, lui, reste en asynchrone natif, c'est de l'I/O réseau et non du calcul.
En résumé, asynchrone pour l'I/O et l'orchestration, déport en thread pour le calcul bloquant.
Étape 12, chiffrer le mapping inverse¶
Sur un seul worker, la mémoire tient dans un dictionnaire du processus
(InMemoryConversationMemory). Un déploiement multi-worker en a besoin d'une partagée,
RedisConversationMemory, pour qu'un worker voie les threads d'un autre.
Mais le mapping inverse est de la PII en clair. Une fuite du store la révélerait. Deux
composants crypto protègent le backend Redis. Un hasher (AnyHasher) transforme chaque
message en clé déterministe sans révéler le texte. Un cipher (AnyCipher) chiffre les
détections au repos, de sorte qu'une fuite de la base ne rende ni le message ni la PII.
Le thread_id reste en clair comme préfixe de clé, pour qu'un thread puisse être
énuméré et oublié.
Étape 13, le garde-fou, défense en profondeur¶
Même avec tout ce qui précède, une PII peut passer entre les mailles, par exemple un nom
que le NER a raté. Le garde-fou (AnyGuardRail) re-analyse le texte dé-identifié et lève
PIIRemainingError s'il y trouve encore une PII en clair.
Le garde-fou n'examine que la sortie dé-identifiée. Les placeholders qu'elle porte sont
clairement synthétiques, donc un contrôle prévu pour de la vraie PII ne les prend pas
pour telle. Le garde-fou est optionnel mais c'est la dernière barrière avant la sortie.
DetectorGuardRail rejoue un détecteur, LLMGuardRail et ModerationGuardRail
interrogent un modèle externe.
Étape 14, raccorder au monde agent, le middleware¶
Reste à brancher tout cela dans une boucle d'agent LangChain, de façon transparente.
C'est le PIIAnonymizationMiddleware, qui intervient en trois points.
- Avant le modèle (
abefore_model), il dé-identifie les messages avant que le LLM ne les voie. - Après le modèle (
aafter_model), il restaure la sortie pour l'affichage utilisateur. - Autour des appels outils (
awrap_tool_call), selon la stratégie choisie (ToolCallStrategy), il restaure les arguments pour que l'outil reçoive de vraies données, puis ré-identifie sa réponse.
Le middleware ne contient aucune logique de dé-identification, il délègue tout au
pipeline conversationnel. C'est un simple adaptateur entre le monde LangChain et le
coeur. Il exige au type une factory qui préserve l'identité, et il reconnaît les tokens
que le modèle invente (InventedPlaceholderStrategy), car après restauration tout token
qui suit encore la grammaire des placeholders n'a pas été émis par le pipeline.
Récapitulatif, chaque composant répond à une contrainte¶
| Contrainte rencontrée | Composant né de la contrainte |
|---|---|
| On ne sait pas où sont les PII | Détecteur (AnyDetector) |
| Le modèle a besoin du type | Placeholder typé (AnyPlaceholderFactory) |
| Distinguer deux individus du même type | Identité par entité et linker (AnyEntityLinker) |
| Occurrences ratées par le détecteur | Expander (AnyDetectionExpander) |
| Détections qui se chevauchent | Résolveur de spans (AnyOverlapResolver) |
| Entités équivalentes à fusionner | Résolveur d'entités (AnyEntityResolver) |
| Produire le texte sans corruption | Anonymiseur, remplacement droite à gauche |
| Revenir en arrière sur un texte quelconque | deanonymize, remplacement token par token |
| Cohérence sur toute la conversation | Mémoire par thread_id, ordre first-seen |
| Valeur venant du modèle, pas de l'utilisateur | Provenance en mémoire (MessageRole) |
| I/O sans bloquer et calcul lourd | Asynchrone et déport en thread de l'inférence |
| Mapping inverse persistant à protéger | Crypto, hasher et cipher du backend Redis |
| PII résiduelle | Garde-fou (AnyGuardRail) |
| Intégration agent transparente | Middleware LangChain |
Voir aussi¶
- Architecture, la carte des couches et l'API de chaque composant
- Placeholder factories, les familles de tokens et ce qu'elles préservent
- Stratégies d'appel outil, le détail de
awrap_tool_call