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:
117
src/providers/openai-compatible.ts
Normal file
117
src/providers/openai-compatible.ts
Normal 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 d’autre.',
|
||||
'',
|
||||
'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. N’anonymise 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. N’anonymise 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 l’entré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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user