--- icon: lucide/layers --- # 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. !!! note "Dé-identification, pas anonymisation" Par défaut `piighost` garde le lien entre une valeur et son jeton, 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, et un anneau intérieur n'importe jamais un anneau extérieur. ```mermaid flowchart TB CFG["`**Config** load_pipeline…`"] ADP["`**Adaptateurs** détecteurs, mémoires, middleware`"] APP["`**Application** AnonymizationPipeline…`"] CORE["`**Coeur** ports, Detection, Entity, Span`"] CFG --> ADP & APP ADP & APP --> CORE ``` *Trois anneaux et le point de composition. Les dépendances pointent toujours vers le coeur.* { .figure-caption } - **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. ```python @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. ```python 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. ```mermaid flowchart TB classDef opt stroke-dasharray:5 5 IN(["`**Texte source** _'Patrick habite à Paris. Patrick aime Paris.'_`"]) DET["`**Détecteur** _AnyDetector_`"] OVR["`override _AnyDetectionOverride_`"]:::opt OVL["`**Résolveur de spans** _AnyOverlapResolver_`"] EXP["`expander _AnyDetectionExpander_`"]:::opt LINK["`**Linker** _AnyEntityLinker_`"] ENT["`résolveur d'entités _AnyEntityResolver_`"]:::opt ANON["`**Anonymiseur** _AnyAnonymizer + factory_`"] GUARD["`garde-fou _AnyGuardRail_`"]:::opt OUT(["`**Sortie** _'#lt;#lt;PERSON:1#gt;#gt; habite à #lt;#lt;LOCATION:1#gt;#gt;. #lt;#lt;PERSON:1#gt;#gt; aime #lt;#lt;LOCATION:1#gt;#gt;.'_`"]) IN --> DET --> OVR --> OVL --> EXP --> LINK --> ENT --> ANON --> GUARD --> OUT ``` *Le pipeline. Les étapes toujours exécutées sont en gras, les étapes optionnelles ont un cadre en pointillé.* { .figure-caption } La page [Conception du pipeline](conception.md) 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.
| Port | Adaptateurs fournis | Rôle | |---|---|---| | `AnyDetector` | `Gliner2Detector`, `Gliner2PiiDetector`, `SpacyDetector`, `TransformersDetector`, `PresidioDetector`, `BridgeDetector`, `LLMDetector`, `RegexDetector`, `ExactMatchDetector`, `CompositeDetector`, `ChunkedDetector` | Trouve les données confidentielles (données personnelles, secrets), renvoie des `Detection` positionnées et typées. | | `AnyOverlapResolver` | `ConfidenceOverlapResolver`, `MergeOverlapResolver` | Arbitre les détections qui se chevauchent, garde la plus confiante ou leur union. | | `AnyDetectionExpander` | `WordBoundaryExpander` | Rattrape les occurrences ratées d'une valeur déjà détectée. | | `AnyEntityLinker` | `ExactEntityLinker` | Regroupe les détections d'une même valeur en une `Entity`. | | `AnyEntityResolver` | `MergeEntityResolver`, `FuzzyEntityResolver`, `SeparateEntityResolver` | Réconcilie les entités qui partagent une détection. | | `AnyAnonymizer` et `AnyPlaceholderFactory` | `Anonymizer` et `LabelCounterPlaceholderFactory` | Remplace chaque entité par son jeton. | | `AnyGuardRail` | `DetectorGuardRail`, `Gliner2GuardRail`, `LLMGuardRail`, `ModerationGuardRail` | Re-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. ```mermaid classDiagram class PlaceholderPreservation { racine } class PreservesNothing { <<REDACT>> } class PreservesLabel { <<PERSON>> } class PreservesShape { "J*******" } class PreservesIdentity { abstraction } class PreservesLabeledIdentity { <<PERSON:1>> } PlaceholderPreservation <|-- PreservesNothing PlaceholderPreservation <|-- PreservesLabel PlaceholderPreservation <|-- PreservesIdentity PreservesLabel <|-- PreservesShape PreservesLabel <|-- PreservesLabeledIdentity PreservesIdentity <|-- PreservesLabeledIdentity ``` *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".* { .figure-caption } 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 `<>` 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 `<>`{ .placeholder }, `LabelPlaceholderFactory` émet `<>`{ .placeholder }, `LabelCounterPlaceholderFactory` émet `<>`{ .placeholder }, `LabelHashPlaceholderFactory` émet `<>`{ .placeholder }. `MaskPlaceholderFactory` garde le premier caractère et masque le reste, si bien que `Jonathan`{ .pii } devient `J*******`{ .placeholder }. Le détail est dans [Fabriques de placeholders](placeholder-factories.md). --- ## 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. ```python 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 -> "<> habite à Paris." # result.tokens -> {Entity("Patrick"): "<>"} 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é. ```python 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`{ .pii } doit garder le même `<>`{ .placeholder } 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. ```python 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. ```mermaid 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 à <> à <>" L->>M: tool_call(send_email, to=<>) M->>M: awrap_tool_call, restaure les arguments M->>T: send_email(to="Patrick") T->>M: "Email envoyé à Patrick" M->>M: awrap_tool_call, dé-identifie le résultat M->>L: "Email envoyé à <>" L->>M: "C'est fait, email envoyé à <>." 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.* { .figure-caption } - `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](tool-call-strategies.md). --- ## 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. ```python 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è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](conception.md) : pourquoi chaque étape existe et dans quel ordre. - [Fabriques de placeholders](placeholder-factories.md) : les familles de jetons et ce qu'elles préservent. - [Stratégies d'appel outil](tool-call-strategies.md) : le détail de `awrap_tool_call`. - [Étendre piighost](extending.md) : brancher son propre adaptateur derrière un port. - [Référence des modèles de données](reference/models.md) : les champs, méthodes et validations de `Detection`, `Entity`, `Span` et `Chunk`.