feat: initial release of @mobiletic/anonymizer

Framework-agnostic PII anonymization extracted from Mobiletic's chatbot.
Pluggable LLM detection + configurable regex fallback, deterministic
coreference, and streaming-safe de-anonymization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-06-20 21:58:38 +01:00
commit f09b917f1a
20 changed files with 3889 additions and 0 deletions

244
src/anonymizer.ts Normal file
View File

@@ -0,0 +1,244 @@
import { RegexFallback } from './fallback.js';
import { presets } from './presets.js';
import {
type AnonymizationResult,
type AnonymizerConfig,
type LlmProvider,
type Logger,
type StreamDeanonymizer,
PLACEHOLDER_RE,
} from './types.js';
const NOOP_LOGGER: Logger = { warn: () => {} };
// Likely proper-name heuristic (two capitalized words), in addition to the structured fallback.
const DEFAULT_NAME_HINT = /\b[A-ZÀ-Ý][a-zà-ÿ]+\s+[A-ZÀ-Ý][a-zà-ÿ]+\b/;
/**
* Framework-agnostic anonymization engine.
*
* `anonymize()`: pre-filter (skips the LLM when there is no PII) → LLM →
* regex fallback on failure/timeout → bidirectional validation.
* `deanonymize()` restores the real values. `makeStreamDeanonymizer()` handles
* the critical case of a placeholder fragmented across several streamed tokens.
*
* Pass an {@link LlmProvider} to detect free-form PII (names, addresses);
* omit it for deterministic regex-only mode.
*/
export class Anonymizer {
private readonly llm?: LlmProvider;
private readonly fallback: RegexFallback;
private readonly nameHint: RegExp;
private readonly logger: Logger;
constructor(config: AnonymizerConfig = {}) {
this.llm = config.llm;
this.fallback = new RegexFallback(config.patterns ?? presets.swiss);
this.nameHint = config.nameHint ?? DEFAULT_NAME_HINT;
this.logger = config.logger ?? NOOP_LOGGER;
}
private hasLlm(): boolean {
return !!this.llm && this.llm.isConfigured();
}
private likelyHasPii(text: string): boolean {
return this.fallback.hasPii(text) || this.nameHint.test(text);
}
async anonymize(text: string): Promise<AnonymizationResult> {
// Pre-filter: most text has no PII → avoid the LLM round-trip (latency + cost)
// and return the text unchanged.
if (!this.likelyHasPii(text)) return { anon: text, mapping: {} };
if (this.hasLlm()) {
try {
const result = await this.llm!.anonymize(text);
this.validate(result); // throws if inconsistent → fall back
return result;
} catch (err) {
this.logger.warn(`LLM unavailable/invalid → regex fallback: ${(err as Error).message}`);
}
}
return this.fallback.anonymize(text);
}
/**
* Anonymize retrieved chunks BEFORE sending them on, CONSISTENTLY with the
* question's mapping (`seed`). Returns the anonymized chunks + the COMPLETE
* mapping (question chunks). Strategy:
* 1. Deterministic reuse: replace any already-known value (from the question)
* by its placeholder → question↔chunk coreference with no LLM call.
* 2. Pre-filter: if no residual PII remains → return as-is (most corpus chunks
* have no PII → LLM cost avoided).
* 3. Otherwise: ONE batched LLM call, then de-collision merge (renumber the
* genuinely new entities). Failure/timeout → regex fallback per chunk.
*/
async anonymizeChunks(
chunks: string[],
seed: Record<string, string>,
): Promise<{ anon: string[]; mapping: Record<string, string> }> {
if (!chunks.length) return { anon: [], mapping: { ...seed } };
const seeded = chunks.map((c) => this.applyKnown(c, seed));
if (!seeded.some((c) => this.likelyHasPii(c))) return { anon: seeded, mapping: { ...seed } };
if (this.hasLlm()) {
try {
const { segments, mapping: gMap } = await this.llm!.anonymizeBatch(seeded, Object.keys(seed));
if (segments.length !== seeded.length) throw new Error('SEGMENT_COUNT_MISMATCH');
const { mapping, rename } = this.mergeMappings(seed, gMap);
const anon = segments.map((s) => this.applyRename(s, rename));
// Anti-leak: every placeholder present in a chunk must be mapped.
for (const s of anon) {
for (const ph of s.match(PLACEHOLDER_RE) ?? []) {
if (!(ph in mapping)) throw new Error(`unmapped placeholder ${ph}`);
}
}
return { anon, mapping };
} catch (err) {
this.logger.warn(`Chunk anonymization → regex fallback: ${(err as Error).message}`);
}
}
return this.fallbackChunks(seeded, seed);
}
/** Replace known values (seed) by their placeholder, longest values first. */
private applyKnown(text: string, seed: Record<string, string>): string {
let out = text;
for (const [ph, val] of Object.entries(seed)
.filter(([, v]) => v)
.sort((a, b) => b[1].length - a[1].length)) {
out = out.split(val).join(ph);
}
return out;
}
private applyRename(text: string, rename: Record<string, string>): string {
let out = text;
for (const [from, to] of Object.entries(rename)) out = out.split(from).join(to);
return out;
}
/** `[PER_1.NOM:M]` → { type:'PER', index:1, suffix:'.NOM:M' } ; `[EMAIL_2]` → suffix ''. */
private parsePlaceholder(ph: string): { type: string; index: number; suffix: string } | null {
const m = /^\[([A-Z]+)_(\d+)(\..*)?\]$/.exec(ph);
return m ? { type: m[1], index: Number(m[2]), suffix: m[3] ?? '' } : null;
}
/**
* Merge the chunks' LLM mapping into the question's (seed).
* - value already known → reuse the question's placeholder (rename).
* - new value without collision → added as-is.
* - collision (same placeholder, different value) → renumbered (next index).
*/
private mergeMappings(
seed: Record<string, string>,
gMap: Record<string, string>,
): { mapping: Record<string, string>; rename: Record<string, string> } {
const mapping: Record<string, string> = { ...seed };
const valueToPh = new Map<string, string>();
const maxIdx = new Map<string, number>();
for (const [ph, val] of Object.entries(seed)) {
valueToPh.set(val, ph);
const p = this.parsePlaceholder(ph);
if (p) maxIdx.set(p.type, Math.max(maxIdx.get(p.type) ?? 0, p.index));
}
const rename: Record<string, string> = {};
for (const [gph, val] of Object.entries(gMap)) {
const known = valueToPh.get(val);
if (known) {
if (known !== gph) rename[gph] = known; // same entity as the question
continue;
}
if (!(gph in mapping)) {
mapping[gph] = val;
valueToPh.set(val, gph);
const p = this.parsePlaceholder(gph);
if (p) maxIdx.set(p.type, Math.max(maxIdx.get(p.type) ?? 0, p.index));
} else {
// collision: placeholder reused for ANOTHER value → renumber.
const p = this.parsePlaceholder(gph);
const type = p?.type ?? 'PER';
const next = (maxIdx.get(type) ?? 0) + 1;
maxIdx.set(type, next);
const newPh = `[${type}_${next}${p?.suffix ?? ''}]`;
rename[gph] = newPh;
mapping[newPh] = val;
valueToPh.set(val, newPh);
}
}
return { mapping, rename };
}
/** Fallback (no LLM): regex per chunk, de-collision merged into the seed. */
private fallbackChunks(
seeded: string[],
seed: Record<string, string>,
): { anon: string[]; mapping: Record<string, string> } {
let mapping: Record<string, string> = { ...seed };
const anon: string[] = [];
for (const c of seeded) {
const r = this.fallback.anonymize(c);
const { mapping: merged, rename } = this.mergeMappings(mapping, r.mapping);
mapping = merged;
anon.push(this.applyRename(r.anon, rename));
}
return { anon, mapping };
}
/** Every placeholder in the text must be in the mapping AND vice-versa (anti-leak). */
private validate(result: AnonymizationResult): void {
const inText = new Set(result.anon.match(PLACEHOLDER_RE) ?? []);
for (const ph of inText) {
if (!(ph in result.mapping)) throw new Error(`placeholder ${ph} without mapping`);
}
for (const ph of Object.keys(result.mapping)) {
if (!inText.has(ph)) throw new Error(`orphan mapping ${ph}`);
}
}
/** Restore the real values (replace every known placeholder). */
deanonymize(text: string, mapping: Record<string, string>): string {
if (!text) return text;
let out = text;
for (const [ph, value] of Object.entries(mapping)) {
out = out.split(ph).join(value);
}
return out;
}
/**
* Streaming de-anonymizer. Buffers any unterminated `[...]` fragment until it
* completes, so a partial placeholder is never emitted and never leaks.
*/
makeStreamDeanonymizer(mapping: Record<string, string>): StreamDeanonymizer {
let buf = '';
const deanon = (s: string) => this.deanonymize(s, mapping);
return {
push: (chunk: string): string => {
buf += chunk;
const lastOpen = buf.lastIndexOf('[');
let emit: string;
if (lastOpen === -1) {
emit = buf;
buf = '';
} else if (buf.indexOf(']', lastOpen) === -1) {
// '[' opened without ']' → placeholder possibly cut: hold from there.
emit = buf.slice(0, lastOpen);
buf = buf.slice(lastOpen);
} else {
emit = buf;
buf = '';
}
return deanon(emit);
},
flush: (): string => {
const out = deanon(buf);
buf = '';
return out;
},
};
}
}

40
src/fallback.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { AnonymizationResult, PatternDef } from './types.js';
/**
* Regex-based anonymizer for STRUCTURED identifiers (email, phone, IBAN, …).
* It is the deterministic fallback used when no LLM provider is available or a
* provider call fails. It does NOT detect proper names — that is the LLM's job.
*
* Patterns are injected (see {@link presets}) so the same engine serves any
* locale or regulation.
*/
export class RegexFallback {
constructor(private readonly patterns: PatternDef[]) {}
anonymize(text: string): AnonymizationResult {
const mapping: Record<string, string> = {};
const counters: Record<string, number> = {};
let anon = text;
for (const { tag, re } of this.patterns) {
anon = anon.replace(re, (match) => {
// Reuse the same placeholder for an identical value (consistency).
const existing = Object.entries(mapping).find(([, v]) => v === match);
if (existing) return existing[0];
counters[tag] = (counters[tag] ?? 0) + 1;
const ph = `[${tag}_${counters[tag]}]`;
mapping[ph] = match;
return ph;
});
}
return { anon, mapping };
}
/** Fast pre-check: is there any structured identifier worth anonymizing? */
hasPii(text: string): boolean {
return this.patterns.some(({ re }) => {
re.lastIndex = 0;
return re.test(text);
});
}
}

17
src/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export { Anonymizer } from './anonymizer.js';
export { RegexFallback } from './fallback.js';
export { presets } from './presets.js';
export {
openAICompatibleProvider,
DEFAULT_SYSTEM_PROMPT,
type OpenAICompatibleOptions,
} from './providers/openai-compatible.js';
export {
PLACEHOLDER_RE,
type AnonymizationResult,
type AnonymizerConfig,
type LlmProvider,
type PatternDef,
type Logger,
type StreamDeanonymizer,
} from './types.js';

29
src/presets.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { PatternDef } from './types.js';
/**
* Swiss preset — the structured identifiers most relevant to the Swiss nLPD.
* Order matters: specific patterns (AVS, IBAN) come before generic ones.
*/
const swiss: PatternDef[] = [
{ tag: 'AVS', re: /\b756\.\d{4}\.\d{4}\.\d{2}\b/g }, // Swiss social security (AVS/AHV)
{ tag: 'IBAN', re: /\bCH\d{2}[0-9A-Z]{17}\b/gi }, // Swiss IBAN
{ tag: 'EMAIL', re: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
{ tag: 'TEL', re: /(?:\+41|0)(?:[\s.-]?\d){9}\b/g }, // Swiss phone number
{ tag: 'DATE', re: /\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g },
];
/**
* Generic preset — locale-agnostic identifiers useful as a starting point
* anywhere. Extend or compose with your own {@link PatternDef}s as needed.
*/
const generic: PatternDef[] = [
{ tag: 'EMAIL', re: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
{ tag: 'IBAN', re: /\b[A-Z]{2}\d{2}[0-9A-Z]{11,30}\b/g },
{ tag: 'CREDIT_CARD', re: /\b(?:\d[ -]?){13,16}\b/g },
{ tag: 'IPV4', re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g },
{ tag: 'TEL', re: /\b(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{2,4}\)?[\s.-]?){2,4}\d{2,4}\b/g },
{ tag: 'DATE', re: /\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g },
];
/** Built-in pattern sets for the regex fallback. */
export const presets = { swiss, generic };

View File

@@ -0,0 +1,117 @@
import type { AnonymizationResult, LlmProvider } from '../types.js';
/** Default nLPD pseudonymization prompt (French). Override for other locales/regulations. */
export const DEFAULT_SYSTEM_PROMPT = [
'Tu es un moteur de pseudonymisation conforme à la nLPD suisse.',
'Identifie UNIQUEMENT les données personnelles (identifiants directs et indirects)',
'et remplace-les par des placeholders. Ne touche à RIEN dautre.',
'',
'FORMAT: [ENTITE_ID.ATTRIBUT:CONTEXTE]',
'Entités: PER (personne), ORG (organisation), LOC (lieu autonome).',
'Attributs PER: NOM, PRENOM, DATE_NAISSANCE, AGE, ADRESSE, EMAIL, TELEPHONE, AVS, IBAN, NSS.',
'Contexte = indice non-identifiant utile au raisonnement:',
' NOM:M|F|U · DATE_NAISSANCE:<année> · AGE:Mineur|Adulte',
' ADRESSE:Lieu|Rue|Ville|NPA|Pays · ORG:Entreprise|Ecole',
'',
'RÈGLES:',
'1. Coréférence: la MÊME personne garde le MÊME identifiant (PER_1) dans tout le texte.',
'2. Nanonymise JAMAIS les termes pédagogiques/techniques (langages, concepts, titres de cours, fonctions).',
'3. Si AUCUNE donnée personnelle: renvoie le texte original et "mapping": {}.',
'4. Nanonymise pas un placeholder déjà présent (idempotence).',
'5. Sortie STRICTEMENT JSON valide: {"texte_anonymise": "...", "mapping": {"[PER_1.NOM:M]": "..."}}.',
].join('\n');
export interface OpenAICompatibleOptions {
/** Base URL of an OpenAI-compatible API, e.g. `https://api.openai.com/v1`. */
baseUrl: string;
/** Bearer API key. */
apiKey: string;
/** Model id, e.g. `gpt-4o-mini` or `gemma-3-...`. */
model: string;
/** Per-request timeout in milliseconds (default 3000). */
timeoutMs?: number;
/** Override the system prompt (e.g. for another language or regulation). */
systemPrompt?: string;
/** Extra instructions appended to the system prompt in batch mode. */
batchInstructions?: (usedIds: string[]) => string;
}
const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
'\n\nMODE LOT (segments) :' +
'\n- ENTRÉE : un objet JSON {"segments": ["…", "…"]}.' +
'\n- Anonymise CHAQUE segment ; coréférence GLOBALE entre segments.' +
(usedIds.length
? `\n- Identifiants DÉJÀ attribués (réutilise-les pour les mêmes valeurs, n'en duplique AUCUN pour d'autres valeurs) : ${usedIds.join(', ')}.`
: '') +
'\n- SORTIE STRICTEMENT JSON : {"segments": ["…anonymisé…"], "mapping": {"[PER_1.NOM:M]": "…"}}.' +
'\n- "segments" DOIT avoir la même longueur et le même ordre que lentrée ; "mapping" ne contient que les NOUVELLES entités.';
/**
* Build an {@link LlmProvider} backed by any OpenAI-compatible Chat Completions
* endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` and
* `response_format: json_object` for deterministic, parseable output, and throws
* on any failure so the {@link Anonymizer} falls back to its regex engine.
*/
export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider {
const timeout = opts.timeoutMs ?? 3000;
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
const isConfigured = (): boolean => !!(opts.baseUrl && opts.apiKey && opts.model);
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${opts.apiKey}`,
},
body: JSON.stringify({
model: opts.model,
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(`LLM_HTTP_${res.status}`);
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
} finally {
clearTimeout(timer);
}
}
return {
isConfigured,
async anonymize(text: string): Promise<AnonymizationResult> {
const content = await chat(systemPrompt, text);
const parsed = JSON.parse(content) as { texte_anonymise?: string; mapping?: Record<string, string> };
if (typeof parsed.texte_anonymise !== 'string' || typeof parsed.mapping !== 'object' || !parsed.mapping) {
throw new Error('LLM_BAD_SHAPE');
}
return { anon: parsed.texte_anonymise, mapping: parsed.mapping };
},
async anonymizeBatch(
texts: string[],
usedIds: string[],
): Promise<{ segments: string[]; mapping: Record<string, string> }> {
const system = systemPrompt + batchInstructions(usedIds);
const content = await chat(system, JSON.stringify({ segments: texts }));
const parsed = JSON.parse(content) as { segments?: string[]; mapping?: Record<string, string> };
if (!Array.isArray(parsed.segments) || typeof parsed.mapping !== 'object' || !parsed.mapping) {
throw new Error('LLM_BATCH_BAD_SHAPE');
}
return { segments: parsed.segments, mapping: parsed.mapping };
},
};
}

67
src/types.ts Normal file
View File

@@ -0,0 +1,67 @@
/** Result of an anonymization: text with placeholders + the reverse-mapping table. */
export interface AnonymizationResult {
/** The text with every detected PII value replaced by a placeholder. */
anon: string;
/** Placeholder → original value, e.g. `{ "[PER_1.NOM:M]": "Alain JACCARD" }`. */
mapping: Record<string, string>;
}
/**
* Matches a single placeholder. Covers the rich LLM form `[PER_1.NOM:M]`
* and the plain regex-fallback form `[EMAIL_1]`.
*/
export const PLACEHOLDER_RE = /\[[A-Z]+_\d+(?:\.[A-Z_]+:[^\]]+)?\]/g;
/**
* Pluggable LLM backend. Implement this (or use {@link openAICompatibleProvider})
* to let the {@link Anonymizer} detect free-form PII such as proper names that
* regular expressions cannot reliably catch.
*/
export interface LlmProvider {
/** Whether the provider is ready to be called. When `false`, the Anonymizer skips it. */
isConfigured(): boolean;
/** Anonymize a single piece of text. */
anonymize(text: string): Promise<AnonymizationResult>;
/**
* Anonymize several segments in ONE call with GLOBAL coreference (same entity →
* same id across segments) and reuse of the ids already assigned upstream
* (`usedIds`). Returns the anonymized segments (same order/length) plus the
* mapping of the NEW entities only.
*/
anonymizeBatch(
texts: string[],
usedIds: string[],
): Promise<{ segments: string[]; mapping: Record<string, string> }>;
}
/** A structured-PII detector: a tag (e.g. `EMAIL`) and the global regex that finds it. */
export interface PatternDef {
tag: string;
re: RegExp;
}
/** Minimal logger sink. Defaults to a no-op; pass your own to capture fallback warnings. */
export interface Logger {
warn(msg: string): void;
}
/** Configuration for {@link Anonymizer}. */
export interface AnonymizerConfig {
/** LLM backend for free-form PII (names, addresses…). Omit for regex-only mode. */
llm?: LlmProvider;
/** Structured-PII patterns for the regex fallback. Defaults to {@link presets.swiss}. */
patterns?: PatternDef[];
/** Heuristic that flags likely proper names so the LLM is consulted. Has a sensible default. */
nameHint?: RegExp;
/** Where fallback/diagnostic warnings go. Defaults to a no-op. */
logger?: Logger;
}
/**
* Streaming de-anonymizer: buffers placeholders that get split across token
* boundaries so a partial `[PER_` is never emitted (and never leaks).
*/
export interface StreamDeanonymizer {
push(chunk: string): string;
flush(): string;
}