Couvre les corrections PII batch A/A-2, le NIR multi-ligne en flux reel, le gazetteer FINESS Corse derive depuis la base locale, et les tests de regression associes. Aucun build ni diffusion.
Trois détecteurs simples « layout/context-aware » (chantier v11.5 P0),
validés par 2 revues Codex + 10 tests adversariaux Qwen, 0 régression :
- RE_ADRESSE réécrit en grammaire de tokens (_RE_VOIE_TYPE + _RE_VOIE_TOKEN) :
capture initiales (« J. Loeb »), voies commémoratives à chiffres
(« 8 Mai 1945 »), apostrophes ' et ’, bornage à la ligne courante,
arrêt sur point post-mot (anti-débordement clinique).
- _mask_ville_gazetteers : retourne toujours un tuple (texte, liste) même
sans Aho-Corasick ; masque les communes Saint/St/Sainte/Ste multi-mots à
espaces (« St Martin de Hinx ») entièrement, sans exiger de contexte géo.
- DATE_NAISSANCE retiré de la propagation globale + DATE_NAISSANCE_GLOBAL
ajouté aux skip vector/raster : on ne masque plus une date nue sur tout le
document. La DDN reste masquée en contexte fort, page par page. Les dates
cliniques identiques à la DDN hors contexte sont préservées.
tests/unit/test_p0_layout_detectors.py : 38 tests dédiés (matrice adresse
générique, anti-FP, communes Saint, propagation DDN, 10 tests adversariaux
Qwen). Suite tests/unit complète : 147 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Configure numerical library and torch threading for H1, keep raster threading/timing instrumentation, remove CONCERTATION from forced masks after real PDF FP testing, and record coordination archive state.
Dernière fuite de l'audit_30. Cas Trackare : un nom composé "NOCENT-EJNAINI"
éclaté en colonnes devient "[NOM]-\nEJNAINI" — le 1er composant est masqué
par le NER mais le 2e reste en clair (ni span NER intact ni candidat regex ne
le couvre ; être dans paranames ne suffit pas sans candidat).
Fix : post-passe dans process_pdf (étape 3a-bis), après selective_rescan, qui
masque le token majuscule orphelin suivant immédiatement un "[NOM]-". Couvre
le texte ET le raster (NOM_GLOBAL). Réfute la conclusion de Qwen ("paranames
résoudra EJNAINI").
Validation audit_30 (29 docs) : score 98.3 → 98.5/100, LEAK SCORE 100/100
(0 fuite), 0 régression FP. tests/unit 85 passed. BA127127 : EJNAINI 7→0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Préparation à l'intégration du gazetteer paranames (Wikidata CC BY 4.0,
Sälevä & Lignos LREC-COLING 2024) qui couvrira les noms étrangers en
France absents du gazetteer INSEE (basques, maghrébins, asiatiques,
africains, etc.).
## Loader
- `_PARANAMES_NOMS_SET` + `_PARANAMES_LOADED` (cache global)
- `_load_paranames_noms()` : lazy load au 1er besoin
- Fichier cible : `data/paranames/noms_famille_world.txt.gz`
- Si fichier absent : retourne set vide, log INFO, comportement actuel
(INSEE seul) — fallback transparent
- Si erreur de lecture : log WARNING, fallback INSEE
## Intégration cross-validation
Dans `_cross_validate_name_candidates`, `is_in_insee` étendu :
is_in_insee = (tok_upper in insee_noms or tok_upper in insee_prenoms
or tok_upper in _load_paranames_noms())
Effets :
- En contexte "low" + non NER : un token comme OYARCABAL (basque) ou
EJNAINI (maghrébin) sera désormais accepté si présent dans paranames.
- Aucun changement pour noms FR (déjà dans INSEE).
- Aucune régression : si le fichier paranames n'est pas généré, le
comportement est strictement identique.
## Génération du gazetteer
Le script de génération `scripts/build_paranames_gazetteer.py` et le
fichier `data/paranames/noms_famille_world.txt.gz` sont produits par un
agent dédié en cours d'exécution. Commit séparé à venir avec :
- Script de génération
- README + attribution CC BY 4.0
- Fichier gazetteer
## Tests
74 passed sur 75 (1 test happy path Q-1) + 10 xfailed. 5 tests
synthetic_review cassés (non liés à ce commit — issue séparée du
CHCB cleanup à fixer dans un commit dédié).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Complète F3 (qui captait le nom APRÈS "Nom usuel :"). Dans certains
comptes-rendus type BACTERIO, l'identité patient sous forme
"NAME Prenom1 Prenom2" apparaît juste AVANT le label, sans label devant.
Cas typique BACTERIO 23232115 :
10.40
SIMONET Marie lise ← cette ligne, pas attrapée par F3
Nom usuel :
14/03/1985
OYARCABAL ← capturée par F3
Ajout de RE_EXTRACT_NAME_BEFORE_NOM_USUEL qui regarde la ligne
précédant directement le label "Nom usuel :" : si elle ressemble à
"MAJUSCULES Prenom Prenom" (NAME ≥4 chars + 1 à 3 tokens
en suite), on la capture en contexte "high" (champ DPI quasi-certain).
Validation sur exemple synthétique :
- F3 OYARCABAL : ['OYARCABAL'] ✅
- F2 SIMONET : ['SIMONET Marie lise'] ✅
Reste à valider sur retraitement audit_30 complet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-applique les remplacements dans anonymizer_core_refactored_onnx.py
(commentaires reverted par un linter entre les commits) et corrige
docs/coordination/inbox/for-dom/2026-06-02_qwen_owncloud-livraison-procedure.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Le pipeline ne reconnaissait pas le label "Nom usuel :" — utilisé dans
certains comptes-rendus type BACTERIO. Ajout d'une regex dédiée
RE_EXTRACT_NOM_USUEL qui :
1. Trouve "Nom usuel :" en début de ligne
2. Skippe les lignes qui ne commencent pas par une lettre majuscule
(date au format DD/MM/YYYY, placeholders entre crochets, lignes vides)
3. Capture le premier token en MAJUSCULES ≥4 chars
Cas couvert : BACTERIO 23232115 contient
SIMONET Marie lise
Nom usuel :
14/03/1985
OYARCABAL
OYARCABAL est ainsi extrait avec contexte "high" (champ DPI structuré
quasi-certain) et masqué.
Test unitaire rapide validé sur l'exemple ci-dessus.
Reste à faire : F2 (SIMONET — pattern NAME+PRENOM+PRENOM sans label) — non
trivial sans label, à implémenter avec heuristique contextuelle (top du doc,
etc.). Reporté.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## F1 — Décomposition noms composés (corrige GRAND, EJNAINI)
Quand le NER détecte un nom à trait d'union (ex "Romain BILLON-GRAND",
"Cécilia NOCENT-EJNAINI"), le regex `\bBILLON-GRAND\b` ne traverse pas le
saut de ligne du formatage Trackare en colonnes étroites ("BILLON-\nGRAND").
Solution dans `_apply_extracted_names` : pour chaque nom validé contenant un
`-` (et ≥5 chars), ajouter aussi les sous-tokens (≥4 chars) à `safe_names`.
Les sous-tokens héritent du `bypass_stopwords` du composé (cas Dr/Mme).
Validation sur audit_30 :
- GRAND : 17 → 0 occurrences ✅
- Score global : 97.9 → 98.3 (+0.4)
- leak_audit : 3 → 1
## F4 — Filet rescan résiduel élargi noms INSEE (OPT-IN)
Le rescan post-anonymisation ne couvrait que NIR/EMAIL/IBAN/TEL. Ajout
d'un check sur les tokens uppercase ≥4 chars présents dans le gazetteer
INSEE (`_INSEE_NOMS_FAMILLE`), hors stopwords médicaux, hors placeholders,
hors whitelist utilisateur.
**Désactivé par défaut** (`cfg["rescan"]["check_insee_names"] = False`).
Raison : INSEE contient beaucoup de mots français courants (VOIR, ALLO,
POLYGONE, MIDI, FAURE, …) qui produisent un sur-masquage massif. Sur le
corpus audit_30, F4 activé met 29/29 docs en quarantaine. Inutilisable
en l'état mais utile pour un futur profil "paranoid" avec filtre par
fréquence INSEE rare + dictionnaire français en exclusion.
À activer via :
cfg["rescan"]["check_insee_names"] = True
## Restant
- F2 (SIMONET) : pattern NAME+PRENOM+PRENOM → medium (à implémenter)
- F3 (OYARCABAL) : label "Nom usuel :" → high sur ligne suivante (à implémenter)
- EJNAINI : mystère — fix F1 devrait suffire mais ne suffit pas, à investiguer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implémentation de la traçabilité B-1 sur les sorties d'anonymisation.
## .audit.jsonl — entrée metadata en 1ère ligne
Chaque .audit.jsonl commence maintenant par une entrée :
{"type": "metadata",
"app_version": "0.11.0-mvp",
"build_date": "...",
"build_commit": "...",
"build_branch": "...",
"processed_at": "<iso>",
"document_name": "...",
"ocr_used": bool,
"extracted_chars": int,
"quarantine_flags": []}
Permet de prouver a posteriori avec quelle config un document a été
anonymisé (audit DPO / CNIL).
## XMP PDF — _apply_pseudo_xmp_metadata()
Helper appelé avant doc.save() dans redact_pdf_vector et redact_pdf_raster :
1. doc.set_metadata({}) — efface TOUTES les métadonnées source
(CRITIQUE : les PDF source peuvent contenir le nom patient dans
/Author, /Title, /Keywords)
2. Pose nos métadonnées : creator/producer "Pseudonymisation v...",
title="Document anonymise", author vide, keywords avec commit+ts
3. Garde-fou : log + overwrite si une métadonnée source survit
(defense in depth)
## Constantes module-level
- APP_VERSION = "0.11.0-mvp" (à incrémenter avant chaque rebuild release)
- BUILD_DATE/BUILD_COMMIT/BUILD_BRANCH chargés depuis build_info.py
(regénéré à chaque rebuild EXE). Fallback "dev/unknown" en dev.
## Tests
74 passed, 10 xfailed — pas de régression.
Ref: docs/coordination/inbox/for-dom/2026-05-29_consolide_pseudocode-Q1-v2.md §7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Étape E du sprint Q-1 — B-3 pré-flight.
Si extract_text_with_fallback_ocr retourne moins de SEUIL_TEXTE_MINI
(=100) caractères :
- log.warning systématique
- Si quarantine_mgr fourni : flag preflight_text_too_short (severity=full),
copie du PDF original dans quarantine_dir/ pour ré-essai manuel
- Return {} (pas de sortie texte/audit/PDF pour ce doc)
Couvre les cas : scan non-OCRisé, PDF vide, OCR raté.
Évite le pire scénario : un opérateur qui croit que son document est
anonymisé alors qu'aucune PII n'a même été détectée parce qu'il n'y
avait pas de texte à traiter.
Rétro-compat préservée : sans quarantine_mgr, le comportement reste
"return {}" + log au lieu du silence (toujours strictement meilleur).
Risque appelants : un caller qui suppose la présence des clés "text"/
"audit" dans le retour doit gérer le cas dict vide. À voir au runtime.
Ref: docs/coordination/inbox/for-dom/2026-05-29_consolide_pseudocode-Q1-v2.md §8
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Étape D3 du sprint Q-1 (sous-commit 3/3 pour process_pdf, finalise D).
Décision B du consolidé v2 : fallback raster SYSTÉMATIQUE (option 3a
validée par Dom). Si redact_pdf_vector rate :
1. Tente redact_pdf_raster avec les mêmes paramètres
2. Si raster OK :
- outputs["pdf_raster"] est rempli
- flag pdf_vector_fallback_to_raster (severity=partial) → signale
au DPO que le PDF livré est en qualité raster (moins précis)
3. Si raster rate aussi :
- flag pdf_redaction_failed avec détail des 2 erreurs
4. Décision A finalisée : si quarantine_mgr fourni, le .pseudonymise.txt
est copié dans quarantine_dir/ pour autoportance opérateur
(un seul dossier à consulter au lieu de naviguer entre 2)
Import ajouté : shutil (stdlib).
Rétro-compat préservée : si quarantine_mgr is None, le fallback raster
est tenté quand même (RGPD-friendly), mais sans flag ni copie texte.
Le bloc "also_make_raster_burn" qui suit reste inchangé — un appelant
qui veut un raster systématique en plus du vector continue de le forcer
via ce flag.
Ref: docs/coordination/inbox/for-dom/2026-05-29_consolide_pseudocode-Q1-v2.md §3 Décisions A+B, §10
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Étape D2 du sprint Q-1 (sous-commit 2/3 pour process_pdf) :
Avant : try/except Exception: pass sur redact_pdf_vector → le PDF
n'était pas généré mais l'opérateur n'en savait rien.
Maintenant :
- log.warning systématique de l'échec (rétro-compat : même si
quarantine_mgr is None, on log)
- Si quarantine_mgr fourni : flag pdf_redaction_failed (severity=partial)
- Le texte .pseudonymise.txt est déjà sorti avant ce bloc, donc on
ne raise pas — le doc sort en quarantaine partielle propre
Le fallback raster + copie texte en quarantaine pour autoportance
arrivent en D3.
Rétro-compat préservée : les appels actuels sans quarantine_mgr
voient seulement une nouvelle ligne de log.warning au lieu du silence.
Ref: docs/coordination/inbox/for-dom/2026-05-29_consolide_pseudocode-Q1-v2.md §1 cas #6, §3 Décision A
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avant : silence sur apply_redactions échec → PDF sortait sans
rédaction (fuite RGPD critique en milieu santé).
Maintenant : log.warning + raise → l'exception remonte à
process_pdf qui la traitera en étape D (try/flag Q-PDF).
Note transitoire : tant que process_pdf:4655 a encore
'except: pass', le comportement net est "PDF non généré
silencieusement". C'est strictement meilleur qu'avant (pas
de fuite) mais pas encore optimal (pas d'alerte opérateur).
L'étape D complète la chaîne avec QuarantineManager.flag().
Ref: docs/coordination/inbox/for-dom/2026-05-29_consolide_pseudocode-Q1-v2.md §1 cas #5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix(detect): EMAIL masqué avant _apply_overrides pour éviter que les
force_terms (ex: CHCB) ne cassent l'adresse — mh.lafitte@chcb.fr → [EMAIL]
- fix(corpus): expected 007 mis à jour ([EMAIL] à la place de mh.[NOM]@[MASK].fr)
- feat(tools): tools/simulate_admin_rule.py — CLI de simulation et validation
isolée d'une règle admin (--text, --file, --corpus, --all)
- fix(admin_rules): required_case_ids corrigés dans admin_rules.default.yml
(noms des répertoires du corpus synthétique mis à jour)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trois fixes qui font passer 009_multi_etablissements en vert et
ferment la liste des fuites identifiées par la couche 2.
#3 — `Centre Hospitalier Universitaire de Bordeaux` coupé sur deux lignes
Nouveau pattern `RE_ETAB_LINEBREAK` (strict) en pré-passe sur la page
entière, juste avant le découpage en lignes. Match `<TYPE>\n<suite>`
avec :
- TYPE limité (Centre Hospitalier, Hôpital, Clinique, Polyclinique,
CHU, CHRU, CHS) ;
- un seul `\n` autorisé entre TYPE et suite ;
- la suite démarre obligatoirement par un connecteur typique
(Universitaire, de, d', du, des, la, le, les) puis UN nom propre.
Évite le FP `CENTRE HOSPITALIER COTE BASQUE\nService d'anesthésie`
(le `\n` n'est pas immédiat après le type, donc pas de match).
#4 — `CHCB` en fin de phrase suivi de ` ;`
`_kv_value_only_mask` splittait `transféré au CHCB pour la rééducation ;`
sur le `;` du `SPLITTER` (`\s*[:|;\t]\s*`), produisant une value vide.
La key contenait CHCB mais n'était passée qu'à `_mask_critical_in_key`
qui ne couvre pas les force_terms admin_rules.
Fix : fallback sur `_mask_line_by_regex(line)` (qui appelle
`_apply_overrides` → force_terms) si la value est vide ou la key
dépasse 5 mots (heuristique narrative).
#5 — `Biarritz` non masqué après `[ETABLISSEMENT] à Biarritz`
`_mask_ville_gazetteers` skippait par sécurité toute ville détectée
juste après un placeholder établissement précédé de `de/du/d'/à`. Le
`à` était inclus pour éviter les FP, mais c'est la préposition de
LOCALISATION par excellence : `Clinique Aguilera à Biarritz` perd
Biarritz à tort. Restreint le skip à `de/du/d'` (qui sont des parties
de nom d'établissement type `CHU de Bordeaux`). `à` reste actif.
Couche 2 entièrement verte : 73 passed, 0 xfailed (avant : 72 + 1
xfailed). KNOWN_FAILURES vidé. La gate pytest est désormais le
contrat de non-régression sur 10 documents complets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Étend `RE_RPPS` pour tolérer 0 à 3 mots qualificateurs entre `RPPS`
et le séparateur `:` ou `-`. Couvre les variantes observées :
- RPPS prescripteur :
- RPPS du médecin signataire :
- RPPS de garde -
- N° RPPS :
Si un qualificateur est présent, le séparateur (`:` ou `-`) devient
obligatoire pour éviter d'aspirer du narratif (faux positif type
"Le RPPS est consulté pour vérifier 12345678901 dans la base").
La lambda `_repl_rpps` reconstruit `RPPS : [RPPS]` en sortie : le
qualificateur est consommé mais perdu (pas de fuite, choix cosmétique).
Cas 005_bacterio_complete passe désormais (retiré de KNOWN_FAILURES).
La fuite `10101010101` derrière `RPPS prescripteur :` est masquée.
Cohérent avec le cadrage section 10.1 (règle cœur générique
applicable à tout établissement de santé français — pas de
spécificité locale).
Tests : 72 passed, 1 xfailed (avant : 71 passed, 2 xfailed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trois nouveaux patterns cœur dans `_mask_structured_line` pour des
labels génériques qui n'étaient pas couverts par le pipeline kv_value
(le split key:value laissait fuir la valeur quand le label dépassait
les patterns existants `RE_EXTRACT_NOM_NAISSANCE`, `RE_EXTRACT_PRENOM`,
`RE_EXTRACT_VILLE_RESIDENCE`).
`RE_LABEL_NOM_VARIANTES` capture :
- Nom de jeune fille / de famille / de naissance(.)
- Nom d'usage / Nom marital / Nom marié
`RE_LABEL_PRENOM` capture :
- Prénom : / Prénoms : / Prénom de naissance / utilisé(e) / usuel
- Capture jusqu'à fin de ligne pour les énumérations virgulées
(Prénoms : Sabine, Marie → tout masqué).
`RE_LABEL_VILLE` capture :
- Ville : / Ville de résidence : / Ville de naissance :
- Capture jusqu'à fin de ligne (gère "Saint-Jean-de-Luz",
"Saint-Denis (974)", composés multi-tokens).
Effets de bord positifs :
- Le bug "Saint-Jean-de-Luz → [ETABLISSEMENT]-de-Luz" est corrigé :
le matcher `RE_LABEL_VILLE` masque toute la valeur en `[VILLE]`
AVANT que le gazetteer FINESS Aho-Corasick ne grignote "Saint-Jean".
Cas 006_trackare_soignants et 008_anesthesie_complete : alignement
des expected.txt sur cette amélioration.
Choix d'architecture (cf cadrage docs/cadrage-projet-anonymisation.md
section 10.1) : ces labels sont des règles cœur génériques applicables
à tout établissement de santé français. Légitimes en hardcodé. Les
patterns layout-specific (Bordeaux suffixe, CHCB en fin de phrase,
email cassé par force_term) seront branchés via admin_rules dans
l'étape suivante.
Cas 010_fiche_admission_minimale passe désormais (retiré de
KNOWN_FAILURES). Le xfail strict aurait signalé xpass.
Tests : 9 passed, 2 xfailed (avant : 8 passed, 3 xfailed sur
test_synthetic_review).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trois fixes regroupés issus de la session de revue couche 2 :
#6 — caractère ñ dans les patterns de noms
Étend les classes de caractères pour inclure Ñ/ñ (basque, hispanique).
Avant : `Beñat` → `[NOM]ñat` (fuite indirecte du suffixe).
Après : `Beñat` → `[NOM]` (capture complète).
Justification : usage prévu La Réunion + populations basques/
hispaniques. Si nécessaire on ajoutera Ã/ã, Õ/õ (portugais) plus
tard.
#10 — règle numéro adhérent mutuelle (nouveau)
Ajoute placeholder [ADHERENT] et `RE_NUM_ADHERENT` :
`(?:n[°o]?\s*|num[ée]ro\s+(?:d['’]\s*)?)adh[ée]rent[e]?\s*[:\-]?\s*([A-Z0-9]{6,15})`
Couvre `n°adhérent`, `n° adhérent:`, `Numéro d'adhérent :`,
`Numéro d'adhérente:`, `numero adherent`, alphanumérique 6-15.
Faux positif `Le patient est adhérent à la mutuelle.` non matché
(préfixe N°/numéro obligatoire).
Branché dans `_mask_structured_line` (pour conserver le préfixe
au moment du matching, avant le split key:value) et dans
`_mask_line_by_regex` (texte non-structuré).
#11 — NIR avant TEL pour éviter consommation prématurée
Réordonne RE_NIR avant RE_TEL dans `_mask_line_by_regex` et
`selective_rescan`. Le NIR au format espacé `2 73 04 65 100 100 88`
est testé d'abord (validation modulo 97). Si validé, masqué en
[NIR] avant que RE_TEL ne consomme les 10 chiffres centraux. Si
la clé échoue (faux positif), TEL reprend la main inchangé.
Avant : `2 73 04 65 100 100 68` → `2 73 [TEL] 68`.
Après : `2 73 04 65 100 100 68` → `[NIR]`.
Cas synthetic_review/010 corrigé : NIR de test mis à clé valide
(68 au lieu de 88), expected aligné sur [ADHERENT] et [NIR].
Le case 010 reste en xfail — fuites résiduelles ELIZONDO / Sabine
/ Bayonne (labels structurels Nom de jeune fille / Prénom / Ville
non couverts) à fixer dans le batch suivant.
Tests : 70 passed, 3 xfailed (inchangé). Pas de régression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- RE_SCAN_FILENAME_ARTIFACT : masque le suffixe numérique des noms de
fichiers internes type EXT2-[IPP]-2300249096.TIF qui fuyaient en sortie.
- _RE_VENUE_BEFORE_IPP : variante BACTERIO observée en production où
le N° venue est rejeté plusieurs lignes après le libellé, juste
avant IPP. Détection en phase 0i.
- _RE_FINAL_VENUE_BEFORE_IPP : nettoyage final pour le résiduel du
même layout BACTERIO si le numéro a survécu jusqu'à process_pdf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit manuel après batch QC : 20 occurrences de "Dr Ute" dans
trackare-03020576-23175616 non masquées. Audit jsonl confirme : 0 hit pour
"Ute" → pas détecté.
Cause : _add_candidate (deux implémentations, lignes 1908 et 2225) filtrait
len(token) < 4, empêchant la création du NameCandidate pour "Ute" (3 chars)
même avec bypass_stopwords=True. La cross-validation écrasait alors
all_names avec validated_names (vide pour Ute), et _apply_extracted_names
ne recevait donc jamais Ute.
Le commit 2f79f7c avait fait le fix uniquement dans _apply_extracted_names.
Fix incomplet : le filtre amont _add_candidate rejetait avant.
Correctif symétrique sur _add_candidate (×2) + _add_tokens_force_first :
accepter 3 chars UNIQUEMENT si bypass=True (contexte Dr/Mme) ET majuscule
initiale ET alpha pur. 2 chars reste filtré (initiales ambigues).
Validation :
- "DR. DURANTEAU Ute" matche bien RE_EXTRACT_DR_DEST et capture "DURANTEAU Ute"
- Audit produit "Ute DURANTEAU" en bloc + "DURANTEAU" seul (41 hits total)
- PDF redacted : 0 résiduel "Ute" (avant : 38)
Cas protégés :
- "Ute" accepté : bypass=True, U majuscule, alpha ✓
- "Les" refusé : bypass=True mais stopword (filtré ailleurs) ✓
- "JF" refusé : 2 chars, filtre longueur < 3 ✓
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fuite détectée lors du QC batch 22 : le nom "Douar" était dans l'audit
(NOM page 6) mais restait visible dans le PDF redacted_vector. Cause :
dans get_text('words') le word était 'Douar,nécessitant' (virgule collée
sans espace). _search_whole_word faisait un == strict après strip des
ponctuations frontières, mais la virgule était au MILIEU — pas stripée.
→ aucun match → aucun rectangle → fuite.
Fix : passe 2 dans _search_whole_word avec regex word-boundary sur le
texte complet du word (pattern `(?<![A-Za-zÀ-ÿ])token(?![A-Za-zÀ-ÿ])`)
+ bbox proportionnelle au ratio chars matched / chars total du word.
Approximation exacte sur polices monospace, précision ±pixels sur
polices proportionnelles — couverte par le rectangle de redaction.
Validation bout-en-bout sur trackare-BA042686-23090597 : "Douar" masqué
(0 page résiduelle). QC strict retombe de 1 anomalie à 0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Le pattern type utilisait [Cc]entre\s+[Hh]ospitalier : seule la 1re lettre
de chaque mot était ambidextre, la suite devait être en minuscules. "CENTRE
HOSPITALIER COTE BASQUE" (tout majuscule) échappait → compensé par regex
YAML force_mask_regex "Centre\s+Hospitalier\s+…".
Fix : utiliser (?i:…) case-insensitive localement sur les sous-motifs "type
d'établissement" et "déterminants" (de, du, la…) tout en gardant le nom
propre strict (1re lettre majuscule obligatoire). Évite les FP tout en
capturant les majuscules complètes.
Cas validés :
- "Centre Hospitalier de Bayonne" → match (inchangé)
- "CENTRE HOSPITALIER COTE BASQUE" → match (nouveau)
- "POLYCLINIQUE CÔTE BASQUE SUD" → match (nouveau)
- "CLINIQUE SAINT-JEAN" → match (nouveau)
- "examen hôpital de Bordeaux" → pas de match (exclusion préservée)
Test YAML stripped : CENTRE HOSPITALIER et COTE BASQUE sont maintenant
masqués par ETAB (regex/AC) au lieu de force_term. Après ce fix + Fix#4,
on peut retirer les regex "Centre\s+Hospitalier…" et "Polyclinique…" du YAML.
Non-régression : 122 hits sur trackare-18007562 avec YAML complet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Trois trous de détection identifiés par l'audit de règles :
1. Énumération "Bordeaux et Bayonne" / "Bordeaux, Bayonne, Biarritz" : la règle
contextuelle _RE_GEO_BEFORE n'acceptait que des déclencheurs directs (à, de,
hôpital de, urgences de…). Dans une énumération, la 2ème ville+ échappait.
Nouvelle passe 2 : propagation mutuelle entre hits AC adjacents liés par
" et " ou ", ". Itération à point fixe pour chaînes longues. Garde-fou :
chaque hit ≥ 5 lettres pour éviter FP sur communes courtes homonymes.
2. Code postal encore en chiffres : _RE_GEO_BEFORE n'acceptait que
[CODE_POSTAL] déjà masqué. Ajout de `\b\d{5}\s+` comme déclencheur pour
couvrir l'ordre dans lequel _mask_ville_gazetteers est appelée avant le
masquage du code postal.
3. Suffixe CEDEX : "BAYONNE CEDEX" capturait BAYONNE seul. Extension automatique
de la capture pour inclure " CEDEX" et " CEDEX N" adjacents.
Cas validés :
- "travaille à Bordeaux et Bayonne" → [VILLE] et [VILLE]
- "Régions : Bordeaux, Bayonne, Biarritz" → 3× [VILLE] (chaîne sans ancre)
- "64109 BAYONNE CEDEX" → [VILLE] (capture CEDEX inclus)
- "charge", "médecin et patient" → aucun FP
Non-régression : 122 hits sur trackare-18007562.
Après ce fix, on peut retirer BAYONNE, BAYONNE CEDEX du YAML force_mask_terms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Le matcher Aho-Corasick FINESS rejetait tous les mono-mots < 10 chars pour
éviter les faux positifs. Conséquence : EMBRUNS (7 chars), présent dans
etablissements_distinctifs.txt, était ignoré et devait être forcé en YAML
(LES EMBRUNS, REED LES EMBRUNS, EMBRUNS BIDART, regex [Ee]mbruns).
Nouveau fichier data/finess/mono_mots_distinctifs.txt contenant la whitelist
curée des mono-mots courts considérés comme distinctifs. Maintenance manuelle
(un mot par ligne, commentaires autorisés). Le matcher accepte un mono-mot
< 10 chars uniquement s'il est dans cette whitelist.
Initialisation : embruns, embrun (documents CHCB "Les Embruns").
Validation :
- _FINESS_AC matche maintenant "les embruns quelque part" et "embruns seul"
- Pas de régression sur trackare-18007562 (122 hits)
Après ce fix + futurs, on pourra retirer LES EMBRUNS / REED LES EMBRUNS /
EMBRUNS BIDART et regex [Ee]mbruns de force_mask_terms du YAML.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deux corrections exploitant mieux les gazetteers FINESS/INSEE pour réduire la
dépendance au YAML force_mask_terms.
1. scripts/build_finess_gazetteers.py : ne lisait que col 1 (finess_et) du CSV.
Les col 2 (entjur, entité juridique) étaient ignorés. ~48k numéros
juridiques manqués, dont 640780417 (CHCB entjur) forcé en YAML à cause
de cette lacune. Fix : lecture col 1 + col 2 avec déduplication.
Régénération : 101 941 → 150 436 numéros (+48 495).
2. anonymizer_core_refactored_onnx.py :
- _FINESS_ETAB_NAMES (122k noms) chargé mais jamais consulté après le
refactoring NER-first (le matching passe par l'Aho-Corasick sur
etablissements_distinctifs.txt). Suppression → -122k entrées RAM.
- _INSEE_PRENOMS (lowercase) et _INSEE_PRENOMS_SET (uppercase sans accents)
lisaient deux fois le même fichier prenoms_france.txt. Fusion en une
seule passe disque, les deux formes dérivées en mémoire. -36k lectures.
Validation :
- 640780417 présent dans _FINESS_NUMBERS après rebuild
- 122 hits sur trackare-18007562 (non-régression)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plantages signalés sous Windows : causes identifiées et corrigées.
1. anonymisation_onefile.spec : les fichiers data/stopwords_manuels.txt,
villes_blacklist.txt, dpi_labels_blacklist.txt, companion_blacklist.txt
n'étaient PAS inclus dans le bundle PyInstaller (seuls les sous-dossiers
data/bdpm, data/finess, data/insee l'étaient). Résultat en frozen : sets
vides, qualité dégradée, plus de faux positifs.
2. anonymizer_core_refactored_onnx.py : chargements robustifiés.
- Helper _load_txt_set avec try/except et logging WARNING si fichier absent
- Fallbacks intégrés (_DPI_LABELS_FALLBACK, _COMPANION_BLACKLIST_FALLBACK)
pour continuer à fonctionner si bundle partiel
- try/except sur stopwords_manuels.txt, villes_blacklist.txt, BDPM
3. launcher.py : UX repensée pour le chargement des modèles.
- SetupWindow (premier lancement) : auto-démarrage (plus de clic nécessaire),
progress bar avec étapes visuelles (⏳/✓/✗ par modèle), bouton relance si
échec, bouton "continuer malgré tout" pour modèles optionnels.
- Splash screen ajouté dans launch_gui() : le chargement des gazetteers
(INSEE 200k+ noms, FINESS 100k+ établissements) prend 15-30 s au démarrage
normal. Sans feedback, l'utilisateur croyait l'app plantée. Le splash
tourne pendant l'import (thread séparé, poll avec splash.after).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Suite de l'externalisation des règles. Trois listes étaient codées en dur dans
anonymizer_core_refactored_onnx.py et impossibles à modifier par les
établissements sans recompiler :
- _NEVER_MASK_AS_NAME (12 entrées) — labels DPI structurels
- _DPI_LABELS_BLACKLIST (14 entrées, doublon partiel du précédent)
- _COMPANION_BLACKLIST (~75 entrées) — spécialités, labos pharma, mots ambigus
Les deux premières fusionnées dans data/dpi_labels_blacklist.txt (11 entrées
uniques, comparaison case-insensitive). La troisième dans
data/companion_blacklist.txt (75 entrées, comparaison uppercase).
Ajout de deux clés YAML pour enrichissement par établissement :
- additional_dpi_labels (ex: "Service", "Statut")
- additional_companion_blacklist (ex: spécialités locales)
Les 3 niveaux cumulatifs habituels s'appliquent : code (vide) → fichiers data/
→ YAML config. Chargement au démarrage avec log INFO du nombre d'entrées.
Test trackare-18007562-23054899 : 122 hits, 0 régression, 0 DPI label masqué
comme NOM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug majeur depuis l'externalisation : la GUI v5.4 écrivait whitelist_phrases
(clé racine), mais le core ne lisait que whitelist.sections_titres /
noms_maj_excepts (imbriqué). _apply_whitelist post-masquage était par ailleurs
désactivée (1bd3495) sans remplacement.
Correctif :
- load_dictionaries() lit whitelist_phrases et alimente deux sets globaux
(_WHITELIST_NEVER_MASK_TOKENS, _WHITELIST_NEVER_MASK_PHRASES). Mots-outils
(de, du, le...) écartés pour éviter blocages collatéraux.
- _apply_extracted_names : check whitelist en pré-masquage, prime sur les
force_names (ex: "DUPONT" reste visible même après "Dr DUPONT").
- process_pdf : filtrage final de l'audit avant redact_pdf_vector. Les hits
multi-mots dont au moins un sous-token est whitelist sont retirés.
- redact_pdf_vector : check whitelist sur les sous-mots cherchés
individuellement quand le multi-mots n'est pas trouvé sur la page.
Validé sur trackare-18007562-23054899 :
- Avec whitelist BELLEAU : 0 hit dans audit, 31 occurrences préservées dans PDF
- Sans whitelist : 0 occurrence dans PDF (non-régression OK)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pyzbar interprétait les cellules de tableaux trackare comme des codes-barres
et les noircissait. Ajout d'un seuil minimum de surface (2000 px²) pour
filtrer les faux positifs sur les petites zones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Whitelist post-masquage désactivée : injectait des phrases au mauvais
endroit dans le texte anonymisé (bug critique)
- Labels DPI "Date", "Note", "Heure", "Type", "Saint", "Page" ajoutés à
_NEVER_MASK_AS_NAME et _DPI_LABELS_BLACKLIST pour empêcher leur
propagation globale comme noms de personnes
- Corrige "Date d'admission → [NOM] d'admission",
"Note d'évolution → [NOM] d'évolution", etc.
Score évaluation : 99.3/100 (fuites pré-existantes Sie/GRAND inchangées)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Les noms avec bypass_stopwords=True (contexte Dr/Mme confirmé) sont
maintenant toujours acceptés par la cross-validation, même s'ils sont
dans les stop-words médicaux (ex: Dr MASSE, Dr GRAND).
Note: les fuites "Sie" (3 chars) et "GRAND" (stop-word) existaient
déjà avant le refactoring NER-first (score 99.3 identique).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
La cross-validation NER (_cross_validate_name_candidates) gère désormais
les décisions contextuelles nom/terme-médical. Les stop-words purement
médicaux sont supprimés :
- data/stopwords_manuels.txt : 1307 → 233 entrées (uniquement les mots
ambigus qui sont aussi des noms/prénoms INSEE)
- _MEDICAL_STOP_WORDS_SET hardcodé : ~400 → 80 entrées essentielles
(mots courts, formes galéniques, titres hospitaliers)
- Les enrichissements BDPM (~7300), edsnlp (~2000) et fichier externe
sont conservés tels quels
Score qualité inchangé : 100/100 (A+), 0 fuite, 0 faux positif.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Step 5: anonymise_document_regex now accepts optional NER managers,
runs NER on the original (unmasked) text, and cross-validates
regex-extracted names against NER detections + INSEE gazetteers.
NER-only detections (names found by NER but missed by regex) are
also added. Falls back to original behavior when no NER is available.
Step 6: process_pdf passes NER managers into anonymise_document_regex
for NER-first cross-validation. The existing NER safety net pass on
masked text is preserved (double-pass: original + masked text).
Quality score: 100.0/100 (A+), zero regression.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add infrastructure for NER-first name validation without changing
existing behavior. New code only, quality score remains 100/100.
Step 1: Load INSEE family names (219K) and prenoms (33K) as
module-level gazetteers (_INSEE_NOMS_FAMILLE, _INSEE_PRENOMS_SET)
normalized uppercase without accents.
Step 2: Add _run_ner_on_original_text() that runs all available NER
models (EDS-Pseudo, GLiNER, CamemBERT-bio) on unmasked text and
returns deduplicated NerDetection list.
Step 3: Add NerDetection and NameCandidate dataclasses. Modify
_extract_document_names and _extract_trackare_identity to also
return NameCandidate lists with context_strength (high/medium/low)
metadata. Callers updated for new return values.
Step 4: Add _cross_validate_name_candidates() implementing decision
matrix: high context always accepted, medium/low validated against
NER confirmations, INSEE membership, and stopword filtering.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Toutes les listes de règles sont maintenant modifiables sans toucher
au code Python :
Fichiers de données (data/) :
- stopwords_manuels.txt : 1307 termes médicaux/techniques
- villes_blacklist.txt : 117 communes à ne pas matcher
- medicaments_stopwords.txt : 7312 médicaments BDPM (existant)
- Chargés automatiquement au démarrage
Config YAML (dictionnaires.yml) :
- additional_stopwords : mots supplémentaires par établissement
- additional_villes_blacklist : villes supplémentaires
- whitelist_phrases : phrases à ne jamais anonymiser
- force_mask_terms : mots à toujours masquer
Chaîne de chargement : code dur → fichiers data/ → YAML config
Les 3 niveaux se cumulent (union).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>