diff --git a/Pseudonymisation_Gui_V5.py b/Pseudonymisation_Gui_V5.py index 254bc64..016f473 100644 --- a/Pseudonymisation_Gui_V5.py +++ b/Pseudonymisation_Gui_V5.py @@ -504,9 +504,72 @@ class App: main, text="Comment ça marche ?", font=self._f_small, bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2", ) - help_lbl.pack(pady=(0, 18)) + help_lbl.pack(pady=(0, 8)) help_lbl.bind("", 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("", _toggle_params) + + # --- Whitelist (phrases à ne pas anonymiser) --- + tk.Label( + self._params_frame, + text="Phrases à ne PAS anonymiser (une par ligne) :", + font=self._f_small, bg=CLR_BG, fg=CLR_TEXT, anchor="w", + ).pack(fill=tk.X, pady=(4, 2)) + + self._whitelist_text = tk.Text( + self._params_frame, height=5, font=("Consolas", 9), + wrap=tk.WORD, relief=tk.GROOVE, bd=1, + ) + self._whitelist_text.pack(fill=tk.X, pady=(0, 8)) + + # --- Blacklist (phrases à toujours masquer) --- + tk.Label( + self._params_frame, + text="Mots/phrases à TOUJOURS masquer (une par ligne) :", + font=self._f_small, bg=CLR_BG, fg=CLR_TEXT, anchor="w", + ).pack(fill=tk.X, pady=(0, 2)) + + self._blacklist_text = tk.Text( + self._params_frame, height=5, font=("Consolas", 9), + wrap=tk.WORD, relief=tk.GROOVE, bd=1, + ) + self._blacklist_text.pack(fill=tk.X, pady=(0, 8)) + + # Bouton sauvegarder + save_btn = tk.Button( + self._params_frame, text="Sauvegarder les paramètres", + font=self._f_small, bg=CLR_CARD_BG, fg=CLR_TEXT, + relief=tk.GROOVE, cursor="hand2", + command=self._save_params, + ) + save_btn.pack(anchor="e", pady=(0, 4)) + + # 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) # ============================================================= @@ -1042,6 +1105,58 @@ class App: "« anonymise/ » à la racine du dossier sélectionné.", ) + # --------------------------------------------------------------- + # Paramètres avancés (whitelist/blacklist) + # --------------------------------------------------------------- + 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", []) + if wl: + self._whitelist_text.delete("1.0", tk.END) + self._whitelist_text.insert("1.0", "\n".join(wl)) + # Blacklist + bl = data.get("blacklist", {}).get("force_mask_terms", []) + if bl: + self._blacklist_text.delete("1.0", tk.END) + self._blacklist_text.insert("1.0", "\n".join(str(t) for t in bl)) + except Exception: + pass + + 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 + wl_text = self._whitelist_text.get("1.0", tk.END).strip() + wl_lines = [l.strip() for l in wl_text.split("\n") if l.strip()] + data["whitelist_phrases"] = wl_lines + + # Blacklist terms + bl_text = self._blacklist_text.get("1.0", tk.END).strip() + bl_lines = [l.strip() for l in bl_text.split("\n") if l.strip()] + if "blacklist" not in data: + data["blacklist"] = {} + data["blacklist"]["force_mask_terms"] = bl_lines + + 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) # --------------------------------------------------------------- diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 6807db3..656ce24 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -3106,6 +3106,49 @@ def _mask_ville_gazetteers(text: str) -> tuple: return "".join(result), masked_originals +# ----------------- Whitelist (phrases à ne jamais anonymiser) ----------------- + +def _apply_whitelist(text: str, phrases: List[str], audit: List[PiiHit]) -> str: + """Restaure les phrases whitelistées qui ont été masquées à tort. + + Pour chaque phrase de la whitelist, construit un pattern flexible qui + accepte des placeholders [XXX] entre les mots originaux. + Ex: "bas de contention" matche "bas [NOM] contention" ou "bas de [NOM]". + """ + _PH = r"\[[A-Z_]+\]" # placeholder pattern + + for phrase in phrases: + if not phrase or not phrase.strip(): + continue + words = phrase.strip().split() + if len(words) < 2: + continue + + # Construire un pattern où chaque mot de la phrase peut être + # remplacé par un placeholder OU être présent tel quel. + # Entre les mots : espace(s) optionnel(s) + parts = [] + for w in words: + # Le mot original OU un placeholder + parts.append(rf"(?:{re.escape(w)}|{_PH})") + # Joindre avec des espaces flexibles + pattern = r"(?i)" + r"[\s]+".join(parts) + + try: + rx = re.compile(pattern) + except re.error: + continue + + for m in rx.finditer(text): + matched = m.group(0) + # Ne restaurer que si au moins un placeholder est présent + # (sinon la phrase est déjà intacte, pas besoin de toucher) + if "[" in matched: + text = text[:m.start()] + phrase + text[m.end():] + + return text + + # ----------------- Selective safety rescan ----------------- def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: @@ -4038,6 +4081,11 @@ def process_pdf( ) final_text = _RE_BRACKET_CLEAN.sub(r"\1", final_text) + # 6) Whitelist : restaurer les phrases qui ne doivent jamais être anonymisées + whitelist_phrases = cfg.get("whitelist_phrases", []) + if whitelist_phrases: + final_text = _apply_whitelist(final_text, whitelist_phrases, anon.audit) + # Sauvegardes base = pdf_path.stem txt_path = out_dir / f"{base}.pseudonymise.txt" diff --git a/config/dictionnaires.yml b/config/dictionnaires.yml index d57755b..b489a0f 100644 --- a/config/dictionnaires.yml +++ b/config/dictionnaires.yml @@ -47,6 +47,19 @@ regex_overrides: placeholder: '[OGC]' flags: - 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" flags: case_insensitive: true unicode_word_boundaries: true