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:
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
PLACEHOLDER_RE,
|
||||
type AnonymizationResult,
|
||||
type AnonymizerConfig,
|
||||
type AnonymizerSession,
|
||||
type LlmProvider,
|
||||
type PatternDef,
|
||||
type Logger,
|
||||
|
||||
@@ -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[],
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user