Files
anonymizer/src/providers/openai-compatible.ts
Mobiletic 5ed8001101 feat: multi-turn conversation support (0.4.0)
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>
2026-07-01 14:26:41 +01:00

291 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
'',
'ABRÉVIATIONS DYNAMIQUES:',
'- Si tu rencontres un TYPE dentité, un ATTRIBUT ou un CONTEXTE non listé ci-dessus,',
' INVENTE une abréviation COURTE en MAJUSCULES (lettres AZ et "_" uniquement, ex. PER, NOM, M),',
' suivant la même convention, et réutilise-la de façon cohérente partout.',
'- Nutilise JAMAIS despaces, accents, chiffres ou symboles dans une abréviation.',
'',
'LÉGENDE:',
'- Renvoie une "legende" : un objet associant CHAQUE abréviation utilisée (entité, attribut, contexte)',
' à sa signification complète en français. Ex.: {"PER":"Personne","NOM":"Nom de famille","M":"Masculin"}.',
'- La légende sert à expliquer les placeholders à un autre modèle ; elle ne contient AUCUNE donnée réelle.',
'',
'COHÉRENCE (impératif) :',
'- Chaque clé de "mapping" est EXACTEMENT un placeholder présent dans "texte_anonymise" ; chaque placeholder du texte est une clé de "mapping".',
'- Les VALEURS de "mapping" sont les données ORIGINALES réelles, JAMAIS un autre placeholder.',
'- Deux valeurs DIFFÉRENTES ne partagent JAMAIS le même placeholder. Pour deux valeurs du même type chez la même personne (ex. ancien et nouvel IBAN), distingue-les par un CONTEXTE différent : [PER_1.IBAN:Ancien] et [PER_1.IBAN:Nouveau].',
'- Format OBLIGATOIRE : [TYPE_N] ou [TYPE_N.ATTRIBUT:CONTEXTE] (TYPE en MAJUSCULES, N entier ; ex. [ORG_1.NOM:Entreprise], jamais [ORG:Entreprise]).',
'- Remplace UNIQUEMENT la valeur elle-même — jamais le libellé voisin (« numéro AVS 756… » → seul « 756… » ; « AVS » reste) NI la ponctuation/séparateurs voisins (virgules, espaces, parenthèses restent HORS des placeholders, ex. « 14 rue X, 1700 Ville » garde la virgule).',
'',
'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, "mapping": {} et "legende": {}.',
'4. Nanonymise pas un placeholder déjà présent (idempotence).',
'5. Sortie STRICTEMENT JSON valide:',
' {"texte_anonymise": "...", "mapping": {"[PER_1.NOM:M]": "..."}, "legende": {"PER": "Personne", "NOM": "Nom de famille", "M": "Masculin"}}.',
].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, per attempt (default 3000). */
timeoutMs?: number;
/**
* Extra retries on TRANSIENT failures only (network error, timeout, HTTP 429/5xx).
* Default 1 (→ up to 2 attempts). Worst-case latency is `(retries + 1) × timeoutMs`.
*/
retries?: number;
/** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */
retryDelayMs?: number;
/**
* `response_format` to send with the request. **Omitted by default** — the
* Infomaniak API and several open-model endpoints reject the older
* `{ type: 'json_object' }`. Pass `{ type: 'json_object' }` for endpoints that
* require it, or a `{ type: 'json_schema', json_schema: … }` object. Regardless,
* responses are parsed leniently (markdown fences / surrounding prose tolerated).
*/
responseFormat?: Record<string, unknown>;
/**
* Include the secret `mapping` (placeholder → real value) in the conversation
* context during {@link LlmProvider.anonymizeInConversation}. **Default false.**
* Enable ONLY when this endpoint is a trusted processor — it sends REAL values
* to the model. (The anonymizer already receives the cleartext being
* anonymized, so a trusted endpoint like a Swiss/self-hosted deployment sees no
* more than it already does; the downstream chatbot never receives the mapping.)
*/
includeMappingInContext?: boolean;
/** 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 sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
class TransientError extends Error {}
/**
* Parse the model's JSON output leniently: tolerate a ```json fenced block and
* any prose around the object, keeping the outermost `{ … }`. Models without an
* enforced `response_format` often wrap their JSON, so this avoids spurious
* parse failures.
*/
function extractJson(content: string): unknown {
let t = content.trim();
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) t = fence[1].trim();
const i = t.indexOf('{');
const j = t.lastIndexOf('}');
if (i >= 0 && j > i) t = t.slice(i, j + 1);
return JSON.parse(t);
}
/**
* Coerce the model's `legende` into a string→string map, lenient by design: a
* missing or malformed legend yields `{}` rather than failing the anonymization
* (the legend is metadata; the `mapping` is what guarantees no leak).
*/
function asLegend(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
}
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]": "…"}, "legende": {"PER": "Personne"}}.' +
'\n- "segments" DOIT avoir la même longueur et le même ordre que lentrée ; "mapping" ne contient que les NOUVELLES entités ; "legende" décrit toutes les abréviations utilisées.';
/** Prior-conversation context block appended to the system prompt for a turn. */
function conversationContext(
ctx: {
history: string[];
legend: Record<string, string>;
usedIds: string[];
mapping?: Record<string, string>;
},
includeMapping: boolean,
): string {
const lines = ['', 'CONTEXTE DE LA CONVERSATION (déjà pseudonymisé) :'];
if (ctx.history.length) {
lines.push('- Tours précédents :', ...ctx.history.map((h, i) => ` [${i + 1}] ${h}`));
}
if (Object.keys(ctx.legend).length) {
lines.push(`- Légende connue : ${JSON.stringify(ctx.legend)}`);
}
if (ctx.usedIds.length) {
lines.push(
`- Identifiants DÉJÀ attribués (réutilise-les pour les MÊMES entités, n'en réattribue AUCUN à une autre valeur) : ${ctx.usedIds.join(', ')}`,
);
}
if (includeMapping && ctx.mapping && Object.keys(ctx.mapping).length) {
const pairs = Object.entries(ctx.mapping)
.map(([ph, v]) => `${ph} = ${v}`)
.join(' ; ');
lines.push(
`- Correspondances connues (réutilise le MÊME placeholder si la valeur réapparaît) : ${pairs}`,
);
}
lines.push(
'Anonymise le MESSAGE suivant en gardant EXACTEMENT la même convention et les mêmes identifiants pour les entités déjà vues ; "mapping" ne contient que les NOUVELLES entités.',
);
return '\n\n' + lines.join('\n');
}
/**
* Build an {@link LlmProvider} backed by any OpenAI-compatible Chat Completions
* endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` for
* deterministic output and parses the JSON response leniently. `response_format`
* is omitted by default (Infomaniak rejects the legacy `json_object`); pass
* `responseFormat` for endpoints that support/require it. Throws on any failure
* (so a configured {@link Anonymizer} fallback can take over, or it fails closed).
*/
export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider {
const timeout = opts.timeoutMs ?? 3000;
const retries = Math.max(0, opts.retries ?? 1);
const retryDelayMs = opts.retryDelayMs ?? 250;
const responseFormat = opts.responseFormat;
const includeMappingInContext = opts.includeMappingInContext ?? false;
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
const isConfigured = (): boolean => !!(opts.baseUrl && opts.apiKey && opts.model);
/** One HTTP attempt. Throws {@link TransientError} for retryable failures. */
async function attempt(system: string, user: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let res: Response;
try {
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,
...(responseFormat ? { response_format: responseFormat } : {}),
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: controller.signal,
});
} catch (err) {
// Network failure or timeout/abort → retryable.
throw new TransientError(`LLM_FETCH_FAILED: ${(err as Error).message}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
// 429 + 5xx are transient; other 4xx are not (won't fix on retry).
if (res.status === 429 || res.status >= 500) throw new TransientError(`LLM_HTTP_${res.status}`);
throw new Error(`LLM_HTTP_${res.status}`);
}
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
}
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
let lastErr: unknown;
for (let i = 0; i <= retries; i++) {
try {
return await attempt(system, user);
} catch (err) {
lastErr = err;
if (!(err instanceof TransientError) || i === retries) throw err;
if (retryDelayMs > 0) await sleep(retryDelayMs * (i + 1));
}
}
throw lastErr; // unreachable, but keeps the type checker happy
}
return {
isConfigured,
async anonymize(text: string): Promise<AnonymizationResult> {
const content = await chat(systemPrompt, text);
const parsed = extractJson(content) as {
texte_anonymise?: string;
mapping?: Record<string, string>;
legende?: 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, legend: asLegend(parsed.legende) };
},
async anonymizeInConversation(text, ctx): Promise<AnonymizationResult> {
const system = systemPrompt + conversationContext(ctx, includeMappingInContext);
const content = await chat(system, text);
const parsed = extractJson(content) as {
texte_anonymise?: string;
mapping?: Record<string, string>;
legende?: 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, legend: asLegend(parsed.legende) };
},
async anonymizeBatch(
texts: string[],
usedIds: string[],
): Promise<{ segments: string[]; mapping: Record<string, string>; legend: Record<string, string> }> {
const system = systemPrompt + batchInstructions(usedIds);
const content = await chat(system, JSON.stringify({ segments: texts }));
const parsed = extractJson(content) as {
segments?: string[];
mapping?: Record<string, string>;
legende?: 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, legend: asLegend(parsed.legende) };
},
};
}