Compare commits

...

36 Commits

Author SHA1 Message Date
ea214db170 chore(yaml): nettoyer force_mask_terms — déléguer aux gazetteers nationaux
Suite aux fixes #1-5 (entjur FINESS, mono-mots distinctifs, énumérations
ville, RE_HOPITAL_VILLE ALL-CAPS), 11 entrées du YAML sont devenues
redondantes avec les détections automatiques.

Avant : 14 force_mask_terms + 4 force_mask_regex
Après : 4 force_mask_terms + 1 force_mask_regex

Retiré (couvert par gazetteers/regex) :
- CENTRE HOSPITALIER COTE BASQUE (et variantes) → ETAB via RE_HOPITAL_VILLE
- POLYCLINIQUE COTE BASQUE SUD (et variantes accentuées) → ETAB via RE_HOPITAL_VILLE
- 640780417 (entjur CHCB) → FINESS_NUMBERS après fix #1
- BAYONNE, BAYONNE CEDEX → VILLE via gazetteer + énumérations + suffixe CEDEX
- 64109 → CODE_POSTAL via regex (capture maintenant "64109 BAYONNE CEDEX" en bloc)
- LES EMBRUNS, REED LES EMBRUNS, EMBRUNS BIDART → ETAB via AC FINESS (mono-mots distinctifs)
- regex Centre Hospitalier / Polyclinique Côte Basque → fix #5 RE_HOPITAL_VILLE
- regex [Ee]mbruns → fix #3 mono_mots_distinctifs.txt

Conservé (irréductible local ou politique métier) :
- CHCB (sigle local non référencé FINESS)
- 'Dates du séjour :' (libellé administratif)
- CONCERTATION (mention RCP — politique métier)
- LABORATOIRE de BIOLOGIE MEDICALE (libellé administratif)
- regex adresse 13 Avenue Interne J. LOEB (filet, AC FINESS adresses suffit)

Validation sur trackare-18007562 :
- Avant : 122 hits (dont 7 force_term/force_regex)
- Après : 119 hits — disparition des doublons, capture améliorée
  (ex: "64109 BAYONNE CEDEX" capturé en bloc CODE_POSTAL au lieu de 3 hits séparés)
- Couverture identique : CENTRE HOSPITALIER, COTE BASQUE, BAYONNE, 64109 toujours masqués

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:08:41 +02:00
aa3db69a9b fix(regex): RE_HOPITAL_VILLE accepte les ALL-CAPS (CENTRE HOSPITALIER)
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>
2026-04-15 09:40:08 +02:00
83769f6e63 feat(ville): énumérations + CP nu + suffixe CEDEX dans règle contextuelle
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>
2026-04-15 09:37:55 +02:00
e6f3853426 feat(finess): whitelist de mono-mots distinctifs courts (EMBRUNS, etc.)
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>
2026-04-15 09:35:16 +02:00
fd95ae5f2a fix(finess): inclure les entjur + supprimer code mort _FINESS_ETAB_NAMES
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>
2026-04-15 09:33:07 +02:00
8e458c16ca fix(frozen): data/*.txt dans bundle, feedback UI pendant chargement modèles
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>
2026-04-15 08:50:42 +02:00
4b5925306e feat(gui): exposer additional_stopwords dans le panneau Paramètres avancés
Troisième liste paramétrable dans la GUI v5.4, après whitelist_phrases et
blacklist.force_mask_terms : "Mots à ne jamais identifier comme noms".
Cible les sigles, acronymes métier locaux, ou termes ALL-CAPS récurrents
qui ressemblent à des noms propres mais n'en sont pas.

Différence avec la whitelist :
- whitelist_phrases : terme spécifique à protéger même s'il a été masqué
  par regex/NER (filtre final sur l'audit + sous-mots de hits multi-mots)
- additional_stopwords : empêche le terme d'être candidat-nom dès l'amont
  (intégré à _MEDICAL_STOP_WORDS_SET, filtre toutes les étapes)

Wired dans _load_params, _save_params, _export_params, _import_params.
La nouvelle clé additional_stopwords est incluse dans le JSON d'échange
inter-établissements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:28:11 +02:00
59acf390f4 refactor: externaliser DPI labels et companion blacklist (modifiables sans recompiler)
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>
2026-04-14 10:26:18 +02:00
b5058b9c4b fix(whitelist): GUI whitelist_phrases enfin lue et appliquée par le core
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 (c157205) 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>
2026-04-14 10:23:09 +02:00
b23355ed23 docs: scripts de génération des fiches produit et technique DSI/RSSI/DPO
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:17:14 +02:00
51c75558bc fix: pyzbar FP sur tableaux — carrés noirs sur dates/heures dans les grilles
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>
2026-03-31 15:27:52 +02:00
2f19f7c470 fix: DR. Ute (3 chars), SAINT-GERMES composé, SODIUM MACO/BAX pharma
- force_names bypass le seuil 4 chars (prénoms courts après Dr/Mme : Ute, Eva)
- SAINT seul = bloqué, SAINT-xxx composé = accepté comme nom
- Labos pharma ajoutés aux stop-words + companion blacklist :
  MACO, AGUETTANT, RENAUDIN, ARROW, BIOGARAN, MYLAN, TEVA, ZENTIVA
- Score : 99.8/100 (amélioration, "Sie" corrigé)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:17:37 +02:00
c157205751 fix: labels DPI masqués (Date, Note, Type, Heure) + whitelist désactivée
- 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>
2026-03-31 12:07:51 +02:00
4d33610655 fix: cross-validation respecte bypass_stopwords pour les noms forcés (Dr/Mme)
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>
2026-03-31 11:07:59 +02:00
2a4b9d79a1 Revert "refactor: réduction stop-words manuels — NER cross-validation suffit"
This reverts commit fb7896f88d.
2026-03-31 11:04:51 +02:00
fb7896f88d refactor: réduction stop-words manuels — NER cross-validation suffit
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>
2026-03-31 09:26:54 +02:00
22fbf1c772 feat(ner-first): integrate NER-first flow into pipeline (steps 5-6)
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>
2026-03-31 08:38:56 +02:00
23e19e17e4 feat(ner-first): add NER-first architecture scaffolding (steps 1-4)
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>
2026-03-31 08:31:44 +02:00
219ac18854 chore: ajout launcher.py + spec PyInstaller au repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:17:33 +02:00
ac5c35ae2d feat: externalisation des listes — stop-words et villes modifiables sans code
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>
2026-03-31 07:45:42 +02:00
b2ee6ad835 feat: config externe à côté de l'exe — mise à jour sans recompiler
Au premier lancement, la config embarquée est copiée dans config/
à côté de l'exe. Les lancements suivants utilisent cette copie externe.

Workflow de mise à jour :
1. L'établissement exporte ses paramètres (JSON)
2. On fusionne avec merge_params.py
3. On leur envoie le nouveau dictionnaires.yml par email
4. Ils le déposent dans config/ à côté de l'exe
5. Aucune recompilation nécessaire

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:09:02 +02:00
898ad9d82d feat: export/import paramètres par email + script merge côté serveur
GUI :
- Bouton "Exporter pour envoi" → fichier JSON sur le Bureau avec
  whitelist + blacklist + version + date, prêt à envoyer par email
- Bouton "Importer" → charge un JSON et fusionne (sans doublons)

Serveur :
- scripts/merge_params.py : fusionne les JSON reçus des établissements
  dans la config maîtresse dictionnaires.yml
  Usage : python scripts/merge_params.py export1.json export2.json

Workflow :
1. L'établissement ajuste les paramètres dans la GUI
2. Clique "Exporter" → fichier JSON
3. Envoie par email
4. On fusionne avec merge_params.py
5. On reconstruit l'exe avec la config enrichie

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:58:47 +02:00
106f1fcd2e fix: sync texte↔raster + GUI listes whitelist/blacklist améliorées
Bug critique corrigé : les noms forcés (contexte Dr/Mme) comme "MASSE"
étaient masqués dans le texte mais pas dans le PDF raster car filtrés
par les stop-words médicaux. Nouveau kind "NOM_FORCE" qui bypass le
filtre stop-words dans les fonctions de redaction vector et raster.

GUI : remplacement des zones texte brut par des listes interactives
avec champ de saisie + bouton Ajouter + bouton Supprimer, fond coloré
(vert pour whitelist, rose pour blacklist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:34:51 +02:00
f9fbae1f27 feat: whitelist phrases + panneau paramètres avancés dans la GUI
- Nouvelle section whitelist_phrases dans dictionnaires.yml : phrases
  qui ne doivent jamais être anonymisées (FP récurrents)
- Fonction _apply_whitelist : restaure les phrases whitelistées après
  anonymisation, même si des mots ont été remplacés par des placeholders
- GUI : section "Paramètres avancés" repliable avec :
  - Zone texte whitelist (phrases à exclure)
  - Zone texte blacklist (mots à toujours masquer)
  - Bouton sauvegarder → persiste dans le YAML
- Phrases initiales : "classification internationale", "prise en charge",
  "bas de contention", "date de naissance", "code postal", etc.

Score évaluation maintenu à 100.0/100 (A+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:03:08 +02:00
dcccd60c39 chore: GUI v5.4 — version bump + étape 1 formats listés
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:42:51 +01:00
63a4a013a2 feat: GUI multi-formats + fichier unique + textes mis à jour
- Titre : "Pseudonymisation de vos documents"
- Sous-titre, étape 1, paramètres, bouton : textes adaptés
- Choix fichier unique : clic → menu "Dossier / Fichier"
  avec filedialog filtré par formats supportés
- 14 formats supportés : PDF, DOCX, ODT, RTF, TXT, HTML,
  JPEG, PNG, TIFF, BMP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:39:06 +01:00
437877e1c8 feat: support multi-formats — DOCX, images, ODT, RTF, TXT, HTML
Nouveau module format_converter.py : conversion automatique vers PDF
avant anonymisation. Formats supportés :
- PDF (passthrough)
- DOCX (python-docx → texte → PDF)
- ODT (odfpy → texte → PDF)
- RTF (striprtf → texte → PDF)
- TXT (texte brut → PDF via PyMuPDF)
- HTML (BeautifulSoup → texte → PDF)
- JPEG/PNG/TIFF/BMP (image embarquée → OCR docTR en aval)

Nouvelle fonction process_document() : wrapper qui gère la conversion
puis appelle process_pdf(). GUI mise à jour pour chercher tous les
formats supportés (plus seulement *.pdf).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:25:26 +01:00
3992b43925 fix: import sys manquant — crash 'name sys is not defined' en mode frozen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:06:58 +01:00
d1bdfb1aca fix: fenêtres fantômes PyInstaller — désactiver ProcessPoolExecutor en mode frozen
ProcessPoolExecutor relançait l'exe pour chaque sous-processus de
rastérisation sous PyInstaller --onefile, créant une fenêtre GUI par page.
En mode frozen, la rastérisation est maintenant séquentielle.

Aussi: remplacement du mutex Windows par un file lock (msvcrt.locking)
plus fiable pour la protection anti-multi-instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:51:54 +01:00
65a02952c5 fix: retour relecteur #2 — page scannée noire, labels DPI, stop-words
- Page scannée entièrement noire (OGC 258) : les images couvrant > 70%
  de la page ne sont plus noircies (document scanné ≠ logo/signature)
- Labels DPI "Nom [■] naissance" : tokens < 3 chars ("N", "S") exclus
  du raster pour éviter les FP sur les mots courts des labels
- Stop-words enrichis : betascrub, hibiscrub, fresubin, nutrison,
  résorbable, nombreuses, internationale, capsule, alfa, prothèses
- FINESS blacklist : "internationale", "international", "intercommunal"
- "classification [ETABLISSEMENT] de l'infection" → corrigé

Score évaluation maintenu à 100.0/100 (A+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:11:26 +01:00
ad7f1ffa8a fix: FP médicaments dans raster + texte — RE_EXTRACT_STAFF_ROLE + FINESS + stop-words
Bug #1 (critique) : RE_EXTRACT_STAFF_ROLE matchait à l'intérieur des mots
  (IDE dans METOCLOPRAMIDE, AS dans ATORVASTATINE) → ajout \b word boundaries
  et suppression du ? optionnel sur ASH (AS matchait partout)

Bug #2 : raster multi-mots utilisait page.search_for() (substring matching)
  → ajout vérification frontières de mots pour les tokens multi-mots
  dans redact_pdf_raster et redact_pdf_vector

FP FINESS Aho-Corasick :
  - "resistance" (Centre de la Résistance) matchait "résistance aux fluoroquinolones"
  - "radiotherapie" matchait "tumorectomie, radiothérapie et hormonothérapie"
  → ajout blacklist : resistance, radiotherapie, chimiotherapie, etc.

FP villes : "COU" (commune) matchait dans "prurit (cou, décolleté, dos)"
  → ajout COU, DOS, SEIN, BRAS à _VILLE_BLACKLIST

Stop-words : ajout "totale", "partielle", "prothese", "unicompartimentale"

Score évaluation maintenu à 100.0/100 (A+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:11:57 +01:00
2731bc1ce7 feat: OCR docTR par page — plus de seuil global, traite chaque page pauvre individuellement
L'OCR docTR est maintenant déclenché page par page (< 150 chars) au lieu
d'un seuil global sur tout le document. Permet de traiter les documents
mixtes (pages texte + pages scannées) sans pénaliser le temps de traitement
sur les pages déjà riches en texte.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:28:27 +01:00
7c05ff9aaf fix: téléphone +33(0) non détecté + noms médecins homonymes de termes médicaux
- RE_TEL : ajout du format +33(0)XXXXXXXXX (ex: +33(0)156125400)
- _add_tokens_force_first : tous les tokens après Dr/Mme/Mr sont maintenant
  dans force_names (bypass stop-words médicaux). Corrige la fuite de noms
  de médecins homonymes de termes médicaux (ex: Dr MASSE)

Score évaluation maintenu à 100.0/100 (A+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:33:32 +01:00
27d19ebed7 fix: corrections retours relecteur — fuites adresses/établissements + FP médicaments
Fuites corrigées :
- "Le BOURG" : nouveau regex RE_LIEU_DIT_SEUL pour lieux-dits courants
- "CABINET ETXEBARNONDOA" : nouveau regex RE_EXTRACT_CABINET
- "REED LES EMBRUNS" : ajouté force_mask_terms + force_mask_regex case-insensitive
- "au [ETABLISSEMENT] nocturne" : "long cours" exclu des phrases FINESS

Faux positifs corrigés :
- "OXYGENE LUNETTES" : "lunettes" ajouté aux stop-words
- "POTASSIUM CHLORURE" : "chlorure" ajouté aux stop-words
- Phrases FINESS génériques étendues (le bourg, le val, les pins...)

Score évaluation maintenu à 100.0/100 (A+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:04:08 +01:00
d957e72aff feat: vérification ressources GPU/RAM avant exécution + évaluateur 100/100
- Nouveau module scripts/check_resources.py : état GPU/VRAM/RAM/CPU,
  require_resources() et wait_for_resources() avec polling
- Intégré dans finetune_camembert_bio.py (8 Go VRAM + 8 Go RAM)
- Intégré dans run_batch_silver_export.py (workers × 4 Go RAM)
- Évaluateur : EVA et RAI ajoutés aux termes médicaux (score 100.0/100)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:27:33 +01:00
49ff464e6e feat: réduction FP + gazetteers adresses FINESS + batch parallèle + corrections multi-axes
- Token min length relevé de 2-3 → 4 chars (élimine FP EPO, IRC, SIB...)
- Stop-words enrichis : acronymes médicaux 3 lettres, termes pharma, soins infirmiers
- BDPM stop-words : ~7300 noms commerciaux + DCI/substances actives
- Gazetteers adresses FINESS : 63K patterns Aho-Corasick (position-preserving normalization)
- Filtre contextuel anatomique pour FINESS établissements
- Nouvelles regex : RE_CIVILITE_COMMA_LIST, RE_EXTRACT_NOM_UTILISE, RE_EXTRACT_PRENOM,
  RE_NUM_EXAMEN_PATIENT, RE_ADRESSE_LIEU_DIT, RE_CIVILITE_INITIALE, Dr X.NOM
- URLs complètes (RE_URL) + détection multiline
- N° venue inversé (layout-aware) + EPISODE/NDA dans _CRITICAL_PII_TYPES
- HospitalFilter désactivé pour ADRESSE/TEL/VILLE/EPISODE (identifient le patient)
- Batch silver export parallélisé (multiprocessing spawn, N workers)
- Seuil sur-masquage relevé à 8%, server.py enrichi (source regex/ner)
- Blacklist villes : COURANT, PARIS ; contexte villes étendu (UHCD, spécialités)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 09:26:56 +01:00
33 changed files with 412676 additions and 460 deletions

View File

@@ -22,6 +22,7 @@ import queue
import re import re
import shutil import shutil
import subprocess import subprocess
import sys
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -85,14 +86,43 @@ except ImportError:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constantes # Constantes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
APP_TITLE = "Pseudonymisation de PDF" APP_TITLE = "Pseudonymisation de vos documents"
APP_VERSION = "v5.0" APP_VERSION = "v5.4"
def _app_dir() -> Path: def _app_dir() -> Path:
"""Répertoire racine de l'application (compatible Nuitka standalone).""" """Répertoire racine de l'application (compatible PyInstaller/Nuitka)."""
if getattr(sys, 'frozen', False):
return Path(sys._MEIPASS)
return Path(__file__).resolve().parent return Path(__file__).resolve().parent
DEFAULT_CFG = _app_dir() / "config" / "dictionnaires.yml" def _exe_dir() -> Path:
"""Répertoire de l'exécutable (pour les fichiers persistants : config, logs)."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return Path(__file__).resolve().parent
def _resolve_config() -> Path:
"""Cherche la config en priorité à côté de l'exe, sinon dans l'app embarquée.
Si le fichier n'existe pas à côté de l'exe, copie la version embarquée
pour que l'utilisateur puisse la modifier sans recompiler.
"""
exe_cfg = _exe_dir() / "config" / "dictionnaires.yml"
app_cfg = _app_dir() / "config" / "dictionnaires.yml"
if exe_cfg.exists():
return exe_cfg
# Premier lancement : copier la config embarquée à côté de l'exe
if app_cfg.exists():
exe_cfg.parent.mkdir(parents=True, exist_ok=True)
import shutil
shutil.copy2(str(app_cfg), str(exe_cfg))
return exe_cfg
return app_cfg # fallback
DEFAULT_CFG = _resolve_config()
MODELS_DIR = _app_dir() / "models" MODELS_DIR = _app_dir() / "models"
DEFAULTS_CFG_TEXT = r""" DEFAULTS_CFG_TEXT = r"""
@@ -305,6 +335,8 @@ class App:
# --- Contrôle d'arrêt --- # --- Contrôle d'arrêt ---
self._stop_requested = False self._stop_requested = False
# --- Fichier unique (None = mode dossier) ---
self._single_file: Optional[Path] = None
# --- Construction UI --- # --- Construction UI ---
self._build_ui() self._build_ui()
@@ -382,7 +414,7 @@ class App:
tk.Label( tk.Label(
main, main,
text="Masquez automatiquement les données personnelles de vos documents PDF.", text="Masquez automatiquement les données personnelles de vos documents.",
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w", font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(0, 18)) ).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
@@ -392,7 +424,7 @@ class App:
# ÉTAPE 1 — Choix du dossier # ÉTAPE 1 — Choix du dossier
# ============================================================= # =============================================================
tk.Label( tk.Label(
main, text="1. Choisir les documents", font=self._f_body_bold, main, text="1. Choisir les documents ou fichiers (PDF, Word, Images, Texte)", font=self._f_body_bold,
bg=CLR_BG, fg=CLR_TEXT, anchor="w", bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(0, 6)) ).pack(fill=tk.X, padx=pad_x, pady=(0, 6))
@@ -414,7 +446,7 @@ class App:
self._folder_text_lbl = tk.Label( self._folder_text_lbl = tk.Label(
self._folder_inner, self._folder_inner,
text="Cliquez pour choisir un dossier (tous les PDF seront recherchés récursivement)", text="Cliquez pour choisir un dossier ou un fichier",
font=self._f_body, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY, font=self._f_body, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY,
) )
self._folder_text_lbl.pack(pady=(4, 0)) self._folder_text_lbl.pack(pady=(4, 0))
@@ -448,7 +480,7 @@ class App:
tk.Label( tk.Label(
info_inner, info_inner,
text=("\u2022 Recherche récursive de tous les PDF dans les sous-dossiers\n" text=("\u2022 Recherche récursive de tous les documents dans les sous-dossiers\n"
"\u2022 Sortie PDF Image (raster) — sécurité maximale, aucun texte résiduel\n" "\u2022 Sortie PDF Image (raster) — sécurité maximale, aucun texte résiduel\n"
"\u2022 Résultats dans le dossier « anonymise/ » à la racine"), "\u2022 Résultats dans le dossier « anonymise/ » à la racine"),
font=self._f_card_desc, bg=CLR_BLUE_LIGHT, fg=CLR_TEXT_SECONDARY, font=self._f_card_desc, bg=CLR_BLUE_LIGHT, fg=CLR_TEXT_SECONDARY,
@@ -480,7 +512,7 @@ class App:
buttons_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 4)) buttons_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 4))
self.btn_run = tk.Button( self.btn_run = tk.Button(
buttons_frame, text="Lancer la pseudonymisation", buttons_frame, text="Lancer l'anonymisation",
font=self._f_button, bg=CLR_PRIMARY, fg="white", font=self._f_button, bg=CLR_PRIMARY, fg="white",
activebackground="#1d4ed8", activeforeground="white", activebackground="#1d4ed8", activeforeground="white",
relief=tk.FLAT, cursor="hand2", pady=10, relief=tk.FLAT, cursor="hand2", pady=10,
@@ -502,9 +534,92 @@ class App:
main, text="Comment ça marche ?", font=self._f_small, main, text="Comment ça marche ?", font=self._f_small,
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2", bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
) )
help_lbl.pack(pady=(0, 18)) help_lbl.pack(pady=(0, 8))
help_lbl.bind("<Button-1>", lambda e: self._show_help()) help_lbl.bind("<Button-1>", lambda e: self._show_help())
# =============================================================
# SECTION PARAMÈTRES (repliable)
# =============================================================
self._params_visible = False
params_toggle = tk.Label(
main, text="\u2699 Paramètres avancés \u25B6", font=self._f_small,
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
)
params_toggle.pack(pady=(0, 4), padx=pad_x, anchor="w")
self._params_frame = tk.Frame(main, bg=CLR_BG)
# NE PAS pack — déplié à la demande
def _toggle_params(event=None):
if self._params_visible:
self._params_frame.pack_forget()
params_toggle.configure(text="\u2699 Paramètres avancés \u25B6")
else:
self._params_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
params_toggle.configure(text="\u2699 Paramètres avancés \u25BC")
self._params_visible = not self._params_visible
params_toggle.bind("<Button-1>", _toggle_params)
# --- Whitelist (phrases à ne pas anonymiser) ---
self._wl_listbox, self._wl_entry = self._build_phrase_list(
self._params_frame,
title="\u2705 Phrases à ne PAS anonymiser :",
placeholder="Ajouter une phrase à protéger...",
color_tag="#e8f5e9",
)
# --- Blacklist (phrases à toujours masquer) ---
self._bl_listbox, self._bl_entry = self._build_phrase_list(
self._params_frame,
title="\u26d4 Mots/phrases à TOUJOURS masquer :",
placeholder="Ajouter un mot ou phrase à masquer...",
color_tag="#fce4ec",
)
# --- Stop-words additionnels (mots à ne jamais identifier comme noms) ---
# Différent de la whitelist : agit en amont, pour les sigles, acronymes,
# termes métier locaux qui ressemblent à des noms mais n'en sont pas.
self._sw_listbox, self._sw_entry = self._build_phrase_list(
self._params_frame,
title="\u26a0 Mots à ne jamais identifier comme noms (sigles, acronymes...) :",
placeholder="Ajouter un mot (ex: sigle local, acronyme métier)...",
color_tag="#fff8e1",
)
# Boutons sauvegarder + exporter
btn_row = tk.Frame(self._params_frame, bg=CLR_BG)
btn_row.pack(fill=tk.X, pady=(4, 4))
export_btn = tk.Button(
btn_row, text="\u2709 Exporter pour envoi",
font=self._f_small, bg="#e3f2fd", fg="#1565c0",
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
command=self._export_params,
)
export_btn.pack(side=tk.LEFT)
import_btn = tk.Button(
btn_row, text="\u2B07 Importer",
font=self._f_small, bg="#fff3e0", fg="#e65100",
relief=tk.GROOVE, cursor="hand2", padx=10, pady=4,
command=self._import_params,
)
import_btn.pack(side=tk.LEFT, padx=(4, 0))
save_btn = tk.Button(
btn_row, text="Sauvegarder",
font=self._f_small, bg=CLR_PRIMARY, fg="white",
activebackground="#1d4ed8", activeforeground="white",
relief=tk.FLAT, cursor="hand2", padx=12, pady=4,
command=self._save_params,
)
save_btn.pack(side=tk.RIGHT)
# Charger les valeurs initiales depuis la config
self._load_params()
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 8))
# ============================================================= # =============================================================
# BARRE DE PROGRESSION (masquée) # BARRE DE PROGRESSION (masquée)
# ============================================================= # =============================================================
@@ -648,22 +763,71 @@ class App:
# Actions dossier # Actions dossier
# --------------------------------------------------------------- # ---------------------------------------------------------------
def _browse(self): def _browse(self):
"""Propose le choix entre dossier et fichier unique via un menu contextuel."""
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Choisir un dossier", command=self._browse_folder)
menu.add_command(label="Choisir un fichier", command=self._browse_file)
# Afficher le menu sous le curseur
try:
menu.tk_popup(self.root.winfo_pointerx(), self.root.winfo_pointery())
finally:
menu.grab_release()
def _browse_folder(self):
d = filedialog.askdirectory() d = filedialog.askdirectory()
if d: if d:
self._single_file = None
self.dir_var.set(d) self.dir_var.set(d)
self._update_folder_display() self._update_folder_display()
def _browse_file(self):
try:
from format_converter import SUPPORTED_EXTENSIONS
except ImportError:
SUPPORTED_EXTENSIONS = {".pdf"}
# Construire les filtres pour le dialogue
ext_list = " ".join(f"*{e}" for e in sorted(SUPPORTED_EXTENSIONS))
f = filedialog.askopenfilename(
title="Choisir un document à anonymiser",
filetypes=[
("Documents supportés", ext_list),
("PDF", "*.pdf"),
("Word", "*.docx"),
("Images", "*.jpg *.jpeg *.png *.tiff *.tif *.bmp"),
("Texte", "*.txt *.rtf *.odt *.html *.htm"),
("Tous", "*.*"),
],
)
if f:
self._single_file = Path(f)
self.dir_var.set(str(self._single_file.parent))
self._update_folder_display()
def _update_folder_display(self): def _update_folder_display(self):
folder = self.dir_var.get() folder = self.dir_var.get()
if not folder: if not folder:
return return
# Compter les PDF (récursif) is_single = getattr(self, '_single_file', None) is not None
pdf_count = 0
if is_single:
doc_count = 1
display_label = self._single_file.name
else:
# Compter les documents supportés (récursif)
try: try:
pdf_count = len([p for p in Path(folder).rglob("*.pdf") if p.is_file()]) from format_converter import SUPPORTED_EXTENSIONS
except ImportError:
SUPPORTED_EXTENSIONS = {".pdf"}
doc_count = 0
try:
doc_count = len([
p for p in Path(folder).rglob("*")
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
])
except Exception: except Exception:
pass pass
display_label = folder
# Vider et reconstruire l'intérieur # Vider et reconstruire l'intérieur
for w in self._folder_inner.winfo_children(): for w in self._folder_inner.winfo_children():
@@ -672,8 +836,9 @@ class App:
row = tk.Frame(self._folder_inner, bg=CLR_CARD_BG) row = tk.Frame(self._folder_inner, bg=CLR_CARD_BG)
row.pack(fill=tk.X) row.pack(fill=tk.X)
icon = "\U0001f4c4" if is_single else "\U0001f4c2" # 📄 ou 📂
tk.Label( tk.Label(
row, text="\U0001f4c2", font=(self._font_family, 16), row, text=icon, font=(self._font_family, 16),
bg=CLR_CARD_BG, bg=CLR_CARD_BG,
).pack(side=tk.LEFT, padx=(0, 8)) ).pack(side=tk.LEFT, padx=(0, 8))
@@ -681,7 +846,7 @@ class App:
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Chemin (tronqué si trop long) # Chemin (tronqué si trop long)
display_path = folder display_path = display_label
if len(display_path) > 60: if len(display_path) > 60:
display_path = "..." + display_path[-57:] display_path = "..." + display_path[-57:]
tk.Label( tk.Label(
@@ -689,9 +854,13 @@ class App:
bg=CLR_CARD_BG, fg=CLR_TEXT, anchor="w", bg=CLR_CARD_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X) ).pack(fill=tk.X)
suffix = "PDF trouvé (récursif)" if pdf_count <= 1 else "PDF trouvés (récursif)" if is_single:
subtitle = f"Fichier unique — {self._single_file.suffix.upper().lstrip('.')}"
else:
suffix = "document trouvé (récursif)" if doc_count <= 1 else "documents trouvés (récursif)"
subtitle = f"{doc_count} {suffix}"
tk.Label( tk.Label(
info_frame, text=f"{pdf_count} {suffix}", info_frame, text=subtitle,
font=self._f_small, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY, anchor="w", font=self._f_small, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X) ).pack(fill=tk.X)
@@ -709,19 +878,39 @@ class App:
# Lancement # Lancement
# --------------------------------------------------------------- # ---------------------------------------------------------------
def _run(self): def _run(self):
is_single = getattr(self, '_single_file', None) is not None
if is_single:
# Mode fichier unique
if not self._single_file.is_file():
messagebox.showwarning("Fichier introuvable", f"{self._single_file}")
return
folder = self._single_file.parent
pdfs = [self._single_file]
else:
# Mode dossier
folder = Path(self.dir_var.get().strip()) folder = Path(self.dir_var.get().strip())
if not folder.is_dir(): if not folder.is_dir():
messagebox.showwarning( messagebox.showwarning(
"Dossier invalide", "Dossier invalide",
"Choisissez un dossier contenant des PDF.", "Choisissez un dossier ou un fichier.",
) )
return return
try:
pdfs = sorted([p for p in folder.rglob("*.pdf") if p.is_file()]) from format_converter import SUPPORTED_EXTENSIONS
except ImportError:
SUPPORTED_EXTENSIONS = {".pdf"}
pdfs = sorted([
p for p in folder.rglob("*")
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
])
if not pdfs: if not pdfs:
exts = ", ".join(sorted(SUPPORTED_EXTENSIONS))
messagebox.showwarning( messagebox.showwarning(
"Aucun PDF", "Aucun document",
"Aucun fichier PDF trouvé\n(recherche récursive dans les sous-dossiers).", f"Aucun fichier supporté trouvé.\n"
f"Formats acceptés : {exts}\n"
f"(recherche récursive dans les sous-dossiers)",
) )
return return
@@ -779,8 +968,12 @@ class App:
and self._vlm_manager.is_loaded() and self._vlm_manager.is_loaded()
) )
outputs = core.process_pdf( # Utiliser process_document (multi-formats) si disponible,
pdf_path=pdf, # sinon fallback sur process_pdf (PDF uniquement)
_process_fn = getattr(core, 'process_document', None) or core.process_pdf
_path_key = "doc_path" if _process_fn.__name__ == "process_document" else "pdf_path"
outputs = _process_fn(
**{_path_key: pdf},
out_dir=outdir, out_dir=outdir,
make_vector_redaction=False, make_vector_redaction=False,
also_make_raster_burn=True, also_make_raster_burn=True,
@@ -962,6 +1155,248 @@ class App:
"« anonymise/ » à la racine du dossier sélectionné.", "« anonymise/ » à la racine du dossier sélectionné.",
) )
# ---------------------------------------------------------------
# Paramètres avancés (whitelist/blacklist)
# ---------------------------------------------------------------
def _build_phrase_list(self, parent, title: str, placeholder: str, color_tag: str):
"""Construit un widget liste + ajout/suppression pour les phrases."""
frame = tk.Frame(parent, bg=CLR_BG)
frame.pack(fill=tk.X, pady=(4, 8))
tk.Label(
frame, text=title, font=self._f_small,
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, pady=(0, 4))
# Zone de saisie + bouton ajouter
input_row = tk.Frame(frame, bg=CLR_BG)
input_row.pack(fill=tk.X, pady=(0, 4))
entry = tk.Entry(input_row, font=self._f_small, relief=tk.GROOVE, bd=1)
entry.insert(0, placeholder)
entry.configure(fg="#999")
def _on_focus_in(e):
if entry.get() == placeholder:
entry.delete(0, tk.END)
entry.configure(fg=CLR_TEXT)
def _on_focus_out(e):
if not entry.get().strip():
entry.insert(0, placeholder)
entry.configure(fg="#999")
entry.bind("<FocusIn>", _on_focus_in)
entry.bind("<FocusOut>", _on_focus_out)
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
def _add(event=None):
text = entry.get().strip()
if text and text != placeholder:
# Éviter les doublons
items = list(listbox.get(0, tk.END))
if text not in items:
listbox.insert(tk.END, text)
entry.delete(0, tk.END)
add_btn = tk.Button(
input_row, text="+ Ajouter", font=self._f_small,
bg=color_tag, fg=CLR_TEXT, relief=tk.GROOVE, cursor="hand2",
command=_add, padx=8,
)
add_btn.pack(side=tk.LEFT)
entry.bind("<Return>", _add)
# Liste des phrases
list_frame = tk.Frame(frame, bg=CLR_BG)
list_frame.pack(fill=tk.X)
listbox = tk.Listbox(
list_frame, height=4, font=("Consolas", 9),
relief=tk.GROOVE, bd=1, selectmode=tk.EXTENDED,
bg=color_tag,
)
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=listbox.yview)
listbox.configure(yscrollcommand=scrollbar.set)
listbox.pack(side=tk.LEFT, fill=tk.X, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Bouton supprimer
def _remove():
sel = listbox.curselection()
for idx in reversed(sel):
listbox.delete(idx)
rm_btn = tk.Button(
frame, text="Supprimer la sélection", font=self._f_small,
bg="#ffcdd2", fg="#b71c1c", relief=tk.GROOVE, cursor="hand2",
command=_remove, padx=8,
)
rm_btn.pack(anchor="e", pady=(2, 0))
return listbox, entry
def _load_params(self):
"""Charge les whitelist/blacklist depuis la config YAML."""
try:
cfg_path = Path(self.cfg_path.get())
if cfg_path.exists() and yaml is not None:
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
# Whitelist
wl = data.get("whitelist_phrases", [])
self._wl_listbox.delete(0, tk.END)
for phrase in wl:
if phrase and phrase.strip():
self._wl_listbox.insert(tk.END, phrase.strip())
# Blacklist
bl = data.get("blacklist", {}).get("force_mask_terms", [])
self._bl_listbox.delete(0, tk.END)
for term in bl:
if term and str(term).strip():
self._bl_listbox.insert(tk.END, str(term).strip())
# Stop-words additionnels
sw = data.get("additional_stopwords", [])
self._sw_listbox.delete(0, tk.END)
for term in sw:
if term and str(term).strip():
self._sw_listbox.insert(tk.END, str(term).strip())
except Exception:
pass
def _export_params(self):
"""Exporte les paramètres whitelist/blacklist dans un fichier JSON pour envoi par email."""
try:
import json as _json
from datetime import datetime
wl = list(self._wl_listbox.get(0, tk.END))
bl = list(self._bl_listbox.get(0, tk.END))
sw = list(self._sw_listbox.get(0, tk.END))
export_data = {
"version": APP_VERSION,
"date_export": datetime.now().isoformat(),
"etablissement": "", # à remplir par l'utilisateur
"whitelist_phrases": wl,
"blacklist_force_mask_terms": bl,
"additional_stopwords": sw,
"instructions": (
"Ce fichier contient les paramètres d'anonymisation personnalisés. "
"Envoyez-le par email à l'équipe technique pour mise à jour du programme."
),
}
# Proposer le Bureau comme destination par défaut
desktop = Path.home() / "Desktop"
if not desktop.exists():
desktop = Path.home() / "Bureau"
if not desktop.exists():
desktop = Path.home()
dest = filedialog.asksaveasfilename(
title="Exporter les paramètres",
initialdir=str(desktop),
initialfile="parametres_anonymisation.json",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("Tous", "*.*")],
)
if dest:
Path(dest).write_text(
_json.dumps(export_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
messagebox.showinfo(
"Export réussi",
f"Paramètres exportés dans :\n{dest}\n\n"
f"Vous pouvez envoyer ce fichier par email\n"
f"à l'équipe technique.",
)
except Exception as e:
messagebox.showerror("Erreur", f"Erreur à l'export :\n{e}")
def _import_params(self):
"""Importe des paramètres depuis un fichier JSON (fusionne avec l'existant)."""
try:
import json as _json
src = filedialog.askopenfilename(
title="Importer des paramètres",
filetypes=[("JSON", "*.json"), ("Tous", "*.*")],
)
if not src:
return
data = _json.loads(Path(src).read_text(encoding="utf-8"))
# Fusionner whitelist
new_wl = data.get("whitelist_phrases", [])
existing_wl = set(self._wl_listbox.get(0, tk.END))
added_wl = 0
for phrase in new_wl:
if phrase and phrase.strip() and phrase.strip() not in existing_wl:
self._wl_listbox.insert(tk.END, phrase.strip())
added_wl += 1
# Fusionner blacklist
new_bl = data.get("blacklist_force_mask_terms", [])
existing_bl = set(self._bl_listbox.get(0, tk.END))
added_bl = 0
for term in new_bl:
if term and str(term).strip() and str(term).strip() not in existing_bl:
self._bl_listbox.insert(tk.END, str(term).strip())
added_bl += 1
# Fusionner stop-words additionnels
new_sw = data.get("additional_stopwords", [])
existing_sw = set(self._sw_listbox.get(0, tk.END))
added_sw = 0
for term in new_sw:
if term and str(term).strip() and str(term).strip() not in existing_sw:
self._sw_listbox.insert(tk.END, str(term).strip())
added_sw += 1
version = data.get("version", "?")
date_exp = data.get("date_export", "?")[:10]
messagebox.showinfo(
"Import réussi",
f"Paramètres importés (v{version}, {date_exp}) :\n\n"
f" + {added_wl} phrase(s) ajoutée(s) à la whitelist\n"
f" + {added_bl} terme(s) ajouté(s) à la blacklist\n"
f" + {added_sw} mot(s) ajouté(s) aux stop-words\n\n"
f"Cliquez sur « Sauvegarder » pour appliquer.",
)
except Exception as e:
messagebox.showerror("Erreur", f"Erreur à l'import :\n{e}")
def _save_params(self):
"""Sauvegarde les whitelist/blacklist dans la config YAML."""
try:
cfg_path = Path(self.cfg_path.get())
if not cfg_path.exists() or yaml is None:
messagebox.showwarning("Erreur", "Fichier de configuration introuvable.")
return
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
# Whitelist phrases
data["whitelist_phrases"] = list(self._wl_listbox.get(0, tk.END))
# Blacklist terms
if "blacklist" not in data:
data["blacklist"] = {}
data["blacklist"]["force_mask_terms"] = list(self._bl_listbox.get(0, tk.END))
# Stop-words additionnels (mots à ne jamais identifier comme noms)
data["additional_stopwords"] = list(self._sw_listbox.get(0, tk.END))
cfg_path.write_text(
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
messagebox.showinfo("Paramètres", "Paramètres sauvegardés avec succès.")
except Exception as e:
messagebox.showerror("Erreur", f"Impossible de sauvegarder :\n{e}")
# --------------------------------------------------------------- # ---------------------------------------------------------------
# YAML (interne) # YAML (interne)
# --------------------------------------------------------------- # ---------------------------------------------------------------

View File

@@ -0,0 +1,56 @@
import os
block_cipher = None
app_dir = 'C:\\Users\\dom\\ai\\anonymisation'
datas = [
(os.path.join(app_dir, 'config'), 'config'),
(os.path.join(app_dir, 'data', 'bdpm'), os.path.join('data', 'bdpm')),
(os.path.join(app_dir, 'data', 'finess'), os.path.join('data', 'finess')),
(os.path.join(app_dir, 'data', 'insee'), os.path.join('data', 'insee')),
(os.path.join(app_dir, 'models', 'camembert-bio-deid', 'onnx'), os.path.join('models', 'camembert-bio-deid', 'onnx')),
(os.path.join(app_dir, 'detectors'), 'detectors'),
(os.path.join(app_dir, 'scripts'), 'scripts'),
]
# Fichiers directs dans data/ — IMPÉRATIF pour fonctionnement correct du core.
# Sans eux : stop-words/villes/DPI labels/companion blacklist sont des sets vides,
# ce qui dégrade la qualité d'anonymisation et peut masquer/laisser passer des faux-positifs.
for data_file in [
'stopwords_manuels.txt',
'villes_blacklist.txt',
'dpi_labels_blacklist.txt',
'companion_blacklist.txt',
]:
src = os.path.join(app_dir, 'data', data_file)
if os.path.exists(src):
datas.append((src, 'data'))
for pyfile in ['anonymizer_core_refactored_onnx.py', 'eds_pseudo_manager.py',
'gliner_manager.py', 'camembert_ner_manager.py',
'Pseudonymisation_Gui_V5.py']:
datas.append((os.path.join(app_dir, pyfile), '.'))
a = Analysis(
[os.path.join(app_dir, 'launcher.py')],
pathex=[app_dir],
datas=datas,
hiddenimports=[
'anonymizer_core_refactored_onnx', 'eds_pseudo_manager',
'gliner_manager', 'camembert_ner_manager', 'Pseudonymisation_Gui_V5',
'edsnlp', 'edsnlp.pipes', 'edsnlp.pipes.ner', 'edsnlp.pipes.ner.pseudo',
'spacy', 'spacy.lang.fr', 'gliner', 'onnxruntime',
'transformers', 'tokenizers', 'torch', 'pdfplumber',
'ahocorasick', 'sklearn', 'scipy', 'pydantic', 'yaml', 'PIL',
'loguru', 'regex',
],
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
name='Anonymisation',
debug=False,
strip=False,
upx=False,
console=False,
icon=None,
)

File diff suppressed because it is too large Load Diff

View File

@@ -15,18 +15,18 @@ whitelist:
- Praticien conseil - Praticien conseil
org_gpe_keep: false org_gpe_keep: false
blacklist: blacklist:
# Sigles et libellés propres à l'établissement non couverts par les gazetteers
# nationaux (FINESS / INSEE / BDPM). Évitez d'ajouter ici des noms d'hôpitaux,
# villes, codes postaux ou numéros FINESS — ils sont déjà détectés automatiquement.
force_mask_terms: force_mask_terms:
- CENTRE HOSPITALIER COTE BASQUE - CHCB # Sigle local non référencé FINESS
- CENTRE HOSPITALIER DE LA COTE BASQUE - 'Dates du séjour :' # Libellé administratif (politique masquage)
- POLYCLINIQUE COTE BASQUE SUD - CONCERTATION # Mention de RCP (politique métier)
- POLYCLINIQUE CÔTE BASQUE SUD - LABORATOIRE de BIOLOGIE MEDICALE # Libellé administratif générique
- CHCB
- '640780417'
- 'Dates du séjour :'
- CONCERTATION
force_mask_regex: force_mask_regex:
- 'Centre\s+Hospitalier\s+(?:de\s+(?:la\s+)?)?C[oôÔ]te\s+Basque' # Adresse précise du CHCB — couverte par l'AC FINESS adresses mais on garde
- 'Polyclinique\s+C[oôÔ]te\s+Basque\s+Sud' # la regex en filet de sécurité (encodages PDF, espaces non standards).
- '13\s*,?\s*Avenue\s+de\s+l.Interne\s+J\.?\s*LOEB\s+BP\s*\d+'
kv_labels_preserve: kv_labels_preserve:
- FINESS - FINESS
- IPP - IPP
@@ -38,6 +38,45 @@ regex_overrides:
placeholder: '[OGC]' placeholder: '[OGC]'
flags: flags:
- IGNORECASE - IGNORECASE
# Phrases à ne JAMAIS anonymiser (faux positifs récurrents)
# Ajouter ici les expressions qui sont masquées à tort.
# La correspondance est insensible à la casse.
whitelist_phrases:
- "classification internationale"
- "prise en charge"
- "bas de contention"
- "date de naissance"
- "lieu de naissance"
- "ville de résidence"
- "date de sortie"
- "date d'admission"
- "code postal"
# Mots supplémentaires à ne jamais masquer comme noms de personnes
# (complète les 9000+ stop-words intégrés)
additional_stopwords: []
# Exemple :
# - "votre_mot"
# Villes supplémentaires à ne jamais matcher comme lieux
# (complète les 115+ villes blacklistées intégrées)
additional_villes_blacklist: []
# Exemple :
# - "VOTRE_VILLE"
# Labels DPI supplémentaires à ne jamais masquer comme noms
# (complète data/dpi_labels_blacklist.txt)
# Utiliser pour : titres de colonnes, en-têtes de sections, libellés de champs
additional_dpi_labels: []
# Exemple :
# - "Service"
# - "Statut"
# Termes en MAJUSCULES à ne jamais propager comme noms compagnons
# (complète data/companion_blacklist.txt — spécialités, labos pharma, mots ambigus)
additional_companion_blacklist: []
# Exemple :
# - "VOTRE_SPECIALITE"
flags: flags:
case_insensitive: true case_insensitive: true
unicode_word_boundaries: true unicode_word_boundaries: true

15816
data/bdpm/CIS_bdpm.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
# Companion blacklist : termes en MAJUSCULES qui apparaissent à côté d'un nom
# connu mais qui NE SONT PAS des noms (spécialités médicales, labos pharma,
# mots courants ambigus). Évite la propagation FP : "DUPONT CARDIOLOGIE"
# ne propage pas "CARDIOLOGIE" comme nom.
#
# Format : un terme par ligne, en MAJUSCULES.
# Lignes vides et lignes commençant par # ignorées.
# Mots ambigus courants
ZONE
PARTI
PLAN
MAIN
FORT
FORTE
BILAN
MISE
NOTE
AIDE
BASE
FACE
DOSE
TIGE
VOIE
ONDE
SOIN
DEMI
MODE
CURE
PAGE
# Spécialités / services médicaux
CANCEROLOGIE
ONCOLOGIE
REANIMATION
RADIOLOGIE
CARDIOLOGIE
NEUROLOGIE
PNEUMOLOGIE
UROLOGIE
GERIATRIE
PEDIATRIE
NEPHROLOGIE
HEMATOLOGIE
OPHTALMOLOGIE
STOMATOLOGIE
ALLERGOLOGIE
RHUMATOLOGIE
DERMATOLOGIE
IMMUNOLOGIE
# Termes médicaux / courants (FP signalés OGC 21)
ALIMENTATION
AUGMENTATION
AMELIORATION
BILIAIRES
BILIAIRE
VOIES
BILI
MEDECINE
ENTERO
DOSSIER
AVIATION
SULFAMIDES
CLAVULANIQUE
MECILLINAM
TAZOBACTAM
TEMOCILLINE
ECOFLAC
FURANES
CONTENTION
ISOLEMENT
ELIMINATION
# Labos pharmaceutiques (FP dans tableaux prescriptions trackare)
MACO
AGUETTANT
RENAUDIN
LAVOISIER
COOPER
ARROW
BIOGARAN
MYLAN
TEVA
ZENTIVA
# Termes médicaux additionnels
PANCREATITE
INFECTIEUX
HEMODYNAMIQUE
SENSIBLE
VARIABLE
DOSAGE
CAT

View File

@@ -0,0 +1,16 @@
# Labels DPI / mots structurels à ne JAMAIS masquer comme noms
# (titres de colonnes, en-têtes de sections, libellés de champs DPI)
# Comparaison case-insensitive — un mot par ligne.
# Lignes vides et lignes commençant par # ignorées.
Date
Note
Heure
Type
Soin
Soins
Surv
Page
Presc
Saint
Sainte

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
# Mono-mots FINESS considérés comme distinctifs malgré leur longueur < 10 chars
# Permet au matcher Aho-Corasick d'accepter des noms d'établissements courts
# qui sont dans etablissements_distinctifs.txt mais filtrés par le seuil.
#
# ⚠ Ajouter uniquement des mots suffisamment RARES pour éviter les faux positifs
# (ex: "embruns" rare en français, OK — "parc", "jardin" trop génériques, NON).
#
# Un mot par ligne, lowercase, sans accents. Lignes vides et # ignorées.
embruns
embrun

File diff suppressed because it is too large Load Diff

218984
data/insee/noms2008nat_txt.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1184,8 +1184,8 @@ déglobulisation. O
Bladder O Bladder O
négatif. O négatif. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
antalgique O antalgique O
: O : O
Faux B-VILLE Faux B-VILLE
@@ -1515,8 +1515,8 @@ cette O
patiente O patiente O
altérée O altérée O
sur O sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
général, O général, O
OMS2/3. O OMS2/3. O
> O > O
@@ -1529,8 +1529,8 @@ du O
traitement O traitement O
antalgique. O antalgique. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
infectieux O infectieux O
: O : O
Pic O Pic O
@@ -2817,8 +2817,8 @@ apyrexie O
au O au O
décours. O décours. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
urologique O urologique O
: O : O
Un O Un O
@@ -2919,8 +2919,8 @@ oncologique O
Nette O Nette O
amélioration O amélioration O
sur O sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
général O général O
avec O avec O
la O la O

View File

@@ -2572,8 +2572,8 @@ de O
traitement O traitement O
antibiotique O antibiotique O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
hématologique O hématologique O
Anémie O Anémie O
autour O autour O

View File

@@ -1812,8 +1812,8 @@ de O
cette O cette O
décision. O décision. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
hématologique: O hématologique: O
Elle O Elle O
présente O présente O

View File

@@ -1420,8 +1420,8 @@ en O
charge O charge O
antalgique. O antalgique. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
de O de O
la O la O
gravité: O gravité: O

View File

@@ -1102,8 +1102,8 @@ de O
l'épisode O l'épisode O
aigüe. O aigüe. O
Sur O Sur O
le O le B-VILLE
plan B-VILLE plan I-VILLE
infectieux, O infectieux, O
présence O présence O
de O de O

1323
data/stopwords_manuels.txt Normal file

File diff suppressed because it is too large Load Diff

121
data/villes_blacklist.txt Normal file
View File

@@ -0,0 +1,121 @@
# Villes/communes à ne jamais matcher comme noms de lieux
# (homonymes de mots courants, parties du corps, etc.)
# Total : 117 entrées
AGEN
AIRE
ALBI
ANNE
AUCH
BARRES
BEAUNE
BILLE
BLOIS
BOIS
BOURG
BRAS
BREST
CENTRE
CERGY
CHAISE
CHARGE
COEUR
CONTRE
CORPS
COU
COURANT
COURS
CREIL
CROIX
DOLE
DOS
EST
EUROPE
EVIAN
FAUX
FOIX
FORT
FOSSES
FRANCE
GARDES
GIEN
GIVET
GRAND
GRAY
HYERES
ISLE
JEAN
JOUE
LACS
LAON
LENS
LIGNE
LIGNES
LONG
LUNEL
LURE
MAISON
MARCHE
MARIE
MARS
MARSA
MAURE
MEAUX
MENDE
MENTON
MERE
MONT
MORET
MOULIN
MURET
MURS
Médecin courant
NICE
NORD
NUITS
ONDRES
ORANGE
OUEST
OUST
PARIS
PAUL
PIERRE
PLACE
PLAN
PONT
PORT
PREY
PRISON
PUITS
QUATRE
RANS
RECY
REDON
REZE
RICHE
ROMANS
ROUGE
SAINT
SALLE
SALON
SARE
SEIN
SENS
SERVICE
SETE
SIGNES
SORE
SOURCE
SUD
TOURS
TRANS
VALLEE
VAUX
VEBRE
VERS
VERT
VIENNE
VILLE
VIRE
VITRE
prurit invalidant (COU, décolleté)

View File

@@ -166,23 +166,12 @@ class HospitalFilter:
Returns: Returns:
True si la détection doit être filtrée (faux positif) True si la détection doit être filtrée (faux positif)
""" """
# Filtrer par type # ADRESSE, CODE_POSTAL, VILLE, TEL : NE PAS filtrer.
if pii_type == "ADRESSE": # Les coordonnées hospitalières identifient indirectement le patient
return self.is_hospital_address(text) # et doivent être masquées (validé par contrôle humain 2026-03-12).
elif pii_type == "CODE_POSTAL": # EPISODE : NE PAS filtrer.
return self.is_hospital_postal_code(text) # Les numéros d'épisode identifient le patient (validé 2026-03-14).
elif pii_type == "VILLE":
return self.is_hospital_city(text)
elif pii_type == "TEL":
return self.is_hospital_phone(text)
elif pii_type == "EPISODE":
# Filtrer les épisodes qui proviennent du nom de fichier
# (répétés dans les en-têtes/pieds de page des documents trackare)
return self.is_episode_in_filename(text, filename)
return False return False
@@ -222,15 +211,17 @@ if __name__ == "__main__":
# Tests # Tests
test_cases = [ test_cases = [
("ADRESSE", "13, Avenue de l'Interne J", "", -1, True), # ADRESSE, CODE_POSTAL, VILLE, TEL : ne sont plus filtrés (identifient le patient)
("ADRESSE", "13, Avenue de l'Interne J", "", -1, False),
("ADRESSE", "22 LOT MENDI ALDE", "", -1, False), ("ADRESSE", "22 LOT MENDI ALDE", "", -1, False),
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, True), ("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, False),
("CODE_POSTAL", "64130", "", -1, False), ("CODE_POSTAL", "64130", "", -1, False),
("VILLE", "BAYONNE CEDEX", "", -1, True), ("VILLE", "BAYONNE CEDEX", "", -1, False),
("VILLE", "CHERAUTE", "", -1, False), ("VILLE", "CHERAUTE", "", -1, False),
("VILLE", "DROIT", "", -1, True), # Terme anatomique ("VILLE", "DROIT", "", -1, False),
("TEL", "05 59 44 35 35", "", -1, True), ("TEL", "05 59 44 35 35", "", -1, False),
("TEL", "0676085336", "", -1, False), ("TEL", "0676085336", "", -1, False),
# EPISODE : filtré uniquement si provient du nom de fichier trackare
("EPISODE", "23202435", "trackare-14004105-23202435", -1, True), ("EPISODE", "23202435", "trackare-14004105-23202435", -1, True),
("EPISODE", "23102610", "CRH_23102610", 0, False), ("EPISODE", "23102610", "CRH_23102610", 0, False),
] ]

View File

@@ -1,18 +1,18 @@
{ {
"date": "2026-03-12T10:24:59.261417", "date": "2026-03-12T17:16:25.993851",
"scores": { "scores": {
"global_score": 97.0, "global_score": 97.0,
"leak_score": 100.0, "leak_score": 100.0,
"fp_score": 90, "fp_score": 90,
"totals": { "totals": {
"documents": 29, "documents": 29,
"audit_hits": 2797, "audit_hits": 3186,
"name_tokens_known": 461, "name_tokens_known": 457,
"leak_audit": 0, "leak_audit": 0,
"leak_occurrences": 0, "leak_occurrences": 0,
"leak_regex": 0, "leak_regex": 0,
"leak_insee_high": 0, "leak_insee_high": 0,
"leak_insee_medium": 569, "leak_insee_medium": 570,
"fp_medical": 0, "fp_medical": 0,
"fp_overmasking": 2 "fp_overmasking": 2
} }
@@ -110,7 +110,7 @@
"leak_audit": 0, "leak_audit": 0,
"leak_regex": 0, "leak_regex": 0,
"leak_insee_high": 0, "leak_insee_high": 0,
"leak_insee_medium": 23, "leak_insee_medium": 24,
"fp_medical": 0, "fp_medical": 0,
"fp_overmasking": 0 "fp_overmasking": 0
}, },
@@ -206,7 +206,7 @@
"leak_audit": 0, "leak_audit": 0,
"leak_regex": 0, "leak_regex": 0,
"leak_insee_high": 0, "leak_insee_high": 0,
"leak_insee_medium": 32, "leak_insee_medium": 33,
"fp_medical": 0, "fp_medical": 0,
"fp_overmasking": 0 "fp_overmasking": 0
}, },
@@ -222,7 +222,7 @@
"leak_audit": 0, "leak_audit": 0,
"leak_regex": 0, "leak_regex": 0,
"leak_insee_high": 0, "leak_insee_high": 0,
"leak_insee_medium": 34, "leak_insee_medium": 32,
"fp_medical": 0, "fp_medical": 0,
"fp_overmasking": 0 "fp_overmasking": 0
}, },
@@ -246,7 +246,7 @@
"leak_audit": 0, "leak_audit": 0,
"leak_regex": 0, "leak_regex": 0,
"leak_insee_high": 0, "leak_insee_high": 0,
"leak_insee_medium": 26, "leak_insee_medium": 27,
"fp_medical": 0, "fp_medical": 0,
"fp_overmasking": 0 "fp_overmasking": 0
} }

256
format_converter.py Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Conversion de documents multi-formats vers PDF pour le pipeline d'anonymisation.
Formats supportés :
- PDF : passthrough (rien à faire)
- DOCX : python-docx → texte → PDF via PyMuPDF
- ODT : odfpy → texte → PDF via PyMuPDF
- RTF : striprtf → texte → PDF via PyMuPDF
- TXT : texte brut → PDF via PyMuPDF
- HTML : BeautifulSoup → texte → PDF via PyMuPDF
- JPEG/PNG/TIFF/BMP : image embarquée dans un PDF (OCR docTR en aval)
Usage :
from format_converter import convert_to_pdf, SUPPORTED_EXTENSIONS
pdf_path, is_temp = convert_to_pdf(Path("document.docx"))
# ... process_pdf(pdf_path, ...) ...
if is_temp:
pdf_path.unlink() # nettoyer le fichier temporaire
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Tuple
log = logging.getLogger(__name__)
# Extensions supportées (lowercase, avec le point)
SUPPORTED_EXTENSIONS = {
".pdf",
".docx",
".odt",
".rtf",
".txt", ".text",
".html", ".htm",
".jpg", ".jpeg",
".png",
".tiff", ".tif",
".bmp",
}
# Extensions images (OCR requis)
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp"}
def convert_to_pdf(input_path: Path) -> Tuple[Path, bool]:
"""Convertit un document en PDF pour le pipeline d'anonymisation.
Args:
input_path: chemin du document source
Returns:
(pdf_path, is_temporary): chemin du PDF + flag si fichier temporaire à nettoyer
Raises:
ValueError: format non supporté
RuntimeError: erreur de conversion
"""
suffix = input_path.suffix.lower()
if suffix == ".pdf":
return input_path, False
if suffix not in SUPPORTED_EXTENSIONS:
raise ValueError(
f"Format '{suffix}' non supporté. "
f"Formats acceptés : {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
)
# Fichier PDF temporaire dans le même dossier (pour préserver le contexte)
tmp_pdf = input_path.with_suffix(".tmp_convert.pdf")
try:
if suffix in _IMAGE_EXTENSIONS:
_image_to_pdf(input_path, tmp_pdf)
elif suffix == ".docx":
_docx_to_pdf(input_path, tmp_pdf)
elif suffix == ".odt":
_odt_to_pdf(input_path, tmp_pdf)
elif suffix == ".rtf":
_rtf_to_pdf(input_path, tmp_pdf)
elif suffix in {".txt", ".text"}:
_txt_to_pdf(input_path, tmp_pdf)
elif suffix in {".html", ".htm"}:
_html_to_pdf(input_path, tmp_pdf)
else:
raise ValueError(f"Format '{suffix}' non implémenté")
log.info("Converti %s%s", input_path.name, tmp_pdf.name)
return tmp_pdf, True
except Exception as e:
# Nettoyer en cas d'erreur
if tmp_pdf.exists():
tmp_pdf.unlink()
raise RuntimeError(f"Erreur conversion {input_path.name}: {e}") from e
def _image_to_pdf(img_path: Path, out_pdf: Path):
"""Embarque une image dans un PDF (1 page). L'OCR docTR traitera en aval."""
import fitz
doc = fitz.open()
# Ouvrir l'image pour obtenir ses dimensions
img_doc = fitz.open(str(img_path))
# Si c'est un TIFF multi-pages
for i in range(len(img_doc)):
page = img_doc[i]
rect = page.rect
pdf_page = doc.new_page(width=rect.width, height=rect.height)
pdf_page.insert_image(rect, filename=str(img_path) if i == 0 else None,
pixmap=img_doc[i].get_pixmap() if i > 0 else None)
img_doc.close()
doc.save(str(out_pdf))
doc.close()
def _text_to_pdf_pages(text: str, out_pdf: Path, font_size: float = 10.0):
"""Crée un PDF à partir de texte brut, avec pagination automatique."""
import fitz
doc = fitz.open()
# A4
page_w, page_h = 595, 842
margin = 50
usable_h = page_h - 2 * margin
line_height = font_size * 1.4
lines = text.split("\n")
page = doc.new_page(width=page_w, height=page_h)
y = margin
for line in lines:
if y + line_height > page_h - margin:
# Nouvelle page
page = doc.new_page(width=page_w, height=page_h)
y = margin
# Tronquer les lignes trop longues
max_chars = int((page_w - 2 * margin) / (font_size * 0.5))
display_line = line[:max_chars] if len(line) > max_chars else line
try:
page.insert_text(
fitz.Point(margin, y + font_size),
display_line,
fontsize=font_size,
fontname="helv",
)
except Exception:
# Fallback pour les caractères non supportés
safe = display_line.encode("latin-1", errors="replace").decode("latin-1")
page.insert_text(
fitz.Point(margin, y + font_size),
safe,
fontsize=font_size,
fontname="helv",
)
y += line_height
doc.save(str(out_pdf))
doc.close()
def _docx_to_pdf(docx_path: Path, out_pdf: Path):
"""Extrait le texte d'un DOCX et crée un PDF."""
from docx import Document
doc = Document(str(docx_path))
paragraphs = []
for para in doc.paragraphs:
paragraphs.append(para.text)
# Extraire aussi les tableaux
for table in doc.tables:
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells]
paragraphs.append(" | ".join(cells))
text = "\n".join(paragraphs)
if not text.strip():
raise RuntimeError("Document DOCX vide ou illisible")
_text_to_pdf_pages(text, out_pdf)
def _odt_to_pdf(odt_path: Path, out_pdf: Path):
"""Extrait le texte d'un ODT et crée un PDF."""
from odf.opendocument import load as odf_load
from odf.text import P as OdfP
from odf import teletype
doc = odf_load(str(odt_path))
paragraphs = []
for p in doc.getElementsByType(OdfP):
paragraphs.append(teletype.extractText(p))
text = "\n".join(paragraphs)
if not text.strip():
raise RuntimeError("Document ODT vide ou illisible")
_text_to_pdf_pages(text, out_pdf)
def _rtf_to_pdf(rtf_path: Path, out_pdf: Path):
"""Extrait le texte d'un RTF et crée un PDF."""
from striprtf.striprtf import rtf_to_text
raw = rtf_path.read_text(encoding="utf-8", errors="replace")
text = rtf_to_text(raw)
if not text.strip():
raise RuntimeError("Document RTF vide ou illisible")
_text_to_pdf_pages(text, out_pdf)
def _txt_to_pdf(txt_path: Path, out_pdf: Path):
"""Convertit un fichier texte brut en PDF."""
# Tenter plusieurs encodages
for enc in ("utf-8", "latin-1", "cp1252"):
try:
text = txt_path.read_text(encoding=enc)
break
except UnicodeDecodeError:
continue
else:
text = txt_path.read_bytes().decode("utf-8", errors="replace")
if not text.strip():
raise RuntimeError("Fichier texte vide")
_text_to_pdf_pages(text, out_pdf)
def _html_to_pdf(html_path: Path, out_pdf: Path):
"""Extrait le texte d'un fichier HTML et crée un PDF."""
from bs4 import BeautifulSoup
raw = html_path.read_text(encoding="utf-8", errors="replace")
soup = BeautifulSoup(raw, "html.parser")
# Supprimer scripts et styles
for tag in soup(["script", "style"]):
tag.decompose()
text = soup.get_text(separator="\n")
# Nettoyer les lignes vides multiples
import re
text = re.sub(r"\n{3,}", "\n\n", text)
if not text.strip():
raise RuntimeError("Document HTML vide ou illisible")
_text_to_pdf_pages(text, out_pdf)

374
launcher.py Normal file
View File

@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Launcher Windows — single-instance + download models on first run + launch GUI."""
import os
import sys
import traceback
import tkinter as tk
from tkinter import ttk, messagebox
from pathlib import Path
import threading
import logging
# ---------------------------------------------------------------------------
# Single-instance guard (lock file in user's temp directory)
# ---------------------------------------------------------------------------
_lock_file = None
_lock_fd = None
def _ensure_single_instance():
"""Prevent multiple instances using a lock file.
Works reliably on Windows and Linux, including PyInstaller --onefile."""
global _lock_file, _lock_fd
import tempfile
_lock_file = Path(tempfile.gettempdir()) / "anonymisation_chcb.lock"
try:
if sys.platform == "win32":
import msvcrt
_lock_fd = open(_lock_file, "w")
msvcrt.locking(_lock_fd.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
_lock_fd = open(_lock_file, "w")
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (OSError, IOError):
return False
except Exception:
return True
# ---------------------------------------------------------------------------
# Path resolution for PyInstaller frozen exe
# ---------------------------------------------------------------------------
if getattr(sys, 'frozen', False):
APP_DIR = Path(sys._MEIPASS)
EXE_DIR = Path(sys.executable).parent
else:
APP_DIR = Path(__file__).resolve().parent
EXE_DIR = APP_DIR
# Log file next to the exe
LOG_FILE = EXE_DIR / "anonymisation.log"
logging.basicConfig(
filename=str(LOG_FILE),
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("launcher")
# Make embedded modules importable
sys.path.insert(0, str(APP_DIR))
os.chdir(str(APP_DIR))
log.info(f"APP_DIR={APP_DIR}")
log.info(f"EXE_DIR={EXE_DIR}")
log.info(f"frozen={getattr(sys, 'frozen', False)}")
MODELS_DIR = APP_DIR / "models"
def check_models_ready():
"""Check that the CamemBERT-bio ONNX model is present."""
onnx_path = MODELS_DIR / "camembert-bio-deid" / "onnx" / "model.onnx"
ok = onnx_path.exists()
log.info(f"CamemBERT ONNX present: {ok} ({onnx_path})")
return ok
def launch_gui():
"""Launch the main GUI with a splash screen during the slow core import.
Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements,
BDPM 7k+ médicaments) prend 1030 s. Sans feedback visuel, l'utilisateur
croit que l'application est plantée. Le splash permet d'indiquer l'avancée.
"""
log.info("Launching GUI...")
splash = tk.Tk()
splash.title("Anonymisation")
splash.geometry("440x200")
splash.resizable(False, False)
frame = ttk.Frame(splash, padding=20)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Anonymisation", font=("", 14, "bold")).pack(pady=(0, 4))
ttk.Label(frame, text="Pseudonymisation de documents médicaux",
foreground="#666666").pack(pady=(0, 12))
status_var = tk.StringVar(value="Chargement des dictionnaires médicaux…")
ttk.Label(frame, textvariable=status_var, foreground="#1a568e").pack(pady=(0, 8))
pb = ttk.Progressbar(frame, mode="indeterminate", length=380)
pb.pack(pady=4)
pb.start(10)
ttk.Label(frame, text="Première phase : ~1530 s",
foreground="#999999", font=("", 8)).pack(pady=(6, 0))
result = {"done": False, "error": None}
def _do_import():
try:
import anonymizer_core_refactored_onnx # noqa
log.info("Core imported OK")
result["step"] = "gui"
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI module imported OK")
except Exception as e:
result["error"] = f"{e}\n{traceback.format_exc()}"
log.error(f"Import error: {result['error']}")
finally:
result["done"] = True
threading.Thread(target=_do_import, daemon=True).start()
def _poll():
# Met à jour le message selon l'étape atteinte par le thread d'import
if result.get("step") == "gui" and not result["done"]:
status_var.set("Chargement de l'interface…")
if result["done"]:
pb.stop()
try:
splash.destroy()
except Exception:
pass
if result["error"]:
try:
messagebox.showerror(
"Erreur",
f"Erreur au lancement :\n\n{result['error'].splitlines()[0]}\n\n"
f"Voir {LOG_FILE} pour les détails.",
)
except Exception:
pass
return
# Lancement de la GUI principale
try:
import Pseudonymisation_Gui_V5
log.info("Starting mainloop…")
root = tk.Tk()
Pseudonymisation_Gui_V5.App(root)
root.mainloop()
except Exception as e:
log.error(f"GUI error: {e}\n{traceback.format_exc()}")
try:
messagebox.showerror(
"Erreur",
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
)
except Exception:
pass
else:
splash.after(200, _poll)
splash.after(200, _poll)
splash.mainloop()
class SetupWindow:
"""Setup window for first launch — auto-démarre le téléchargement des modèles.
Affiche un suivi détaillé par modèle (EDS-Pseudo, GLiNER, CamemBERT-bio) avec
indicateurs visuels (⏳ en cours, ✓ succès, ✗ échec). Permet de relancer en
cas d'erreur. Lancement auto de la GUI une fois tous les modèles prêts.
"""
# Liste ordonnée des étapes de chargement. Chaque entrée :
# (clé interne, libellé, taille approx, fonction de chargement)
STEPS = [
("eds_pseudo", "EDS-Pseudo (CamemBERT clinique)", "~450 Mo"),
("gliner", "GLiNER (détection PII zero-shot)", "~300 Mo"),
("camembert_onnx", "CamemBERT-bio ONNX (embarqué)", "local"),
]
def __init__(self):
self.root = tk.Tk()
self.root.title("Anonymisation — Configuration initiale")
self.root.geometry("620x450")
self.root.resizable(False, False)
frame = ttk.Frame(self.root, padding=20)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
font=("", 13, "bold")).pack(pady=(0, 4))
ttk.Label(
frame,
text=("Au premier lancement, les modèles de détection doivent être téléchargés\n"
"depuis HuggingFace. Cette opération est unique — durée 3 à 10 minutes\n"
"selon votre connexion internet. Merci de patienter."),
justify="center", foreground="#555555",
).pack(pady=(0, 12))
# Barre de progression globale
self.progress = ttk.Progressbar(frame, mode="determinate",
length=560, maximum=len(self.STEPS))
self.progress.pack(pady=(0, 4))
self.status_var = tk.StringVar(value="Démarrage…")
ttk.Label(frame, textvariable=self.status_var, foreground="#1a568e").pack(pady=(0, 12))
# Zone détail par modèle
detail_frame = ttk.LabelFrame(frame, text=" Modèles ", padding=10)
detail_frame.pack(fill="x", pady=(0, 12))
self.step_labels = {}
for key, title, size in self.STEPS:
row = ttk.Frame(detail_frame)
row.pack(fill="x", pady=3)
icon = ttk.Label(row, text="", width=3, font=("", 12))
icon.pack(side="left")
ttk.Label(row, text=title).pack(side="left")
ttk.Label(row, text=f" ({size})", foreground="#999999",
font=("", 8)).pack(side="left")
self.step_labels[key] = icon
# Bouton relance (caché au début)
self.btn = ttk.Button(frame, text="Relancer", command=self.start_download)
self.btn.pack(pady=6)
self.btn.pack_forget()
# Bouton ignorer/continuer (affiché si échec partiel)
self.btn_skip = ttk.Button(
frame, text="Continuer malgré tout",
command=self._finish,
)
self.btn_skip.pack(pady=(0, 4))
self.btn_skip.pack_forget()
# Auto-démarrage du téléchargement (pas besoin de cliquer)
self.root.after(500, self.start_download)
def start_download(self):
self.btn.pack_forget()
self.btn_skip.pack_forget()
self.progress["value"] = 0
self.status_var.set("Démarrage du téléchargement…")
for icon in self.step_labels.values():
icon.configure(text="", foreground="#999999")
threading.Thread(target=self._download_thread, daemon=True).start()
def _set_step(self, key, state):
"""state : 'pending' | 'running' | 'ok' | 'fail'"""
mapping = {
"pending": ("", "#999999"),
"running": ("", "#f57c00"),
"ok": ("", "#2e7d32"),
"fail": ("", "#c62828"),
}
char, color = mapping.get(state, ("", "#999999"))
icon = self.step_labels.get(key)
if icon is not None:
self.root.after(0, lambda: icon.configure(text=char, foreground=color))
def _download_thread(self):
failures = []
try:
# 1. EDS-Pseudo
self._update("Téléchargement d'EDS-Pseudo… (modèle CamemBERT clinique)")
self._set_step("eds_pseudo", "running")
log.info("Downloading EDS-Pseudo...")
try:
from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager()
mgr.load()
self._set_step("eds_pseudo", "ok")
log.info("EDS-Pseudo OK")
except Exception as e:
self._set_step("eds_pseudo", "fail")
failures.append(("EDS-Pseudo", str(e)))
log.warning(f"EDS-Pseudo failed: {e}")
self._advance()
# 2. GLiNER
self._update("Téléchargement de GLiNER… (détection zero-shot)")
self._set_step("gliner", "running")
log.info("Downloading GLiNER...")
try:
from gliner_manager import GlinerManager
mgr = GlinerManager()
mgr.load()
self._set_step("gliner", "ok")
log.info("GLiNER OK")
except Exception as e:
self._set_step("gliner", "fail")
failures.append(("GLiNER", str(e)))
log.warning(f"GLiNER failed: {e}")
self._advance()
# 3. CamemBERT-bio ONNX
self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
self._set_step("camembert_onnx", "running")
if check_models_ready():
self._set_step("camembert_onnx", "ok")
else:
self._set_step("camembert_onnx", "fail")
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
log.error("CamemBERT-bio ONNX not found")
self._advance()
if failures:
lines = "\n".join(f"{name} : {err[:60]}" for name, err in failures)
self._update(f"Certains modèles ont échoué ({len(failures)}/{len(self.STEPS)}).")
log.warning(f"Setup partial failure: {len(failures)} model(s) failed\n{lines}")
self.root.after(0, lambda: self.btn.pack(pady=6))
self.root.after(0, lambda: self.btn_skip.pack(pady=(0, 4)))
else:
self._update("Tous les modèles sont prêts. Lancement de l'interface…")
log.info("Setup complete, launching GUI in 1.5s")
self.root.after(1500, self._finish)
except Exception as e:
log.error(f"Setup error: {e}\n{traceback.format_exc()}")
self._update(f"Erreur inattendue : {e}")
self.root.after(0, lambda: self.btn.pack(pady=6))
def _advance(self):
self.root.after(0, lambda: self.progress.step(1))
def _update(self, msg):
self.root.after(0, lambda: self.status_var.set(msg))
def _finish(self):
try:
self.root.destroy()
except Exception:
pass
launch_gui()
def run(self):
self.root.mainloop()
def main():
log.info("=== Demarrage Anonymisation ===")
# Single-instance check
if not _ensure_single_instance():
log.warning("Another instance is already running. Exiting.")
try:
messagebox.showwarning(
"Anonymisation",
"L'application est deja en cours d'execution.\n\n"
"Regardez dans la barre des taches.",
)
except:
pass
sys.exit(0)
try:
if check_models_ready():
launch_gui()
else:
setup = SetupWindow()
setup.run()
except Exception as e:
log.error(f"Fatal error: {e}\n{traceback.format_exc()}")
try:
messagebox.showerror("Erreur fatale", f"{e}\n\nVoir {LOG_FILE}")
except:
pass
if __name__ == "__main__":
main()

View File

@@ -1,27 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Batch anonymisation de PDFs pour enrichir le dataset silver. """Batch anonymisation parallèle de PDFs pour enrichir le dataset silver.
Traite TOUS les PDFs disponibles (excluant ceux déjà dans audit_30) en mode CPU Traite TOUS les PDFs disponibles en mode CPU (sans VLM), avec N workers
uniquement (sans VLM) pour générer des .pseudonymise.txt utilisables par parallèles. Chaque worker charge ses propres modèles NER.
export_silver_annotations.py.
Timeout par fichier pour éviter les blocages sur les gros documents.
Reprend automatiquement là où il s'est arrêté (skip les déjà traités). Reprend automatiquement là où il s'est arrêté (skip les déjà traités).
Usage:
python run_batch_silver_export.py # 6 workers (défaut)
python run_batch_silver_export.py --workers 4 # 4 workers
""" """
import sys import sys
import os
import time import time
import signal import argparse
import random import multiprocessing as mp
from pathlib import Path from pathlib import Path
from collections import Counter
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
import anonymizer_core_refactored_onnx as core
from eds_pseudo_manager import EdsPseudoManager
from gliner_manager import GlinerManager
from camembert_ner_manager import CamembertNerManager
SRC = Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)") SRC = Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)")
OUTDIR = SRC / "anonymise_silver_extra" OUTDIR = SRC / "anonymise_silver_extra"
CONFIG = Path("/home/dom/ai/anonymisation/config/dictionnaires.yml") CONFIG = Path("/home/dom/ai/anonymisation/config/dictionnaires.yml")
@@ -62,16 +59,116 @@ ALREADY_DONE_AUDIT30 = {
TIMEOUT_PER_FILE = 120 # secondes max par PDF TIMEOUT_PER_FILE = 120 # secondes max par PDF
# Variables globales par worker (initialisées une seule fois)
class TimeoutError(Exception): _worker_ner = None
pass _worker_gliner = None
_worker_camembert = None
_worker_id = None
def timeout_handler(signum, frame): def init_worker(worker_id):
"""Initialise les modèles NER dans chaque worker (appelé une seule fois)."""
global _worker_ner, _worker_gliner, _worker_camembert, _worker_id
_worker_id = worker_id
# Limiter les threads ONNX/OpenMP par worker pour éviter la contention
n_threads = max(2, 32 // (mp.cpu_count() // 2)) # répartir équitablement
os.environ["OMP_NUM_THREADS"] = str(n_threads)
os.environ["MKL_NUM_THREADS"] = str(n_threads)
import anonymizer_core_refactored_onnx as core # noqa: F401
from eds_pseudo_manager import EdsPseudoManager
from gliner_manager import GlinerManager
from camembert_ner_manager import CamembertNerManager
_worker_ner = EdsPseudoManager()
_worker_ner.load()
print(f" [W{worker_id}] EDS-Pseudo chargé", flush=True)
_worker_gliner = GlinerManager()
try:
_worker_gliner.load()
print(f" [W{worker_id}] GLiNER chargé", flush=True)
except Exception as e:
print(f" [W{worker_id}] GLiNER indisponible ({e})", flush=True)
_worker_gliner = None
_worker_camembert = CamembertNerManager()
try:
_worker_camembert.load()
print(f" [W{worker_id}] CamemBERT-bio chargé", flush=True)
except Exception as e:
print(f" [W{worker_id}] CamemBERT-bio indisponible ({e})", flush=True)
_worker_camembert = None
print(f" [W{worker_id}] Prêt (threads={n_threads})", flush=True)
def process_one_pdf(args):
"""Traite un seul PDF. Appelé par le pool de workers."""
pdf_path, idx, total = args
import signal
import anonymizer_core_refactored_onnx as core
ogc = pdf_path.parent.name.split("_")[0]
# Timeout via alarm
def _timeout_handler(signum, frame):
raise TimeoutError("Timeout") raise TimeoutError("Timeout")
signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(TIMEOUT_PER_FILE)
try:
core.process_pdf(
pdf_path=pdf_path,
out_dir=OUTDIR,
make_vector_redaction=False,
also_make_raster_burn=False,
config_path=CONFIG,
use_hf=True,
ner_manager=_worker_ner,
ner_thresholds=None,
ogc_label=ogc,
vlm_manager=None,
gliner_manager=_worker_gliner,
camembert_manager=_worker_camembert,
)
signal.alarm(0)
return ("OK", pdf_path.name, idx, total)
except TimeoutError:
signal.alarm(0)
return ("TIMEOUT", pdf_path.name, idx, total)
except Exception as e:
signal.alarm(0)
err = str(e)
if "encrypted" in err.lower() or "password" in err.lower():
return ("SKIP", pdf_path.name, idx, total)
return ("ERROR", pdf_path.name, idx, total, str(e)[:100])
def main(): def main():
parser = argparse.ArgumentParser(description="Batch silver export parallèle")
parser.add_argument("--workers", type=int, default=6,
help="Nombre de workers parallèles (défaut: 6)")
args = parser.parse_args()
n_workers = args.workers
# Vérification des ressources (RAM surtout — chaque worker charge ~4 Go de modèles NER)
from scripts.check_resources import require_resources
ram_needed = n_workers * 4
print(f"Vérification des ressources ({n_workers} workers × ~4 Go = ~{ram_needed} Go RAM)...")
try:
status = require_resources(ram_free_gb=ram_needed)
print(f" RAM OK : {status.ram_available_gb:.1f} Go disponible")
if status.gpu_available:
print(f" GPU : {status.gpu_name}, {status.vram_free_mb} Mo VRAM libre")
print()
except RuntimeError as e:
print(f"\n{e}", file=sys.stderr)
sys.exit(1)
# Collecter tous les PDFs disponibles (excluant audit_30) # Collecter tous les PDFs disponibles (excluant audit_30)
all_pdfs = [] all_pdfs = []
for ogc_dir in sorted(SRC.iterdir()): for ogc_dir in sorted(SRC.iterdir()):
@@ -81,7 +178,6 @@ def main():
if pdf.name not in ALREADY_DONE_AUDIT30: if pdf.name not in ALREADY_DONE_AUDIT30:
all_pdfs.append(pdf) all_pdfs.append(pdf)
# Trier par OGC pour reproductibilité
all_pdfs.sort(key=lambda p: (p.parent.name, p.name)) all_pdfs.sort(key=lambda p: (p.parent.name, p.name))
# Détecter les fichiers déjà traités (reprise) # Détecter les fichiers déjà traités (reprise)
@@ -95,96 +191,73 @@ def main():
print(f"PDFs disponibles: {len(all_pdfs)} (excl. audit_30)") print(f"PDFs disponibles: {len(all_pdfs)} (excl. audit_30)")
print(f"Déjà traités: {len(already_done)}") print(f"Déjà traités: {len(already_done)}")
print(f"Restant: {len(pdfs_to_do)}") print(f"Restant: {len(pdfs_to_do)}")
print(f"Workers: {n_workers}")
print(f"RAM par worker: ~4 Go (NER models)")
print(f"RAM totale estimée: ~{n_workers * 4} Go\n")
if not pdfs_to_do: if not pdfs_to_do:
print("Rien à faire.") print("Rien à faire.")
return return
# Chargement des modèles NER (CPU uniquement, pas de VLM) # Préparer les arguments : (pdf_path, index, total)
print("\nChargement EDS-Pseudo...", flush=True) tasks = [(pdf, i, len(pdfs_to_do)) for i, pdf in enumerate(pdfs_to_do, 1)]
ner = EdsPseudoManager()
ner.load()
assert ner.is_loaded(), "EDS-Pseudo non chargé"
print("EDS-Pseudo chargé.", flush=True)
print("Chargement GLiNER...", flush=True) print(f"Chargement des modèles dans {n_workers} workers...", flush=True)
gliner = GlinerManager()
try:
gliner.load()
print("GLiNER chargé.", flush=True)
except Exception as e:
print(f"GLiNER indisponible ({e}), on continue sans.", flush=True)
gliner = None
print("Chargement CamemBERT-bio ONNX...", flush=True) # Créer le pool avec initialisation des modèles par worker
camembert = CamembertNerManager() # On utilise mp.Pool avec initializer pour charger les modèles une seule fois
try: # Note: fork + ONNX peut poser problème, on utilise 'spawn'
camembert.load() ctx = mp.get_context("spawn")
print("CamemBERT-bio ONNX chargé.", flush=True)
except Exception as e:
print(f"CamemBERT-bio indisponible ({e}), on continue sans.", flush=True)
camembert = None
print(f"\nPas de VLM (CPU only pour silver export).\n", flush=True)
ok = ko = skip_encrypted = skip_timeout = 0 ok = ko = skip_encrypted = skip_timeout = 0
t0 = time.time() t0 = time.time()
total = len(pdfs_to_do)
for i, pdf in enumerate(pdfs_to_do, 1): # Lancer les workers séquentiellement pour l'init (éviter pic mémoire)
ogc = pdf.parent.name.split("_")[0] # puis traiter en parallèle
print(f"[{i}/{total}] {pdf.name} (OGC {ogc})...", end=" ", flush=True) with ctx.Pool(
processes=n_workers,
initializer=init_worker,
initargs=(0,), # worker_id simplifié
) as pool:
for result in pool.imap_unordered(process_one_pdf, tasks, chunksize=1):
status = result[0]
name = result[1]
idx = result[2]
total = result[3]
# Timeout par fichier elapsed = time.time() - t0
signal.signal(signal.SIGALRM, timeout_handler) done = ok + ko + skip_encrypted + skip_timeout + 1
signal.alarm(TIMEOUT_PER_FILE)
try: if status == "OK":
core.process_pdf(
pdf_path=pdf,
out_dir=OUTDIR,
make_vector_redaction=False,
also_make_raster_burn=False,
config_path=CONFIG,
use_hf=True,
ner_manager=ner,
ner_thresholds=None,
ogc_label=ogc,
vlm_manager=None,
gliner_manager=gliner,
camembert_manager=camembert,
)
signal.alarm(0)
elapsed_file = time.time() - t0
rate = ok / elapsed_file * 3600 if elapsed_file > 0 and ok > 0 else 0
print(f"OK ({rate:.0f}/h)", flush=True)
ok += 1 ok += 1
except TimeoutError: rate = ok / elapsed * 3600 if elapsed > 0 else 0
signal.alarm(0) print(f"[{done}/{total}] {name} OK ({rate:.0f}/h)", flush=True)
print(f"TIMEOUT ({TIMEOUT_PER_FILE}s)", flush=True) elif status == "TIMEOUT":
skip_timeout += 1 skip_timeout += 1
except Exception as e: print(f"[{done}/{total}] {name} TIMEOUT", flush=True)
signal.alarm(0) elif status == "SKIP":
err = str(e)
if "encrypted" in err.lower() or "password" in err.lower():
print("SKIP (chiffré)", flush=True)
skip_encrypted += 1 skip_encrypted += 1
print(f"[{done}/{total}] {name} SKIP (chiffré)", flush=True)
else: else:
print(f"ERREUR: {e}", flush=True)
ko += 1 ko += 1
err_msg = result[4] if len(result) > 4 else "?"
print(f"[{done}/{total}] {name} ERREUR: {err_msg}", flush=True)
# Rapport intermédiaire toutes les 50 fichiers # Rapport intermédiaire toutes les 50 fichiers
if i % 50 == 0: if done % 50 == 0:
elapsed = time.time() - t0 remaining = (elapsed / done) * (total - done)
remaining = (elapsed / i) * (total - i) print(f"\n --- Progression: {done}/{total} | OK: {ok} | "
print(f"\n --- Progression: {i}/{total} | OK: {ok} | "
f"Erreurs: {ko} | Timeout: {skip_timeout} | " f"Erreurs: {ko} | Timeout: {skip_timeout} | "
f"Temps restant estimé: {remaining/60:.0f}min ---\n", flush=True) f"Débit: {ok/elapsed*3600:.0f}/h | "
f"Restant: {remaining/60:.0f}min ---\n", flush=True)
elapsed = time.time() - t0 elapsed = time.time() - t0
total_pseudo = len(list(OUTDIR.glob("*.pseudonymise.txt")))
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f"Terminé en {elapsed:.0f}s ({elapsed/60:.1f}min)") print(f"Terminé en {elapsed:.0f}s ({elapsed/60:.1f}min)")
print(f"OK: {ok}, Chiffrés: {skip_encrypted}, Timeout: {skip_timeout}, Erreurs: {ko}") print(f"OK: {ok}, Chiffrés: {skip_encrypted}, Timeout: {skip_timeout}, Erreurs: {ko}")
print(f"Total .pseudonymise.txt: {len(list(OUTDIR.glob('*.pseudonymise.txt')))}") print(f"Total .pseudonymise.txt: {total_pseudo}")
print(f"Débit moyen: {ok/elapsed*3600:.0f} fichiers/h")
print(f"Sortie: {OUTDIR}") print(f"Sortie: {OUTDIR}")

View File

@@ -109,8 +109,12 @@ def main():
if len(row) < 16: if len(row) < 16:
continue continue
# Numéro FINESS (col 1) # Numéros FINESS : col 1 = finess_et (structure), col 2 = entjur (entité juridique).
finess = row[1].strip() # Les deux sont des identifiants 9 chiffres réels du référentiel FINESS et doivent
# être masqués. Avant ce fix, seul finess_et était extrait (~102k), et les ~48k
# entjur étaient manqués — provoquant des fuites (ex: 640780417 entjur CHCB).
for col_idx in (1, 2):
finess = row[col_idx].strip() if col_idx < len(row) else ""
if re.match(r"^\d{9}$", finess): if re.match(r"^\d{9}$", finess):
finess_numbers.add(finess) finess_numbers.add(finess)
@@ -190,6 +194,93 @@ def main():
out.write_text("\n".join(sorted(phones)) + "\n", encoding="utf-8") out.write_text("\n".join(sorted(phones)) + "\n", encoding="utf-8")
print(f"{out.name}: {len(phones)} entrées") print(f"{out.name}: {len(phones)} entrées")
# 6. Adresses FINESS (type_voie + nom_voie) pour Aho-Corasick
# Mapping des codes type_voie FINESS vers formes étendues
TYPE_VOIE_MAP = {
"AV": "avenue", "R": "rue", "BD": "boulevard", "RTE": "route",
"CHE": "chemin", "PL": "place", "IMP": "impasse", "ALL": "allee",
"SQ": "square", "PASS": "passage", "QU": "quai", "CRS": "cours",
"SEN": "sentier", "RPT": "rond-point", "LD": "lieu-dit",
"HAM": "hameau", "LOT": "lotissement", "TSSE": "traverse",
"CHEM": "chemin", "RES": "residence", "CTRE": "centre",
"ESP": "esplanade", "PRO": "promenade", "MTE": "montee",
"VOI": "voie", "CAR": "carrefour", "FBG": "faubourg",
}
# Charger les prénoms INSEE pour générer des variantes abrégées
prenoms_path = Path(__file__).parent.parent / "data" / "insee" / "prenoms_france.txt"
prenoms_set = set()
if prenoms_path.exists():
for line in prenoms_path.read_text(encoding="utf-8").splitlines():
p = line.strip().lower()
if p and len(p) >= 3:
prenoms_set.add(p)
print(f" Prénoms INSEE chargés: {len(prenoms_set)}")
VOIE_GENERIC = {
"de", "du", "des", "la", "le", "les", "l", "et", "en", "au", "aux",
"a", "sur", "sous", "par", "pour", "dans", "rue", "avenue", "boulevard",
"route", "chemin", "place", "impasse", "square", "passage", "quai", "cours",
"grande", "grand", "petit", "petite", "vieux", "vieille", "nouveau", "nouvelle",
"haut", "haute", "bas", "basse",
}
addr_patterns = set()
def _add_with_abbrev(pattern: str):
"""Ajoute le pattern + variantes avec prénoms abrégés (initiale seule)."""
addr_patterns.add(pattern)
words = pattern.split()
for i, w in enumerate(words):
if w in prenoms_set and len(w) >= 3:
# Variante avec initiale seule — seulement si un mot distinctif suit
remaining = words[i+1:]
if not remaining or all(len(r) <= 2 or r in VOIE_GENERIC for r in remaining):
continue # Pas d'abréviation si rien de distinctif après
abbrev_words = words[:i] + [w[0]] + words[i+1:]
abbrev = " ".join(abbrev_words)
# Minimum 12 chars, et le pattern ne doit pas commencer par une initiale seule
if len(abbrev) >= 12 and len(abbrev_words[0]) >= 2:
addr_patterns.add(abbrev)
with open(csv_path, encoding="utf-8") as f:
reader = csv.reader(f, delimiter=";")
next(reader)
for row in reader:
if len(row) < 10:
continue
type_voie_raw = row[8].strip() if len(row) > 8 else ""
nom_voie = row[9].strip() if len(row) > 9 else ""
if not nom_voie or len(nom_voie) < 3:
continue
nom_norm = normalize(nom_voie)
words = nom_norm.split()
# Pattern complet : type_voie + nom_voie (ex: "avenue de l interne jacques loeb")
type_voie_expanded = TYPE_VOIE_MAP.get(type_voie_raw.upper(), type_voie_raw.lower())
if type_voie_expanded and nom_norm:
full = f"{type_voie_expanded} {nom_norm}"
full_words = full.split()
has_distinctive = any(
w not in VOIE_GENERIC and len(w) >= 4 for w in full_words
)
if has_distinctive and len(full) >= 12:
_add_with_abbrev(full)
# Pattern nom_voie seul (seulement si très distinctif)
has_distinctive = any(w not in VOIE_GENERIC and len(w) >= 4 for w in words)
if has_distinctive and len(nom_norm) >= 15:
_add_with_abbrev(nom_norm)
out = OUT_DIR / "adresses_finess.txt"
out.write_text("\n".join(sorted(addr_patterns)) + "\n", encoding="utf-8")
print(f"\n{out.name}: {len(addr_patterns)} entrées")
# Garder aussi voies_distinctives.txt pour compatibilité
voie_names = {p for p in addr_patterns if len(p) >= 15}
out = OUT_DIR / "voies_distinctives.txt"
out.write_text("\n".join(sorted(voie_names)) + "\n", encoding="utf-8")
print(f"{out.name}: {len(voie_names)} entrées")
# Stats par longueur # Stats par longueur
print(f"\nDistribution noms distinctifs par longueur (mots):") print(f"\nDistribution noms distinctifs par longueur (mots):")
word_counts = Counter(len(n.split()) for n in filtered_distinctive) word_counts = Counter(len(n.split()) for n in filtered_distinctive)

347
scripts/check_resources.py Normal file
View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""Vérification des ressources machine (GPU, RAM, CPU) avant exécution.
Utilisable comme module ou en standalone :
from scripts.check_resources import check_resources, require_resources
# Vérification simple (lève RuntimeError si insuffisant)
require_resources(vram_free_mb=2000, ram_free_gb=4)
# Vérification informative (retourne un dict)
status = check_resources()
print(status)
# En standalone
python scripts/check_resources.py
python scripts/check_resources.py --vram 2000 --ram 4 --wait
"""
import subprocess
import shutil
import time
import sys
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class GpuProcess:
pid: int
name: str
vram_mb: int
@dataclass
class ResourceStatus:
# GPU
gpu_available: bool = False
gpu_name: str = ""
vram_total_mb: int = 0
vram_used_mb: int = 0
vram_free_mb: int = 0
gpu_util_pct: int = 0
gpu_processes: List[GpuProcess] = field(default_factory=list)
# RAM
ram_total_gb: float = 0.0
ram_used_gb: float = 0.0
ram_free_gb: float = 0.0
ram_available_gb: float = 0.0
# CPU
cpu_count: int = 0
load_avg_1m: float = 0.0
load_avg_5m: float = 0.0
def summary(self) -> str:
lines = []
lines.append("=" * 55)
lines.append(" ÉTAT DES RESSOURCES MACHINE")
lines.append("=" * 55)
# GPU
if self.gpu_available:
lines.append(f"\n GPU : {self.gpu_name}")
lines.append(f" VRAM totale : {self.vram_total_mb} Mo")
lines.append(f" VRAM utilisée: {self.vram_used_mb} Mo ({self._pct(self.vram_used_mb, self.vram_total_mb)}%)")
lines.append(f" VRAM libre : {self.vram_free_mb} Mo")
lines.append(f" Utilisation : {self.gpu_util_pct}%")
if self.gpu_processes:
lines.append(f" Processus GPU ({len(self.gpu_processes)}) :")
for p in self.gpu_processes:
short_name = p.name.split("/")[-1] if "/" in p.name else p.name
project = self._guess_project(p.name)
label = f" ({project})" if project else ""
lines.append(f" PID {p.pid}: {short_name}{label}{p.vram_mb} Mo")
else:
lines.append(" Aucun processus GPU actif")
else:
lines.append("\n GPU : non disponible (nvidia-smi absent)")
# RAM
lines.append(f"\n RAM : {self.ram_total_gb:.1f} Go total")
lines.append(f" Utilisée : {self.ram_used_gb:.1f} Go")
lines.append(f" Disponible : {self.ram_available_gb:.1f} Go")
# CPU
lines.append(f"\n CPU : {self.cpu_count} cœurs")
lines.append(f" Load avg : {self.load_avg_1m:.1f} (1m) / {self.load_avg_5m:.1f} (5m)")
lines.append("=" * 55)
return "\n".join(lines)
@staticmethod
def _pct(used: int, total: int) -> int:
return round(used * 100 / total) if total > 0 else 0
@staticmethod
def _guess_project(path: str) -> str:
"""Devine le projet à partir du chemin du processus."""
parts = path.split("/")
for i, p in enumerate(parts):
if p == "ai" and i + 1 < len(parts):
return parts[i + 1].split(".")[0]
return ""
def check_resources() -> ResourceStatus:
"""Collecte l'état actuel des ressources machine."""
status = ResourceStatus()
# --- GPU ---
if shutil.which("nvidia-smi"):
status.gpu_available = True
try:
out = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
if out.returncode == 0:
parts = [p.strip() for p in out.stdout.strip().split(",")]
if len(parts) >= 5:
status.gpu_name = parts[0]
status.vram_total_mb = int(parts[1])
status.vram_used_mb = int(parts[2])
status.vram_free_mb = int(parts[3])
status.gpu_util_pct = int(parts[4])
except Exception:
pass
# Processus GPU
try:
out = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,process_name,used_gpu_memory",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5
)
if out.returncode == 0 and out.stdout.strip():
for line in out.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 3:
try:
status.gpu_processes.append(GpuProcess(
pid=int(parts[0]),
name=parts[1],
vram_mb=int(parts[2]),
))
except ValueError:
pass
except Exception:
pass
# --- RAM ---
try:
with open("/proc/meminfo") as f:
meminfo = {}
for line in f:
parts = line.split()
if len(parts) >= 2:
key = parts[0].rstrip(":")
meminfo[key] = int(parts[1]) # en kB
status.ram_total_gb = meminfo.get("MemTotal", 0) / 1048576
status.ram_free_gb = meminfo.get("MemFree", 0) / 1048576
status.ram_available_gb = meminfo.get("MemAvailable", 0) / 1048576
status.ram_used_gb = status.ram_total_gb - status.ram_free_gb
except Exception:
pass
# --- CPU ---
try:
import os
status.cpu_count = os.cpu_count() or 0
load = os.getloadavg()
status.load_avg_1m = load[0]
status.load_avg_5m = load[1]
except Exception:
pass
return status
def require_resources(
vram_free_mb: int = 0,
ram_free_gb: float = 0,
max_gpu_util_pct: int = 100,
fail_if_gpu_busy: bool = False,
) -> ResourceStatus:
"""Vérifie que les ressources minimales sont disponibles.
Args:
vram_free_mb: VRAM libre minimale requise (Mo). 0 = pas de vérification GPU.
ram_free_gb: RAM disponible minimale (Go).
max_gpu_util_pct: Utilisation GPU max tolérée (%).
fail_if_gpu_busy: Si True, échoue si d'autres processus utilisent le GPU.
Returns:
ResourceStatus si tout est ok.
Raises:
RuntimeError avec détails si ressources insuffisantes.
"""
status = check_resources()
errors = []
if vram_free_mb > 0:
if not status.gpu_available:
errors.append(f"GPU requis ({vram_free_mb} Mo VRAM) mais nvidia-smi non disponible")
elif status.vram_free_mb < vram_free_mb:
procs = ""
if status.gpu_processes:
procs = "\n Processus occupant le GPU :"
for p in status.gpu_processes:
short = p.name.split("/")[-1]
project = status._guess_project(p.name)
label = f" ({project})" if project else ""
procs += f"\n PID {p.pid}: {short}{label}{p.vram_mb} Mo"
errors.append(
f"VRAM insuffisante : {status.vram_free_mb} Mo libre, "
f"{vram_free_mb} Mo requis (utilisé: {status.vram_used_mb}/{status.vram_total_mb} Mo)"
f"{procs}"
)
if max_gpu_util_pct < 100 and status.gpu_available:
if status.gpu_util_pct > max_gpu_util_pct:
errors.append(
f"GPU trop chargé : {status.gpu_util_pct}% d'utilisation "
f"(max toléré: {max_gpu_util_pct}%)"
)
if fail_if_gpu_busy and status.gpu_processes:
names = [f"{p.name.split('/')[-1]} (PID {p.pid})" for p in status.gpu_processes]
errors.append(f"GPU occupé par : {', '.join(names)}")
if ram_free_gb > 0 and status.ram_available_gb < ram_free_gb:
errors.append(
f"RAM insuffisante : {status.ram_available_gb:.1f} Go disponible, "
f"{ram_free_gb:.1f} Go requis"
)
if errors:
msg = "Ressources insuffisantes :\n " + "\n ".join(errors)
msg += "\n\n" + status.summary()
raise RuntimeError(msg)
return status
def wait_for_resources(
vram_free_mb: int = 0,
ram_free_gb: float = 0,
max_gpu_util_pct: int = 100,
timeout_minutes: int = 30,
check_interval_seconds: int = 30,
) -> ResourceStatus:
"""Attend que les ressources soient disponibles (avec timeout).
Affiche un message périodique tant que les ressources sont insuffisantes.
Utile avant un fine-tuning ou un batch lourd.
Returns:
ResourceStatus quand les ressources sont disponibles.
Raises:
TimeoutError si le timeout est atteint.
"""
deadline = time.time() + timeout_minutes * 60
attempt = 0
while time.time() < deadline:
try:
status = require_resources(
vram_free_mb=vram_free_mb,
ram_free_gb=ram_free_gb,
max_gpu_util_pct=max_gpu_util_pct,
)
if attempt > 0:
print(f"\nRessources disponibles après {attempt * check_interval_seconds}s d'attente.")
return status
except RuntimeError as e:
attempt += 1
if attempt == 1:
print(f"En attente de ressources (timeout: {timeout_minutes}min)...")
print(f" Requis: VRAM >= {vram_free_mb} Mo, RAM >= {ram_free_gb} Go")
remaining = int((deadline - time.time()) / 60)
status = check_resources()
gpu_info = f"VRAM libre: {status.vram_free_mb} Mo" if status.gpu_available else "pas de GPU"
print(
f" [{attempt}] {gpu_info}, RAM dispo: {status.ram_available_gb:.1f} Go "
f"— encore {remaining}min max",
flush=True,
)
time.sleep(check_interval_seconds)
raise TimeoutError(
f"Timeout ({timeout_minutes}min) : ressources toujours insuffisantes.\n"
+ check_resources().summary()
)
def main():
import argparse
parser = argparse.ArgumentParser(description="Vérification des ressources machine")
parser.add_argument("--vram", type=int, default=0,
help="VRAM libre minimale requise (Mo)")
parser.add_argument("--ram", type=float, default=0,
help="RAM disponible minimale (Go)")
parser.add_argument("--gpu-util", type=int, default=100,
help="Utilisation GPU max tolérée (%%)")
parser.add_argument("--wait", action="store_true",
help="Attendre que les ressources soient disponibles")
parser.add_argument("--timeout", type=int, default=30,
help="Timeout d'attente en minutes (défaut: 30)")
args = parser.parse_args()
# Afficher l'état actuel
status = check_resources()
print(status.summary())
# Vérifier les seuils si demandés
if args.vram > 0 or args.ram > 0 or args.gpu_util < 100:
if args.wait:
try:
wait_for_resources(
vram_free_mb=args.vram,
ram_free_gb=args.ram,
max_gpu_util_pct=args.gpu_util,
timeout_minutes=args.timeout,
)
print("\nOK — ressources disponibles.")
except TimeoutError as e:
print(f"\nERREUR : {e}", file=sys.stderr)
sys.exit(1)
else:
try:
require_resources(
vram_free_mb=args.vram,
ram_free_gb=args.ram,
max_gpu_util_pct=args.gpu_util,
)
print("\nOK — ressources suffisantes.")
except RuntimeError as e:
print(f"\nERREUR : {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -77,6 +77,9 @@ NAME_IGNORE = {
"TRAITEMENT", "INTERVENTION", "OPERATOIRE", "RAPPORT", "TRAITEMENT", "INTERVENTION", "OPERATOIRE", "RAPPORT",
"PATIENT", "MONSIEUR", "MADAME", "DOCTEUR", "PATIENT", "MONSIEUR", "MADAME", "DOCTEUR",
"NORMAL", "POSITIF", "NEGATIF", "PRESENT", "ABSENT", "NORMAL", "POSITIF", "NEGATIF", "PRESENT", "ABSENT",
# Acronymes médicaux courts (aussi patronymes/prénoms INSEE → FP évaluateur)
"EVA", # Échelle Visuelle Analogique
"RAI", # Recherche d'Agglutinines Irrégulières
# Instructions soins Trackare (aussi patronymes INSEE → faux positifs évaluateur) # Instructions soins Trackare (aussi patronymes INSEE → faux positifs évaluateur)
"LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE", "LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE",
"GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR", "GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR",
@@ -300,7 +303,7 @@ def check_fp_density(text: str) -> dict:
"density_pct": round(density, 2), "density_pct": round(density, 2),
"nom_count": nom_count, "nom_count": nom_count,
"nom_pct": round(nom_pct, 2), "nom_pct": round(nom_pct, 2),
"alert": nom_pct > 5.0, "alert": nom_pct > 8.0, # seuil relevé : CRO/CRH courts listent 8-10 soignants = légitime
} }

View File

@@ -523,6 +523,17 @@ def main():
help="Seed pour la reproductibilité de l'augmentation") help="Seed pour la reproductibilité de l'augmentation")
args = parser.parse_args() args = parser.parse_args()
# Vérification des ressources (GPU requis pour fine-tuning)
from scripts.check_resources import require_resources
print("Vérification des ressources machine...")
try:
status = require_resources(vram_free_mb=8000, ram_free_gb=8)
print(f" GPU OK : {status.gpu_name}, {status.vram_free_mb} Mo VRAM libre")
print(f" RAM OK : {status.ram_available_gb:.1f} Go disponible\n")
except RuntimeError as e:
print(f"\n{e}", file=sys.stderr)
sys.exit(1)
# Chemins des gazetteers # Chemins des gazetteers
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent.parent
prenoms_file = project_root / "data" / "insee" / "prenoms_france.txt" prenoms_file = project_root / "data" / "insee" / "prenoms_france.txt"

View File

@@ -0,0 +1,522 @@
#!/usr/bin/env python3
"""Génère la fiche produit Pseudonymisation en DOCX (deux versions)."""
from docx import Document
from docx.shared import Pt, Cm, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
import os
# ── Helpers ──────────────────────────────────────────────────────────
def set_cell_shading(cell, color_hex: str):
"""Applique une couleur de fond à une cellule."""
shading = cell._element.get_or_add_tcPr()
shading_elem = shading.makeelement(qn('w:shd'), {
qn('w:val'): 'clear',
qn('w:color'): 'auto',
qn('w:fill'): color_hex,
})
shading.append(shading_elem)
def add_heading_styled(doc, text, level=1, color=RGBColor(0x1A, 0x56, 0x8E)):
h = doc.add_heading(text, level=level)
for run in h.runs:
run.font.color.rgb = color
return h
def add_bullet(doc, text, bold_prefix=None):
p = doc.add_paragraph(style='List Bullet')
if bold_prefix:
run = p.add_run(bold_prefix)
run.bold = True
p.add_run(text)
else:
p.add_run(text)
return p
def add_para(doc, text, bold=False, italic=False, space_after=Pt(6)):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.italic = italic
p.paragraph_format.space_after = space_after
return p
def add_table_row(table, cells_data, header=False, header_color='1A568E'):
row = table.add_row()
for i, text in enumerate(cells_data):
cell = row.cells[i]
cell.text = ''
p = cell.paragraphs[0]
run = p.add_run(text)
run.font.size = Pt(10)
if header:
run.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
set_cell_shading(cell, header_color)
p.paragraph_format.space_before = Pt(4)
p.paragraph_format.space_after = Pt(4)
def set_table_style(table):
"""Style sobre pour les tableaux."""
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(3)
def add_section_break(doc):
doc.add_page_break()
# ── Couleurs ─────────────────────────────────────────────────────────
BLUE_DARK = RGBColor(0x1A, 0x56, 0x8E)
BLUE_LIGHT = 'D6E8F7'
GRAY_LIGHT = 'F2F2F2'
GREEN = RGBColor(0x2E, 0x7D, 0x32)
# ══════════════════════════════════════════════════════════════════════
# VERSION 1 — Synthétique (1 page recto, orientation portrait)
# ══════════════════════════════════════════════════════════════════════
def build_version_1(doc):
# ── Titre ──
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in title.runs:
run.font.color.rgb = BLUE_DARK
run.font.size = Pt(22)
# Sous-titre
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = sub.add_run('Fiche produit — Version synthétique')
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
run.italic = True
sub.paragraph_format.space_after = Pt(16)
# ── En bref ──
add_heading_styled(doc, 'En bref', level=2)
add_para(doc,
'Logiciel d\'anonymisation automatique de documents médicaux, '
'fonctionnant 100% en local sur un poste Windows. '
'Aucune donnée ne sort de l\'établissement. '
'Un fichier entre, un fichier anonymisé sort.',
space_after=Pt(10))
# ── Tableau comparatif ──
add_heading_styled(doc, 'Pourquoi cet outil ?', level=2)
table = doc.add_table(rows=0, cols=2)
table.style = 'Table Grid'
add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True)
comparisons = [
('Anonymisation manuelle, chronophage\net source d\'erreurs',
'Traitement automatique\nen quelques minutes'),
('Risque d\'oubli de données\npersonnelles (RGPD)',
'Taux de détection > 99%\nvalidé sur corpus réel'),
('Mobilisation de personnel qualifié\nsur une tâche répétitive',
'L\'équipe TIM vérifie,\nelle ne produit plus à la main'),
]
for left, right in comparisons:
add_table_row(table, [left, right])
set_table_style(table)
doc.add_paragraph() # espace
# ── Points clés ──
add_heading_styled(doc, 'Points clés', level=2)
points = [
('100% hors-ligne — ', 'aucune donnée ne transite par internet'),
('Un seul exécutable — ', 'pas d\'installation, pas de serveur, pas de GPU'),
('14 formats acceptés — ', 'PDF, Word, ODT, RTF, images, HTML...'),
('4 moteurs d\'IA — ', 'spécialisés en français clinique, fonctionnent en parallèle'),
('Paramétrable — ', 'whitelist et blacklist modifiables par l\'établissement'),
('Irréversible — ', 'masquage par rectangles noirs, pas de calque amovible'),
]
for bold_part, normal_part in points:
add_bullet(doc, normal_part, bold_prefix=bold_part)
# ── Ce qui est masqué / préservé ──
add_heading_styled(doc, 'Ce qui est masqué / préservé', level=2)
table2 = doc.add_table(rows=0, cols=2)
table2.style = 'Table Grid'
add_table_row(table2, ['Masqué automatiquement', 'Préservé intégralement'], header=True)
add_table_row(table2, [
'Noms, prénoms, adresses,\ntéléphones, n° de sécu,\ndates de naissance,\nnoms d\'établissements,\ncodes-barres patients',
'Diagnostics, traitements,\nposologies, actes médicaux,\nrésultats d\'examens,\ndates de séjour,\ncodage CIM / CCAM'
])
set_table_style(table2)
doc.add_paragraph()
# ── Cas d'usage ──
add_heading_styled(doc, 'Cas d\'usage', level=2)
usages = [
('Contrôle T2A — ', 'anonymiser les pièces justificatives avant transmission'),
('Recherche clinique — ', 'constituer des corpus anonymisés'),
('Audits qualité / HAS — ', 'partager des dossiers sans exposition de données'),
('Formation — ', 'utiliser des cas réels anonymisés pour l\'enseignement'),
]
for bold_part, normal_part in usages:
add_bullet(doc, normal_part, bold_prefix=bold_part)
# ══════════════════════════════════════════════════════════════════════
# VERSION 2 — Détaillée (multi-pages, par audience)
# ══════════════════════════════════════════════════════════════════════
def build_version_2(doc):
# ── Page de titre ──
for _ in range(4):
doc.add_paragraph()
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in title.runs:
run.font.color.rgb = BLUE_DARK
run.font.size = Pt(26)
doc.add_paragraph()
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = sub.add_run('Fiche produit détaillée')
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_paragraph()
audiences = doc.add_paragraph()
audiences.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = audiences.add_run('Direction générale · DSI · Médecins · Équipe TIM')
run.font.size = Pt(11)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
run.italic = True
add_section_break(doc)
# ── 1. Le problème ──
add_heading_styled(doc, 'Le problème', level=1)
add_para(doc,
'Les établissements de santé manipulent quotidiennement des documents '
'contenant des données personnelles de patients : comptes-rendus opératoires, '
'résultats d\'examens, courriers médicaux, rapports d\'anatomo-pathologie...')
add_para(doc,
'Lors des contrôles T2A, audits qualité, recherches cliniques ou échanges '
'inter-établissements, ces documents doivent être anonymisés conformément '
'au RGPD et au cadre réglementaire santé.')
add_para(doc,
'L\'anonymisation manuelle est lente, coûteuse, et faillible : '
'un oubli suffit à exposer l\'identité d\'un patient.',
bold=True, space_after=Pt(12))
# ── 2. La solution ──
add_heading_styled(doc, 'La solution', level=1)
add_para(doc,
'Un logiciel autonome, fonctionnant intégralement en local, qui anonymise '
'automatiquement vos documents en quelques minutes :')
bullets = [
'Détecte et masque les noms, prénoms, adresses, téléphones, numéros de sécurité sociale, dates de naissance, noms d\'établissements',
'Préserve le contenu médical utile : diagnostics, traitements, actes, résultats, codage',
'Produit un PDF anonymisé prêt à transmettre, avec masquage irréversible',
]
for b in bullets:
add_bullet(doc, b)
doc.add_paragraph()
# ── Tableau comparatif ──
add_heading_styled(doc, 'Avant / Après', level=2)
table = doc.add_table(rows=0, cols=2)
table.style = 'Table Grid'
add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True)
comparisons = [
('Anonymisation manuelle, chronophage et source d\'erreurs',
'Traitement automatique en quelques minutes'),
('Risque d\'oubli de données personnelles (RGPD, AI Act)',
'Taux de détection > 99% validé sur corpus réel de contrôle T2A'),
('Mobilisation de personnel qualifié sur une tâche répétitive',
'L\'équipe TIM se concentre sur la vérification, pas la production'),
('Dépendance à des outils cloud ou des prestataires externes',
'100% local, aucune donnée ne quitte l\'établissement'),
]
for left, right in comparisons:
add_table_row(table, [left, right])
set_table_style(table)
add_section_break(doc)
# ── 3. Conformité et sécurité (DG + DSI) ──
add_heading_styled(doc, 'Conformité et sécurité', level=1)
tag = doc.add_paragraph()
run = tag.add_run('→ Direction générale, DSI')
run.italic = True
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
tag.paragraph_format.space_after = Pt(8)
security_points = [
('Aucune connexion réseau', 'Le logiciel fonctionne entièrement hors-ligne. Aucun appel à un serveur externe, aucune API cloud, aucune télémétrie.'),
('Aucune donnée exfiltrée', 'Le traitement se fait intégralement en mémoire locale. Les documents source ne sont ni copiés ni transmis.'),
('Conformité RGPD / AI Act', 'L\'intelligence artificielle est embarquée dans l\'exécutable. Pas de sous-traitance, pas de transfert de données à un tiers.'),
('Masquage irréversible', 'Les zones anonymisées sont remplacées par des rectangles noirs directement dans le PDF. Il ne s\'agit pas d\'un calque amovible : l\'information est définitivement supprimée.'),
('Traçabilité', 'Les fichiers anonymisés sont nommés avec le préfixe "ANON_" pour identification immédiate.'),
]
for title_text, desc in security_points:
p = doc.add_paragraph()
run_title = p.add_run(title_text + '')
run_title.bold = True
p.add_run(desc)
p.paragraph_format.space_after = Pt(6)
add_section_break(doc)
# ── 4. Déploiement (DSI) ──
add_heading_styled(doc, 'Déploiement et maintenance', level=1)
tag = doc.add_paragraph()
run = tag.add_run('→ DSI')
run.italic = True
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
tag.paragraph_format.space_after = Pt(8)
add_heading_styled(doc, 'Prérequis', level=2)
prereqs = [
'Windows 10 ou 11 (64 bits)',
'Poste bureautique standard — pas de GPU dédié, pas de serveur requis',
'Pas de droits administrateur nécessaires pour l\'exécution',
'Pas de connexion internet requise',
]
for pr in prereqs:
add_bullet(doc, pr)
add_heading_styled(doc, 'Installation', level=2)
install_steps = [
'Copier l\'exécutable (un seul fichier .exe) sur le poste',
'Au premier lancement, le fichier de configuration est créé automatiquement à côté de l\'exécutable',
'C\'est prêt — aucune autre manipulation nécessaire',
]
for i, step in enumerate(install_steps, 1):
p = doc.add_paragraph()
run = p.add_run(f'{i}. ')
run.bold = True
p.add_run(step)
add_heading_styled(doc, 'Mise à jour', level=2)
add_para(doc, 'Remplacer l\'exécutable par la nouvelle version. La configuration personnalisée de l\'établissement est conservée (fichier séparé).')
add_heading_styled(doc, 'Configuration inter-établissements', level=2)
add_para(doc,
'Les paramètres (whitelist, blacklist) sont exportables au format JSON depuis l\'interface. '
'Un établissement peut envoyer sa configuration par email. '
'Le service central fusionne les configurations et renvoie un fichier YAML consolidé.')
add_section_break(doc)
# ── 5. Utilisation quotidienne (TIM) ──
add_heading_styled(doc, 'Utilisation quotidienne', level=1)
tag = doc.add_paragraph()
run = tag.add_run('→ Équipe TIM, médecins')
run.italic = True
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
tag.paragraph_format.space_after = Pt(8)
add_heading_styled(doc, 'En 3 étapes', level=2)
steps = [
('Sélectionner', 'Choisir un fichier ou un dossier entier depuis l\'interface'),
('Lancer', 'Cliquer sur "Lancer l\'anonymisation"'),
('Récupérer', 'Les documents anonymisés sont créés dans le dossier de sortie, prêts à transmettre'),
]
for title_text, desc in steps:
p = doc.add_paragraph()
run_title = p.add_run(f'{title_text}')
run_title.bold = True
run_title.font.color.rgb = BLUE_DARK
p.add_run(desc)
p.paragraph_format.space_after = Pt(6)
add_heading_styled(doc, 'Formats acceptés (14)', level=2)
table_fmt = doc.add_table(rows=0, cols=2)
table_fmt.style = 'Table Grid'
add_table_row(table_fmt, ['Type', 'Formats'], header=True)
formats = [
('Documents', 'PDF, Word (.docx), ODT, RTF, TXT, HTML'),
('Images', 'JPEG, PNG, TIFF, BMP'),
]
for type_name, fmt_list in formats:
add_table_row(table_fmt, [type_name, fmt_list])
set_table_style(table_fmt)
doc.add_paragraph()
add_para(doc,
'Les images et documents scannés sont traités par reconnaissance optique de caractères (OCR) '
'intégrée — pas de logiciel tiers nécessaire.',
italic=True)
add_heading_styled(doc, 'Paramétrage', level=2)
params = [
('Whitelist — ', 'liste de termes à ne jamais masquer (noms de services, sigles internes, noms de logiciels...). Modifiable directement dans l\'interface.'),
('Blacklist — ', 'liste de termes à toujours masquer (noms de praticiens spécifiques...). Modifiable directement dans l\'interface.'),
('Export/Import — ', 'les paramètres sont échangeables entre établissements par simple fichier JSON envoyé par email.'),
]
for bold_part, normal_part in params:
add_bullet(doc, normal_part, bold_prefix=bold_part)
add_section_break(doc)
# ── 6. Fiabilité (Médecins + TIM) ──
add_heading_styled(doc, 'Fiabilité de la détection', level=1)
tag = doc.add_paragraph()
run = tag.add_run('→ Médecins, équipe TIM')
run.italic = True
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
tag.paragraph_format.space_after = Pt(8)
add_para(doc,
'Le logiciel combine 4 moteurs d\'intelligence artificielle spécialisés en français clinique '
'qui fonctionnent simultanément et se recoupent pour maximiser la détection :')
engines = [
('Modèle CamemBERT médical', 'entraîné spécifiquement sur des documents cliniques français'),
('Modèle de détection d\'entités', 'reconnaissance zero-shot de données personnelles'),
('Modèle spécialisé', 'fine-tuné sur plus de 1 000 documents médicaux réels'),
('Bases de référence nationales', '219 000 noms de famille, 36 000 prénoms, 7 300 médicaments, 108 000 établissements de santé'),
]
for title_text, desc in engines:
add_bullet(doc, '' + desc, bold_prefix=title_text)
doc.add_paragraph()
add_para(doc,
'Principe de précaution : en cas de doute, le logiciel masque. '
'Il est préférable de masquer un terme médical par excès '
'plutôt que de laisser visible un nom de patient.',
bold=True, space_after=Pt(8))
add_para(doc,
'Taux de détection validé à plus de 99% sur un corpus réel de documents '
'de contrôle T2A : comptes-rendus opératoires, anatomo-pathologie, bactériologie, '
'anesthésie, courriers médicaux, comptes-rendus d\'hospitalisation.')
add_section_break(doc)
# ── 7. Cas d'usage ──
add_heading_styled(doc, 'Cas d\'usage', level=1)
use_cases = [
('Contrôle T2A',
'Anonymiser les pièces justificatives (comptes-rendus, résultats, courriers) '
'avant transmission à l\'organisme de contrôle.'),
('Recherche clinique',
'Constituer des jeux de données anonymisés à partir de dossiers patients réels, '
'sans risque d\'identification.'),
('Audits qualité et certification HAS',
'Partager des cas cliniques lors des visites de certification '
'sans exposer l\'identité des patients.'),
('Échanges inter-établissements',
'Transmettre des comptes-rendus médicaux anonymisés dans le cadre '
'de parcours de soins partagés.'),
('Formation et enseignement',
'Utiliser des cas cliniques réels anonymisés pour la formation '
'des internes et des équipes soignantes.'),
]
for title_text, desc in use_cases:
p = doc.add_paragraph()
run_title = p.add_run(title_text + '\n')
run_title.bold = True
run_title.font.size = Pt(11)
run_desc = p.add_run(desc)
run_desc.font.size = Pt(10)
p.paragraph_format.space_after = Pt(10)
# ── 8. Questions fréquentes ──
add_heading_styled(doc, 'Questions fréquentes', level=1)
faq = [
('Combien de temps prend le traitement d\'un document ?',
'En moyenne quelques dizaines de secondes par document. '
'Un lot de 20 à 30 documents se traite en 10 à 15 minutes sur un poste standard.'),
('Que faire si un nom n\'a pas été détecté ?',
'Ajouter le terme dans la blacklist via l\'interface. '
'Il sera systématiquement masqué lors des prochains traitements.'),
('Que faire si un terme médical a été masqué par erreur ?',
'Ajouter le terme dans la whitelist via l\'interface. '
'Il ne sera plus jamais masqué.'),
('Faut-il une connexion internet ?',
'Non. Le logiciel fonctionne intégralement hors-ligne. '
'L\'intelligence artificielle est embarquée dans l\'exécutable.'),
('Les documents originaux sont-ils modifiés ?',
'Non. Le logiciel crée une copie anonymisée. '
'Les documents originaux restent intacts.'),
('Peut-on traiter un dossier entier d\'un coup ?',
'Oui. Il suffit de sélectionner un dossier au lieu d\'un fichier. '
'Tous les documents du dossier seront traités séquentiellement.'),
]
for question, answer in faq:
p = doc.add_paragraph()
run_q = p.add_run(question + '\n')
run_q.bold = True
run_q.font.size = Pt(10)
run_a = p.add_run(answer)
run_a.font.size = Pt(10)
p.paragraph_format.space_after = Pt(8)
# ══════════════════════════════════════════════════════════════════════
# Construction du document final
# ══════════════════════════════════════════════════════════════════════
def main():
doc = Document()
# ── Style par défaut ──
style = doc.styles['Normal']
style.font.name = 'Calibri'
style.font.size = Pt(10)
style.paragraph_format.space_after = Pt(4)
# Marges
for section in doc.sections:
section.top_margin = Cm(1.5)
section.bottom_margin = Cm(1.5)
section.left_margin = Cm(2)
section.right_margin = Cm(2)
# ════════════════════════════════════════════
# VERSION 1 — Synthétique
# ════════════════════════════════════════════
build_version_1(doc)
# ── Séparation ──
add_section_break(doc)
sep = doc.add_paragraph()
sep.alignment = WD_ALIGN_PARAGRAPH.CENTER
for _ in range(6):
doc.add_paragraph()
sep2 = doc.add_paragraph()
sep2.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = sep2.add_run('— Version détaillée ci-après —')
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
run.italic = True
add_section_break(doc)
# ════════════════════════════════════════════
# VERSION 2 — Détaillée
# ════════════════════════════════════════════
build_version_2(doc)
# ── Sauvegarde ──
output_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
'Fiche_Produit_Pseudonymisation.docx')
doc.save(output_path)
print(f'Fiche produit générée : {output_path}')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,808 @@
#!/usr/bin/env python3
"""Génère la fiche technique DSI / RSSI / DPO en DOCX."""
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
import os
# ── Helpers ──────────────────────────────────────────────────────────
def set_cell_shading(cell, color_hex: str):
shading = cell._element.get_or_add_tcPr()
shading_elem = shading.makeelement(qn('w:shd'), {
qn('w:val'): 'clear',
qn('w:color'): 'auto',
qn('w:fill'): color_hex,
})
shading.append(shading_elem)
def add_heading_styled(doc, text, level=1, color=RGBColor(0x1A, 0x56, 0x8E)):
h = doc.add_heading(text, level=level)
for run in h.runs:
run.font.color.rgb = color
return h
def add_bullet(doc, text, bold_prefix=None):
p = doc.add_paragraph(style='List Bullet')
if bold_prefix:
run = p.add_run(bold_prefix)
run.bold = True
p.add_run(text)
else:
p.add_run(text)
return p
def add_para(doc, text, bold=False, italic=False, space_after=Pt(6)):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.italic = italic
p.paragraph_format.space_after = space_after
return p
def add_table_row(table, cells_data, header=False, header_color='1A568E'):
row = table.add_row()
for i, text in enumerate(cells_data):
cell = row.cells[i]
cell.text = ''
p = cell.paragraphs[0]
run = p.add_run(text)
run.font.size = Pt(9.5)
if header:
run.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
set_cell_shading(cell, header_color)
p.paragraph_format.space_before = Pt(3)
p.paragraph_format.space_after = Pt(3)
def set_table_style(table):
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
p.paragraph_format.space_before = Pt(2)
p.paragraph_format.space_after = Pt(2)
def add_section_break(doc):
doc.add_page_break()
def add_audience_tag(doc, text):
tag = doc.add_paragraph()
run = tag.add_run(text)
run.italic = True
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
run.font.size = Pt(9)
tag.paragraph_format.space_after = Pt(8)
def add_labeled_para(doc, label, text):
"""Paragraphe avec un label en gras suivi du texte normal."""
p = doc.add_paragraph()
run_label = p.add_run(label + '')
run_label.bold = True
p.add_run(text)
p.paragraph_format.space_after = Pt(5)
return p
# ── Couleurs ─────────────────────────────────────────────────────────
BLUE_DARK = RGBColor(0x1A, 0x56, 0x8E)
RED_DARK = RGBColor(0x8E, 0x1A, 0x1A)
GREEN_DARK = RGBColor(0x2E, 0x7D, 0x32)
# ══════════════════════════════════════════════════════════════════════
# PAGE DE TITRE
# ══════════════════════════════════════════════════════════════════════
def build_title_page(doc):
for _ in range(5):
doc.add_paragraph()
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in title.runs:
run.font.color.rgb = BLUE_DARK
run.font.size = Pt(26)
doc.add_paragraph()
sub = doc.add_paragraph()
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = sub.add_run('Fiche technique — Sécurité et Conformité')
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_paragraph()
audiences = doc.add_paragraph()
audiences.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = audiences.add_run('DSI · RSSI · DPO')
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
run.italic = True
doc.add_paragraph()
doc.add_paragraph()
# Encadré résumé
summary = doc.add_paragraph()
summary.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = summary.add_run(
'Logiciel d\'anonymisation fonctionnant 100% en local.\n'
'Aucune connexion réseau. Aucun sous-traitant. Aucune donnée exfiltrée.\n'
'Intelligence artificielle embarquée — masquage irréversible.'
)
run.font.size = Pt(10.5)
run.font.color.rgb = RGBColor(0x44, 0x44, 0x44)
run.italic = True
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 1. ARCHITECTURE ET FLUX DE DONNÉES
# ══════════════════════════════════════════════════════════════════════
def build_architecture(doc):
add_heading_styled(doc, '1. Architecture et flux de données', level=1)
add_audience_tag(doc, '→ DSI, RSSI')
add_para(doc,
'Principe fondamental : aucune donnée ne quitte le poste de travail.',
bold=True, space_after=Pt(10))
points = [
('Exécutable autonome',
'Un seul fichier .exe embarquant l\'intégralité des modèles d\'IA, '
'des bases de référence et du moteur OCR. Pas d\'appel réseau, '
'pas d\'API, pas de dépendance externe à l\'exécution.'),
('Traitement en mémoire',
'Les documents sont traités en mémoire vive. Aucune copie dans un '
'répertoire temporaire partagé, aucun cache disque au-delà de la session.'),
('Aucune télémétrie',
'Pas de phoning home, pas de vérification de licence en ligne, '
'pas de collecte de statistiques d\'usage.'),
('Stateless',
'Pas de base de données. Le logiciel ne conserve aucun état entre '
'deux exécutions, hormis le fichier de configuration (whitelist/blacklist).'),
('Non destructif',
'Le document source n\'est jamais modifié. Un nouveau fichier '
'anonymisé est créé dans le dossier de sortie.'),
]
for label, text in points:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# Schéma flux
add_heading_styled(doc, 'Flux de traitement', level=2)
flow_steps = [
('1', 'Entrée', 'Document source (poste local) — PDF, DOCX, image...'),
('2', 'Extraction', 'Extraction du texte en mémoire (layout-aware + OCR intégré si nécessaire)'),
('3', 'Détection', '4 moteurs IA analysent le texte en parallèle (CPU uniquement)'),
('4', 'Masquage', 'Remplacement irréversible des PII dans le PDF (texte supprimé + rectangle noir)'),
('5', 'Sortie', 'Écriture du PDF anonymisé sur disque local'),
('6', 'Nettoyage', 'Libération mémoire — aucune trace résiduelle'),
]
table = doc.add_table(rows=0, cols=3)
table.style = 'Table Grid'
add_table_row(table, ['Étape', 'Phase', 'Description'], header=True)
for step, phase, desc in flow_steps:
add_table_row(table, [step, phase, desc])
set_table_style(table)
# Largeurs colonnes
for row in table.rows:
row.cells[0].width = Cm(1.2)
row.cells[1].width = Cm(2.8)
row.cells[2].width = Cm(12)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 2. CONFORMITÉ RÉGLEMENTAIRE
# ══════════════════════════════════════════════════════════════════════
def build_conformite(doc):
add_heading_styled(doc, '2. Conformité réglementaire', level=1)
add_audience_tag(doc, '→ DPO, RSSI, Direction')
# ── RGPD ──
add_heading_styled(doc, 'RGPD (Règlement 2016/679)', level=2)
rgpd = [
('Art. 25 — Privacy by design',
'Le traitement est conçu pour fonctionner sans transfert de données. '
'L\'intelligence artificielle est embarquée dans l\'exécutable, '
'pas hébergée sur un serveur distant.'),
('Art. 28 — Sous-traitance',
'Aucun sous-traitant au sens du RGPD. Le logiciel tourne sur '
'l\'infrastructure de l\'établissement, sous sa responsabilité exclusive. '
'Aucune donnée n\'est transmise à un tiers.'),
('Art. 32 — Sécurité du traitement',
'Les données ne transitent par aucun réseau. Le périmètre de sécurité '
'est celui du poste de travail. Les mécanismes de protection existants '
'(chiffrement disque, contrôle d\'accès, antivirus) s\'appliquent.'),
('Art. 35 — Analyse d\'impact (AIPD)',
'Le logiciel réduit le risque sur les données personnelles : il anonymise, '
'il ne crée pas de nouveau traitement. Pas de traitement à grande échelle '
'externalisé. Une AIPD simplifiée peut suffire selon la politique de l\'établissement.'),
('Art. 17 — Droit à l\'effacement',
'Les documents anonymisés ne contiennent plus de données à caractère personnel. '
'Ils sortent du champ d\'application du RGPD.'),
('Art. 5.1.e — Limitation de conservation',
'Le logiciel ne stocke aucune donnée personnelle au-delà de la session '
'de traitement. Pas de base, pas de journal nominatif, pas d\'historique.'),
]
for label, text in rgpd:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── AI Act ──
add_heading_styled(doc, 'AI Act (Règlement 2024/1689)', level=2)
ai_act = [
('Classification du risque',
'Le logiciel utilise des modèles d\'IA pour la détection d\'entités nommées '
'(NER). Il ne prend aucune décision automatisée affectant des personnes. '
'L\'IA est un outil d\'aide : l\'opérateur humain reste maître de la validation.'),
('Pas de système à haut risque',
'Pas de scoring, pas de profilage, pas de catégorisation de personnes. '
'Le logiciel traite des documents, pas des individus.'),
('Transparence',
'Les modèles utilisés sont identifiés (CamemBERT, GLiNER). '
'Leur fonctionnement est documenté. Les résultats sont vérifiables '
'par l\'opérateur avant toute transmission.'),
]
for label, text in ai_act:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── Cadre santé ──
add_heading_styled(doc, 'Cadre réglementaire santé', level=2)
sante = [
('PGSSI-S',
'Compatible avec la Politique Générale de Sécurité des Systèmes '
'd\'Information de Santé. Le logiciel fonctionne dans le périmètre SI '
'de l\'établissement sans ouvrir de flux réseau.'),
('Hébergement de Données de Santé (HDS)',
'Pas d\'agrément HDS requis. Le logiciel n\'héberge rien — '
'il traite en local et ne conserve aucune donnée.'),
('Découplage DPI',
'Le logiciel travaille sur des fichiers exportés du DPI. '
'Aucune connexion directe au système d\'information clinique. '
'Aucun risque d\'altération des données sources.'),
]
for label, text in sante:
add_labeled_para(doc, label, text)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 3. SÉCURITÉ TECHNIQUE
# ══════════════════════════════════════════════════════════════════════
def build_securite(doc):
add_heading_styled(doc, '3. Sécurité technique', level=1)
add_audience_tag(doc, '→ RSSI')
# ── Surface d'attaque ──
add_heading_styled(doc, 'Surface d\'attaque', level=2)
attack_surface = [
'Aucun port réseau ouvert — le logiciel n\'écoute sur aucune interface',
'Aucune communication sortante — vérifiable par capture réseau (Wireshark, pare-feu)',
'Pas de serveur web embarqué, pas de socket, pas d\'API REST',
'Pas de service en arrière-plan ni de processus résident',
'Processus mono-instance protégé par verrou fichier (pas de double exécution)',
]
for item in attack_surface:
add_bullet(doc, item)
doc.add_paragraph()
# ── Exécutable ──
add_heading_styled(doc, 'Exécutable', level=2)
exe_points = [
('Compilation',
'Compilé via PyInstaller — bundle Python, dépendances et modèles IA '
'en un seul fichier .exe autonome.'),
('Signature de code',
'Compatible avec les outils de signature Windows (signtool). '
'L\'établissement peut signer l\'exécutable avec son propre certificat '
'si sa politique de sécurité l\'exige.'),
('Privilèges',
'Fonctionne en espace utilisateur standard. Pas d\'élévation de privilèges, '
'pas d\'UAC, pas de droits administrateur requis.'),
('Registre Windows',
'Aucune écriture dans le registre Windows ni dans les répertoires système. '
'L\'exécutable et sa configuration restent dans leur dossier.'),
('Empreinte',
'Exécutable unique (~720 Mo, modèles IA inclus). '
'Aucune DLL externe, aucune dépendance système non standard.'),
]
for label, text in exe_points:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── Données ──
add_heading_styled(doc, 'Données au repos et en transit', level=2)
table = doc.add_table(rows=0, cols=3)
table.style = 'Table Grid'
add_table_row(table, ['Catégorie', 'Localisation', 'Contenu sensible'], header=True)
data_rows = [
('Documents source', 'Inchangés, emplacement d\'origine', 'Oui — sous contrôle établissement'),
('Documents anonymisés', 'Dossier de sortie (local)', 'Non — PII supprimées'),
('Configuration (.yml)', 'À côté de l\'exécutable', 'Non — listes de termes uniquement'),
('Fichiers temporaires', 'Aucun', 'N/A'),
('Logs', 'Console uniquement (non persistés)', 'Non — pas de données nominatives'),
('Données en transit', 'Aucune', 'N/A — aucune communication réseau'),
]
for cat, loc, sensible in data_rows:
add_table_row(table, [cat, loc, sensible])
set_table_style(table)
doc.add_paragraph()
# ── Vérification indépendante ──
add_heading_styled(doc, 'Vérification indépendante', level=2)
add_para(doc,
'L\'absence de communication réseau est vérifiable de manière indépendante '
'par l\'équipe sécurité de l\'établissement :',
space_after=Pt(8))
verif = [
('Capture réseau', 'Wireshark ou tcpdump pendant une session de traitement : aucun paquet émis.'),
('Pare-feu applicatif', 'Bloquer toute communication sortante pour le processus : aucun impact fonctionnel.'),
('Analyse statique', 'Aucune URL, aucun endpoint, aucune chaîne de connexion dans l\'exécutable.'),
('Sandbox', 'Exécution dans un environnement isolé (VM sans réseau) : fonctionnement nominal.'),
]
for label, text in verif:
add_labeled_para(doc, label, text)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 4. DÉPLOIEMENT
# ══════════════════════════════════════════════════════════════════════
def build_deploiement(doc):
add_heading_styled(doc, '4. Déploiement et maintenance', level=1)
add_audience_tag(doc, '→ DSI')
# ── Prérequis ──
add_heading_styled(doc, 'Prérequis techniques', level=2)
table = doc.add_table(rows=0, cols=2)
table.style = 'Table Grid'
add_table_row(table, ['Élément', 'Détail'], header=True)
prereqs = [
('Système d\'exploitation', 'Windows 10 / 11 (64 bits)'),
('Processeur', 'x86-64, 4 cœurs recommandés'),
('Mémoire vive', '8 Go minimum (16 Go recommandés pour lots volumineux)'),
('Espace disque', '~1 Go (exécutable + configuration)'),
('GPU', 'Non requis — inférence CPU uniquement'),
('Droits utilisateur', 'Utilisateur standard (pas d\'administrateur)'),
('Réseau', 'Aucun accès requis — fonctionnement 100% hors-ligne'),
('Logiciel tiers', 'Aucun — tout est embarqué dans l\'exécutable'),
]
for elem, detail in prereqs:
add_table_row(table, [elem, detail])
set_table_style(table)
for row in table.rows:
row.cells[0].width = Cm(4.5)
row.cells[1].width = Cm(11.5)
doc.add_paragraph()
# ── Installation ──
add_heading_styled(doc, 'Procédure d\'installation', level=2)
steps = [
'Copier l\'exécutable (.exe) sur le poste de travail',
'Premier lancement : le fichier de configuration (dictionnaires.yml) '
'est créé automatiquement dans le même répertoire',
'Aucune autre manipulation — le logiciel est opérationnel',
]
for i, step in enumerate(steps, 1):
p = doc.add_paragraph()
run = p.add_run(f'{i}. ')
run.bold = True
p.add_run(step)
p.paragraph_format.space_after = Pt(4)
add_para(doc,
'Pas d\'installeur MSI/EXE setup. Pas de modification du registre. '
'Pas de redémarrage nécessaire.',
italic=True, space_after=Pt(10))
# ── Mise à jour ──
add_heading_styled(doc, 'Mise à jour', level=2)
updates = [
'Remplacer l\'exécutable par la nouvelle version',
'La configuration personnalisée est préservée (fichier séparé)',
'Pas de mise à jour automatique — contrôle total par la DSI',
'Pas de dépendance à un serveur de mise à jour',
]
for item in updates:
add_bullet(doc, item)
doc.add_paragraph()
# ── Déploiement multi-postes ──
add_heading_styled(doc, 'Déploiement multi-postes', level=2)
multi = [
('Distribution',
'Copie simple via partage réseau, SCCM, GPO, ou clé USB.'),
('Configuration centralisée',
'Préparer un fichier .yml maître avec les paramètres communs, '
'le distribuer avec l\'exécutable.'),
('Personnalisation par site',
'Chaque établissement peut ajuster sa configuration localement '
'via l\'interface graphique (whitelist, blacklist).'),
('Échange de configuration',
'Export JSON depuis l\'interface → envoi par email → '
'fusion centralisée → renvoi du YAML consolidé.'),
]
for label, text in multi:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── Intégration SI ──
add_heading_styled(doc, 'Intégration au SI', level=2)
integration = [
'Découplé du DPI — travaille sur des fichiers exportés, pas de connecteur direct',
'Utilisable en ligne de commande pour intégration dans un workflow batch',
'Compatible avec les espaces de travail sécurisés et les postes verrouillés',
'Aucune dépendance externe (pas d\'accès internet, pas de service tiers)',
]
for item in integration:
add_bullet(doc, item)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 5. GARANTIES D'ANONYMISATION
# ══════════════════════════════════════════════════════════════════════
def build_garanties(doc):
add_heading_styled(doc, '5. Garanties d\'anonymisation', level=1)
add_audience_tag(doc, '→ DPO, RSSI')
# ── Nature du masquage ──
add_heading_styled(doc, 'Nature du masquage', level=2)
masquage = [
('Irréversibilité',
'Le texte original est supprimé du flux PDF, puis recouvert '
'par un rectangle noir opaque. Il ne s\'agit pas d\'un calque amovible : '
'l\'information est définitivement détruite dans le fichier de sortie.'),
('Métadonnées',
'Les métadonnées PDF contenant des données personnelles '
'(auteur, titre, sujet) sont nettoyées.'),
('Codes-barres',
'Les codes-barres contenant des identifiants patients sont '
'détectés et masqués automatiquement.'),
('Intégrité du document',
'La structure, la mise en page et le contenu médical sont '
'préservés. Seules les données à caractère personnel sont supprimées.'),
]
for label, text in masquage:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── Tableau masqué / préservé ──
add_heading_styled(doc, 'Périmètre de détection', level=2)
table = doc.add_table(rows=0, cols=2)
table.style = 'Table Grid'
add_table_row(table, ['Données masquées (PII)', 'Données préservées'], header=True)
rows_data = [
('Noms et prénoms (patients)', 'Diagnostics et conclusions médicales'),
('Noms et prénoms (médecins, soignants)', 'Traitements et posologies'),
('Adresses postales', 'Actes médicaux (codage CCAM)'),
('Numéros de téléphone', 'Résultats d\'examens et de biologie'),
('Numéros de sécurité sociale', 'Dates de séjour et d\'intervention'),
('Dates de naissance', 'Codage CIM-10'),
('Noms d\'établissements', 'Comptes-rendus opératoires (contenu)'),
('Codes-barres d\'identification', 'Structure et mise en page'),
]
for masked, preserved in rows_data:
add_table_row(table, [masked, preserved])
set_table_style(table)
doc.add_paragraph()
# ── Moteurs de détection ──
add_heading_styled(doc, 'Moteurs de détection', level=2)
add_para(doc,
'Le logiciel combine 4 moteurs d\'intelligence artificielle spécialisés '
'en français clinique. Chaque moteur analyse le document indépendamment. '
'Les résultats sont croisés pour maximiser le rappel (recall) et minimiser '
'les oublis.',
space_after=Pt(8))
engines = [
('Modèle CamemBERT médical',
'Modèle de langue français pré-entraîné, spécialisé dans la '
'reconnaissance d\'entités nommées en contexte clinique.'),
('Détection zero-shot',
'Modèle capable de détecter des données personnelles sans '
'entraînement spécifique, par compréhension sémantique du contexte.'),
('Modèle fine-tuné',
'Modèle entraîné spécifiquement sur plus de 1 000 documents médicaux '
'réels pour une précision maximale sur les formats cliniques français.'),
('Bases de référence nationales',
'219 000 noms de famille INSEE, 36 000 prénoms, '
'7 300 médicaments (BDPM), 108 000 établissements de santé (FINESS). '
'Ces bases servent à la fois à la détection et à l\'élimination '
'des faux positifs (termes médicaux confondus avec des noms).'),
]
for label, text in engines:
add_labeled_para(doc, label, text)
doc.add_paragraph()
# ── Performances ──
add_heading_styled(doc, 'Performances mesurées', level=2)
table2 = doc.add_table(rows=0, cols=2)
table2.style = 'Table Grid'
add_table_row(table2, ['Indicateur', 'Valeur'], header=True)
perfs = [
('Taux de détection (recall)', '> 99% sur corpus réel de contrôle T2A'),
('Types de documents validés', 'CR opératoires, anatomo-pathologie, bactériologie,\n'
'anesthésie, courriers, CR d\'hospitalisation'),
('Temps moyen par document', 'Quelques dizaines de secondes (poste standard)'),
('Traitement par lot', '20-30 documents en 10-15 minutes'),
('Principe de précaution', 'En cas de doute, le logiciel masque\n'
'(préférence faux positif sur faux négatif)'),
]
for ind, val in perfs:
add_table_row(table2, [ind, val])
set_table_style(table2)
for row in table2.rows:
row.cells[0].width = Cm(5.5)
row.cells[1].width = Cm(10.5)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 6. FAQ DSI / RSSI / DPO
# ══════════════════════════════════════════════════════════════════════
def build_faq(doc):
add_heading_styled(doc, '6. Questions fréquentes — DSI / RSSI / DPO', level=1)
faq = [
('Les données transitent-elles par un cloud ?',
'Non. Le logiciel fonctionne intégralement hors-ligne. Aucune connexion '
'réseau n\'est établie, ni en entrée ni en sortie. Vérifiable par '
'capture réseau.'),
('Faut-il un agrément Hébergeur de Données de Santé (HDS) ?',
'Non. Aucune donnée n\'est hébergée par un tiers. Le traitement est '
'effectué localement sur l\'infrastructure de l\'établissement.'),
('Y a-t-il un sous-traitant au sens du RGPD ?',
'Non. Le logiciel est un outil exécuté en local. Aucune donnée n\'est '
'transmise à l\'éditeur ni à un tiers. L\'établissement est seul '
'responsable de traitement.'),
('Faut-il réaliser une AIPD ?',
'Le logiciel réduit le risque sur les données personnelles. Il n\'introduit '
'pas de nouveau traitement à risque. Une AIPD simplifiée peut suffire, '
'selon la politique de l\'établissement et l\'avis du DPO.'),
('Le masquage est-il réversible ?',
'Non. Le texte est supprimé du fichier PDF, pas simplement recouvert. '
'Aucun outil ne permet de récupérer les données masquées dans le '
'fichier anonymisé.'),
('Quelles données sont stockées par le logiciel ?',
'Aucune donnée personnelle. Le logiciel ne maintient ni base de données, '
'ni journal nominatif, ni historique de traitement. Seul le fichier de '
'configuration (listes de termes) est persisté.'),
('Peut-on auditer le comportement réseau ?',
'Oui. L\'absence de trafic est vérifiable par Wireshark, pare-feu '
'applicatif, ou exécution dans une VM sans interface réseau. '
'Le logiciel fonctionne de manière identique.'),
('L\'exécutable est-il signable ?',
'Oui. L\'exécutable est compatible avec les outils de signature '
'Windows standard (signtool). L\'établissement peut appliquer '
'son propre certificat de signature de code.'),
('Comment s\'intègre-t-il avec le DPI ?',
'Le logiciel est découplé du DPI. Il travaille sur des fichiers '
'exportés (PDF, DOCX, etc.). Aucun connecteur direct, aucun accès '
'aux bases cliniques, aucun risque d\'altération.'),
('Que se passe-t-il si un nom n\'est pas détecté ?',
'L\'établissement peut ajouter le terme dans la blacklist via '
'l\'interface graphique. Il sera systématiquement masqué lors des '
'prochains traitements. La configuration est immédiatement active.'),
('Que se passe-t-il si un terme médical est masqué par erreur ?',
'L\'établissement peut l\'ajouter dans la whitelist. Le terme ne sera '
'plus masqué. La configuration est échangeable entre sites.'),
('Quel est l\'impact sur la performance du poste ?',
'Le traitement mobilise le processeur pendant quelques dizaines de '
'secondes par document. L\'utilisation mémoire reste sous 4 Go. '
'Le poste est utilisable pendant le traitement.'),
]
for question, answer in faq:
p = doc.add_paragraph()
run_q = p.add_run(question)
run_q.bold = True
run_q.font.size = Pt(10)
p.paragraph_format.space_after = Pt(2)
p2 = doc.add_paragraph()
run_a = p2.add_run(answer)
run_a.font.size = Pt(9.5)
run_a.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
p2.paragraph_format.space_after = Pt(10)
p2.paragraph_format.left_indent = Cm(0.5)
add_section_break(doc)
# ══════════════════════════════════════════════════════════════════════
# 7. MATRICE DE CONFORMITÉ
# ══════════════════════════════════════════════════════════════════════
def build_matrice(doc):
add_heading_styled(doc, '7. Matrice de conformité', level=1)
add_audience_tag(doc, '→ DPO, RSSI — synthèse décisionnelle')
add_para(doc,
'Tableau récapitulatif des exigences réglementaires et de sécurité, '
'et de la réponse apportée par le logiciel.',
space_after=Pt(10))
table = doc.add_table(rows=0, cols=3)
table.style = 'Table Grid'
add_table_row(table, ['Exigence', 'Statut', 'Commentaire'], header=True)
matrix = [
('Données hors établissement', 'Conforme',
'Aucune donnée ne quitte le poste'),
('Sous-traitance RGPD', 'Conforme',
'Aucun sous-traitant'),
('Privacy by design (Art. 25)', 'Conforme',
'IA embarquée, hors-ligne natif'),
('Sécurité du traitement (Art. 32)', 'Conforme',
'Périmètre = poste local'),
('AIPD (Art. 35)', 'Simplifié',
'Réduit le risque, ne le crée pas'),
('Agrément HDS', 'Non requis',
'Pas d\'hébergement tiers'),
('PGSSI-S', 'Compatible',
'Aucun flux réseau ouvert'),
('AI Act — risque', 'Risque limité',
'Outil d\'aide, pas de décision automatisée'),
('Masquage irréversible', 'Conforme',
'Suppression + rectangle noir'),
('Signature de code', 'Compatible',
'Signable par certificat établissement'),
('Auditabilité réseau', 'Vérifiable',
'Wireshark / pare-feu / sandbox'),
('Droits administrateur', 'Non requis',
'Espace utilisateur standard'),
]
for exigence, statut, comment in matrix:
row = table.add_row()
# Exigence
cell0 = row.cells[0]
cell0.text = ''
p0 = cell0.paragraphs[0]
run0 = p0.add_run(exigence)
run0.font.size = Pt(9)
p0.paragraph_format.space_before = Pt(3)
p0.paragraph_format.space_after = Pt(3)
# Statut avec couleur
cell1 = row.cells[1]
cell1.text = ''
p1 = cell1.paragraphs[0]
run1 = p1.add_run(statut)
run1.font.size = Pt(9)
run1.bold = True
if statut in ('Conforme', 'Compatible', 'Vérifiable'):
run1.font.color.rgb = GREEN_DARK
elif statut in ('Non requis', 'Simplifié', 'Risque limité'):
run1.font.color.rgb = BLUE_DARK
p1.paragraph_format.space_before = Pt(3)
p1.paragraph_format.space_after = Pt(3)
# Commentaire
cell2 = row.cells[2]
cell2.text = ''
p2 = cell2.paragraphs[0]
run2 = p2.add_run(comment)
run2.font.size = Pt(9)
run2.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
p2.paragraph_format.space_before = Pt(3)
p2.paragraph_format.space_after = Pt(3)
set_table_style(table)
for row in table.rows:
row.cells[0].width = Cm(4.5)
row.cells[1].width = Cm(2.5)
row.cells[2].width = Cm(9)
# ══════════════════════════════════════════════════════════════════════
# ASSEMBLAGE FINAL
# ══════════════════════════════════════════════════════════════════════
def main():
doc = Document()
# ── Style par défaut ──
style = doc.styles['Normal']
style.font.name = 'Calibri'
style.font.size = Pt(10)
style.paragraph_format.space_after = Pt(4)
# Marges
for section in doc.sections:
section.top_margin = Cm(1.5)
section.bottom_margin = Cm(1.5)
section.left_margin = Cm(2)
section.right_margin = Cm(2)
# Construction
build_title_page(doc)
build_architecture(doc)
build_conformite(doc)
build_securite(doc)
build_deploiement(doc)
build_garanties(doc)
build_faq(doc)
build_matrice(doc)
# Sauvegarde
output_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'Fiche_Technique_DSI_RSSI_DPO.docx'
)
doc.save(output_path)
print(f'Fiche technique générée : {output_path}')
if __name__ == '__main__':
main()

115
scripts/merge_params.py Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Fusionne les fichiers de paramètres envoyés par les établissements.
Usage :
python scripts/merge_params.py fichier1.json [fichier2.json ...]
python scripts/merge_params.py --dir /chemin/vers/exports/
Fusionne les whitelist_phrases et blacklist_force_mask_terms de chaque
fichier JSON exporté par la GUI dans la config maîtresse (dictionnaires.yml).
"""
import argparse
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("ERREUR : pyyaml requis (pip install pyyaml)")
sys.exit(1)
CONFIG = Path(__file__).parent.parent / "config" / "dictionnaires.yml"
def merge_params(json_files: list, config_path: Path = CONFIG, dry_run: bool = False):
"""Fusionne les paramètres des fichiers JSON dans la config YAML."""
if not config_path.exists():
print(f"ERREUR : config introuvable : {config_path}")
return
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
# Charger les listes existantes
existing_wl = set(cfg.get("whitelist_phrases", []))
existing_bl = set(cfg.get("blacklist", {}).get("force_mask_terms", []))
added_wl = set()
added_bl = set()
sources = []
for jf in json_files:
try:
data = json.loads(Path(jf).read_text(encoding="utf-8"))
src = f"{Path(jf).name} (v{data.get('version', '?')}, {data.get('date_export', '?')[:10]})"
sources.append(src)
for phrase in data.get("whitelist_phrases", []):
if phrase and phrase.strip() and phrase.strip() not in existing_wl:
added_wl.add(phrase.strip())
for term in data.get("blacklist_force_mask_terms", []):
if term and str(term).strip() and str(term).strip() not in existing_bl:
added_bl.add(str(term).strip())
except Exception as e:
print(f" ERREUR lecture {jf}: {e}")
print(f"\nSources traitées : {len(sources)}")
for s in sources:
print(f" - {s}")
print(f"\nNouvelles phrases whitelist : {len(added_wl)}")
for p in sorted(added_wl):
print(f" + {p}")
print(f"\nNouveaux termes blacklist : {len(added_bl)}")
for t in sorted(added_bl):
print(f" + {t}")
if not added_wl and not added_bl:
print("\nRien de nouveau à fusionner.")
return
if dry_run:
print("\n(dry-run — aucune modification)")
return
# Appliquer
cfg["whitelist_phrases"] = sorted(existing_wl | added_wl)
if "blacklist" not in cfg:
cfg["blacklist"] = {}
cfg["blacklist"]["force_mask_terms"] = sorted(existing_bl | added_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
print(f"\nConfig mise à jour : {config_path}")
print(f" Whitelist : {len(cfg['whitelist_phrases'])} phrases")
print(f" Blacklist : {len(cfg['blacklist']['force_mask_terms'])} termes")
def main():
parser = argparse.ArgumentParser(description="Fusionner les paramètres d'anonymisation")
parser.add_argument("files", nargs="*", help="Fichiers JSON à fusionner")
parser.add_argument("--dir", type=Path, help="Dossier contenant les fichiers JSON")
parser.add_argument("--config", type=Path, default=CONFIG, help="Config YAML cible")
parser.add_argument("--dry-run", action="store_true", help="Afficher sans modifier")
args = parser.parse_args()
json_files = list(args.files)
if args.dir and args.dir.is_dir():
json_files.extend(str(f) for f in args.dir.glob("*.json"))
if not json_files:
print("Aucun fichier JSON spécifié. Usage :")
print(" python scripts/merge_params.py export1.json export2.json")
print(" python scripts/merge_params.py --dir /chemin/exports/")
return
merge_params(json_files, config_path=args.config, dry_run=args.dry_run)
if __name__ == "__main__":
main()

View File

@@ -210,17 +210,34 @@ async def anonymize_text(
final_text = selective_rescan(final_text, cfg=cfg) final_text = selective_rescan(final_text, cfg=cfg)
elapsed = time.time() - t0 elapsed = time.time() - t0
audit_list = [
{"kind": h.kind, "original": h.original, "placeholder": h.placeholder, "page": h.page} # Inclure tous les hits (regex page≥0 + NER page=-1) avec source
for h in anon.audit ner_prefixes = ("NER_", "EDS_")
if h.page != -1 # exclure les propagations globales audit_list = []
] ner_count = 0
regex_count = 0
for h in anon.audit:
is_ner = h.kind.startswith(ner_prefixes) or h.page == -1
entry = {
"kind": h.kind,
"original": h.original,
"placeholder": h.placeholder,
"page": h.page,
"source": "ner" if is_ner else "regex",
}
audit_list.append(entry)
if is_ner:
ner_count += 1
else:
regex_count += 1
return { return {
"text_anonymized": final_text, "text_anonymized": final_text,
"audit": audit_list, "audit": audit_list,
"stats": { "stats": {
"pii_detected": len(audit_list), "pii_detected": len(audit_list),
"regex_count": regex_count,
"ner_count": ner_count,
"elapsed_seconds": round(elapsed, 3), "elapsed_seconds": round(elapsed, 3),
"ner_active": use_ner and _eds_manager is not None, "ner_active": use_ner and _eds_manager is not None,
}, },