Files
anonymizer/examples/demo.mjs
Oussama Knouz 0f188803a8 chore: migrate to mobiletic-labs org and rename npm scope
- Git URLs: git.mobiletic.net/mobiletic/anonymizer -> .../mobiletic-labs/anonymizer
  (homepage, repository, bugs, CI badge, clone instructions).
- npm package renamed @mobiletic/anonymizer -> @mobiletic-labs/anonymizer
  across package.json, lockfile, README, CONTRIBUTING, examples, issue template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:05:47 +01:00

288 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Anonymizer, openAICompatibleProvider, AnonymizationError } from '@mobiletic-labs/anonymizer';
import { writeFileSync } from 'node:fs';
// ── LLM-only (no regex patterns) → everything is handled purely by the model.
// Infomaniak is a trusted processor (it already sees the cleartext being anonymized),
// so we feed the anonymizer the running mapping for the sharpest cross-turn coreference.
const anonymizer = new Anonymizer({
llm: openAICompatibleProvider({
baseUrl: process.env.INFOMANIAK_AI_BASE_URL,
apiKey: process.env.INFOMANIAK_AI_API_KEY,
model: process.env.GEMMA_MODEL,
timeoutMs: Number(process.env.PRIVACY_GEMMA_TIMEOUT_MS ?? 60000),
retries: 2,
includeMappingInContext: true,
}),
logger: { warn: (m) => console.warn(' ⚠ ', m) },
});
// Entities/attributes/context codes present in the default prompt. Anything in a
// result legend that is NOT here was COINED by the model on its own.
const BASE_VOCAB = new Set([
'PER',
'ORG',
'LOC',
'NOM',
'PRENOM',
'DATE_NAISSANCE',
'AGE',
'ADRESSE',
'EMAIL',
'TELEPHONE',
'AVS',
'IBAN',
'NSS',
'M',
'F',
'U',
'Mineur',
'Adulte',
'Ville',
'Rue',
'NPA',
'Pays',
'Entreprise',
'Ecole',
]);
const coined = (legend) => Object.keys(legend).filter((k) => !BASE_VOCAB.has(k));
// ── Single-message cases — complexity rises, and cases 35 carry RARE attributes
// NOT in the prompt (passport, matricule, residence permit, plate, health-insurance
// number, blood type, medical-record no., allergy) that the model must detect + name itself.
// All personal data is FICTIONAL.
const cases = [
{
title: '1 personne — inscription simple',
text: "Bonjour, je suis Camille Perret, mon e-mail est camille.perret@yopmail.com. Je souhaite m'inscrire à la formation « Bureautique de base ».",
},
{
title: '2 personnes — collègue + matricule',
text: "Salut, c'est Diego Fontana (identifiant dfontana, 079 400 11 22). Je m'inscris avec mon collègue Rui Almeida à la formation « Machiniste » ; le matricule interne de Rui est EMP-88231.",
},
{
title: '3 personnes + org — admission (passeport, permis, matricule)',
text: "Dossier d'admission : Léna Hofmann, née le 03.11.1998, passeport suisse X1234567 et matricule étudiant 18-402-901, est parrainée par sa tutrice Sofia Marchetti et par son employeur, la société Béton Léman SA. Léna dispose aussi d'un permis de séjour de type B.",
},
{
title: '4 personnes + org — RH (permis conduire, plaque, LAMal, groupe sanguin)',
text: "Bonjour, ici Patrick Nussbaum, responsable RH chez Migros Vaud (patrick.nussbaum@migros.ch, +41 21 924 11 11). J'inscris trois collaborateurs à « Sécurité au travail » : Chloé Aebischer (permis de conduire 5501234567, plaque VD 148 552), Deniz Yilmaz (numéro d'assuré LAMal 756.9911.2233.44, groupe sanguin A+) et Amir Haddad (permis de séjour C, matricule EMP-40127). Merci d'adresser la facture à l'IBAN CH70 0076 2011 6238 5295 7.",
},
{
title: '5+ personnes — dossier médical complet (biométrie, RCC, dossier médical, allergie)',
text: "Note de dossier complète : le patient mineur Noah Baumann (né le 12.06.2013, groupe sanguin B+, numéro d'assuré maladie 756.2211.9988.77, allergique aux arachides) est inscrit à l'atelier « Robotique junior ». Ses parents, Delphine Rieder (mère, permis de séjour C, 021 555 12 34) et Marco Baumann (père, passeport italien YA9087654), cosignent l'autorisation. Le suivi est assuré par le Dr Farah Haddad (n° RCC V123456), qui a ouvert le dossier médical DM-2025-0417. L'assurance est gérée par l'agent Kevin Zbinden de la caisse Helvetia. Pour accéder au laboratoire, Noah utilise l'identifiant biométrique BIO-7729.",
},
];
// ── Multi-turn conversations — escalating, with rarer attributes surfacing later.
// Harder conversations: 4+ turns, up to 4 people, and a LONG-RANGE back-reference
// (an attribute stated in turn 1 is recalled in a later turn) to stress session continuity.
const conversations = [
{
title: 'Inscription — rappel du matricule (T1 → T4)',
turns: [
"Bonjour, je m'appelle Yasmine Cottier, matricule étudiant 21-305-118, je m'inscris à « Développement web ».",
'Mon e-mail est yasmine.cottier@gmx.ch et mon téléphone 078 331 22 10.',
'Ma camarade Inès Lefort aimerait aussi rejoindre le cours.',
"Pouvez-vous rattacher mon matricule 21-305-118 au dossier d'Inès pour le projet de groupe ?",
],
},
{
title: 'Identité — même nom de famille + rappel du passeport (T1 → T4)',
turns: [
'Ici Tomás Varga, passeport P-CH 4451220, je confirme mon inscription.',
"J'ai également un permis de séjour de type C.",
"Mon épouse Ana Varga s'inscrit aussi ; son passeport est P-CH 4451221.",
'Utilisez le passeport P-CH 4451220 pour la facturation à mon nom.',
],
},
{
title: 'Transport — 3 personnes + rappel de la plaque (T1 → T4)',
turns: [
"Salut, c'est Bruno Steiner, plaque d'immatriculation GE 289 774, je viens en voiture.",
'Mes collègues Farah Haddad et Kevin Zbinden covoiturent avec moi.',
'Le permis de conduire de Farah est 6602345678.',
'Réservez la place de parking pour la plaque GE 289 774, merci.',
],
},
{
title: 'Santé — rappel du numéro LAMal (T1 → T5)',
turns: [
"Bonjour, je suis Salomé Girard, numéro d'assurée LAMal 756.9911.2233.44, apprentie infirmière.",
'Mon groupe sanguin est A+ et je suis allergique au latex.',
'Mon superviseur de stage est le Dr Killian Roux.',
'Ma collègue Priya Nair, groupe sanguin O-, partage le même planning.',
'Facturez le stage sur mon assurance LAMal 756.9911.2233.44.',
],
},
{
title: 'Dossier complexe — 4 personnes, rappel du groupe sanguin (T1 → T6)',
turns: [
'Je vous écris pour mon fils mineur, Noah Baumann, né le 12.06.2013, groupe sanguin B+.',
"Son numéro d'assuré maladie est 756.2211.9988.77.",
'Moi, sa mère Delphine Rieder, je gère le dossier ; mon permis de séjour est de type C.',
"Son père, Marco Baumann, passeport italien YA9087654, cosigne l'autorisation.",
'Le Dr Farah Haddad a ouvert le dossier médical DM-2025-0417 pour Noah.',
"Rappel : le groupe sanguin B+ de Noah doit figurer sur son badge d'accès au laboratoire.",
],
},
];
const line = (c = '─') => console.log(c.repeat(78));
const fmt = (o) => JSON.stringify(o, null, 2).replace(/\n/g, '\n ');
const results = [];
const convResults = [];
async function runCase(i, { title, text }) {
line('═');
console.log(`CAS ${i}${title}`);
line();
console.log('MESSAGE :', text);
try {
const t0 = Date.now();
const { anon, mapping, legend } = await anonymizer.anonymize(text);
const ms = Date.now() - t0;
const restored = anonymizer.deanonymize(anon, mapping);
const ok = restored === text;
console.log(`ANONYMISÉ : ${anon} (${ms} ms)`);
console.log('MAPPING 🔒 :', fmt(mapping));
console.log('LEGEND 🏷️ :', fmt(legend), ' coined:', coined(legend).join(', ') || '—');
console.log(ok ? '✓ round-trip identique' : '✗ round-trip DIFFÈRE');
results.push({ i, title, text, anon, mapping, legend, ms, ok });
} catch (err) {
const msg = err instanceof AnonymizationError ? err.message : String(err);
console.error('✗', msg);
results.push({ i, title, text, error: msg });
}
console.log();
}
async function runConversation(i, { title, turns }) {
line('═');
console.log(`CONVERSATION ${i}${title}`);
line();
const conv = anonymizer.conversation();
const rows = [];
for (const text of turns) {
try {
const { anon } = await conv.anonymize(text);
const ok = conv.deanonymize(anon) === text;
console.log(` utilisateur : ${text}`);
console.log(` anonymisé : ${anon}`);
rows.push({ text, anon, ok });
} catch (err) {
console.error(' ✗', err?.message ?? String(err));
rows.push({ text, error: err?.message ?? String(err) });
}
}
const s = conv.session();
console.log('MAPPING 🔒 cumulé :', fmt(s.mapping));
console.log('coined :', coined(s.legend).join(', ') || '—');
convResults.push({ i, title, rows, mapping: s.mapping, legend: s.legend });
console.log();
}
// ── README-ready Markdown ───────────────────────────────────────────────────
const esc = (v) => String(v).replace(/\|/g, '\\|');
function kvTable(obj, [h1, h2], markCoined = false) {
const rows = Object.entries(obj);
if (!rows.length) return '_(vide)_';
return [
`| ${h1} | ${h2} |`,
'| --- | --- |',
...rows.map(([k, v]) => `| \`${esc(k)}\` | ${esc(v)} |`),
].join('\n');
}
function legendTable(legend) {
const rows = Object.entries(legend);
if (!rows.length) return '_(vide)_';
return [
'| Abbrev. | Meaning | |',
'| --- | --- | --- |',
...rows.map(([k, v]) => `| \`${esc(k)}\` | ${esc(v)} | ${BASE_VOCAB.has(k) ? '' : '🆕 coined'} |`),
].join('\n');
}
function toMarkdown() {
const parts = [
'## Examples',
'',
'> Real learner ↔ platform chat messages anonymized **live by Gemma 4** (via Infomaniak). All personal',
'> data is **fictional**. `mapping` is the secret re-identification key (kept by the operator); `legend`',
'> is safe to send downstream. **🆕 = an abbreviation the model coined on its own** — the attribute was',
'> not in the base prompt (e.g. passport, matricule, residence permit, plate, blood type, medical record).',
'',
'### Single messages (complexity rising)',
'',
];
for (const r of results) {
parts.push(`#### Case ${r.i}${r.title}`, '');
if (r.error) {
parts.push(`> ${r.text}`, '', `\`${r.error}\``, '');
continue;
}
parts.push(
'**User message:**',
'',
`> ${r.text}`,
'',
'**Anonymized (what the model sees):**',
'',
`> ${r.anon}`,
'',
'**`mapping` 🔒 (secret):**',
'',
kvTable(r.mapping, ['Placeholder', 'Real value']),
'',
'**`legend` 🏷️ (shareable):**',
'',
legendTable(r.legend),
'',
`**Round-trip:** ${r.ok ? '✅ restored identically' : '⚠️ differs'} · ${r.ms} ms`,
'',
);
}
parts.push('### Multi-turn conversations (ids stay stable; new attributes appear as it escalates)', '');
for (const c of convResults) {
parts.push(
`#### Conversation ${c.i}${c.title}`,
'',
'| Turn | User message | Anonymized (model sees) |',
'| --- | --- | --- |',
);
c.rows.forEach((row, idx) => {
const shown = row.error ? `${row.error}` : esc(row.anon);
parts.push(`| ${idx + 1} | ${esc(row.text)} | ${shown} |`);
});
parts.push(
'',
'**Cumulative `mapping` 🔒:**',
'',
kvTable(c.mapping, ['Placeholder', 'Real value']),
'',
'**`legend` 🏷️:**',
'',
legendTable(c.legend),
'',
);
const co = coined(c.legend);
if (co.length) parts.push(`**Auto-coined attributes:** ${co.map((k) => `\`${k}\``).join(', ')}`, '');
}
return parts.join('\n');
}
console.log('\n@mobiletic-labs/anonymizer — démonstration live (Infomaniak)\n');
console.log(
`Modèle: ${process.env.GEMMA_MODEL || '(non défini)'} · Endpoint: ${process.env.INFOMANIAK_AI_BASE_URL || '(non défini)'}\n`,
);
for (let i = 0; i < cases.length; i++) await runCase(i + 1, cases[i]);
for (let i = 0; i < conversations.length; i++) await runConversation(i + 1, conversations[i]);
writeFileSync(new URL('./RESULTS.md', import.meta.url), toMarkdown());
line('═');
console.log(
`Terminé. Single: ${results.filter((r) => r.ok).length}/${results.length} round-trips OK · ` +
`${convResults.length} conversations. Résultats écrits dans RESULTS.md`,
);