npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

pii-fr

Reversible PII pseudonymisation for French text, built for LLM calls.

CI License: MIT Node

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-fr

Node 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 reality

For 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 identity

Optional: 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_lg classifies French person names as ORG, a category Presidio ignores by default — so names are never detected, and nothing warns you. docker/presidio/ builds an image with fr_core_news_md and 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 value

Limits

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 nerDegraded before 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 redact when 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.
  • ORGANIZATION needs your dictionary. The bundled Presidio config drops the NER ORGANIZATION label — 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 build

The 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