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, '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']:

View File

@@ -251,11 +251,16 @@ _VILLE_BLACKLIST = {
# Enrichissement depuis fichier externe (modifiable sans toucher au code)
_villes_bl_file = Path(__file__).parent / "data" / "villes_blacklist.txt"
if _villes_bl_file.exists():
for _line in _villes_bl_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_VILLE_BLACKLIST.add(_w)
log.info("Villes blacklist chargées : %d entrées", len(_VILLE_BLACKLIST))
try:
for _line in _villes_bl_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_VILLE_BLACKLIST.add(_w)
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:
import ahocorasick as _ahocorasick
@@ -827,24 +832,34 @@ _MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names())
# Enrichissement depuis fichier externe (modifiable sans toucher au code)
_stopwords_file = Path(__file__).parent / "data" / "stopwords_manuels.txt"
if _stopwords_file.exists():
_sw_count = 0
for _line in _stopwords_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_MEDICAL_STOP_WORDS_SET.add(_w)
_sw_count += 1
log.info("Stop-words manuels chargés : %d mots depuis %s", _sw_count, _stopwords_file.name)
try:
_sw_count = 0
for _line in _stopwords_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_MEDICAL_STOP_WORDS_SET.add(_w)
_sw_count += 1
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
_bdpm_path = Path(__file__).parent / "data" / "bdpm" / "medicaments_stopwords.txt"
if _bdpm_path.exists():
_bdpm_count = 0
for _line in _bdpm_path.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_MEDICAL_STOP_WORDS_SET.add(_w)
_bdpm_count += 1
log.info("BDPM stop-words chargés : %d mots", _bdpm_count)
try:
_bdpm_count = 0
for _line in _bdpm_path.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_MEDICAL_STOP_WORDS_SET.add(_w)
_bdpm_count += 1
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 = (
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_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...)
# Stocké en LOWERCASE — la comparaison est case-insensitive.
# Chargé depuis data/dpi_labels_blacklist.txt + cfg["additional_dpi_labels"].
_DPI_LABELS_SET: set = set()
_dpi_file = Path(__file__).parent / "data" / "dpi_labels_blacklist.txt"
if _dpi_file.exists():
for _line in _dpi_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_DPI_LABELS_SET.add(_w.lower())
log.info("DPI labels blacklist chargés : %d entrées", len(_DPI_LABELS_SET))
_DPI_LABELS_SET: set = _load_txt_set(
Path(__file__).parent / "data" / "dpi_labels_blacklist.txt",
transform=str.lower,
label="DPI labels blacklist",
)
if not _DPI_LABELS_SET:
_DPI_LABELS_SET = set(_DPI_LABELS_FALLBACK)
# Companion blacklist : termes EN MAJUSCULES qui ne sont JAMAIS des noms
# (spécialités, labos pharma, mots courants ambigus).
# Stocké en UPPERCASE — la comparaison est faite contre des candidats déjà uppercase.
# Chargé depuis data/companion_blacklist.txt + cfg["additional_companion_blacklist"].
_COMPANION_BLACKLIST_SET: set = set()
_comp_file = Path(__file__).parent / "data" / "companion_blacklist.txt"
if _comp_file.exists():
for _line in _comp_file.read_text(encoding="utf-8").splitlines():
_w = _line.strip()
if _w and not _w.startswith("#"):
_COMPANION_BLACKLIST_SET.add(_w.upper())
log.info("Companion blacklist chargée : %d entrées", len(_COMPANION_BLACKLIST_SET))
_COMPANION_BLACKLIST_SET: set = _load_txt_set(
Path(__file__).parent / "data" / "companion_blacklist.txt",
transform=str.upper,
label="Companion blacklist",
)
if not _COMPANION_BLACKLIST_SET:
_COMPANION_BLACKLIST_SET = set(_COMPANION_BLACKLIST_FALLBACK)
_WHITELIST_FUNCTION_WORDS = {

View File

@@ -76,112 +76,264 @@ def check_models_ready():
def launch_gui():
"""Launch the main GUI."""
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()}")
"""Launch the main GUI with a splash screen during the slow core import.
try:
import Pseudonymisation_Gui_V5 # noqa
log.info("GUI imported OK, 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()}")
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:
messagebox.showerror("Erreur", f"Erreur au lancement de l'interface:\n\n{e}\n\nVoir {LOG_FILE}")
except:
pass
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 (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):
self.root = tk.Tk()
self.root.title("Anonymisation — Premier lancement")
self.root.geometry("520x320")
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="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(
frame,
text="Les modeles NER vont etre telecharges depuis HuggingFace.\nCela peut prendre quelques minutes selon la connexion.",
justify="center",
).pack(pady=(0, 15))
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))
self.progress = ttk.Progressbar(frame, mode="indeterminate", length=420)
self.progress.pack(pady=10)
# 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="En attente...")
ttk.Label(frame, textvariable=self.status_var).pack(pady=5)
self.status_var = tk.StringVar(value="Démarrage…")
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)
self.btn.pack(pady=15)
# 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.configure(state="disabled")
self.progress.start(10)
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("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...")
try:
from eds_pseudo_manager import EdsPseudoManager
mgr = EdsPseudoManager()
mgr.load()
self._update("EDS-Pseudo OK")
self._set_step("eds_pseudo", "ok")
log.info("EDS-Pseudo OK")
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}")
self._advance()
# 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...")
try:
from gliner_manager import GlinerManager
mgr = GlinerManager()
mgr.load()
self._update("GLiNER OK")
self._set_step("gliner", "ok")
log.info("GLiNER OK")
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}")
self._advance()
# 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():
self._update("CamemBERT-bio ONNX OK")
self._set_step("camembert_onnx", "ok")
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")
self._advance()
self._update("Configuration terminee ! Lancement de l'interface...")
log.info("Setup complete, launching GUI in 2s")
self.root.after(2000, self._finish)
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: {e}")
self.root.after(0, lambda: self.btn.configure(state="normal"))
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):
self.progress.stop()
self.root.destroy()
try:
self.root.destroy()
except Exception:
pass
launch_gui()
def run(self):