Files
anonymizer/README.md
Mobiletic 5ed8001101 feat: multi-turn conversation support (0.4.0)
anonymizeTurn(text, session) threads a serializable AnonymizerSession
({mapping, legend, history}) so one entity keeps one id across a whole chat
(applyKnown reuse + usedIds + de-collision merge). Adds conversation()
in-memory wrapper and an optional LlmProvider.anonymizeInConversation(text, ctx)
for rich cross-turn context; providers without it fall back to the batch path.

openAICompatibleProvider gains includeMappingInContext (default false — only
send real values to a trusted anonymizer endpoint) + historyMaxTurns (default
10). Verified live vs Gemma 4: Nora stays PER_1 across 4 turns (name/email/AVS/
IBAN), Yanis = PER_2; 41 tests, 98% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:26:41 +01:00

421 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @mobiletic/anonymizer
[![CI](https://git.mobiletic.net/mobiletic/anonymizer/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.mobiletic.net/mobiletic/anonymizer/actions)
[![npm version](https://img.shields.io/npm/v/@mobiletic/anonymizer.svg)](https://www.npmjs.com/package/@mobiletic/anonymizer)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
Framework-agnostic **PII anonymization & pseudonymization** for TypeScript/JavaScript.
It replaces personal data in text with stable placeholders before the text leaves your trust boundary
(e.g. before sending it to a third-party LLM, log sink, or analytics pipeline), and restores the real
values afterwards — including across **streamed** tokens.
- 🔌 **Pluggable LLM detection** — catch free-form PII (names, addresses) via any OpenAI-compatible
endpoint, or your own provider.
- 🧩 **Optional regex fallback** — structured identifiers (email, phone, IBAN, …) via configurable
presets (`swiss`, `generic`) or your own. Opt in for graceful degradation, or omit it to **fail closed**.
- 🏷️ **Self-describing tokens** — every result ships a `legend` (`PER``Personne`, `M``Masculin`) you can
hand to the downstream LLM so it understands the placeholders; the model may coin new abbreviations too.
- 🔁 **Deterministic coreference** — the same person keeps the same id (`[PER_1]`) across a question and
every retrieved chunk, with automatic de-collision.
- 🌊 **Streaming-safe** — a placeholder split across two stream chunks (`[PER_` + `1.NOM:M]`) is never
leaked partially.
- 🪶 **Zero runtime dependencies**, ESM + CJS, fully typed.
> Built by [Mobiletic](https://mobiletic.com).
## Install
```bash
npm install @mobiletic/anonymizer
```
Requires Node ≥ 18 (uses native `fetch`).
## Quick start
### Regex-only (no LLM, fully deterministic)
```ts
import { Anonymizer, presets } from '@mobiletic/anonymizer';
const anonymizer = new Anonymizer({ patterns: presets.swiss });
const { anon, mapping, legend } = await anonymizer.anonymize('Écris à jean@exemple.ch');
// anon -> "Écris à [EMAIL_1]"
// mapping -> { "[EMAIL_1]": "jean@exemple.ch" } (secret — keep on your side)
// legend -> { "EMAIL": "Adresse e-mail" } (safe to share downstream)
anonymizer.deanonymize(anon, mapping); // -> "Écris à jean@exemple.ch"
```
### With an LLM (also catches names, addresses…)
```ts
import { Anonymizer, openAICompatibleProvider, presets } from '@mobiletic/anonymizer';
const anonymizer = new Anonymizer({
llm: openAICompatibleProvider({
baseUrl: process.env.LLM_BASE_URL!, // OpenAI, Infomaniak, vLLM, Ollama, …
apiKey: process.env.LLM_API_KEY!,
model: process.env.LLM_MODEL!,
timeoutMs: 3000,
}),
patterns: presets.swiss, // OPTIONAL regex fallback if the LLM is down/misbehaves
});
const { anon, mapping, legend } = await anonymizer.anonymize('Le dossier de Alain Jaccard est complet.');
// anon -> "Le dossier de [PER_1.NOM:M] est complet."
// legend -> { "PER": "Personne", "NOM": "Nom de famille", "M": "Masculin" }
```
**Fallback is opt-in (fail-closed).** If a `patterns` fallback is configured, a failed/timed-out/invalid
LLM call degrades to the regex engine. If you omit `patterns`, there's nothing to degrade to, so the call
**throws an `AnonymizationError`** (with the underlying error as `.cause`) — it never silently returns
un-anonymized text. With no fallback the pre-filter is also bypassed, so every non-trivial call consults
the LLM (more calls, no leaks). At least one of `llm` or `patterns` is required.
`openAICompatibleProvider` retries **transient** failures (network error, timeout, HTTP 429/5xx) before
giving up; 4xx and malformed responses are not retried. Tune with `timeoutMs` (per attempt, default 3000),
`retries` (default 1 → 2 attempts), and `retryDelayMs` (linear backoff, default 250). Worst-case latency
is `(retries + 1) × timeoutMs`, so keep `retries` low on latency-sensitive paths.
It works with **Infomaniak** and other open-model endpoints out of the box: `response_format` is **omitted
by default** (Infomaniak rejects the legacy `{ type: 'json_object' }`), and responses are parsed leniently
(a fenced JSON code block or surrounding prose is tolerated). For endpoints that support it, opt in with
`responseFormat` — e.g. `{ type: 'json_object' }` or a `json_schema` object.
### Streaming de-anonymization
When you stream an LLM answer back to a user, restore real values without ever emitting a half-written
placeholder:
```ts
const stream = anonymizer.makeStreamDeanonymizer(mapping);
for await (const token of llmTokens) process.stdout.write(stream.push(token));
process.stdout.write(stream.flush());
```
### Anonymizing retrieved chunks consistently (RAG)
`anonymizeChunks(chunks, seed)` reuses the question's `{ mapping, legend }` so the same person gets the
same id across the question and every chunk, batches the LLM call, and de-collides genuinely new entities:
```ts
const q = await anonymizer.anonymize(question);
const { anon, mapping, legend } = await anonymizer.anonymizeChunks(retrievedChunks, q);
// `mapping`/`legend` are the full question chunks tables;
// pass `mapping` to deanonymize()/makeStreamDeanonymizer(), and `legend` to the downstream LLM.
```
### Multi-turn conversations
In a chat, anonymizing each message independently would renumber entities every turn (Adil could become
`PER_1` in turn 2 while Oussama was `PER_1` in turn 1). `anonymizeTurn` threads a serializable
`AnonymizerSession` so **one entity keeps one id across the whole conversation**:
```ts
let session; // persist this per conversation (Redis/DB); pass it back each turn
for (const message of userTurns) {
const turn = await anonymizer.anonymizeTurn(message, session);
session = turn.session; // { mapping, legend, history } — carries coreference forward
send(turn.anon, turn.legend); // → the downstream chatbot (placeholders only)
}
// restore the bot's placeholder-bearing reply for the user:
anonymizer.deanonymize(botReply, session.mapping);
```
Or the stateful wrapper for in-memory use:
```ts
const conv = anonymizer.conversation();
await conv.anonymize('Bonjour, je suis Oussama'); // → Oussama = [PER_1…]
await conv.anonymize('Mon amie Adil …'); // → Oussama stays [PER_1…], Adil = [PER_2…]
conv.deanonymize(botReply);
```
Under the hood: known values are re-substituted locally (`applyKnown`), the provider is told which ids are
taken, and new entities are de-collided into the session — so numbering never drifts.
**Trust boundary & the `mapping`.** The real boundary is "keep PII out of the _downstream chatbot_" — it
only ever receives placeholders + `legend`. Your **anonymizer** endpoint already sees the cleartext it's
asked to anonymize, so if (and only if) it's a _trusted_ processor you may give it richer context — set
`includeMappingInContext: true` on `openAICompatibleProvider` to also send the `mapping` for maximum
cross-turn accuracy. It's **off by default**; keep it off for third-party endpoints you don't trust with
raw values.
## Configuration
```ts
new Anonymizer({
llm?, // LlmProvider — omit for regex-only mode
patterns?, // PatternDef[] — opt-in regex fallback; omit to fail closed
nameHint?, // RegExp flagging likely names so the LLM is consulted (has a default)
logger?, // { warn(msg) } — receives fallback warnings; defaults to no-op
});
// At least one of `llm` or `patterns` must be provided, or the constructor throws.
```
### Presets & custom patterns
```ts
import { presets } from '@mobiletic/anonymizer';
presets.swiss; // AVS, IBAN CH, EMAIL, Swiss phone, DATE
presets.generic; // EMAIL, IBAN, credit card, IPv4, phone, DATE
// Compose / extend:
const patterns = [
...presets.generic,
{ tag: 'TICKET', re: /\bJIRA-\d+\b/g }, // patterns must use the global flag
];
```
Each pattern may carry an optional `validate(match) => boolean` second stage — a match is only redacted
if it passes. `presets.generic` uses it for a [Luhn](https://en.wikipedia.org/wiki/Luhn_algorithm) check
so arbitrary long digit runs aren't mistaken for credit cards:
```ts
{ tag: 'CREDIT_CARD', re: /\b\d(?:[ -]?\d){12,18}\b/g, validate: luhnValid }
```
> The `generic` preset is a **best-effort** starting point — broad patterns (phone, date, card) can
> overlap. For production use, prefer a locale-specific preset (`presets.swiss`) or your own patterns.
### Custom LLM provider
Implement `LlmProvider` to use any backend (Anthropic, a local model, a rules engine…):
```ts
import type { LlmProvider } from '@mobiletic/anonymizer';
const myProvider: LlmProvider = {
isConfigured: () => true,
async anonymize(text) {
/* return { anon, mapping, legend } */
},
async anonymizeBatch(texts, usedIds) {
/* return { segments, mapping, legend } */
},
};
```
## Placeholder format
```
[PER_1.NOM:M] entity PER #1, attribute NOM, context M (rich, from the LLM)
[EMAIL_1] structured id from the regex fallback
```
`PLACEHOLDER_RE` is exported if you need to scan text for placeholders. The LLM may also **coin new
abbreviations** (always uppercase `[A-Z_]`) for entities/attributes/context it discovers — every one it
uses is described in the result `legend`.
## How it works
The library does **query-time pseudonymization**: it rewrites text so that personal data never leaves your
trust boundary in identifiable form, while keeping the answer fully reversible on your side.
```
┌───────────────────────────── your trust boundary ──────────────────────────────┐
raw text ─▶ ① pre-filter ─▶ ② LLM detect ─▶ ③ regex fallback ─▶ ④ validate ─▶ anon + mapping + legend
(skip if (names, (optional; (every │ │
clearly addresses, structured ids; placeholder ┌───────┘ │
PII-free) coins abbrevs, fail closed if is mapped) ▼ ▼
builds legend) absent) mapping legend
(SECRET, (shareable:
downstream LLM ◀── anon + legend ───────────────────────────────────────────── reversible) PER=Personne…)
answer (with [PER_1.NOM:M] tokens)
⑤ stream de-anonymize ──▶ real values restored for the end user (placeholders never leak, even if split)
```
1. **Pre-filter** — a cheap regex/name check skips the LLM round-trip for text that clearly has no PII.
(Bypassed when no regex fallback is configured, so nothing slips through.)
2. **LLM detection** — finds free-form PII a regex can't (names, addresses), keeps **coreference** (the
same person is always `[PER_1]`), coins uppercase abbreviations for anything new, and returns a
**legend** describing them.
3. **Regex fallback** — optional, deterministic detection of structured identifiers; used if the LLM is
unavailable. Omit it to **fail closed** (raise `AnonymizationError` rather than risk a leak).
4. **Validation** — bidirectional check that every placeholder has a mapping entry and vice-versa.
5. **Streaming de-anonymization**`makeStreamDeanonymizer` restores real values token-by-token, buffering
any placeholder split across chunks so a partial `[PER_` is never emitted.
Three distinct outputs, with different sensitivities:
| Output | Example | Sensitivity |
| --------- | ---------------------------------------- | ------------------------------------------------------- |
| `anon` | `Le dossier de [PER_1.NOM:M]` | Safe to send onward — no identifiers |
| `mapping` | `{ "[PER_1.NOM:M]": "Alain Jaccard" }` | **Secret** — re-identifies people; keep it on your side |
| `legend` | `{ "PER": "Personne", "M": "Masculin" }` | Safe to share — explains the tokens to a downstream LLM |
## Examples
Real learner ↔ platform chat messages, anonymized **live by Gemma 4** (via Infomaniak) with no regex
fallback. All personal data below is **fictional**. `mapping` is the secret re-identification key (kept by
the operator); `legend` is safe to send to the downstream model. Every case restores identically.
| # | Scenario | Sensitive data detected | Notable capability | Round-trip |
| --- | ----------------------- | --------------------------------------------------- | ----------------------------------------- | ---------- |
| 1 | Course signup | name, e-mail | baseline detection | ✅ |
| 2 | Login problem | + username, phone, signup date | coins `[PER_1.ID_USER:LOGIN]` on the fly | ✅ |
| 3 | Billing / IBAN change | + address, **two IBANs**, AVS | old vs new IBAN kept **distinct** | ✅ |
| 4 | HR enrolls staff | **3 people** + org, e-mails, phones, DOB, IBAN | coreference (same person → same id) + org | ✅ |
| 5 | Parent + minor + health | 3 people, DOB, address, contacts, IBAN, maiden name | minor/guardian/doctor kept distinct | ✅ |
### Case 1 — course signup (baseline)
**User message:**
> Bonjour, je suis Jean Dupont et mon adresse e-mail est jean.dupont@yopmail.com. Je viens de m'inscrire à la formation « Bureautique de base » et je voulais confirmer que tout est en ordre. Merci d'avance !
**Anonymized — what the model sees:**
> Bonjour, je suis [PER_1.PRENOM:M] [PER_1.NOM:M] et mon adresse e-mail est [PER_1.EMAIL:PERSO]. Je viens de m'inscrire à la formation « Bureautique de base » et je voulais confirmer que tout est en ordre. Merci d'avance !
| `mapping` 🔒 (secret) | value | | `legend` 🏷️ (shareable) | meaning |
| --------------------- | ----------------------- | --- | ----------------------- | ------------------ |
| `[PER_1.PRENOM:M]` | Jean | | `PER` | Personne |
| `[PER_1.NOM:M]` | Dupont | | `PRENOM` / `NOM` | Prénom / Nom |
| `[PER_1.EMAIL:PERSO]` | jean.dupont@yopmail.com | | `EMAIL` · `M` · `PERSO` | E-mail · M · Perso |
Note the course name « Bureautique de base » is **left intact** — it isn't personal data.
### Case 4 — HR enrolls staff (coreference + organization)
**User message:**
> Bonjour, je suis Sophie Meyer, responsable formation chez Nestlé Suisse (sophie.meyer@nestle.com, +41 21 924 11 11). Je souhaite inscrire deux collaborateurs à la formation « Sécurité au travail » qui débute le 03/03/2026 : Marc Rossi, né le 12.03.1990 (marc.rossi@nestle.com), et Amélie Girard, joignable au 078 321 65 43. La facture est à adresser à notre comptabilité, IBAN CH93 0076 2011 6238 5295 7. Marc Rossi avait déjà suivi une formation l'an dernier — pouvez-vous réactiver son ancien compte plutôt que d'en créer un nouveau ?
**Anonymized — what the model sees** (note **Marc Rossi → `PER_2` in both mentions**, and the org as `ORG_1`):
> Bonjour, je suis [PER_1.PRENOM:F] [PER_1.NOM:F], responsable formation chez [ORG_1.NOM:Entreprise] ([PER_1.EMAIL:F], [PER_1.TELEPHONE:F]). Je souhaite inscrire deux collaborateurs à la formation « Sécurité au travail » qui débute le 03/03/2026 : [PER_2.PRENOM:M] [PER_2.NOM:M], né le [PER_2.DATE_NAISSANCE:1990] ([PER_2.EMAIL:M]), et [PER_3.PRENOM:F] [PER_3.NOM:F], joignable au [PER_3.TELEPHONE:F]. La facture est à adresser à notre comptabilité, IBAN [ORG_1.IBAN:Comptabilite]. [PER_2.PRENOM:M] [PER_2.NOM:M] avait déjà suivi une formation l'an dernier — pouvez-vous réactiver son ancien compte plutôt que d'en créer un nouveau ?
**`mapping` 🔒 (secret — kept on your side):**
| Placeholder | Real value |
| ----------------------------- | -------------------------- |
| `[PER_1.PRENOM:F]` | Sophie |
| `[PER_1.NOM:F]` | Meyer |
| `[ORG_1.NOM:Entreprise]` | Nestlé Suisse |
| `[PER_1.EMAIL:F]` | sophie.meyer@nestle.com |
| `[PER_1.TELEPHONE:F]` | +41 21 924 11 11 |
| `[PER_2.PRENOM:M]` | Marc |
| `[PER_2.NOM:M]` | Rossi |
| `[PER_2.DATE_NAISSANCE:1990]` | 12.03.1990 |
| `[PER_2.EMAIL:M]` | marc.rossi@nestle.com |
| `[PER_3.PRENOM:F]` | Amélie |
| `[PER_3.NOM:F]` | Girard |
| `[PER_3.TELEPHONE:F]` | 078 321 65 43 |
| `[ORG_1.IBAN:Comptabilite]` | CH93 0076 2011 6238 5295 7 |
**`legend` 🏷️ (shareable — sent to the downstream model):** `PER`=Personne, `PRENOM`=Prénom, `NOM`=Nom de
famille, `F`=Féminin, `M`=Masculin, `ORG`=Organisation, `EMAIL`=Adresse e-mail, `TELEPHONE`=Numéro de
téléphone, `DATE_NAISSANCE`=Date de naissance, `IBAN`=Numéro de compte bancaire.
<details>
<summary><b>More examples — Case 2 (login), Case 3 (two IBANs), Case 5 (minor + health)</b></summary>
#### Case 2 — login problem (coins an abbreviation for the username)
**User message:**
> Salut, moi c'est Marie Favre. J'ai créé mon compte avec l'e-mail marie.favre@bluewin.ch le 15/02/2026 mais je n'arrive plus à me connecter. Mon identifiant est mfavre et vous pouvez me joindre au 079 456 78 90. Pouvez-vous réinitialiser mon accès à la formation « Machiniste » ?
**Anonymized:**
> Salut, moi c'est [PER_1.PRENOM:F] [PER_1.NOM:F]. J'ai créé mon compte avec l'e-mail [PER_1.EMAIL:PERSO] le [PER_1.DATE_CREATION:2026] mais je n'arrive plus à me connecter. Mon identifiant est [PER_1.ID_USER:LOGIN] et vous pouvez me joindre au [PER_1.TELEPHONE:MOBILE]. Pouvez-vous réinitialiser mon accès à la formation « Machiniste » ?
| Placeholder | Real value |
| ---------------------------- | ---------------------- |
| `[PER_1.PRENOM:F]` | Marie |
| `[PER_1.NOM:F]` | Favre |
| `[PER_1.EMAIL:PERSO]` | marie.favre@bluewin.ch |
| `[PER_1.DATE_CREATION:2026]` | 15/02/2026 |
| `[PER_1.ID_USER:LOGIN]` | mfavre |
| `[PER_1.TELEPHONE:MOBILE]` | 079 456 78 90 |
`ID_USER` and `DATE_CREATION` are **coined by the model** — not in the base vocabulary — and documented in
the legend.
#### Case 3 — billing / IBAN change (two different IBANs kept distinct)
**User message:**
> Bonjour, je m'appelle Luc Berset, domicilié au 14 avenue de la Gare, 1700 Fribourg. J'ai un souci avec le paiement de la formation « Comptabilité PME » (CHF 1'200.) : mon IBAN CH93 0076 2011 6238 5295 7 n'est plus valide, je souhaite le remplacer par CH56 0483 5012 3456 7800 9. Si besoin, mon numéro AVS est le 756.1234.5678.90. Merci de mettre à jour mon dossier.
**Anonymized** (old and new IBAN get **distinct** placeholders via context):
> Bonjour, je m'appelle [PER_1.PRENOM:M] [PER_1.NOM:M], domicilié au [PER_1.ADRESSE:Rue], [PER_1.ADRESSE:NPA] [PER_1.ADRESSE:Ville]. J'ai un souci avec le paiement de la formation « Comptabilité PME » (CHF 1'200.) : mon IBAN [PER_1.IBAN:Ancien] n'est plus valide, je souhaite le remplacer par [PER_1.IBAN:Nouveau]. Si besoin, mon numéro AVS est le [PER_1.AVS:Suisse]. Merci de mettre à jour mon dossier.
| Placeholder | Real value |
| ----------------------- | -------------------------- |
| `[PER_1.PRENOM:M]` | Luc |
| `[PER_1.NOM:M]` | Berset |
| `[PER_1.ADRESSE:Rue]` | 14 avenue de la Gare |
| `[PER_1.ADRESSE:NPA]` | 1700 |
| `[PER_1.ADRESSE:Ville]` | Fribourg |
| `[PER_1.IBAN:Ancien]` | CH93 0076 2011 6238 5295 7 |
| `[PER_1.IBAN:Nouveau]` | CH56 0483 5012 3456 7800 9 |
| `[PER_1.AVS:Suisse]` | 756.1234.5678.90 |
#### Case 5 — parent, minor child and health (dense coreference)
**User message:**
> Bonjour, je vous écris au sujet de mon fils, Lucas Favre, né le 04.07.2011, que j'aimerais inscrire à la formation junior « Robotique » à Lausanne. Étant mineur, c'est moi, sa mère Camille Favre, qui gère le dossier — vous pouvez me joindre au 021 555 12 34 ou à camille.favre@bluewin.ch, nous habitons au 8 chemin des Vignes, 1009 Pully. Lucas est asthmatique ; son médecin, le Dr Nadia Benali (cabinet à Renens), a établi un certificat le 15.05.2024. Par ailleurs, j'avais moi-même suivi la formation « Photographie » en 2023 sous mon nom de jeune fille, Camille Rochat — mes deux comptes peuvent-ils être fusionnés ? Le paiement se fera depuis mon IBAN CH56 0483 5012 3456 7800 9.
**Anonymized** (son = `PER_1`, mother = `PER_2` incl. her maiden name, doctor = `PER_3`):
> Bonjour, je vous écris au sujet de mon fils, [PER_1.PRENOM:M] [PER_1.NOM:M], né le [PER_1.DATE_NAISSANCE:2011], que j'aimerais inscrire à la formation junior « Robotique » à [LOC_1.VILLE:Lausanne]. Étant mineur, c'est moi, sa mère [PER_2.PRENOM:F] [PER_2.NOM:F], qui gère le dossier — vous pouvez me joindre au [PER_2.TELEPHONE:Fixe] ou à [PER_2.EMAIL:Privé], nous habitons au [PER_2.ADRESSE:Rue], [PER_2.ADRESSE:NPA] [PER_2.ADRESSE:Ville]. [PER_1.PRENOM:M] est asthmatique ; son médecin, le Dr [PER_3.PRENOM:F] [PER_3.NOM:F] (cabinet à [LOC_2.VILLE:Renens]), a établi un certificat le 15.05.2024. Par ailleurs, j'avais moi-même suivi la formation « Photographie » en 2023 sous mon nom de jeune fille, [PER_2.PRENOM:F] [PER_2.NOM_JEUNE_FILLE:F] — mes deux comptes peuvent-ils être fusionnés ? Le paiement se fera depuis mon IBAN [PER_2.IBAN:Principal].
| Placeholder | Real value |
| ------------------------------------------------- | ---------------------------------- |
| `[PER_1.PRENOM:M]` / `[PER_1.NOM:M]` | Lucas / Favre |
| `[PER_1.DATE_NAISSANCE:2011]` | 04.07.2011 |
| `[PER_2.PRENOM:F]` / `[PER_2.NOM:F]` | Camille / Favre |
| `[PER_2.NOM_JEUNE_FILLE:F]` | Rochat |
| `[PER_2.TELEPHONE:Fixe]` | 021 555 12 34 |
| `[PER_2.EMAIL:Privé]` | camille.favre@bluewin.ch |
| `[PER_2.ADRESSE:Rue/NPA/Ville]` | 8 chemin des Vignes / 1009 / Pully |
| `[PER_3.PRENOM:F]` / `[PER_3.NOM:F]` | Nadia / Benali |
| `[LOC_1.VILLE:Lausanne]` / `[LOC_2.VILLE:Renens]` | Lausanne / Renens |
| `[PER_2.IBAN:Principal]` | CH56 0483 5012 3456 7800 9 |
The health detail ("asthmatique") is kept as non-identifying context, and the reference chain
("mon fils" / "sa mère" / "son médecin") is resolved into three distinct entities.
</details>
## How this helps with the nLPD
Switzerland's [nLPD](https://www.fedlex.admin.ch/eli/cc/2022/491/fr) (and the GDPR) push for **data
minimisation** and favour **pseudonymisation** when personal data is processed by third parties. This
library is built around those principles:
- **The third party never sees raw PII.** When you send text to an external LLM (or any external service),
it receives only pseudonymised tokens like `[PER_1.NOM:M]` plus the non-identifying `legend` — never the
real name, e-mail, AVS number, etc.
- **Pseudonymisation, not loss of meaning.** The re-identification key (`mapping`) stays in your
infrastructure; only you can reverse the tokens. The `legend` lets the downstream model still reason
correctly ("a person", "male") without knowing _who_.
- **Fail-closed option.** Omitting the regex fallback means that if detection can't run, the call errors
instead of forwarding data that wasn't pseudonymised — no silent leak.
- **Coreference & minimisation.** Re-using one id per entity avoids spreading extra distinguishing detail
across a prompt, and the corpus itself can stay in clear text — pseudonymisation happens only at the
boundary, at query time.
> This is an engineering aid, not legal advice or a certification. You remain the data controller; assess
> it against your own obligations (see the disclaimer below).
## Compliance note
This library is a **best-effort** pseudonymization aid, not a guarantee of legal compliance. LLM and regex
detection can miss or mis-classify data. Validate against your own requirements (nLPD, GDPR, HIPAA, …)
before relying on it for regulated data.
## License
[MIT](./LICENSE) © Mobiletic