Compare commits

..

10 Commits

Author SHA1 Message Date
012445755a fix(splash): étapes de chargement dans le splash NATIF (pas le tkinter)
Ma précédente modif affichait les étapes dans un SECOND splash tkinter
qui s'ouvrait après le splash natif PyInstaller. L'utilisateur voulait
voir les étapes dans la PREMIÈRE fenêtre (splash natif avec logo).

Refonte launch_gui() :
- Suppression du splash tkinter intermédiaire (pas de fenêtre qui clignote)
- Le splash natif PyInstaller reste visible pendant toute la phase d'import
- Handler logging installé sur le root logger pour intercepter chaque
  log.info() du core. Traduction en libellé lisible + pyi_splash.update_text()
- Import synchrone (pas besoin de thread puisque le splash natif tourne
  dans son propre processus bootloader)
- À la fin : splash natif fermé + lancement de la GUI principale

Résultat : l'utilisateur voit une seule fenêtre (splash natif avec logo)
où défilent sous le message "Démarrage…" toutes les étapes de chargement
des gazetteers, modèles et index. Quand tout est prêt, le splash disparaît
et la GUI apparaît. Plus de fenêtre intermédiaire.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:34:40 +02:00
4b825976bd feat(splash): afficher les étapes de chargement dans le splash
Demande utilisateur : voir défiler les étapes (chargement des dictionnaires,
des modèles...) dans le splash au démarrage — effet pro apprécié des clients.

Implémentation :
- Nouveau handler logging.Handler installé sur le root logger avant l'import
  du core. Intercepte chaque log.info() et :
  * Traduit le message technique en libellé "prod" lisible (table de
    correspondance _LOG_TRANSLATIONS : "Gazetteers INSEE prénoms" →
    "Chargement des prénoms français (INSEE)…", etc.)
  * Pousse le libellé dans le splash tkinter (detail_var, label secondaire)
  * Pousse aussi dans le splash natif PyInstaller via pyi_splash.update_text()
- Splash tkinter agrandi 440×200 → 480×240 pour la nouvelle ligne détail
- Couleur primaire magenta (#E91E63) pour cohérence avec la GUI principale
- Handler retiré quand le splash se ferme (évite impact sur la GUI)

L'utilisateur voit maintenant défiler :
  Chargement des prénoms français (INSEE)…
  Chargement des noms de famille (INSEE)…
  Chargement des communes françaises (INSEE)…
  Chargement des numéros FINESS…
  Indexation des établissements de santé…
  Chargement du lexique médical…
  Chargement de la base médicamenteuse (BDPM)…
  Chargement des stop-words…
  Chargement du vocabulaire clinique…
  Chargement des phrases protégées…
  Moteur d'anonymisation prêt…
  Interface prête — finalisation…

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:23:57 +02:00
ab5a24fa68 feat(ui): refonte UI — logo aivanonym + palette magenta/pêche + onglets + v5.5
Intégration du logo "aivanonym" (gradient magenta → rose → pêche → noir)
fourni par le propriétaire. Refonte visuelle complète :

• APP_VERSION bump v5.4 → v5.5

• Assets (tous générés depuis assets/icons/logo.png) :
  - assets/icons/app.ico multi-résolution 16→256 (icône EXE Windows)
  - assets/icons/icon_{16,32,48,64,128,256,512}.png (fallback + taskbar)
  - assets/logo_header.png (260×61, intégré dans l'en-tête de la GUI)
  - assets/logo_splash.png (335×80, intégré dans le splash)
  - assets/splash.png redessiné avec logo + bandeau gradient primary→accent

• Palette dérivée du logo (remplace l'ancien bleu) :
  - CLR_PRIMARY       #E91E63  magenta logo (CTA, liens)
  - CLR_PRIMARY_DARK  #C2185B  hover / pressed
  - CLR_PRIMARY_LIGHT #FCE4EC  fond doux (tags, cartes)
  - CLR_ACCENT        #FFB74D  pêche logo (secondaire)
  - CLR_ACCENT_LIGHT  #FFF3E0
  - CLR_TEXT/SECONDARY proches du noir/gris du logo

• Pseudonymisation_Gui_V5.py :
  - Helper _asset(name) : résout sous sys._MEIPASS/assets en mode frozen
  - _apply_window_icon() : iconbitmap (.ico sur Windows) + iconphoto (PNG)
  - _load_image_safe() : charge PIL avec ref persistante (évite GC tkinter)
  - Header fixe hors onglets : logo image + baseline "100% local"
  - Ligne accent magenta sous le header (inspiration logo)
  - Onglets custom uniformes (remplace ttk.Notebook dont les tabs avaient
    des tailles variables selon l'état) : tous les boutons identiques,
    seule une bordure basse magenta signale l'onglet actif. _switch_tab()
    gère l'affichage du contenu et la mise à jour des styles.
  - Onglet 1 "Anonymisation" : workflow principal (choix, lancer, résultats)
  - Onglet 2 "Paramètres" : 3 listes (whitelist/blacklist/stopwords) +
    export/import + save. Plus de section repliable — respiration visuelle.
  - Boutons export/import repensés avec les couleurs de la palette

• anonymisation_onefile.spec :
  - datas : ajout du dossier assets/ entier
  - EXE(icon=assets/icons/app.ico) : le .exe a maintenant le logo dans
    l'Explorateur Windows, la barre des tâches, le gestionnaire des tâches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:04:41 +02:00
6586b89b8f feat(gui): afficher version + build date + commit dans titre et status bar
Demande utilisateur : pouvoir identifier la build au premier coup d'oeil
sans confondre ancien/nouveau exe lors des tests.

Implémentation :
- build_info.py (gitignored, fallback "dev" pour mode développement)
  régénéré automatiquement par scripts/rebuild_anon.ps1 avec :
  BUILD_DATE = "2026-04-15 18:15"
  BUILD_COMMIT = "234137e"
  BUILD_BRANCH = "main"
- Pseudonymisation_Gui_V5.py : fonction _version_long() qui construit
  "v5.4 · 2026-04-15 18:15 · #234137e" depuis build_info (avec fallback
  silencieux si module absent en dev). Affichée dans :
    - Titre fenêtre : "Pseudonymisation de vos documents — v5.4 · ..."
    - Status bar en bas à droite
- anonymisation_onefile.spec : build_info.py ajouté aux datas bundlées.
- scripts/rebuild_anon.ps1 : STEP 4a génère build_info.py avant le
  PyInstaller avec git rev-parse short + branch + date courante.
- .gitignore : build_info.py exclu (volatile, regénéré).

En mode dev (pas frozen) : affichage "v5.4" seul (fallback).
En mode frozen : affichage complet avec date/commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:40:58 +02:00
234137ec50 fix(frozen): ajouter optimum aux hiddenimports PyInstaller
Message cosmétique sur Windows : "Prêt (NER indisponible : optimum.onnxruntime
introuvable. Installez 'optimum' et 'onnxruntime')". Apparaît dans la barre de
statut de la GUI quand EDS-Pseudo échoue à charger, et que le fallback
ner_manager_onnx.py essaie d'utiliser optimum.

Cause : 'optimum' n'était pas dans hiddenimports → PyInstaller ne le bundlait
pas → ner_manager_onnx.py mettait ORTModelForTokenClassification = None au
niveau module → l'appel à load() levait RuntimeError.

Le pipeline principal (CamemBERT-bio ONNX + EDS-Pseudo + GLiNER) ne passe
PAS par ner_manager_onnx.py — il utilise camembert_ner_manager.py qui charge
directement l'ONNX via onnxruntime sans optimum. Donc le masquage fonctionne
correctement malgré ce message. Mais le message inquiète l'utilisateur.

Fix : ajouter optimum + sous-modules aux hiddenimports. Impact taille
attendu : ~30-80 MB selon les dépendances embarquées.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:37:20 +02:00
003be68ca8 chore(rebuild): script PowerShell robuste — rename + verif timestamp
Après deux rebuilds Windows silencieusement échoués (PermissionError
WinError 5 lors du os.remove par PyInstaller), amélioration du script :

1. Renommer l'ancien Anonymisation.exe en Anonymisation.old-HHMMSS.exe
   AVANT le build (au lieu de laisser PyInstaller faire os.remove qui
   échoue si Defender tient un handle). Move-Item bypass la plupart des
   scanners antivirus.

2. Exclusions Defender sur dist/ et build/ (Add-MpPreference).

3. Retry Remove-Item avec délai 10s × 5 sur build/ en cas de lock.

4. Vérification timestamp APRÈS/AVANT : si l'exe final a le même
   LastWriteTime qu'avant le build, exit code 2 "ÉCHEC CRITIQUE —
   timestamp inchangé". Évite le faux OK quand le build rate mais que
   l'ancien exe subsiste.

5. Encodage UTF-8 BOM nécessaire pour PowerShell Windows (accents
   français dans les messages).

Validé : rebuild v5d a passé — nouveau exe 17:47:40 (vs ancien 17:09:32),
ancien renommé en Anonymisation.old-174023.exe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:48:19 +02:00
8e43d8d1ae fix(detect): accepter prénoms 3 chars après Dr/Mme (Ute, Eva, Léo…)
Audit manuel après batch QC : 20 occurrences de "Dr Ute" dans
trackare-03020576-23175616 non masquées. Audit jsonl confirme : 0 hit pour
"Ute" → pas détecté.

Cause : _add_candidate (deux implémentations, lignes 1908 et 2225) filtrait
len(token) < 4, empêchant la création du NameCandidate pour "Ute" (3 chars)
même avec bypass_stopwords=True. La cross-validation écrasait alors
all_names avec validated_names (vide pour Ute), et _apply_extracted_names
ne recevait donc jamais Ute.

Le commit 2f79f7c avait fait le fix uniquement dans _apply_extracted_names.
Fix incomplet : le filtre amont _add_candidate rejetait avant.

Correctif symétrique sur _add_candidate (×2) + _add_tokens_force_first :
accepter 3 chars UNIQUEMENT si bypass=True (contexte Dr/Mme) ET majuscule
initiale ET alpha pur. 2 chars reste filtré (initiales ambigues).

Validation :
- "DR. DURANTEAU Ute" matche bien RE_EXTRACT_DR_DEST et capture "DURANTEAU Ute"
- Audit produit "Ute DURANTEAU" en bloc + "DURANTEAU" seul (41 hits total)
- PDF redacted : 0 résiduel "Ute" (avant : 38)

Cas protégés :
- "Ute" accepté : bypass=True, U majuscule, alpha ✓
- "Les" refusé : bypass=True mais stopword (filtré ailleurs) ✓
- "JF" refusé : 2 chars, filtre longueur < 3 ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:21:54 +02:00
f17438c2ec ui(splash): retirer ligne statique qui chevauche le texte dynamique
L'utilisateur a signalé un chevauchement visuel entre la ligne statique
"Premier lancement : 30-60 secondes…" du PNG et la ligne dynamique
PyInstaller (qui affiche "Chargement EDS-Pseudo…", etc.) affichée par
pyi_splash.update_text().

Correctifs :
- PNG redessiné avec 3 lignes statiques seulement (titre, sous-titre,
  "Démarrage en cours — merci de patienter…") et une ZONE LIBRE y=170-235
  pour le texte dynamique.
- text_pos du Splash() ajusté à (60, 195) pour centrer dans la zone libre.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:15:02 +02:00
0a377bc001 feat(splash): splash natif PyInstaller — couvre la décompression onefile
L'exe --onefile décompresse ~720 Mo dans %TEMP% au lancement. Sur Windows,
cela prend 15-30 s AVANT que Python ne démarre. Pendant ce temps :
- Aucune fenêtre visible (même le splash tkinter existant n'était pas encore
  exécuté, car il faut d'abord l'import de Python).
- L'utilisateur clique parfois plusieurs fois, croit que l'app est plantée.

Solution : Splash natif PyInstaller (Splash() dans le .spec). L'image est
affichée PAR LE BOOTLOADER de l'exe, AVANT même le démarrage Python. Le
texte sous l'image est actualisable via pyi_splash.update_text(), puis
fermé via pyi_splash.close() une fois le splash tkinter visible.

Changements :
- assets/splash.png (480x240) : titre + sous-titre + indication de durée
- anonymisation_onefile.spec : Splash() + splash/splash.binaries dans EXE()
- launcher.py : import pyi_splash (fallback silencieux en mode dev), helpers
  _splash_update / _splash_close, fermeture du splash natif dès que le
  splash tkinter est à l'écran (évite superposition).
- .gitignore : exception !assets/** pour versionner l'image du splash
  (règle générale *.png exclut tout le reste).

Effet utilisateur attendu : fenêtre visible IMMÉDIATEMENT au double-clic,
avec message "Démarrage en cours — merci de patienter…". Suppression du
trou noir de 15-30 s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:45 +02:00
e2e2a7c8e3 fix(redact): masquer tokens collés à ponctuation ("Douar,nécessitant")
Fuite détectée lors du QC batch 22 : le nom "Douar" était dans l'audit
(NOM page 6) mais restait visible dans le PDF redacted_vector. Cause :
dans get_text('words') le word était 'Douar,nécessitant' (virgule collée
sans espace). _search_whole_word faisait un == strict après strip des
ponctuations frontières, mais la virgule était au MILIEU — pas stripée.
→ aucun match → aucun rectangle → fuite.

Fix : passe 2 dans _search_whole_word avec regex word-boundary sur le
texte complet du word (pattern `(?<![A-Za-zÀ-ÿ])token(?![A-Za-zÀ-ÿ])`)
+ bbox proportionnelle au ratio chars matched / chars total du word.
Approximation exacte sur polices monospace, précision ±pixels sur
polices proportionnelles — couverte par le rectangle de redaction.

Validation bout-en-bout sur trackare-BA042686-23090597 : "Douar" masqué
(0 page résiduelle). QC strict retombe de 1 anomalie à 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:10:34 +02:00
18 changed files with 619 additions and 159 deletions

7
.gitignore vendored
View File

@@ -40,6 +40,13 @@ models/
*.jpg
*.jpeg
*.gif
# Exception : assets embarqués dans l'exe (splash, icônes…) doivent être versionnés
!assets/**
!assets
# build_info.py : régénéré automatiquement par scripts/rebuild_anon.ps1
# avec date/commit/branch. Ne pas versionner.
build_info.py
*.mp3
*.wav
*.mp4

View File

@@ -83,11 +83,45 @@ try:
except ImportError:
sv_ttk = None
# PIL pour charger le logo / icônes (optionnel — dégradation si absent).
try:
from PIL import Image, ImageTk
_PIL_AVAILABLE = True
except Exception:
_PIL_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------
APP_TITLE = "Pseudonymisation de vos documents"
APP_VERSION = "v5.4"
APP_VERSION = "v5.5"
# Métadonnées de build — chargées depuis build_info.py (régénéré par rebuild_anon.ps1)
try:
from build_info import BUILD_DATE, BUILD_COMMIT, BUILD_BRANCH
except Exception:
BUILD_DATE = "dev"
BUILD_COMMIT = "dev"
BUILD_BRANCH = "dev"
def _version_long() -> str:
"""Version étendue : v5.4 · 2026-04-15 18:15 · 234137e"""
parts = [APP_VERSION]
if BUILD_DATE != "dev":
parts.append(BUILD_DATE)
if BUILD_COMMIT != "dev":
parts.append(f"#{BUILD_COMMIT}")
return " · ".join(parts)
def _asset(name: str) -> Path:
"""Résout le chemin d'un asset dans assets/ (compatible frozen PyInstaller)."""
if getattr(sys, 'frozen', False):
base = Path(sys._MEIPASS)
else:
base = Path(__file__).resolve().parent
return base / 'assets' / name
def _app_dir() -> Path:
"""Répertoire racine de l'application (compatible PyInstaller/Nuitka)."""
@@ -150,19 +184,27 @@ flags:
regex_engine: "python"
"""
# Couleurs
CLR_PRIMARY = "#2563eb"
CLR_PRIMARY_LIGHT = "#dbeafe"
CLR_GREEN = "#16a34a"
CLR_GREEN_LIGHT = "#dcfce7"
CLR_RED = "#dc2626"
CLR_RED_LIGHT = "#fee2e2"
CLR_BLUE_LIGHT = "#eff6ff"
CLR_CARD_BG = "#ffffff"
CLR_CARD_BORDER = "#d1d5db"
CLR_BG = "#f9fafb"
CLR_TEXT = "#111827"
CLR_TEXT_SECONDARY = "#6b7280"
# Palette dérivée du logo aivanonym (gradient magenta → rose → pêche → noir)
# Magenta du logo : primaire (boutons, accents)
# Pêche : secondaire (tags, highlights)
# Noir/gris : texte et neutres
# Blanc/gris clair : fonds
CLR_PRIMARY = "#E91E63" # magenta logo (CTA, liens)
CLR_PRIMARY_DARK = "#C2185B" # hover / pressed
CLR_PRIMARY_LIGHT = "#FCE4EC" # fond léger (cartes sélectionnées)
CLR_ACCENT = "#FFB74D" # pêche logo (tags secondaires)
CLR_ACCENT_LIGHT = "#FFF3E0" # fond accent léger
CLR_GREEN = "#2E7D32" # succès
CLR_GREEN_LIGHT = "#E8F5E9"
CLR_RED = "#C62828" # erreur / danger
CLR_RED_LIGHT = "#FFEBEE"
CLR_BLUE_LIGHT = "#FCE4EC" # conservé pour compat (remappé vers primary_light)
CLR_CARD_BG = "#FFFFFF"
CLR_CARD_BORDER = "#E0E0E0"
CLR_BG = "#FAFAFA" # fond principal (gris très clair)
CLR_TEXT = "#212121" # quasi-noir (du logo)
CLR_TEXT_SECONDARY = "#757575" # gris moyen
CLR_DIVIDER = "#EEEEEE"
# ---------------------------------------------------------------------------
# Messages worker → UI
@@ -283,10 +325,22 @@ class ToolTip:
class App:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title(APP_TITLE)
# Titre avec version longue pour identifier la build au premier coup d'œil
# (évite les confusions entre exe ancien/nouveau lors des tests).
self.root.title(f"{APP_TITLE}{_version_long()}")
self.root.geometry("780x820")
self.root.minsize(600, 650)
# Icône de la fenêtre (coin haut-gauche + taskbar Windows).
# En mode dev (Linux) tkinter lit iconphoto PNG ; sur Windows, iconbitmap
# accepte .ico. On tente les deux pour couvrir.
self._icon_refs: list = [] # refs pour éviter garbage collection
self._apply_window_icon()
# Préchargement logo pour l'en-tête (besoin de ref persistante sinon
# tkinter nettoie l'image → label blanc).
self._logo_img = self._load_image_safe(_asset('logo_header.png'))
# --- Thème ---
self._apply_theme()
@@ -340,6 +394,8 @@ class App:
# --- Construction UI ---
self._build_ui()
# Afficher l'onglet Anonymisation par défaut
self._switch_tab("anonym")
self._pump_logs()
self._ensure_cfg_exists()
self._load_cfg()
@@ -347,6 +403,63 @@ class App:
# --- Chargement automatique du modèle NER ---
self._auto_load_ner()
# ---------------------------------------------------------------
# Onglets custom
# ---------------------------------------------------------------
def _switch_tab(self, name: str):
"""Affiche l'onglet nommé, met à jour les styles des boutons."""
if name not in self._tab_frames:
return
# Cacher tous les contenus
for frame in self._tab_frames.values():
frame.pack_forget()
# Afficher l'onglet demandé
self._tab_frames[name].pack(fill=tk.BOTH, expand=True)
# Mettre à jour les styles des boutons d'onglets
for tab_name, widgets in self._tab_buttons.items():
if tab_name == name:
widgets["label"].configure(fg=CLR_PRIMARY, bg=CLR_BG)
widgets["underline"].configure(bg=CLR_PRIMARY)
else:
widgets["label"].configure(fg=CLR_TEXT_SECONDARY, bg=CLR_BG)
widgets["underline"].configure(bg=CLR_BG)
self._active_tab = name
# ---------------------------------------------------------------
# Icônes & assets
# ---------------------------------------------------------------
def _apply_window_icon(self):
"""Définit l'icône de la fenêtre. Windows : .ico préférable ; Linux : PNG."""
try:
ico = _asset('icons/app.ico')
if sys.platform == 'win32' and ico.exists():
try:
self.root.iconbitmap(str(ico))
return
except Exception:
pass
# Fallback : iconphoto PNG (toutes plateformes)
png = _asset('icons/icon_128.png')
if png.exists() and _PIL_AVAILABLE:
img = Image.open(png)
photo = ImageTk.PhotoImage(img)
self._icon_refs.append(photo)
self.root.iconphoto(True, photo)
except Exception:
pass # dégradation silencieuse — l'icône n'est pas bloquante
def _load_image_safe(self, path: Path):
"""Charge une image et garde la ref pour éviter le GC. None si PIL absent."""
if not _PIL_AVAILABLE or not path.exists():
return None
try:
img = Image.open(path).convert('RGBA')
photo = ImageTk.PhotoImage(img)
self._icon_refs.append(photo)
return photo
except Exception:
return None
# ---------------------------------------------------------------
# Thème
# ---------------------------------------------------------------
@@ -366,15 +479,89 @@ class App:
# ---------------------------------------------------------------
def _build_ui(self):
self.root.configure(bg=CLR_BG)
pad_x = 32
# Conteneur scrollable
outer = tk.Frame(self.root, bg=CLR_BG)
outer.pack(fill=tk.BOTH, expand=True)
# =============================================================
# HEADER fixe (logo + titre + baseline), hors onglets
# =============================================================
header = tk.Frame(self.root, bg=CLR_BG)
header.pack(fill=tk.X, padx=pad_x, pady=(16, 8))
canvas = tk.Canvas(outer, bg=CLR_BG, highlightthickness=0)
scrollbar = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
if self._logo_img is not None:
tk.Label(header, image=self._logo_img, bg=CLR_BG).pack(anchor="w")
else:
tk.Label(header, text="aivanonym", font=(self._font_family, 22, "bold"),
bg=CLR_BG, fg=CLR_PRIMARY).pack(anchor="w")
tk.Label(
header,
text="Pseudonymisation de documents médicaux — 100% local",
font=(self._font_family, 10),
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X, pady=(4, 0))
# Ligne colorée inspirée du gradient du logo
accent_bar = tk.Frame(self.root, bg=CLR_PRIMARY, height=3)
accent_bar.pack(fill=tk.X)
# =============================================================
# ONGLETS CUSTOM (boutons uniformes — rendu pro)
# Remplace ttk.Notebook dont les onglets ont des tailles/styles
# variables selon l'état actif. Ici : tous les onglets identiques,
# seule une bordure basse magenta signale l'onglet actif.
# =============================================================
tabs_bar = tk.Frame(self.root, bg=CLR_BG)
tabs_bar.pack(fill=tk.X, padx=0, pady=(4, 0))
self._tab_frames: dict = {} # nom → frame outer
self._tab_buttons: dict = {} # nom → dict(container, label, underline)
self._active_tab: Optional[str] = None
def _make_tab_button(parent, name: str, label: str):
"""Crée un onglet cliquable uniforme (fond, texte, underline)."""
container = tk.Frame(parent, bg=CLR_BG, cursor="hand2")
container.pack(side=tk.LEFT)
txt = tk.Label(
container, text=label,
font=(self._font_family, 11, "bold"),
bg=CLR_BG, fg=CLR_TEXT_SECONDARY,
padx=26, pady=10, cursor="hand2",
)
txt.pack(fill=tk.X)
# Bordure basse qui devient magenta quand actif
underline = tk.Frame(container, bg=CLR_BG, height=3)
underline.pack(fill=tk.X)
def _on_click(_e=None):
self._switch_tab(name)
for w in (container, txt, underline):
w.bind("<Button-1>", _on_click)
self._tab_buttons[name] = {
"container": container, "label": txt, "underline": underline,
}
_make_tab_button(tabs_bar, "anonym", "Anonymisation")
_make_tab_button(tabs_bar, "params", "Paramètres")
# Séparateur gris clair sous les onglets
tk.Frame(self.root, bg=CLR_DIVIDER, height=1).pack(fill=tk.X)
# Conteneur des contenus (un seul visible à la fois)
tabs_content = tk.Frame(self.root, bg=CLR_BG)
tabs_content.pack(fill=tk.BOTH, expand=True)
tab_anonym_outer = tk.Frame(tabs_content, bg=CLR_BG)
tab_params_outer = tk.Frame(tabs_content, bg=CLR_BG)
self._tab_frames["anonym"] = tab_anonym_outer
self._tab_frames["params"] = tab_params_outer
# --- Scroll pour l'onglet Anonymisation ---
canvas = tk.Canvas(tab_anonym_outer, bg=CLR_BG, highlightthickness=0)
scrollbar = ttk.Scrollbar(tab_anonym_outer, orient=tk.VERTICAL, command=canvas.yview)
self._scroll_frame = tk.Frame(canvas, bg=CLR_BG)
self._scroll_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
@@ -382,12 +569,10 @@ class App:
canvas_window = canvas.create_window((0, 0), window=self._scroll_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
# Ajuster la largeur du frame interne à celle du canvas
def _on_canvas_configure(event):
canvas.itemconfig(canvas_window, width=event.width)
canvas.bind("<Configure>", _on_canvas_configure)
# Scroll molette
def _on_mousewheel(event):
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def _on_mousewheel_linux(event):
@@ -395,30 +580,32 @@ class App:
canvas.yview_scroll(-3, "units")
elif event.num == 5:
canvas.yview_scroll(3, "units")
canvas.bind_all("<MouseWheel>", _on_mousewheel)
canvas.bind_all("<Button-4>", _on_mousewheel_linux)
canvas.bind_all("<Button-5>", _on_mousewheel_linux)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# --- Scroll pour l'onglet Paramètres ---
canvas2 = tk.Canvas(tab_params_outer, bg=CLR_BG, highlightthickness=0)
scrollbar2 = ttk.Scrollbar(tab_params_outer, orient=tk.VERTICAL, command=canvas2.yview)
self._params_scroll = tk.Frame(canvas2, bg=CLR_BG)
self._params_scroll.bind(
"<Configure>",
lambda e: canvas2.configure(scrollregion=canvas2.bbox("all")),
)
canvas2_window = canvas2.create_window((0, 0), window=self._params_scroll, anchor="nw")
canvas2.configure(yscrollcommand=scrollbar2.set)
def _on_canvas2_configure(event):
canvas2.itemconfig(canvas2_window, width=event.width)
canvas2.bind("<Configure>", _on_canvas2_configure)
canvas2.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar2.pack(side=tk.RIGHT, fill=tk.Y)
# "main" pointe désormais sur le scroll de l'onglet Anonymisation.
# Tout le contenu existant (étape 1, formats, boutons, progress, résultats)
# reste inchangé — seul le parent implicite a changé.
main = self._scroll_frame
pad_x = 32
# --- Titre ---
tk.Label(
main, text=APP_TITLE, font=self._f_title,
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(24, 2))
tk.Label(
main,
text="Masquez automatiquement les données personnelles de vos documents.",
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
# =============================================================
# ÉTAPE 1 — Choix du dossier
@@ -538,70 +725,71 @@ class App:
help_lbl.bind("<Button-1>", lambda e: self._show_help())
# =============================================================
# SECTION PARAMÈTRES (repliable)
# ONGLET "PARAMÈTRES" — contenu monté dans self._params_scroll
# =============================================================
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 = self._params_scroll
self._params_frame = tk.Frame(main, bg=CLR_BG)
# NE PAS pack — déplié à la demande
tk.Label(
self._params_frame,
text="Personnaliser le masquage",
font=(self._font_family, 14, "bold"),
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
).pack(fill=tk.X, padx=pad_x, pady=(20, 4))
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)
tk.Label(
self._params_frame,
text=("Ces listes complètent les détections automatiques du programme. "
"Utile pour gérer les spécificités de votre établissement."),
font=self._f_small,
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w", justify=tk.LEFT, wraplength=700,
).pack(fill=tk.X, padx=pad_x, pady=(0, 16))
# Conteneur interne avec padding latéral pour les listboxes
params_inner = tk.Frame(self._params_frame, bg=CLR_BG)
params_inner.pack(fill=tk.X, padx=pad_x, pady=(0, 12))
# --- Whitelist (phrases à ne pas anonymiser) ---
self._wl_listbox, self._wl_entry = self._build_phrase_list(
self._params_frame,
params_inner,
title="\u2705 Phrases à ne PAS anonymiser :",
placeholder="Ajouter une phrase à protéger...",
color_tag="#e8f5e9",
color_tag=CLR_GREEN_LIGHT,
)
# --- Blacklist (phrases à toujours masquer) ---
self._bl_listbox, self._bl_entry = self._build_phrase_list(
self._params_frame,
params_inner,
title="\u26d4 Mots/phrases à TOUJOURS masquer :",
placeholder="Ajouter un mot ou phrase à masquer...",
color_tag="#fce4ec",
color_tag=CLR_PRIMARY_LIGHT,
)
# --- 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,
params_inner,
title="\u26a0 Mots à ne jamais identifier comme noms (sigles, acronymes...) :",
placeholder="Ajouter un mot (ex: sigle local, acronyme métier)...",
color_tag="#fff8e1",
color_tag=CLR_ACCENT_LIGHT,
)
# Boutons sauvegarder + exporter
btn_row = tk.Frame(self._params_frame, bg=CLR_BG)
btn_row.pack(fill=tk.X, pady=(4, 4))
btn_row = tk.Frame(params_inner, bg=CLR_BG)
btn_row.pack(fill=tk.X, pady=(12, 12))
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,
font=self._f_small, bg=CLR_ACCENT_LIGHT, fg=CLR_TEXT,
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
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,
font=self._f_small, bg=CLR_PRIMARY_LIGHT, fg=CLR_TEXT,
relief=tk.GROOVE, cursor="hand2", padx=10, pady=6,
command=self._import_params,
)
import_btn.pack(side=tk.LEFT, padx=(4, 0))
@@ -609,8 +797,8 @@ class App:
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,
activebackground=CLR_PRIMARY_DARK, activeforeground="white",
relief=tk.FLAT, cursor="hand2", padx=14, pady=6,
command=self._save_params,
)
save_btn.pack(side=tk.RIGHT)
@@ -618,6 +806,7 @@ class App:
# Charger les valeurs initiales depuis la config
self._load_params()
# Retour dans l'onglet Anonymisation
ttk.Separator(main).pack(fill=tk.X, padx=pad_x, pady=(0, 8))
# =============================================================
@@ -726,7 +915,7 @@ class App:
).pack(side=tk.LEFT)
tk.Label(
status_bar, text=APP_VERSION, font=self._f_small,
status_bar, text=_version_long(), font=self._f_small,
bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="e",
).pack(side=tk.RIGHT)

View File

@@ -10,6 +10,10 @@ datas = [
(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'),
# Assets UI : logo (header + splash), icônes fenêtre, splash image.
# Le launcher et la GUI y accèdent via _asset(name) qui résout sous
# sys._MEIPASS/assets en mode frozen.
(os.path.join(app_dir, 'assets'), 'assets'),
]
# Fichiers directs dans data/ — IMPÉRATIF pour fonctionnement correct du core.
# Sans eux : stop-words/villes/DPI labels/companion blacklist sont des sets vides,
@@ -25,7 +29,7 @@ for data_file in [
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']:
'Pseudonymisation_Gui_V5.py', 'build_info.py']:
datas.append((os.path.join(app_dir, pyfile), '.'))
a = Analysis(
@@ -40,17 +44,47 @@ a = Analysis(
'transformers', 'tokenizers', 'torch', 'pdfplumber',
'ahocorasick', 'sklearn', 'scipy', 'pydantic', 'yaml', 'PIL',
'loguru', 'regex',
# optimum : utilisé par ner_manager_onnx.py (fallback NER legacy).
# Sans ça, la GUI affiche "NER indisponible : optimum.onnxruntime introuvable"
# si EDS-Pseudo échoue. Le pipeline principal (CamemBERT-bio ONNX +
# EDS-Pseudo + GLiNER) n'en dépend pas — mais l'absence du hiddenimport
# crée un message d'erreur cosmétique gênant.
'optimum', 'optimum.onnxruntime', 'optimum.pipelines',
'optimum.modeling_base', 'optimum.exporters.onnx',
],
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# Splash natif PyInstaller : image affichée AU LANCEMENT DE L'EXE,
# avant même que Python démarre. Couvre les ~15-30 s de décompression
# du bundle --onefile dans %TEMP% qui laissaient l'écran vide auparavant.
# Le launcher ferme le splash via pyi_splash.close() une fois la GUI prête.
splash = Splash(
os.path.join(app_dir, 'assets', 'splash.png'),
binaries=a.binaries,
datas=a.datas,
# Texte dynamique PyInstaller positionné dans la zone libre du PNG
# (y=170-235). text_pos correspond au coin haut-gauche du texte.
text_pos=(60, 195),
text_size=10,
text_color='white',
minify_script=True,
always_on_top=False,
)
exe = EXE(
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
pyz, a.scripts,
splash, # image affichée immédiatement
splash.binaries, # bootloader splash
a.binaries, a.zipfiles, a.datas, [],
name='Anonymisation',
debug=False,
strip=False,
upx=False,
console=False,
icon=None,
# Icône du fichier .exe visible dans l'Explorateur Windows et la taskbar
# (dérivée du logo aivanonym, multi-résolution 16→256 dans le .ico).
icon=os.path.join(app_dir, 'assets', 'icons', 'app.ico'),
)

View File

@@ -1903,9 +1903,13 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit], set,
force_names: set = set() # noms issus de contextes structurés (DR., Signé, etc.) → bypass stop words
def _add_candidate(token: str, source: str, strength: str, bypass: bool):
"""Ajoute un NameCandidate à la liste."""
"""Ajoute un NameCandidate à la liste.
Accepte les prénoms courts 3 chars (Ute, Eva, Léo…) si bypass=True
(contexte Dr/Mme fort) ET majuscule initiale + alpha pur."""
token = token.strip(" .-'(),")
if len(token) < 4:
if len(token) < 3:
return
if len(token) == 3 and not (bypass and token[0].isupper() and token.isalpha()):
return
candidates.append(NameCandidate(
token=token, source=source,
@@ -2220,9 +2224,13 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
candidates: List[NameCandidate] = []
def _add_candidate(token: str, source: str, strength: str, bypass: bool):
"""Ajoute un NameCandidate à la liste (dédupliqué par token+source)."""
"""Ajoute un NameCandidate à la liste (dédupliqué par token+source).
Accepte les prénoms courts 3 chars (Ute, Eva, Léo…) si bypass=True
(contexte Dr/Mme fort) ET majuscule initiale + alpha pur."""
token = token.strip(" .-'")
if len(token) < 4:
if len(token) < 3:
return
if len(token) == 3 and not (bypass and token[0].isupper() and token.isalpha()):
return
candidates.append(NameCandidate(
token=token, source=source,
@@ -2270,12 +2278,21 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
Après Dr/Mme, tous les tokens sont des noms — même s'ils sont
homonymes de termes médicaux (ex: Dr Laurence MASSE).
Accepte les prénoms courts 3 chars (Dr Ute, Dr Eva, Dr Léo) : le
contexte Dr/Mme est suffisamment fort pour lever le filtre de
longueur, à condition que le token soit alpha et commence par
une majuscule. 2 chars reste filtré (trop ambigu : initiales).
"""
_add_compound(match_str)
tokens = match_str.split()
for token in tokens:
token = token.strip(" .-'")
if len(token) < 4:
if len(token) < 3:
continue
# 3 chars : accepter uniquement si majuscule initiale + alpha
# (évite "Les", "Des" mais accepte "Ute", "Eva").
if len(token) == 3 and not (token[0].isupper() and token.isalpha()):
continue
if token.upper() in wl_sections or token in wl_phrases:
continue
@@ -3929,15 +3946,42 @@ def _search_ocr_words(ocr_words: List[Tuple[str, float, float, float, float]], t
def _search_whole_word(page, token: str) -> list:
"""Cherche un token comme mot entier (pas substring) via get_text('words').
Évite les faux positifs de page.search_for() qui fait du substring matching.
Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF."""
Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF.
Gère aussi les tokens collés à de la ponctuation sans espace : "Douar,nécessitant"
(passe 2 avec regex word-boundary + bbox proportionnelle)."""
rects = []
token_lower = token.lower().strip()
words = page.get_text("words")
# Passe 1 : comparaison stricte après strip des ponctuations frontières.
# Couvre la majorité des cas normaux (mots bien séparés).
for w in words:
# w = (x0, y0, x1, y1, word, block_no, line_no, word_no)
word_text = w[4].strip(".,;:!?()[]{}\"'«»-–—/\\")
if word_text.lower() == token_lower:
rects.append(fitz.Rect(w[0], w[1], w[2], w[3]))
# Passe 2 : token collé à d'autres mots via ponctuation interne.
# Ex: "Douar,nécessitant" où "Douar" doit être masqué. Le strip ci-dessus
# ne marche pas (la virgule est au milieu). Utiliser regex word-boundary
# sur le texte complet du word, calculer bbox proportionnelle.
if not rects and len(token_lower) >= 3:
pattern = re.compile(
r"(?<![A-Za-zÀ-ÿ])" + re.escape(token_lower) + r"(?![A-Za-zÀ-ÿ])",
re.IGNORECASE,
)
for w in words:
word_text = w[4]
if len(word_text) < len(token_lower):
continue
for m in pattern.finditer(word_text):
# Bbox proportionnelle : approximation pour polices proportionnelles,
# exacte pour chasses fixes. Marge de quelques pixels couverte par
# le rectangle de redaction.
wlen = len(word_text)
start_ratio = m.start() / wlen
end_ratio = m.end() / wlen
x0 = w[0] + (w[2] - w[0]) * start_ratio
x1 = w[0] + (w[2] - w[0]) * end_ratio
rects.append(fitz.Rect(x0, w[1], x1, w[3]))
# Fallback pour noms composés avec tiret (JEAN-PIERRE) splittés par le PDF
if not rects and "-" in token:
parts = [p for p in token.split("-") if p]

BIN
assets/icons/app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

BIN
assets/icons/icon_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
assets/icons/icon_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

BIN
assets/icons/icon_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
assets/icons/icon_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
assets/icons/icon_48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
assets/icons/icon_512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icons/icon_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/logo_header.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
assets/logo_splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -9,6 +9,35 @@ from pathlib import Path
import threading
import logging
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
# pendant la décompression --onefile (~15-30 s sur Windows). En mode dev
# (pas frozen), le module n'existe pas → fallback silencieux.
try:
import pyi_splash # type: ignore
_HAS_PYI_SPLASH = True
except Exception:
pyi_splash = None
_HAS_PYI_SPLASH = False
def _splash_update(text: str) -> None:
"""Met à jour le texte affiché sous le splash natif PyInstaller (si actif)."""
if _HAS_PYI_SPLASH:
try:
pyi_splash.update_text(text)
except Exception:
pass
def _splash_close() -> None:
"""Ferme le splash natif PyInstaller (si actif)."""
if _HAS_PYI_SPLASH:
try:
pyi_splash.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# Single-instance guard (lock file in user's temp directory)
# ---------------------------------------------------------------------------
@@ -76,62 +105,92 @@ def check_models_ready():
def launch_gui():
"""Launch the main GUI with a splash screen during the slow core import.
"""Launch the main GUI — étapes de chargement affichées DANS le splash natif.
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.
Le splash natif PyInstaller (image avec logo + texte dynamique) reste
visible pendant TOUTE la phase de chargement. On intercepte les log.info()
du core via un logging.Handler et on pousse chaque étape traduite dans
le splash natif via pyi_splash.update_text(). L'utilisateur voit défiler
sous le logo :
"Chargement des prénoms français (INSEE)…"
"Chargement des noms de famille (INSEE)…"
"Chargement des numéros FINESS…"
Puis le splash se ferme et la GUI s'ouvre — pas de fenêtre intermédiaire.
En mode dev (pas frozen), pyi_splash n'existe pas ; on ajoute un
mini-splash tkinter temporaire pour voir le même rendu pendant le test.
"""
log.info("Launching GUI...")
splash = tk.Tk()
splash.title("Anonymisation")
splash.geometry("440x200")
splash.resizable(False, False)
# Traductions log.info() → libellés "prod" lisibles pour l'utilisateur.
_LOG_TRANSLATIONS = [
("Gazetteers INSEE prénoms", "Chargement des prénoms français (INSEE)…"),
("Gazetteers INSEE communes", "Chargement des communes françaises (INSEE)…"),
("Gazetteers INSEE noms de famille", "Chargement des noms de famille (INSEE)…"),
("Villes blacklist", "Chargement de la blacklist des villes…"),
("Gazetteer FINESS numéros", "Chargement des numéros FINESS…"),
("Gazetteer FINESS villes", "Chargement des villes FINESS…"),
("Gazetteer FINESS téléphones", "Chargement des téléphones FINESS…"),
("Gazetteer FINESS Aho-Corasick", "Indexation des établissements de santé…"),
("Gazetteer FINESS adresses", "Chargement des adresses FINESS…"),
("Gazetteer VILLE Aho-Corasick", "Indexation des villes…"),
("Whitelist termes médicaux", "Chargement du lexique médical…"),
("Whitelist médicaments", "Chargement de la base médicamenteuse (BDPM)…"),
("Stop-words manuels", "Chargement des stop-words…"),
("BDPM stop-words", "Chargement des médicaments BDPM…"),
("DPI labels blacklist", "Chargement des libellés DPI…"),
("Companion blacklist", "Chargement du vocabulaire clinique…"),
("Whitelist phrases", "Chargement des phrases protégées…"),
("FINESS mono-mots", "Chargement des sigles d'établissement…"),
("Core imported OK", "Moteur d'anonymisation prêt…"),
("GUI module imported OK", "Interface prête — finalisation…"),
]
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))
def _translate(msg: str) -> str:
for key, human in _LOG_TRANSLATIONS:
if key in msg:
return human
return msg
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():
# Handler logs → splash natif. Installé sur le root logger pour capturer
# tous les log.info() des modules chargés pendant l'import.
class _SplashHandler(logging.Handler):
def emit(self, record):
try:
_splash_update(_translate(record.getMessage()))
except Exception:
pass
_handler = _SplashHandler()
_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(_handler)
# Afficher tout de suite un message initial sous le logo
_splash_update("Démarrage…")
# Import du core et de la GUI (synchrone : pas besoin de thread puisque
# le splash natif tourne dans son propre processus bootloader).
result = {"error": None}
try:
_splash_update("Chargement des dictionnaires médicaux…")
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()
# Retirer le handler — la GUI principale utilise ses propres logs
try:
splash.destroy()
logging.getLogger().removeHandler(_handler)
except Exception:
pass
# Fermer le splash natif maintenant que tout est prêt
_splash_close()
if result["error"]:
try:
messagebox.showerror(
@@ -142,7 +201,8 @@ def launch_gui():
except Exception:
pass
return
# Lancement de la GUI principale
# Lancer la fenêtre principale
try:
import Pseudonymisation_Gui_V5
log.info("Starting mainloop…")
@@ -158,11 +218,6 @@ def launch_gui():
)
except Exception:
pass
else:
splash.after(200, _poll)
splash.after(200, _poll)
splash.mainloop()
class SetupWindow:
@@ -358,8 +413,11 @@ def main():
try:
if check_models_ready():
_splash_update("Modèles déjà installés — chargement…")
launch_gui()
else:
_splash_update("Premier lancement — configuration initiale")
_splash_close() # laisse place à la SetupWindow qui a sa propre UI
setup = SetupWindow()
setup.run()
except Exception as e:

128
scripts/rebuild_anon.ps1 Normal file
View File

@@ -0,0 +1,128 @@
# Rebuild script - anonymisation Windows
$ErrorActionPreference = "Continue"
$project = "C:\Users\dom\ai\anonymisation"
$gitea = "http://192.168.1.40:3100/Dom/anonymisation.git"
Write-Host "=== STEP 0 : Désactiver le .git parasite dans C:\Users\dom\ ==="
if (Test-Path "C:\Users\dom\.git") {
if (-not (Test-Path "C:\Users\dom\.git_parasite_disabled")) {
Rename-Item "C:\Users\dom\.git" "C:\Users\dom\.git_parasite_disabled" -ErrorAction SilentlyContinue
Write-Host " .git parasite renommé en .git_parasite_disabled"
} else {
Write-Host " .git_parasite_disabled existe déjà"
}
}
Write-Host "=== STEP 1 : Init/sync repo dans $project ==="
Set-Location $project
if (-not (Test-Path "$project\.git")) {
Write-Host " git init"
git init 2>&1 | Out-Host
Write-Host " git remote add gitea $gitea"
git remote add gitea $gitea 2>&1 | Out-Host
} else {
Write-Host " .git existe déjà"
$existing = git remote 2>&1 | Out-String
if ($existing -notmatch "gitea") {
git remote add gitea $gitea 2>&1 | Out-Host
}
}
Write-Host "=== STEP 2 : Fetch + reset main ==="
git fetch gitea 2>&1 | Out-Host
git checkout -B main 2>&1 | Out-Host
git reset --hard gitea/main 2>&1 | Out-Host
Write-Host "=== STEP 3 : Vérification du dernier commit ==="
git log --oneline -3 2>&1 | Out-Host
# Choix du venv
$venvPython = if (Test-Path "$project\venv\Scripts\python.exe") {
"$project\venv\Scripts\python.exe"
} elseif (Test-Path "$project\.venv\Scripts\python.exe") {
"$project\.venv\Scripts\python.exe"
} else {
Write-Host "ERREUR : aucun venv trouvé (ni venv\, ni .venv\)"
exit 1
}
Write-Host " Venv détecté : $venvPython"
Write-Host "=== STEP 4 : Régénérer finess_numbers.txt (entjur fix) ==="
& $venvPython "$project\scripts\build_finess_gazetteers.py" 2>&1 | Out-Host
Write-Host "=== STEP 4a : Générer build_info.py (identifiant de build) ==="
$commit = (git rev-parse --short HEAD 2>&1).Trim()
$branch = (git rev-parse --abbrev-ref HEAD 2>&1).Trim()
$buildDate = Get-Date -Format "yyyy-MM-dd HH:mm"
$buildInfo = @"
"""Métadonnées de build - GENERE AUTOMATIQUEMENT par rebuild_anon.ps1.
Ne PAS editer a la main - ecrase a chaque rebuild."""
BUILD_DATE = "$buildDate"
BUILD_COMMIT = "$commit"
BUILD_BRANCH = "$branch"
"@
$buildInfo | Set-Content "$project\build_info.py" -Encoding UTF8
Write-Host " build_info.py: $buildDate / $commit / $branch"
Write-Host "=== STEP 4bis : Désactiver scan Defender sur dist/ (évite locks PyInstaller) ==="
try {
Add-MpPreference -ExclusionPath "$project\dist" -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath "$project\build" -ErrorAction SilentlyContinue
Write-Host " Exclusions Defender ajoutées (dist, build)"
} catch {
Write-Host " Impossible d'ajouter exclusion Defender : $_"
}
Write-Host "=== STEP 5 : Cleanup build/ et dist/ (contournement lock) ==="
# Stratégie anti-lock : RENOMMER l'ancien EXE au lieu de le supprimer.
# PyInstaller essaie de `os.remove` l'EXE existant avant d'en créer un nouveau.
# Si Defender/antivirus tient un handle sur le fichier, le remove échoue avec
# PermissionError WinError 5. En renommant AVANT (Move-Item bypass le lock de
# la plupart des scanners), PyInstaller voit dist\Anonymisation.exe absent et
# peut créer le nouveau fichier sans conflit.
$exePath = "$project\dist\Anonymisation.exe"
$timestampAvant = if (Test-Path $exePath) { (Get-Item $exePath).LastWriteTime } else { [DateTime]::MinValue }
# Renommer l'ancien EXE avec un suffixe timestamp (évite conflit si multiples retries)
if (Test-Path $exePath) {
$backupName = "Anonymisation.old-$(Get-Date -Format 'HHmmss').exe"
try {
Move-Item $exePath "$project\dist\$backupName" -Force -ErrorAction Stop
Write-Host " Ancien EXE renomme -> $backupName"
} catch {
Write-Host " AVERTISSEMENT : impossible de renommer l'ancien EXE : $_"
# Fallback : essayer de le supprimer quand meme (peut marcher si le lock se libere)
Remove-Item $exePath -Force -ErrorAction SilentlyContinue
}
}
# Nettoyer le dossier build/ (peut toujours etre verrouille mais moins critique)
if (Test-Path "$project\build") {
Remove-Item -Recurse -Force "$project\build" -ErrorAction SilentlyContinue
}
Write-Host "=== STEP 6 : PyInstaller (5-15 min) ==="
$pyinstaller = if (Test-Path "$project\venv\Scripts\pyinstaller.exe") {
"$project\venv\Scripts\pyinstaller.exe"
} else {
"$project\.venv\Scripts\pyinstaller.exe"
}
& $pyinstaller --clean anonymisation_onefile.spec 2>&1 | Out-Host
Write-Host "=== STEP 7 : Vérification (timestamp AVANT/APRÈS) ==="
$exe = "$project\dist\Anonymisation.exe"
if (-not (Test-Path $exe)) {
Write-Host "ÉCHEC CRITIQUE : aucun exe produit"
exit 1
}
$timestampApres = (Get-Item $exe).LastWriteTime
if ($timestampApres -le $timestampAvant) {
$size = [math]::Round((Get-Item $exe).Length / 1MB, 1)
Write-Host "ÉCHEC CRITIQUE : l'exe n'a pas été mis à jour (timestamp inchangé)"
Write-Host " Avant : $timestampAvant"
Write-Host " Après : $timestampApres"
Write-Host " Taille : $size MB (mais c'est probablement l'ancien exe)"
exit 2
}
$size = [math]::Round((Get-Item $exe).Length / 1MB, 1)
Write-Host "OK : $exe ($size MB) - LastWriteTime : $timestampApres"