Aller au contenu

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.


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, et un anneau intérieur n'importe jamais un anneau extérieur.

SchémaSchéma

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, deanonymize et forget_thread.
  • Adaptateurs. Les implémentations concrètes des ports, c'est-à-dire les 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
        ...

Cinq ports n'ont pas de template commun à tous leurs adaptateurs, ceux du détecteur, de l'override, des gardes-fous, des backends de mémoire et du chiffrement. Leurs adaptateurs diffèrent par tout leur mécanisme, pas par un seul pas, donc ils n'ont rien de commun à factoriser. C'est l'exception assumée à la règle du template systématique.

Le détecteur est une exception partielle. Les détecteurs à modèle (Gliner2Detector, SpacyDetector, TransformersDetector, PresidioDetector, BridgeDetector, LLMDetector) partagent le template BaseNERDetector. Il relit le texte de chaque détection dans la source, applique le seuil de confiance et traduit les labels. RegexDetector, ExactMatchDetector, CompositeDetector et ChunkedDetector implémentent le port directement.


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. Quand on les omet, ils retombent sur des composants intégrés par défaut. Ces composants sont 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.

SchémaSchéma

Le pipeline. Les étapes toujours exécutées sont en gras, les étapes optionnelles ont un cadre en pointillé.

La page Conception du pipeline explique pourquoi chaque étape existe et pourquoi elles s'enchaînent dans cet ordre. Voici le rôle et l'adaptateur par défaut de chacune.

PortAdaptateurs fournisRôle
AnyDetectorGliner2Detector, Gliner2PiiDetector, SpacyDetector, TransformersDetector, PresidioDetector, BridgeDetector, LLMDetector, RegexDetector, ExactMatchDetector, CompositeDetector, ChunkedDetectorTrouve les données confidentielles (données personnelles, secrets), renvoie des Detection positionnées et typées.
AnyOverlapResolverConfidenceOverlapResolver, MergeOverlapResolverArbitre les détections qui se chevauchent, garde la plus confiante ou leur union.
AnyDetectionExpanderWordBoundaryExpanderRattrape les occurrences ratées d'une valeur déjà détectée.
AnyEntityLinkerExactEntityLinkerRegroupe les détections d'une même valeur en une Entity.
AnyEntityResolverMergeEntityResolver, FuzzyEntityResolver, SeparateEntityResolverRéconcilie les entités qui partagent une détection.
AnyAnonymizer et AnyPlaceholderFactoryAnonymizer et LabelCounterPlaceholderFactoryRemplace chaque entité par son jeton.
AnyGuardRailDetectorGuardRail, Gliner2GuardRail, LLMGuardRail, ModerationGuardRailRe-vérifie la sortie, lève PIIRemainingError sur donnée confidentielle résiduelle.

L'override (AnyDetectionOverride, adaptateur DetectionOverride) est un composant serveur optionnel. Il applique une liste à masquer et une liste à laisser en clair à 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 jeton à une placeholder factory (AnyPlaceholderFactory). Ce qui change entre deux factories, c'est ce que le jeton préserve de la valeur d'origine.

SchémaSchéma

Les tags de préservation, du jeton qui ne garde rien à celui qui identifie chaque entité. Chaque flèche va d'un tag vers son parent et se lit "est un".

Chaque tag est une sous-classe de str. Un jeton est donc une vraie chaîne qui porte son niveau de préservation dans son propre type. Ces tags sont des types fantômes, c'est-à-dire qu'ils n'existent que pour le vérificateur de types. Le middleware exige un tag qui préserve l'identité (PreservesRecognizableIdentity). Brancher une factory <<PERSON>> sur le middleware est donc 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 garde le premier caractère et masque le reste, si bien que Jonathan devient J*******. Le détail est dans Fabriques de placeholders.


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 jeton vers entité produit par anonymize et restaure les valeurs.

from piighost.components.anonymizer import Anonymizer
from piighost.components.detector import ExactMatchDetector
from piighost.components.linker import ExactEntityLinker
from piighost.components.placeholder import LabelCounterPlaceholderFactory
from piighost.pipeline import AnonymizationPipeline

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 par l'argument nommé memory. Sans cet argument, le pipeline construit une InMemoryConversationMemory. Un agent enchaîne des messages, et le même Patrick doit garder le même <<PERSON:1>> du premier au dernier.

Les jetons sont attribués sur l'union des détections de tous les messages de la conversation, pas sur un message seul. Une valeur revue plus tard retrouve donc son jeton au lieu d'en créer un nouveau. Le rendu, lui, reste par message. Seuls les spans du message courant sont remplacés, parce que 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_id est obligatoire. Il n'y a pas de conversation partagée par défaut. Deux appelants ne peuvent donc pas tomber dans la même conversation et fuiter leurs données confidentielles.
  • deanonymize reconstruit les jetons de la conversation depuis la mémoire. Il restaure donc n'importe quel texte porteur de ces jetons, y compris une réponse du modèle que le pipeline n'a jamais dé-identifiée.
  • forget_thread efface toute la mémoire d'une conversation et indique ce qui a été supprimé, pour le droit à l'oubli.

La provenance des valeurs

Une valeur dont la première occurrence dans la conversation vient d'un message du modèle n'est pas une donnée confidentielle de l'utilisateur. La dé-identifier 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 trois adaptateurs.

  • InMemoryConversationMemory garde tout dans un dictionnaire du processus, borné par défaut. Simple, suffisant pour un seul worker.
  • RedisConversationMemory persiste dans Redis, pour un déploiement multi-worker où chaque worker doit voir les conversations des autres.
  • SqlAlchemyConversationMemory persiste dans une table SQL, pour des conversations longues qui survivent au processus.

Par nature, un backend persistant stocke des données confidentielles, parce qu'il garde le mapping inverse, qui ramène chaque jeton à sa valeur. Deux composants crypto optionnels, à fournir ensemble, le protègent sur Redis comme sur SQL. 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 les valeurs. Le thread_id reste en clair, préfixe de clé dans Redis et colonne dans la table SQL, pour qu'une conversation puisse être énumérée et oubliée.


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.

SchémaSchéma

Le middleware intercepte la boucle d'agent en trois points.

  • abefore_model dé-identifie les messages avant que le LLM ne les voie.
  • aafter_model restaure la sortie du modèle pour l'affichage utilisateur.
  • awrap_tool_call traite 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 dé-identifiant sa réponse.

Le type du middleware exige une factory qui préserve l'identité. À l'exécution, il refuse aussi un pipeline dont les jetons n'ont pas de grammaire délimitée, comme un masque (UnrecognizableFactoryError). Cette grammaire lui permet de reconnaître les jetons que le modèle invente (InventedPlaceholderStrategy). Après la restauration, tout jeton 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. Le pipeline peut donc toujours émettre ses traces sans vérifier si le traçage est actif. Un observation_redactor optionnel remplace les valeurs des traces par des jetons, pour un backend qui n'a pas le droit de voir les données confidentielles.


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. Ces modèles sont 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("pipeline.toml")
thread_pipeline = load_thread_pipeline("thread.toml")

Un fichier sans section [memory] construit un pipeline. Un fichier qui déclare une section [memory] construit un pipeline de conversation. Chaque chargeur refuse le fichier destiné à l'autre. load_pipeline refuse un fichier avec [memory], et load_thread_pipeline un fichier sans.

Le couplage est à sens unique. La config dépend du coeur et des adaptateurs, mais 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èleChamps clés
Detectiontext, label, span: Span, confidence
Entitydetections: tuple[Detection, ...], label et text en propriété
Spanstart, end, overlaps(), extract()

Voir aussi