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:
@@ -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 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<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[],
|
||||
|
||||
Reference in New Issue
Block a user