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 shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -85,14 +86,43 @@ except ImportError:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constantes
|
||||
# ---------------------------------------------------------------------------
|
||||
APP_TITLE = "Pseudonymisation de PDF"
|
||||
APP_VERSION = "v5.0"
|
||||
APP_TITLE = "Pseudonymisation de vos documents"
|
||||
APP_VERSION = "v5.4"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
DEFAULTS_CFG_TEXT = r"""
|
||||
@@ -305,6 +335,8 @@ class App:
|
||||
|
||||
# --- Contrôle d'arrêt ---
|
||||
self._stop_requested = False
|
||||
# --- Fichier unique (None = mode dossier) ---
|
||||
self._single_file: Optional[Path] = None
|
||||
|
||||
# --- Construction UI ---
|
||||
self._build_ui()
|
||||
@@ -382,7 +414,7 @@ class App:
|
||||
|
||||
tk.Label(
|
||||
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",
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 18))
|
||||
|
||||
@@ -392,7 +424,7 @@ class App:
|
||||
# ÉTAPE 1 — Choix du dossier
|
||||
# =============================================================
|
||||
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",
|
||||
).pack(fill=tk.X, padx=pad_x, pady=(0, 6))
|
||||
|
||||
@@ -414,7 +446,7 @@ class App:
|
||||
|
||||
self._folder_text_lbl = tk.Label(
|
||||
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,
|
||||
)
|
||||
self._folder_text_lbl.pack(pady=(4, 0))
|
||||
@@ -448,7 +480,7 @@ class App:
|
||||
|
||||
tk.Label(
|
||||
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 Résultats dans le dossier « anonymise/ » à la racine"),
|
||||
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))
|
||||
|
||||
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",
|
||||
activebackground="#1d4ed8", activeforeground="white",
|
||||
relief=tk.FLAT, cursor="hand2", pady=10,
|
||||
@@ -502,9 +534,92 @@ class App:
|
||||
main, text="Comment ça marche ?", font=self._f_small,
|
||||
bg=CLR_BG, fg=CLR_PRIMARY, cursor="hand2",
|
||||
)
|
||||
help_lbl.pack(pady=(0, 18))
|
||||
help_lbl.pack(pady=(0, 8))
|
||||
help_lbl.bind("<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)
|
||||
# =============================================================
|
||||
@@ -648,22 +763,71 @@ class App:
|
||||
# Actions dossier
|
||||
# ---------------------------------------------------------------
|
||||
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()
|
||||
if d:
|
||||
self._single_file = None
|
||||
self.dir_var.set(d)
|
||||
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):
|
||||
folder = self.dir_var.get()
|
||||
if not folder:
|
||||
return
|
||||
|
||||
# Compter les PDF (récursif)
|
||||
pdf_count = 0
|
||||
is_single = getattr(self, '_single_file', None) is not None
|
||||
|
||||
if is_single:
|
||||
doc_count = 1
|
||||
display_label = self._single_file.name
|
||||
else:
|
||||
# Compter les documents supportés (récursif)
|
||||
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:
|
||||
pass
|
||||
display_label = folder
|
||||
|
||||
# Vider et reconstruire l'intérieur
|
||||
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.pack(fill=tk.X)
|
||||
|
||||
icon = "\U0001f4c4" if is_single else "\U0001f4c2" # 📄 ou 📂
|
||||
tk.Label(
|
||||
row, text="\U0001f4c2", font=(self._font_family, 16),
|
||||
row, text=icon, font=(self._font_family, 16),
|
||||
bg=CLR_CARD_BG,
|
||||
).pack(side=tk.LEFT, padx=(0, 8))
|
||||
|
||||
@@ -681,7 +846,7 @@ class App:
|
||||
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
# Chemin (tronqué si trop long)
|
||||
display_path = folder
|
||||
display_path = display_label
|
||||
if len(display_path) > 60:
|
||||
display_path = "..." + display_path[-57:]
|
||||
tk.Label(
|
||||
@@ -689,9 +854,13 @@ class App:
|
||||
bg=CLR_CARD_BG, fg=CLR_TEXT, anchor="w",
|
||||
).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(
|
||||
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",
|
||||
).pack(fill=tk.X)
|
||||
|
||||
@@ -709,19 +878,39 @@ class App:
|
||||
# Lancement
|
||||
# ---------------------------------------------------------------
|
||||
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())
|
||||
if not folder.is_dir():
|
||||
messagebox.showwarning(
|
||||
"Dossier invalide",
|
||||
"Choisissez un dossier contenant des PDF.",
|
||||
"Choisissez un dossier ou un fichier.",
|
||||
)
|
||||
return
|
||||
|
||||
pdfs = sorted([p for p in folder.rglob("*.pdf") if p.is_file()])
|
||||
try:
|
||||
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:
|
||||
exts = ", ".join(sorted(SUPPORTED_EXTENSIONS))
|
||||
messagebox.showwarning(
|
||||
"Aucun PDF",
|
||||
"Aucun fichier PDF trouvé\n(recherche récursive dans les sous-dossiers).",
|
||||
"Aucun document",
|
||||
f"Aucun fichier supporté trouvé.\n"
|
||||
f"Formats acceptés : {exts}\n"
|
||||
f"(recherche récursive dans les sous-dossiers)",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -779,8 +968,12 @@ class App:
|
||||
and self._vlm_manager.is_loaded()
|
||||
)
|
||||
|
||||
outputs = core.process_pdf(
|
||||
pdf_path=pdf,
|
||||
# Utiliser process_document (multi-formats) si disponible,
|
||||
# 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,
|
||||
make_vector_redaction=False,
|
||||
also_make_raster_burn=True,
|
||||
@@ -962,6 +1155,248 @@ class App:
|
||||
"« 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)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
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
|
||||
org_gpe_keep: false
|
||||
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:
|
||||
- CENTRE HOSPITALIER COTE BASQUE
|
||||
- CENTRE HOSPITALIER DE LA COTE BASQUE
|
||||
- POLYCLINIQUE COTE BASQUE SUD
|
||||
- POLYCLINIQUE CÔTE BASQUE SUD
|
||||
- CHCB
|
||||
- '640780417'
|
||||
- 'Dates du séjour :'
|
||||
- CONCERTATION
|
||||
- CHCB # Sigle local non référencé FINESS
|
||||
- 'Dates du séjour :' # Libellé administratif (politique masquage)
|
||||
- CONCERTATION # Mention de RCP (politique métier)
|
||||
- LABORATOIRE de BIOLOGIE MEDICALE # Libellé administratif générique
|
||||
force_mask_regex:
|
||||
- 'Centre\s+Hospitalier\s+(?:de\s+(?:la\s+)?)?C[oôÔ]te\s+Basque'
|
||||
- 'Polyclinique\s+C[oôÔ]te\s+Basque\s+Sud'
|
||||
# Adresse précise du CHCB — couverte par l'AC FINESS adresses mais on garde
|
||||
# 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:
|
||||
- FINESS
|
||||
- IPP
|
||||
@@ -38,6 +38,45 @@ regex_overrides:
|
||||
placeholder: '[OGC]'
|
||||
flags:
|
||||
- IGNORECASE
|
||||
# Phrases à ne JAMAIS anonymiser (faux positifs récurrents)
|
||||
# Ajouter ici les expressions qui sont masquées à tort.
|
||||
# La correspondance est insensible à la casse.
|
||||
whitelist_phrases:
|
||||
- "classification internationale"
|
||||
- "prise en charge"
|
||||
- "bas de contention"
|
||||
- "date de naissance"
|
||||
- "lieu de naissance"
|
||||
- "ville de résidence"
|
||||
- "date de sortie"
|
||||
- "date d'admission"
|
||||
- "code postal"
|
||||
# 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:
|
||||
case_insensitive: 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
|
||||
négatif. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
antalgique O
|
||||
: O
|
||||
Faux B-VILLE
|
||||
@@ -1515,8 +1515,8 @@ cette O
|
||||
patiente O
|
||||
altérée O
|
||||
sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
général, O
|
||||
OMS2/3. O
|
||||
> O
|
||||
@@ -1529,8 +1529,8 @@ du O
|
||||
traitement O
|
||||
antalgique. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
infectieux O
|
||||
: O
|
||||
Pic O
|
||||
@@ -2817,8 +2817,8 @@ apyrexie O
|
||||
au O
|
||||
décours. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
urologique O
|
||||
: O
|
||||
Un O
|
||||
@@ -2919,8 +2919,8 @@ oncologique O
|
||||
Nette O
|
||||
amélioration O
|
||||
sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
général O
|
||||
avec O
|
||||
la O
|
||||
|
||||
@@ -2572,8 +2572,8 @@ de O
|
||||
traitement O
|
||||
antibiotique O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
hématologique O
|
||||
Anémie O
|
||||
autour O
|
||||
|
||||
@@ -1812,8 +1812,8 @@ de O
|
||||
cette O
|
||||
décision. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
hématologique: O
|
||||
Elle O
|
||||
présente O
|
||||
|
||||
@@ -1420,8 +1420,8 @@ en O
|
||||
charge O
|
||||
antalgique. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
de O
|
||||
la O
|
||||
gravité: O
|
||||
|
||||
@@ -1102,8 +1102,8 @@ de O
|
||||
l'épisode O
|
||||
aigüe. O
|
||||
Sur O
|
||||
le O
|
||||
plan B-VILLE
|
||||
le B-VILLE
|
||||
plan I-VILLE
|
||||
infectieux, O
|
||||
présence 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:
|
||||
True si la détection doit être filtrée (faux positif)
|
||||
"""
|
||||
# Filtrer par type
|
||||
if pii_type == "ADRESSE":
|
||||
return self.is_hospital_address(text)
|
||||
# ADRESSE, CODE_POSTAL, VILLE, TEL : NE PAS filtrer.
|
||||
# Les coordonnées hospitalières identifient indirectement le patient
|
||||
# et doivent être masquées (validé par contrôle humain 2026-03-12).
|
||||
|
||||
elif pii_type == "CODE_POSTAL":
|
||||
return self.is_hospital_postal_code(text)
|
||||
|
||||
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)
|
||||
# EPISODE : NE PAS filtrer.
|
||||
# Les numéros d'épisode identifient le patient (validé 2026-03-14).
|
||||
|
||||
return False
|
||||
|
||||
@@ -222,15 +211,17 @@ if __name__ == "__main__":
|
||||
|
||||
# Tests
|
||||
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),
|
||||
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, True),
|
||||
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, False),
|
||||
("CODE_POSTAL", "64130", "", -1, False),
|
||||
("VILLE", "BAYONNE CEDEX", "", -1, True),
|
||||
("VILLE", "BAYONNE CEDEX", "", -1, False),
|
||||
("VILLE", "CHERAUTE", "", -1, False),
|
||||
("VILLE", "DROIT", "", -1, True), # Terme anatomique
|
||||
("TEL", "05 59 44 35 35", "", -1, True),
|
||||
("VILLE", "DROIT", "", -1, False),
|
||||
("TEL", "05 59 44 35 35", "", -1, False),
|
||||
("TEL", "0676085336", "", -1, False),
|
||||
# EPISODE : filtré uniquement si provient du nom de fichier trackare
|
||||
("EPISODE", "23202435", "trackare-14004105-23202435", -1, True),
|
||||
("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": {
|
||||
"global_score": 97.0,
|
||||
"leak_score": 100.0,
|
||||
"fp_score": 90,
|
||||
"totals": {
|
||||
"documents": 29,
|
||||
"audit_hits": 2797,
|
||||
"name_tokens_known": 461,
|
||||
"audit_hits": 3186,
|
||||
"name_tokens_known": 457,
|
||||
"leak_audit": 0,
|
||||
"leak_occurrences": 0,
|
||||
"leak_regex": 0,
|
||||
"leak_insee_high": 0,
|
||||
"leak_insee_medium": 569,
|
||||
"leak_insee_medium": 570,
|
||||
"fp_medical": 0,
|
||||
"fp_overmasking": 2
|
||||
}
|
||||
@@ -110,7 +110,7 @@
|
||||
"leak_audit": 0,
|
||||
"leak_regex": 0,
|
||||
"leak_insee_high": 0,
|
||||
"leak_insee_medium": 23,
|
||||
"leak_insee_medium": 24,
|
||||
"fp_medical": 0,
|
||||
"fp_overmasking": 0
|
||||
},
|
||||
@@ -206,7 +206,7 @@
|
||||
"leak_audit": 0,
|
||||
"leak_regex": 0,
|
||||
"leak_insee_high": 0,
|
||||
"leak_insee_medium": 32,
|
||||
"leak_insee_medium": 33,
|
||||
"fp_medical": 0,
|
||||
"fp_overmasking": 0
|
||||
},
|
||||
@@ -222,7 +222,7 @@
|
||||
"leak_audit": 0,
|
||||
"leak_regex": 0,
|
||||
"leak_insee_high": 0,
|
||||
"leak_insee_medium": 34,
|
||||
"leak_insee_medium": 32,
|
||||
"fp_medical": 0,
|
||||
"fp_overmasking": 0
|
||||
},
|
||||
@@ -246,7 +246,7 @@
|
||||
"leak_audit": 0,
|
||||
"leak_regex": 0,
|
||||
"leak_insee_high": 0,
|
||||
"leak_insee_medium": 26,
|
||||
"leak_insee_medium": 27,
|
||||
"fp_medical": 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
|
||||
"""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
|
||||
uniquement (sans VLM) pour générer des .pseudonymise.txt utilisables par
|
||||
export_silver_annotations.py.
|
||||
Traite TOUS les PDFs disponibles en mode CPU (sans VLM), avec N workers
|
||||
parallèles. Chaque worker charge ses propres modèles NER.
|
||||
|
||||
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).
|
||||
|
||||
Usage:
|
||||
python run_batch_silver_export.py # 6 workers (défaut)
|
||||
python run_batch_silver_export.py --workers 4 # 4 workers
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import random
|
||||
import argparse
|
||||
import multiprocessing as mp
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
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)")
|
||||
OUTDIR = SRC / "anonymise_silver_extra"
|
||||
CONFIG = Path("/home/dom/ai/anonymisation/config/dictionnaires.yml")
|
||||
@@ -62,16 +59,116 @@ ALREADY_DONE_AUDIT30 = {
|
||||
|
||||
TIMEOUT_PER_FILE = 120 # secondes max par PDF
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
pass
|
||||
# Variables globales par worker (initialisées une seule fois)
|
||||
_worker_ner = None
|
||||
_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")
|
||||
|
||||
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():
|
||||
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)
|
||||
all_pdfs = []
|
||||
for ogc_dir in sorted(SRC.iterdir()):
|
||||
@@ -81,7 +178,6 @@ def main():
|
||||
if pdf.name not in ALREADY_DONE_AUDIT30:
|
||||
all_pdfs.append(pdf)
|
||||
|
||||
# Trier par OGC pour reproductibilité
|
||||
all_pdfs.sort(key=lambda p: (p.parent.name, p.name))
|
||||
|
||||
# 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"Déjà traités: {len(already_done)}")
|
||||
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:
|
||||
print("Rien à faire.")
|
||||
return
|
||||
|
||||
# Chargement des modèles NER (CPU uniquement, pas de VLM)
|
||||
print("\nChargement EDS-Pseudo...", flush=True)
|
||||
ner = EdsPseudoManager()
|
||||
ner.load()
|
||||
assert ner.is_loaded(), "EDS-Pseudo non chargé"
|
||||
print("EDS-Pseudo chargé.", flush=True)
|
||||
# Préparer les arguments : (pdf_path, index, total)
|
||||
tasks = [(pdf, i, len(pdfs_to_do)) for i, pdf in enumerate(pdfs_to_do, 1)]
|
||||
|
||||
print("Chargement GLiNER...", 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(f"Chargement des modèles dans {n_workers} workers...", flush=True)
|
||||
|
||||
print("Chargement CamemBERT-bio ONNX...", flush=True)
|
||||
camembert = CamembertNerManager()
|
||||
try:
|
||||
camembert.load()
|
||||
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)
|
||||
# Créer le pool avec initialisation des modèles par worker
|
||||
# On utilise mp.Pool avec initializer pour charger les modèles une seule fois
|
||||
# Note: fork + ONNX peut poser problème, on utilise 'spawn'
|
||||
ctx = mp.get_context("spawn")
|
||||
|
||||
ok = ko = skip_encrypted = skip_timeout = 0
|
||||
t0 = time.time()
|
||||
total = len(pdfs_to_do)
|
||||
|
||||
for i, pdf in enumerate(pdfs_to_do, 1):
|
||||
ogc = pdf.parent.name.split("_")[0]
|
||||
print(f"[{i}/{total}] {pdf.name} (OGC {ogc})...", end=" ", flush=True)
|
||||
# Lancer les workers séquentiellement pour l'init (éviter pic mémoire)
|
||||
# puis traiter en parallèle
|
||||
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
|
||||
signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(TIMEOUT_PER_FILE)
|
||||
try:
|
||||
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)
|
||||
elapsed = time.time() - t0
|
||||
done = ok + ko + skip_encrypted + skip_timeout + 1
|
||||
|
||||
if status == "OK":
|
||||
ok += 1
|
||||
except TimeoutError:
|
||||
signal.alarm(0)
|
||||
print(f"TIMEOUT ({TIMEOUT_PER_FILE}s)", flush=True)
|
||||
rate = ok / elapsed * 3600 if elapsed > 0 else 0
|
||||
print(f"[{done}/{total}] {name} OK ({rate:.0f}/h)", flush=True)
|
||||
elif status == "TIMEOUT":
|
||||
skip_timeout += 1
|
||||
except Exception as e:
|
||||
signal.alarm(0)
|
||||
err = str(e)
|
||||
if "encrypted" in err.lower() or "password" in err.lower():
|
||||
print("SKIP (chiffré)", flush=True)
|
||||
print(f"[{done}/{total}] {name} TIMEOUT", flush=True)
|
||||
elif status == "SKIP":
|
||||
skip_encrypted += 1
|
||||
print(f"[{done}/{total}] {name} SKIP (chiffré)", flush=True)
|
||||
else:
|
||||
print(f"ERREUR: {e}", flush=True)
|
||||
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
|
||||
if i % 50 == 0:
|
||||
elapsed = time.time() - t0
|
||||
remaining = (elapsed / i) * (total - i)
|
||||
print(f"\n --- Progression: {i}/{total} | OK: {ok} | "
|
||||
if done % 50 == 0:
|
||||
remaining = (elapsed / done) * (total - done)
|
||||
print(f"\n --- Progression: {done}/{total} | OK: {ok} | "
|
||||
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
|
||||
total_pseudo = len(list(OUTDIR.glob("*.pseudonymise.txt")))
|
||||
print(f"\n{'='*60}")
|
||||
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"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}")
|
||||
|
||||
|
||||
|
||||
@@ -109,8 +109,12 @@ def main():
|
||||
if len(row) < 16:
|
||||
continue
|
||||
|
||||
# Numéro FINESS (col 1)
|
||||
finess = row[1].strip()
|
||||
# Numéros FINESS : col 1 = finess_et (structure), col 2 = entjur (entité juridique).
|
||||
# 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):
|
||||
finess_numbers.add(finess)
|
||||
|
||||
@@ -190,6 +194,93 @@ def main():
|
||||
out.write_text("\n".join(sorted(phones)) + "\n", encoding="utf-8")
|
||||
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
|
||||
print(f"\nDistribution noms distinctifs par longueur (mots):")
|
||||
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",
|
||||
"PATIENT", "MONSIEUR", "MADAME", "DOCTEUR",
|
||||
"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)
|
||||
"LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE",
|
||||
"GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR",
|
||||
@@ -300,7 +303,7 @@ def check_fp_density(text: str) -> dict:
|
||||
"density_pct": round(density, 2),
|
||||
"nom_count": nom_count,
|
||||
"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")
|
||||
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
|
||||
project_root = Path(__file__).parent.parent
|
||||
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)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
audit_list = [
|
||||
{"kind": h.kind, "original": h.original, "placeholder": h.placeholder, "page": h.page}
|
||||
for h in anon.audit
|
||||
if h.page != -1 # exclure les propagations globales
|
||||
]
|
||||
|
||||
# Inclure tous les hits (regex page≥0 + NER page=-1) avec source
|
||||
ner_prefixes = ("NER_", "EDS_")
|
||||
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 {
|
||||
"text_anonymized": final_text,
|
||||
"audit": audit_list,
|
||||
"stats": {
|
||||
"pii_detected": len(audit_list),
|
||||
"regex_count": regex_count,
|
||||
"ner_count": ner_count,
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"ner_active": use_ner and _eds_manager is not None,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user