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>
This commit is contained in:
2026-04-15 08:50:42 +02:00
parent 4b5925306e
commit 8e458c16ca
3 changed files with 293 additions and 84 deletions

View File

@@ -11,6 +11,18 @@ datas = [
(os.path.join(app_dir, 'detectors'), 'detectors'), (os.path.join(app_dir, 'detectors'), 'detectors'),
(os.path.join(app_dir, 'scripts'), 'scripts'), (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', for pyfile in ['anonymizer_core_refactored_onnx.py', 'eds_pseudo_manager.py',
'gliner_manager.py', 'camembert_ner_manager.py', 'gliner_manager.py', 'camembert_ner_manager.py',
'Pseudonymisation_Gui_V5.py']: 'Pseudonymisation_Gui_V5.py']:

View File

@@ -251,11 +251,16 @@ _VILLE_BLACKLIST = {
# Enrichissement depuis fichier externe (modifiable sans toucher au code) # Enrichissement depuis fichier externe (modifiable sans toucher au code)
_villes_bl_file = Path(__file__).parent / "data" / "villes_blacklist.txt" _villes_bl_file = Path(__file__).parent / "data" / "villes_blacklist.txt"
if _villes_bl_file.exists(): if _villes_bl_file.exists():
try:
for _line in _villes_bl_file.read_text(encoding="utf-8").splitlines(): for _line in _villes_bl_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip() _w = _line.strip()
if _w and not _w.startswith("#"): if _w and not _w.startswith("#"):
_VILLE_BLACKLIST.add(_w) _VILLE_BLACKLIST.add(_w)
log.info("Villes blacklist chargées : %d entrées", len(_VILLE_BLACKLIST)) log.info("Villes blacklist chargées : %d entrées", len(_VILLE_BLACKLIST))
except Exception as _exc:
log.error("Villes blacklist : erreur de lecture %s%s", _villes_bl_file, _exc)
else:
log.warning("Villes blacklist : fichier introuvable %s — défauts intégrés utilisés", _villes_bl_file)
try: try:
import ahocorasick as _ahocorasick import ahocorasick as _ahocorasick
@@ -827,6 +832,7 @@ _MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names())
# Enrichissement depuis fichier externe (modifiable sans toucher au code) # Enrichissement depuis fichier externe (modifiable sans toucher au code)
_stopwords_file = Path(__file__).parent / "data" / "stopwords_manuels.txt" _stopwords_file = Path(__file__).parent / "data" / "stopwords_manuels.txt"
if _stopwords_file.exists(): if _stopwords_file.exists():
try:
_sw_count = 0 _sw_count = 0
for _line in _stopwords_file.read_text(encoding="utf-8").splitlines(): for _line in _stopwords_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip() _w = _line.strip()
@@ -834,10 +840,15 @@ if _stopwords_file.exists():
_MEDICAL_STOP_WORDS_SET.add(_w) _MEDICAL_STOP_WORDS_SET.add(_w)
_sw_count += 1 _sw_count += 1
log.info("Stop-words manuels chargés : %d mots depuis %s", _sw_count, _stopwords_file.name) log.info("Stop-words manuels chargés : %d mots depuis %s", _sw_count, _stopwords_file.name)
except Exception as _exc:
log.error("Stop-words manuels : erreur de lecture %s%s", _stopwords_file, _exc)
else:
log.warning("Stop-words manuels : fichier introuvable %s — qualité dégradée", _stopwords_file)
# Enrichissement BDPM : ~7300 noms commerciaux + DCI/substances actives # Enrichissement BDPM : ~7300 noms commerciaux + DCI/substances actives
_bdpm_path = Path(__file__).parent / "data" / "bdpm" / "medicaments_stopwords.txt" _bdpm_path = Path(__file__).parent / "data" / "bdpm" / "medicaments_stopwords.txt"
if _bdpm_path.exists(): if _bdpm_path.exists():
try:
_bdpm_count = 0 _bdpm_count = 0
for _line in _bdpm_path.read_text(encoding="utf-8").splitlines(): for _line in _bdpm_path.read_text(encoding="utf-8").splitlines():
_w = _line.strip() _w = _line.strip()
@@ -845,6 +856,10 @@ if _bdpm_path.exists():
_MEDICAL_STOP_WORDS_SET.add(_w) _MEDICAL_STOP_WORDS_SET.add(_w)
_bdpm_count += 1 _bdpm_count += 1
log.info("BDPM stop-words chargés : %d mots", _bdpm_count) log.info("BDPM stop-words chargés : %d mots", _bdpm_count)
except Exception as _exc:
log.error("BDPM stop-words : erreur de lecture %s%s", _bdpm_path, _exc)
else:
log.warning("BDPM stop-words : fichier introuvable %s — qualité dégradée", _bdpm_path)
_MEDICAL_STOP_WORDS = ( _MEDICAL_STOP_WORDS = (
r"(?:" + "|".join(re.escape(w) for w in _MEDICAL_STOP_WORDS_SET) + r")" r"(?:" + "|".join(re.escape(w) for w in _MEDICAL_STOP_WORDS_SET) + r")"
@@ -1133,30 +1148,60 @@ class NameCandidate:
_WHITELIST_NEVER_MASK_TOKENS: set = set() _WHITELIST_NEVER_MASK_TOKENS: set = set()
_WHITELIST_NEVER_MASK_PHRASES: set = set() _WHITELIST_NEVER_MASK_PHRASES: set = set()
# Safe-guards pour les défauts intégrés quand les fichiers data/*.txt sont absents
# (mode frozen où le bundle aurait omis de les inclure). Contenu minimal pour
# garantir un comportement de masquage correct même en mode dégradé.
_DPI_LABELS_FALLBACK = {
"date", "note", "heure", "type", "soin", "soins", "surv",
"page", "presc", "saint", "sainte",
}
_COMPANION_BLACKLIST_FALLBACK = {
"CANCEROLOGIE", "ONCOLOGIE", "REANIMATION", "RADIOLOGIE",
"CARDIOLOGIE", "NEUROLOGIE", "PNEUMOLOGIE", "UROLOGIE",
"MEDECINE", "DOSSIER", "CONTENTION", "ISOLEMENT", "ELIMINATION",
"ZONE", "PARTI", "PLAN", "MAIN", "FORT", "FORTE",
}
def _load_txt_set(path: Path, transform=str.lower, label: str = "file") -> set:
"""Charge un fichier .txt ligne par ligne. Robuste aux erreurs (frozen exe)."""
result: set = set()
if not path.exists():
log.warning("%s introuvable : %s — utilisation des défauts intégrés", label, path)
return result
try:
for _line in path.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
result.add(transform(_w))
log.info("%s chargé : %d entrées depuis %s", label, len(result), path.name)
except Exception as exc:
log.error("%s : erreur de lecture %s%s", label, path, exc)
return result
# Labels DPI structurels à ne JAMAIS masquer comme noms (Date, Note, Heure...) # Labels DPI structurels à ne JAMAIS masquer comme noms (Date, Note, Heure...)
# Stocké en LOWERCASE — la comparaison est case-insensitive. # Stocké en LOWERCASE — la comparaison est case-insensitive.
# Chargé depuis data/dpi_labels_blacklist.txt + cfg["additional_dpi_labels"]. # Chargé depuis data/dpi_labels_blacklist.txt + cfg["additional_dpi_labels"].
_DPI_LABELS_SET: set = set() _DPI_LABELS_SET: set = _load_txt_set(
_dpi_file = Path(__file__).parent / "data" / "dpi_labels_blacklist.txt" Path(__file__).parent / "data" / "dpi_labels_blacklist.txt",
if _dpi_file.exists(): transform=str.lower,
for _line in _dpi_file.read_text(encoding="utf-8").splitlines(): label="DPI labels blacklist",
_w = _line.strip() )
if _w and not _w.startswith("#"): if not _DPI_LABELS_SET:
_DPI_LABELS_SET.add(_w.lower()) _DPI_LABELS_SET = set(_DPI_LABELS_FALLBACK)
log.info("DPI labels blacklist chargés : %d entrées", len(_DPI_LABELS_SET))
# Companion blacklist : termes EN MAJUSCULES qui ne sont JAMAIS des noms # Companion blacklist : termes EN MAJUSCULES qui ne sont JAMAIS des noms
# (spécialités, labos pharma, mots courants ambigus). # (spécialités, labos pharma, mots courants ambigus).
# Stocké en UPPERCASE — la comparaison est faite contre des candidats déjà uppercase. # Stocké en UPPERCASE — la comparaison est faite contre des candidats déjà uppercase.
# Chargé depuis data/companion_blacklist.txt + cfg["additional_companion_blacklist"]. # Chargé depuis data/companion_blacklist.txt + cfg["additional_companion_blacklist"].
_COMPANION_BLACKLIST_SET: set = set() _COMPANION_BLACKLIST_SET: set = _load_txt_set(
_comp_file = Path(__file__).parent / "data" / "companion_blacklist.txt" Path(__file__).parent / "data" / "companion_blacklist.txt",
if _comp_file.exists(): transform=str.upper,
for _line in _comp_file.read_text(encoding="utf-8").splitlines(): label="Companion blacklist",
_w = _line.strip() )
if _w and not _w.startswith("#"): if not _COMPANION_BLACKLIST_SET:
_COMPANION_BLACKLIST_SET.add(_w.upper()) _COMPANION_BLACKLIST_SET = set(_COMPANION_BLACKLIST_FALLBACK)
log.info("Companion blacklist chargée : %d entrées", len(_COMPANION_BLACKLIST_SET))
_WHITELIST_FUNCTION_WORDS = { _WHITELIST_FUNCTION_WORDS = {

View File

@@ -76,112 +76,264 @@ def check_models_ready():
def launch_gui(): def launch_gui():
"""Launch the main GUI.""" """Launch the main GUI with a splash screen during the slow core import.
log.info("Launching GUI...")
try:
import anonymizer_core_refactored_onnx # noqa — pre-import the core
log.info("Core imported OK")
except Exception as e:
log.error(f"Core import error: {e}\n{traceback.format_exc()}")
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: try:
import anonymizer_core_refactored_onnx # noqa
log.info("Core imported OK")
result["step"] = "gui"
import Pseudonymisation_Gui_V5 # noqa import Pseudonymisation_Gui_V5 # noqa
log.info("GUI imported OK, starting mainloop...") 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() root = tk.Tk()
Pseudonymisation_Gui_V5.App(root) Pseudonymisation_Gui_V5.App(root)
root.mainloop() root.mainloop()
except Exception as e: except Exception as e:
log.error(f"GUI error: {e}\n{traceback.format_exc()}") log.error(f"GUI error: {e}\n{traceback.format_exc()}")
try: try:
messagebox.showerror("Erreur", f"Erreur au lancement de l'interface:\n\n{e}\n\nVoir {LOG_FILE}") messagebox.showerror(
except: "Erreur",
f"Erreur de l'interface :\n\n{e}\n\nVoir {LOG_FILE}",
)
except Exception:
pass pass
else:
splash.after(200, _poll)
splash.after(200, _poll)
splash.mainloop()
class SetupWindow: class SetupWindow:
"""Setup window for first launch (model download).""" """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): def __init__(self):
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("Anonymisation — Premier lancement") self.root.title("Anonymisation — Configuration initiale")
self.root.geometry("520x320") self.root.geometry("620x450")
self.root.resizable(False, False) self.root.resizable(False, False)
frame = ttk.Frame(self.root, padding=20) frame = ttk.Frame(self.root, padding=20)
frame.pack(fill="both", expand=True) frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Configuration initiale", font=("", 14, "bold")).pack(pady=(0, 10)) ttk.Label(frame, text="Préparation des modèles d'intelligence artificielle",
font=("", 13, "bold")).pack(pady=(0, 4))
ttk.Label( ttk.Label(
frame, frame,
text="Les modeles NER vont etre telecharges depuis HuggingFace.\nCela peut prendre quelques minutes selon la connexion.", text=("Au premier lancement, les modèles de détection doivent être téléchargés\n"
justify="center", "depuis HuggingFace. Cette opération est unique — durée 3 à 10 minutes\n"
).pack(pady=(0, 15)) "selon votre connexion internet. Merci de patienter."),
justify="center", foreground="#555555",
).pack(pady=(0, 12))
self.progress = ttk.Progressbar(frame, mode="indeterminate", length=420) # Barre de progression globale
self.progress.pack(pady=10) 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="En attente...") self.status_var = tk.StringVar(value="Démarrage…")
ttk.Label(frame, textvariable=self.status_var).pack(pady=5) ttk.Label(frame, textvariable=self.status_var, foreground="#1a568e").pack(pady=(0, 12))
self.btn = ttk.Button(frame, text="Demarrer le telechargement", command=self.start_download) # Zone détail par modèle
self.btn.pack(pady=15) 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): def start_download(self):
self.btn.configure(state="disabled") self.btn.pack_forget()
self.progress.start(10) 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() 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): def _download_thread(self):
failures = []
try: try:
# 1. EDS-Pseudo # 1. EDS-Pseudo
self._update("Chargement EDS-Pseudo (telechargement HuggingFace)...") self._update("Téléchargement d'EDS-Pseudo (modèle CamemBERT clinique)")
self._set_step("eds_pseudo", "running")
log.info("Downloading EDS-Pseudo...") log.info("Downloading EDS-Pseudo...")
try: try:
from eds_pseudo_manager import EdsPseudoManager from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager() mgr = EdsPseudoManager()
mgr.load() mgr.load()
self._update("EDS-Pseudo OK") self._set_step("eds_pseudo", "ok")
log.info("EDS-Pseudo OK") log.info("EDS-Pseudo OK")
except Exception as e: except Exception as e:
self._update(f"EDS-Pseudo: {e}") self._set_step("eds_pseudo", "fail")
failures.append(("EDS-Pseudo", str(e)))
log.warning(f"EDS-Pseudo failed: {e}") log.warning(f"EDS-Pseudo failed: {e}")
self._advance()
# 2. GLiNER # 2. GLiNER
self._update("Chargement GLiNER (telechargement HuggingFace)...") self._update("Téléchargement de GLiNER (détection zero-shot)")
self._set_step("gliner", "running")
log.info("Downloading GLiNER...") log.info("Downloading GLiNER...")
try: try:
from gliner_manager import GlinerManager from gliner_manager import GlinerManager
mgr = GlinerManager() mgr = GlinerManager()
mgr.load() mgr.load()
self._update("GLiNER OK") self._set_step("gliner", "ok")
log.info("GLiNER OK") log.info("GLiNER OK")
except Exception as e: except Exception as e:
self._update(f"GLiNER: {e}") self._set_step("gliner", "fail")
failures.append(("GLiNER", str(e)))
log.warning(f"GLiNER failed: {e}") log.warning(f"GLiNER failed: {e}")
self._advance()
# 3. CamemBERT-bio ONNX # 3. CamemBERT-bio ONNX
self._update("Verification CamemBERT-bio ONNX...") self._update("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
self._set_step("camembert_onnx", "running")
if check_models_ready(): if check_models_ready():
self._update("CamemBERT-bio ONNX OK") self._set_step("camembert_onnx", "ok")
else: else:
self._update("CamemBERT-bio ONNX manquant") self._set_step("camembert_onnx", "fail")
failures.append(("CamemBERT-bio ONNX", "fichier ONNX introuvable dans le bundle"))
log.error("CamemBERT-bio ONNX not found") log.error("CamemBERT-bio ONNX not found")
self._advance()
self._update("Configuration terminee ! Lancement de l'interface...") if failures:
log.info("Setup complete, launching GUI in 2s") lines = "\n".join(f"{name} : {err[:60]}" for name, err in failures)
self.root.after(2000, self._finish) 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: except Exception as e:
log.error(f"Setup error: {e}\n{traceback.format_exc()}") log.error(f"Setup error: {e}\n{traceback.format_exc()}")
self._update(f"Erreur: {e}") self._update(f"Erreur inattendue : {e}")
self.root.after(0, lambda: self.btn.configure(state="normal")) 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): def _update(self, msg):
self.root.after(0, lambda: self.status_var.set(msg)) self.root.after(0, lambda: self.status_var.set(msg))
def _finish(self): def _finish(self):
self.progress.stop() try:
self.root.destroy() self.root.destroy()
except Exception:
pass
launch_gui() launch_gui()
def run(self): def run(self):