-
Published on
-
Authors
-
Name
-
Petros Savvakis @PetrosSavvakis
How I Measured the PII Layer in Front of an LLM (And Version One Masked the Label, Not the Tax Number)
PII masking, GDPR, AI Act, "just put Presidio in front of it"... those are some of the keywords that come up every time someone talks about putting an LLM in a real-world system. All of that is valid, of course. The question is what it actually looks like when you stop treating it as a checkbox that works and start measuring it.
This is Part 1 of 7 of a series I'm calling From Prompt to Proof. I'll be publishing it on the blog before my PyCon Greece 2026 talk (a repo with all of this will be available soon), because the talk is 30 minutes and the interesting bits (the numbers, the mistakes, the "wait, that silently does nothing" parts) do not fit in 30 minutes.
The series is about the control plane around an LLM feature and around AI agents: identity, PII, policy as code, human in the loop, signed audit, tracing. The working example in the repo is QuoteBot, a small loan pricing assistant (the same patterns apply to insurance, healthcare, and anything else that is regulated). Every layer is a real package with tests. If a box on the architecture diagram has no package behind it, it's a claim, not a control.
Part 1 is Layer 2: mask before the model. And measure the masker, or you don't have one.
The failure that started this
A colleague pasted a customer record into a support prompt, because that's what the box is for. (No attack mode yet)
One paste and a Greek tax number (ΑΦΜ) is now sitting in a US inference log. That's a personal data transfer to a third country, and it didn't need a hacker.
So I put a detection layer between the request handler and the model client. Version one of that layer produced this:
Ο πελάτης με <ORGANIZATION> EL526018151
The Greek NER model tagged the letters ΑΦΜ — the label, the words "tax number" — as an organisation, at 85% confidence. So the masker masked the label.
It masked the words "tax number". And it let the tax number through.
Everything looked green. HTTP 200. Nothing looked like it failed, the request succeeded... and the tax number is still in the logs. The layer failed silently, which is the worst way for a compliance control to fail: there is nothing to alert on.
What Presidio actually is (and what the quickstart hides)
I used Microsoft Presidio. If you haven't touched it, it's two engines:
AnalyzerEngine walks the text with spaCy NER plus a stack of recognizers. Each hit is a span, an entity type, and ascore.** AnonymizerEngine**replaces those spans. The default operator already emits<ENTITY_TYPE>
placeholders, so<GR_AFM>
needs no extra config.
The English quickstart is three lines, and for emails / credit cards / people it actually works easily:
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
text = "Contact Jane Doe, visa 4012 8888 8888 1881, jane@acme.com"
hits = AnalyzerEngine().analyze(text=text, language="en")
print(AnonymizerEngine().anonymize(text, analyzer_results=hits).text)
Now point that at Greek.
It doesn't score zero. It scores wrong:
1.00 EMAIL_ADDRESS 'nikos@example.gr'
0.85 PERSON 'ζητάει προσφορά'
0.05 US_SSN '526018151'
0.05 US_PASSPORT '526018151'
0.05 US_BANK_NUMBER '526018151'
ζητάει προσφορά
means "asks for a quote". The English pipeline tagged a verb phrase as a person, at 85% confidence. And the tax number came back as three American identifiers at 5%, which is below any threshold you would ever set, so in practice it is invisible.
Ask that same engine for language="el"
and it doesn't fail quietly at all:
ValueError: No matching recognizers were found to serve the request.
Presidio ships no predefined recognizers for el
, and RecognizerRegistry
infers its language list from the predefined set, so you have to pass supported_languages=["el"]
yourself or you get ValueError: Misconfigured engine, supported languages have to be consistent
. A default English pipeline on Greek text is not a PII layer.
For an ΑΦΜ you write a recognizer. Presidio gives you three signals to combine, and combining them is the whole game:
- a
pattern(the shape of the number) - a
checksum hook(
validate_result()
) - a
context word enhancer that reads the surrounding tokens through spaCy (ΑΦΜ
,tax
,vat
,tin
, ...)
from presidio_analyzer import Pattern, PatternRecognizer
class GrAfmRecognizer(PatternRecognizer):
def __init__(self, supported_language: str) -> None:
super().__init__(
supported_entity="GR_AFM",
patterns=[Pattern("afm-9-digits", r"\b[0-9]{9}\b", 0.3)],
context=["ΑΦΜ", "afm", "tax", "vat", "tin"],
supported_language=supported_language,
)
A nine digit string is only eligible. Context decides how confident you are. That's the design. The rest is measuring whether it holds.
The one return value that defines the whole layer
validate_result()
can return True
, False
, or None
. I treated this as a detail. It is not a detail.
Return True
on a valid checksum and Presidio forces the score to 1.0. Detection becomes binary. Context stops mattering. Sounds great... until you measure it.
9.95% of uniformly random nine digit numbers pass the Greek tax checksum. Roughly one in ten order numbers, reference codes and batch IDs in a business document would get masked as a tax number. That's not a PII layer. That's a random redactor.
So this returns None
on a pass and False
on a fail. The checksum can only eliminate candidates. Context sets the score.
def validate_result(self, pattern_text: str) -> bool | None:
return None if afm.is_valid(afm.normalise(pattern_text)) else False
| Signal | Score |
|---|---|
| nine digits, checksum fails | candidate removed |
| nine digits, checksum passes, no context word | 0.30 |
nine digits, checksum passes, ΑΦΜ / tax id nearby |
0.65 |
Detection is a score, not a boolean. Somebody has to pick the threshold. That choice is a compliance decision. So the threshold goes in the audit record, next to the ruleset version. If you can't say which detector, at which threshold decided the text was clean, you don't have evidence. You have a log line that says "masked: true".
Then I actually measured it
I stopped trusting it and labelled 30 Greek and English sentences, with deliberate decoys: phone numbers, an IBAN, an AMKA, and a nine digit order number that happens to pass the checksum. (That's the 1 in 10 accident from above. Checksum only detection cannot tell them apart.)
threshold precision recall TP FP FN TN
0.3 0.80 1.00 16 4 0 10
0.5 1.00 1.00 16 0 0 14
0.7 1.00 0.00 0 0 16 14
Look at the last row. Threshold 0.7. Precision 1. Recall zero.
The highest score this layer can physically emit is 0.65 (base 0.30 + context boost 0.35). Set the threshold to 0.7 the change a reasonable person makes when they are told to be stricter and the PII layer detects nothing. It does not error. It does not warn. It returns clean text and a green request, and every tax number in your traffic goes to the model.
That sweep is a test, not a notebook I ran once. It fails below precision 1 on every pytest
run, so the numbers cannot quietly rot. That's the closest thing I have to "risk management" that isn't a PDF.
One caveat, said plainly: these numbers hold on 30 sentences I wrote. They are not a claim about your corpus. Measure on held out data from your own domain, or you are doing the same thing I did in v1, just with better slides.
One pattern is never one format
The first recognizer used \b[0-9]{9}\b
. Reality writes the same number four ways:
| Input | v1 |
|---|---|
ΑΦΜ 526018151 |
0.65 |
ΑΦΜ 526 018 151 |
leak |
ΑΦΜ 526-018-151 |
leak |
ΑΦΜ EL526018151 (the EU VAT form on a Greek invoice) |
the opening line of this post |
Three things closed it:
Normalise before the checksum, don't widen the checksum. Strip spaces, hyphens, theEL
prefix.is_valid()
still only accepts nine canonical digits.Mandatory separators in the grouped pattern. Make them optional and you start matching IBAN blocks and 3-3-4 phone numbers that live in the same documents.An entity allow list. Mask the five things this service decided to mask (GR_AFM
, email, IBAN, person, phone), not whatever the NER model happened to emit.ORGANIZATION
is out because of the label bug. Dates, cities, ages are out for a product reason: you cannot price a loan without them. Masking everything youcanis not a compliance win. It's a broken feature.
One more leak, and this one is invisible. Greek Α
is U+0391. Latin A
is U+0041. Same pixels. (This one was found by Claude, not by me, which I think is cool catch.) With only the Greek spelling in the context list, writing AΦΜ
with a Latin A drops the score from 0.65 to 0.30, below the threshold. One character you cannot see, tax number on the wire.
The attack surface is enumerable, so the fix is one line:
AFM_SPELLINGS = tuple(a + "Φ" + m for a in "ΑA" for m in "ΜM")
What this actually hands to the next layers
The job of this layer is not "mask a string". It's to produce a decision record that policy and the audit trail can both consume:
PiiFindings(entity_types, count, max_score, threshold, ruleset_version)
If personal data was detected, policy routes the request to a model inside the EU (or on premise, if the company runs its own infrastructure for the models). That's not an if
in the handler. It's a verdict, and it gets signed. ruleset_version
is gr-pii-2026.08.2
today; it moved because the patterns and the context list moved. That field exists so an audit record from last week cannot be confused with one from today.
You can (soon, in this series) prove which policy allowed an action and which prompt produced the output. You should also be able to prove which detector decided the text was clean.
And one honest sentence, because this gets hand waved constantly: this is pseudonymisation, not anonymisation. There is a reidentification path, so it is still personal data under GDPR Recital 26. Presidio's encrypt
operator is reversible by design. It's a control. It's not a legal opinion. Presidio does let you write custom operators, so you can plug in your own scheme.
Final Thoughts
If you are putting an LLM or an agent in front of real users and "PII masking" is a slide in the deck, here's my advice:
Don't trust the quickstart. Presidio is solid. The English demo is not a Greek (or any custom entity) layer. You will write recognizers, and you have to test them.Don't return unless you have measured the false positive rate onTrue
from the checksumyourdocuments. Mine was ~10%.Detection is a score. Pick a threshold on purpose, put it in the audit record, and put a floor in your test suite. "Be stricter" can silently detect nothing. This is also the place where you want input from audit or legal before you settle on a cutoff.Allow list the entities. NER will mask the wrong thing and let the right thing through.Measure on your own corpus. My 30 sentences prove my layer. They do not prove yours though.
Next up: Part 2 — signed audit trail. Auditable doesn't mean you have logs. It means you can prove them.
The rest of the plane, in the order I'll publish it (Cedar, OPA, Microsoft Foundry etc are on the following layers):
- Layer 7 — signed audit trail
- Layer 3 — policy as code
- Layer 4 — model and prompt pinning
- Layer 6 — human in the loop
- Layer 8 — tracing
- Layers 1 + 5 — identity and the output guard
Code, tests, and the sweep that produced these numbers will be in the repo, which goes public before the talk.
Sources
Microsoft Presidio— AnalyzerEngine + AnonymizerEngine- QuoteBot / From Prompt to Proof repo — the layer, the recognizer,
tests/test_eval.py
(link added once the repo is public) - GDPR Recital 26 — pseudonymisation is still personal data
- EU AI Act Article 9 / Article 10 — risk management and data governance, as a pytest assertion rather than a PDF
Disclaimer
This article is based on my personal work on the open source control plane I'm presenting at PyCon Greece 2026. The example, the numbers, and the mistakes are from that repo. They reflect my specific use case (Greek AFM detection in front of a local model) and may vary for different languages, entity types, or corpora. This is not legal advice, and it is not the internal process of any employer. I did not receive any money or incentives for mentioning Presidio or any other tool in this article.