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

@@ -79,6 +79,39 @@ describe('openAICompatibleProvider', () => {
expect(system).toContain('[PER_1.NOM:M]');
});
it('anonymizeInConversation includes used-ids + history, and the mapping ONLY when opted in', async () => {
const content = JSON.stringify({
texte_anonymise: '[PER_2.NOM:F]',
mapping: { '[PER_2.NOM:F]': 'X' },
legende: {},
});
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content } }] }) });
vi.stubGlobal('fetch', fetchMock);
const ctx = {
history: ['Je suis [PER_1.NOM:F]'],
legend: { PER: 'Personne' },
usedIds: ['[PER_1.NOM:F]'],
mapping: { '[PER_1.NOM:F]': 'Nora Steiner' },
};
// default: real values (mapping) are NOT sent
await openAICompatibleProvider(opts).anonymizeInConversation!('x', ctx);
let system = JSON.parse(fetchMock.mock.calls[0][1].body).messages[0].content;
expect(system).toContain('[PER_1.NOM:F]'); // used-ids present
expect(system).toContain('Je suis [PER_1.NOM:F]'); // anonymized history present
expect(system).not.toContain('Nora Steiner'); // real value withheld by default
// opted in: the mapping (real value) is included
await openAICompatibleProvider({ ...opts, includeMappingInContext: true }).anonymizeInConversation!(
'x',
ctx,
);
system = JSON.parse(fetchMock.mock.calls[1][1].body).messages[0].content;
expect(system).toContain('Nora Steiner');
});
it('throws on a non-OK HTTP status (no retry when retries: 0)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
vi.stubGlobal('fetch', fetchMock);