From 8e458c16ca2139201e1543bc33a5f9bd3e626a8d Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Wed, 15 Apr 2026 08:50:42 +0200 Subject: [PATCH] =?UTF-8?q?fix(frozen):=20data/*.txt=20dans=20bundle,=20fe?= =?UTF-8?q?edback=20UI=20pendant=20chargement=20mod=C3=A8les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- anonymisation_onefile.spec | 12 ++ anonymizer_core_refactored_onnx.py | 115 +++++++++---- launcher.py | 250 +++++++++++++++++++++++------ 3 files changed, 293 insertions(+), 84 deletions(-) diff --git a/anonymisation_onefile.spec b/anonymisation_onefile.spec index 41aeb2b..40d1d8e 100644 --- a/anonymisation_onefile.spec +++ b/anonymisation_onefile.spec @@ -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']: diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index b1b2042..5f6ae20 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -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 = { diff --git a/launcher.py b/launcher.py index 4a2d0f2..9970263 100644 --- a/launcher.py +++ b/launcher.py @@ -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 10–30 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 : ~15–30 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):