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:
244
src/anonymizer.ts
Normal file
244
src/anonymizer.ts
Normal 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
40
src/fallback.ts
Normal 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
17
src/index.ts
Normal 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
29
src/presets.ts
Normal 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 };
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
67
src/types.ts
Normal file
67
src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user