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

@@ -108,6 +108,42 @@ const { anon, mapping, legend } = await anonymizer.anonymizeChunks(retrievedChun
// pass `mapping` to deanonymize()/makeStreamDeanonymizer(), and `legend` to the downstream LLM.
```
### Multi-turn conversations
In a chat, anonymizing each message independently would renumber entities every turn (Adil could become
`PER_1` in turn 2 while Oussama was `PER_1` in turn 1). `anonymizeTurn` threads a serializable
`AnonymizerSession` so **one entity keeps one id across the whole conversation**:
```ts
let session; // persist this per conversation (Redis/DB); pass it back each turn
for (const message of userTurns) {
const turn = await anonymizer.anonymizeTurn(message, session);
session = turn.session; // { mapping, legend, history } — carries coreference forward
send(turn.anon, turn.legend); // → the downstream chatbot (placeholders only)
}
// restore the bot's placeholder-bearing reply for the user:
anonymizer.deanonymize(botReply, session.mapping);
```
Or the stateful wrapper for in-memory use:
```ts
const conv = anonymizer.conversation();
await conv.anonymize('Bonjour, je suis Oussama'); // → Oussama = [PER_1…]
await conv.anonymize('Mon amie Adil …'); // → Oussama stays [PER_1…], Adil = [PER_2…]
conv.deanonymize(botReply);
```
Under the hood: known values are re-substituted locally (`applyKnown`), the provider is told which ids are
taken, and new entities are de-collided into the session — so numbering never drifts.
**Trust boundary & the `mapping`.** The real boundary is "keep PII out of the _downstream chatbot_" — it
only ever receives placeholders + `legend`. Your **anonymizer** endpoint already sees the cleartext it's
asked to anonymize, so if (and only if) it's a _trusted_ processor you may give it richer context — set
`includeMappingInContext: true` on `openAICompatibleProvider` to also send the `mapping` for maximum
cross-turn accuracy. It's **off by default**; keep it off for third-party endpoints you don't trust with
raw values.
## Configuration
```ts