<?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>DEV Community: Athroniaeth</title>
    <description>The latest articles on DEV Community by Athroniaeth (@athroniaeth).</description>
    <link>https://dev.to/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%2F9bc732f0-38c4-4cec-93c7-fcd3f39bdd19.jpeg</url>
      <title>DEV Community: Athroniaeth</title>
      <link>https://dev.to/athroniaeth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/athroniaeth"/>
    <language>en</language>
    <item>
      <title>Comment laisser GPT-5.5 corriger un CV sans jamais lui montrer un seul donnée personnelle</title>
      <dc:creator>Athroniaeth</dc:creator>
      <pubDate>Wed, 27 May 2026 16:13:29 +0000</pubDate>
      <link>https://dev.to/athroniaeth/comment-laisser-gpt-55-corriger-un-cv-sans-jamais-lui-montrer-un-seul-donnee-personnelle-ij7</link>
      <guid>https://dev.to/athroniaeth/comment-laisser-gpt-55-corriger-un-cv-sans-jamais-lui-montrer-un-seul-donnee-personnelle-ij7</guid>
      <description>&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;Pour relire votre CV avant un envoi important, vous pouvez le confier à un LLM. Quelques secondes, et vous avez une liste de fautes. Sauf que vous venez aussi de donner votre nom, votre adresse, vos employeurs et vos dates à un service tiers.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;piighost-proofreader&lt;/code&gt; résout ça. Le CV est anonymisé localement avant l'appel au LLM, et les corrections retrouvent leur place sur le PDF d'origine :&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%2Funalmxdsyuyzpe3utb5k.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%2Funalmxdsyuyzpe3utb5k.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Le LLM ne voit jamais un nom, une date, une adresse.&lt;/p&gt;

&lt;p&gt;L'anonymisation, c'est la partie facile. Le morceau pénible, c'est de retrouver dans le PDF un mot que le LLM n'a vu qu'en Markdown. Et le LLM et PyMuPDF ne tokenisent pas pareil.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pourquoi pas juste une regex ?
&lt;/h2&gt;

&lt;p&gt;Première idée : avant d'envoyer le CV au LLM, on remplace les données sensibles par une bonne grosse regex. Ça marche pour les emails et les numéros de téléphone, qui ont un format reconnaissable. Pour le reste, c'est mort.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Un nom n'a aucune forme syntaxique distinctive. &lt;code&gt;Paul Martin&lt;/code&gt; ressemble à n'importe quels deux mots capitalisés ; rien dans le texte ne dit à une regex que c'est un nom.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Orange&lt;/code&gt; est une entreprise. C'est aussi un fruit. &lt;code&gt;Mars&lt;/code&gt;, &lt;code&gt;Apple&lt;/code&gt;, &lt;code&gt;Carrefour&lt;/code&gt;, pareil.&lt;/li&gt;
&lt;li&gt;Une date dans un CV peut être une naissance, un diplôme, un changement de poste. Le format est le même.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Il faut un détecteur entraîné, pas un pattern. &lt;code&gt;piighost&lt;/code&gt; en fournit un, et l'appel ressemble à ça :&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;# src/proofreader/anonymize.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&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;self&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/anonymize&lt;/span&gt;&lt;span class="sh"&gt;"&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;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anonymized_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le &lt;code&gt;thread_id&lt;/code&gt; est une UUID par CV. Le mapping entité→placeholder reste côté serveur, isolé par cet ID : un même nom devient le même placeholder à chaque occurrence.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Streamer les erreurs avec &lt;code&gt;instructor&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Un CV de deux pages contient une bonne quinzaine de fautes, et le LLM prend plusieurs secondes pour les sortir. Sans streaming, l'utilisateur fixe un loader pendant tout ce temps. Avec, les fautes apparaissent une par une au fur et à mesure que le modèle les émet.&lt;/p&gt;

&lt;p&gt;Le piège : la plupart des libs de structured output (LangChain &lt;code&gt;with_structured_output&lt;/code&gt;, OpenAI Functions, Pydantic AI) renvoient le résultat &lt;em&gt;complet&lt;/em&gt;. Vous demandez un &lt;code&gt;list[Mistake]&lt;/code&gt;, vous recevez la liste entière une fois l'inférence terminée. Pas de granularité objet par objet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;instructor&lt;/code&gt; règle exactement ce cas. Sa méthode &lt;code&gt;create_iterable&lt;/code&gt; parse le JSON streamé par le LLM au fil de l'eau et renvoie chaque objet pydantic dès qu'il est complet :&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;# src/proofreader/llm.py
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_litellm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;litellm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acompletion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_iterable&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="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# un seul objet, pas list[Mistake]
&lt;/span&gt;    &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;system&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;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;SYSTEM_PROMPT_STREAM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;language&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;role&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;user&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;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;markdown&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deux complications qui ne sautent pas aux yeux :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Le prompt change selon le mode.&lt;/strong&gt; Pour un &lt;code&gt;with_structured_output&lt;/code&gt; LangChain, on demande au LLM de renvoyer un objet wrapper avec une liste de Mistakes dedans. Pour &lt;code&gt;create_iterable&lt;/code&gt;, on lui demande d'émettre un seul Mistake JSON par tour de génération. Les deux prompts ne sont pas tout à fait les mêmes. Le projet maintient les deux côte à côte : LangChain pour le chemin Streamlit one-shot, &lt;code&gt;instructor&lt;/code&gt; pour le streaming FastAPI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Le streaming SSE en aval.&lt;/strong&gt; Chaque &lt;code&gt;Mistake&lt;/code&gt; émis est immédiatement repackagé en event Server-Sent Events côté FastAPI, puis envoyé au frontend. Le locator de la section suivante tourne &lt;em&gt;par-Mistake&lt;/em&gt;, donc l'utilisateur voit chaque rectangle rouge apparaître au fur et à mesure, pas en bloc à la fin.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Le retour sur PDF : quatre stratégies de fallback
&lt;/h2&gt;

&lt;p&gt;Pour chaque &lt;code&gt;Mistake&lt;/code&gt; qu'&lt;code&gt;instructor&lt;/code&gt; renvoie, j'ai un &lt;code&gt;error_text&lt;/code&gt;, un &lt;code&gt;correction&lt;/code&gt;, un &lt;code&gt;context_before&lt;/code&gt;, et une &lt;code&gt;description&lt;/code&gt;. Le LLM, lui, n'a jamais vu un seul pixel du PDF : il travaillait sur le Markdown extrait. Aucun champ ne contient des coordonnées.&lt;/p&gt;

&lt;p&gt;Or l'utilisateur veut voir les corrections sur le PDF d'origine, pas un texte plat dans une page de résultats. Donc il faut, pour chaque erreur, retrouver le mot dans le PDF.&lt;/p&gt;

&lt;p&gt;Du côté PDF, j'utilise PyMuPDF, qui me donne un &lt;em&gt;word stream&lt;/em&gt; : la liste de tous les mots de la page avec leurs &lt;code&gt;bbox&lt;/code&gt; (rectangles en points). Le problème devient : trouver la fenêtre &lt;code&gt;[mot1, mot2, …]&lt;/code&gt; dans cette liste. Sauf que le LLM et PyMuPDF ne tokenisent pas pareil, que les apostrophes typographiques ne sont pas alignées, et que sur un CV en deux colonnes le LLM hallucine parfois son &lt;code&gt;context_before&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;D'où quatre stratégies essayées dans l'ordre. Chacune rattrape un cas que la précédente ne sait pas gérer :&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;# src/proofreader/locator.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;locate_mistake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Word&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LocatedMistake&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;err_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;ctx_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context_before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 1: strict whole-word match.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_match_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 2: punctuation-tolerant (casefold + ASCII quotes + strip punct).
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_match_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 3: error_text alone if it appears exactly once on the page.
&lt;/span&gt;    &lt;span class="c1"&gt;# Catches LLM context drift in multi-column layouts.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_find_error_alone_if_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 4: substring of the concatenated normalised stream. Handles LLM
&lt;/span&gt;    &lt;span class="c1"&gt;# tokenisation drift like `d'une` → `d' + une`, where the standalone word
&lt;/span&gt;    &lt;span class="c1"&gt;# has no PyMuPDF token equivalent.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_find_error_as_substring_if_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pourquoi cet ordre exact :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Strict.&lt;/strong&gt; La fenêtre &lt;code&gt;context_before + error_text&lt;/code&gt; correspond au mot près, sans normalisation. Le cas heureux : le LLM cite le PDF parfaitement, correspondance exacte, zéro ambiguïté.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tolérant.&lt;/strong&gt; Le LLM capitalise le premier mot d'une phrase, ou remplace &lt;code&gt;'&lt;/code&gt; par &lt;code&gt;'&lt;/code&gt; (apostrophe typographique). &lt;code&gt;_normalize&lt;/code&gt; casefold le tout, remplace les guillemets et apostrophes typographiques par leur version ASCII, et retire la ponctuation que PyMuPDF colle aux tokens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Error-only unique.&lt;/strong&gt; Sur les CVs en deux colonnes, le &lt;code&gt;context_before&lt;/code&gt; que le LLM produit est parfois pioché dans la &lt;em&gt;mauvaise&lt;/em&gt; colonne (les modèles linéarisent maladroitement le multi-colonne). Si l'&lt;code&gt;error_text&lt;/code&gt; n'apparaît qu'une fois sur la page, on prend, peu importe le contexte. Ça suffit dans la quasi-totalité des cas.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substring du stream concaténé.&lt;/strong&gt; Cas tordu : &lt;code&gt;d'une&lt;/code&gt; est un mot pour le LLM, mais PyMuPDF le tokenise en &lt;code&gt;d'&lt;/code&gt; + &lt;code&gt;une&lt;/code&gt;. Le LLM peut renvoyer &lt;code&gt;error_text="une"&lt;/code&gt; comme mot isolé, sans token PyMuPDF correspondant. Solution : concaténer tous les tokens de la page en une seule chaîne et chercher en sous-chaîne. On filtre par &lt;code&gt;_MIN_SUBSTRING_CHARS = 5&lt;/code&gt;, parce que sans ça un &lt;code&gt;error_text="une"&lt;/code&gt; se retrouve dans &lt;code&gt;commune&lt;/code&gt;, &lt;code&gt;lacune&lt;/code&gt;, &lt;code&gt;tribune&lt;/code&gt;. Bonjour les faux positifs.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Si aucune des quatre n'attrape rien, l'erreur passe dans une section &lt;em&gt;« Non localisées »&lt;/em&gt; du résultat plutôt que d'être silencieusement perdue. Une erreur visible que l'utilisateur peut lire mais qui n'a pas son rectangle rouge, c'est moins grave qu'une erreur dont on prétend qu'elle est ailleurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bilan
&lt;/h2&gt;

&lt;p&gt;Si vous bricolez quelque chose de similaire, trois choses à retenir :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Une regex ne détecte pas les noms, entreprises ou dates. Il faut un détecteur entraîné.&lt;/li&gt;
&lt;li&gt;Si vous voulez streamer du structured output (objets pydantic au fil de l'eau, pas la liste entière à la fin), les libs habituelles ne suffisent pas. &lt;code&gt;instructor&lt;/code&gt; est conçu pour ça.&lt;/li&gt;
&lt;li&gt;Si le LLM travaille sur du texte extrait d'un document (PDF, OCR, scans), il vous rend des erreurs sans coordonnées. Vous devez les relocaliser après coup, et accepter que ce ne soit pas toujours possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;piighost&lt;/code&gt; règle le premier point. &lt;code&gt;instructor&lt;/code&gt; règle le deuxième. Le troisième m'a fait écrire ce projet, dont le code est ouvert.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application&lt;/strong&gt; : &lt;a href="https://piighost-proofreader.athroniaeth.cloud/" rel="noopener noreferrer"&gt;https://piighost-proofreader.athroniaeth.cloud/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;piighost&lt;/strong&gt; : &lt;a href="https://github.com/Athroniaeth/piighost" rel="noopener noreferrer"&gt;github.com/Athroniaeth/piighost&lt;/a&gt;, la lib d'anonymisation utilisée ici.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;piighost-proofreader&lt;/strong&gt; : &lt;a href="https://github.com/Athroniaeth/piighost-proofreader" rel="noopener noreferrer"&gt;github.com/Athroniaeth/piighost-proofreader&lt;/a&gt;, le projet complet, démo en ligne, locator inclus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues et PR bienvenues. Si vous travaillez sur du texte privé avec un LLM, les trois points ci-dessus vont probablement vous parler.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>privacy</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to let GPT-5.5 proofread a CV without leak it personal data</title>
      <dc:creator>Athroniaeth</dc:creator>
      <pubDate>Wed, 27 May 2026 16:09:06 +0000</pubDate>
      <link>https://dev.to/athroniaeth/how-to-let-gpt-55-proofread-a-cv-without-leak-it-personal-data-1apf</link>
      <guid>https://dev.to/athroniaeth/how-to-let-gpt-55-proofread-a-cv-without-leak-it-personal-data-1apf</guid>
      <description>&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;p&gt;Before you send out an important CV, you can hand it to an LLM for proofreading. A few seconds later, you have a list of mistakes. Except you've also just handed your name, your address, your employers and your dates to a third-party service.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;piighost-proofreader&lt;/code&gt; fixes that. The CV is anonymized locally before the LLM call, and the corrections find their way back onto the right word in the original PDF:&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%2Ft3se4bizceif9r62ox9r.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%2Ft3se4bizceif9r62ox9r.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The LLM never sees a name, a date, an address.&lt;/p&gt;

&lt;p&gt;Anonymization is the easy part. The painful bit is finding, back in the PDF, a word the LLM only ever saw as Markdown. And the LLM and PyMuPDF don't tokenize the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why not just a regex?
&lt;/h2&gt;

&lt;p&gt;First idea: before sending the CV to the LLM, you replace the sensitive data with one big regex. That works for emails and phone numbers, which have a recognizable format. For everything else, forget it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A name has no distinctive syntactic shape. &lt;code&gt;Paul Martin&lt;/code&gt; looks like any two capitalized words; nothing in the text tells a regex it's a name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Orange&lt;/code&gt; is a company. It's also a fruit. &lt;code&gt;Mars&lt;/code&gt;, &lt;code&gt;Apple&lt;/code&gt;, &lt;code&gt;Carrefour&lt;/code&gt;, same story.&lt;/li&gt;
&lt;li&gt;A date in a CV can be a birth, a degree, a job change. The format is identical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need a trained detector, not a pattern. &lt;code&gt;piighost&lt;/code&gt; provides one, and the call looks like this:&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;# src/proofreader/anonymize.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&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;self&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/anonymize&lt;/span&gt;&lt;span class="sh"&gt;"&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;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anonymized_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;thread_id&lt;/code&gt; is a UUID per CV. The entity→placeholder mapping stays server-side, scoped by that ID: the same name becomes the same placeholder on every occurrence.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Streaming the mistakes with &lt;code&gt;instructor&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A two-page CV holds a good fifteen mistakes, and the LLM takes several seconds to spit them out. Without streaming, the user stares at a loader the whole time. With it, the mistakes show up one by one as the model emits them.&lt;/p&gt;

&lt;p&gt;The catch: most structured-output libs (LangChain &lt;code&gt;with_structured_output&lt;/code&gt;, OpenAI Functions, Pydantic AI) return the &lt;em&gt;complete&lt;/em&gt; result. You ask for a &lt;code&gt;list[Mistake]&lt;/code&gt;, you get the whole list once inference is done. No object-by-object granularity.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;instructor&lt;/code&gt; is built for exactly this. Its &lt;code&gt;create_iterable&lt;/code&gt; method parses the LLM's streamed JSON on the fly and yields each pydantic object as soon as it's complete:&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;# src/proofreader/llm.py
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;instructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_litellm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;litellm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acompletion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_iterable&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="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# a single object, not list[Mistake]
&lt;/span&gt;    &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;system&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;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;SYSTEM_PROMPT_STREAM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;language&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;role&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;user&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;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;markdown&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two complications that aren't obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The prompt changes with the mode.&lt;/strong&gt; For LangChain's &lt;code&gt;with_structured_output&lt;/code&gt;, you ask the LLM to return a wrapper object with a list of Mistakes inside. For &lt;code&gt;create_iterable&lt;/code&gt;, you ask it to emit a single Mistake JSON per generation turn. The two prompts aren't quite the same. The project keeps both side by side: LangChain for the one-shot Streamlit path, &lt;code&gt;instructor&lt;/code&gt; for the FastAPI streaming path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The SSE streaming downstream.&lt;/strong&gt; Each &lt;code&gt;Mistake&lt;/code&gt; emitted is immediately repackaged into a Server-Sent Events event on the FastAPI side, then pushed to the frontend. The locator from the next section runs &lt;em&gt;per-Mistake&lt;/em&gt;, so the user watches each red rectangle pop up as it goes, not all at once at the end.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Back onto the PDF: four fallback strategies
&lt;/h2&gt;

&lt;p&gt;For each &lt;code&gt;Mistake&lt;/code&gt; &lt;code&gt;instructor&lt;/code&gt; yields, I have an &lt;code&gt;error_text&lt;/code&gt;, a &lt;code&gt;correction&lt;/code&gt;, a &lt;code&gt;context_before&lt;/code&gt;, and a &lt;code&gt;description&lt;/code&gt;. The LLM, though, never saw a single pixel of the PDF: it worked on the extracted Markdown. No field carries coordinates.&lt;/p&gt;

&lt;p&gt;But the user wants to see the corrections on the original PDF, not flat text on a results page. So for each mistake, I have to find the word back in the PDF.&lt;/p&gt;

&lt;p&gt;On the PDF side, I use PyMuPDF, which gives me a &lt;em&gt;word stream&lt;/em&gt;: the list of every word on the page with its &lt;code&gt;bbox&lt;/code&gt; (rectangles in points). The problem becomes: find the window &lt;code&gt;[word1, word2, …]&lt;/code&gt; in that list. Except the LLM and PyMuPDF don't tokenize the same way, the typographic apostrophes don't line up, and on a two-column CV the LLM sometimes hallucinates its &lt;code&gt;context_before&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hence four strategies tried in order. Each one catches a case the previous can't handle:&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;# src/proofreader/locator.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;locate_mistake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Word&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LocatedMistake&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;err_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;ctx_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context_before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 1: strict whole-word match.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_match_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 2: punctuation-tolerant (casefold + ASCII quotes + strip punct).
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_match_window&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 3: error_text alone if it appears exactly once on the page.
&lt;/span&gt;    &lt;span class="c1"&gt;# Catches LLM context drift in multi-column layouts.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_find_error_alone_if_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Strategy 4: substring of the concatenated normalised stream. Handles LLM
&lt;/span&gt;    &lt;span class="c1"&gt;# tokenisation drift like `d'une` → `d' + une`, where the standalone word
&lt;/span&gt;    &lt;span class="c1"&gt;# has no PyMuPDF token equivalent.
&lt;/span&gt;    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_find_error_as_substring_if_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_build_located&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mistake&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this exact order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Strict.&lt;/strong&gt; The &lt;code&gt;context_before + error_text&lt;/code&gt; window matches word for word, no normalization. The happy case: the LLM quotes the PDF perfectly, exact match, zero ambiguity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tolerant.&lt;/strong&gt; The LLM capitalizes the first word of a sentence, or swaps &lt;code&gt;'&lt;/code&gt; for &lt;code&gt;'&lt;/code&gt; (a typographic apostrophe). &lt;code&gt;_normalize&lt;/code&gt; casefolds everything, replaces curly quotes and apostrophes with their ASCII version, and strips the punctuation PyMuPDF glues onto tokens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Error-only unique.&lt;/strong&gt; On two-column CVs, the &lt;code&gt;context_before&lt;/code&gt; the LLM produces is sometimes lifted from the &lt;em&gt;wrong&lt;/em&gt; column (models linearize multi-column layouts clumsily). If &lt;code&gt;error_text&lt;/code&gt; appears exactly once on the page, take it, context be damned. That's enough in the vast majority of cases.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substring of the concatenated stream.&lt;/strong&gt; Nasty case: &lt;code&gt;d'une&lt;/code&gt; is one word to the LLM, but PyMuPDF tokenizes it as &lt;code&gt;d'&lt;/code&gt; + &lt;code&gt;une&lt;/code&gt;. The LLM may return &lt;code&gt;error_text="une"&lt;/code&gt; as a standalone word with no matching PyMuPDF token. Fix: concatenate all the page's tokens into a single string and search by substring. We gate on &lt;code&gt;_MIN_SUBSTRING_CHARS = 5&lt;/code&gt;, because without it an &lt;code&gt;error_text="une"&lt;/code&gt; shows up inside &lt;code&gt;commune&lt;/code&gt;, &lt;code&gt;lacune&lt;/code&gt;, &lt;code&gt;tribune&lt;/code&gt;. Cue the false positives.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If none of the four catches anything, the mistake lands in a &lt;em&gt;"Not located"&lt;/em&gt; section of the result instead of being silently dropped. A visible mistake the user can read but that has no red rectangle is less bad than a mistake we claim is somewhere it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're hacking on something similar, three things to remember:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A regex doesn't detect names, companies or dates. You need a trained detector.&lt;/li&gt;
&lt;li&gt;If you want to stream structured output (pydantic objects on the fly, not the whole list at the end), the usual libs won't cut it. &lt;code&gt;instructor&lt;/code&gt; is built for that.&lt;/li&gt;
&lt;li&gt;If the LLM works on text extracted from a document (PDF, OCR, scans), it hands you mistakes with no coordinates. You have to relocate them afterward, and accept it won't always be possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;piighost&lt;/code&gt; handles the first point. &lt;code&gt;instructor&lt;/code&gt; handles the second. The third is what made me write this project, whose code is open.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Example of app&lt;/strong&gt; : &lt;a href="https://piighost-proofreader.athroniaeth.cloud/" rel="noopener noreferrer"&gt;https://piighost-proofreader.athroniaeth.cloud/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;piighost&lt;/strong&gt;: &lt;a href="https://github.com/Athroniaeth/piighost" rel="noopener noreferrer"&gt;github.com/Athroniaeth/piighost&lt;/a&gt;, the anonymization lib used here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;piighost-proofreader&lt;/strong&gt;: &lt;a href="https://github.com/Athroniaeth/piighost-proofreader" rel="noopener noreferrer"&gt;github.com/Athroniaeth/piighost-proofreader&lt;/a&gt;, the full project, live demo, locator included.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues and PRs welcome. If you work with private text in an LLM, the three points above will probably ring a bell.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>privacy</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <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://dev.to/athroniaeth/piighost-a-python-library-for-pii-anonymization-in-llm-agents-183l</link>
      <guid>https://dev.to/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://dev.to/athroniaeth/piighost-une-librairie-python-danonymisation-de-donnees-confidentiels-pour-les-agents-llm-3c1i</link>
      <guid>https://dev.to/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>
