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>
421 lines
25 KiB
Markdown
421 lines
25 KiB
Markdown
# @mobiletic/anonymizer
|
||
|
||
[](https://git.mobiletic.net/mobiletic/anonymizer/actions)
|
||
[](https://www.npmjs.com/package/@mobiletic/anonymizer)
|
||
[](./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
|