<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Athroniaeth</title>
    <description>The latest articles on Forem by Athroniaeth (@athroniaeth).</description>
    <link>https://forem.com/athroniaeth</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3899270%2Fbe5c8a40-e93a-4cd6-a6a1-4dc0caffdcff.jpeg</url>
      <title>Forem: Athroniaeth</title>
      <link>https://forem.com/athroniaeth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/athroniaeth"/>
    <language>en</language>
    <item>
      <title>PIIGhost: a Python library for PII anonymization in LLM agents</title>
      <dc:creator>Athroniaeth</dc:creator>
      <pubDate>Mon, 27 Apr 2026 18:18:21 +0000</pubDate>
      <link>https://forem.com/athroniaeth/piighost-a-python-library-for-pii-anonymization-in-llm-agents-183l</link>
      <guid>https://forem.com/athroniaeth/piighost-a-python-library-for-pii-anonymization-in-llm-agents-183l</guid>
      <description>&lt;p&gt;I've been building agents on top of LangGraph for a while now, and I keep running into the same problem: every message sent to the LLM might contain sensitive data, and depending on the provider you're using, what happens to that data changes completely.&lt;/p&gt;

&lt;p&gt;To simplify, there are three families of providers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-EU cloud&lt;/strong&gt; (OpenAI, Anthropic, Google): the best models, but data leaves the EU, which is problematic on many fronts. I wrote a summary &lt;a href="https://athroniaeth.github.io/piighost/why-anonymize/#how-a-cloud-llm-works" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sovereign EU cloud&lt;/strong&gt; (Mistral, Aleph Alpha): processing happens in the EU, but a more restricted catalog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted&lt;/strong&gt; (Ollama, vLLM, open-weight models): you never hand your data to a third party, you control everything, but you have to manage the infrastructure yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm currently working on notarial documents, which in practice limits me to Mistral. So I can't take advantage of the best LLMs to do my work. The only clean way to decouple the LLM from the sensitivity of the content is to anonymize upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's harder than it looks
&lt;/h2&gt;

&lt;p&gt;On paper, it's simple. You take a detector (regex for emails, NER model for names), replace what matches with placeholders, and send to the LLM.&lt;/p&gt;

&lt;p&gt;In practice, four problems show up almost immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Placeholder consistency.&lt;/strong&gt; The point of anonymization is to replace "Patrick" with a placeholder like &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;, which tells the LLM two things. A person has been hidden here, and every occurrence of &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; refers to the same person. If "Patrick" becomes &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; at the start of the text and &lt;code&gt;&amp;lt;&amp;lt;PERSON:3&amp;gt;&amp;gt;&lt;/code&gt; at the end, the LLM can no longer reason about the fact that it's the same individual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variants missed by the detector.&lt;/strong&gt; The NER detects "Patrick Dupont" at the start of the text but misses "Patrick" alone two sentences later. Or it detects "Patrick" but not "patrick" in lowercase. Or not "Patriick" with a typo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlap between detectors.&lt;/strong&gt; You chain two NERs to boost recall. On "Patrick", both can claim the same span with different labels (one says &lt;code&gt;PERSON&lt;/code&gt;, the other says &lt;code&gt;ORG&lt;/code&gt; because it confused it with a company name). Without arbitration, the final replacement hits the same position twice and breaks the text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence across messages.&lt;/strong&gt; Once the LLM has seen &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; in message 1, message 2 needs to use the same placeholder. Without shared memory, "Patrick" becomes &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; then &lt;code&gt;&amp;lt;&amp;lt;PERSON:7&amp;gt;&amp;gt;&lt;/code&gt; depending on the moment, and the LLM loses track.&lt;/p&gt;

&lt;p&gt;And that's before we even get to the agent, where tools need to receive the real values (to send an email, for example) while the LLM should only see placeholders. On the front-end side, you also have to deanonymize the placeholders before showing the response to the user, without the LLM ever knowing the mapping.&lt;/p&gt;

&lt;p&gt;It's to address all of this that I built &lt;strong&gt;PIIGhost&lt;/strong&gt;, an open-source project that adds a layer of detection, anonymization and deanonymization on top of your detectors (NER, regex, LLM, whatever you want). It also offers a conversational mode and a LangChain middleware that plugs into LangGraph without modifying your existing code.&lt;/p&gt;

&lt;p&gt;The rest of the article follows the pipeline order: detection, span arbitration, entity linking, merging, anonymization, then the conversational and agent layers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Detection
&lt;/h2&gt;

&lt;p&gt;Everything starts with detection. A detector takes text and returns a list of &lt;code&gt;Detection&lt;/code&gt; objects (text found, label, position, confidence). PIIGhost ships several out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RegexDetector&lt;/code&gt; for structured formats (emails, phone numbers, IBAN).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExactMatchDetector&lt;/code&gt; for fixed words known in advance, useful for tests or business dictionaries.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Gliner2Detector&lt;/code&gt; for NER, plugged on GLiNER2 by default.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CompositeDetector&lt;/code&gt; to combine multiple detectors into one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interface is an &lt;code&gt;AnyDetector&lt;/code&gt; protocol, so you can plug in your own (an LLM call, another NER model, whatever you want).&lt;/p&gt;

&lt;p&gt;Here's an example without an ML model, just to show the mechanics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ExactMatchDetector&lt;/span&gt;

&lt;span class="n"&gt;detector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ExactMatchDetector&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PERSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Paris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOCATION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;detections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick lives in Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Detection(text='Patrick', label='PERSON',   position=Span(0, 7),   confidence=1.0)
# Detection(text='Paris',   label='LOCATION', position=Span(15, 20), confidence=1.0)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this stage, we have a raw list of detections. No anonymization, no duplicate handling, nothing. Just "here's what looks like PII and where it sits".&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Span arbitration
&lt;/h2&gt;

&lt;p&gt;First real problem. When you chain multiple detectors on the same text, they can claim the same chunk with different labels. This is typically what happens when you combine two NERs to boost recall. They step on each other and one of them is wrong.&lt;/p&gt;

&lt;p&gt;A concrete example. On the following sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Patrick works at Orange since 2015."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You run two NERs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NER A (a generalist model) detects "Patrick" → &lt;code&gt;PERSON&lt;/code&gt;, span &lt;code&gt;[0:7]&lt;/code&gt;, confidence &lt;code&gt;0.95&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;NER B (a domain model less reliable on first names) detects "Patrick" → &lt;code&gt;ORG&lt;/code&gt;, span &lt;code&gt;[0:7]&lt;/code&gt;, confidence &lt;code&gt;0.60&lt;/code&gt; (it confused it with a company name)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both point to exactly the same span &lt;code&gt;[0:7]&lt;/code&gt;, but with mutually exclusive labels. If we replace both, we hit the same position twice and end up with something broken like &lt;code&gt;&amp;lt;&amp;lt;ORG:1&amp;gt;&amp;gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; works at...&lt;/code&gt;. We have to choose.&lt;/p&gt;

&lt;p&gt;That's the role of the &lt;strong&gt;span resolver&lt;/strong&gt;. PIIGhost ships two by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ConfidenceSpanConflictResolver&lt;/code&gt;: keeps the detection with the highest confidence in case of overlap. The reasonable default.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DisabledSpanConflictResolver&lt;/code&gt;: does nothing, to use if your detections are already clean or if you want to handle the case yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also write your own (prefer the longest span, prefer a specific label, etc.) by implementing the &lt;code&gt;SpanConflictResolver&lt;/code&gt; protocol.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;

&lt;span class="n"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Input detections:
#   - PERSON "Patrick" [0:7] confidence=0.95   (NER A)
#   - ORG    "Patrick" [0:7] confidence=0.60   (NER B)
#
# After resolution, only this remains:
#   - PERSON "Patrick" [0:7] confidence=0.95
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the end of this step, no more overlaps. Each chunk of text is claimed by only one detection.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Overlap isn't necessarily exact. The resolver also handles cases where one span is included in another, or where two spans partially overlap. The principle stays the same. Keep the most confident.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3: Entity linking
&lt;/h2&gt;

&lt;p&gt;Second problem. The NER misses occurrences. It finds "Patrick Dupont" in sentence 1 but misses "Patrick" alone in sentence 3. If we stop at raw detection, "Patrick" stays in clear text in the anonymized output. That's exactly what we want to avoid.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;linker&lt;/strong&gt; fixes this. &lt;code&gt;ExactEntityLinker&lt;/code&gt; does two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For each detection, it searches for all other occurrences of the same text in the document, using a word-boundary regex (to avoid matching "Patric" inside "Patricia").&lt;/li&gt;
&lt;li&gt;It groups every detection that points to the same normalized text into a single &lt;code&gt;Entity&lt;/code&gt; object.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Concretely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Text: "Patrick Dupont lives in Paris. Patrick loves Paris."

Raw NER detections:
  - PERSON   "Patrick Dupont"  (sentence 1)
  - LOCATION "Paris"            (sentence 1)
  # "Patrick" and "Paris" in sentence 2 were missed by the NER

After ExactEntityLinker:
  - Entity(label=PERSON,   detections=["Patrick Dupont", "Patrick"])
  - Entity(label=LOCATION, detections=["Paris", "Paris"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All occurrences are recovered, grouped by entity. The NER misses things, the linker catches them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One caveat. The linker does exact string matching. It won't catch "patrick" in lowercase or "Patriick" with a typo. For that, you need a fuzzy linker, which you can write by implementing the &lt;code&gt;EntityLinker&lt;/code&gt; protocol.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Entity merging
&lt;/h2&gt;

&lt;p&gt;Third problem, more subtle. Imagine two detectors that see the same person but with different spans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The NER detects "Patrick Dupont" → entity A, label &lt;code&gt;PERSON&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A business dictionary detects "Patrick" alone (because they're in the firm's associates list) → entity B, label &lt;code&gt;PERSON&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After the linker, you end up with two distinct entities even though it's clearly the same person. If you anonymize as is, "Patrick Dupont" becomes &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; and "Patrick" alone becomes &lt;code&gt;&amp;lt;&amp;lt;PERSON:2&amp;gt;&amp;gt;&lt;/code&gt;. The LLM thinks these are two different people.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;entity resolver&lt;/strong&gt; merges these duplicates. Two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MergeEntityConflictResolver&lt;/code&gt;: uses union-find to merge entities sharing at least one detection (strict matching). The default.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FuzzyEntityConflictResolver&lt;/code&gt;: uses Jaro-Winkler distance to merge entities whose canonical text is close (e.g. "Patrick" and "Patriick" with a typo). More tolerant, but higher false-positive risk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A concrete example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before merge:
  - Entity(label=PERSON, detections=["Patrick Dupont"])
  - Entity(label=PERSON, detections=["Patrick"])
  # Both entities share a detection on the string "Patrick"

After MergeEntityConflictResolver:
  - Entity(label=PERSON, detections=["Patrick Dupont", "Patrick"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this stage, you have a clean list of entities, each grouping all of its occurrences. No more duplicates, no more overlaps.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Anonymization
&lt;/h2&gt;

&lt;p&gt;Now we can replace. The &lt;code&gt;Anonymizer&lt;/code&gt; generates a unique placeholder per entity via a &lt;code&gt;PlaceholderFactory&lt;/code&gt;, then replaces the spans in the text from right to left (so the positions of the following spans don't shift).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;

&lt;span class="n"&gt;anonymizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anonymizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Patrick Dupont lives in Paris. Patrick loves Paris.
# becomes
# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; lives in &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;. &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; loves &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several factories are provided, to choose based on your case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LabelCounterPlaceholderFactory&lt;/code&gt;: &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;&lt;/code&gt;. Readable in logs and traces.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LabelHashPlaceholderFactory&lt;/code&gt;: &lt;code&gt;&amp;lt;&amp;lt;PERSON:a3f9&amp;gt;&amp;gt;&lt;/code&gt;. Avoids leaking the order in which entities appear from one conversation to another.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FakerCounterPlaceholderFactory&lt;/code&gt;: "John Smith", "Springfield". Preserves linguistic flow for the LLM (useful if the model struggles with raw placeholders).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MaskPlaceholderFactory&lt;/code&gt;: &lt;code&gt;[REDACTED]&lt;/code&gt;. Pure anonymization, irreversible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default &lt;code&gt;&amp;lt;&amp;lt;LABEL:N&amp;gt;&amp;gt;&lt;/code&gt; format has four useful properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it's unique as a token in theory,&lt;/li&gt;
&lt;li&gt;the LLM immediately sees what type of PII it's dealing with,&lt;/li&gt;
&lt;li&gt;it's not ambiguous in regular text,&lt;/li&gt;
&lt;li&gt;it can't be confused with another placeholder (unlike a plain &lt;code&gt;&amp;lt;&amp;lt;PERSON&amp;gt;&amp;gt;&lt;/code&gt;, which doesn't distinguish people from one another).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The assembled pipeline
&lt;/h2&gt;

&lt;p&gt;All the steps above chain together into a pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.pipeline&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnonymizationPipeline&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ExactEntityLinker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MergeEntityConflictResolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AnonymizationPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;span_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_linker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ExactEntityLinker&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;MergeEntityConflictResolver&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;anonymizer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;anonymized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick Dupont lives in Paris. Patrick loves Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; lives in &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;. &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; loves &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deanonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anonymized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Patrick Dupont lives in Paris. Patrick loves Paris.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline keeps a cache of the mapping (SHA-256 key on the input text), so deanonymization is free after the first call.&lt;/p&gt;




&lt;h2&gt;
  
  
  The conversation problem
&lt;/h2&gt;

&lt;p&gt;All of this works for an isolated message. In a real conversation, it breaks because of three problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Counters not shared.&lt;/strong&gt; Every call to &lt;code&gt;anonymize&lt;/code&gt; starts from scratch. The &lt;code&gt;Patrick → &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; mapping from message 1 is not guaranteed to be reused at message 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detections missed across messages.&lt;/strong&gt; The NER detects "Patrick" in message 1 but misses it in message 5. Without memory of entities already seen, we can't fill the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent conversations.&lt;/strong&gt; If multiple users share the same pipeline instance, their entities mix together. The &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; of one and the other become indistinguishable.&lt;/p&gt;

&lt;p&gt;Bug demonstration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Message 1
&lt;/span&gt;&lt;span class="n"&gt;m1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick lives in Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; lives in &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="c1"&gt;# Message 2, state not shared
&lt;/span&gt;&lt;span class="n"&gt;m2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bob is happy.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; is happy.   ← the counter restarted at 1
# Bob inherits the same placeholder as Patrick → collision:
# the LLM thinks it's the same person.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ThreadAnonymizationPipeline&lt;/code&gt; extends the standard pipeline with a &lt;code&gt;ConversationMemory&lt;/code&gt; scoped by &lt;code&gt;thread_id&lt;/code&gt;. The memory accumulates entities across messages, deduplicated by &lt;code&gt;(text.lower(), label)&lt;/code&gt;. Each call passes a &lt;code&gt;thread_id&lt;/code&gt;, and the cache is prefixed with that identifier so conversations stay isolated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.pipeline.thread&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadAnonymizationPipeline&lt;/span&gt;

&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ThreadAnonymizationPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;span_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;

&lt;span class="c1"&gt;# Conversation A
&lt;/span&gt;&lt;span class="n"&gt;m1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick lives in Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; lives in &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="n"&gt;m2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick is happy.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; is happy.   ← guaranteed, shared via the thread memory
&lt;/span&gt;
&lt;span class="c1"&gt;# Conversation B in parallel, isolated
&lt;/span&gt;&lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bob loves Lyon.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; loves &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.   ← counter independent from conversation A
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ThreadAnonymizationPipeline&lt;/code&gt; also adds two operations useful for the agent case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;anonymize_with_ent(text, thread_id=...)&lt;/code&gt;: pure string replacement, without detection. Uses the entities already known to the thread to anonymize a new text. Faster, but doesn't detect new PII.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deanonymize_with_ent(text, thread_id=...)&lt;/code&gt;: inverse replacement. Useful when the LLM produces text with placeholders we want to restore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These two operations correctly handle cases where one placeholder is a prefix of another (&lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; vs &lt;code&gt;&amp;lt;&amp;lt;PERSON:10&amp;gt;&amp;gt;&lt;/code&gt;) by replacing the longer ones first.&lt;/p&gt;




&lt;h2&gt;
  
  
  The agent problem
&lt;/h2&gt;

&lt;p&gt;In a LangGraph agent, the LLM doesn't just process messages. It calls tools, reads their results, and reasons in a loop. Anonymizing properly in this setting requires three interventions at precise moments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the LLM call.&lt;/strong&gt; All messages have to be anonymized. This is the standard &lt;code&gt;pipeline.anonymize()&lt;/code&gt;, applied to each message of the context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before and after a tool execution.&lt;/strong&gt; The LLM calls &lt;code&gt;send_email(to=&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;)&lt;/code&gt;. The tool needs the real address, not the placeholder. We deanonymize the arguments via &lt;code&gt;deanonymize_with_ent&lt;/code&gt;, execute, then re-anonymize the result before handing it back to the LLM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before display to the user.&lt;/strong&gt; The LLM produces "Done, I sent the email to &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;". The user wants to see "Patrick", not the placeholder.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PIIAnonymizationMiddleware&lt;/code&gt; wires these three hooks into LangGraph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.middleware&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PIIAnonymizationMiddleware&lt;/span&gt;

&lt;span class="n"&gt;middleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PIIAnonymizationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mistral:mistral-large-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_weather&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, the middleware reads the &lt;code&gt;thread_id&lt;/code&gt; from the LangGraph config (&lt;code&gt;get_config()["configurable"]["thread_id"]&lt;/code&gt;) and passes it to every pipeline operation. The LLM never sees real values, the tools receive them normally, the user gets the response with their names intact. No agent code to modify.&lt;/p&gt;




&lt;h2&gt;
  
  
  piighost-chat: the human-in-the-loop demo
&lt;/h2&gt;

&lt;p&gt;To make all of this concrete, I built a chatbot on top of the library. The user sees what is about to be anonymized before the message is sent to the LLM. They can deselect a span flagged by mistake, or select text the detector missed. Once validated, the message goes into the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpac3ix2cnjrdi9y8si31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpac3ix2cnjrdi9y8si31.png" alt="piighost-chat application" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This kind of human-in-the-loop UX is what makes auto-anonymization actually usable in real workflows, where automatic precision often plateaus around 90-95% and those few missed percent can be a problem. The auto pass does the heavy lifting, the human catches the edges.&lt;/p&gt;

&lt;p&gt;For instance, here you type your message, it goes through the piighost API and the front shows what was detected and what's about to be anonymized.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zji4sbp1pwcsg2l43rs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zji4sbp1pwcsg2l43rs.png" alt="Automatic PII detection before sending to the LLM" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can remove anonymized entities if there's a false positive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7i5u9da5v7t63qlsnop9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7i5u9da5v7t63qlsnop9.png" alt="Manual removal of a false positive" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also select text to add new entities to anonymize.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheaxmgmlhpxuu3s8ns5f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheaxmgmlhpxuu3s8ns5f.png" alt="Manual selection of a PII missed by the detector" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5n1ni84nrudib57wtql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5n1ni84nrudib57wtql.png" alt="The added entity appears in the list of anonymized PII" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you ask for information about an anonymized PII, for instance which letter the word starts with, the LLM won't be able to answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa4eccg4abdie2bu7685a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa4eccg4abdie2bu7685a.png" alt="The LLM, seeing only the placeholder, can't answer about the actual content" width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;The library is in its early days. I tried to anticipate as many cases as possible starting from my own needs on notarial documents, but I know that's a particular angle and that many things can be debated. Components that aren't generic enough, abstractions that don't pull their weight, use cases I haven't seen.&lt;br&gt;
If you give it a try, your feedback genuinely matters to me:&lt;/p&gt;

&lt;p&gt;what felt missing or counter-intuitive,&lt;br&gt;
what feels too complex or pointless and should be removed,&lt;br&gt;
the use cases where it doesn't hold up.&lt;/p&gt;

&lt;p&gt;Anything is welcome, whether through a GitHub issue, a PR, or even a direct message. I'd rather cut early on what doesn't belong than accumulate debt.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Athroniaeth/piighost" rel="noopener noreferrer"&gt;piighost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Athroniaeth/piighost-chat" rel="noopener noreferrer"&gt;piighost-chat&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://athroniaeth.github.io/piighost/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>programming</category>
      <category>langchain</category>
    </item>
    <item>
      <title>PIIGhost : une librairie Python d'anonymisation de données confidentiels pour les agents LLM</title>
      <dc:creator>Athroniaeth</dc:creator>
      <pubDate>Sun, 26 Apr 2026 23:38:08 +0000</pubDate>
      <link>https://forem.com/athroniaeth/piighost-une-librairie-python-danonymisation-de-donnees-confidentiels-pour-les-agents-llm-3c1i</link>
      <guid>https://forem.com/athroniaeth/piighost-une-librairie-python-danonymisation-de-donnees-confidentiels-pour-les-agents-llm-3c1i</guid>
      <description>&lt;p&gt;Ça fait un moment que je construis des agents avec LangGraph, et je retombe toujours sur le même problème : chaque message envoyé au LLM peut contenir des données sensibles, et selon le fournisseur que vous utilisez, ce qu'il advient de ces données change complètement.&lt;/p&gt;

&lt;p&gt;En simplifiant, il y a trois familles de fournisseurs :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud non-européen&lt;/strong&gt; (OpenAI, Anthropic, Google) : les meilleurs modèles, mais les données quittent l'UE, ce qui est problématique sur plein d'aspects. J'en ai fait un résumé &lt;a href="https://athroniaeth.github.io/piighost/fr/why-anonymize/#fonctionnement-dun-llm-cloud" rel="noopener noreferrer"&gt;ici&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud souverain européen&lt;/strong&gt; (Mistral, Aleph Alpha) : traitement en UE, mais catalogue plus restreint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted&lt;/strong&gt; (Ollama, vLLM, modèles open-weight) : vous ne fournissez jamais vos données à un tiers, vous contrôlez tout, mais vous devez gérer l'infrastructure vous-même.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Je travaille actuellement sur des documents notariaux, ce qui me limite en pratique à Mistral. Je ne peux donc pas profiter des meilleurs LLM pour effectuer mes tâches. La seule façon propre de découpler le LLM de la sensibilité du contenu, c'est d'anonymiser en amont.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi c'est plus dur qu'il n'y paraît
&lt;/h2&gt;

&lt;p&gt;Sur le papier, c'est simple : on prend un détecteur (regex pour les emails, modèle NER pour les noms), on remplace ce qui matche par des placeholders, et on envoie au LLM.&lt;/p&gt;

&lt;p&gt;En pratique, quatre problèmes apparaissent presque immédiatement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cohérence des placeholders.&lt;/strong&gt; Le but de l'anonymisation est de remplacer "Patrick" par un placeholder du type &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;, qui dit deux choses au LLM : on a caché une personne ici, et toutes les occurrences de &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; parlent de la même personne. Si "Patrick" devient &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; au début du texte et &lt;code&gt;&amp;lt;&amp;lt;PERSON:3&amp;gt;&amp;gt;&lt;/code&gt; à la fin, le LLM ne peut plus raisonner sur le fait qu'il s'agit du même individu.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variantes ratées par le détecteur.&lt;/strong&gt; Le NER détecte "Patrick Dupont" en début de texte mais rate "Patrick" tout seul deux phrases plus loin. Ou il détecte "Patrick" mais pas "patrick" en bas de casse. Ou pas "Patriick" avec une faute d'orthographe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chevauchement entre détecteurs.&lt;/strong&gt; Vous chaînez deux NER pour augmenter le rappel. Sur "Patrick", les deux peuvent revendiquer le même span avec des labels différents (l'un dit &lt;code&gt;PERSON&lt;/code&gt;, l'autre dit &lt;code&gt;ORG&lt;/code&gt; parce qu'il a confondu avec un nom d'entreprise). Sans arbitrage, le remplacement final tape sur la même position deux fois et casse le texte.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistance entre messages.&lt;/strong&gt; Une fois que le LLM a vu &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; dans le message 1, il faut que le message 2 utilise le même placeholder. Sans mémoire partagée, "Patrick" devient &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; puis &lt;code&gt;&amp;lt;&amp;lt;PERSON:7&amp;gt;&amp;gt;&lt;/code&gt; selon le moment, et le LLM perd le fil.&lt;/p&gt;

&lt;p&gt;Et c'est avant même de parler de l'agent, où les outils doivent recevoir les vraies valeurs (pour envoyer un email, par exemple) tandis que le LLM ne doit voir que les placeholders. Côté front, il faut aussi désanonymiser les placeholders avant de montrer la réponse à l'utilisateur, sans que le LLM ait connaissance du mapping.&lt;/p&gt;

&lt;p&gt;C'est pour répondre à tout ça que j'ai construit &lt;strong&gt;PIIGhost&lt;/strong&gt;, un projet open-source qui ajoute une couche de détection, d'anonymisation et de désanonymisation par-dessus vos détecteurs (NER, regex, LLM, ce que vous voulez). Il propose en plus un mode conversationnel et un middleware LangChain qui s'intègre dans LangGraph sans modifier votre code existant.&lt;/p&gt;

&lt;p&gt;Le reste de l'article suit l'ordre du pipeline : détection, arbitrage des spans, liaison d'entités, fusion, anonymisation, puis les couches conversationnelle et agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Étape 1 : Détection
&lt;/h2&gt;

&lt;p&gt;Tout commence par la détection. Un détecteur prend du texte et retourne une liste d'objets &lt;code&gt;Detection&lt;/code&gt; (texte trouvé, label, position, confiance). PIIGhost en fournit plusieurs en standard :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RegexDetector&lt;/code&gt; pour les formats structurés (emails, téléphones, IBAN).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ExactMatchDetector&lt;/code&gt; pour des mots fixes connus à l'avance, utile pour les tests ou pour des dictionnaires métier.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Gliner2Detector&lt;/code&gt; pour le NER, branché sur GLiNER2 par défaut.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CompositeDetector&lt;/code&gt; pour combiner plusieurs détecteurs en un seul.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;L'interface est un protocole &lt;code&gt;AnyDetector&lt;/code&gt;, donc vous pouvez brancher le vôtre (un appel LLM, un autre modèle NER, ce que vous voulez).&lt;/p&gt;

&lt;p&gt;Voici un exemple sans modèle ML, juste pour montrer la mécanique :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ExactMatchDetector&lt;/span&gt;

&lt;span class="n"&gt;detector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ExactMatchDetector&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PERSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Paris&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOCATION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;detections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick habite à Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Detection(text='Patrick', label='PERSON',   position=Span(0, 7),   confidence=1.0)
# Detection(text='Paris',   label='LOCATION', position=Span(17, 22), confidence=1.0)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;À ce stade, on a une liste brute de détections. Pas encore d'anonymisation, pas de gestion de doublons, rien. Juste : "voici ce qui ressemble à des PII et où elles sont".&lt;/p&gt;




&lt;h2&gt;
  
  
  Étape 2 : Arbitrage des spans
&lt;/h2&gt;

&lt;p&gt;Premier vrai problème : quand vous chaînez plusieurs détecteurs sur le même texte, ils peuvent revendiquer le même morceau avec des labels différents. C'est typiquement ce qui arrive quand on combine deux NER pour augmenter le rappel : ils se marchent dessus et l'un des deux se trompe.&lt;/p&gt;

&lt;p&gt;Prenons un exemple concret. Sur la phrase suivante :&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Patrick travaille chez Orange depuis 2015."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vous faites tourner deux NER :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NER A (un modèle généraliste) détecte "Patrick" → &lt;code&gt;PERSON&lt;/code&gt;, span &lt;code&gt;[0:7]&lt;/code&gt;, confidence &lt;code&gt;0.95&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;NER B (un modèle métier moins fiable sur les prénoms) détecte "Patrick" → &lt;code&gt;ORG&lt;/code&gt;, span &lt;code&gt;[0:7]&lt;/code&gt;, confidence &lt;code&gt;0.60&lt;/code&gt; (il a confondu avec un nom d'entreprise)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Les deux pointent exactement sur le même span &lt;code&gt;[0:7]&lt;/code&gt;, mais avec des labels qui s'excluent mutuellement. Si on remplace les deux, on tape deux fois sur la même position et on obtient un truc cassé du genre &lt;code&gt;&amp;lt;&amp;lt;ORG:1&amp;gt;&amp;gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; travaille chez...&lt;/code&gt;. Il faut choisir.&lt;/p&gt;

&lt;p&gt;C'est le rôle du &lt;strong&gt;résolveur de spans&lt;/strong&gt;. PIIGhost en fournit deux par défaut :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ConfidenceSpanConflictResolver&lt;/code&gt; : garde la détection avec la plus haute confiance en cas de chevauchement. C'est le défaut raisonnable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DisabledSpanConflictResolver&lt;/code&gt; : ne fait rien, à utiliser si vos détections sont déjà propres ou si vous voulez gérer le cas vous-même.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vous pouvez aussi écrire le vôtre (préférer le span le plus long, préférer un label spécifique, etc.) en implémentant le protocole &lt;code&gt;SpanConflictResolver&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;

&lt;span class="n"&gt;resolver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Détections en entrée :
#   - PERSON "Patrick" [0:7] confidence=0.95   (NER A)
#   - ORG    "Patrick" [0:7] confidence=0.60   (NER B)
#
# Après résolution, il ne reste que :
#   - PERSON "Patrick" [0:7] confidence=0.95
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;À la fin de cette étape, plus de chevauchements. Chaque morceau de texte n'est revendiqué que par une seule détection.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Le chevauchement n'est pas forcément exact. Le résolveur gère aussi les cas où un span est inclus dans un autre, ou où deux spans se recouvrent partiellement. Le principe reste le même : garder le plus confiant.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Étape 3 : Liaison d'entités
&lt;/h2&gt;

&lt;p&gt;Deuxième problème : le NER rate des occurrences. Il trouve "Patrick Dupont" dans la phrase 1, mais rate "Patrick" tout seul dans la phrase 3. Si on s'arrête à la détection brute, "Patrick" reste en clair dans le texte anonymisé. C'est exactement ce qu'on veut éviter.&lt;/p&gt;

&lt;p&gt;Le &lt;strong&gt;linker&lt;/strong&gt; corrige ça. &lt;code&gt;ExactEntityLinker&lt;/code&gt; fait deux choses :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pour chaque détection, il cherche toutes les autres occurrences du même texte dans le document, avec une regex word-boundary (pour éviter de matcher "Patric" dans "Patricia").&lt;/li&gt;
&lt;li&gt;Il regroupe toutes les détections qui pointent vers le même texte normalisé en un seul objet &lt;code&gt;Entity&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Concrètement :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Texte : "Patrick Dupont habite à Paris. Patrick adore Paris."

Détections brutes du NER :
  - PERSON   "Patrick Dupont"  (phrase 1)
  - LOCATION "Paris"            (phrase 1)
  # "Patrick" et "Paris" de la phrase 2 ont été ratés par le NER

Après ExactEntityLinker :
  - Entity(label=PERSON,   detections=["Patrick Dupont", "Patrick"])
  - Entity(label=LOCATION, detections=["Paris", "Paris"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Toutes les occurrences sont retrouvées, regroupées par entité. Le NER rate des choses, le linker rattrape derrière.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;À noter : le linker fait du matching exact sur la chaîne. Il n'attrape pas "patrick" en bas de casse ou "Patriick" avec une faute. Pour ça, il faut un linker fuzzy, qu'on peut écrire en implémentant le protocole &lt;code&gt;EntityLinker&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Étape 4 : Fusion d'entités
&lt;/h2&gt;

&lt;p&gt;Troisième problème, plus subtil. Imaginez deux détecteurs qui voient la même personne mais avec des spans différents :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Le NER détecte "Patrick Dupont" → entité A, label &lt;code&gt;PERSON&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Un dictionnaire métier détecte "Patrick" tout seul (parce qu'il est dans la liste des associés du cabinet) → entité B, label &lt;code&gt;PERSON&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Après le linker, vous vous retrouvez avec deux entités distinctes alors qu'il s'agit clairement de la même personne. Si vous anonymisez tel quel, "Patrick Dupont" devient &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; et "Patrick" tout seul devient &lt;code&gt;&amp;lt;&amp;lt;PERSON:2&amp;gt;&amp;gt;&lt;/code&gt;. Le LLM pense que ce sont deux personnes différentes.&lt;/p&gt;

&lt;p&gt;Le &lt;strong&gt;resolver d'entités&lt;/strong&gt; fusionne ces doublons. Deux options :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MergeEntityConflictResolver&lt;/code&gt; : utilise un union-find pour fusionner les entités qui partagent au moins une détection en commun (matching strict). C'est le défaut.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FuzzyEntityConflictResolver&lt;/code&gt; : utilise la distance Jaro-Winkler pour fusionner les entités dont le texte canonique est proche (ex. "Patrick" et "Patriick" avec une typo). Plus tolérant, mais risque de faux positifs plus élevé.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exemple concret :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Avant fusion :
  - Entity(label=PERSON, detections=["Patrick Dupont"])
  - Entity(label=PERSON, detections=["Patrick"])
  # Les deux entités partagent une détection sur la chaîne "Patrick"

Après MergeEntityConflictResolver :
  - Entity(label=PERSON, detections=["Patrick Dupont", "Patrick"])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;À ce stade, vous avez une liste propre d'entités, chacune regroupant toutes ses occurrences. Plus de doublons, plus de chevauchements.&lt;/p&gt;




&lt;h2&gt;
  
  
  Étape 5 : Anonymisation
&lt;/h2&gt;

&lt;p&gt;Maintenant on peut remplacer. L'&lt;code&gt;Anonymizer&lt;/code&gt; génère un placeholder unique par entité via une &lt;code&gt;PlaceholderFactory&lt;/code&gt;, puis remplace les spans dans le texte de droite à gauche (pour ne pas décaler les positions des spans suivants).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;

&lt;span class="n"&gt;anonymizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anonymizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Patrick Dupont habite à Paris. Patrick adore Paris.
# devient
# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; habite à &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;. &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; adore &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plusieurs factories sont fournies, à choisir selon votre cas :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LabelCounterPlaceholderFactory&lt;/code&gt; : &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;&lt;/code&gt;. Lisible dans les logs et les traces.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LabelHashPlaceholderFactory&lt;/code&gt; : &lt;code&gt;&amp;lt;&amp;lt;PERSON:a3f9&amp;gt;&amp;gt;&lt;/code&gt;. Évite de fuiter l'ordre d'apparition des entités d'une conversation à l'autre.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FakerCounterPlaceholderFactory&lt;/code&gt; : "John Smith", "Springfield". Préserve le flux linguistique pour le LLM (utile si le modèle galère avec les placeholders bruts).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MaskPlaceholderFactory&lt;/code&gt; : &lt;code&gt;[REDACTED]&lt;/code&gt;. Anonymisation pure, irréversible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Le format &lt;code&gt;&amp;lt;&amp;lt;LABEL:N&amp;gt;&amp;gt;&lt;/code&gt; par défaut a quatre propriétés utiles :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;il est en théorie unique comme token,&lt;/li&gt;
&lt;li&gt;le LLM voit immédiatement de quel type de PII il s'agit,&lt;/li&gt;
&lt;li&gt;il n'est pas ambigu dans du texte normal,&lt;/li&gt;
&lt;li&gt;il ne peut pas être confondu avec un autre placeholder (contrairement à &lt;code&gt;&amp;lt;&amp;lt;PERSON&amp;gt;&amp;gt;&lt;/code&gt; tout court, qui ne distingue pas les personnes entre elles).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Le pipeline assemblé
&lt;/h2&gt;

&lt;p&gt;Toutes les étapes ci-dessus s'enchaînent dans un pipeline :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.pipeline&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnonymizationPipeline&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ExactEntityLinker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MergeEntityConflictResolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AnonymizationPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;span_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ConfidenceSpanConflictResolver&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_linker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ExactEntityLinker&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;MergeEntityConflictResolver&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;anonymizer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Anonymizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LabelCounterPlaceholderFactory&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;anonymized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick Dupont habite à Paris. Patrick adore Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; habite à &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;. &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; adore &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deanonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anonymized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Patrick Dupont habite à Paris. Patrick adore Paris.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le pipeline garde un cache du mapping (clé SHA-256 sur le texte d'entrée), donc la désanonymisation est gratuite après le premier appel.&lt;/p&gt;




&lt;h2&gt;
  
  
  Le problème de la conversation
&lt;/h2&gt;

&lt;p&gt;Tout ça marche pour un message isolé. Dans une vraie conversation, ça casse à cause de trois problèmes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compteurs non partagés.&lt;/strong&gt; Chaque appel à &lt;code&gt;anonymize&lt;/code&gt; repart de zéro. Le mapping &lt;code&gt;Patrick → &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; du message 1 n'est pas garanti d'être réutilisé au message 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Détections manquées entre messages.&lt;/strong&gt; Le NER détecte "Patrick" dans le message 1 mais le rate dans le message 5. Sans mémoire des entités déjà vues, on ne peut pas combler le trou.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversations concurrentes.&lt;/strong&gt; Si plusieurs utilisateurs partagent la même instance de pipeline, leurs entités se mélangent. Les &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; des uns et des autres deviennent indiscernables.&lt;/p&gt;

&lt;p&gt;Démonstration du bug :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Message 1
&lt;/span&gt;&lt;span class="n"&gt;m1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick habite à Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; habite à &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="c1"&gt;# Message 2 : état non partagé
&lt;/span&gt;&lt;span class="n"&gt;m2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bob est content.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; est content.   ← le compteur est reparti à 1
# Bob hérite donc du même placeholder que Patrick → collision :
# le LLM pense que c'est la même personne.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ThreadAnonymizationPipeline&lt;/code&gt; étend le pipeline standard avec une &lt;code&gt;ConversationMemory&lt;/code&gt; scopée par &lt;code&gt;thread_id&lt;/code&gt;. La mémoire accumule les entités au fil des messages, dédupliquées par &lt;code&gt;(text.lower(), label)&lt;/code&gt;. Chaque appel passe un &lt;code&gt;thread_id&lt;/code&gt;, et le cache est préfixé par cet identifiant pour isoler les conversations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.pipeline.thread&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadAnonymizationPipeline&lt;/span&gt;

&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ThreadAnonymizationPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;span_resolver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;

&lt;span class="c1"&gt;# Conversation A
&lt;/span&gt;&lt;span class="n"&gt;m1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick habite à Paris.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; habite à &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.
&lt;/span&gt;
&lt;span class="n"&gt;m2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Patrick est content.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; est content.   ← garanti, partagé via la mémoire du thread
&lt;/span&gt;
&lt;span class="c1"&gt;# Conversation B en parallèle, isolée
&lt;/span&gt;&lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anonymize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bob aime Lyon.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user-B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# &amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt; aime &amp;lt;&amp;lt;LOCATION:1&amp;gt;&amp;gt;.   ← compteur indépendant de la conversation A
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ThreadAnonymizationPipeline&lt;/code&gt; ajoute aussi deux opérations utiles pour le cas agent :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;anonymize_with_ent(text, thread_id=...)&lt;/code&gt; : remplacement de chaîne pur, sans détection. Utilise les entités déjà connues du thread pour anonymiser un nouveau texte. Plus rapide, mais ne détecte pas de nouvelles PII.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deanonymize_with_ent(text, thread_id=...)&lt;/code&gt; : remplacement inverse. Utile quand le LLM produit un texte avec des placeholders qu'on veut restaurer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ces deux opérations gèrent correctement les cas où un placeholder est préfixe d'un autre (&lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt; vs &lt;code&gt;&amp;lt;&amp;lt;PERSON:10&amp;gt;&amp;gt;&lt;/code&gt;) en remplaçant les plus longs en premier.&lt;/p&gt;




&lt;h2&gt;
  
  
  Le problème de l'agent
&lt;/h2&gt;

&lt;p&gt;Dans un agent LangGraph, le LLM ne traite pas juste des messages. Il appelle des outils, lit leurs résultats, et raisonne en boucle. Anonymiser proprement dans ce contexte demande trois interventions à des moments précis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avant l'appel LLM.&lt;/strong&gt; Tous les messages doivent être anonymisés. C'est le &lt;code&gt;pipeline.anonymize()&lt;/code&gt; standard, appliqué sur chaque message du contexte.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avant et après l'exécution d'un outil.&lt;/strong&gt; Le LLM appelle &lt;code&gt;send_email(to=&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;)&lt;/code&gt;. Le tool a besoin de la vraie adresse, pas du placeholder. On désanonymise les arguments via &lt;code&gt;deanonymize_with_ent&lt;/code&gt;, on exécute, puis on réanonymise le résultat avant de le redonner au LLM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avant l'affichage à l'utilisateur.&lt;/strong&gt; Le LLM produit "C'est fait, j'ai envoyé l'email à &lt;code&gt;&amp;lt;&amp;lt;PERSON:1&amp;gt;&amp;gt;&lt;/code&gt;". L'utilisateur veut voir "Patrick", pas le placeholder.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PIIAnonymizationMiddleware&lt;/code&gt; pose ces trois hooks dans LangGraph :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;piighost.middleware&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PIIAnonymizationMiddleware&lt;/span&gt;

&lt;span class="n"&gt;middleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PIIAnonymizationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mistral:mistral-large-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_weather&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sous le capot, le middleware lit le &lt;code&gt;thread_id&lt;/code&gt; depuis la config LangGraph (&lt;code&gt;get_config()["configurable"]["thread_id"]&lt;/code&gt;) et le passe à toutes les opérations du pipeline. Le LLM ne voit jamais les vraies valeurs, les outils les reçoivent normalement, l'utilisateur récupère sa réponse avec ses noms intacts. Aucun code agent à modifier.&lt;/p&gt;




&lt;h2&gt;
  
  
  piighost-chat : la démo human-in-the-loop
&lt;/h2&gt;

&lt;p&gt;Pour rendre tout ça concret, j'ai construit un chatbot par-dessus la librairie. L'utilisateur voit ce qui va être anonymisé avant que le message parte au LLM. Il peut désélectionner un span flaggué par erreur, ou sélectionner du texte que le détecteur a raté. Une fois validé, le message part dans la pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpac3ix2cnjrdi9y8si31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpac3ix2cnjrdi9y8si31.png" alt="Application piighost-chat" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ce genre d'UX human-in-the-loop est ce qui rend l'anonymisation automatique vraiment utilisable dans les workflows réels, où la précision automatique plafonne souvent autour de 90-95 % et où ces quelques pourcents manqués peuvent être problématiques. La passe automatique fait le gros du boulot, l'humain rattrape les bords.&lt;/p&gt;

&lt;p&gt;Par exemple ici vous rentrez votre message, il passe par l'API piighost et le front affiche ce qui a été détecté et ce qui va être anonymisé.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zji4sbp1pwcsg2l43rs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zji4sbp1pwcsg2l43rs.png" alt="Détection automatique des PII avant envoi au LLM" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vous pouvez supprimer des entités anonymisées s'il y a eu un faux positif.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7i5u9da5v7t63qlsnop9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7i5u9da5v7t63qlsnop9.png" alt="Suppression manuelle d'un faux positif" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vous pouvez aussi sélectionner du texte pour rajouter des entités à anonymiser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheaxmgmlhpxuu3s8ns5f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fheaxmgmlhpxuu3s8ns5f.png" alt="Sélection manuelle d'une PII oubliée par le détecteur" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5n1ni84nrudib57wtql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe5n1ni84nrudib57wtql.png" alt="L'entité ajoutée apparaît dans la liste des PII anonymisées" width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Si vous demandez des informations sur une PII anonymisée, par exemple par quelle lettre commence le mot, le LLM ne pourra pas vous répondre.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa4eccg4abdie2bu7685a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa4eccg4abdie2bu7685a.png" alt="Le LLM, ne voyant que le placeholder, est incapable de répondre sur le contenu réel" width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;La librairie est à ses débuts. J'ai essayé d'anticiper un maximum de cas en partant de mes propres besoins sur des documents notariaux, mais je sais que c'est un angle particulier et que beaucoup de choses peuvent être discutées : des composants pas assez génériques, des abstractions qui ne servent à rien, des cas d'usage que je n'ai pas vus.&lt;br&gt;
Si vous l'essayez, vos retours m'intéressent vraiment :&lt;/p&gt;

&lt;p&gt;ce qui vous a manqué ou paru contre-intuitif,&lt;br&gt;
ce qui vous semble trop complexe ou inutile et mériterait d'être supprimé,&lt;br&gt;
les cas d'usage où elle ne tient pas la route.&lt;/p&gt;

&lt;p&gt;Tout est bon à prendre, que ce soit via une issue GitHub, une PR, ou même un message direct. Je préfère trancher tôt sur ce qui n'a pas sa place plutôt que d'accumuler de la dette.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Athroniaeth/piighost" rel="noopener noreferrer"&gt;piighost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Athroniaeth/piighost-chat" rel="noopener noreferrer"&gt;piighost-chat&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://athroniaeth.github.io/piighost/fr/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Merci d'avoir lu.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>french</category>
      <category>python</category>
    </item>
  </channel>
</rss>
