420 lines
20 KiB
Python
420 lines
20 KiB
Python
"""Onglet « Configuration » de la GUI V6 (G4 — alignement maquette).
|
||
|
||
Sous-navigation Réglages / Masquage / Partage / Règles (cf. maquette). Le
|
||
sous-onglet Réglages édite un :class:`ConfigState` partagé (profil, NER local,
|
||
moteurs). Les autres sous-onglets reprennent le style maquette (cartes denses).
|
||
Aucune logique de détection ; ``config_defaults.py`` n'est pas modifié.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from tkinter import filedialog
|
||
|
||
import customtkinter as ctk
|
||
|
||
from gui_v6 import theme as theme_mod
|
||
from gui_v6 import ui_kit
|
||
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
|
||
|
||
_SUBTABS = [
|
||
("reg", "⚙️ Réglages"),
|
||
("msk", "🎭 Masquage"),
|
||
("shr", "🔄 Partage"),
|
||
("rul", "🛡️ Règles 2"),
|
||
]
|
||
|
||
_DETECTION_OPTIONS = [
|
||
("Noms et prénoms", "Gazetteers INSEE · CamemBERT"),
|
||
("Dates de naissance", "Uniquement la date de naissance"),
|
||
("Établissements", "Répertoire FINESS + contexte"),
|
||
("Adresses et codes postaux", ""),
|
||
("N° sécurité sociale", ""),
|
||
("Téléphones et e-mails", ""),
|
||
("N° adhérent mutuelle", ""),
|
||
]
|
||
|
||
_PRESERVE_TERMS = ["FUROSEMIDE", "rééducation fonctionnelle", "classification internationale"]
|
||
_MASK_TERMS = ["CHUXX"]
|
||
|
||
CONFIG_MOCKUP_SECTIONS = {
|
||
"reglages": [
|
||
"Profil métier",
|
||
"Moteurs NER",
|
||
"Données à détecter",
|
||
"Termes à toujours conserver",
|
||
"Termes à toujours masquer",
|
||
],
|
||
"masquage": [
|
||
"Couleur de masquage (PDF)",
|
||
"Style des marqueurs (texte)",
|
||
"Épaisseur du masque",
|
||
"Codes de remplacement",
|
||
"Masques de zones fixes",
|
||
],
|
||
"partage": ["Exporter la configuration", "Importer une configuration"],
|
||
"regles": ["Règles actives", "Testeur de règle"],
|
||
}
|
||
|
||
|
||
class ConfigTab(ctk.CTkFrame):
|
||
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
|
||
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
|
||
super().__init__(master, fg_color=self._p["bg"], **kwargs)
|
||
self._state = state if state is not None else ConfigState()
|
||
self._sub = "reg"
|
||
self._sub_buttons: dict = {}
|
||
self._build()
|
||
|
||
@property
|
||
def state(self) -> ConfigState:
|
||
return self._state
|
||
|
||
def _build(self) -> None:
|
||
p = self._p
|
||
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||
bar.pack(fill="x", padx=14, pady=(14, 4))
|
||
for key, label in _SUBTABS:
|
||
btn = ctk.CTkButton(
|
||
bar, text=label, command=lambda k=key: self._show_sub(k),
|
||
fg_color="transparent", hover_color=p["card_border"],
|
||
text_color=p["primary"] if key == self._sub else p["text_dim"],
|
||
font=ui_kit.font(13, "bold" if key == self._sub else "normal"),
|
||
corner_radius=0, width=10,
|
||
)
|
||
btn.pack(side="left", padx=3)
|
||
self._sub_buttons[key] = btn
|
||
|
||
self._body = ctk.CTkFrame(self, fg_color="transparent")
|
||
self._body.pack(fill="both", expand=True)
|
||
self._show_sub("reg")
|
||
|
||
def _show_sub(self, key: str) -> None:
|
||
self._sub = key
|
||
p = self._p
|
||
for k, btn in self._sub_buttons.items():
|
||
btn.configure(text_color=p["primary"] if k == key else p["text_dim"],
|
||
font=ui_kit.font(13, "bold" if k == key else "normal"))
|
||
for w in self._body.winfo_children():
|
||
w.destroy()
|
||
{"reg": self._build_reglages, "msk": self._build_masquage,
|
||
"shr": self._build_partage, "rul": self._build_regles}[key]()
|
||
|
||
# -- Réglages (câblé ConfigState) -------------------------------------
|
||
|
||
def _build_reglages(self) -> None:
|
||
p = self._p
|
||
|
||
cols = self._two_columns(self._body)
|
||
left, right = cols
|
||
|
||
prof = ui_kit.Card(left, p, title="🗂️ Profil métier")
|
||
prof.pack(fill="x", pady=7)
|
||
row = ctk.CTkFrame(prof, fg_color="transparent")
|
||
row.pack(fill="x", padx=16, pady=(0, 10))
|
||
profiles = list_profile_keys()
|
||
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
|
||
self._state.profile = current or None
|
||
ctk.CTkLabel(row, text="Profil :", text_color=p["text"], font=ui_kit.font(13)).pack(side="left", padx=(0, 8))
|
||
menu = ctk.CTkOptionMenu(row, values=profiles or ["(aucun profil)"], command=self._on_profile,
|
||
fg_color=p["btn_sec_bg"], button_color=p["primary"], text_color=p["text"])
|
||
if current:
|
||
menu.set(current)
|
||
menu.pack(side="left")
|
||
outrow = ctk.CTkFrame(prof, fg_color="transparent")
|
||
outrow.pack(fill="x", padx=16, pady=(0, 14))
|
||
ui_kit.secondary_button(outrow, p, "📁 Dossier de sortie…", command=self._pick_output).pack(side="left", padx=(0, 8))
|
||
self._out_label = ctk.CTkLabel(outrow, text=str(self._state.output_dir or "(défaut anonymise/)"),
|
||
text_color=p["text_muted"], font=ui_kit.font(12))
|
||
self._out_label.pack(side="left")
|
||
|
||
ner = ui_kit.Card(left, p, title="🧠 Moteurs NER")
|
||
ner.pack(fill="x", pady=7)
|
||
self._tog_ner = ui_kit.ToggleRow(ner, p, "CamemBERT-bio ⚡", "~10 ms/doc · F1 = 0.963", value=self._state.use_local_ner, command=self._on_ner)
|
||
self._tog_ner.pack(fill="x", padx=16, pady=2)
|
||
self._tog_eds = ui_kit.ToggleRow(ner, p, "EDS-Pseudo PRÉCIS", "~200 ms/doc · médical français", value=self._state.enable_eds, command=self._on_eds)
|
||
self._tog_eds.pack(fill="x", padx=16, pady=2)
|
||
self._tog_gli = ui_kit.ToggleRow(ner, p, "GLiNER OPTIONNEL", "~95 ms/doc · vote croisé", value=self._state.enable_gliner, command=self._on_gliner)
|
||
self._tog_gli.pack(fill="x", padx=16, pady=(2, 14))
|
||
|
||
det = ui_kit.Card(right, p, title="🔍 Données à détecter")
|
||
det.pack(fill="x", pady=7)
|
||
for label, hint in _DETECTION_OPTIONS:
|
||
ui_kit.ToggleRow(det, p, label, hint, value=True).pack(fill="x", padx=16, pady=2)
|
||
ctk.CTkFrame(det, fg_color="transparent", height=8).pack()
|
||
|
||
keep = ui_kit.Card(right, p, title="✅ Termes à toujours conserver")
|
||
keep.pack(fill="x", pady=7)
|
||
self._note(keep, "Ces termes ne seront jamais masqués, même s'ils ressemblent à un nom propre.")
|
||
self._tag_editor(keep, "Ex : FUROSEMIDE…", _PRESERVE_TERMS, "keep")
|
||
|
||
mask = ui_kit.Card(right, p, title="🚫 Termes à toujours masquer")
|
||
mask.pack(fill="x", pady=7)
|
||
self._note(mask, "Ces termes seront toujours masqués, même sans contexte médical autour.")
|
||
self._tag_editor(mask, "Ex : CHUXX, Dr Dupont…", _MASK_TERMS, "mask")
|
||
|
||
def _build_masquage(self) -> None:
|
||
p = self._p
|
||
cols = self._two_columns(self._body)
|
||
left, right = cols
|
||
|
||
color = ui_kit.Card(left, p, title="⬛ Couleur de masquage (PDF)")
|
||
color.pack(fill="x", pady=7)
|
||
self._note(color, "Couleur des rectangles dans le PDF final.")
|
||
swatches = ctk.CTkFrame(color, fg_color="transparent")
|
||
swatches.pack(fill="x", padx=16, pady=(0, 14))
|
||
for color_value in ["#000000", "#1a1a2e", "#374151", "#92400e", "#1e3a5f"]:
|
||
ctk.CTkButton(
|
||
swatches,
|
||
text="",
|
||
width=30,
|
||
height=30,
|
||
fg_color=color_value,
|
||
hover_color=color_value,
|
||
border_color=p["primary"] if color_value == "#000000" else p["card_border"],
|
||
border_width=3 if color_value == "#000000" else 1,
|
||
corner_radius=6,
|
||
command=lambda: None,
|
||
).pack(side="left", padx=(0, 8))
|
||
|
||
style = ui_kit.Card(left, p, title="🏷️ Style des marqueurs (texte)")
|
||
style.pack(fill="x", pady=7)
|
||
self._mask_style_var = ctk.StringVar(value="brackets")
|
||
for label, value, preview in [
|
||
("Crochets", "brackets", "[NOM]"),
|
||
("Étoiles", "stars", "***"),
|
||
("Noirci", "blackout", "████"),
|
||
]:
|
||
row = ctk.CTkFrame(style, fg_color="transparent")
|
||
row.pack(fill="x", padx=16, pady=2)
|
||
ctk.CTkRadioButton(
|
||
row,
|
||
text=f"{label} — {preview}",
|
||
variable=self._mask_style_var,
|
||
value=value,
|
||
command=self._update_mask_preview,
|
||
text_color=p["text"],
|
||
fg_color=p["primary"],
|
||
hover_color=p["primary_dim"],
|
||
font=ui_kit.font(13),
|
||
).pack(anchor="w")
|
||
self._mask_preview = ctk.CTkLabel(
|
||
style,
|
||
text="Patient [NOM], né le [DATE_NAISSANCE]",
|
||
text_color=p["text_dim"],
|
||
fg_color=p["divider"],
|
||
corner_radius=6,
|
||
font=ui_kit.font(13),
|
||
anchor="w",
|
||
)
|
||
self._mask_preview.pack(fill="x", padx=16, pady=(8, 14), ipady=8)
|
||
|
||
thick = ui_kit.Card(right, p, title="📐 Épaisseur du masque")
|
||
thick.pack(fill="x", pady=7)
|
||
self._note(thick, "Marge autour du texte masqué, en points.")
|
||
self._slider_row(thick, "Marge horizontale", 2)
|
||
self._slider_row(thick, "Marge verticale", 1)
|
||
ui_kit.ToggleRow(thick, p, "Coins arrondis", "", value=False).pack(fill="x", padx=16, pady=(2, 14))
|
||
|
||
codes = ui_kit.Card(right, p, title="🔒 Codes de remplacement")
|
||
codes.pack(fill="x", pady=7)
|
||
for k, v in [
|
||
("Nom/Prénom", "[NOM]"),
|
||
("Date naissance", "[DATE_NAISSANCE]"),
|
||
("Établissement", "[ETABLISSEMENT]"),
|
||
("Adresse", "[ADRESSE]"),
|
||
("Téléphone", "[TEL]"),
|
||
("N° sécu", "[NIR]"),
|
||
("IPP", "[IPP]"),
|
||
("Email", "[EMAIL]"),
|
||
]:
|
||
r = ctk.CTkFrame(codes, fg_color="transparent")
|
||
r.pack(fill="x", padx=16, pady=1)
|
||
ctk.CTkLabel(r, text=k, text_color=p["text_muted"], font=ui_kit.font(12)).pack(side="left")
|
||
ctk.CTkLabel(r, text=v, text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="right")
|
||
ctk.CTkFrame(codes, fg_color="transparent", height=8).pack()
|
||
|
||
editor = ui_kit.Card(self._body, p, title="🏠 Masques de zones fixes (logos, en-têtes)")
|
||
editor.pack(fill="x", padx=14, pady=7)
|
||
top = ctk.CTkFrame(editor, fg_color="transparent")
|
||
top.pack(fill="x", padx=16, pady=(0, 8))
|
||
ctk.CTkLabel(
|
||
top,
|
||
text="Dessinez des rectangles sur un PDF modèle pour masquer systématiquement les logos, en-têtes ou zones fixes.",
|
||
text_color=p["text_dim"],
|
||
font=ui_kit.font(12),
|
||
justify="left",
|
||
wraplength=610,
|
||
).pack(side="left", fill="x", expand=True)
|
||
ui_kit.primary_button(top, p, "✏ Ouvrir l'éditeur de masques", command=lambda: None).pack(side="right", padx=(8, 0))
|
||
toolbar = ctk.CTkFrame(editor, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8)
|
||
toolbar.pack(fill="x", padx=16, pady=(0, 10))
|
||
for label in ["📄 Ouvrir PDF…", "−", "100%", "+", "💾 Sauver", "📁 Charger", "🗑 Effacer page"]:
|
||
ui_kit.secondary_button(toolbar, p, label, command=lambda: None).pack(side="left", padx=4, pady=8)
|
||
canvas = ctk.CTkFrame(editor, fg_color=p["divider"], border_color=p["card_border"], border_width=1, corner_radius=8, height=150)
|
||
canvas.pack(fill="x", padx=16, pady=(0, 12))
|
||
canvas.pack_propagate(False)
|
||
ctk.CTkLabel(
|
||
canvas,
|
||
text="📄\nOuvrez un PDF pour commencer à dessiner des zones de masquage.\nCliquez-glissez pour tracer un rectangle.",
|
||
text_color=p["text_muted"],
|
||
font=ui_kit.font(12),
|
||
justify="center",
|
||
).pack(expand=True)
|
||
|
||
def _build_partage(self) -> None:
|
||
p = self._p
|
||
export = ui_kit.Card(self._body, p, title="📤 Exporter la configuration")
|
||
export.pack(fill="x", padx=14, pady=7)
|
||
self._note(export, "Génère un fichier .json avec vos listes, à envoyer par e-mail à d'autres établissements.")
|
||
ui_kit.secondary_button(export, p, "⬇ Exporter (.json)", command=lambda: None).pack(anchor="w", padx=16, pady=(0, 14))
|
||
|
||
import_card = ui_kit.Card(self._body, p, title="📥 Importer une configuration")
|
||
import_card.pack(fill="x", padx=14, pady=7)
|
||
self._note(import_card, "Importez un fichier reçu. Vos réglages locaux ne seront pas supprimés.")
|
||
ui_kit.secondary_button(import_card, p, "⬆ Importer (.json)", command=lambda: None).pack(anchor="w", padx=16, pady=(0, 14))
|
||
|
||
def _build_regles(self) -> None:
|
||
p = self._p
|
||
card = ui_kit.Card(self._body, p, title="🛡️ Règles actives")
|
||
card.pack(fill="x", padx=14, pady=7)
|
||
self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.")
|
||
headers = ctk.CTkFrame(card, fg_color="transparent")
|
||
headers.pack(fill="x", padx=16, pady=(0, 4))
|
||
for text, width in [("Label", 190), ("Type", 80), ("Cible → Résultat", 210), ("Statut", 70), ("", 70)]:
|
||
ctk.CTkLabel(headers, text=text.upper(), width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left")
|
||
for row in [
|
||
("Masquer le sigle CHUXX", "exact", "CHUXX → [MASK]", "Actif"),
|
||
("Préserver “classification internationale”", "preserve", "conservé tel quel", "Actif"),
|
||
("Identifier N° 1234567", "norm-id", "N° 1234567 → [NDA]", "Candidat"),
|
||
]:
|
||
self._rule_row(card, row)
|
||
actions = ctk.CTkFrame(card, fg_color="transparent")
|
||
actions.pack(fill="x", padx=16, pady=(10, 14))
|
||
ui_kit.primary_button(actions, p, "+ Nouvelle règle", command=lambda: None).pack(side="left", padx=(0, 8))
|
||
ui_kit.secondary_button(actions, p, "🔄 Recharger", command=lambda: None).pack(side="left")
|
||
|
||
sim = ui_kit.Card(self._body, p, title="🧪 Testeur de règle")
|
||
sim.pack(fill="x", padx=14, pady=7)
|
||
ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=16)
|
||
txt = ctk.CTkTextbox(sim, height=78, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1)
|
||
txt.pack(fill="x", padx=16, pady=(5, 10))
|
||
txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.")
|
||
btns = ctk.CTkFrame(sim, fg_color="transparent")
|
||
btns.pack(fill="x", padx=16, pady=(0, 14))
|
||
ui_kit.primary_button(btns, p, "▶ Tester", command=lambda: None).pack(side="left", padx=(0, 8))
|
||
ui_kit.secondary_button(btns, p, "✖ Fermer", command=lambda: None).pack(side="left")
|
||
|
||
# -- callbacks --------------------------------------------------------
|
||
|
||
def _on_profile(self, value: str) -> None:
|
||
self._state.profile = value
|
||
|
||
def _on_ner(self) -> None:
|
||
self._state.use_local_ner = self._tog_ner.get()
|
||
|
||
def _on_eds(self) -> None:
|
||
self._state.enable_eds = self._tog_eds.get()
|
||
|
||
def _on_gliner(self) -> None:
|
||
self._state.enable_gliner = self._tog_gli.get()
|
||
|
||
def _pick_output(self) -> None:
|
||
path = filedialog.askdirectory(title="Dossier de sortie")
|
||
if path:
|
||
self._state.output_dir = Path(path)
|
||
self._out_label.configure(text=str(self._state.output_dir))
|
||
|
||
# -- helpers UI -------------------------------------------------------
|
||
|
||
def _two_columns(self, parent) -> tuple[ctk.CTkFrame, ctk.CTkFrame]:
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="both", expand=True, padx=14)
|
||
left = ctk.CTkFrame(row, fg_color="transparent")
|
||
right = ctk.CTkFrame(row, fg_color="transparent")
|
||
left.pack(side="left", fill="both", expand=True, padx=(0, 7))
|
||
right.pack(side="left", fill="both", expand=True, padx=(7, 0))
|
||
return left, right
|
||
|
||
def _note(self, parent, text: str) -> None:
|
||
p = self._p
|
||
ctk.CTkLabel(
|
||
parent,
|
||
text=text,
|
||
text_color=p["text_muted"],
|
||
fg_color=p["divider"],
|
||
corner_radius=4,
|
||
font=ui_kit.font(11),
|
||
anchor="w",
|
||
justify="left",
|
||
wraplength=330,
|
||
).pack(fill="x", padx=16, pady=(0, 10), ipady=5)
|
||
|
||
def _tag_editor(self, parent, placeholder: str, terms: list[str], kind: str) -> None:
|
||
p = self._p
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=16, pady=(0, 8))
|
||
entry = ctk.CTkEntry(
|
||
row,
|
||
placeholder_text=placeholder,
|
||
fg_color=p["btn_sec_bg"],
|
||
border_color=p["btn_sec_border"],
|
||
text_color=p["text"],
|
||
height=32,
|
||
)
|
||
entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||
ui_kit.secondary_button(row, p, "+ Ajouter", command=lambda: None).pack(side="right")
|
||
cloud = ctk.CTkFrame(parent, fg_color="transparent")
|
||
cloud.pack(fill="x", padx=16, pady=(0, 14))
|
||
tag_color = p["success"] if kind == "keep" else p["primary"]
|
||
line = ctk.CTkFrame(cloud, fg_color="transparent")
|
||
line.pack(fill="x", anchor="w")
|
||
used_width = 0
|
||
for term in terms:
|
||
estimated_width = min(230, max(88, len(term) * 8 + 34))
|
||
if used_width and used_width + estimated_width > 360:
|
||
line = ctk.CTkFrame(cloud, fg_color="transparent")
|
||
line.pack(fill="x", anchor="w")
|
||
used_width = 0
|
||
ctk.CTkLabel(
|
||
line,
|
||
text=f"{term[:30]}{'…' if len(term) > 30 else ''} ×",
|
||
width=estimated_width,
|
||
text_color=tag_color,
|
||
fg_color=p["btn_sec_bg"],
|
||
corner_radius=99,
|
||
font=ui_kit.font(12),
|
||
).pack(side="left", padx=(0, 6), pady=3, ipadx=4, ipady=3)
|
||
used_width += estimated_width + 6
|
||
|
||
def _slider_row(self, parent, label: str, value: int) -> None:
|
||
p = self._p
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=16, pady=3)
|
||
ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(13)).pack(side="left")
|
||
slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=125)
|
||
slider.set(value)
|
||
slider.pack(side="right")
|
||
|
||
def _update_mask_preview(self) -> None:
|
||
value = self._mask_style_var.get()
|
||
if value == "stars":
|
||
text = "Patient ***, né le ***"
|
||
elif value == "blackout":
|
||
text = "Patient ████, né le ████"
|
||
else:
|
||
text = "Patient [NOM], né le [DATE_NAISSANCE]"
|
||
self._mask_preview.configure(text=text)
|
||
|
||
def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None:
|
||
p = self._p
|
||
label, rule_type, target, status = values
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=16, pady=1)
|
||
ctk.CTkLabel(row, text=label, width=190, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left")
|
||
ctk.CTkLabel(row, text=rule_type, width=80, anchor="w", text_color=p["text_dim"], font=ui_kit.font(11)).pack(side="left")
|
||
ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left")
|
||
color = p["success"] if status == "Actif" else p["warning"]
|
||
ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left")
|
||
ui_kit.secondary_button(row, p, "▶ Tester", command=lambda: None).pack(side="left")
|