Architecture¶
piighost follows a hexagonal architecture, also known as ports and adapters. The core
knows only abstract contracts, the ports. Each concrete implementation, a GLiNER2
detector, a Redis backend, a LangChain middleware, is an adapter that satisfies a
port without the core knowing about it. The de-identification pipeline is assembled by
injecting the chosen adapters behind the ports it expects.
De-identification, not anonymization
By default piighost keeps the link between a value and its token, so it can
restore the value. This is reversible de-identification, which under the GDPR is
pseudonymization, not anonymization. The word anonymization stays reserved for an
irreversible removal, for example with RedactPlaceholderFactory.
The three rings¶
The code reads as three rings, from the most abstract to the most concrete. The direction of the dependencies is fixed once and for all, an outer ring imports an inner ring, never the reverse.
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, composition root"]
direction LR
CFG["load_pipeline / load_thread_pipeline"]:::config
end
subgraph ADAPTERS ["Adapters, concrete implementations"]
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 ["Core, ports and models"]
direction LR
PORTS["AnyDetector, AnyEntityLinker,\nAnyAnonymizer, AnyConversationMemory…"]:::core
MODELS["Detection, Entity, Span"]:::core
end
CONFIG --> ADAPTERS
CONFIG --> APP
ADAPTERS --> CORE
APP --> CORE
Three rings and the composition root. Dependencies always point toward the core.
- Core. The data models (
Detection,Entity,Span, frozen dataclasses) and the ports. No external dependency, no pydantic, no I/O. - Application. The pipeline orchestration, which depends only on the core ports.
This is where
anonymize,deanonymize, andforget_threadlive. - Adapters. The concrete implementations of the ports, detectors, resolvers, factories, guard rails, memory backends, observation, HTTP client, middleware. Each adapter imports the core, never the reverse.
- Config. The composition root. It is the only place allowed to know both the ports and the concrete adapters, in order to assemble them.
Ports and templates¶
A port is a Python Protocol marked runtime_checkable, in each component's
base.py. The typing there is structural, an object satisfies the port as soon as
it has the methods, without inheriting from it. The pipeline depends on the port, never
on a concrete class.
@runtime_checkable
class AnyDetector(Protocol):
async def detect(self, text: str) -> list[Detection]: ...
When several adapters of one port share a skeleton, that skeleton lives in a Base*
class, an abstract class that applies the Template Method pattern. The skeleton is
written once in the base class, and each subclass provides only the step that varies.
class BaseEntityLinker(ABC):
def link(self, detections: list[Detection]) -> list[Entity]:
# common skeleton: group by key
...
@abstractmethod
def _key(self, detection: Detection) -> Hashable:
# only varying step, defined by the subclass
...
Two ports have no template. The guard rails and the memory backends differ by their whole mechanism, not by a single step, so there is nothing common to factor out. This is the deliberate exception to the always-template rule.
The pipeline stages¶
BaseAnonymizationPipeline chains the stages from detection to de-identified text.
Only the detector is a required constructor argument. Linking, de-identification, and
overlap resolution always run, falling back to built-in defaults when omitted, an
ExactEntityLinker, an Anonymizer with a LabelCounterPlaceholderFactory, and a
ConfidenceOverlapResolver. The override, expand, entity-resolve, and guard stages
are pass-throughs when not provided.
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(["`**Source text**
_'Patrick lives in Paris.
Patrick loves Paris.'_`"]):::data
DET["`**Detector**
_AnyDetector_`"]:::req
OVR["`override
_AnyDetectionOverride_`"]:::opt
OVL["`span resolver
_AnyOverlapResolver_`"]:::opt
EXP["`expander
_AnyDetectionExpander_`"]:::opt
LINK["`**Linker**
_AnyEntityLinker_`"]:::req
ENT["`entity resolver
_AnyEntityResolver_`"]:::opt
ANON["`**Anonymizer**
_AnyAnonymizer + factory_`"]:::req
GUARD["`guard rail
_AnyGuardRail_`"]:::opt
OUT(["`**Output**
_'<<PERSON:1>> lives in <<LOCATION:1>>.
<<PERSON:1>> loves <<LOCATION:1>>.'_`"]):::data
IN --> DET --> OVR --> OVL --> EXP --> LINK --> ENT --> ANON --> GUARD --> OUT
The pipeline, mandatory stages in blue, optional stages in yellow.
Why each stage exists and in which order is covered in Pipeline design. Here is the role and the default adapter of each.
| Stage | Port | Provided adapter | Role |
|---|---|---|---|
| Detector | AnyDetector |
Gliner2Detector, RegexDetector, LLMDetector, ExactMatchDetector, CompositeDetector, ChunkedDetector |
Finds the PII, returns positioned and typed Detection objects. |
| Span resolver | AnyOverlapResolver |
ConfidenceOverlapResolver |
Arbitrates overlapping detections, keeps the highest-confidence one. |
| Expander | AnyDetectionExpander |
WordBoundaryExpander |
Catches missed occurrences of an already-detected value. |
| Linker | AnyEntityLinker |
ExactEntityLinker |
Groups the detections of one value into an Entity. |
| Entity resolver | AnyEntityResolver |
MergeEntityResolver, FuzzyEntityResolver, SeparateEntityResolver |
Reconciles entities that share a detection. |
| Anonymizer | AnyAnonymizer (+ AnyPlaceholderFactory) |
Anonymizer + LabelCounterPlaceholderFactory |
Replaces each entity with its token. |
| Guard rail | AnyGuardRail |
DetectorGuardRail, LLMGuardRail, ModerationGuardRail |
Re-checks the output, raises PIIRemainingError on residual PII. |
The override (AnyDetectionOverride, adapter DetectionOverride) is an optional server
component. It applies a whitelist and a blacklist to every detection set, right after
detection, before span resolution.
The placeholder component and its preservation tags¶
The anonymizer delegates the shape of the token to a placeholder factory
(AnyPlaceholderFactory). What changes between two factories is what the token
preserves of the original value.
classDiagram
class PlaceholderPreservation {
root
}
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
The preservation tags, from the token that keeps nothing to the one that identifies each entity.
Each tag is a subclass of str, so a token is a real string carrying its preservation
level in its own type. These tags are phantom types, they exist only for the type
checker. The middleware requires a tag that preserves identity
(PreservesRecognizableIdentity), so plugging a <<PERSON>> factory into the
middleware is an error caught at type-check time, not a runtime surprise.
The provided factories range from the least to the most informative.
RedactPlaceholderFactory emits <<REDACT>>, LabelPlaceholderFactory
emits <<PERSON>>, LabelCounterPlaceholderFactory emits
<<PERSON:1>>, LabelHashPlaceholderFactory emits
<<PERSON:a1b2c3d4>>, MaskPlaceholderFactory emits
j***@mail.com. The detail is in
Placeholder factories.
The single-text pipeline¶
AnonymizationPipeline handles an isolated text. It detects, applies the optional
stages that are present, groups into entities, de-identifies, then passes the output to
the guard rail. Its deanonymize method takes the token-to-entity mapping produced by
anonymize and restores the values.
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."
The constructor requires only the detector. The linker and anonymizer default to
ExactEntityLinker and an Anonymizer with a LabelCounterPlaceholderFactory.
The other stages come as keyword arguments.
AnonymizationPipeline(
detector,
linker,
anonymizer,
overlap_resolver=None, # AnyOverlapResolver, defaults to ConfidenceOverlapResolver
expander=None, # AnyDetectionExpander
entity_resolver=None, # AnyEntityResolver
guard=None, # AnyGuardRail
override=None, # AnyDetectionOverride
)
Omitting overlap_resolver, or passing None, builds a ConfidenceOverlapResolver,
since the render stage needs disjoint spans. The expand, entity-resolve, guard, and
override stages stay disabled when None.
The conversation pipeline¶
ThreadAnonymizationPipeline shares the same base but adds a conversation memory
(AnyConversationMemory), passed as a mandatory argument. An agent chains messages, and
the same Patrick must keep the same <<PERSON:1>> from the
first to the last.
Tokens are assigned over the union of every message's detections in the thread, not over one message alone. A value seen again later therefore recovers its token instead of creating a new one. Rendering, in contrast, stays per message, only the current message's spans are replaced, because detections from different messages do not share the same offset space.
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")
- The
thread_idis mandatory, there is no shared default thread, so two callers cannot fall into the same thread and leak each other's PII. deanonymizerebuilds the thread's tokens from memory, so any text carrying those tokens is restored, including a model reply the pipeline never de-identified.forget_threaderases a thread's whole memory and reports how much was dropped, for the right to erasure.
Value provenance¶
A value whose first occurrence in the thread comes from a model message is not user PII.
Tokenizing it would strip the model of its world knowledge. So the memory records the
role of each value's first occurrence (MessageRole.USER or
MessageRole.ASSISTANT), and the pipeline leaves assistant-introduced values in clear.
The conversation memory and encryption¶
The memory is a repository, an AnyConversationMemory port with two adapters.
InMemoryConversationMemorykeeps everything in a process-local dict. Simple, enough for a single worker.RedisConversationMemorypersists to Redis, for a multi-worker deployment where each worker must see the others' threads.
The Redis backend stores clear PII by nature, the reverse mapping. Two crypto
components protect it. An AnyHasher (Sha256Hasher, Argon2Hasher) turns each
message into a deterministic key without revealing the text. An AnyCipher
(AesGcmCipher) encrypts the detections at rest, so a store leak reveals neither the
message nor the PII. The thread_id stays clear as a key prefix, so a thread can be
enumerated and forgotten.
The LangChain middleware¶
PIIAnonymizationMiddleware wires the conversation pipeline into a LangChain agent
loop. It contains no de-identification logic, it delegates everything to the pipeline.
It is an adapter between the LangChain world and the core.
sequenceDiagram
participant U as User
participant M as Middleware
participant L as LLM
participant T as Tool
U->>M: "Send an email to Patrick in Paris"
M->>M: abefore_model, de-identifies
M->>L: "Send an email to <<PERSON:1>> in <<LOCATION:1>>"
L->>M: tool_call(send_email, to=<<PERSON:1>>)
M->>M: awrap_tool_call, restores the arguments
M->>T: send_email(to="Patrick")
T->>M: "Email sent to Patrick"
M->>M: awrap_tool_call, re-identifies the result
M->>L: "Email sent to <<PERSON:1>>"
L->>M: "Done, email sent to <<PERSON:1>>."
M->>M: aafter_model, restores for the user
M->>U: "Done, email sent to Patrick."
The middleware intercepts the agent loop at three points.
abefore_modelde-identifies the messages before the LLM sees them.aafter_modelrestores the model's output for the user display.awrap_tool_callhandles the tool call according to the chosen strategy (ToolCallStrategy), restoring the arguments so the tool receives real data, then re-identifying its response.
The middleware requires a factory that preserves identity, at type-check time. It also
recognizes the tokens the model invents (InventedPlaceholderStrategy), since after
restoration any token still following the placeholder grammar was not emitted by the
pipeline. The detail of the tool strategies is in
Tool-call strategies.
Observation¶
piighost emits one trace per pipeline stage through a port (AnyObservationTracer), a
seam on top of OpenTelemetry. With no backend configured, a no-op implementation traces
nothing and costs nothing, so the pipeline can always emit without checking whether
tracing is active. An optional observation_redactor replaces the values in the traces
with tokens, for a backend not allowed to see PII.
The config, composition root¶
A TOML or JSON file describes the whole pipeline. The config subsystem reads it with
pydantic-settings and turns it into config models, discriminated unions where each
component type carries a build() method. Assembling the pipeline amounts to calling
build() on each model.
from piighost.config import load_pipeline, load_thread_pipeline
pipeline = load_pipeline("piighost.toml")
thread_pipeline = load_thread_pipeline("piighost.toml")
The coupling is one-way, config depends on the core and the adapters, the core never
imports config. Adding a component means writing an adapter, a config model with
build(), and nothing else. The pipeline does not change.
Data models¶
All core models are frozen dataclasses, immutable so they can be shared across coroutines without risk.
| Model | Key fields |
|---|---|
Detection |
text, label, span: Span, confidence |
Entity |
detections: tuple[Detection, ...], label and text as properties |
Span |
start, end, overlaps(), extract() |
See also¶
- Pipeline design, why each stage exists and in which order
- Placeholder factories, the families of tokens and what they preserve
- Tool-call strategies, the detail of
awrap_tool_call - Extending PIIGhost, plugging your own adapter behind a port
- Data models reference, the fields, methods and validation of
Detection,Entity,SpanandChunk