feat(gui): onglet Profils éditable (création/modification/persistance)

Retour Dom : remplacer la page vitrine par un vrai éditeur de profils.

- gui_v6/profile_editor.py : couche logique (build_profile_spec,
  profile_is_editable runtime vs defaut, list_profile_choices, slug_for_copy,
  save/set_default/delete) au-dessus de profile_defaults — persistance dans
  config/profiles.yml.
- gui_v6/editable_list.py : EditableTermList (tableau scrollable de termes,
  ajout/suppression, pas de pastilles) — reste lisible à 50+ termes.
- tab_config : sous-onglet « 👤 Profils » réintroduit comme éditeur — menu
  déroulant « Profil à modifier », boutons Nouveau / Dupliquer / Enregistrer /
  Annuler / Définir par défaut, sections Identité, Masquage (require_manual_mask,
  template), Moteurs (force_disable_vlm), Mots (à masquer/conserver/ignorer
  éditables), Règles « à venir ». Profils défaut = lecture seule (dupliquer
  pour modifier). Confirmation non bloquante (pas de modale).
- Réglages : bouton « ✏️ Modifier le profil… » → ouvre Profils sur le profil
  actif. Pas de pastilles inline.

Persiste : label, description, require_manual_mask, force_disable_vlm,
preferred_manual_mask_template, param_lists (3 listes). 260 tests unit OK
(0 régression), self-test OK, nav 5 sous-onglets + thème OK. Préserve
1bbe70a/d30f7b7. Aucun build/push sans GO Dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:09:01 +02:00
parent 1bbe70a911
commit 72841ed7b3
5 changed files with 623 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_
_SUBTABS = [
("reg", "⚙️ Réglages"),
("pro", "👤 Profils"),
("msk", "🎭 Masquage"),
("shr", "🔄 Partage"),
("rul", "🛡️ Règles"),
@@ -184,6 +185,12 @@ class ConfigTab(ctk.CTkFrame):
# ouverte pour éviter d'en empiler plusieurs.
self._mask_editor_window = None
# Éditeur de profils : chemin overlay runtime (None = config/profiles.yml standard,
# surchargeable en test), clé en cours d'édition + widgets.
self._profiles_path = None
self._pro_edit_key: str | None = None
self._pro_term_lists: dict = {}
self._build()
@property
@@ -221,6 +228,7 @@ class ConfigTab(ctk.CTkFrame):
builders = {
"reg": self._build_reglages,
"pro": self._build_profils,
"msk": self._build_masquage,
"shr": self._build_partage,
"rul": self._build_regles,
@@ -288,6 +296,9 @@ class ConfigTab(ctk.CTkFrame):
self._profile_menu.set(current)
self._profile_menu.pack(side="left", pady=10)
ui_kit.help_button(top, p, _HELP_PROFIL, title="Profil d'anonymisation").pack(side="left", padx=(6, 0), pady=10)
ui_kit.secondary_button(top, p, "✏️ Modifier le profil…", command=self._open_profile_editor).pack(
side="left", padx=(10, 4), pady=10
)
sortie = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output)
sortie.pack(side="left", padx=(6, 6), pady=10)
@@ -426,6 +437,250 @@ class ConfigTab(ctk.CTkFrame):
rows = profile_term_rows(self._active_profile_dict())
TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label)
# -- Profils (éditeur) ------------------------------------------------
def _build_profils(self, parent) -> None:
p = self._p
from gui_v6.editable_list import EditableTermList
self._section_intro(
parent,
"Un profil regroupe les moteurs, les masques, les règles et les mots à conserver ou masquer.",
_HELP_PROFIL,
"Profils d'anonymisation",
)
bar = ui_kit.Card(parent, p, title="👤 Profil à modifier")
bar.pack(fill="x", pady=(0, 8))
top = ctk.CTkFrame(bar, fg_color="transparent")
top.pack(fill="x", padx=12, pady=(0, 4))
self._pro_menu_var = ctk.StringVar(value="")
self._pro_menu = ctk.CTkOptionMenu(
top, values=[""], variable=self._pro_menu_var, command=self._pro_on_select,
fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"],
text_color=p["text"], width=260, height=30,
)
self._pro_menu.pack(side="left")
self._pro_status = ctk.CTkLabel(top, text="", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w")
self._pro_status.pack(side="left", padx=(10, 0))
actions = ctk.CTkFrame(bar, fg_color="transparent")
actions.pack(fill="x", padx=12, pady=(0, 12))
ui_kit.secondary_button(actions, p, "+ Nouveau", command=self._pro_new).pack(side="left", padx=(0, 6))
ui_kit.secondary_button(actions, p, "⧉ Dupliquer", command=self._pro_duplicate).pack(side="left", padx=(0, 6))
self._pro_save_btn = ui_kit.primary_button(actions, p, "💾 Enregistrer", command=self._pro_save)
self._pro_save_btn.pack(side="left", padx=(6, 6))
ui_kit.secondary_button(actions, p, "↩ Annuler", command=self._pro_cancel).pack(side="left")
self._pro_default_btn = ui_kit.secondary_button(actions, p, "⭐ Définir par défaut", command=self._pro_set_default)
self._pro_default_btn.pack(side="right")
cols = self._columns(parent, 2, gap=8)
left, right = cols[0], cols[1]
ident = ui_kit.Card(left, p, title="🏷️ Identité")
ident.pack(fill="x", pady=(0, 8))
self._pro_label_var = ctk.StringVar()
self._pro_label_entry = ctk.CTkEntry(ident, textvariable=self._pro_label_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
ctk.CTkLabel(ident, text="Nom du profil", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
self._pro_label_entry.pack(fill="x", padx=12, pady=(0, 6))
self._pro_desc_var = ctk.StringVar()
self._pro_desc_entry = ctk.CTkEntry(ident, textvariable=self._pro_desc_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
ctk.CTkLabel(ident, text="Description", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
self._pro_desc_entry.pack(fill="x", padx=12, pady=(0, 12))
mask = ui_kit.Card(left, p, title="⬛ Masquage")
mask.pack(fill="x", pady=(0, 8))
self._pro_require_mask_var = ctk.BooleanVar(value=False)
self._pro_require_switch = ctk.CTkSwitch(mask, text="Masque manuel obligatoire", variable=self._pro_require_mask_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12))
self._pro_require_switch.pack(anchor="w", padx=12, pady=(0, 6))
ctk.CTkLabel(mask, text="Template de masque préféré", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
self._pro_template_var = ctk.StringVar()
self._pro_template_entry = ctk.CTkEntry(mask, textvariable=self._pro_template_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
self._pro_template_entry.pack(fill="x", padx=12, pady=(0, 12))
eng = ui_kit.Card(left, p, title="🧠 Moteurs")
eng.pack(fill="x")
self._pro_disable_vlm_var = ctk.BooleanVar(value=False)
self._pro_vlm_switch = ctk.CTkSwitch(eng, text="Désactiver le moteur VLM (images)", variable=self._pro_disable_vlm_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12))
self._pro_vlm_switch.pack(anchor="w", padx=12, pady=(0, 6))
ctk.CTkLabel(eng, text="CamemBERT-bio (standard) toujours actif ; EDS-Pseudo / GLiNER optionnels.", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w", wraplength=300, justify="left").pack(fill="x", padx=12, pady=(0, 12))
words = ui_kit.Card(right, p, title="📝 Mots du profil")
words.pack(fill="both", expand=True)
self._pro_term_lists = {
"blacklist": EditableTermList(words, p, title="À masquer", height=104),
"whitelist": EditableTermList(words, p, title="À conserver", height=104),
"stopwords": EditableTermList(words, p, title="À ignorer", height=88),
}
for tl in self._pro_term_lists.values():
tl.pack(fill="x", padx=12, pady=(0, 8))
rules = ui_kit.Card(parent, p, title="🛡️ Règles du profil")
rules.pack(fill="x", pady=(8, 0))
self._note(rules, "Les règles embarquées par profil seront éditables prochainement.")
self._mockup_button(rules, "+ Ajouter une règle").pack(anchor="w", padx=12, pady=(0, 12))
self._pro_refresh_and_load()
# -- Profils : logique -----------------------------------------------
def _pro_choices(self) -> list:
from gui_v6.profile_editor import list_profile_choices
return list_profile_choices(self._profiles_path)
@staticmethod
def _pro_label_for(choice: dict) -> str:
if choice["is_default"]:
return f"{choice['label']} (défaut)"
if not choice["editable"]:
return f"{choice['label']} (lecture seule)"
return choice["label"]
def _pro_refresh_and_load(self, select_key: str | None = None) -> None:
choices = self._pro_choices()
self._pro_choice_by_label = {self._pro_label_for(c): c for c in choices}
labels = list(self._pro_choice_by_label) or [""]
self._pro_menu.configure(values=labels)
target = None
if select_key is not None:
target = next((lbl for lbl, c in self._pro_choice_by_label.items() if c["key"] == select_key), None)
if target is None:
target = self._pro_menu_var.get() if self._pro_menu_var.get() in self._pro_choice_by_label else labels[0]
self._pro_menu_var.set(target)
self._pro_on_select(target)
def _pro_on_select(self, label: str) -> None:
choice = getattr(self, "_pro_choice_by_label", {}).get(label)
if choice is None:
return
self._pro_load(choice["key"])
def _profile_dict_for(self, key: str) -> dict:
from profile_defaults import list_effective_profiles
try:
return list_effective_profiles(self._profiles_path).get(key, {}) or {}
except Exception:
return {}
def _pro_load(self, key: str) -> None:
from gui_v6.profile_editor import profile_is_editable
profile = self._profile_dict_for(key)
self._pro_edit_key = key
self._pro_label_var.set(str(profile.get("label") or key))
self._pro_desc_var.set(str(profile.get("description") or ""))
self._pro_require_mask_var.set(bool(profile.get("require_manual_mask")))
self._pro_disable_vlm_var.set(bool(profile.get("force_disable_vlm")))
self._pro_template_var.set(str(profile.get("preferred_manual_mask_template") or ""))
param_lists = profile.get("param_lists") or {}
self._pro_term_lists["blacklist"].set_terms(param_lists.get("blacklist_force_mask_terms") or [])
self._pro_term_lists["whitelist"].set_terms(param_lists.get("whitelist_phrases") or [])
self._pro_term_lists["stopwords"].set_terms(param_lists.get("additional_stopwords") or [])
editable = profile_is_editable(key, self._profiles_path)
self._pro_set_editable(editable)
self._pro_status.configure(
text="Éditable" if editable else "Profil par défaut — lecture seule (dupliquez pour modifier)"
)
def _pro_set_editable(self, editable: bool) -> None:
state = "normal" if editable else "disabled"
for widget in (self._pro_label_entry, self._pro_desc_entry, self._pro_template_entry,
self._pro_require_switch, self._pro_vlm_switch, self._pro_save_btn):
widget.configure(state=state)
for term_list in self._pro_term_lists.values():
term_list.set_editable(editable)
def _pro_collect_spec(self) -> dict:
from gui_v6.profile_editor import build_profile_spec
return build_profile_spec(
label=self._pro_label_var.get(),
description=self._pro_desc_var.get(),
require_manual_mask=bool(self._pro_require_mask_var.get()),
force_disable_vlm=bool(self._pro_disable_vlm_var.get()),
preferred_manual_mask_template=self._pro_template_var.get(),
whitelist=self._pro_term_lists["whitelist"].terms(),
blacklist=self._pro_term_lists["blacklist"].terms(),
stopwords=self._pro_term_lists["stopwords"].terms(),
)
def _pro_unique_key(self, base: str) -> str:
from gui_v6.profile_editor import slug_for_copy
existing = {c["key"] for c in self._pro_choices()}
if base not in existing:
return base
return slug_for_copy(base, existing)
def _pro_new(self) -> None:
from gui_v6.profile_editor import build_profile_spec, save_profile
key = self._pro_unique_key("nouveau_profil")
spec = build_profile_spec(label="Nouveau profil")
try:
save_profile(key, spec, path=self._profiles_path)
except Exception as exc: # pragma: no cover
messagebox.showerror("Profils", f"Impossible de créer le profil : {exc}")
return
self._pro_refresh_and_load(select_key=key)
def _pro_duplicate(self) -> None:
from gui_v6.profile_editor import save_profile, slug_for_copy
if not self._pro_edit_key:
return
existing = {c["key"] for c in self._pro_choices()}
new_key = slug_for_copy(self._pro_edit_key, existing)
spec = self._pro_collect_spec()
spec["label"] = f"{spec['label']} (copie)"
try:
save_profile(new_key, spec, path=self._profiles_path)
except Exception as exc: # pragma: no cover
messagebox.showerror("Profils", f"Impossible de dupliquer : {exc}")
return
self._pro_refresh_and_load(select_key=new_key)
def _pro_save(self) -> None:
from gui_v6.profile_editor import save_profile
key = self._pro_edit_key
if not key:
return
spec = self._pro_collect_spec()
try:
save_profile(key, spec, path=self._profiles_path)
except Exception as exc: # pragma: no cover
messagebox.showerror("Profils", f"Impossible d'enregistrer le profil : {exc}")
return
self._pro_refresh_and_load(select_key=key)
# Confirmation non bloquante (pas de modale qui fige l'app).
self._pro_status.configure(text=f"✓ Profil « {spec['label']} » enregistré.")
def _pro_cancel(self) -> None:
if self._pro_edit_key:
self._pro_load(self._pro_edit_key)
def _pro_set_default(self) -> None:
from gui_v6.profile_editor import set_default_profile
if not self._pro_edit_key:
return
try:
set_default_profile(self._pro_edit_key, path=self._profiles_path)
except Exception as exc: # pragma: no cover
messagebox.showerror("Profils", f"Impossible de définir par défaut : {exc}")
return
self._pro_refresh_and_load(select_key=self._pro_edit_key)
def _open_profile_editor(self) -> None:
"""Ouvre le sous-onglet Profils sur le profil actif (depuis Réglages)."""
self._show_sub("pro")
active = self._state.profile
if active:
self._pro_refresh_and_load(select_key=active)
# -- Masquage ---------------------------------------------------------
def _build_masquage(self, parent) -> None: