Architecture¶
piighost suit une architecture hexagonale, aussi appelée ports et adaptateurs.
Le coeur ne connaît que des contrats abstraits, les ports. Chaque implémentation
concrète, un détecteur GLiNER2, un backend Redis, un middleware LangChain, est un
adaptateur qui satisfait un port sans que le coeur ne le connaisse. Le pipeline
de dé-identification s'assemble en injectant les adaptateurs voulus derrière les ports
qu'il attend.
Dé-identification, pas anonymisation
Par défaut piighost garde le lien entre une valeur et son token, pour pouvoir
restaurer la valeur. C'est de la dé-identification réversible, au sens du RGPD une
pseudonymisation, et non de l'anonymisation. Le terme anonymisation reste réservé à
une suppression irréversible, par exemple avec RedactPlaceholderFactory.
Les trois anneaux¶
Le code se lit en trois anneaux, du plus abstrait au plus concret. Le sens des dépendances est fixé une fois pour toutes, un anneau extérieur importe un anneau intérieur, jamais l'inverse.
flowchart TB
classDef core fill:#A5D6A7,stroke:#2E7D32,color:#000
classDef app fill:#90CAF9,stroke:#1565C0,color:#000
classDef adapter fill:#E1BEE7,stroke:#6A1B9A,color:#000
classDef config fill:#FFF9C4,stroke:#F9A825,color:#000
subgraph CONFIG ["Config, point de composition"]
direction LR
CFG["load_pipeline / load_thread_pipeline"]:::config
end
subgraph ADAPTERS ["Adaptateurs, implémentations concrètes"]
direction LR
A_DET["Gliner2Detector, RegexDetector…"]:::adapter
A_MEM["InMemoryConversationMemory, Redis…"]:::adapter
A_MW["PIIAnonymizationMiddleware"]:::adapter
end
subgraph APP ["Application, orchestration"]
direction LR
P_BASE["BaseAnonymizationPipeline"]:::app
P_ONE["AnonymizationPipeline"]:::app
P_THREAD["ThreadAnonymizationPipeline"]:::app
end
subgraph CORE ["Coeur, ports et modèles"]
direction LR
PORTS["AnyDetector, AnyEntityLinker,\nAnyAnonymizer, AnyConversationMemory…"]:::core
MODELS["Detection, Entity, Span"]:::core
end
CONFIG --> ADAPTERS
CONFIG --> APP
ADAPTERS --> CORE
APP --> CORE
Trois anneaux et le point de composition. Les dépendances pointent toujours vers le coeur.
- Coeur. Les modèles de données (
Detection,Entity,Span, des dataclasses gelées) et les ports. Aucune dépendance externe, pas de pydantic, pas d'I/O. - Application. L'orchestration du pipeline, qui ne dépend que des ports du coeur.
C'est là que vivent
anonymize,deanonymizeetforget_thread. - Adaptateurs. Les implémentations concrètes des ports, détecteurs, résolveurs, factories, gardes-fous, backends de mémoire, observation, client HTTP, middleware. Chaque adaptateur importe le coeur, jamais le contraire.
- Config. Le point de composition. C'est le seul endroit autorisé à connaître à la fois les ports et les adaptateurs concrets, pour les assembler.
Ports et templates¶
Un port est un Protocol Python marqué runtime_checkable, dans le base.py de
chaque composant. Le typage y est structurel, un objet satisfait le port dès qu'il
en a les méthodes, sans en hériter. Le pipeline dépend du port, jamais d'une classe
concrète.
@runtime_checkable
class AnyDetector(Protocol):
async def detect(self, text: str) -> list[Detection]: ...
Quand plusieurs adaptateurs d'un même port partagent un squelette, ce squelette vit
dans une classe Base*, une classe abstraite qui applique le patron de méthode
(Template Method). Le squelette est écrit une fois dans la classe de base, et chaque
sous-classe ne fournit que le pas qui varie.
class BaseEntityLinker(ABC):
def link(self, detections: list[Detection]) -> list[Entity]:
# squelette commun : grouper par clé
...
@abstractmethod
def _key(self, detection: Detection) -> Hashable:
# seul pas variable, défini par la sous-classe
...
Deux ports n'ont pas de template. Les gardes-fous et les backends de mémoire diffèrent par tout leur mécanisme, pas par un seul pas, donc rien de commun n'est à factoriser. C'est l'exception assumée à la règle du template systématique.
Les étapes du pipeline¶
BaseAnonymizationPipeline enchaîne les étapes de la détection au texte
dé-identifié. Seul le détecteur est un argument obligatoire du constructeur. Le
linking, la dé-identification et la résolution des chevauchements tournent toujours et
retombent sur des composants intégrés par défaut quand on les omet, un
ExactEntityLinker, un Anonymizer doté d'une LabelCounterPlaceholderFactory et
un ConfidenceOverlapResolver. Les étapes override, expand, entity-resolve et guard
se comportent en passe-plat quand elles ne sont pas fournies.
flowchart LR
classDef req fill:#90CAF9,stroke:#1565C0,color:#000
classDef opt fill:#FFF9C4,stroke:#F9A825,color:#000
classDef data fill:#A5D6A7,stroke:#2E7D32,color:#000
IN(["`**Texte source**
_'Patrick habite à Paris.
Patrick aime Paris.'_`"]):::data
DET["`**Détecteur**
_AnyDetector_`"]:::req
OVR["`override
_AnyDetectionOverride_`"]:::opt
OVL["`résolveur de spans
_AnyOverlapResolver_`"]:::opt
EXP["`expander
_AnyDetectionExpander_`"]:::opt
LINK["`**Linker**
_AnyEntityLinker_`"]:::req
ENT["`résolveur d'entités
_AnyEntityResolver_`"]:::opt
ANON["`**Anonymiseur**
_AnyAnonymizer + factory_`"]:::req
GUARD["`garde-fou
_AnyGuardRail_`"]:::opt
OUT(["`**Sortie**
_'<<PERSON:1>> habite à <<LOCATION:1>>.
<<PERSON:1>> aime <<LOCATION:1>>.'_`"]):::data
IN --> DET --> OVR --> OVL --> EXP --> LINK --> ENT --> ANON --> GUARD --> OUT
Le pipeline, étapes obligatoires en bleu, étapes optionnelles en jaune.
Le détail de pourquoi chaque étape existe et dans quel ordre est traité dans Conception du pipeline. Voici le rôle et l'adaptateur par défaut de chacune.
| Étape | Port | Adaptateur fourni | Rôle |
|---|---|---|---|
| Détecteur | AnyDetector |
Gliner2Detector, RegexDetector, LLMDetector, ExactMatchDetector, CompositeDetector, ChunkedDetector |
Trouve les PII, renvoie des Detection positionnées et typées. |
| Résolveur de spans | AnyOverlapResolver |
ConfidenceOverlapResolver |
Arbitre les détections qui se chevauchent, garde la plus confiante. |
| Expander | AnyDetectionExpander |
WordBoundaryExpander |
Rattrape les occurrences ratées d'une valeur déjà détectée. |
| Linker | AnyEntityLinker |
ExactEntityLinker |
Regroupe les détections d'une même valeur en une Entity. |
| Résolveur d'entités | AnyEntityResolver |
MergeEntityResolver, FuzzyEntityResolver, SeparateEntityResolver |
Réconcilie les entités qui partagent une détection. |
| Anonymiseur | AnyAnonymizer (+ AnyPlaceholderFactory) |
Anonymizer + LabelCounterPlaceholderFactory |
Remplace chaque entité par son token. |
| Garde-fou | AnyGuardRail |
DetectorGuardRail, LLMGuardRail, ModerationGuardRail |
Re-vérifie la sortie, lève PIIRemainingError sur PII résiduelle. |
L'override (AnyDetectionOverride, adaptateur DetectionOverride) est un composant
serveur optionnel. Il applique une liste blanche et une liste noire à chaque jeu de
détections, juste après la détection, avant la résolution des spans.
Le composant placeholder et ses tags de préservation¶
L'anonymiseur délègue la forme du token à une placeholder factory
(AnyPlaceholderFactory). Ce qui change entre deux factories, c'est ce que le token
préserve de la valeur d'origine.
classDiagram
class PlaceholderPreservation {
racine
}
class PreservesNothing {
<<REDACT>>
}
class PreservesLabel {
<<PERSON>>
}
class PreservesShape {
j***@mail.com
}
class PreservesLabeledIdentity {
<<PERSON:1>>
}
PlaceholderPreservation <|-- PreservesNothing
PlaceholderPreservation <|-- PreservesLabel
PlaceholderPreservation <|-- PreservesIdentity
PreservesLabel <|-- PreservesShape
PreservesLabel <|-- PreservesLabeledIdentity
PreservesIdentity <|-- PreservesLabeledIdentity
Les tags de préservation, du token qui ne garde rien à celui qui identifie chaque entité.
Chaque tag est une sous-classe de str, donc un token est une vraie chaîne qui porte
son niveau de préservation dans son propre type. Ces tags sont des types fantômes, ils
n'existent que pour le vérificateur de types. Le middleware exige un tag qui préserve
l'identité (PreservesRecognizableIdentity), donc brancher une factory <<PERSON>>
sur le middleware est une erreur détectée à la vérification de types, pas une surprise
à l'exécution.
Les factories fournies vont du moins au plus informatif. RedactPlaceholderFactory
émet <<REDACT>>, LabelPlaceholderFactory émet
<<PERSON>>, LabelCounterPlaceholderFactory émet
<<PERSON:1>>, LabelHashPlaceholderFactory émet
<<PERSON:a1b2c3d4>>, MaskPlaceholderFactory émet
j***@mail.com. Le détail est dans
Placeholder factories.
Le pipeline mono-texte¶
AnonymizationPipeline traite un texte isolé. Il détecte, applique les étapes
optionnelles présentes, groupe en entités, dé-identifie, puis passe la sortie au
garde-fou. Sa méthode deanonymize reçoit le mapping token vers entité produit par
anonymize et restaure les valeurs.
from piighost.pipeline import AnonymizationPipeline
from piighost.components.detector import ExactMatchDetector
from piighost.components.linker import ExactEntityLinker
from piighost.components.anonymizer import Anonymizer
from piighost.components.placeholder import LabelCounterPlaceholderFactory
detector = ExactMatchDetector({"Patrick": "PERSON"})
linker = ExactEntityLinker()
factory = LabelCounterPlaceholderFactory()
anonymizer = Anonymizer(factory)
pipeline = AnonymizationPipeline(
detector=detector,
linker=linker,
anonymizer=anonymizer,
)
result = await pipeline.anonymize("Patrick habite à Paris.")
# result.text -> "<<PERSON:1>> habite à Paris."
# result.tokens -> {Entity("Patrick"): "<<PERSON:1>>"}
restored = pipeline.deanonymize(result.text, result.tokens)
# restored -> "Patrick habite à Paris."
Le constructeur n'exige que le détecteur. Le linker et l'anonymiseur retombent par
défaut sur ExactEntityLinker et un Anonymizer doté d'une
LabelCounterPlaceholderFactory. Les autres étapes arrivent en argument nommé.
AnonymizationPipeline(
detector,
linker,
anonymizer,
overlap_resolver=None, # AnyOverlapResolver, ConfidenceOverlapResolver par défaut
expander=None, # AnyDetectionExpander
entity_resolver=None, # AnyEntityResolver
guard=None, # AnyGuardRail
override=None, # AnyDetectionOverride
)
Omettre overlap_resolver, ou passer None, construit un ConfidenceOverlapResolver,
car l'étape de rendu a besoin de spans disjoints. Les étapes expand, entity-resolve,
guard et override restent désactivées quand elles valent None.
Le pipeline conversationnel¶
ThreadAnonymizationPipeline partage le même socle mais ajoute une mémoire de
conversation (AnyConversationMemory), passée en argument obligatoire. Un agent
enchaîne des messages, et le même Patrick doit garder le même
<<PERSON:1>> du premier au dernier.
Les tokens sont attribués sur l'union des détections de tous les messages du thread, pas sur un message seul. Une valeur revue plus tard retrouve donc son token au lieu d'en créer un nouveau. Le rendu, lui, reste par message, seuls les spans du message courant sont remplacés, car les détections de messages différents ne partagent pas le même espace d'offsets.
result = await thread_pipeline.anonymize(text, thread_id="t-42")
restored = await thread_pipeline.deanonymize(reply, thread_id="t-42")
dropped = await thread_pipeline.forget_thread("t-42")
- Le
thread_idest obligatoire, il n'y a pas de thread partagé par défaut, donc deux appelants ne peuvent pas tomber dans le même thread et fuiter leurs PII. deanonymizereconstruit les tokens du thread depuis la mémoire, donc n'importe quel texte porteur de ces tokens est restauré, y compris une réponse du modèle que le pipeline n'a jamais dé-identifiée.forget_threadefface toute la mémoire d'un thread et rend le compte de ce qui a été supprimé, pour le droit à l'oubli.
La provenance des valeurs¶
Une valeur dont la première occurrence dans le thread vient d'un message du modèle
n'est pas de la PII utilisateur. La tokeniser priverait le modèle de sa connaissance du
monde. La mémoire enregistre donc le rôle de la première occurrence de chaque
valeur (MessageRole.USER ou MessageRole.ASSISTANT), et le pipeline laisse en clair
les valeurs introduites par l'assistant.
La mémoire de conversation et le chiffrement¶
La mémoire est un repository, un port AnyConversationMemory avec deux
adaptateurs.
InMemoryConversationMemorygarde tout dans un dictionnaire du processus. Simple, suffisant pour un seul worker.RedisConversationMemorypersiste dans Redis, pour un déploiement multi-worker où chaque worker doit voir les threads des autres.
Le backend Redis stocke de la PII en clair par nature, le mapping inverse. Deux
composants crypto le protègent. Un AnyHasher (Sha256Hasher, Argon2Hasher)
transforme chaque message en clé déterministe sans révéler le texte. Un AnyCipher
(AesGcmCipher) chiffre les détections au repos, de sorte qu'une fuite de la base ne
révèle 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é.
Le middleware LangChain¶
PIIAnonymizationMiddleware branche le pipeline conversationnel dans une boucle
d'agent LangChain. Il ne contient aucune logique de dé-identification, il délègue tout
au pipeline. C'est un adaptateur entre le monde LangChain et le coeur.
sequenceDiagram
participant U as Utilisateur
participant M as Middleware
participant L as LLM
participant T as Outil
U->>M: "Envoie un email à Patrick à Paris"
M->>M: abefore_model, dé-identifie
M->>L: "Envoie un email à <<PERSON:1>> à <<LOCATION:1>>"
L->>M: tool_call(send_email, to=<<PERSON:1>>)
M->>M: awrap_tool_call, restaure les arguments
M->>T: send_email(to="Patrick")
T->>M: "Email envoyé à Patrick"
M->>M: awrap_tool_call, ré-identifie le résultat
M->>L: "Email envoyé à <<PERSON:1>>"
L->>M: "C'est fait, email envoyé à <<PERSON:1>>."
M->>M: aafter_model, restaure pour l'utilisateur
M->>U: "C'est fait, email envoyé à Patrick."
Le middleware intercepte la boucle d'agent en trois points.
abefore_modeldé-identifie les messages avant que le LLM ne les voie.aafter_modelrestaure la sortie du modèle pour l'affichage utilisateur.awrap_tool_calltraite l'appel d'outil selon la stratégie choisie (ToolCallStrategy), en restaurant les arguments pour que l'outil reçoive de vraies données, puis en ré-identifiant sa réponse.
Le middleware exige au type une factory qui préserve l'identité. Il reconnaît aussi 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. Le détail des stratégies d'outil est dans
Stratégies d'appel outil.
L'observation¶
piighost émet une trace par étape du pipeline à travers un port
(AnyObservationTracer), une couture au-dessus d'OpenTelemetry. Sans backend configuré,
une implémentation no-op ne trace rien et ne coûte rien, donc le pipeline peut toujours
émettre sans vérifier si le traçage est actif. Un observation_redactor optionnel
remplace les valeurs des traces par des tokens, pour un backend qui n'a pas le droit de
voir la PII.
La config, point de composition¶
Un fichier TOML ou JSON décrit tout le pipeline. Le sous-système config le lit avec
pydantic-settings et le convertit en modèles de config, des unions discriminées où
chaque type de composant porte une méthode build(). Assembler le pipeline revient à
appeler build() sur chaque modèle.
from piighost.config import load_pipeline, load_thread_pipeline
pipeline = load_pipeline("piighost.toml")
thread_pipeline = load_thread_pipeline("piighost.toml")
Le couplage est à sens unique, la config dépend du coeur et des adaptateurs, le coeur
n'importe jamais la config. Ajouter un composant, c'est écrire un adaptateur, un modèle
de config avec build(), et rien d'autre. Le pipeline ne change pas.
Modèles de données¶
Tous les modèles du coeur sont des dataclasses gelées, immuables donc partageables entre coroutines sans risque.
| Modèle | Champs clés |
|---|---|
Detection |
text, label, span: Span, confidence |
Entity |
detections: tuple[Detection, ...], label et text en propriété |
Span |
start, end, overlaps(), extract() |
Voir aussi¶
- Conception du pipeline, pourquoi chaque étape existe et dans quel ordre
- Placeholder factories, les familles de tokens et ce qu'elles préservent
- Stratégies d'appel outil, le détail de
awrap_tool_call - Étendre PIIGhost, brancher son propre adaptateur derrière un port
- Référence des modèles de données, les champs, méthodes et
validations de
Detection,Entity,SpanetChunk