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

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 };
},
};
}