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

@@ -3,6 +3,7 @@ import { AnonymizationError } from './errors.js';
import {
type AnonymizationResult,
type AnonymizerConfig,
type AnonymizerSession,
type LlmProvider,
type Logger,
type StreamDeanonymizer,
@@ -73,12 +74,14 @@ export class Anonymizer {
private readonly fallback?: RegexFallback;
private readonly nameHint: RegExp;
private readonly logger: Logger;
private readonly historyMaxTurns: number;
constructor(config: AnonymizerConfig = {}) {
this.llm = config.llm;
this.fallback = config.patterns ? new RegexFallback(config.patterns) : undefined;
this.nameHint = stateless(config.nameHint ?? DEFAULT_NAME_HINT);
this.logger = config.logger ?? NOOP_LOGGER;
this.historyMaxTurns = config.historyMaxTurns ?? 10;
if (!this.llm && !this.fallback) {
throw new AnonymizationError('Anonymizer requires an LlmProvider, regex patterns, or both');
}
@@ -169,6 +172,109 @@ export class Anonymizer {
return this.fallbackChunks(seeded, seed.mapping, seedLegend);
}
/**
* Anonymize ONE conversation turn, consistently with a running
* {@link AnonymizerSession}, so an entity keeps the same id across the whole
* chat. Returns the anonymized turn + the updated session (persist it and pass
* it back next turn). Strategy per turn:
* 1. `applyKnown` — swap already-known values to their placeholders (id reuse,
* no value leaves for those spans).
* 2. Detect: if the provider supports {@link LlmProvider.anonymizeInConversation}
* it gets prior context (history/legend/usedIds, and the mapping only if the
* provider opted in); otherwise fall back to the batched path with `usedIds`
* (still collision-free). No LLM → regex only.
* 3. `mergeMappings` de-collides into the session, `validate` anti-leak, and the
* anonymized turn is appended to `history` (capped to `historyMaxTurns`).
* On failure with no regex fallback → throws {@link AnonymizationError} (fail-closed).
*/
async anonymizeTurn(
text: string,
session?: AnonymizerSession,
): Promise<{
anon: string;
mapping: Record<string, string>;
legend: Record<string, string>;
session: AnonymizerSession;
}> {
const prev: AnonymizerSession = session ?? { mapping: {}, legend: {}, history: [] };
const seeded = this.applyKnown(text, prev.mapping);
let turn: AnonymizationResult;
if (this.hasLlm()) {
try {
if (this.llm!.anonymizeInConversation) {
turn = await this.llm!.anonymizeInConversation(seeded, {
history: prev.history,
legend: prev.legend,
usedIds: Object.keys(prev.mapping),
mapping: prev.mapping, // provider decides whether to actually transmit it
});
} else {
const b = await this.llm!.anonymizeBatch([seeded], Object.keys(prev.mapping));
if (b.segments.length !== 1) throw new Error('SEGMENT_COUNT_MISMATCH');
turn = { anon: b.segments[0], mapping: b.mapping, legend: b.legend };
}
return this.commitTurn(prev, this.mergeAndCheck(prev.mapping, turn));
} catch (err) {
if (!this.fallback) {
throw new AnonymizationError(`anonymizeTurn failed: ${(err as Error).message}`, { cause: err });
}
this.logger.warn(`Conversation turn → regex fallback: ${(err as Error).message}`);
}
}
return this.commitTurn(prev, this.mergeAndCheck(prev.mapping, this.fallback!.anonymize(seeded)));
}
/** Merge a turn's result into the running mapping (de-collision) + anti-leak check. */
private mergeAndCheck(
seedMapping: Record<string, string>,
result: AnonymizationResult,
): { anon: string; mapping: Record<string, string>; legend: Record<string, string> } {
const { mapping, rename } = this.mergeMappings(seedMapping, result.mapping);
const anon = this.applyRename(result.anon, rename);
for (const ph of anon.match(PLACEHOLDER_RE) ?? []) {
if (!(ph in mapping)) throw new Error(`unmapped placeholder ${ph}`);
}
return { anon, mapping, legend: result.legend };
}
/** Fold a merged turn into the session: cap history, rebuild the cumulative legend. */
private commitTurn(
prev: AnonymizerSession,
turn: { anon: string; mapping: Record<string, string>; legend: Record<string, string> },
): {
anon: string;
mapping: Record<string, string>;
legend: Record<string, string>;
session: AnonymizerSession;
} {
const history = [...prev.history, turn.anon].slice(-this.historyMaxTurns);
const legend = this.buildLegend(history.join('\n'), { ...prev.legend, ...turn.legend });
const nextSession: AnonymizerSession = { mapping: turn.mapping, legend, history };
return { anon: turn.anon, mapping: turn.mapping, legend, session: nextSession };
}
/**
* Stateful convenience wrapper over {@link anonymizeTurn} for in-memory use.
* Holds the evolving session so callers just do `await conv.anonymize(text)`.
*/
conversation(initial?: AnonymizerSession): {
anonymize: (text: string) => Promise<AnonymizationResult>;
deanonymize: (text: string) => string;
session: () => AnonymizerSession;
} {
let session: AnonymizerSession = initial ?? { mapping: {}, legend: {}, history: [] };
return {
anonymize: async (text: string) => {
const r = await this.anonymizeTurn(text, session);
session = r.session;
return { anon: r.anon, mapping: r.mapping, legend: r.legend };
},
deanonymize: (text: string) => this.deanonymize(text, session.mapping),
session: () => session,
};
}
/** Replace known values (seed) by their placeholder, longest values first. */
private applyKnown(text: string, seed: Record<string, string>): string {
let out = text;

View File

@@ -11,6 +11,7 @@ export {
PLACEHOLDER_RE,
type AnonymizationResult,
type AnonymizerConfig,
type AnonymizerSession,
type LlmProvider,
type PatternDef,
type Logger,

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[],

View File

@@ -40,6 +40,39 @@ export interface LlmProvider {
texts: string[],
usedIds: string[],
): Promise<{ segments: string[]; mapping: Record<string, string>; legend: Record<string, string> }>;
/**
* OPTIONAL — anonymize one conversation turn with prior context so entity ids
* stay stable across turns. `ctx.history` is the (already anonymized) prior
* turns, `ctx.legend` the accumulated abbreviations, `ctx.usedIds` the ids
* already assigned, and `ctx.mapping` the placeholder→value table — the latter
* is only passed when the caller has opted in AND the provider is trusted with
* cleartext (it already sees the message being anonymized). Providers that omit
* this method still work: {@link Anonymizer.anonymizeTurn} falls back to the
* batch path.
*/
anonymizeInConversation?(
text: string,
ctx: {
history: string[];
legend: Record<string, string>;
usedIds: string[];
mapping?: Record<string, string>;
},
): Promise<AnonymizationResult>;
}
/**
* Serializable state for a multi-turn conversation, persisted by the caller
* (e.g. in a session store) and threaded through {@link Anonymizer.anonymizeTurn}
* so one entity keeps one id across the whole chat.
*/
export interface AnonymizerSession {
/** Placeholder → original value across the whole conversation. SECRET — keep it on your side. */
mapping: Record<string, string>;
/** Abbreviation → meaning across the whole conversation. Non-secret. */
legend: Record<string, string>;
/** Anonymized prior turns (most recent last), capped by `historyMaxTurns`. */
history: string[];
}
/** A structured-PII detector: a tag (e.g. `EMAIL`) and the global regex that finds it. */
@@ -84,6 +117,12 @@ export interface AnonymizerConfig {
nameHint?: RegExp;
/** Where fallback/diagnostic warnings go. Defaults to a no-op. */
logger?: Logger;
/**
* Max number of prior anonymized turns kept in an {@link AnonymizerSession}
* history (and thus fed back to the provider). Default 10. Caps token/latency
* growth over long conversations.
*/
historyMaxTurns?: number;
}
/**