pii-fr
v0.1.0
Published
Reversible PII pseudonymisation for French text, built for LLM calls: swap names, SIRET, IBAN, NIR and more for realistic fake data, then restore the real values in the model's answer.
Maintainers
Readme
pii-fr
Reversible PII pseudonymisation for French text, built for LLM calls.
You want to send a document to an LLM. The document contains a person's name, a phone number, a SIRET, an IBAN, a social security number. Sending it as-is is a GDPR problem. Stripping the values out leaves the model with unreadable holes.
pii-fr swaps every sensitive value for a realistic fake one, lets the model
reason over that, then puts the real values back in the answer.
in → Sophie Marchand (06 12 34 56 78) conteste une facture de 4 500 EUR
émise par Bâtiments Réunis (SIRET 73282932000074).
sent → Tancrède Renaud (05 80 01 12 58) conteste une facture de 4 500 EUR
émise par Dufour SASU (SIRET 70773626014119).
out → Sophie Marchand peut réclamer le remboursement des 4 500 EUR.Note what survived: the amount, the currency, the sentence structure, the phone number's shape, and a SIRET that still passes its checksum.
The provider never sees a real identity. The user never sees a fake one.
Why fake data instead of [PERSON_1]
Placeholder redaction is easier, and it costs you answer quality:
| | [PERSON_1] | pii-fr |
| ------------------------- | ---------------------------------- | ----------------------------------------- |
| Grammatical agreement | lost — the model cannot tell gender | preserved (civility prefixes are honoured) |
| Reasoning over the text | degraded: tokens carry no meaning | intact: it reads as ordinary French |
| Downstream validation | breaks ([SIRET_1] fails a checksum) | passes — fake SIRET/SIREN are Luhn-valid |
| Reversible | only via a stored table | yes, deterministically |
Amounts, dates and currencies are deliberately left alone: a model reasoning over altered figures is worse than no model at all.
Install
npm install pii-frNode 20+. One runtime dependency (@faker-js/faker). The NER layer is an
optional HTTP service — see Presidio below.
Quickstart
import { pseudonymize, depseudonymize, personEntity } from "pii-fr";
const { text, mapping } = await pseudonymize(
"Sophie Marchand (SIRET 73282932000074) réclame 4 500 EUR.",
{
scopeId: "case-42",
// Entities you already hold in your own database. This layer matters most:
// NER guesses, a dictionary knows. Names it does not cover are left to
// Presidio — and without Presidio, to nothing at all.
knownEntities: [personEntity("Sophie Marchand")],
}
);
const answer = await callYourModel(text); // the provider only sees fakes
return depseudonymize(answer, mapping); // the user only sees realityFor a real call you usually pseudonymise several strings against one shared mapping, so the same person keeps one identity across the system prompt, the question and any retrieved context:
import { pseudonymizeBatch } from "pii-fr";
const { texts, mapping, nerDegraded } = await pseudonymizeBatch(
[systemPrompt, question, retrievedChunk],
{ scopeId: "case-42", knownEntities: [personEntity("Sophie Marchand")] }
);
if (nerDegraded) throw new Error("NER unavailable — refusing to forward");Without a shared mapping, one person becomes three different people and the model
loses the thread. See examples/llm-call.ts for a
runnable end-to-end version (npx tsx examples/llm-call.ts, no API key needed).
Irreversible redaction
For output shared outside the organisation, use redact. Same detection, but
neutral labels and no mapping retained — the operation cannot be undone.
import { redact } from "pii-fr";
const { text, byKind } = await redact(document, { knownEntities });
// "PERSONNE_1 conteste la facture de SOCIETE_1 (SIRET SIRET_1)."
// byKind → { PERSON: 1, ORGANIZATION: 1, SIRET: 1 }One real value keeps one label throughout the document, so a reader can still
follow who is who — PERSONNE_1 is the same person on page one and page nine.
What gets detected
Three layers run on every text, in decreasing order of confidence. Overlaps
resolve to the longest match, so Jean Dupont beats Dupont.
| Layer | Source | Catches | | ---------------- | ------------------------------------- | ------------------------------------------ | | 1 — Dictionary | entities you pass in | known parties, including their short forms | | 2 — Regex | this library | structured French identifiers, checksummed | | 3 — NER | Presidio (optional) | everyone else: a witness, a third party |
French identifiers, validated
The regex layer does not just match shapes — it validates them. A 14-digit invoice reference is not reported as a SIRET.
| Entity | Validation |
| ------------- | ------------------------------------- |
| SIRET | 14 digits, Luhn |
| SIREN | 9 digits, Luhn, de-duplicated against SIRET |
| IBAN | French format, MOD-97 checksum |
| NIR | 15 digits, structural (sex, month, overseas ranges) |
| RPPS | 11 digits, requires a context keyword |
| PHONE | 0X/+33 formats, all separators |
| EMAIL | structural — tolerates the spaces PDF extraction inserts (a . b @ c . fr) |
| POSTAL_CODE | 5 digits, département must exist, town name required |
Emails are matched structurally rather than through NER on purpose: an email is unambiguously personal data and must never depend on an optional service being reachable.
Scope isolation and stability
scopeId seeds fake-value generation. Two properties follow, and both matter:
Isolation. The same person in two different scopes gets two different fake identities. Someone holding two pseudonymised corpora cannot correlate them.
await pseudonymize("Sophie Marchand", { scopeId: "case-1", knownEntities }); // → "Clovis Aubert"
await pseudonymize("Sophie Marchand", { scopeId: "case-2", knownEntities }); // → "Eudoxe Paris"Stability. Within a scope, a value always maps to the same fake — across
processes, across restarts, with nothing persisted. Fake values derive from
SHA-256(scope + value), not from a stored table.
That second property is what makes pseudonymised embeddings viable. Text indexed as "Sophie Marchand" and a question asked months later as "Mme Marchand" must produce the same fake value, or the two vectors never meet. Supplying variants collapses every designation onto one canonical identity:
knownEntities: [personEntity("Sophie Marchand")]
// generates: "Sophie Marchand", "Marchand", "M. Marchand", "Mme Marchand", …
// all → the same fake identityOptional: the NER layer
Dictionary and regex cover what you know and what has structure. Everything else
— a witness mentioned once, a judge, an unregistered third party — needs named
entity recognition. pii-fr talks to a Microsoft Presidio
analyser over HTTP.
docker compose up -d --build # builds an analyser with the French model
curl -s localhost:5002/health// Enabled by default, pointing at PRESIDIO_ANALYZER_URL or localhost:5002.
await pseudonymize(text, { scopeId: "case-42" });
// Explicit configuration:
await pseudonymize(text, { scopeId: "case-42" }, {
presidio: { url: "http://presidio:3000", minScore: 0.6, timeoutMs: 3000 },
});
// Fully local, no service:
await pseudonymize(text, { scopeId: "case-42" }, { presidio: false });The trap worth knowing about. The official Presidio image ships English only.
en_core_web_lgclassifies French person names asORG, a category Presidio ignores by default — so names are never detected, and nothing warns you.docker/presidio/builds an image withfr_core_news_mdand switches the configuration to French.
Degradation is reported, never silent
Presidio failures degrade instead of breaking: a timeout or an unreachable service resolves to zero NER detections and the pipeline falls back to dictionary
- regex. Recall drops; nothing throws.
That is the right default for availability and a trap for correctness — a degraded pass produces output that looks exactly as clean as a complete one. Every entry point therefore reports it:
const { text, mapping, nerDegraded } = await pseudonymize(document, context);
if (nerDegraded) {
// NER was expected and unreachable: persons only it could catch are still in
// the clear. Fail the request, or fall back to redact() — but do not forward.
throw new Error("PII coverage degraded");
}nerDegraded is false when you opt out with presidio: false — a deliberate
choice is not a failure. In that mode, coverage is exactly your dictionary plus
the regex layer, and unlisted names pass through untouched. Know which mode you
are in.
Audit without leaking
Logging a pseudonymisation pass should not itself become a PII leak.
import { mappingStats, safeMappingExport } from "pii-fr";
mappingStats(mapping); // { total: 6, byKind: { PERSON: 1, IBAN: 1, … } }
safeMappingExport(mapping); // kind + fakeValue + realValue.length — never the valueLimits
Stated plainly, because they decide whether this fits your risk model.
- Not a guarantee. Layers 1 and 2 are deterministic and dependable. Layer 3
is statistical: NER misses things, especially unusual names and names inside
malformed text. Anything you can supply through the dictionary, supply — and
check
nerDegradedbefore trusting a pass. - Free text is where leaks live. A person identifiable by description rather than by name ("the plaintiff's brother, the baker on the main square") is not detected by anything here.
- Reversal is a capability. The mapping is the key: hold it in memory for the
duration of a call and drop it. Persisting it recreates the risk you removed.
Use
redactwhen reversal is not needed. - Pseudonymisation, not anonymisation. Under GDPR, pseudonymised data is still personal data. This library reduces exposure to a third-party processor; it does not remove your obligations.
- French-first. The regex layer and the fake-data locale are French. The architecture is locale-agnostic, but the identifiers are not.
ORGANIZATIONneeds your dictionary. The bundled Presidio config drops the NERORGANIZATIONlabel — the false-positive rate is high enough to do more harm than good. Company names come from layer 1.
API
| Export | Purpose |
| ------------------------ | ---------------------------------------------------- |
| pseudonymize | replace entities with fake data, return a mapping |
| pseudonymizeBatch | several texts, one shared mapping |
| depseudonymize | restore real values using a mapping |
| redact | irreversible neutral labels (PERSONNE_1) |
| detect | detections only, no replacement — bring your own strategy |
| personEntity, nameVariants | build dictionary entries with French surface forms |
| dedupeEntities | merge dictionary entries from several sources |
| detectFrenchIdentifiers | the regex layer on its own |
| isLuhnValid, isIbanValid | the checksum validators |
| mappingStats, safeMappingExport | leak-free audit output |
| presidioHealthCheck | liveness probe for the analyser |
pseudonymize, pseudonymizeBatch and redact all return nerDegraded — see
above.
Full types are exported; every public function carries JSDoc.
Development
npm install
npm test # 110 tests, no service required
npm run typecheck
npm run buildThe test suite mocks the analyser, so it runs offline and deterministically. To
exercise the real NER layer, bring it up with npm run presidio:up and drop the
presidio: false option from examples/llm-call.ts.
Provenance
Extracted from the AI layer of a production legal-tech SaaS, where it sits
between the application and a hosted LLM so that client files never leave the
premises in the clear. The domain-coupled parts (database access, provider
wrappers, RAG plumbing) were replaced by the knownEntities interface; the
detection and substitution logic is the code that runs in production.
License
MIT © Bruce Mong-The
