Compare commits
36 Commits
a827d860f1
...
ea214db170
| Author | SHA1 | Date | |
|---|---|---|---|
| ea214db170 | |||
| aa3db69a9b | |||
| 83769f6e63 | |||
| e6f3853426 | |||
| fd95ae5f2a | |||
| 8e458c16ca | |||
| 4b5925306e | |||
| 59acf390f4 | |||
| b5058b9c4b | |||
| b23355ed23 | |||
| 51c75558bc | |||
| 2f19f7c470 | |||
| c157205751 | |||
| 4d33610655 | |||
| 2a4b9d79a1 | |||
| fb7896f88d | |||
| 22fbf1c772 | |||
| 23e19e17e4 | |||
| 219ac18854 | |||
| ac5c35ae2d | |||
| b2ee6ad835 | |||
| 898ad9d82d | |||
| 106f1fcd2e | |||
| f9fbae1f27 | |||
| dcccd60c39 | |||
| 63a4a013a2 | |||
| 437877e1c8 | |||
| 3992b43925 | |||
| d1bdfb1aca | |||
| 65a02952c5 | |||
| ad7f1ffa8a | |||
| 2731bc1ce7 | |||
| 7c05ff9aaf | |||
| 27d19ebed7 | |||
| d957e72aff | |||
| 49ff464e6e |
@@ -22,6 +22,7 @@ import queue
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -85,14 +86,43 @@ except ImportError:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constantes
|
# Constantes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
APP_TITLE = "Pseudonymisation de PDF"
|
APP_TITLE = "Pseudonymisation de vos documents"
|
||||||
APP_VERSION = "v5.0"
|
APP_VERSION = "v5.4"
|
||||||
|
|
||||||
def _app_dir() -> Path:
|
def _app_dir() -> Path:
|
||||||
"""Répertoire racine de l'application (compatible Nuitka standalone)."""
|
"""Répertoire racine de l'application (compatible PyInstaller/Nuitka)."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
return Path(sys._MEIPASS)
|
||||||
return Path(__file__).resolve().parent
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
DEFAULT_CFG = _app_dir() / "config" / "dictionnaires.yml"
|
def _exe_dir() -> Path:
|
||||||
|
"""Répertoire de l'exécutable (pour les fichiers persistants : config, logs)."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
return Path(sys.executable).parent
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
def _resolve_config() -> Path:
|
||||||
|
"""Cherche la config en priorité à côté de l'exe, sinon dans l'app embarquée.
|
||||||
|
|
||||||
|
Si le fichier n'existe pas à côté de l'exe, copie la version embarquée
|
||||||
|
pour que l'utilisateur puisse la modifier sans recompiler.
|
||||||
|
"""
|
||||||
|
exe_cfg = _exe_dir() / "config" / "dictionnaires.yml"
|
||||||
|
app_cfg = _app_dir() / "config" / "dictionnaires.yml"
|
||||||
|
|
||||||
|
if exe_cfg.exists():
|
||||||
|
return exe_cfg
|
||||||
|
|
||||||
|
# Premier lancement : copier la config embarquée à côté de l'exe
|
||||||
|
if app_cfg.exists():
|
||||||
|
exe_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(str(app_cfg), str(exe_cfg))
|
||||||
|
return exe_cfg
|
||||||
|
|
||||||
|
return app_cfg # fallback
|
||||||
|
|
||||||
|
DEFAULT_CFG = _resolve_config()
|
||||||
MODELS_DIR = _app_dir() / "models"
|
MODELS_DIR = _app_dir() / "models"
|
||||||
|
|
||||||
DEFAULTS_CFG_TEXT = r"""
|
DEFAULTS_CFG_TEXT = r"""
|
||||||
@@ -305,6 +335,8 @@ class App:
|
|||||||
|
|
||||||
# --- Contrôle d'arrêt ---
|
# --- Contrôle d'arrêt ---
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
|
# --- Fichier unique (None = mode dossier) ---
|
||||||
|
self._single_file: Optional[Path] = None
|
||||||
|
|
||||||
# --- Construction UI ---
|
# --- Construction UI ---
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@@ -382,7 +414,7 @@ class App:
|
|||||||
|
|
||||||
tk.Label(
|
tk.Label(
|
||||||
main,
|
main,
|
||||||
text="Masquez automatiquement les données personnelles de vos documents PDF.",
|
text="Masquez automatiquement les données personnelles de vos documents.",
|
||||||
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
font=self._f_body, bg=CLR_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
||||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
|
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
|
||||||
|
|
||||||
@@ -392,7 +424,7 @@ class App:
|
|||||||
# ÉTAPE 1 — Choix du dossier
|
# ÉTAPE 1 — Choix du dossier
|
||||||
# =============================================================
|
# =============================================================
|
||||||
tk.Label(
|
tk.Label(
|
||||||
main, text="1. Choisir les documents", font=self._f_body_bold,
|
main, text="1. Choisir les documents ou fichiers (PDF, Word, Images, Texte)", font=self._f_body_bold,
|
||||||
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
|
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
|
||||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 6))
|
).pack(fill=tk.X, padx=pad_x, pady=(0, 6))
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ class App:
|
|||||||
|
|
||||||
self._folder_text_lbl = tk.Label(
|
self._folder_text_lbl = tk.Label(
|
||||||
self._folder_inner,
|
self._folder_inner,
|
||||||
text="Cliquez pour choisir un dossier (tous les PDF seront recherchés récursivement)",
|
text="Cliquez pour choisir un dossier ou un fichier",
|
||||||
font=self._f_body, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY,
|
font=self._f_body, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY,
|
||||||
)
|
)
|
||||||
self._folder_text_lbl.pack(pady=(4, 0))
|
self._folder_text_lbl.pack(pady=(4, 0))
|
||||||
@@ -448,7 +480,7 @@ class App:
|
|||||||
|
|
||||||
tk.Label(
|
tk.Label(
|
||||||
info_inner,
|
info_inner,
|
||||||
text=("\u2022 Recherche récursive de tous les PDF dans les sous-dossiers\n"
|
text=("\u2022 Recherche récursive de tous les documents dans les sous-dossiers\n"
|
||||||
"\u2022 Sortie PDF Image (raster) — sécurité maximale, aucun texte résiduel\n"
|
"\u2022 Sortie PDF Image (raster) — sécurité maximale, aucun texte résiduel\n"
|
||||||
"\u2022 Résultats dans le dossier « anonymise/ » à la racine"),
|
"\u2022 Résultats dans le dossier « anonymise/ » à la racine"),
|
||||||
font=self._f_card_desc, bg=CLR_BLUE_LIGHT, fg=CLR_TEXT_SECONDARY,
|
font=self._f_card_desc, bg=CLR_BLUE_LIGHT, fg=CLR_TEXT_SECONDARY,
|
||||||
@@ -480,7 +512,7 @@ class App:
|
|||||||
buttons_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 4))
|
buttons_frame.pack(fill=tk.X, padx=pad_x, pady=(0, 4))
|
||||||
|
|
||||||
self.btn_run = tk.Button(
|
self.btn_run = tk.Button(
|
||||||
buttons_frame, text="Lancer la pseudonymisation",
|
buttons_frame, text="Lancer l'anonymisation",
|
||||||
font=self._f_button, bg=CLR_PRIMARY, fg="white",
|
font=self._f_button, bg=CLR_PRIMARY, fg="white",
|
||||||
activebackground="#1d4ed8", activeforeground="white",
|
activebackground="#1d4ed8", activeforeground="white",
|
||||||
relief=tk.FLAT, cursor="hand2", pady=10,
|
relief=tk.FLAT, cursor="hand2", pady=10,
|
||||||
@@ -502,9 +534,92 @@ class App:
|
|||||||
main, text="Comment ça marche ?", font=self._f_small,
|
main, text="Comment ça marche ?", font=self._f_small,
|
||||||
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
|
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
|
||||||
)
|
)
|
||||||
help_lbl.pack(pady=(0, 18))
|
help_lbl.pack(pady=(0, 8))
|
||||||
help_lbl.bind("<Button-1>", lambda e: self._show_help())
|
help_lbl.bind("<Button-1>", 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("<Button-1>", _toggle_params)
|
||||||
|
|
||||||
|
# --- Whitelist (phrases à ne pas anonymiser) ---
|
||||||
|
self._wl_listbox, self._wl_entry = self._build_phrase_list(
|
||||||
|
self._params_frame,
|
||||||
|
title="\u2705 Phrases à ne PAS anonymiser :",
|
||||||
|
placeholder="Ajouter une phrase à protéger...",
|
||||||
|
color_tag="#e8f5e9",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Blacklist (phrases à toujours masquer) ---
|
||||||
|
self._bl_listbox, self._bl_entry = self._build_phrase_list(
|
||||||
|
self._params_frame,
|
||||||
|
title="\u26d4 Mots/phrases à TOUJOURS masquer :",
|
||||||
|
placeholder="Ajouter un mot ou phrase à masquer...",
|
||||||
|
color_tag="#fce4ec",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 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,
|
||||||
|
title="\u26a0 Mots à ne jamais identifier comme noms (sigles, acronymes...) :",
|
||||||
|
placeholder="Ajouter un mot (ex: sigle local, acronyme métier)...",
|
||||||
|
color_tag="#fff8e1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Boutons sauvegarder + exporter
|
||||||
|
btn_row = tk.Frame(self._params_frame, bg=CLR_BG)
|
||||||
|
btn_row.pack(fill=tk.X, pady=(4, 4))
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
command=self._import_params,
|
||||||
|
)
|
||||||
|
import_btn.pack(side=tk.LEFT, padx=(4, 0))
|
||||||
|
|
||||||
|
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,
|
||||||
|
command=self._save_params,
|
||||||
|
)
|
||||||
|
save_btn.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
# 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)
|
# BARRE DE PROGRESSION (masquée)
|
||||||
# =============================================================
|
# =============================================================
|
||||||
@@ -648,22 +763,71 @@ class App:
|
|||||||
# Actions dossier
|
# Actions dossier
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
def _browse(self):
|
def _browse(self):
|
||||||
|
"""Propose le choix entre dossier et fichier unique via un menu contextuel."""
|
||||||
|
menu = tk.Menu(self.root, tearoff=0)
|
||||||
|
menu.add_command(label="Choisir un dossier", command=self._browse_folder)
|
||||||
|
menu.add_command(label="Choisir un fichier", command=self._browse_file)
|
||||||
|
# Afficher le menu sous le curseur
|
||||||
|
try:
|
||||||
|
menu.tk_popup(self.root.winfo_pointerx(), self.root.winfo_pointery())
|
||||||
|
finally:
|
||||||
|
menu.grab_release()
|
||||||
|
|
||||||
|
def _browse_folder(self):
|
||||||
d = filedialog.askdirectory()
|
d = filedialog.askdirectory()
|
||||||
if d:
|
if d:
|
||||||
|
self._single_file = None
|
||||||
self.dir_var.set(d)
|
self.dir_var.set(d)
|
||||||
self._update_folder_display()
|
self._update_folder_display()
|
||||||
|
|
||||||
|
def _browse_file(self):
|
||||||
|
try:
|
||||||
|
from format_converter import SUPPORTED_EXTENSIONS
|
||||||
|
except ImportError:
|
||||||
|
SUPPORTED_EXTENSIONS = {".pdf"}
|
||||||
|
# Construire les filtres pour le dialogue
|
||||||
|
ext_list = " ".join(f"*{e}" for e in sorted(SUPPORTED_EXTENSIONS))
|
||||||
|
f = filedialog.askopenfilename(
|
||||||
|
title="Choisir un document à anonymiser",
|
||||||
|
filetypes=[
|
||||||
|
("Documents supportés", ext_list),
|
||||||
|
("PDF", "*.pdf"),
|
||||||
|
("Word", "*.docx"),
|
||||||
|
("Images", "*.jpg *.jpeg *.png *.tiff *.tif *.bmp"),
|
||||||
|
("Texte", "*.txt *.rtf *.odt *.html *.htm"),
|
||||||
|
("Tous", "*.*"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if f:
|
||||||
|
self._single_file = Path(f)
|
||||||
|
self.dir_var.set(str(self._single_file.parent))
|
||||||
|
self._update_folder_display()
|
||||||
|
|
||||||
def _update_folder_display(self):
|
def _update_folder_display(self):
|
||||||
folder = self.dir_var.get()
|
folder = self.dir_var.get()
|
||||||
if not folder:
|
if not folder:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Compter les PDF (récursif)
|
is_single = getattr(self, '_single_file', None) is not None
|
||||||
pdf_count = 0
|
|
||||||
|
if is_single:
|
||||||
|
doc_count = 1
|
||||||
|
display_label = self._single_file.name
|
||||||
|
else:
|
||||||
|
# Compter les documents supportés (récursif)
|
||||||
try:
|
try:
|
||||||
pdf_count = len([p for p in Path(folder).rglob("*.pdf") if p.is_file()])
|
from format_converter import SUPPORTED_EXTENSIONS
|
||||||
|
except ImportError:
|
||||||
|
SUPPORTED_EXTENSIONS = {".pdf"}
|
||||||
|
doc_count = 0
|
||||||
|
try:
|
||||||
|
doc_count = len([
|
||||||
|
p for p in Path(folder).rglob("*")
|
||||||
|
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
display_label = folder
|
||||||
|
|
||||||
# Vider et reconstruire l'intérieur
|
# Vider et reconstruire l'intérieur
|
||||||
for w in self._folder_inner.winfo_children():
|
for w in self._folder_inner.winfo_children():
|
||||||
@@ -672,8 +836,9 @@ class App:
|
|||||||
row = tk.Frame(self._folder_inner, bg=CLR_CARD_BG)
|
row = tk.Frame(self._folder_inner, bg=CLR_CARD_BG)
|
||||||
row.pack(fill=tk.X)
|
row.pack(fill=tk.X)
|
||||||
|
|
||||||
|
icon = "\U0001f4c4" if is_single else "\U0001f4c2" # 📄 ou 📂
|
||||||
tk.Label(
|
tk.Label(
|
||||||
row, text="\U0001f4c2", font=(self._font_family, 16),
|
row, text=icon, font=(self._font_family, 16),
|
||||||
bg=CLR_CARD_BG,
|
bg=CLR_CARD_BG,
|
||||||
).pack(side=tk.LEFT, padx=(0, 8))
|
).pack(side=tk.LEFT, padx=(0, 8))
|
||||||
|
|
||||||
@@ -681,7 +846,7 @@ class App:
|
|||||||
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||||
|
|
||||||
# Chemin (tronqué si trop long)
|
# Chemin (tronqué si trop long)
|
||||||
display_path = folder
|
display_path = display_label
|
||||||
if len(display_path) > 60:
|
if len(display_path) > 60:
|
||||||
display_path = "..." + display_path[-57:]
|
display_path = "..." + display_path[-57:]
|
||||||
tk.Label(
|
tk.Label(
|
||||||
@@ -689,9 +854,13 @@ class App:
|
|||||||
bg=CLR_CARD_BG, fg=CLR_TEXT, anchor="w",
|
bg=CLR_CARD_BG, fg=CLR_TEXT, anchor="w",
|
||||||
).pack(fill=tk.X)
|
).pack(fill=tk.X)
|
||||||
|
|
||||||
suffix = "PDF trouvé (récursif)" if pdf_count <= 1 else "PDF trouvés (récursif)"
|
if is_single:
|
||||||
|
subtitle = f"Fichier unique — {self._single_file.suffix.upper().lstrip('.')}"
|
||||||
|
else:
|
||||||
|
suffix = "document trouvé (récursif)" if doc_count <= 1 else "documents trouvés (récursif)"
|
||||||
|
subtitle = f"{doc_count} {suffix}"
|
||||||
tk.Label(
|
tk.Label(
|
||||||
info_frame, text=f"{pdf_count} {suffix}",
|
info_frame, text=subtitle,
|
||||||
font=self._f_small, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
font=self._f_small, bg=CLR_CARD_BG, fg=CLR_TEXT_SECONDARY, anchor="w",
|
||||||
).pack(fill=tk.X)
|
).pack(fill=tk.X)
|
||||||
|
|
||||||
@@ -709,19 +878,39 @@ class App:
|
|||||||
# Lancement
|
# Lancement
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
def _run(self):
|
def _run(self):
|
||||||
|
is_single = getattr(self, '_single_file', None) is not None
|
||||||
|
|
||||||
|
if is_single:
|
||||||
|
# Mode fichier unique
|
||||||
|
if not self._single_file.is_file():
|
||||||
|
messagebox.showwarning("Fichier introuvable", f"{self._single_file}")
|
||||||
|
return
|
||||||
|
folder = self._single_file.parent
|
||||||
|
pdfs = [self._single_file]
|
||||||
|
else:
|
||||||
|
# Mode dossier
|
||||||
folder = Path(self.dir_var.get().strip())
|
folder = Path(self.dir_var.get().strip())
|
||||||
if not folder.is_dir():
|
if not folder.is_dir():
|
||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Dossier invalide",
|
"Dossier invalide",
|
||||||
"Choisissez un dossier contenant des PDF.",
|
"Choisissez un dossier ou un fichier.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
pdfs = sorted([p for p in folder.rglob("*.pdf") if p.is_file()])
|
from format_converter import SUPPORTED_EXTENSIONS
|
||||||
|
except ImportError:
|
||||||
|
SUPPORTED_EXTENSIONS = {".pdf"}
|
||||||
|
pdfs = sorted([
|
||||||
|
p for p in folder.rglob("*")
|
||||||
|
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
])
|
||||||
if not pdfs:
|
if not pdfs:
|
||||||
|
exts = ", ".join(sorted(SUPPORTED_EXTENSIONS))
|
||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Aucun PDF",
|
"Aucun document",
|
||||||
"Aucun fichier PDF trouvé\n(recherche récursive dans les sous-dossiers).",
|
f"Aucun fichier supporté trouvé.\n"
|
||||||
|
f"Formats acceptés : {exts}\n"
|
||||||
|
f"(recherche récursive dans les sous-dossiers)",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -779,8 +968,12 @@ class App:
|
|||||||
and self._vlm_manager.is_loaded()
|
and self._vlm_manager.is_loaded()
|
||||||
)
|
)
|
||||||
|
|
||||||
outputs = core.process_pdf(
|
# Utiliser process_document (multi-formats) si disponible,
|
||||||
pdf_path=pdf,
|
# sinon fallback sur process_pdf (PDF uniquement)
|
||||||
|
_process_fn = getattr(core, 'process_document', None) or core.process_pdf
|
||||||
|
_path_key = "doc_path" if _process_fn.__name__ == "process_document" else "pdf_path"
|
||||||
|
outputs = _process_fn(
|
||||||
|
**{_path_key: pdf},
|
||||||
out_dir=outdir,
|
out_dir=outdir,
|
||||||
make_vector_redaction=False,
|
make_vector_redaction=False,
|
||||||
also_make_raster_burn=True,
|
also_make_raster_burn=True,
|
||||||
@@ -962,6 +1155,248 @@ class App:
|
|||||||
"« anonymise/ » à la racine du dossier sélectionné.",
|
"« anonymise/ » à la racine du dossier sélectionné.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Paramètres avancés (whitelist/blacklist)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _build_phrase_list(self, parent, title: str, placeholder: str, color_tag: str):
|
||||||
|
"""Construit un widget liste + ajout/suppression pour les phrases."""
|
||||||
|
frame = tk.Frame(parent, bg=CLR_BG)
|
||||||
|
frame.pack(fill=tk.X, pady=(4, 8))
|
||||||
|
|
||||||
|
tk.Label(
|
||||||
|
frame, text=title, font=self._f_small,
|
||||||
|
bg=CLR_BG, fg=CLR_TEXT, anchor="w",
|
||||||
|
).pack(fill=tk.X, pady=(0, 4))
|
||||||
|
|
||||||
|
# Zone de saisie + bouton ajouter
|
||||||
|
input_row = tk.Frame(frame, bg=CLR_BG)
|
||||||
|
input_row.pack(fill=tk.X, pady=(0, 4))
|
||||||
|
|
||||||
|
entry = tk.Entry(input_row, font=self._f_small, relief=tk.GROOVE, bd=1)
|
||||||
|
entry.insert(0, placeholder)
|
||||||
|
entry.configure(fg="#999")
|
||||||
|
|
||||||
|
def _on_focus_in(e):
|
||||||
|
if entry.get() == placeholder:
|
||||||
|
entry.delete(0, tk.END)
|
||||||
|
entry.configure(fg=CLR_TEXT)
|
||||||
|
|
||||||
|
def _on_focus_out(e):
|
||||||
|
if not entry.get().strip():
|
||||||
|
entry.insert(0, placeholder)
|
||||||
|
entry.configure(fg="#999")
|
||||||
|
|
||||||
|
entry.bind("<FocusIn>", _on_focus_in)
|
||||||
|
entry.bind("<FocusOut>", _on_focus_out)
|
||||||
|
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
|
||||||
|
|
||||||
|
def _add(event=None):
|
||||||
|
text = entry.get().strip()
|
||||||
|
if text and text != placeholder:
|
||||||
|
# Éviter les doublons
|
||||||
|
items = list(listbox.get(0, tk.END))
|
||||||
|
if text not in items:
|
||||||
|
listbox.insert(tk.END, text)
|
||||||
|
entry.delete(0, tk.END)
|
||||||
|
|
||||||
|
add_btn = tk.Button(
|
||||||
|
input_row, text="+ Ajouter", font=self._f_small,
|
||||||
|
bg=color_tag, fg=CLR_TEXT, relief=tk.GROOVE, cursor="hand2",
|
||||||
|
command=_add, padx=8,
|
||||||
|
)
|
||||||
|
add_btn.pack(side=tk.LEFT)
|
||||||
|
entry.bind("<Return>", _add)
|
||||||
|
|
||||||
|
# Liste des phrases
|
||||||
|
list_frame = tk.Frame(frame, bg=CLR_BG)
|
||||||
|
list_frame.pack(fill=tk.X)
|
||||||
|
|
||||||
|
listbox = tk.Listbox(
|
||||||
|
list_frame, height=4, font=("Consolas", 9),
|
||||||
|
relief=tk.GROOVE, bd=1, selectmode=tk.EXTENDED,
|
||||||
|
bg=color_tag,
|
||||||
|
)
|
||||||
|
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=listbox.yview)
|
||||||
|
listbox.configure(yscrollcommand=scrollbar.set)
|
||||||
|
listbox.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||||
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
|
||||||
|
# Bouton supprimer
|
||||||
|
def _remove():
|
||||||
|
sel = listbox.curselection()
|
||||||
|
for idx in reversed(sel):
|
||||||
|
listbox.delete(idx)
|
||||||
|
|
||||||
|
rm_btn = tk.Button(
|
||||||
|
frame, text="Supprimer la sélection", font=self._f_small,
|
||||||
|
bg="#ffcdd2", fg="#b71c1c", relief=tk.GROOVE, cursor="hand2",
|
||||||
|
command=_remove, padx=8,
|
||||||
|
)
|
||||||
|
rm_btn.pack(anchor="e", pady=(2, 0))
|
||||||
|
|
||||||
|
return listbox, entry
|
||||||
|
|
||||||
|
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", [])
|
||||||
|
self._wl_listbox.delete(0, tk.END)
|
||||||
|
for phrase in wl:
|
||||||
|
if phrase and phrase.strip():
|
||||||
|
self._wl_listbox.insert(tk.END, phrase.strip())
|
||||||
|
# Blacklist
|
||||||
|
bl = data.get("blacklist", {}).get("force_mask_terms", [])
|
||||||
|
self._bl_listbox.delete(0, tk.END)
|
||||||
|
for term in bl:
|
||||||
|
if term and str(term).strip():
|
||||||
|
self._bl_listbox.insert(tk.END, str(term).strip())
|
||||||
|
# Stop-words additionnels
|
||||||
|
sw = data.get("additional_stopwords", [])
|
||||||
|
self._sw_listbox.delete(0, tk.END)
|
||||||
|
for term in sw:
|
||||||
|
if term and str(term).strip():
|
||||||
|
self._sw_listbox.insert(tk.END, str(term).strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _export_params(self):
|
||||||
|
"""Exporte les paramètres whitelist/blacklist dans un fichier JSON pour envoi par email."""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
wl = list(self._wl_listbox.get(0, tk.END))
|
||||||
|
bl = list(self._bl_listbox.get(0, tk.END))
|
||||||
|
sw = list(self._sw_listbox.get(0, tk.END))
|
||||||
|
|
||||||
|
export_data = {
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"date_export": datetime.now().isoformat(),
|
||||||
|
"etablissement": "", # à remplir par l'utilisateur
|
||||||
|
"whitelist_phrases": wl,
|
||||||
|
"blacklist_force_mask_terms": bl,
|
||||||
|
"additional_stopwords": sw,
|
||||||
|
"instructions": (
|
||||||
|
"Ce fichier contient les paramètres d'anonymisation personnalisés. "
|
||||||
|
"Envoyez-le par email à l'équipe technique pour mise à jour du programme."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proposer le Bureau comme destination par défaut
|
||||||
|
desktop = Path.home() / "Desktop"
|
||||||
|
if not desktop.exists():
|
||||||
|
desktop = Path.home() / "Bureau"
|
||||||
|
if not desktop.exists():
|
||||||
|
desktop = Path.home()
|
||||||
|
|
||||||
|
dest = filedialog.asksaveasfilename(
|
||||||
|
title="Exporter les paramètres",
|
||||||
|
initialdir=str(desktop),
|
||||||
|
initialfile="parametres_anonymisation.json",
|
||||||
|
defaultextension=".json",
|
||||||
|
filetypes=[("JSON", "*.json"), ("Tous", "*.*")],
|
||||||
|
)
|
||||||
|
if dest:
|
||||||
|
Path(dest).write_text(
|
||||||
|
_json.dumps(export_data, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Export réussi",
|
||||||
|
f"Paramètres exportés dans :\n{dest}\n\n"
|
||||||
|
f"Vous pouvez envoyer ce fichier par email\n"
|
||||||
|
f"à l'équipe technique.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Erreur", f"Erreur à l'export :\n{e}")
|
||||||
|
|
||||||
|
def _import_params(self):
|
||||||
|
"""Importe des paramètres depuis un fichier JSON (fusionne avec l'existant)."""
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
src = filedialog.askopenfilename(
|
||||||
|
title="Importer des paramètres",
|
||||||
|
filetypes=[("JSON", "*.json"), ("Tous", "*.*")],
|
||||||
|
)
|
||||||
|
if not src:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = _json.loads(Path(src).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Fusionner whitelist
|
||||||
|
new_wl = data.get("whitelist_phrases", [])
|
||||||
|
existing_wl = set(self._wl_listbox.get(0, tk.END))
|
||||||
|
added_wl = 0
|
||||||
|
for phrase in new_wl:
|
||||||
|
if phrase and phrase.strip() and phrase.strip() not in existing_wl:
|
||||||
|
self._wl_listbox.insert(tk.END, phrase.strip())
|
||||||
|
added_wl += 1
|
||||||
|
|
||||||
|
# Fusionner blacklist
|
||||||
|
new_bl = data.get("blacklist_force_mask_terms", [])
|
||||||
|
existing_bl = set(self._bl_listbox.get(0, tk.END))
|
||||||
|
added_bl = 0
|
||||||
|
for term in new_bl:
|
||||||
|
if term and str(term).strip() and str(term).strip() not in existing_bl:
|
||||||
|
self._bl_listbox.insert(tk.END, str(term).strip())
|
||||||
|
added_bl += 1
|
||||||
|
|
||||||
|
# Fusionner stop-words additionnels
|
||||||
|
new_sw = data.get("additional_stopwords", [])
|
||||||
|
existing_sw = set(self._sw_listbox.get(0, tk.END))
|
||||||
|
added_sw = 0
|
||||||
|
for term in new_sw:
|
||||||
|
if term and str(term).strip() and str(term).strip() not in existing_sw:
|
||||||
|
self._sw_listbox.insert(tk.END, str(term).strip())
|
||||||
|
added_sw += 1
|
||||||
|
|
||||||
|
version = data.get("version", "?")
|
||||||
|
date_exp = data.get("date_export", "?")[:10]
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Import réussi",
|
||||||
|
f"Paramètres importés (v{version}, {date_exp}) :\n\n"
|
||||||
|
f" + {added_wl} phrase(s) ajoutée(s) à la whitelist\n"
|
||||||
|
f" + {added_bl} terme(s) ajouté(s) à la blacklist\n"
|
||||||
|
f" + {added_sw} mot(s) ajouté(s) aux stop-words\n\n"
|
||||||
|
f"Cliquez sur « Sauvegarder » pour appliquer.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Erreur", f"Erreur à l'import :\n{e}")
|
||||||
|
|
||||||
|
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
|
||||||
|
data["whitelist_phrases"] = list(self._wl_listbox.get(0, tk.END))
|
||||||
|
|
||||||
|
# Blacklist terms
|
||||||
|
if "blacklist" not in data:
|
||||||
|
data["blacklist"] = {}
|
||||||
|
data["blacklist"]["force_mask_terms"] = list(self._bl_listbox.get(0, tk.END))
|
||||||
|
|
||||||
|
# Stop-words additionnels (mots à ne jamais identifier comme noms)
|
||||||
|
data["additional_stopwords"] = list(self._sw_listbox.get(0, tk.END))
|
||||||
|
|
||||||
|
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)
|
# YAML (interne)
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
56
anonymisation_onefile.spec
Normal file
56
anonymisation_onefile.spec
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import os
|
||||||
|
block_cipher = None
|
||||||
|
app_dir = 'C:\\Users\\dom\\ai\\anonymisation'
|
||||||
|
|
||||||
|
datas = [
|
||||||
|
(os.path.join(app_dir, 'config'), 'config'),
|
||||||
|
(os.path.join(app_dir, 'data', 'bdpm'), os.path.join('data', 'bdpm')),
|
||||||
|
(os.path.join(app_dir, 'data', 'finess'), os.path.join('data', 'finess')),
|
||||||
|
(os.path.join(app_dir, 'data', 'insee'), os.path.join('data', 'insee')),
|
||||||
|
(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'),
|
||||||
|
]
|
||||||
|
# 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']:
|
||||||
|
datas.append((os.path.join(app_dir, pyfile), '.'))
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[os.path.join(app_dir, 'launcher.py')],
|
||||||
|
pathex=[app_dir],
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=[
|
||||||
|
'anonymizer_core_refactored_onnx', 'eds_pseudo_manager',
|
||||||
|
'gliner_manager', 'camembert_ner_manager', 'Pseudonymisation_Gui_V5',
|
||||||
|
'edsnlp', 'edsnlp.pipes', 'edsnlp.pipes.ner', 'edsnlp.pipes.ner.pseudo',
|
||||||
|
'spacy', 'spacy.lang.fr', 'gliner', 'onnxruntime',
|
||||||
|
'transformers', 'tokenizers', 'torch', 'pdfplumber',
|
||||||
|
'ahocorasick', 'sklearn', 'scipy', 'pydantic', 'yaml', 'PIL',
|
||||||
|
'loguru', 'regex',
|
||||||
|
],
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
exe = EXE(
|
||||||
|
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
|
||||||
|
name='Anonymisation',
|
||||||
|
debug=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
console=False,
|
||||||
|
icon=None,
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,18 +15,18 @@ whitelist:
|
|||||||
- Praticien conseil
|
- Praticien conseil
|
||||||
org_gpe_keep: false
|
org_gpe_keep: false
|
||||||
blacklist:
|
blacklist:
|
||||||
|
# Sigles et libellés propres à l'établissement non couverts par les gazetteers
|
||||||
|
# nationaux (FINESS / INSEE / BDPM). Évitez d'ajouter ici des noms d'hôpitaux,
|
||||||
|
# villes, codes postaux ou numéros FINESS — ils sont déjà détectés automatiquement.
|
||||||
force_mask_terms:
|
force_mask_terms:
|
||||||
- CENTRE HOSPITALIER COTE BASQUE
|
- CHCB # Sigle local non référencé FINESS
|
||||||
- CENTRE HOSPITALIER DE LA COTE BASQUE
|
- 'Dates du séjour :' # Libellé administratif (politique masquage)
|
||||||
- POLYCLINIQUE COTE BASQUE SUD
|
- CONCERTATION # Mention de RCP (politique métier)
|
||||||
- POLYCLINIQUE CÔTE BASQUE SUD
|
- LABORATOIRE de BIOLOGIE MEDICALE # Libellé administratif générique
|
||||||
- CHCB
|
|
||||||
- '640780417'
|
|
||||||
- 'Dates du séjour :'
|
|
||||||
- CONCERTATION
|
|
||||||
force_mask_regex:
|
force_mask_regex:
|
||||||
- 'Centre\s+Hospitalier\s+(?:de\s+(?:la\s+)?)?C[oôÔ]te\s+Basque'
|
# Adresse précise du CHCB — couverte par l'AC FINESS adresses mais on garde
|
||||||
- 'Polyclinique\s+C[oôÔ]te\s+Basque\s+Sud'
|
# la regex en filet de sécurité (encodages PDF, espaces non standards).
|
||||||
|
- '13\s*,?\s*Avenue\s+de\s+l.Interne\s+J\.?\s*LOEB\s+BP\s*\d+'
|
||||||
kv_labels_preserve:
|
kv_labels_preserve:
|
||||||
- FINESS
|
- FINESS
|
||||||
- IPP
|
- IPP
|
||||||
@@ -38,6 +38,45 @@ regex_overrides:
|
|||||||
placeholder: '[OGC]'
|
placeholder: '[OGC]'
|
||||||
flags:
|
flags:
|
||||||
- IGNORECASE
|
- 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"
|
||||||
|
# Mots supplémentaires à ne jamais masquer comme noms de personnes
|
||||||
|
# (complète les 9000+ stop-words intégrés)
|
||||||
|
additional_stopwords: []
|
||||||
|
# Exemple :
|
||||||
|
# - "votre_mot"
|
||||||
|
|
||||||
|
# Villes supplémentaires à ne jamais matcher comme lieux
|
||||||
|
# (complète les 115+ villes blacklistées intégrées)
|
||||||
|
additional_villes_blacklist: []
|
||||||
|
# Exemple :
|
||||||
|
# - "VOTRE_VILLE"
|
||||||
|
|
||||||
|
# Labels DPI supplémentaires à ne jamais masquer comme noms
|
||||||
|
# (complète data/dpi_labels_blacklist.txt)
|
||||||
|
# Utiliser pour : titres de colonnes, en-têtes de sections, libellés de champs
|
||||||
|
additional_dpi_labels: []
|
||||||
|
# Exemple :
|
||||||
|
# - "Service"
|
||||||
|
# - "Statut"
|
||||||
|
|
||||||
|
# Termes en MAJUSCULES à ne jamais propager comme noms compagnons
|
||||||
|
# (complète data/companion_blacklist.txt — spécialités, labos pharma, mots ambigus)
|
||||||
|
additional_companion_blacklist: []
|
||||||
|
# Exemple :
|
||||||
|
# - "VOTRE_SPECIALITE"
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
case_insensitive: true
|
case_insensitive: true
|
||||||
unicode_word_boundaries: true
|
unicode_word_boundaries: true
|
||||||
|
|||||||
15816
data/bdpm/CIS_bdpm.txt
Normal file
15816
data/bdpm/CIS_bdpm.txt
Normal file
File diff suppressed because it is too large
Load Diff
7316
data/bdpm/medicaments_stopwords.txt
Normal file
7316
data/bdpm/medicaments_stopwords.txt
Normal file
File diff suppressed because it is too large
Load Diff
94
data/companion_blacklist.txt
Normal file
94
data/companion_blacklist.txt
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Companion blacklist : termes en MAJUSCULES qui apparaissent à côté d'un nom
|
||||||
|
# connu mais qui NE SONT PAS des noms (spécialités médicales, labos pharma,
|
||||||
|
# mots courants ambigus). Évite la propagation FP : "DUPONT CARDIOLOGIE"
|
||||||
|
# ne propage pas "CARDIOLOGIE" comme nom.
|
||||||
|
#
|
||||||
|
# Format : un terme par ligne, en MAJUSCULES.
|
||||||
|
# Lignes vides et lignes commençant par # ignorées.
|
||||||
|
|
||||||
|
# Mots ambigus courants
|
||||||
|
ZONE
|
||||||
|
PARTI
|
||||||
|
PLAN
|
||||||
|
MAIN
|
||||||
|
FORT
|
||||||
|
FORTE
|
||||||
|
BILAN
|
||||||
|
MISE
|
||||||
|
NOTE
|
||||||
|
AIDE
|
||||||
|
BASE
|
||||||
|
FACE
|
||||||
|
DOSE
|
||||||
|
TIGE
|
||||||
|
VOIE
|
||||||
|
ONDE
|
||||||
|
SOIN
|
||||||
|
DEMI
|
||||||
|
MODE
|
||||||
|
CURE
|
||||||
|
PAGE
|
||||||
|
|
||||||
|
# Spécialités / services médicaux
|
||||||
|
CANCEROLOGIE
|
||||||
|
ONCOLOGIE
|
||||||
|
REANIMATION
|
||||||
|
RADIOLOGIE
|
||||||
|
CARDIOLOGIE
|
||||||
|
NEUROLOGIE
|
||||||
|
PNEUMOLOGIE
|
||||||
|
UROLOGIE
|
||||||
|
GERIATRIE
|
||||||
|
PEDIATRIE
|
||||||
|
NEPHROLOGIE
|
||||||
|
HEMATOLOGIE
|
||||||
|
OPHTALMOLOGIE
|
||||||
|
STOMATOLOGIE
|
||||||
|
ALLERGOLOGIE
|
||||||
|
RHUMATOLOGIE
|
||||||
|
DERMATOLOGIE
|
||||||
|
IMMUNOLOGIE
|
||||||
|
|
||||||
|
# Termes médicaux / courants (FP signalés OGC 21)
|
||||||
|
ALIMENTATION
|
||||||
|
AUGMENTATION
|
||||||
|
AMELIORATION
|
||||||
|
BILIAIRES
|
||||||
|
BILIAIRE
|
||||||
|
VOIES
|
||||||
|
BILI
|
||||||
|
MEDECINE
|
||||||
|
ENTERO
|
||||||
|
DOSSIER
|
||||||
|
AVIATION
|
||||||
|
SULFAMIDES
|
||||||
|
CLAVULANIQUE
|
||||||
|
MECILLINAM
|
||||||
|
TAZOBACTAM
|
||||||
|
TEMOCILLINE
|
||||||
|
ECOFLAC
|
||||||
|
FURANES
|
||||||
|
CONTENTION
|
||||||
|
ISOLEMENT
|
||||||
|
ELIMINATION
|
||||||
|
|
||||||
|
# Labos pharmaceutiques (FP dans tableaux prescriptions trackare)
|
||||||
|
MACO
|
||||||
|
AGUETTANT
|
||||||
|
RENAUDIN
|
||||||
|
LAVOISIER
|
||||||
|
COOPER
|
||||||
|
ARROW
|
||||||
|
BIOGARAN
|
||||||
|
MYLAN
|
||||||
|
TEVA
|
||||||
|
ZENTIVA
|
||||||
|
|
||||||
|
# Termes médicaux additionnels
|
||||||
|
PANCREATITE
|
||||||
|
INFECTIEUX
|
||||||
|
HEMODYNAMIQUE
|
||||||
|
SENSIBLE
|
||||||
|
VARIABLE
|
||||||
|
DOSAGE
|
||||||
|
CAT
|
||||||
16
data/dpi_labels_blacklist.txt
Normal file
16
data/dpi_labels_blacklist.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Labels DPI / mots structurels à ne JAMAIS masquer comme noms
|
||||||
|
# (titres de colonnes, en-têtes de sections, libellés de champs DPI)
|
||||||
|
# Comparaison case-insensitive — un mot par ligne.
|
||||||
|
# Lignes vides et lignes commençant par # ignorées.
|
||||||
|
|
||||||
|
Date
|
||||||
|
Note
|
||||||
|
Heure
|
||||||
|
Type
|
||||||
|
Soin
|
||||||
|
Soins
|
||||||
|
Surv
|
||||||
|
Page
|
||||||
|
Presc
|
||||||
|
Saint
|
||||||
|
Sainte
|
||||||
63107
data/finess/adresses_finess.txt
Normal file
63107
data/finess/adresses_finess.txt
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
11
data/finess/mono_mots_distinctifs.txt
Normal file
11
data/finess/mono_mots_distinctifs.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Mono-mots FINESS considérés comme distinctifs malgré leur longueur < 10 chars
|
||||||
|
# Permet au matcher Aho-Corasick d'accepter des noms d'établissements courts
|
||||||
|
# qui sont dans etablissements_distinctifs.txt mais filtrés par le seuil.
|
||||||
|
#
|
||||||
|
# ⚠ Ajouter uniquement des mots suffisamment RARES pour éviter les faux positifs
|
||||||
|
# (ex: "embruns" rare en français, OK — "parc", "jardin" trop génériques, NON).
|
||||||
|
#
|
||||||
|
# Un mot par ligne, lowercase, sans accents. Lignes vides et # ignorées.
|
||||||
|
|
||||||
|
embruns
|
||||||
|
embrun
|
||||||
52463
data/finess/voies_distinctives.txt
Normal file
52463
data/finess/voies_distinctives.txt
Normal file
File diff suppressed because it is too large
Load Diff
218984
data/insee/noms2008nat_txt.txt
Normal file
218984
data/insee/noms2008nat_txt.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1184,8 +1184,8 @@ déglobulisation. O
|
|||||||
Bladder O
|
Bladder O
|
||||||
négatif. O
|
négatif. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
antalgique O
|
antalgique O
|
||||||
: O
|
: O
|
||||||
Faux B-VILLE
|
Faux B-VILLE
|
||||||
@@ -1515,8 +1515,8 @@ cette O
|
|||||||
patiente O
|
patiente O
|
||||||
altérée O
|
altérée O
|
||||||
sur O
|
sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
général, O
|
général, O
|
||||||
OMS2/3. O
|
OMS2/3. O
|
||||||
> O
|
> O
|
||||||
@@ -1529,8 +1529,8 @@ du O
|
|||||||
traitement O
|
traitement O
|
||||||
antalgique. O
|
antalgique. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
infectieux O
|
infectieux O
|
||||||
: O
|
: O
|
||||||
Pic O
|
Pic O
|
||||||
@@ -2817,8 +2817,8 @@ apyrexie O
|
|||||||
au O
|
au O
|
||||||
décours. O
|
décours. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
urologique O
|
urologique O
|
||||||
: O
|
: O
|
||||||
Un O
|
Un O
|
||||||
@@ -2919,8 +2919,8 @@ oncologique O
|
|||||||
Nette O
|
Nette O
|
||||||
amélioration O
|
amélioration O
|
||||||
sur O
|
sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
général O
|
général O
|
||||||
avec O
|
avec O
|
||||||
la O
|
la O
|
||||||
|
|||||||
@@ -2572,8 +2572,8 @@ de O
|
|||||||
traitement O
|
traitement O
|
||||||
antibiotique O
|
antibiotique O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
hématologique O
|
hématologique O
|
||||||
Anémie O
|
Anémie O
|
||||||
autour O
|
autour O
|
||||||
|
|||||||
@@ -1812,8 +1812,8 @@ de O
|
|||||||
cette O
|
cette O
|
||||||
décision. O
|
décision. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
hématologique: O
|
hématologique: O
|
||||||
Elle O
|
Elle O
|
||||||
présente O
|
présente O
|
||||||
|
|||||||
@@ -1420,8 +1420,8 @@ en O
|
|||||||
charge O
|
charge O
|
||||||
antalgique. O
|
antalgique. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
de O
|
de O
|
||||||
la O
|
la O
|
||||||
gravité: O
|
gravité: O
|
||||||
|
|||||||
@@ -1102,8 +1102,8 @@ de O
|
|||||||
l'épisode O
|
l'épisode O
|
||||||
aigüe. O
|
aigüe. O
|
||||||
Sur O
|
Sur O
|
||||||
le O
|
le B-VILLE
|
||||||
plan B-VILLE
|
plan I-VILLE
|
||||||
infectieux, O
|
infectieux, O
|
||||||
présence O
|
présence O
|
||||||
de O
|
de O
|
||||||
|
|||||||
1323
data/stopwords_manuels.txt
Normal file
1323
data/stopwords_manuels.txt
Normal file
File diff suppressed because it is too large
Load Diff
121
data/villes_blacklist.txt
Normal file
121
data/villes_blacklist.txt
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Villes/communes à ne jamais matcher comme noms de lieux
|
||||||
|
# (homonymes de mots courants, parties du corps, etc.)
|
||||||
|
# Total : 117 entrées
|
||||||
|
|
||||||
|
AGEN
|
||||||
|
AIRE
|
||||||
|
ALBI
|
||||||
|
ANNE
|
||||||
|
AUCH
|
||||||
|
BARRES
|
||||||
|
BEAUNE
|
||||||
|
BILLE
|
||||||
|
BLOIS
|
||||||
|
BOIS
|
||||||
|
BOURG
|
||||||
|
BRAS
|
||||||
|
BREST
|
||||||
|
CENTRE
|
||||||
|
CERGY
|
||||||
|
CHAISE
|
||||||
|
CHARGE
|
||||||
|
COEUR
|
||||||
|
CONTRE
|
||||||
|
CORPS
|
||||||
|
COU
|
||||||
|
COURANT
|
||||||
|
COURS
|
||||||
|
CREIL
|
||||||
|
CROIX
|
||||||
|
DOLE
|
||||||
|
DOS
|
||||||
|
EST
|
||||||
|
EUROPE
|
||||||
|
EVIAN
|
||||||
|
FAUX
|
||||||
|
FOIX
|
||||||
|
FORT
|
||||||
|
FOSSES
|
||||||
|
FRANCE
|
||||||
|
GARDES
|
||||||
|
GIEN
|
||||||
|
GIVET
|
||||||
|
GRAND
|
||||||
|
GRAY
|
||||||
|
HYERES
|
||||||
|
ISLE
|
||||||
|
JEAN
|
||||||
|
JOUE
|
||||||
|
LACS
|
||||||
|
LAON
|
||||||
|
LENS
|
||||||
|
LIGNE
|
||||||
|
LIGNES
|
||||||
|
LONG
|
||||||
|
LUNEL
|
||||||
|
LURE
|
||||||
|
MAISON
|
||||||
|
MARCHE
|
||||||
|
MARIE
|
||||||
|
MARS
|
||||||
|
MARSA
|
||||||
|
MAURE
|
||||||
|
MEAUX
|
||||||
|
MENDE
|
||||||
|
MENTON
|
||||||
|
MERE
|
||||||
|
MONT
|
||||||
|
MORET
|
||||||
|
MOULIN
|
||||||
|
MURET
|
||||||
|
MURS
|
||||||
|
Médecin courant
|
||||||
|
NICE
|
||||||
|
NORD
|
||||||
|
NUITS
|
||||||
|
ONDRES
|
||||||
|
ORANGE
|
||||||
|
OUEST
|
||||||
|
OUST
|
||||||
|
PARIS
|
||||||
|
PAUL
|
||||||
|
PIERRE
|
||||||
|
PLACE
|
||||||
|
PLAN
|
||||||
|
PONT
|
||||||
|
PORT
|
||||||
|
PREY
|
||||||
|
PRISON
|
||||||
|
PUITS
|
||||||
|
QUATRE
|
||||||
|
RANS
|
||||||
|
RECY
|
||||||
|
REDON
|
||||||
|
REZE
|
||||||
|
RICHE
|
||||||
|
ROMANS
|
||||||
|
ROUGE
|
||||||
|
SAINT
|
||||||
|
SALLE
|
||||||
|
SALON
|
||||||
|
SARE
|
||||||
|
SEIN
|
||||||
|
SENS
|
||||||
|
SERVICE
|
||||||
|
SETE
|
||||||
|
SIGNES
|
||||||
|
SORE
|
||||||
|
SOURCE
|
||||||
|
SUD
|
||||||
|
TOURS
|
||||||
|
TRANS
|
||||||
|
VALLEE
|
||||||
|
VAUX
|
||||||
|
VEBRE
|
||||||
|
VERS
|
||||||
|
VERT
|
||||||
|
VIENNE
|
||||||
|
VILLE
|
||||||
|
VIRE
|
||||||
|
VITRE
|
||||||
|
prurit invalidant (COU, décolleté)
|
||||||
@@ -166,23 +166,12 @@ class HospitalFilter:
|
|||||||
Returns:
|
Returns:
|
||||||
True si la détection doit être filtrée (faux positif)
|
True si la détection doit être filtrée (faux positif)
|
||||||
"""
|
"""
|
||||||
# Filtrer par type
|
# ADRESSE, CODE_POSTAL, VILLE, TEL : NE PAS filtrer.
|
||||||
if pii_type == "ADRESSE":
|
# Les coordonnées hospitalières identifient indirectement le patient
|
||||||
return self.is_hospital_address(text)
|
# et doivent être masquées (validé par contrôle humain 2026-03-12).
|
||||||
|
|
||||||
elif pii_type == "CODE_POSTAL":
|
# EPISODE : NE PAS filtrer.
|
||||||
return self.is_hospital_postal_code(text)
|
# Les numéros d'épisode identifient le patient (validé 2026-03-14).
|
||||||
|
|
||||||
elif pii_type == "VILLE":
|
|
||||||
return self.is_hospital_city(text)
|
|
||||||
|
|
||||||
elif pii_type == "TEL":
|
|
||||||
return self.is_hospital_phone(text)
|
|
||||||
|
|
||||||
elif pii_type == "EPISODE":
|
|
||||||
# Filtrer les épisodes qui proviennent du nom de fichier
|
|
||||||
# (répétés dans les en-têtes/pieds de page des documents trackare)
|
|
||||||
return self.is_episode_in_filename(text, filename)
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -222,15 +211,17 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
test_cases = [
|
test_cases = [
|
||||||
("ADRESSE", "13, Avenue de l'Interne J", "", -1, True),
|
# ADRESSE, CODE_POSTAL, VILLE, TEL : ne sont plus filtrés (identifient le patient)
|
||||||
|
("ADRESSE", "13, Avenue de l'Interne J", "", -1, False),
|
||||||
("ADRESSE", "22 LOT MENDI ALDE", "", -1, False),
|
("ADRESSE", "22 LOT MENDI ALDE", "", -1, False),
|
||||||
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, True),
|
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, False),
|
||||||
("CODE_POSTAL", "64130", "", -1, False),
|
("CODE_POSTAL", "64130", "", -1, False),
|
||||||
("VILLE", "BAYONNE CEDEX", "", -1, True),
|
("VILLE", "BAYONNE CEDEX", "", -1, False),
|
||||||
("VILLE", "CHERAUTE", "", -1, False),
|
("VILLE", "CHERAUTE", "", -1, False),
|
||||||
("VILLE", "DROIT", "", -1, True), # Terme anatomique
|
("VILLE", "DROIT", "", -1, False),
|
||||||
("TEL", "05 59 44 35 35", "", -1, True),
|
("TEL", "05 59 44 35 35", "", -1, False),
|
||||||
("TEL", "0676085336", "", -1, False),
|
("TEL", "0676085336", "", -1, False),
|
||||||
|
# EPISODE : filtré uniquement si provient du nom de fichier trackare
|
||||||
("EPISODE", "23202435", "trackare-14004105-23202435", -1, True),
|
("EPISODE", "23202435", "trackare-14004105-23202435", -1, True),
|
||||||
("EPISODE", "23102610", "CRH_23102610", 0, False),
|
("EPISODE", "23102610", "CRH_23102610", 0, False),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"date": "2026-03-12T10:24:59.261417",
|
"date": "2026-03-12T17:16:25.993851",
|
||||||
"scores": {
|
"scores": {
|
||||||
"global_score": 97.0,
|
"global_score": 97.0,
|
||||||
"leak_score": 100.0,
|
"leak_score": 100.0,
|
||||||
"fp_score": 90,
|
"fp_score": 90,
|
||||||
"totals": {
|
"totals": {
|
||||||
"documents": 29,
|
"documents": 29,
|
||||||
"audit_hits": 2797,
|
"audit_hits": 3186,
|
||||||
"name_tokens_known": 461,
|
"name_tokens_known": 457,
|
||||||
"leak_audit": 0,
|
"leak_audit": 0,
|
||||||
"leak_occurrences": 0,
|
"leak_occurrences": 0,
|
||||||
"leak_regex": 0,
|
"leak_regex": 0,
|
||||||
"leak_insee_high": 0,
|
"leak_insee_high": 0,
|
||||||
"leak_insee_medium": 569,
|
"leak_insee_medium": 570,
|
||||||
"fp_medical": 0,
|
"fp_medical": 0,
|
||||||
"fp_overmasking": 2
|
"fp_overmasking": 2
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"leak_audit": 0,
|
"leak_audit": 0,
|
||||||
"leak_regex": 0,
|
"leak_regex": 0,
|
||||||
"leak_insee_high": 0,
|
"leak_insee_high": 0,
|
||||||
"leak_insee_medium": 23,
|
"leak_insee_medium": 24,
|
||||||
"fp_medical": 0,
|
"fp_medical": 0,
|
||||||
"fp_overmasking": 0
|
"fp_overmasking": 0
|
||||||
},
|
},
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
"leak_audit": 0,
|
"leak_audit": 0,
|
||||||
"leak_regex": 0,
|
"leak_regex": 0,
|
||||||
"leak_insee_high": 0,
|
"leak_insee_high": 0,
|
||||||
"leak_insee_medium": 32,
|
"leak_insee_medium": 33,
|
||||||
"fp_medical": 0,
|
"fp_medical": 0,
|
||||||
"fp_overmasking": 0
|
"fp_overmasking": 0
|
||||||
},
|
},
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
"leak_audit": 0,
|
"leak_audit": 0,
|
||||||
"leak_regex": 0,
|
"leak_regex": 0,
|
||||||
"leak_insee_high": 0,
|
"leak_insee_high": 0,
|
||||||
"leak_insee_medium": 34,
|
"leak_insee_medium": 32,
|
||||||
"fp_medical": 0,
|
"fp_medical": 0,
|
||||||
"fp_overmasking": 0
|
"fp_overmasking": 0
|
||||||
},
|
},
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
"leak_audit": 0,
|
"leak_audit": 0,
|
||||||
"leak_regex": 0,
|
"leak_regex": 0,
|
||||||
"leak_insee_high": 0,
|
"leak_insee_high": 0,
|
||||||
"leak_insee_medium": 26,
|
"leak_insee_medium": 27,
|
||||||
"fp_medical": 0,
|
"fp_medical": 0,
|
||||||
"fp_overmasking": 0
|
"fp_overmasking": 0
|
||||||
}
|
}
|
||||||
|
|||||||
256
format_converter.py
Normal file
256
format_converter.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Conversion de documents multi-formats vers PDF pour le pipeline d'anonymisation.
|
||||||
|
|
||||||
|
Formats supportés :
|
||||||
|
- PDF : passthrough (rien à faire)
|
||||||
|
- DOCX : python-docx → texte → PDF via PyMuPDF
|
||||||
|
- ODT : odfpy → texte → PDF via PyMuPDF
|
||||||
|
- RTF : striprtf → texte → PDF via PyMuPDF
|
||||||
|
- TXT : texte brut → PDF via PyMuPDF
|
||||||
|
- HTML : BeautifulSoup → texte → PDF via PyMuPDF
|
||||||
|
- JPEG/PNG/TIFF/BMP : image embarquée dans un PDF (OCR docTR en aval)
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
from format_converter import convert_to_pdf, SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
pdf_path, is_temp = convert_to_pdf(Path("document.docx"))
|
||||||
|
# ... process_pdf(pdf_path, ...) ...
|
||||||
|
if is_temp:
|
||||||
|
pdf_path.unlink() # nettoyer le fichier temporaire
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Extensions supportées (lowercase, avec le point)
|
||||||
|
SUPPORTED_EXTENSIONS = {
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".odt",
|
||||||
|
".rtf",
|
||||||
|
".txt", ".text",
|
||||||
|
".html", ".htm",
|
||||||
|
".jpg", ".jpeg",
|
||||||
|
".png",
|
||||||
|
".tiff", ".tif",
|
||||||
|
".bmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extensions images (OCR requis)
|
||||||
|
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp"}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_pdf(input_path: Path) -> Tuple[Path, bool]:
|
||||||
|
"""Convertit un document en PDF pour le pipeline d'anonymisation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path: chemin du document source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(pdf_path, is_temporary): chemin du PDF + flag si fichier temporaire à nettoyer
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: format non supporté
|
||||||
|
RuntimeError: erreur de conversion
|
||||||
|
"""
|
||||||
|
suffix = input_path.suffix.lower()
|
||||||
|
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return input_path, False
|
||||||
|
|
||||||
|
if suffix not in SUPPORTED_EXTENSIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Format '{suffix}' non supporté. "
|
||||||
|
f"Formats acceptés : {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fichier PDF temporaire dans le même dossier (pour préserver le contexte)
|
||||||
|
tmp_pdf = input_path.with_suffix(".tmp_convert.pdf")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if suffix in _IMAGE_EXTENSIONS:
|
||||||
|
_image_to_pdf(input_path, tmp_pdf)
|
||||||
|
elif suffix == ".docx":
|
||||||
|
_docx_to_pdf(input_path, tmp_pdf)
|
||||||
|
elif suffix == ".odt":
|
||||||
|
_odt_to_pdf(input_path, tmp_pdf)
|
||||||
|
elif suffix == ".rtf":
|
||||||
|
_rtf_to_pdf(input_path, tmp_pdf)
|
||||||
|
elif suffix in {".txt", ".text"}:
|
||||||
|
_txt_to_pdf(input_path, tmp_pdf)
|
||||||
|
elif suffix in {".html", ".htm"}:
|
||||||
|
_html_to_pdf(input_path, tmp_pdf)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Format '{suffix}' non implémenté")
|
||||||
|
|
||||||
|
log.info("Converti %s → %s", input_path.name, tmp_pdf.name)
|
||||||
|
return tmp_pdf, True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Nettoyer en cas d'erreur
|
||||||
|
if tmp_pdf.exists():
|
||||||
|
tmp_pdf.unlink()
|
||||||
|
raise RuntimeError(f"Erreur conversion {input_path.name}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_pdf(img_path: Path, out_pdf: Path):
|
||||||
|
"""Embarque une image dans un PDF (1 page). L'OCR docTR traitera en aval."""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
doc = fitz.open()
|
||||||
|
# Ouvrir l'image pour obtenir ses dimensions
|
||||||
|
img_doc = fitz.open(str(img_path))
|
||||||
|
# Si c'est un TIFF multi-pages
|
||||||
|
for i in range(len(img_doc)):
|
||||||
|
page = img_doc[i]
|
||||||
|
rect = page.rect
|
||||||
|
pdf_page = doc.new_page(width=rect.width, height=rect.height)
|
||||||
|
pdf_page.insert_image(rect, filename=str(img_path) if i == 0 else None,
|
||||||
|
pixmap=img_doc[i].get_pixmap() if i > 0 else None)
|
||||||
|
img_doc.close()
|
||||||
|
doc.save(str(out_pdf))
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _text_to_pdf_pages(text: str, out_pdf: Path, font_size: float = 10.0):
|
||||||
|
"""Crée un PDF à partir de texte brut, avec pagination automatique."""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
doc = fitz.open()
|
||||||
|
# A4
|
||||||
|
page_w, page_h = 595, 842
|
||||||
|
margin = 50
|
||||||
|
usable_h = page_h - 2 * margin
|
||||||
|
line_height = font_size * 1.4
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
page = doc.new_page(width=page_w, height=page_h)
|
||||||
|
y = margin
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if y + line_height > page_h - margin:
|
||||||
|
# Nouvelle page
|
||||||
|
page = doc.new_page(width=page_w, height=page_h)
|
||||||
|
y = margin
|
||||||
|
|
||||||
|
# Tronquer les lignes trop longues
|
||||||
|
max_chars = int((page_w - 2 * margin) / (font_size * 0.5))
|
||||||
|
display_line = line[:max_chars] if len(line) > max_chars else line
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.insert_text(
|
||||||
|
fitz.Point(margin, y + font_size),
|
||||||
|
display_line,
|
||||||
|
fontsize=font_size,
|
||||||
|
fontname="helv",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback pour les caractères non supportés
|
||||||
|
safe = display_line.encode("latin-1", errors="replace").decode("latin-1")
|
||||||
|
page.insert_text(
|
||||||
|
fitz.Point(margin, y + font_size),
|
||||||
|
safe,
|
||||||
|
fontsize=font_size,
|
||||||
|
fontname="helv",
|
||||||
|
)
|
||||||
|
y += line_height
|
||||||
|
|
||||||
|
doc.save(str(out_pdf))
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _docx_to_pdf(docx_path: Path, out_pdf: Path):
|
||||||
|
"""Extrait le texte d'un DOCX et crée un PDF."""
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
doc = Document(str(docx_path))
|
||||||
|
paragraphs = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
paragraphs.append(para.text)
|
||||||
|
|
||||||
|
# Extraire aussi les tableaux
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
cells = [cell.text.strip() for cell in row.cells]
|
||||||
|
paragraphs.append(" | ".join(cells))
|
||||||
|
|
||||||
|
text = "\n".join(paragraphs)
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("Document DOCX vide ou illisible")
|
||||||
|
|
||||||
|
_text_to_pdf_pages(text, out_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def _odt_to_pdf(odt_path: Path, out_pdf: Path):
|
||||||
|
"""Extrait le texte d'un ODT et crée un PDF."""
|
||||||
|
from odf.opendocument import load as odf_load
|
||||||
|
from odf.text import P as OdfP
|
||||||
|
from odf import teletype
|
||||||
|
|
||||||
|
doc = odf_load(str(odt_path))
|
||||||
|
paragraphs = []
|
||||||
|
for p in doc.getElementsByType(OdfP):
|
||||||
|
paragraphs.append(teletype.extractText(p))
|
||||||
|
|
||||||
|
text = "\n".join(paragraphs)
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("Document ODT vide ou illisible")
|
||||||
|
|
||||||
|
_text_to_pdf_pages(text, out_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def _rtf_to_pdf(rtf_path: Path, out_pdf: Path):
|
||||||
|
"""Extrait le texte d'un RTF et crée un PDF."""
|
||||||
|
from striprtf.striprtf import rtf_to_text
|
||||||
|
|
||||||
|
raw = rtf_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
text = rtf_to_text(raw)
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("Document RTF vide ou illisible")
|
||||||
|
|
||||||
|
_text_to_pdf_pages(text, out_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def _txt_to_pdf(txt_path: Path, out_pdf: Path):
|
||||||
|
"""Convertit un fichier texte brut en PDF."""
|
||||||
|
# Tenter plusieurs encodages
|
||||||
|
for enc in ("utf-8", "latin-1", "cp1252"):
|
||||||
|
try:
|
||||||
|
text = txt_path.read_text(encoding=enc)
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
text = txt_path.read_bytes().decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("Fichier texte vide")
|
||||||
|
|
||||||
|
_text_to_pdf_pages(text, out_pdf)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_pdf(html_path: Path, out_pdf: Path):
|
||||||
|
"""Extrait le texte d'un fichier HTML et crée un PDF."""
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
raw = html_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
soup = BeautifulSoup(raw, "html.parser")
|
||||||
|
|
||||||
|
# Supprimer scripts et styles
|
||||||
|
for tag in soup(["script", "style"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = soup.get_text(separator="\n")
|
||||||
|
# Nettoyer les lignes vides multiples
|
||||||
|
import re
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
raise RuntimeError("Document HTML vide ou illisible")
|
||||||
|
|
||||||
|
_text_to_pdf_pages(text, out_pdf)
|
||||||
374
launcher.py
Normal file
374
launcher.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Launcher Windows — single-instance + download models on first run + launch GUI."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Single-instance guard (lock file in user's temp directory)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_lock_file = None
|
||||||
|
_lock_fd = None
|
||||||
|
|
||||||
|
def _ensure_single_instance():
|
||||||
|
"""Prevent multiple instances using a lock file.
|
||||||
|
Works reliably on Windows and Linux, including PyInstaller --onefile."""
|
||||||
|
global _lock_file, _lock_fd
|
||||||
|
import tempfile
|
||||||
|
_lock_file = Path(tempfile.gettempdir()) / "anonymisation_chcb.lock"
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import msvcrt
|
||||||
|
_lock_fd = open(_lock_file, "w")
|
||||||
|
msvcrt.locking(_lock_fd.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
_lock_fd = open(_lock_file, "w")
|
||||||
|
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
return True
|
||||||
|
except (OSError, IOError):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path resolution for PyInstaller frozen exe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
APP_DIR = Path(sys._MEIPASS)
|
||||||
|
EXE_DIR = Path(sys.executable).parent
|
||||||
|
else:
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
EXE_DIR = APP_DIR
|
||||||
|
|
||||||
|
# Log file next to the exe
|
||||||
|
LOG_FILE = EXE_DIR / "anonymisation.log"
|
||||||
|
logging.basicConfig(
|
||||||
|
filename=str(LOG_FILE),
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("launcher")
|
||||||
|
|
||||||
|
# Make embedded modules importable
|
||||||
|
sys.path.insert(0, str(APP_DIR))
|
||||||
|
os.chdir(str(APP_DIR))
|
||||||
|
|
||||||
|
log.info(f"APP_DIR={APP_DIR}")
|
||||||
|
log.info(f"EXE_DIR={EXE_DIR}")
|
||||||
|
log.info(f"frozen={getattr(sys, 'frozen', False)}")
|
||||||
|
|
||||||
|
MODELS_DIR = APP_DIR / "models"
|
||||||
|
|
||||||
|
|
||||||
|
def check_models_ready():
|
||||||
|
"""Check that the CamemBERT-bio ONNX model is present."""
|
||||||
|
onnx_path = MODELS_DIR / "camembert-bio-deid" / "onnx" / "model.onnx"
|
||||||
|
ok = onnx_path.exists()
|
||||||
|
log.info(f"CamemBERT ONNX present: {ok} ({onnx_path})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def launch_gui():
|
||||||
|
"""Launch the main GUI with a splash screen during the slow core import.
|
||||||
|
|
||||||
|
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:
|
||||||
|
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 — 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 — 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="Préparation des modèles d'intelligence artificielle",
|
||||||
|
font=("", 13, "bold")).pack(pady=(0, 4))
|
||||||
|
ttk.Label(
|
||||||
|
frame,
|
||||||
|
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))
|
||||||
|
|
||||||
|
# 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="Démarrage…")
|
||||||
|
ttk.Label(frame, textvariable=self.status_var, foreground="#1a568e").pack(pady=(0, 12))
|
||||||
|
|
||||||
|
# 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.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("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._set_step("eds_pseudo", "ok")
|
||||||
|
log.info("EDS-Pseudo OK")
|
||||||
|
except Exception as 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("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._set_step("gliner", "ok")
|
||||||
|
log.info("GLiNER OK")
|
||||||
|
except Exception as 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("Vérification CamemBERT-bio ONNX (modèle embarqué)…")
|
||||||
|
self._set_step("camembert_onnx", "running")
|
||||||
|
if check_models_ready():
|
||||||
|
self._set_step("camembert_onnx", "ok")
|
||||||
|
else:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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 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):
|
||||||
|
try:
|
||||||
|
self.root.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
launch_gui()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=== Demarrage Anonymisation ===")
|
||||||
|
|
||||||
|
# Single-instance check
|
||||||
|
if not _ensure_single_instance():
|
||||||
|
log.warning("Another instance is already running. Exiting.")
|
||||||
|
try:
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Anonymisation",
|
||||||
|
"L'application est deja en cours d'execution.\n\n"
|
||||||
|
"Regardez dans la barre des taches.",
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if check_models_ready():
|
||||||
|
launch_gui()
|
||||||
|
else:
|
||||||
|
setup = SetupWindow()
|
||||||
|
setup.run()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Fatal error: {e}\n{traceback.format_exc()}")
|
||||||
|
try:
|
||||||
|
messagebox.showerror("Erreur fatale", f"{e}\n\nVoir {LOG_FILE}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,27 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Batch anonymisation de PDFs pour enrichir le dataset silver.
|
"""Batch anonymisation parallèle de PDFs pour enrichir le dataset silver.
|
||||||
|
|
||||||
Traite TOUS les PDFs disponibles (excluant ceux déjà dans audit_30) en mode CPU
|
Traite TOUS les PDFs disponibles en mode CPU (sans VLM), avec N workers
|
||||||
uniquement (sans VLM) pour générer des .pseudonymise.txt utilisables par
|
parallèles. Chaque worker charge ses propres modèles NER.
|
||||||
export_silver_annotations.py.
|
|
||||||
|
|
||||||
Timeout par fichier pour éviter les blocages sur les gros documents.
|
|
||||||
Reprend automatiquement là où il s'est arrêté (skip les déjà traités).
|
Reprend automatiquement là où il s'est arrêté (skip les déjà traités).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run_batch_silver_export.py # 6 workers (défaut)
|
||||||
|
python run_batch_silver_export.py --workers 4 # 4 workers
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import signal
|
import argparse
|
||||||
import random
|
import multiprocessing as mp
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
import anonymizer_core_refactored_onnx as core
|
|
||||||
from eds_pseudo_manager import EdsPseudoManager
|
|
||||||
from gliner_manager import GlinerManager
|
|
||||||
from camembert_ner_manager import CamembertNerManager
|
|
||||||
|
|
||||||
SRC = Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)")
|
SRC = Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)")
|
||||||
OUTDIR = SRC / "anonymise_silver_extra"
|
OUTDIR = SRC / "anonymise_silver_extra"
|
||||||
CONFIG = Path("/home/dom/ai/anonymisation/config/dictionnaires.yml")
|
CONFIG = Path("/home/dom/ai/anonymisation/config/dictionnaires.yml")
|
||||||
@@ -62,16 +59,116 @@ ALREADY_DONE_AUDIT30 = {
|
|||||||
|
|
||||||
TIMEOUT_PER_FILE = 120 # secondes max par PDF
|
TIMEOUT_PER_FILE = 120 # secondes max par PDF
|
||||||
|
|
||||||
|
# Variables globales par worker (initialisées une seule fois)
|
||||||
class TimeoutError(Exception):
|
_worker_ner = None
|
||||||
pass
|
_worker_gliner = None
|
||||||
|
_worker_camembert = None
|
||||||
|
_worker_id = None
|
||||||
|
|
||||||
|
|
||||||
def timeout_handler(signum, frame):
|
def init_worker(worker_id):
|
||||||
|
"""Initialise les modèles NER dans chaque worker (appelé une seule fois)."""
|
||||||
|
global _worker_ner, _worker_gliner, _worker_camembert, _worker_id
|
||||||
|
_worker_id = worker_id
|
||||||
|
|
||||||
|
# Limiter les threads ONNX/OpenMP par worker pour éviter la contention
|
||||||
|
n_threads = max(2, 32 // (mp.cpu_count() // 2)) # répartir équitablement
|
||||||
|
os.environ["OMP_NUM_THREADS"] = str(n_threads)
|
||||||
|
os.environ["MKL_NUM_THREADS"] = str(n_threads)
|
||||||
|
|
||||||
|
import anonymizer_core_refactored_onnx as core # noqa: F401
|
||||||
|
from eds_pseudo_manager import EdsPseudoManager
|
||||||
|
from gliner_manager import GlinerManager
|
||||||
|
from camembert_ner_manager import CamembertNerManager
|
||||||
|
|
||||||
|
_worker_ner = EdsPseudoManager()
|
||||||
|
_worker_ner.load()
|
||||||
|
print(f" [W{worker_id}] EDS-Pseudo chargé", flush=True)
|
||||||
|
|
||||||
|
_worker_gliner = GlinerManager()
|
||||||
|
try:
|
||||||
|
_worker_gliner.load()
|
||||||
|
print(f" [W{worker_id}] GLiNER chargé", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [W{worker_id}] GLiNER indisponible ({e})", flush=True)
|
||||||
|
_worker_gliner = None
|
||||||
|
|
||||||
|
_worker_camembert = CamembertNerManager()
|
||||||
|
try:
|
||||||
|
_worker_camembert.load()
|
||||||
|
print(f" [W{worker_id}] CamemBERT-bio chargé", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [W{worker_id}] CamemBERT-bio indisponible ({e})", flush=True)
|
||||||
|
_worker_camembert = None
|
||||||
|
|
||||||
|
print(f" [W{worker_id}] Prêt (threads={n_threads})", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def process_one_pdf(args):
|
||||||
|
"""Traite un seul PDF. Appelé par le pool de workers."""
|
||||||
|
pdf_path, idx, total = args
|
||||||
|
import signal
|
||||||
|
import anonymizer_core_refactored_onnx as core
|
||||||
|
|
||||||
|
ogc = pdf_path.parent.name.split("_")[0]
|
||||||
|
|
||||||
|
# Timeout via alarm
|
||||||
|
def _timeout_handler(signum, frame):
|
||||||
raise TimeoutError("Timeout")
|
raise TimeoutError("Timeout")
|
||||||
|
|
||||||
|
signal.signal(signal.SIGALRM, _timeout_handler)
|
||||||
|
signal.alarm(TIMEOUT_PER_FILE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
core.process_pdf(
|
||||||
|
pdf_path=pdf_path,
|
||||||
|
out_dir=OUTDIR,
|
||||||
|
make_vector_redaction=False,
|
||||||
|
also_make_raster_burn=False,
|
||||||
|
config_path=CONFIG,
|
||||||
|
use_hf=True,
|
||||||
|
ner_manager=_worker_ner,
|
||||||
|
ner_thresholds=None,
|
||||||
|
ogc_label=ogc,
|
||||||
|
vlm_manager=None,
|
||||||
|
gliner_manager=_worker_gliner,
|
||||||
|
camembert_manager=_worker_camembert,
|
||||||
|
)
|
||||||
|
signal.alarm(0)
|
||||||
|
return ("OK", pdf_path.name, idx, total)
|
||||||
|
except TimeoutError:
|
||||||
|
signal.alarm(0)
|
||||||
|
return ("TIMEOUT", pdf_path.name, idx, total)
|
||||||
|
except Exception as e:
|
||||||
|
signal.alarm(0)
|
||||||
|
err = str(e)
|
||||||
|
if "encrypted" in err.lower() or "password" in err.lower():
|
||||||
|
return ("SKIP", pdf_path.name, idx, total)
|
||||||
|
return ("ERROR", pdf_path.name, idx, total, str(e)[:100])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Batch silver export parallèle")
|
||||||
|
parser.add_argument("--workers", type=int, default=6,
|
||||||
|
help="Nombre de workers parallèles (défaut: 6)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
n_workers = args.workers
|
||||||
|
|
||||||
|
# Vérification des ressources (RAM surtout — chaque worker charge ~4 Go de modèles NER)
|
||||||
|
from scripts.check_resources import require_resources
|
||||||
|
ram_needed = n_workers * 4
|
||||||
|
print(f"Vérification des ressources ({n_workers} workers × ~4 Go = ~{ram_needed} Go RAM)...")
|
||||||
|
try:
|
||||||
|
status = require_resources(ram_free_gb=ram_needed)
|
||||||
|
print(f" RAM OK : {status.ram_available_gb:.1f} Go disponible")
|
||||||
|
if status.gpu_available:
|
||||||
|
print(f" GPU : {status.gpu_name}, {status.vram_free_mb} Mo VRAM libre")
|
||||||
|
print()
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"\n{e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Collecter tous les PDFs disponibles (excluant audit_30)
|
# Collecter tous les PDFs disponibles (excluant audit_30)
|
||||||
all_pdfs = []
|
all_pdfs = []
|
||||||
for ogc_dir in sorted(SRC.iterdir()):
|
for ogc_dir in sorted(SRC.iterdir()):
|
||||||
@@ -81,7 +178,6 @@ def main():
|
|||||||
if pdf.name not in ALREADY_DONE_AUDIT30:
|
if pdf.name not in ALREADY_DONE_AUDIT30:
|
||||||
all_pdfs.append(pdf)
|
all_pdfs.append(pdf)
|
||||||
|
|
||||||
# Trier par OGC pour reproductibilité
|
|
||||||
all_pdfs.sort(key=lambda p: (p.parent.name, p.name))
|
all_pdfs.sort(key=lambda p: (p.parent.name, p.name))
|
||||||
|
|
||||||
# Détecter les fichiers déjà traités (reprise)
|
# Détecter les fichiers déjà traités (reprise)
|
||||||
@@ -95,96 +191,73 @@ def main():
|
|||||||
print(f"PDFs disponibles: {len(all_pdfs)} (excl. audit_30)")
|
print(f"PDFs disponibles: {len(all_pdfs)} (excl. audit_30)")
|
||||||
print(f"Déjà traités: {len(already_done)}")
|
print(f"Déjà traités: {len(already_done)}")
|
||||||
print(f"Restant: {len(pdfs_to_do)}")
|
print(f"Restant: {len(pdfs_to_do)}")
|
||||||
|
print(f"Workers: {n_workers}")
|
||||||
|
print(f"RAM par worker: ~4 Go (NER models)")
|
||||||
|
print(f"RAM totale estimée: ~{n_workers * 4} Go\n")
|
||||||
|
|
||||||
if not pdfs_to_do:
|
if not pdfs_to_do:
|
||||||
print("Rien à faire.")
|
print("Rien à faire.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Chargement des modèles NER (CPU uniquement, pas de VLM)
|
# Préparer les arguments : (pdf_path, index, total)
|
||||||
print("\nChargement EDS-Pseudo...", flush=True)
|
tasks = [(pdf, i, len(pdfs_to_do)) for i, pdf in enumerate(pdfs_to_do, 1)]
|
||||||
ner = EdsPseudoManager()
|
|
||||||
ner.load()
|
|
||||||
assert ner.is_loaded(), "EDS-Pseudo non chargé"
|
|
||||||
print("EDS-Pseudo chargé.", flush=True)
|
|
||||||
|
|
||||||
print("Chargement GLiNER...", flush=True)
|
print(f"Chargement des modèles dans {n_workers} workers...", flush=True)
|
||||||
gliner = GlinerManager()
|
|
||||||
try:
|
|
||||||
gliner.load()
|
|
||||||
print("GLiNER chargé.", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"GLiNER indisponible ({e}), on continue sans.", flush=True)
|
|
||||||
gliner = None
|
|
||||||
|
|
||||||
print("Chargement CamemBERT-bio ONNX...", flush=True)
|
# Créer le pool avec initialisation des modèles par worker
|
||||||
camembert = CamembertNerManager()
|
# On utilise mp.Pool avec initializer pour charger les modèles une seule fois
|
||||||
try:
|
# Note: fork + ONNX peut poser problème, on utilise 'spawn'
|
||||||
camembert.load()
|
ctx = mp.get_context("spawn")
|
||||||
print("CamemBERT-bio ONNX chargé.", flush=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"CamemBERT-bio indisponible ({e}), on continue sans.", flush=True)
|
|
||||||
camembert = None
|
|
||||||
|
|
||||||
print(f"\nPas de VLM (CPU only pour silver export).\n", flush=True)
|
|
||||||
|
|
||||||
ok = ko = skip_encrypted = skip_timeout = 0
|
ok = ko = skip_encrypted = skip_timeout = 0
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
total = len(pdfs_to_do)
|
|
||||||
|
|
||||||
for i, pdf in enumerate(pdfs_to_do, 1):
|
# Lancer les workers séquentiellement pour l'init (éviter pic mémoire)
|
||||||
ogc = pdf.parent.name.split("_")[0]
|
# puis traiter en parallèle
|
||||||
print(f"[{i}/{total}] {pdf.name} (OGC {ogc})...", end=" ", flush=True)
|
with ctx.Pool(
|
||||||
|
processes=n_workers,
|
||||||
|
initializer=init_worker,
|
||||||
|
initargs=(0,), # worker_id simplifié
|
||||||
|
) as pool:
|
||||||
|
for result in pool.imap_unordered(process_one_pdf, tasks, chunksize=1):
|
||||||
|
status = result[0]
|
||||||
|
name = result[1]
|
||||||
|
idx = result[2]
|
||||||
|
total = result[3]
|
||||||
|
|
||||||
# Timeout par fichier
|
elapsed = time.time() - t0
|
||||||
signal.signal(signal.SIGALRM, timeout_handler)
|
done = ok + ko + skip_encrypted + skip_timeout + 1
|
||||||
signal.alarm(TIMEOUT_PER_FILE)
|
|
||||||
try:
|
if status == "OK":
|
||||||
core.process_pdf(
|
|
||||||
pdf_path=pdf,
|
|
||||||
out_dir=OUTDIR,
|
|
||||||
make_vector_redaction=False,
|
|
||||||
also_make_raster_burn=False,
|
|
||||||
config_path=CONFIG,
|
|
||||||
use_hf=True,
|
|
||||||
ner_manager=ner,
|
|
||||||
ner_thresholds=None,
|
|
||||||
ogc_label=ogc,
|
|
||||||
vlm_manager=None,
|
|
||||||
gliner_manager=gliner,
|
|
||||||
camembert_manager=camembert,
|
|
||||||
)
|
|
||||||
signal.alarm(0)
|
|
||||||
elapsed_file = time.time() - t0
|
|
||||||
rate = ok / elapsed_file * 3600 if elapsed_file > 0 and ok > 0 else 0
|
|
||||||
print(f"OK ({rate:.0f}/h)", flush=True)
|
|
||||||
ok += 1
|
ok += 1
|
||||||
except TimeoutError:
|
rate = ok / elapsed * 3600 if elapsed > 0 else 0
|
||||||
signal.alarm(0)
|
print(f"[{done}/{total}] {name} OK ({rate:.0f}/h)", flush=True)
|
||||||
print(f"TIMEOUT ({TIMEOUT_PER_FILE}s)", flush=True)
|
elif status == "TIMEOUT":
|
||||||
skip_timeout += 1
|
skip_timeout += 1
|
||||||
except Exception as e:
|
print(f"[{done}/{total}] {name} TIMEOUT", flush=True)
|
||||||
signal.alarm(0)
|
elif status == "SKIP":
|
||||||
err = str(e)
|
|
||||||
if "encrypted" in err.lower() or "password" in err.lower():
|
|
||||||
print("SKIP (chiffré)", flush=True)
|
|
||||||
skip_encrypted += 1
|
skip_encrypted += 1
|
||||||
|
print(f"[{done}/{total}] {name} SKIP (chiffré)", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f"ERREUR: {e}", flush=True)
|
|
||||||
ko += 1
|
ko += 1
|
||||||
|
err_msg = result[4] if len(result) > 4 else "?"
|
||||||
|
print(f"[{done}/{total}] {name} ERREUR: {err_msg}", flush=True)
|
||||||
|
|
||||||
# Rapport intermédiaire toutes les 50 fichiers
|
# Rapport intermédiaire toutes les 50 fichiers
|
||||||
if i % 50 == 0:
|
if done % 50 == 0:
|
||||||
elapsed = time.time() - t0
|
remaining = (elapsed / done) * (total - done)
|
||||||
remaining = (elapsed / i) * (total - i)
|
print(f"\n --- Progression: {done}/{total} | OK: {ok} | "
|
||||||
print(f"\n --- Progression: {i}/{total} | OK: {ok} | "
|
|
||||||
f"Erreurs: {ko} | Timeout: {skip_timeout} | "
|
f"Erreurs: {ko} | Timeout: {skip_timeout} | "
|
||||||
f"Temps restant estimé: {remaining/60:.0f}min ---\n", flush=True)
|
f"Débit: {ok/elapsed*3600:.0f}/h | "
|
||||||
|
f"Restant: {remaining/60:.0f}min ---\n", flush=True)
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
|
total_pseudo = len(list(OUTDIR.glob("*.pseudonymise.txt")))
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f"Terminé en {elapsed:.0f}s ({elapsed/60:.1f}min)")
|
print(f"Terminé en {elapsed:.0f}s ({elapsed/60:.1f}min)")
|
||||||
print(f"OK: {ok}, Chiffrés: {skip_encrypted}, Timeout: {skip_timeout}, Erreurs: {ko}")
|
print(f"OK: {ok}, Chiffrés: {skip_encrypted}, Timeout: {skip_timeout}, Erreurs: {ko}")
|
||||||
print(f"Total .pseudonymise.txt: {len(list(OUTDIR.glob('*.pseudonymise.txt')))}")
|
print(f"Total .pseudonymise.txt: {total_pseudo}")
|
||||||
|
print(f"Débit moyen: {ok/elapsed*3600:.0f} fichiers/h")
|
||||||
print(f"Sortie: {OUTDIR}")
|
print(f"Sortie: {OUTDIR}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ def main():
|
|||||||
if len(row) < 16:
|
if len(row) < 16:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Numéro FINESS (col 1)
|
# Numéros FINESS : col 1 = finess_et (structure), col 2 = entjur (entité juridique).
|
||||||
finess = row[1].strip()
|
# Les deux sont des identifiants 9 chiffres réels du référentiel FINESS et doivent
|
||||||
|
# être masqués. Avant ce fix, seul finess_et était extrait (~102k), et les ~48k
|
||||||
|
# entjur étaient manqués — provoquant des fuites (ex: 640780417 entjur CHCB).
|
||||||
|
for col_idx in (1, 2):
|
||||||
|
finess = row[col_idx].strip() if col_idx < len(row) else ""
|
||||||
if re.match(r"^\d{9}$", finess):
|
if re.match(r"^\d{9}$", finess):
|
||||||
finess_numbers.add(finess)
|
finess_numbers.add(finess)
|
||||||
|
|
||||||
@@ -190,6 +194,93 @@ def main():
|
|||||||
out.write_text("\n".join(sorted(phones)) + "\n", encoding="utf-8")
|
out.write_text("\n".join(sorted(phones)) + "\n", encoding="utf-8")
|
||||||
print(f" → {out.name}: {len(phones)} entrées")
|
print(f" → {out.name}: {len(phones)} entrées")
|
||||||
|
|
||||||
|
# 6. Adresses FINESS (type_voie + nom_voie) pour Aho-Corasick
|
||||||
|
# Mapping des codes type_voie FINESS vers formes étendues
|
||||||
|
TYPE_VOIE_MAP = {
|
||||||
|
"AV": "avenue", "R": "rue", "BD": "boulevard", "RTE": "route",
|
||||||
|
"CHE": "chemin", "PL": "place", "IMP": "impasse", "ALL": "allee",
|
||||||
|
"SQ": "square", "PASS": "passage", "QU": "quai", "CRS": "cours",
|
||||||
|
"SEN": "sentier", "RPT": "rond-point", "LD": "lieu-dit",
|
||||||
|
"HAM": "hameau", "LOT": "lotissement", "TSSE": "traverse",
|
||||||
|
"CHEM": "chemin", "RES": "residence", "CTRE": "centre",
|
||||||
|
"ESP": "esplanade", "PRO": "promenade", "MTE": "montee",
|
||||||
|
"VOI": "voie", "CAR": "carrefour", "FBG": "faubourg",
|
||||||
|
}
|
||||||
|
# Charger les prénoms INSEE pour générer des variantes abrégées
|
||||||
|
prenoms_path = Path(__file__).parent.parent / "data" / "insee" / "prenoms_france.txt"
|
||||||
|
prenoms_set = set()
|
||||||
|
if prenoms_path.exists():
|
||||||
|
for line in prenoms_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
p = line.strip().lower()
|
||||||
|
if p and len(p) >= 3:
|
||||||
|
prenoms_set.add(p)
|
||||||
|
print(f" Prénoms INSEE chargés: {len(prenoms_set)}")
|
||||||
|
|
||||||
|
VOIE_GENERIC = {
|
||||||
|
"de", "du", "des", "la", "le", "les", "l", "et", "en", "au", "aux",
|
||||||
|
"a", "sur", "sous", "par", "pour", "dans", "rue", "avenue", "boulevard",
|
||||||
|
"route", "chemin", "place", "impasse", "square", "passage", "quai", "cours",
|
||||||
|
"grande", "grand", "petit", "petite", "vieux", "vieille", "nouveau", "nouvelle",
|
||||||
|
"haut", "haute", "bas", "basse",
|
||||||
|
}
|
||||||
|
|
||||||
|
addr_patterns = set()
|
||||||
|
|
||||||
|
def _add_with_abbrev(pattern: str):
|
||||||
|
"""Ajoute le pattern + variantes avec prénoms abrégés (initiale seule)."""
|
||||||
|
addr_patterns.add(pattern)
|
||||||
|
words = pattern.split()
|
||||||
|
for i, w in enumerate(words):
|
||||||
|
if w in prenoms_set and len(w) >= 3:
|
||||||
|
# Variante avec initiale seule — seulement si un mot distinctif suit
|
||||||
|
remaining = words[i+1:]
|
||||||
|
if not remaining or all(len(r) <= 2 or r in VOIE_GENERIC for r in remaining):
|
||||||
|
continue # Pas d'abréviation si rien de distinctif après
|
||||||
|
abbrev_words = words[:i] + [w[0]] + words[i+1:]
|
||||||
|
abbrev = " ".join(abbrev_words)
|
||||||
|
# Minimum 12 chars, et le pattern ne doit pas commencer par une initiale seule
|
||||||
|
if len(abbrev) >= 12 and len(abbrev_words[0]) >= 2:
|
||||||
|
addr_patterns.add(abbrev)
|
||||||
|
|
||||||
|
with open(csv_path, encoding="utf-8") as f:
|
||||||
|
reader = csv.reader(f, delimiter=";")
|
||||||
|
next(reader)
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 10:
|
||||||
|
continue
|
||||||
|
type_voie_raw = row[8].strip() if len(row) > 8 else ""
|
||||||
|
nom_voie = row[9].strip() if len(row) > 9 else ""
|
||||||
|
if not nom_voie or len(nom_voie) < 3:
|
||||||
|
continue
|
||||||
|
nom_norm = normalize(nom_voie)
|
||||||
|
words = nom_norm.split()
|
||||||
|
|
||||||
|
# Pattern complet : type_voie + nom_voie (ex: "avenue de l interne jacques loeb")
|
||||||
|
type_voie_expanded = TYPE_VOIE_MAP.get(type_voie_raw.upper(), type_voie_raw.lower())
|
||||||
|
if type_voie_expanded and nom_norm:
|
||||||
|
full = f"{type_voie_expanded} {nom_norm}"
|
||||||
|
full_words = full.split()
|
||||||
|
has_distinctive = any(
|
||||||
|
w not in VOIE_GENERIC and len(w) >= 4 for w in full_words
|
||||||
|
)
|
||||||
|
if has_distinctive and len(full) >= 12:
|
||||||
|
_add_with_abbrev(full)
|
||||||
|
|
||||||
|
# Pattern nom_voie seul (seulement si très distinctif)
|
||||||
|
has_distinctive = any(w not in VOIE_GENERIC and len(w) >= 4 for w in words)
|
||||||
|
if has_distinctive and len(nom_norm) >= 15:
|
||||||
|
_add_with_abbrev(nom_norm)
|
||||||
|
|
||||||
|
out = OUT_DIR / "adresses_finess.txt"
|
||||||
|
out.write_text("\n".join(sorted(addr_patterns)) + "\n", encoding="utf-8")
|
||||||
|
print(f"\n → {out.name}: {len(addr_patterns)} entrées")
|
||||||
|
|
||||||
|
# Garder aussi voies_distinctives.txt pour compatibilité
|
||||||
|
voie_names = {p for p in addr_patterns if len(p) >= 15}
|
||||||
|
out = OUT_DIR / "voies_distinctives.txt"
|
||||||
|
out.write_text("\n".join(sorted(voie_names)) + "\n", encoding="utf-8")
|
||||||
|
print(f" → {out.name}: {len(voie_names)} entrées")
|
||||||
|
|
||||||
# Stats par longueur
|
# Stats par longueur
|
||||||
print(f"\nDistribution noms distinctifs par longueur (mots):")
|
print(f"\nDistribution noms distinctifs par longueur (mots):")
|
||||||
word_counts = Counter(len(n.split()) for n in filtered_distinctive)
|
word_counts = Counter(len(n.split()) for n in filtered_distinctive)
|
||||||
|
|||||||
347
scripts/check_resources.py
Normal file
347
scripts/check_resources.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Vérification des ressources machine (GPU, RAM, CPU) avant exécution.
|
||||||
|
|
||||||
|
Utilisable comme module ou en standalone :
|
||||||
|
from scripts.check_resources import check_resources, require_resources
|
||||||
|
|
||||||
|
# Vérification simple (lève RuntimeError si insuffisant)
|
||||||
|
require_resources(vram_free_mb=2000, ram_free_gb=4)
|
||||||
|
|
||||||
|
# Vérification informative (retourne un dict)
|
||||||
|
status = check_resources()
|
||||||
|
print(status)
|
||||||
|
|
||||||
|
# En standalone
|
||||||
|
python scripts/check_resources.py
|
||||||
|
python scripts/check_resources.py --vram 2000 --ram 4 --wait
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GpuProcess:
|
||||||
|
pid: int
|
||||||
|
name: str
|
||||||
|
vram_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResourceStatus:
|
||||||
|
# GPU
|
||||||
|
gpu_available: bool = False
|
||||||
|
gpu_name: str = ""
|
||||||
|
vram_total_mb: int = 0
|
||||||
|
vram_used_mb: int = 0
|
||||||
|
vram_free_mb: int = 0
|
||||||
|
gpu_util_pct: int = 0
|
||||||
|
gpu_processes: List[GpuProcess] = field(default_factory=list)
|
||||||
|
# RAM
|
||||||
|
ram_total_gb: float = 0.0
|
||||||
|
ram_used_gb: float = 0.0
|
||||||
|
ram_free_gb: float = 0.0
|
||||||
|
ram_available_gb: float = 0.0
|
||||||
|
# CPU
|
||||||
|
cpu_count: int = 0
|
||||||
|
load_avg_1m: float = 0.0
|
||||||
|
load_avg_5m: float = 0.0
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = []
|
||||||
|
lines.append("=" * 55)
|
||||||
|
lines.append(" ÉTAT DES RESSOURCES MACHINE")
|
||||||
|
lines.append("=" * 55)
|
||||||
|
|
||||||
|
# GPU
|
||||||
|
if self.gpu_available:
|
||||||
|
lines.append(f"\n GPU : {self.gpu_name}")
|
||||||
|
lines.append(f" VRAM totale : {self.vram_total_mb} Mo")
|
||||||
|
lines.append(f" VRAM utilisée: {self.vram_used_mb} Mo ({self._pct(self.vram_used_mb, self.vram_total_mb)}%)")
|
||||||
|
lines.append(f" VRAM libre : {self.vram_free_mb} Mo")
|
||||||
|
lines.append(f" Utilisation : {self.gpu_util_pct}%")
|
||||||
|
if self.gpu_processes:
|
||||||
|
lines.append(f" Processus GPU ({len(self.gpu_processes)}) :")
|
||||||
|
for p in self.gpu_processes:
|
||||||
|
short_name = p.name.split("/")[-1] if "/" in p.name else p.name
|
||||||
|
project = self._guess_project(p.name)
|
||||||
|
label = f" ({project})" if project else ""
|
||||||
|
lines.append(f" PID {p.pid}: {short_name}{label} — {p.vram_mb} Mo")
|
||||||
|
else:
|
||||||
|
lines.append(" Aucun processus GPU actif")
|
||||||
|
else:
|
||||||
|
lines.append("\n GPU : non disponible (nvidia-smi absent)")
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
lines.append(f"\n RAM : {self.ram_total_gb:.1f} Go total")
|
||||||
|
lines.append(f" Utilisée : {self.ram_used_gb:.1f} Go")
|
||||||
|
lines.append(f" Disponible : {self.ram_available_gb:.1f} Go")
|
||||||
|
|
||||||
|
# CPU
|
||||||
|
lines.append(f"\n CPU : {self.cpu_count} cœurs")
|
||||||
|
lines.append(f" Load avg : {self.load_avg_1m:.1f} (1m) / {self.load_avg_5m:.1f} (5m)")
|
||||||
|
|
||||||
|
lines.append("=" * 55)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pct(used: int, total: int) -> int:
|
||||||
|
return round(used * 100 / total) if total > 0 else 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _guess_project(path: str) -> str:
|
||||||
|
"""Devine le projet à partir du chemin du processus."""
|
||||||
|
parts = path.split("/")
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p == "ai" and i + 1 < len(parts):
|
||||||
|
return parts[i + 1].split(".")[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_resources() -> ResourceStatus:
|
||||||
|
"""Collecte l'état actuel des ressources machine."""
|
||||||
|
status = ResourceStatus()
|
||||||
|
|
||||||
|
# --- GPU ---
|
||||||
|
if shutil.which("nvidia-smi"):
|
||||||
|
status.gpu_available = True
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu",
|
||||||
|
"--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if out.returncode == 0:
|
||||||
|
parts = [p.strip() for p in out.stdout.strip().split(",")]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
status.gpu_name = parts[0]
|
||||||
|
status.vram_total_mb = int(parts[1])
|
||||||
|
status.vram_used_mb = int(parts[2])
|
||||||
|
status.vram_free_mb = int(parts[3])
|
||||||
|
status.gpu_util_pct = int(parts[4])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Processus GPU
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-compute-apps=pid,process_name,used_gpu_memory",
|
||||||
|
"--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if out.returncode == 0 and out.stdout.strip():
|
||||||
|
for line in out.stdout.strip().splitlines():
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
status.gpu_processes.append(GpuProcess(
|
||||||
|
pid=int(parts[0]),
|
||||||
|
name=parts[1],
|
||||||
|
vram_mb=int(parts[2]),
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- RAM ---
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
meminfo = {}
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
key = parts[0].rstrip(":")
|
||||||
|
meminfo[key] = int(parts[1]) # en kB
|
||||||
|
|
||||||
|
status.ram_total_gb = meminfo.get("MemTotal", 0) / 1048576
|
||||||
|
status.ram_free_gb = meminfo.get("MemFree", 0) / 1048576
|
||||||
|
status.ram_available_gb = meminfo.get("MemAvailable", 0) / 1048576
|
||||||
|
status.ram_used_gb = status.ram_total_gb - status.ram_free_gb
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- CPU ---
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
status.cpu_count = os.cpu_count() or 0
|
||||||
|
load = os.getloadavg()
|
||||||
|
status.load_avg_1m = load[0]
|
||||||
|
status.load_avg_5m = load[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def require_resources(
|
||||||
|
vram_free_mb: int = 0,
|
||||||
|
ram_free_gb: float = 0,
|
||||||
|
max_gpu_util_pct: int = 100,
|
||||||
|
fail_if_gpu_busy: bool = False,
|
||||||
|
) -> ResourceStatus:
|
||||||
|
"""Vérifie que les ressources minimales sont disponibles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vram_free_mb: VRAM libre minimale requise (Mo). 0 = pas de vérification GPU.
|
||||||
|
ram_free_gb: RAM disponible minimale (Go).
|
||||||
|
max_gpu_util_pct: Utilisation GPU max tolérée (%).
|
||||||
|
fail_if_gpu_busy: Si True, échoue si d'autres processus utilisent le GPU.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResourceStatus si tout est ok.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError avec détails si ressources insuffisantes.
|
||||||
|
"""
|
||||||
|
status = check_resources()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if vram_free_mb > 0:
|
||||||
|
if not status.gpu_available:
|
||||||
|
errors.append(f"GPU requis ({vram_free_mb} Mo VRAM) mais nvidia-smi non disponible")
|
||||||
|
elif status.vram_free_mb < vram_free_mb:
|
||||||
|
procs = ""
|
||||||
|
if status.gpu_processes:
|
||||||
|
procs = "\n Processus occupant le GPU :"
|
||||||
|
for p in status.gpu_processes:
|
||||||
|
short = p.name.split("/")[-1]
|
||||||
|
project = status._guess_project(p.name)
|
||||||
|
label = f" ({project})" if project else ""
|
||||||
|
procs += f"\n PID {p.pid}: {short}{label} — {p.vram_mb} Mo"
|
||||||
|
errors.append(
|
||||||
|
f"VRAM insuffisante : {status.vram_free_mb} Mo libre, "
|
||||||
|
f"{vram_free_mb} Mo requis (utilisé: {status.vram_used_mb}/{status.vram_total_mb} Mo)"
|
||||||
|
f"{procs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if max_gpu_util_pct < 100 and status.gpu_available:
|
||||||
|
if status.gpu_util_pct > max_gpu_util_pct:
|
||||||
|
errors.append(
|
||||||
|
f"GPU trop chargé : {status.gpu_util_pct}% d'utilisation "
|
||||||
|
f"(max toléré: {max_gpu_util_pct}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fail_if_gpu_busy and status.gpu_processes:
|
||||||
|
names = [f"{p.name.split('/')[-1]} (PID {p.pid})" for p in status.gpu_processes]
|
||||||
|
errors.append(f"GPU occupé par : {', '.join(names)}")
|
||||||
|
|
||||||
|
if ram_free_gb > 0 and status.ram_available_gb < ram_free_gb:
|
||||||
|
errors.append(
|
||||||
|
f"RAM insuffisante : {status.ram_available_gb:.1f} Go disponible, "
|
||||||
|
f"{ram_free_gb:.1f} Go requis"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
msg = "Ressources insuffisantes :\n " + "\n ".join(errors)
|
||||||
|
msg += "\n\n" + status.summary()
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_resources(
|
||||||
|
vram_free_mb: int = 0,
|
||||||
|
ram_free_gb: float = 0,
|
||||||
|
max_gpu_util_pct: int = 100,
|
||||||
|
timeout_minutes: int = 30,
|
||||||
|
check_interval_seconds: int = 30,
|
||||||
|
) -> ResourceStatus:
|
||||||
|
"""Attend que les ressources soient disponibles (avec timeout).
|
||||||
|
|
||||||
|
Affiche un message périodique tant que les ressources sont insuffisantes.
|
||||||
|
Utile avant un fine-tuning ou un batch lourd.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResourceStatus quand les ressources sont disponibles.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError si le timeout est atteint.
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout_minutes * 60
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
status = require_resources(
|
||||||
|
vram_free_mb=vram_free_mb,
|
||||||
|
ram_free_gb=ram_free_gb,
|
||||||
|
max_gpu_util_pct=max_gpu_util_pct,
|
||||||
|
)
|
||||||
|
if attempt > 0:
|
||||||
|
print(f"\nRessources disponibles après {attempt * check_interval_seconds}s d'attente.")
|
||||||
|
return status
|
||||||
|
except RuntimeError as e:
|
||||||
|
attempt += 1
|
||||||
|
if attempt == 1:
|
||||||
|
print(f"En attente de ressources (timeout: {timeout_minutes}min)...")
|
||||||
|
print(f" Requis: VRAM >= {vram_free_mb} Mo, RAM >= {ram_free_gb} Go")
|
||||||
|
|
||||||
|
remaining = int((deadline - time.time()) / 60)
|
||||||
|
status = check_resources()
|
||||||
|
gpu_info = f"VRAM libre: {status.vram_free_mb} Mo" if status.gpu_available else "pas de GPU"
|
||||||
|
print(
|
||||||
|
f" [{attempt}] {gpu_info}, RAM dispo: {status.ram_available_gb:.1f} Go "
|
||||||
|
f"— encore {remaining}min max",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
time.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timeout ({timeout_minutes}min) : ressources toujours insuffisantes.\n"
|
||||||
|
+ check_resources().summary()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Vérification des ressources machine")
|
||||||
|
parser.add_argument("--vram", type=int, default=0,
|
||||||
|
help="VRAM libre minimale requise (Mo)")
|
||||||
|
parser.add_argument("--ram", type=float, default=0,
|
||||||
|
help="RAM disponible minimale (Go)")
|
||||||
|
parser.add_argument("--gpu-util", type=int, default=100,
|
||||||
|
help="Utilisation GPU max tolérée (%%)")
|
||||||
|
parser.add_argument("--wait", action="store_true",
|
||||||
|
help="Attendre que les ressources soient disponibles")
|
||||||
|
parser.add_argument("--timeout", type=int, default=30,
|
||||||
|
help="Timeout d'attente en minutes (défaut: 30)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Afficher l'état actuel
|
||||||
|
status = check_resources()
|
||||||
|
print(status.summary())
|
||||||
|
|
||||||
|
# Vérifier les seuils si demandés
|
||||||
|
if args.vram > 0 or args.ram > 0 or args.gpu_util < 100:
|
||||||
|
if args.wait:
|
||||||
|
try:
|
||||||
|
wait_for_resources(
|
||||||
|
vram_free_mb=args.vram,
|
||||||
|
ram_free_gb=args.ram,
|
||||||
|
max_gpu_util_pct=args.gpu_util,
|
||||||
|
timeout_minutes=args.timeout,
|
||||||
|
)
|
||||||
|
print("\nOK — ressources disponibles.")
|
||||||
|
except TimeoutError as e:
|
||||||
|
print(f"\nERREUR : {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
require_resources(
|
||||||
|
vram_free_mb=args.vram,
|
||||||
|
ram_free_gb=args.ram,
|
||||||
|
max_gpu_util_pct=args.gpu_util,
|
||||||
|
)
|
||||||
|
print("\nOK — ressources suffisantes.")
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"\nERREUR : {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -77,6 +77,9 @@ NAME_IGNORE = {
|
|||||||
"TRAITEMENT", "INTERVENTION", "OPERATOIRE", "RAPPORT",
|
"TRAITEMENT", "INTERVENTION", "OPERATOIRE", "RAPPORT",
|
||||||
"PATIENT", "MONSIEUR", "MADAME", "DOCTEUR",
|
"PATIENT", "MONSIEUR", "MADAME", "DOCTEUR",
|
||||||
"NORMAL", "POSITIF", "NEGATIF", "PRESENT", "ABSENT",
|
"NORMAL", "POSITIF", "NEGATIF", "PRESENT", "ABSENT",
|
||||||
|
# Acronymes médicaux courts (aussi patronymes/prénoms INSEE → FP évaluateur)
|
||||||
|
"EVA", # Échelle Visuelle Analogique
|
||||||
|
"RAI", # Recherche d'Agglutinines Irrégulières
|
||||||
# Instructions soins Trackare (aussi patronymes INSEE → faux positifs évaluateur)
|
# Instructions soins Trackare (aussi patronymes INSEE → faux positifs évaluateur)
|
||||||
"LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE",
|
"LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE",
|
||||||
"GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR",
|
"GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR",
|
||||||
@@ -300,7 +303,7 @@ def check_fp_density(text: str) -> dict:
|
|||||||
"density_pct": round(density, 2),
|
"density_pct": round(density, 2),
|
||||||
"nom_count": nom_count,
|
"nom_count": nom_count,
|
||||||
"nom_pct": round(nom_pct, 2),
|
"nom_pct": round(nom_pct, 2),
|
||||||
"alert": nom_pct > 5.0,
|
"alert": nom_pct > 8.0, # seuil relevé : CRO/CRH courts listent 8-10 soignants = légitime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,17 @@ def main():
|
|||||||
help="Seed pour la reproductibilité de l'augmentation")
|
help="Seed pour la reproductibilité de l'augmentation")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Vérification des ressources (GPU requis pour fine-tuning)
|
||||||
|
from scripts.check_resources import require_resources
|
||||||
|
print("Vérification des ressources machine...")
|
||||||
|
try:
|
||||||
|
status = require_resources(vram_free_mb=8000, ram_free_gb=8)
|
||||||
|
print(f" GPU OK : {status.gpu_name}, {status.vram_free_mb} Mo VRAM libre")
|
||||||
|
print(f" RAM OK : {status.ram_available_gb:.1f} Go disponible\n")
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"\n{e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Chemins des gazetteers
|
# Chemins des gazetteers
|
||||||
project_root = Path(__file__).parent.parent
|
project_root = Path(__file__).parent.parent
|
||||||
prenoms_file = project_root / "data" / "insee" / "prenoms_france.txt"
|
prenoms_file = project_root / "data" / "insee" / "prenoms_france.txt"
|
||||||
|
|||||||
522
scripts/generate_fiche_produit.py
Normal file
522
scripts/generate_fiche_produit.py
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Génère la fiche produit Pseudonymisation en DOCX (deux versions)."""
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, Cm, Inches, RGBColor
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_cell_shading(cell, color_hex: str):
|
||||||
|
"""Applique une couleur de fond à une cellule."""
|
||||||
|
shading = cell._element.get_or_add_tcPr()
|
||||||
|
shading_elem = shading.makeelement(qn('w:shd'), {
|
||||||
|
qn('w:val'): 'clear',
|
||||||
|
qn('w:color'): 'auto',
|
||||||
|
qn('w:fill'): color_hex,
|
||||||
|
})
|
||||||
|
shading.append(shading_elem)
|
||||||
|
|
||||||
|
|
||||||
|
def add_heading_styled(doc, text, level=1, color=RGBColor(0x1A, 0x56, 0x8E)):
|
||||||
|
h = doc.add_heading(text, level=level)
|
||||||
|
for run in h.runs:
|
||||||
|
run.font.color.rgb = color
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def add_bullet(doc, text, bold_prefix=None):
|
||||||
|
p = doc.add_paragraph(style='List Bullet')
|
||||||
|
if bold_prefix:
|
||||||
|
run = p.add_run(bold_prefix)
|
||||||
|
run.bold = True
|
||||||
|
p.add_run(text)
|
||||||
|
else:
|
||||||
|
p.add_run(text)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_para(doc, text, bold=False, italic=False, space_after=Pt(6)):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.bold = bold
|
||||||
|
run.italic = italic
|
||||||
|
p.paragraph_format.space_after = space_after
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_table_row(table, cells_data, header=False, header_color='1A568E'):
|
||||||
|
row = table.add_row()
|
||||||
|
for i, text in enumerate(cells_data):
|
||||||
|
cell = row.cells[i]
|
||||||
|
cell.text = ''
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.font.size = Pt(10)
|
||||||
|
if header:
|
||||||
|
run.bold = True
|
||||||
|
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
||||||
|
set_cell_shading(cell, header_color)
|
||||||
|
p.paragraph_format.space_before = Pt(4)
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
|
||||||
|
|
||||||
|
def set_table_style(table):
|
||||||
|
"""Style sobre pour les tableaux."""
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for p in cell.paragraphs:
|
||||||
|
p.paragraph_format.space_before = Pt(3)
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
|
||||||
|
def add_section_break(doc):
|
||||||
|
doc.add_page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Couleurs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BLUE_DARK = RGBColor(0x1A, 0x56, 0x8E)
|
||||||
|
BLUE_LIGHT = 'D6E8F7'
|
||||||
|
GRAY_LIGHT = 'F2F2F2'
|
||||||
|
GREEN = RGBColor(0x2E, 0x7D, 0x32)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# VERSION 1 — Synthétique (1 page recto, orientation portrait)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_version_1(doc):
|
||||||
|
# ── Titre ──
|
||||||
|
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for run in title.runs:
|
||||||
|
run.font.color.rgb = BLUE_DARK
|
||||||
|
run.font.size = Pt(22)
|
||||||
|
|
||||||
|
# Sous-titre
|
||||||
|
sub = doc.add_paragraph()
|
||||||
|
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = sub.add_run('Fiche produit — Version synthétique')
|
||||||
|
run.font.size = Pt(12)
|
||||||
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
||||||
|
run.italic = True
|
||||||
|
sub.paragraph_format.space_after = Pt(16)
|
||||||
|
|
||||||
|
# ── En bref ──
|
||||||
|
add_heading_styled(doc, 'En bref', level=2)
|
||||||
|
add_para(doc,
|
||||||
|
'Logiciel d\'anonymisation automatique de documents médicaux, '
|
||||||
|
'fonctionnant 100% en local sur un poste Windows. '
|
||||||
|
'Aucune donnée ne sort de l\'établissement. '
|
||||||
|
'Un fichier entre, un fichier anonymisé sort.',
|
||||||
|
space_after=Pt(10))
|
||||||
|
|
||||||
|
# ── Tableau comparatif ──
|
||||||
|
add_heading_styled(doc, 'Pourquoi cet outil ?', level=2)
|
||||||
|
table = doc.add_table(rows=0, cols=2)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True)
|
||||||
|
comparisons = [
|
||||||
|
('Anonymisation manuelle, chronophage\net source d\'erreurs',
|
||||||
|
'Traitement automatique\nen quelques minutes'),
|
||||||
|
('Risque d\'oubli de données\npersonnelles (RGPD)',
|
||||||
|
'Taux de détection > 99%\nvalidé sur corpus réel'),
|
||||||
|
('Mobilisation de personnel qualifié\nsur une tâche répétitive',
|
||||||
|
'L\'équipe TIM vérifie,\nelle ne produit plus à la main'),
|
||||||
|
]
|
||||||
|
for left, right in comparisons:
|
||||||
|
add_table_row(table, [left, right])
|
||||||
|
set_table_style(table)
|
||||||
|
doc.add_paragraph() # espace
|
||||||
|
|
||||||
|
# ── Points clés ──
|
||||||
|
add_heading_styled(doc, 'Points clés', level=2)
|
||||||
|
points = [
|
||||||
|
('100% hors-ligne — ', 'aucune donnée ne transite par internet'),
|
||||||
|
('Un seul exécutable — ', 'pas d\'installation, pas de serveur, pas de GPU'),
|
||||||
|
('14 formats acceptés — ', 'PDF, Word, ODT, RTF, images, HTML...'),
|
||||||
|
('4 moteurs d\'IA — ', 'spécialisés en français clinique, fonctionnent en parallèle'),
|
||||||
|
('Paramétrable — ', 'whitelist et blacklist modifiables par l\'établissement'),
|
||||||
|
('Irréversible — ', 'masquage par rectangles noirs, pas de calque amovible'),
|
||||||
|
]
|
||||||
|
for bold_part, normal_part in points:
|
||||||
|
add_bullet(doc, normal_part, bold_prefix=bold_part)
|
||||||
|
|
||||||
|
# ── Ce qui est masqué / préservé ──
|
||||||
|
add_heading_styled(doc, 'Ce qui est masqué / préservé', level=2)
|
||||||
|
table2 = doc.add_table(rows=0, cols=2)
|
||||||
|
table2.style = 'Table Grid'
|
||||||
|
add_table_row(table2, ['Masqué automatiquement', 'Préservé intégralement'], header=True)
|
||||||
|
add_table_row(table2, [
|
||||||
|
'Noms, prénoms, adresses,\ntéléphones, n° de sécu,\ndates de naissance,\nnoms d\'établissements,\ncodes-barres patients',
|
||||||
|
'Diagnostics, traitements,\nposologies, actes médicaux,\nrésultats d\'examens,\ndates de séjour,\ncodage CIM / CCAM'
|
||||||
|
])
|
||||||
|
set_table_style(table2)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Cas d'usage ──
|
||||||
|
add_heading_styled(doc, 'Cas d\'usage', level=2)
|
||||||
|
usages = [
|
||||||
|
('Contrôle T2A — ', 'anonymiser les pièces justificatives avant transmission'),
|
||||||
|
('Recherche clinique — ', 'constituer des corpus anonymisés'),
|
||||||
|
('Audits qualité / HAS — ', 'partager des dossiers sans exposition de données'),
|
||||||
|
('Formation — ', 'utiliser des cas réels anonymisés pour l\'enseignement'),
|
||||||
|
]
|
||||||
|
for bold_part, normal_part in usages:
|
||||||
|
add_bullet(doc, normal_part, bold_prefix=bold_part)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# VERSION 2 — Détaillée (multi-pages, par audience)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_version_2(doc):
|
||||||
|
# ── Page de titre ──
|
||||||
|
for _ in range(4):
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for run in title.runs:
|
||||||
|
run.font.color.rgb = BLUE_DARK
|
||||||
|
run.font.size = Pt(26)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
sub = doc.add_paragraph()
|
||||||
|
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = sub.add_run('Fiche produit détaillée')
|
||||||
|
run.font.size = Pt(14)
|
||||||
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
audiences = doc.add_paragraph()
|
||||||
|
audiences.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = audiences.add_run('Direction générale · DSI · Médecins · Équipe TIM')
|
||||||
|
run.font.size = Pt(11)
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
run.italic = True
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 1. Le problème ──
|
||||||
|
add_heading_styled(doc, 'Le problème', level=1)
|
||||||
|
add_para(doc,
|
||||||
|
'Les établissements de santé manipulent quotidiennement des documents '
|
||||||
|
'contenant des données personnelles de patients : comptes-rendus opératoires, '
|
||||||
|
'résultats d\'examens, courriers médicaux, rapports d\'anatomo-pathologie...')
|
||||||
|
add_para(doc,
|
||||||
|
'Lors des contrôles T2A, audits qualité, recherches cliniques ou échanges '
|
||||||
|
'inter-établissements, ces documents doivent être anonymisés conformément '
|
||||||
|
'au RGPD et au cadre réglementaire santé.')
|
||||||
|
add_para(doc,
|
||||||
|
'L\'anonymisation manuelle est lente, coûteuse, et faillible : '
|
||||||
|
'un oubli suffit à exposer l\'identité d\'un patient.',
|
||||||
|
bold=True, space_after=Pt(12))
|
||||||
|
|
||||||
|
# ── 2. La solution ──
|
||||||
|
add_heading_styled(doc, 'La solution', level=1)
|
||||||
|
add_para(doc,
|
||||||
|
'Un logiciel autonome, fonctionnant intégralement en local, qui anonymise '
|
||||||
|
'automatiquement vos documents en quelques minutes :')
|
||||||
|
bullets = [
|
||||||
|
'Détecte et masque les noms, prénoms, adresses, téléphones, numéros de sécurité sociale, dates de naissance, noms d\'établissements',
|
||||||
|
'Préserve le contenu médical utile : diagnostics, traitements, actes, résultats, codage',
|
||||||
|
'Produit un PDF anonymisé prêt à transmettre, avec masquage irréversible',
|
||||||
|
]
|
||||||
|
for b in bullets:
|
||||||
|
add_bullet(doc, b)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Tableau comparatif ──
|
||||||
|
add_heading_styled(doc, 'Avant / Après', level=2)
|
||||||
|
table = doc.add_table(rows=0, cols=2)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Situation actuelle', 'Avec l\'outil'], header=True)
|
||||||
|
comparisons = [
|
||||||
|
('Anonymisation manuelle, chronophage et source d\'erreurs',
|
||||||
|
'Traitement automatique en quelques minutes'),
|
||||||
|
('Risque d\'oubli de données personnelles (RGPD, AI Act)',
|
||||||
|
'Taux de détection > 99% validé sur corpus réel de contrôle T2A'),
|
||||||
|
('Mobilisation de personnel qualifié sur une tâche répétitive',
|
||||||
|
'L\'équipe TIM se concentre sur la vérification, pas la production'),
|
||||||
|
('Dépendance à des outils cloud ou des prestataires externes',
|
||||||
|
'100% local, aucune donnée ne quitte l\'établissement'),
|
||||||
|
]
|
||||||
|
for left, right in comparisons:
|
||||||
|
add_table_row(table, [left, right])
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 3. Conformité et sécurité (DG + DSI) ──
|
||||||
|
add_heading_styled(doc, 'Conformité et sécurité', level=1)
|
||||||
|
tag = doc.add_paragraph()
|
||||||
|
run = tag.add_run('→ Direction générale, DSI')
|
||||||
|
run.italic = True
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
tag.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
security_points = [
|
||||||
|
('Aucune connexion réseau', 'Le logiciel fonctionne entièrement hors-ligne. Aucun appel à un serveur externe, aucune API cloud, aucune télémétrie.'),
|
||||||
|
('Aucune donnée exfiltrée', 'Le traitement se fait intégralement en mémoire locale. Les documents source ne sont ni copiés ni transmis.'),
|
||||||
|
('Conformité RGPD / AI Act', 'L\'intelligence artificielle est embarquée dans l\'exécutable. Pas de sous-traitance, pas de transfert de données à un tiers.'),
|
||||||
|
('Masquage irréversible', 'Les zones anonymisées sont remplacées par des rectangles noirs directement dans le PDF. Il ne s\'agit pas d\'un calque amovible : l\'information est définitivement supprimée.'),
|
||||||
|
('Traçabilité', 'Les fichiers anonymisés sont nommés avec le préfixe "ANON_" pour identification immédiate.'),
|
||||||
|
]
|
||||||
|
for title_text, desc in security_points:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_title = p.add_run(title_text + ' — ')
|
||||||
|
run_title.bold = True
|
||||||
|
p.add_run(desc)
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 4. Déploiement (DSI) ──
|
||||||
|
add_heading_styled(doc, 'Déploiement et maintenance', level=1)
|
||||||
|
tag = doc.add_paragraph()
|
||||||
|
run = tag.add_run('→ DSI')
|
||||||
|
run.italic = True
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
tag.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Prérequis', level=2)
|
||||||
|
prereqs = [
|
||||||
|
'Windows 10 ou 11 (64 bits)',
|
||||||
|
'Poste bureautique standard — pas de GPU dédié, pas de serveur requis',
|
||||||
|
'Pas de droits administrateur nécessaires pour l\'exécution',
|
||||||
|
'Pas de connexion internet requise',
|
||||||
|
]
|
||||||
|
for pr in prereqs:
|
||||||
|
add_bullet(doc, pr)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Installation', level=2)
|
||||||
|
install_steps = [
|
||||||
|
'Copier l\'exécutable (un seul fichier .exe) sur le poste',
|
||||||
|
'Au premier lancement, le fichier de configuration est créé automatiquement à côté de l\'exécutable',
|
||||||
|
'C\'est prêt — aucune autre manipulation nécessaire',
|
||||||
|
]
|
||||||
|
for i, step in enumerate(install_steps, 1):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run = p.add_run(f'{i}. ')
|
||||||
|
run.bold = True
|
||||||
|
p.add_run(step)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Mise à jour', level=2)
|
||||||
|
add_para(doc, 'Remplacer l\'exécutable par la nouvelle version. La configuration personnalisée de l\'établissement est conservée (fichier séparé).')
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Configuration inter-établissements', level=2)
|
||||||
|
add_para(doc,
|
||||||
|
'Les paramètres (whitelist, blacklist) sont exportables au format JSON depuis l\'interface. '
|
||||||
|
'Un établissement peut envoyer sa configuration par email. '
|
||||||
|
'Le service central fusionne les configurations et renvoie un fichier YAML consolidé.')
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 5. Utilisation quotidienne (TIM) ──
|
||||||
|
add_heading_styled(doc, 'Utilisation quotidienne', level=1)
|
||||||
|
tag = doc.add_paragraph()
|
||||||
|
run = tag.add_run('→ Équipe TIM, médecins')
|
||||||
|
run.italic = True
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
tag.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'En 3 étapes', level=2)
|
||||||
|
steps = [
|
||||||
|
('Sélectionner', 'Choisir un fichier ou un dossier entier depuis l\'interface'),
|
||||||
|
('Lancer', 'Cliquer sur "Lancer l\'anonymisation"'),
|
||||||
|
('Récupérer', 'Les documents anonymisés sont créés dans le dossier de sortie, prêts à transmettre'),
|
||||||
|
]
|
||||||
|
for title_text, desc in steps:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_title = p.add_run(f'{title_text} — ')
|
||||||
|
run_title.bold = True
|
||||||
|
run_title.font.color.rgb = BLUE_DARK
|
||||||
|
p.add_run(desc)
|
||||||
|
p.paragraph_format.space_after = Pt(6)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Formats acceptés (14)', level=2)
|
||||||
|
table_fmt = doc.add_table(rows=0, cols=2)
|
||||||
|
table_fmt.style = 'Table Grid'
|
||||||
|
add_table_row(table_fmt, ['Type', 'Formats'], header=True)
|
||||||
|
formats = [
|
||||||
|
('Documents', 'PDF, Word (.docx), ODT, RTF, TXT, HTML'),
|
||||||
|
('Images', 'JPEG, PNG, TIFF, BMP'),
|
||||||
|
]
|
||||||
|
for type_name, fmt_list in formats:
|
||||||
|
add_table_row(table_fmt, [type_name, fmt_list])
|
||||||
|
set_table_style(table_fmt)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Les images et documents scannés sont traités par reconnaissance optique de caractères (OCR) '
|
||||||
|
'intégrée — pas de logiciel tiers nécessaire.',
|
||||||
|
italic=True)
|
||||||
|
|
||||||
|
add_heading_styled(doc, 'Paramétrage', level=2)
|
||||||
|
params = [
|
||||||
|
('Whitelist — ', 'liste de termes à ne jamais masquer (noms de services, sigles internes, noms de logiciels...). Modifiable directement dans l\'interface.'),
|
||||||
|
('Blacklist — ', 'liste de termes à toujours masquer (noms de praticiens spécifiques...). Modifiable directement dans l\'interface.'),
|
||||||
|
('Export/Import — ', 'les paramètres sont échangeables entre établissements par simple fichier JSON envoyé par email.'),
|
||||||
|
]
|
||||||
|
for bold_part, normal_part in params:
|
||||||
|
add_bullet(doc, normal_part, bold_prefix=bold_part)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 6. Fiabilité (Médecins + TIM) ──
|
||||||
|
add_heading_styled(doc, 'Fiabilité de la détection', level=1)
|
||||||
|
tag = doc.add_paragraph()
|
||||||
|
run = tag.add_run('→ Médecins, équipe TIM')
|
||||||
|
run.italic = True
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
tag.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Le logiciel combine 4 moteurs d\'intelligence artificielle spécialisés en français clinique '
|
||||||
|
'qui fonctionnent simultanément et se recoupent pour maximiser la détection :')
|
||||||
|
|
||||||
|
engines = [
|
||||||
|
('Modèle CamemBERT médical', 'entraîné spécifiquement sur des documents cliniques français'),
|
||||||
|
('Modèle de détection d\'entités', 'reconnaissance zero-shot de données personnelles'),
|
||||||
|
('Modèle spécialisé', 'fine-tuné sur plus de 1 000 documents médicaux réels'),
|
||||||
|
('Bases de référence nationales', '219 000 noms de famille, 36 000 prénoms, 7 300 médicaments, 108 000 établissements de santé'),
|
||||||
|
]
|
||||||
|
for title_text, desc in engines:
|
||||||
|
add_bullet(doc, ' — ' + desc, bold_prefix=title_text)
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Principe de précaution : en cas de doute, le logiciel masque. '
|
||||||
|
'Il est préférable de masquer un terme médical par excès '
|
||||||
|
'plutôt que de laisser visible un nom de patient.',
|
||||||
|
bold=True, space_after=Pt(8))
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Taux de détection validé à plus de 99% sur un corpus réel de documents '
|
||||||
|
'de contrôle T2A : comptes-rendus opératoires, anatomo-pathologie, bactériologie, '
|
||||||
|
'anesthésie, courriers médicaux, comptes-rendus d\'hospitalisation.')
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ── 7. Cas d'usage ──
|
||||||
|
add_heading_styled(doc, 'Cas d\'usage', level=1)
|
||||||
|
|
||||||
|
use_cases = [
|
||||||
|
('Contrôle T2A',
|
||||||
|
'Anonymiser les pièces justificatives (comptes-rendus, résultats, courriers) '
|
||||||
|
'avant transmission à l\'organisme de contrôle.'),
|
||||||
|
('Recherche clinique',
|
||||||
|
'Constituer des jeux de données anonymisés à partir de dossiers patients réels, '
|
||||||
|
'sans risque d\'identification.'),
|
||||||
|
('Audits qualité et certification HAS',
|
||||||
|
'Partager des cas cliniques lors des visites de certification '
|
||||||
|
'sans exposer l\'identité des patients.'),
|
||||||
|
('Échanges inter-établissements',
|
||||||
|
'Transmettre des comptes-rendus médicaux anonymisés dans le cadre '
|
||||||
|
'de parcours de soins partagés.'),
|
||||||
|
('Formation et enseignement',
|
||||||
|
'Utiliser des cas cliniques réels anonymisés pour la formation '
|
||||||
|
'des internes et des équipes soignantes.'),
|
||||||
|
]
|
||||||
|
for title_text, desc in use_cases:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_title = p.add_run(title_text + '\n')
|
||||||
|
run_title.bold = True
|
||||||
|
run_title.font.size = Pt(11)
|
||||||
|
run_desc = p.add_run(desc)
|
||||||
|
run_desc.font.size = Pt(10)
|
||||||
|
p.paragraph_format.space_after = Pt(10)
|
||||||
|
|
||||||
|
# ── 8. Questions fréquentes ──
|
||||||
|
add_heading_styled(doc, 'Questions fréquentes', level=1)
|
||||||
|
|
||||||
|
faq = [
|
||||||
|
('Combien de temps prend le traitement d\'un document ?',
|
||||||
|
'En moyenne quelques dizaines de secondes par document. '
|
||||||
|
'Un lot de 20 à 30 documents se traite en 10 à 15 minutes sur un poste standard.'),
|
||||||
|
('Que faire si un nom n\'a pas été détecté ?',
|
||||||
|
'Ajouter le terme dans la blacklist via l\'interface. '
|
||||||
|
'Il sera systématiquement masqué lors des prochains traitements.'),
|
||||||
|
('Que faire si un terme médical a été masqué par erreur ?',
|
||||||
|
'Ajouter le terme dans la whitelist via l\'interface. '
|
||||||
|
'Il ne sera plus jamais masqué.'),
|
||||||
|
('Faut-il une connexion internet ?',
|
||||||
|
'Non. Le logiciel fonctionne intégralement hors-ligne. '
|
||||||
|
'L\'intelligence artificielle est embarquée dans l\'exécutable.'),
|
||||||
|
('Les documents originaux sont-ils modifiés ?',
|
||||||
|
'Non. Le logiciel crée une copie anonymisée. '
|
||||||
|
'Les documents originaux restent intacts.'),
|
||||||
|
('Peut-on traiter un dossier entier d\'un coup ?',
|
||||||
|
'Oui. Il suffit de sélectionner un dossier au lieu d\'un fichier. '
|
||||||
|
'Tous les documents du dossier seront traités séquentiellement.'),
|
||||||
|
]
|
||||||
|
for question, answer in faq:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_q = p.add_run(question + '\n')
|
||||||
|
run_q.bold = True
|
||||||
|
run_q.font.size = Pt(10)
|
||||||
|
run_a = p.add_run(answer)
|
||||||
|
run_a.font.size = Pt(10)
|
||||||
|
p.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# Construction du document final
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# ── Style par défaut ──
|
||||||
|
style = doc.styles['Normal']
|
||||||
|
style.font.name = 'Calibri'
|
||||||
|
style.font.size = Pt(10)
|
||||||
|
style.paragraph_format.space_after = Pt(4)
|
||||||
|
|
||||||
|
# Marges
|
||||||
|
for section in doc.sections:
|
||||||
|
section.top_margin = Cm(1.5)
|
||||||
|
section.bottom_margin = Cm(1.5)
|
||||||
|
section.left_margin = Cm(2)
|
||||||
|
section.right_margin = Cm(2)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
# VERSION 1 — Synthétique
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
build_version_1(doc)
|
||||||
|
|
||||||
|
# ── Séparation ──
|
||||||
|
add_section_break(doc)
|
||||||
|
sep = doc.add_paragraph()
|
||||||
|
sep.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for _ in range(6):
|
||||||
|
doc.add_paragraph()
|
||||||
|
sep2 = doc.add_paragraph()
|
||||||
|
sep2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = sep2.add_run('— Version détaillée ci-après —')
|
||||||
|
run.font.size = Pt(14)
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
run.italic = True
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
# VERSION 2 — Détaillée
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
build_version_2(doc)
|
||||||
|
|
||||||
|
# ── Sauvegarde ──
|
||||||
|
output_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'Fiche_Produit_Pseudonymisation.docx')
|
||||||
|
doc.save(output_path)
|
||||||
|
print(f'Fiche produit générée : {output_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
808
scripts/generate_fiche_technique_dsi.py
Normal file
808
scripts/generate_fiche_technique_dsi.py
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Génère la fiche technique DSI / RSSI / DPO en DOCX."""
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, Cm, RGBColor
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_cell_shading(cell, color_hex: str):
|
||||||
|
shading = cell._element.get_or_add_tcPr()
|
||||||
|
shading_elem = shading.makeelement(qn('w:shd'), {
|
||||||
|
qn('w:val'): 'clear',
|
||||||
|
qn('w:color'): 'auto',
|
||||||
|
qn('w:fill'): color_hex,
|
||||||
|
})
|
||||||
|
shading.append(shading_elem)
|
||||||
|
|
||||||
|
|
||||||
|
def add_heading_styled(doc, text, level=1, color=RGBColor(0x1A, 0x56, 0x8E)):
|
||||||
|
h = doc.add_heading(text, level=level)
|
||||||
|
for run in h.runs:
|
||||||
|
run.font.color.rgb = color
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def add_bullet(doc, text, bold_prefix=None):
|
||||||
|
p = doc.add_paragraph(style='List Bullet')
|
||||||
|
if bold_prefix:
|
||||||
|
run = p.add_run(bold_prefix)
|
||||||
|
run.bold = True
|
||||||
|
p.add_run(text)
|
||||||
|
else:
|
||||||
|
p.add_run(text)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_para(doc, text, bold=False, italic=False, space_after=Pt(6)):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.bold = bold
|
||||||
|
run.italic = italic
|
||||||
|
p.paragraph_format.space_after = space_after
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def add_table_row(table, cells_data, header=False, header_color='1A568E'):
|
||||||
|
row = table.add_row()
|
||||||
|
for i, text in enumerate(cells_data):
|
||||||
|
cell = row.cells[i]
|
||||||
|
cell.text = ''
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.font.size = Pt(9.5)
|
||||||
|
if header:
|
||||||
|
run.bold = True
|
||||||
|
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
||||||
|
set_cell_shading(cell, header_color)
|
||||||
|
p.paragraph_format.space_before = Pt(3)
|
||||||
|
p.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
|
||||||
|
def set_table_style(table):
|
||||||
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for p in cell.paragraphs:
|
||||||
|
p.paragraph_format.space_before = Pt(2)
|
||||||
|
p.paragraph_format.space_after = Pt(2)
|
||||||
|
|
||||||
|
|
||||||
|
def add_section_break(doc):
|
||||||
|
doc.add_page_break()
|
||||||
|
|
||||||
|
|
||||||
|
def add_audience_tag(doc, text):
|
||||||
|
tag = doc.add_paragraph()
|
||||||
|
run = tag.add_run(text)
|
||||||
|
run.italic = True
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
run.font.size = Pt(9)
|
||||||
|
tag.paragraph_format.space_after = Pt(8)
|
||||||
|
|
||||||
|
|
||||||
|
def add_labeled_para(doc, label, text):
|
||||||
|
"""Paragraphe avec un label en gras suivi du texte normal."""
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_label = p.add_run(label + ' — ')
|
||||||
|
run_label.bold = True
|
||||||
|
p.add_run(text)
|
||||||
|
p.paragraph_format.space_after = Pt(5)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ── Couleurs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BLUE_DARK = RGBColor(0x1A, 0x56, 0x8E)
|
||||||
|
RED_DARK = RGBColor(0x8E, 0x1A, 0x1A)
|
||||||
|
GREEN_DARK = RGBColor(0x2E, 0x7D, 0x32)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# PAGE DE TITRE
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_title_page(doc):
|
||||||
|
for _ in range(5):
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
title = doc.add_heading('Pseudonymisation Automatique\nde Documents Médicaux', level=0)
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for run in title.runs:
|
||||||
|
run.font.color.rgb = BLUE_DARK
|
||||||
|
run.font.size = Pt(26)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
sub = doc.add_paragraph()
|
||||||
|
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = sub.add_run('Fiche technique — Sécurité et Conformité')
|
||||||
|
run.font.size = Pt(14)
|
||||||
|
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
audiences = doc.add_paragraph()
|
||||||
|
audiences.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = audiences.add_run('DSI · RSSI · DPO')
|
||||||
|
run.font.size = Pt(12)
|
||||||
|
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
||||||
|
run.italic = True
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# Encadré résumé
|
||||||
|
summary = doc.add_paragraph()
|
||||||
|
summary.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = summary.add_run(
|
||||||
|
'Logiciel d\'anonymisation fonctionnant 100% en local.\n'
|
||||||
|
'Aucune connexion réseau. Aucun sous-traitant. Aucune donnée exfiltrée.\n'
|
||||||
|
'Intelligence artificielle embarquée — masquage irréversible.'
|
||||||
|
)
|
||||||
|
run.font.size = Pt(10.5)
|
||||||
|
run.font.color.rgb = RGBColor(0x44, 0x44, 0x44)
|
||||||
|
run.italic = True
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 1. ARCHITECTURE ET FLUX DE DONNÉES
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_architecture(doc):
|
||||||
|
add_heading_styled(doc, '1. Architecture et flux de données', level=1)
|
||||||
|
add_audience_tag(doc, '→ DSI, RSSI')
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Principe fondamental : aucune donnée ne quitte le poste de travail.',
|
||||||
|
bold=True, space_after=Pt(10))
|
||||||
|
|
||||||
|
points = [
|
||||||
|
('Exécutable autonome',
|
||||||
|
'Un seul fichier .exe embarquant l\'intégralité des modèles d\'IA, '
|
||||||
|
'des bases de référence et du moteur OCR. Pas d\'appel réseau, '
|
||||||
|
'pas d\'API, pas de dépendance externe à l\'exécution.'),
|
||||||
|
('Traitement en mémoire',
|
||||||
|
'Les documents sont traités en mémoire vive. Aucune copie dans un '
|
||||||
|
'répertoire temporaire partagé, aucun cache disque au-delà de la session.'),
|
||||||
|
('Aucune télémétrie',
|
||||||
|
'Pas de phoning home, pas de vérification de licence en ligne, '
|
||||||
|
'pas de collecte de statistiques d\'usage.'),
|
||||||
|
('Stateless',
|
||||||
|
'Pas de base de données. Le logiciel ne conserve aucun état entre '
|
||||||
|
'deux exécutions, hormis le fichier de configuration (whitelist/blacklist).'),
|
||||||
|
('Non destructif',
|
||||||
|
'Le document source n\'est jamais modifié. Un nouveau fichier '
|
||||||
|
'anonymisé est créé dans le dossier de sortie.'),
|
||||||
|
]
|
||||||
|
for label, text in points:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# Schéma flux
|
||||||
|
add_heading_styled(doc, 'Flux de traitement', level=2)
|
||||||
|
|
||||||
|
flow_steps = [
|
||||||
|
('1', 'Entrée', 'Document source (poste local) — PDF, DOCX, image...'),
|
||||||
|
('2', 'Extraction', 'Extraction du texte en mémoire (layout-aware + OCR intégré si nécessaire)'),
|
||||||
|
('3', 'Détection', '4 moteurs IA analysent le texte en parallèle (CPU uniquement)'),
|
||||||
|
('4', 'Masquage', 'Remplacement irréversible des PII dans le PDF (texte supprimé + rectangle noir)'),
|
||||||
|
('5', 'Sortie', 'Écriture du PDF anonymisé sur disque local'),
|
||||||
|
('6', 'Nettoyage', 'Libération mémoire — aucune trace résiduelle'),
|
||||||
|
]
|
||||||
|
table = doc.add_table(rows=0, cols=3)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Étape', 'Phase', 'Description'], header=True)
|
||||||
|
for step, phase, desc in flow_steps:
|
||||||
|
add_table_row(table, [step, phase, desc])
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
# Largeurs colonnes
|
||||||
|
for row in table.rows:
|
||||||
|
row.cells[0].width = Cm(1.2)
|
||||||
|
row.cells[1].width = Cm(2.8)
|
||||||
|
row.cells[2].width = Cm(12)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 2. CONFORMITÉ RÉGLEMENTAIRE
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_conformite(doc):
|
||||||
|
add_heading_styled(doc, '2. Conformité réglementaire', level=1)
|
||||||
|
add_audience_tag(doc, '→ DPO, RSSI, Direction')
|
||||||
|
|
||||||
|
# ── RGPD ──
|
||||||
|
add_heading_styled(doc, 'RGPD (Règlement 2016/679)', level=2)
|
||||||
|
|
||||||
|
rgpd = [
|
||||||
|
('Art. 25 — Privacy by design',
|
||||||
|
'Le traitement est conçu pour fonctionner sans transfert de données. '
|
||||||
|
'L\'intelligence artificielle est embarquée dans l\'exécutable, '
|
||||||
|
'pas hébergée sur un serveur distant.'),
|
||||||
|
('Art. 28 — Sous-traitance',
|
||||||
|
'Aucun sous-traitant au sens du RGPD. Le logiciel tourne sur '
|
||||||
|
'l\'infrastructure de l\'établissement, sous sa responsabilité exclusive. '
|
||||||
|
'Aucune donnée n\'est transmise à un tiers.'),
|
||||||
|
('Art. 32 — Sécurité du traitement',
|
||||||
|
'Les données ne transitent par aucun réseau. Le périmètre de sécurité '
|
||||||
|
'est celui du poste de travail. Les mécanismes de protection existants '
|
||||||
|
'(chiffrement disque, contrôle d\'accès, antivirus) s\'appliquent.'),
|
||||||
|
('Art. 35 — Analyse d\'impact (AIPD)',
|
||||||
|
'Le logiciel réduit le risque sur les données personnelles : il anonymise, '
|
||||||
|
'il ne crée pas de nouveau traitement. Pas de traitement à grande échelle '
|
||||||
|
'externalisé. Une AIPD simplifiée peut suffire selon la politique de l\'établissement.'),
|
||||||
|
('Art. 17 — Droit à l\'effacement',
|
||||||
|
'Les documents anonymisés ne contiennent plus de données à caractère personnel. '
|
||||||
|
'Ils sortent du champ d\'application du RGPD.'),
|
||||||
|
('Art. 5.1.e — Limitation de conservation',
|
||||||
|
'Le logiciel ne stocke aucune donnée personnelle au-delà de la session '
|
||||||
|
'de traitement. Pas de base, pas de journal nominatif, pas d\'historique.'),
|
||||||
|
]
|
||||||
|
for label, text in rgpd:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── AI Act ──
|
||||||
|
add_heading_styled(doc, 'AI Act (Règlement 2024/1689)', level=2)
|
||||||
|
|
||||||
|
ai_act = [
|
||||||
|
('Classification du risque',
|
||||||
|
'Le logiciel utilise des modèles d\'IA pour la détection d\'entités nommées '
|
||||||
|
'(NER). Il ne prend aucune décision automatisée affectant des personnes. '
|
||||||
|
'L\'IA est un outil d\'aide : l\'opérateur humain reste maître de la validation.'),
|
||||||
|
('Pas de système à haut risque',
|
||||||
|
'Pas de scoring, pas de profilage, pas de catégorisation de personnes. '
|
||||||
|
'Le logiciel traite des documents, pas des individus.'),
|
||||||
|
('Transparence',
|
||||||
|
'Les modèles utilisés sont identifiés (CamemBERT, GLiNER). '
|
||||||
|
'Leur fonctionnement est documenté. Les résultats sont vérifiables '
|
||||||
|
'par l\'opérateur avant toute transmission.'),
|
||||||
|
]
|
||||||
|
for label, text in ai_act:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Cadre santé ──
|
||||||
|
add_heading_styled(doc, 'Cadre réglementaire santé', level=2)
|
||||||
|
|
||||||
|
sante = [
|
||||||
|
('PGSSI-S',
|
||||||
|
'Compatible avec la Politique Générale de Sécurité des Systèmes '
|
||||||
|
'd\'Information de Santé. Le logiciel fonctionne dans le périmètre SI '
|
||||||
|
'de l\'établissement sans ouvrir de flux réseau.'),
|
||||||
|
('Hébergement de Données de Santé (HDS)',
|
||||||
|
'Pas d\'agrément HDS requis. Le logiciel n\'héberge rien — '
|
||||||
|
'il traite en local et ne conserve aucune donnée.'),
|
||||||
|
('Découplage DPI',
|
||||||
|
'Le logiciel travaille sur des fichiers exportés du DPI. '
|
||||||
|
'Aucune connexion directe au système d\'information clinique. '
|
||||||
|
'Aucun risque d\'altération des données sources.'),
|
||||||
|
]
|
||||||
|
for label, text in sante:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 3. SÉCURITÉ TECHNIQUE
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_securite(doc):
|
||||||
|
add_heading_styled(doc, '3. Sécurité technique', level=1)
|
||||||
|
add_audience_tag(doc, '→ RSSI')
|
||||||
|
|
||||||
|
# ── Surface d'attaque ──
|
||||||
|
add_heading_styled(doc, 'Surface d\'attaque', level=2)
|
||||||
|
attack_surface = [
|
||||||
|
'Aucun port réseau ouvert — le logiciel n\'écoute sur aucune interface',
|
||||||
|
'Aucune communication sortante — vérifiable par capture réseau (Wireshark, pare-feu)',
|
||||||
|
'Pas de serveur web embarqué, pas de socket, pas d\'API REST',
|
||||||
|
'Pas de service en arrière-plan ni de processus résident',
|
||||||
|
'Processus mono-instance protégé par verrou fichier (pas de double exécution)',
|
||||||
|
]
|
||||||
|
for item in attack_surface:
|
||||||
|
add_bullet(doc, item)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Exécutable ──
|
||||||
|
add_heading_styled(doc, 'Exécutable', level=2)
|
||||||
|
exe_points = [
|
||||||
|
('Compilation',
|
||||||
|
'Compilé via PyInstaller — bundle Python, dépendances et modèles IA '
|
||||||
|
'en un seul fichier .exe autonome.'),
|
||||||
|
('Signature de code',
|
||||||
|
'Compatible avec les outils de signature Windows (signtool). '
|
||||||
|
'L\'établissement peut signer l\'exécutable avec son propre certificat '
|
||||||
|
'si sa politique de sécurité l\'exige.'),
|
||||||
|
('Privilèges',
|
||||||
|
'Fonctionne en espace utilisateur standard. Pas d\'élévation de privilèges, '
|
||||||
|
'pas d\'UAC, pas de droits administrateur requis.'),
|
||||||
|
('Registre Windows',
|
||||||
|
'Aucune écriture dans le registre Windows ni dans les répertoires système. '
|
||||||
|
'L\'exécutable et sa configuration restent dans leur dossier.'),
|
||||||
|
('Empreinte',
|
||||||
|
'Exécutable unique (~720 Mo, modèles IA inclus). '
|
||||||
|
'Aucune DLL externe, aucune dépendance système non standard.'),
|
||||||
|
]
|
||||||
|
for label, text in exe_points:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Données ──
|
||||||
|
add_heading_styled(doc, 'Données au repos et en transit', level=2)
|
||||||
|
|
||||||
|
table = doc.add_table(rows=0, cols=3)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Catégorie', 'Localisation', 'Contenu sensible'], header=True)
|
||||||
|
|
||||||
|
data_rows = [
|
||||||
|
('Documents source', 'Inchangés, emplacement d\'origine', 'Oui — sous contrôle établissement'),
|
||||||
|
('Documents anonymisés', 'Dossier de sortie (local)', 'Non — PII supprimées'),
|
||||||
|
('Configuration (.yml)', 'À côté de l\'exécutable', 'Non — listes de termes uniquement'),
|
||||||
|
('Fichiers temporaires', 'Aucun', 'N/A'),
|
||||||
|
('Logs', 'Console uniquement (non persistés)', 'Non — pas de données nominatives'),
|
||||||
|
('Données en transit', 'Aucune', 'N/A — aucune communication réseau'),
|
||||||
|
]
|
||||||
|
for cat, loc, sensible in data_rows:
|
||||||
|
add_table_row(table, [cat, loc, sensible])
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Vérification indépendante ──
|
||||||
|
add_heading_styled(doc, 'Vérification indépendante', level=2)
|
||||||
|
add_para(doc,
|
||||||
|
'L\'absence de communication réseau est vérifiable de manière indépendante '
|
||||||
|
'par l\'équipe sécurité de l\'établissement :',
|
||||||
|
space_after=Pt(8))
|
||||||
|
|
||||||
|
verif = [
|
||||||
|
('Capture réseau', 'Wireshark ou tcpdump pendant une session de traitement : aucun paquet émis.'),
|
||||||
|
('Pare-feu applicatif', 'Bloquer toute communication sortante pour le processus : aucun impact fonctionnel.'),
|
||||||
|
('Analyse statique', 'Aucune URL, aucun endpoint, aucune chaîne de connexion dans l\'exécutable.'),
|
||||||
|
('Sandbox', 'Exécution dans un environnement isolé (VM sans réseau) : fonctionnement nominal.'),
|
||||||
|
]
|
||||||
|
for label, text in verif:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 4. DÉPLOIEMENT
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_deploiement(doc):
|
||||||
|
add_heading_styled(doc, '4. Déploiement et maintenance', level=1)
|
||||||
|
add_audience_tag(doc, '→ DSI')
|
||||||
|
|
||||||
|
# ── Prérequis ──
|
||||||
|
add_heading_styled(doc, 'Prérequis techniques', level=2)
|
||||||
|
|
||||||
|
table = doc.add_table(rows=0, cols=2)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Élément', 'Détail'], header=True)
|
||||||
|
|
||||||
|
prereqs = [
|
||||||
|
('Système d\'exploitation', 'Windows 10 / 11 (64 bits)'),
|
||||||
|
('Processeur', 'x86-64, 4 cœurs recommandés'),
|
||||||
|
('Mémoire vive', '8 Go minimum (16 Go recommandés pour lots volumineux)'),
|
||||||
|
('Espace disque', '~1 Go (exécutable + configuration)'),
|
||||||
|
('GPU', 'Non requis — inférence CPU uniquement'),
|
||||||
|
('Droits utilisateur', 'Utilisateur standard (pas d\'administrateur)'),
|
||||||
|
('Réseau', 'Aucun accès requis — fonctionnement 100% hors-ligne'),
|
||||||
|
('Logiciel tiers', 'Aucun — tout est embarqué dans l\'exécutable'),
|
||||||
|
]
|
||||||
|
for elem, detail in prereqs:
|
||||||
|
add_table_row(table, [elem, detail])
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
for row in table.rows:
|
||||||
|
row.cells[0].width = Cm(4.5)
|
||||||
|
row.cells[1].width = Cm(11.5)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Installation ──
|
||||||
|
add_heading_styled(doc, 'Procédure d\'installation', level=2)
|
||||||
|
steps = [
|
||||||
|
'Copier l\'exécutable (.exe) sur le poste de travail',
|
||||||
|
'Premier lancement : le fichier de configuration (dictionnaires.yml) '
|
||||||
|
'est créé automatiquement dans le même répertoire',
|
||||||
|
'Aucune autre manipulation — le logiciel est opérationnel',
|
||||||
|
]
|
||||||
|
for i, step in enumerate(steps, 1):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run = p.add_run(f'{i}. ')
|
||||||
|
run.bold = True
|
||||||
|
p.add_run(step)
|
||||||
|
p.paragraph_format.space_after = Pt(4)
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Pas d\'installeur MSI/EXE setup. Pas de modification du registre. '
|
||||||
|
'Pas de redémarrage nécessaire.',
|
||||||
|
italic=True, space_after=Pt(10))
|
||||||
|
|
||||||
|
# ── Mise à jour ──
|
||||||
|
add_heading_styled(doc, 'Mise à jour', level=2)
|
||||||
|
updates = [
|
||||||
|
'Remplacer l\'exécutable par la nouvelle version',
|
||||||
|
'La configuration personnalisée est préservée (fichier séparé)',
|
||||||
|
'Pas de mise à jour automatique — contrôle total par la DSI',
|
||||||
|
'Pas de dépendance à un serveur de mise à jour',
|
||||||
|
]
|
||||||
|
for item in updates:
|
||||||
|
add_bullet(doc, item)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Déploiement multi-postes ──
|
||||||
|
add_heading_styled(doc, 'Déploiement multi-postes', level=2)
|
||||||
|
multi = [
|
||||||
|
('Distribution',
|
||||||
|
'Copie simple via partage réseau, SCCM, GPO, ou clé USB.'),
|
||||||
|
('Configuration centralisée',
|
||||||
|
'Préparer un fichier .yml maître avec les paramètres communs, '
|
||||||
|
'le distribuer avec l\'exécutable.'),
|
||||||
|
('Personnalisation par site',
|
||||||
|
'Chaque établissement peut ajuster sa configuration localement '
|
||||||
|
'via l\'interface graphique (whitelist, blacklist).'),
|
||||||
|
('Échange de configuration',
|
||||||
|
'Export JSON depuis l\'interface → envoi par email → '
|
||||||
|
'fusion centralisée → renvoi du YAML consolidé.'),
|
||||||
|
]
|
||||||
|
for label, text in multi:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Intégration SI ──
|
||||||
|
add_heading_styled(doc, 'Intégration au SI', level=2)
|
||||||
|
integration = [
|
||||||
|
'Découplé du DPI — travaille sur des fichiers exportés, pas de connecteur direct',
|
||||||
|
'Utilisable en ligne de commande pour intégration dans un workflow batch',
|
||||||
|
'Compatible avec les espaces de travail sécurisés et les postes verrouillés',
|
||||||
|
'Aucune dépendance externe (pas d\'accès internet, pas de service tiers)',
|
||||||
|
]
|
||||||
|
for item in integration:
|
||||||
|
add_bullet(doc, item)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 5. GARANTIES D'ANONYMISATION
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_garanties(doc):
|
||||||
|
add_heading_styled(doc, '5. Garanties d\'anonymisation', level=1)
|
||||||
|
add_audience_tag(doc, '→ DPO, RSSI')
|
||||||
|
|
||||||
|
# ── Nature du masquage ──
|
||||||
|
add_heading_styled(doc, 'Nature du masquage', level=2)
|
||||||
|
masquage = [
|
||||||
|
('Irréversibilité',
|
||||||
|
'Le texte original est supprimé du flux PDF, puis recouvert '
|
||||||
|
'par un rectangle noir opaque. Il ne s\'agit pas d\'un calque amovible : '
|
||||||
|
'l\'information est définitivement détruite dans le fichier de sortie.'),
|
||||||
|
('Métadonnées',
|
||||||
|
'Les métadonnées PDF contenant des données personnelles '
|
||||||
|
'(auteur, titre, sujet) sont nettoyées.'),
|
||||||
|
('Codes-barres',
|
||||||
|
'Les codes-barres contenant des identifiants patients sont '
|
||||||
|
'détectés et masqués automatiquement.'),
|
||||||
|
('Intégrité du document',
|
||||||
|
'La structure, la mise en page et le contenu médical sont '
|
||||||
|
'préservés. Seules les données à caractère personnel sont supprimées.'),
|
||||||
|
]
|
||||||
|
for label, text in masquage:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Tableau masqué / préservé ──
|
||||||
|
add_heading_styled(doc, 'Périmètre de détection', level=2)
|
||||||
|
|
||||||
|
table = doc.add_table(rows=0, cols=2)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Données masquées (PII)', 'Données préservées'], header=True)
|
||||||
|
|
||||||
|
rows_data = [
|
||||||
|
('Noms et prénoms (patients)', 'Diagnostics et conclusions médicales'),
|
||||||
|
('Noms et prénoms (médecins, soignants)', 'Traitements et posologies'),
|
||||||
|
('Adresses postales', 'Actes médicaux (codage CCAM)'),
|
||||||
|
('Numéros de téléphone', 'Résultats d\'examens et de biologie'),
|
||||||
|
('Numéros de sécurité sociale', 'Dates de séjour et d\'intervention'),
|
||||||
|
('Dates de naissance', 'Codage CIM-10'),
|
||||||
|
('Noms d\'établissements', 'Comptes-rendus opératoires (contenu)'),
|
||||||
|
('Codes-barres d\'identification', 'Structure et mise en page'),
|
||||||
|
]
|
||||||
|
for masked, preserved in rows_data:
|
||||||
|
add_table_row(table, [masked, preserved])
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Moteurs de détection ──
|
||||||
|
add_heading_styled(doc, 'Moteurs de détection', level=2)
|
||||||
|
add_para(doc,
|
||||||
|
'Le logiciel combine 4 moteurs d\'intelligence artificielle spécialisés '
|
||||||
|
'en français clinique. Chaque moteur analyse le document indépendamment. '
|
||||||
|
'Les résultats sont croisés pour maximiser le rappel (recall) et minimiser '
|
||||||
|
'les oublis.',
|
||||||
|
space_after=Pt(8))
|
||||||
|
|
||||||
|
engines = [
|
||||||
|
('Modèle CamemBERT médical',
|
||||||
|
'Modèle de langue français pré-entraîné, spécialisé dans la '
|
||||||
|
'reconnaissance d\'entités nommées en contexte clinique.'),
|
||||||
|
('Détection zero-shot',
|
||||||
|
'Modèle capable de détecter des données personnelles sans '
|
||||||
|
'entraînement spécifique, par compréhension sémantique du contexte.'),
|
||||||
|
('Modèle fine-tuné',
|
||||||
|
'Modèle entraîné spécifiquement sur plus de 1 000 documents médicaux '
|
||||||
|
'réels pour une précision maximale sur les formats cliniques français.'),
|
||||||
|
('Bases de référence nationales',
|
||||||
|
'219 000 noms de famille INSEE, 36 000 prénoms, '
|
||||||
|
'7 300 médicaments (BDPM), 108 000 établissements de santé (FINESS). '
|
||||||
|
'Ces bases servent à la fois à la détection et à l\'élimination '
|
||||||
|
'des faux positifs (termes médicaux confondus avec des noms).'),
|
||||||
|
]
|
||||||
|
for label, text in engines:
|
||||||
|
add_labeled_para(doc, label, text)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# ── Performances ──
|
||||||
|
add_heading_styled(doc, 'Performances mesurées', level=2)
|
||||||
|
|
||||||
|
table2 = doc.add_table(rows=0, cols=2)
|
||||||
|
table2.style = 'Table Grid'
|
||||||
|
add_table_row(table2, ['Indicateur', 'Valeur'], header=True)
|
||||||
|
|
||||||
|
perfs = [
|
||||||
|
('Taux de détection (recall)', '> 99% sur corpus réel de contrôle T2A'),
|
||||||
|
('Types de documents validés', 'CR opératoires, anatomo-pathologie, bactériologie,\n'
|
||||||
|
'anesthésie, courriers, CR d\'hospitalisation'),
|
||||||
|
('Temps moyen par document', 'Quelques dizaines de secondes (poste standard)'),
|
||||||
|
('Traitement par lot', '20-30 documents en 10-15 minutes'),
|
||||||
|
('Principe de précaution', 'En cas de doute, le logiciel masque\n'
|
||||||
|
'(préférence faux positif sur faux négatif)'),
|
||||||
|
]
|
||||||
|
for ind, val in perfs:
|
||||||
|
add_table_row(table2, [ind, val])
|
||||||
|
set_table_style(table2)
|
||||||
|
|
||||||
|
for row in table2.rows:
|
||||||
|
row.cells[0].width = Cm(5.5)
|
||||||
|
row.cells[1].width = Cm(10.5)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 6. FAQ DSI / RSSI / DPO
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_faq(doc):
|
||||||
|
add_heading_styled(doc, '6. Questions fréquentes — DSI / RSSI / DPO', level=1)
|
||||||
|
|
||||||
|
faq = [
|
||||||
|
('Les données transitent-elles par un cloud ?',
|
||||||
|
'Non. Le logiciel fonctionne intégralement hors-ligne. Aucune connexion '
|
||||||
|
'réseau n\'est établie, ni en entrée ni en sortie. Vérifiable par '
|
||||||
|
'capture réseau.'),
|
||||||
|
|
||||||
|
('Faut-il un agrément Hébergeur de Données de Santé (HDS) ?',
|
||||||
|
'Non. Aucune donnée n\'est hébergée par un tiers. Le traitement est '
|
||||||
|
'effectué localement sur l\'infrastructure de l\'établissement.'),
|
||||||
|
|
||||||
|
('Y a-t-il un sous-traitant au sens du RGPD ?',
|
||||||
|
'Non. Le logiciel est un outil exécuté en local. Aucune donnée n\'est '
|
||||||
|
'transmise à l\'éditeur ni à un tiers. L\'établissement est seul '
|
||||||
|
'responsable de traitement.'),
|
||||||
|
|
||||||
|
('Faut-il réaliser une AIPD ?',
|
||||||
|
'Le logiciel réduit le risque sur les données personnelles. Il n\'introduit '
|
||||||
|
'pas de nouveau traitement à risque. Une AIPD simplifiée peut suffire, '
|
||||||
|
'selon la politique de l\'établissement et l\'avis du DPO.'),
|
||||||
|
|
||||||
|
('Le masquage est-il réversible ?',
|
||||||
|
'Non. Le texte est supprimé du fichier PDF, pas simplement recouvert. '
|
||||||
|
'Aucun outil ne permet de récupérer les données masquées dans le '
|
||||||
|
'fichier anonymisé.'),
|
||||||
|
|
||||||
|
('Quelles données sont stockées par le logiciel ?',
|
||||||
|
'Aucune donnée personnelle. Le logiciel ne maintient ni base de données, '
|
||||||
|
'ni journal nominatif, ni historique de traitement. Seul le fichier de '
|
||||||
|
'configuration (listes de termes) est persisté.'),
|
||||||
|
|
||||||
|
('Peut-on auditer le comportement réseau ?',
|
||||||
|
'Oui. L\'absence de trafic est vérifiable par Wireshark, pare-feu '
|
||||||
|
'applicatif, ou exécution dans une VM sans interface réseau. '
|
||||||
|
'Le logiciel fonctionne de manière identique.'),
|
||||||
|
|
||||||
|
('L\'exécutable est-il signable ?',
|
||||||
|
'Oui. L\'exécutable est compatible avec les outils de signature '
|
||||||
|
'Windows standard (signtool). L\'établissement peut appliquer '
|
||||||
|
'son propre certificat de signature de code.'),
|
||||||
|
|
||||||
|
('Comment s\'intègre-t-il avec le DPI ?',
|
||||||
|
'Le logiciel est découplé du DPI. Il travaille sur des fichiers '
|
||||||
|
'exportés (PDF, DOCX, etc.). Aucun connecteur direct, aucun accès '
|
||||||
|
'aux bases cliniques, aucun risque d\'altération.'),
|
||||||
|
|
||||||
|
('Que se passe-t-il si un nom n\'est pas détecté ?',
|
||||||
|
'L\'établissement peut ajouter le terme dans la blacklist via '
|
||||||
|
'l\'interface graphique. Il sera systématiquement masqué lors des '
|
||||||
|
'prochains traitements. La configuration est immédiatement active.'),
|
||||||
|
|
||||||
|
('Que se passe-t-il si un terme médical est masqué par erreur ?',
|
||||||
|
'L\'établissement peut l\'ajouter dans la whitelist. Le terme ne sera '
|
||||||
|
'plus masqué. La configuration est échangeable entre sites.'),
|
||||||
|
|
||||||
|
('Quel est l\'impact sur la performance du poste ?',
|
||||||
|
'Le traitement mobilise le processeur pendant quelques dizaines de '
|
||||||
|
'secondes par document. L\'utilisation mémoire reste sous 4 Go. '
|
||||||
|
'Le poste est utilisable pendant le traitement.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for question, answer in faq:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
run_q = p.add_run(question)
|
||||||
|
run_q.bold = True
|
||||||
|
run_q.font.size = Pt(10)
|
||||||
|
p.paragraph_format.space_after = Pt(2)
|
||||||
|
|
||||||
|
p2 = doc.add_paragraph()
|
||||||
|
run_a = p2.add_run(answer)
|
||||||
|
run_a.font.size = Pt(9.5)
|
||||||
|
run_a.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
||||||
|
p2.paragraph_format.space_after = Pt(10)
|
||||||
|
p2.paragraph_format.left_indent = Cm(0.5)
|
||||||
|
|
||||||
|
add_section_break(doc)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# 7. MATRICE DE CONFORMITÉ
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_matrice(doc):
|
||||||
|
add_heading_styled(doc, '7. Matrice de conformité', level=1)
|
||||||
|
add_audience_tag(doc, '→ DPO, RSSI — synthèse décisionnelle')
|
||||||
|
|
||||||
|
add_para(doc,
|
||||||
|
'Tableau récapitulatif des exigences réglementaires et de sécurité, '
|
||||||
|
'et de la réponse apportée par le logiciel.',
|
||||||
|
space_after=Pt(10))
|
||||||
|
|
||||||
|
table = doc.add_table(rows=0, cols=3)
|
||||||
|
table.style = 'Table Grid'
|
||||||
|
add_table_row(table, ['Exigence', 'Statut', 'Commentaire'], header=True)
|
||||||
|
|
||||||
|
matrix = [
|
||||||
|
('Données hors établissement', 'Conforme',
|
||||||
|
'Aucune donnée ne quitte le poste'),
|
||||||
|
('Sous-traitance RGPD', 'Conforme',
|
||||||
|
'Aucun sous-traitant'),
|
||||||
|
('Privacy by design (Art. 25)', 'Conforme',
|
||||||
|
'IA embarquée, hors-ligne natif'),
|
||||||
|
('Sécurité du traitement (Art. 32)', 'Conforme',
|
||||||
|
'Périmètre = poste local'),
|
||||||
|
('AIPD (Art. 35)', 'Simplifié',
|
||||||
|
'Réduit le risque, ne le crée pas'),
|
||||||
|
('Agrément HDS', 'Non requis',
|
||||||
|
'Pas d\'hébergement tiers'),
|
||||||
|
('PGSSI-S', 'Compatible',
|
||||||
|
'Aucun flux réseau ouvert'),
|
||||||
|
('AI Act — risque', 'Risque limité',
|
||||||
|
'Outil d\'aide, pas de décision automatisée'),
|
||||||
|
('Masquage irréversible', 'Conforme',
|
||||||
|
'Suppression + rectangle noir'),
|
||||||
|
('Signature de code', 'Compatible',
|
||||||
|
'Signable par certificat établissement'),
|
||||||
|
('Auditabilité réseau', 'Vérifiable',
|
||||||
|
'Wireshark / pare-feu / sandbox'),
|
||||||
|
('Droits administrateur', 'Non requis',
|
||||||
|
'Espace utilisateur standard'),
|
||||||
|
]
|
||||||
|
for exigence, statut, comment in matrix:
|
||||||
|
row = table.add_row()
|
||||||
|
# Exigence
|
||||||
|
cell0 = row.cells[0]
|
||||||
|
cell0.text = ''
|
||||||
|
p0 = cell0.paragraphs[0]
|
||||||
|
run0 = p0.add_run(exigence)
|
||||||
|
run0.font.size = Pt(9)
|
||||||
|
p0.paragraph_format.space_before = Pt(3)
|
||||||
|
p0.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
# Statut avec couleur
|
||||||
|
cell1 = row.cells[1]
|
||||||
|
cell1.text = ''
|
||||||
|
p1 = cell1.paragraphs[0]
|
||||||
|
run1 = p1.add_run(statut)
|
||||||
|
run1.font.size = Pt(9)
|
||||||
|
run1.bold = True
|
||||||
|
if statut in ('Conforme', 'Compatible', 'Vérifiable'):
|
||||||
|
run1.font.color.rgb = GREEN_DARK
|
||||||
|
elif statut in ('Non requis', 'Simplifié', 'Risque limité'):
|
||||||
|
run1.font.color.rgb = BLUE_DARK
|
||||||
|
p1.paragraph_format.space_before = Pt(3)
|
||||||
|
p1.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
# Commentaire
|
||||||
|
cell2 = row.cells[2]
|
||||||
|
cell2.text = ''
|
||||||
|
p2 = cell2.paragraphs[0]
|
||||||
|
run2 = p2.add_run(comment)
|
||||||
|
run2.font.size = Pt(9)
|
||||||
|
run2.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
|
||||||
|
p2.paragraph_format.space_before = Pt(3)
|
||||||
|
p2.paragraph_format.space_after = Pt(3)
|
||||||
|
|
||||||
|
set_table_style(table)
|
||||||
|
|
||||||
|
for row in table.rows:
|
||||||
|
row.cells[0].width = Cm(4.5)
|
||||||
|
row.cells[1].width = Cm(2.5)
|
||||||
|
row.cells[2].width = Cm(9)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
# ASSEMBLAGE FINAL
|
||||||
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def main():
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# ── Style par défaut ──
|
||||||
|
style = doc.styles['Normal']
|
||||||
|
style.font.name = 'Calibri'
|
||||||
|
style.font.size = Pt(10)
|
||||||
|
style.paragraph_format.space_after = Pt(4)
|
||||||
|
|
||||||
|
# Marges
|
||||||
|
for section in doc.sections:
|
||||||
|
section.top_margin = Cm(1.5)
|
||||||
|
section.bottom_margin = Cm(1.5)
|
||||||
|
section.left_margin = Cm(2)
|
||||||
|
section.right_margin = Cm(2)
|
||||||
|
|
||||||
|
# Construction
|
||||||
|
build_title_page(doc)
|
||||||
|
build_architecture(doc)
|
||||||
|
build_conformite(doc)
|
||||||
|
build_securite(doc)
|
||||||
|
build_deploiement(doc)
|
||||||
|
build_garanties(doc)
|
||||||
|
build_faq(doc)
|
||||||
|
build_matrice(doc)
|
||||||
|
|
||||||
|
# Sauvegarde
|
||||||
|
output_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'Fiche_Technique_DSI_RSSI_DPO.docx'
|
||||||
|
)
|
||||||
|
doc.save(output_path)
|
||||||
|
print(f'Fiche technique générée : {output_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
115
scripts/merge_params.py
Normal file
115
scripts/merge_params.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fusionne les fichiers de paramètres envoyés par les établissements.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python scripts/merge_params.py fichier1.json [fichier2.json ...]
|
||||||
|
python scripts/merge_params.py --dir /chemin/vers/exports/
|
||||||
|
|
||||||
|
Fusionne les whitelist_phrases et blacklist_force_mask_terms de chaque
|
||||||
|
fichier JSON exporté par la GUI dans la config maîtresse (dictionnaires.yml).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print("ERREUR : pyyaml requis (pip install pyyaml)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
CONFIG = Path(__file__).parent.parent / "config" / "dictionnaires.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_params(json_files: list, config_path: Path = CONFIG, dry_run: bool = False):
|
||||||
|
"""Fusionne les paramètres des fichiers JSON dans la config YAML."""
|
||||||
|
if not config_path.exists():
|
||||||
|
print(f"ERREUR : config introuvable : {config_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
# Charger les listes existantes
|
||||||
|
existing_wl = set(cfg.get("whitelist_phrases", []))
|
||||||
|
existing_bl = set(cfg.get("blacklist", {}).get("force_mask_terms", []))
|
||||||
|
|
||||||
|
added_wl = set()
|
||||||
|
added_bl = set()
|
||||||
|
sources = []
|
||||||
|
|
||||||
|
for jf in json_files:
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(jf).read_text(encoding="utf-8"))
|
||||||
|
src = f"{Path(jf).name} (v{data.get('version', '?')}, {data.get('date_export', '?')[:10]})"
|
||||||
|
sources.append(src)
|
||||||
|
|
||||||
|
for phrase in data.get("whitelist_phrases", []):
|
||||||
|
if phrase and phrase.strip() and phrase.strip() not in existing_wl:
|
||||||
|
added_wl.add(phrase.strip())
|
||||||
|
|
||||||
|
for term in data.get("blacklist_force_mask_terms", []):
|
||||||
|
if term and str(term).strip() and str(term).strip() not in existing_bl:
|
||||||
|
added_bl.add(str(term).strip())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERREUR lecture {jf}: {e}")
|
||||||
|
|
||||||
|
print(f"\nSources traitées : {len(sources)}")
|
||||||
|
for s in sources:
|
||||||
|
print(f" - {s}")
|
||||||
|
|
||||||
|
print(f"\nNouvelles phrases whitelist : {len(added_wl)}")
|
||||||
|
for p in sorted(added_wl):
|
||||||
|
print(f" + {p}")
|
||||||
|
|
||||||
|
print(f"\nNouveaux termes blacklist : {len(added_bl)}")
|
||||||
|
for t in sorted(added_bl):
|
||||||
|
print(f" + {t}")
|
||||||
|
|
||||||
|
if not added_wl and not added_bl:
|
||||||
|
print("\nRien de nouveau à fusionner.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n(dry-run — aucune modification)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Appliquer
|
||||||
|
cfg["whitelist_phrases"] = sorted(existing_wl | added_wl)
|
||||||
|
if "blacklist" not in cfg:
|
||||||
|
cfg["blacklist"] = {}
|
||||||
|
cfg["blacklist"]["force_mask_terms"] = sorted(existing_bl | added_bl)
|
||||||
|
|
||||||
|
config_path.write_text(
|
||||||
|
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"\nConfig mise à jour : {config_path}")
|
||||||
|
print(f" Whitelist : {len(cfg['whitelist_phrases'])} phrases")
|
||||||
|
print(f" Blacklist : {len(cfg['blacklist']['force_mask_terms'])} termes")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Fusionner les paramètres d'anonymisation")
|
||||||
|
parser.add_argument("files", nargs="*", help="Fichiers JSON à fusionner")
|
||||||
|
parser.add_argument("--dir", type=Path, help="Dossier contenant les fichiers JSON")
|
||||||
|
parser.add_argument("--config", type=Path, default=CONFIG, help="Config YAML cible")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Afficher sans modifier")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
json_files = list(args.files)
|
||||||
|
if args.dir and args.dir.is_dir():
|
||||||
|
json_files.extend(str(f) for f in args.dir.glob("*.json"))
|
||||||
|
|
||||||
|
if not json_files:
|
||||||
|
print("Aucun fichier JSON spécifié. Usage :")
|
||||||
|
print(" python scripts/merge_params.py export1.json export2.json")
|
||||||
|
print(" python scripts/merge_params.py --dir /chemin/exports/")
|
||||||
|
return
|
||||||
|
|
||||||
|
merge_params(json_files, config_path=args.config, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
server.py
27
server.py
@@ -210,17 +210,34 @@ async def anonymize_text(
|
|||||||
final_text = selective_rescan(final_text, cfg=cfg)
|
final_text = selective_rescan(final_text, cfg=cfg)
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
audit_list = [
|
|
||||||
{"kind": h.kind, "original": h.original, "placeholder": h.placeholder, "page": h.page}
|
# Inclure tous les hits (regex page≥0 + NER page=-1) avec source
|
||||||
for h in anon.audit
|
ner_prefixes = ("NER_", "EDS_")
|
||||||
if h.page != -1 # exclure les propagations globales
|
audit_list = []
|
||||||
]
|
ner_count = 0
|
||||||
|
regex_count = 0
|
||||||
|
for h in anon.audit:
|
||||||
|
is_ner = h.kind.startswith(ner_prefixes) or h.page == -1
|
||||||
|
entry = {
|
||||||
|
"kind": h.kind,
|
||||||
|
"original": h.original,
|
||||||
|
"placeholder": h.placeholder,
|
||||||
|
"page": h.page,
|
||||||
|
"source": "ner" if is_ner else "regex",
|
||||||
|
}
|
||||||
|
audit_list.append(entry)
|
||||||
|
if is_ner:
|
||||||
|
ner_count += 1
|
||||||
|
else:
|
||||||
|
regex_count += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"text_anonymized": final_text,
|
"text_anonymized": final_text,
|
||||||
"audit": audit_list,
|
"audit": audit_list,
|
||||||
"stats": {
|
"stats": {
|
||||||
"pii_detected": len(audit_list),
|
"pii_detected": len(audit_list),
|
||||||
|
"regex_count": regex_count,
|
||||||
|
"ner_count": ner_count,
|
||||||
"elapsed_seconds": round(elapsed, 3),
|
"elapsed_seconds": round(elapsed, 3),
|
||||||
"ner_active": use_ner and _eds_manager is not None,
|
"ner_active": use_ner and _eds_manager is not None,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user