feat: make openAICompatibleProvider work with Infomaniak out of the box (0.3.0)

- response_format is omitted by default (Infomaniak rejects the legacy
  json_object → HTTP 422); opt in via the new `responseFormat` option
  ({type:'json_object'} or a json_schema object). BREAKING for endpoints
  that relied on the forced json_object.
- parse LLM JSON leniently (tolerate markdown fences / surrounding prose)
- fold strict-coherence rules into DEFAULT_SYSTEM_PROMPT (exact
  placeholder<->mapping-key identity, values are originals, strict format,
  mask value not adjacent label) → reliable output across models

Verified live against Gemma 4 (google/gemma-4-31B-it, Infomaniak v2): all
demo phrases anonymize with clean round-trips, no custom provider needed.
36 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-07-01 09:43:19 +01:00
parent 669794522e
commit f5c9f0fbd4
5 changed files with 84 additions and 9 deletions

View File

@@ -24,6 +24,12 @@ export const DEFAULT_SYSTEM_PROMPT = [
' à 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.',
'- 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, pas le libellé voisin (ex. « numéro AVS 756… » → seul « 756… » devient un placeholder ; le mot « AVS » reste).',
'',
'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).',
@@ -49,6 +55,14 @@ export interface OpenAICompatibleOptions {
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>;
/** Override the system prompt (e.g. for another language or regulation). */
systemPrompt?: string;
/** Extra instructions appended to the system prompt in batch mode. */
@@ -60,6 +74,22 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
/** 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
@@ -86,14 +116,17 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
/**
* 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.
* 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 systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
@@ -114,7 +147,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
body: JSON.stringify({
model: opts.model,
temperature: 0,
response_format: { type: 'json_object' },
...(responseFormat ? { response_format: responseFormat } : {}),
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
@@ -157,7 +190,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
async anonymize(text: string): Promise<AnonymizationResult> {
const content = await chat(systemPrompt, text);
const parsed = JSON.parse(content) as {
const parsed = extractJson(content) as {
texte_anonymise?: string;
mapping?: Record<string, string>;
legende?: Record<string, string>;
@@ -178,7 +211,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
): 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 = JSON.parse(content) as {
const parsed = extractJson(content) as {
segments?: string[];
mapping?: Record<string, string>;
legende?: Record<string, string>;