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>
This commit is contained in:
Mobiletic
2026-07-01 14:26:41 +01:00
parent bc34d9470e
commit 5ed8001101
9 changed files with 415 additions and 2 deletions

View File

@@ -64,6 +64,15 @@ export interface OpenAICompatibleOptions {
* 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. */
@@ -115,6 +124,42 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
'\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
@@ -128,6 +173,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
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;
@@ -206,6 +252,24 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
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[],