feat!: optional fail-closed fallback, legend output, richer prompt & docs
BREAKING CHANGE: regex fallback is now opt-in (patterns no longer defaults
to presets.swiss). With no fallback an LLM failure throws AnonymizationError
(fail-closed) and the pre-filter is bypassed; at least one of llm/patterns is
required. AnonymizationResult gains a required `legend`; anonymizeChunks seed
is now { mapping, legend? } and returns legend.
- prompt: model may coin new UPPERCASE abbreviations and returns a 'legende'
explaining every abbreviation used (French); backfilled by DEFAULT_LEGEND
- PatternDef.meaning surfaces in the legend; swiss/generic presets get meanings
- AnonymizationError (exported) wraps the cause on fail-closed
- README: drop the chatbot provenance line; add 'How it works' + nLPD sections
- 34 tests / 99% coverage; bump to 0.2.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,24 @@ export const DEFAULT_SYSTEM_PROMPT = [
|
||||
' NOM:M|F|U · DATE_NAISSANCE:<année> · AGE:Mineur|Adulte',
|
||||
' ADRESSE:Lieu|Rue|Ville|NPA|Pays · ORG:Entreprise|Ecole',
|
||||
'',
|
||||
'ABRÉVIATIONS DYNAMIQUES:',
|
||||
'- Si tu rencontres un TYPE d’entité, un ATTRIBUT ou un CONTEXTE non listé ci-dessus,',
|
||||
' INVENTE une abréviation COURTE en MAJUSCULES (lettres A–Z et "_" uniquement, ex. PER, NOM, M),',
|
||||
' suivant la même convention, et réutilise-la de façon cohérente partout.',
|
||||
'- N’utilise JAMAIS d’espaces, accents, chiffres ou symboles dans une abréviation.',
|
||||
'',
|
||||
'LÉGENDE:',
|
||||
'- Renvoie une "legende" : un objet associant CHAQUE abréviation utilisée (entité, attribut, contexte)',
|
||||
' à sa signification complète en français. Ex.: {"PER":"Personne","NOM":"Nom de famille","M":"Masculin"}.',
|
||||
'- La légende sert à expliquer les placeholders à un autre modèle ; elle ne contient AUCUNE donnée réelle.',
|
||||
'',
|
||||
'RÈGLES:',
|
||||
'1. Coréférence: la MÊME personne garde le MÊME identifiant (PER_1) dans tout le texte.',
|
||||
'2. N’anonymise JAMAIS les termes pédagogiques/techniques (langages, concepts, titres de cours, fonctions).',
|
||||
'3. Si AUCUNE donnée personnelle: renvoie le texte original et "mapping": {}.',
|
||||
'3. Si AUCUNE donnée personnelle: renvoie le texte original, "mapping": {} et "legende": {}.',
|
||||
'4. N’anonymise pas un placeholder déjà présent (idempotence).',
|
||||
'5. Sortie STRICTEMENT JSON valide: {"texte_anonymise": "...", "mapping": {"[PER_1.NOM:M]": "..."}}.',
|
||||
'5. Sortie STRICTEMENT JSON valide:',
|
||||
' {"texte_anonymise": "...", "mapping": {"[PER_1.NOM:M]": "..."}, "legende": {"PER": "Personne", "NOM": "Nom de famille", "M": "Masculin"}}.',
|
||||
].join('\n');
|
||||
|
||||
export interface OpenAICompatibleOptions {
|
||||
@@ -48,6 +60,20 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
|
||||
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
|
||||
class TransientError extends Error {}
|
||||
|
||||
/**
|
||||
* Coerce the model's `legende` into a string→string map, lenient by design: a
|
||||
* missing or malformed legend yields `{}` rather than failing the anonymization
|
||||
* (the legend is metadata; the `mapping` is what guarantees no leak).
|
||||
*/
|
||||
function asLegend(raw: unknown): Record<string, string> {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
|
||||
'\n\nMODE LOT (segments) :' +
|
||||
'\n- ENTRÉE : un objet JSON {"segments": ["…", "…"]}.' +
|
||||
@@ -55,8 +81,8 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
|
||||
(usedIds.length
|
||||
? `\n- Identifiants DÉJÀ attribués (réutilise-les pour les mêmes valeurs, n'en duplique AUCUN pour d'autres valeurs) : ${usedIds.join(', ')}.`
|
||||
: '') +
|
||||
'\n- SORTIE STRICTEMENT JSON : {"segments": ["…anonymisé…"], "mapping": {"[PER_1.NOM:M]": "…"}}.' +
|
||||
'\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.';
|
||||
'\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.';
|
||||
|
||||
/**
|
||||
* Build an {@link LlmProvider} backed by any OpenAI-compatible Chat Completions
|
||||
@@ -131,7 +157,11 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
|
||||
|
||||
async anonymize(text: string): Promise<AnonymizationResult> {
|
||||
const content = await chat(systemPrompt, text);
|
||||
const parsed = JSON.parse(content) as { texte_anonymise?: string; mapping?: Record<string, string> };
|
||||
const parsed = JSON.parse(content) as {
|
||||
texte_anonymise?: string;
|
||||
mapping?: Record<string, string>;
|
||||
legende?: Record<string, string>;
|
||||
};
|
||||
if (
|
||||
typeof parsed.texte_anonymise !== 'string' ||
|
||||
typeof parsed.mapping !== 'object' ||
|
||||
@@ -139,20 +169,24 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
|
||||
) {
|
||||
throw new Error('LLM_BAD_SHAPE');
|
||||
}
|
||||
return { anon: parsed.texte_anonymise, mapping: parsed.mapping };
|
||||
return { anon: parsed.texte_anonymise, mapping: parsed.mapping, legend: asLegend(parsed.legende) };
|
||||
},
|
||||
|
||||
async anonymizeBatch(
|
||||
texts: string[],
|
||||
usedIds: string[],
|
||||
): Promise<{ segments: string[]; mapping: Record<string, string> }> {
|
||||
): Promise<{ segments: string[]; mapping: Record<string, string>; legend: Record<string, string> }> {
|
||||
const system = systemPrompt + batchInstructions(usedIds);
|
||||
const content = await chat(system, JSON.stringify({ segments: texts }));
|
||||
const parsed = JSON.parse(content) as { segments?: string[]; mapping?: Record<string, string> };
|
||||
const parsed = JSON.parse(content) as {
|
||||
segments?: string[];
|
||||
mapping?: Record<string, string>;
|
||||
legende?: Record<string, string>;
|
||||
};
|
||||
if (!Array.isArray(parsed.segments) || typeof parsed.mapping !== 'object' || !parsed.mapping) {
|
||||
throw new Error('LLM_BATCH_BAD_SHAPE');
|
||||
}
|
||||
return { segments: parsed.segments, mapping: parsed.mapping };
|
||||
return { segments: parsed.segments, mapping: parsed.mapping, legend: asLegend(parsed.legende) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user