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: · AGE:Mineur|Adulte', ' ADRESSE:Lieu|Rue|Ville|NPA|Pays · ORG:Entreprise|Ecole', '', 'ABRÉVIATIONS DYNAMIQUES:', '- Si tu rencontres un TYPE d’entité, un ATTRIBUT ou un CONTEXTE non listé ci-dessus,', ' INVENTE une abréviation COURTE en MAJUSCULES (lettres A–Z et "_" uniquement, ex. PER, NOM, M),', ' suivant la même convention, et réutilise-la de façon cohérente partout.', '- N’utilise JAMAIS d’espaces, 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. N’anonymise 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. N’anonymise 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; /** * 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 => 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 { if (!raw || typeof raw !== 'object') return {}; const out: Record = {}; for (const [k, v] of Object.entries(raw as Record)) { 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 l’entré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; usedIds: string[]; mapping?: Record; }, 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 { 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 { 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 { const content = await chat(systemPrompt, text); const parsed = extractJson(content) as { texte_anonymise?: string; mapping?: Record; legende?: Record; }; 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 { const system = systemPrompt + conversationContext(ctx, includeMappingInContext); const content = await chat(system, text); const parsed = extractJson(content) as { texte_anonymise?: string; mapping?: Record; legende?: Record; }; 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; legend: Record }> { const system = systemPrompt + batchInstructions(usedIds); const content = await chat(system, JSON.stringify({ segments: texts })); const parsed = extractJson(content) as { segments?: string[]; mapping?: Record; legende?: Record; }; 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) }; }, }; }