Files
anonymisation/docs/superpowers/plans/2026-06-29-gui-v6-beta-plan-1c-honnetete-ui.md
2026-06-29 19:42:16 +02:00

36 KiB

GUI V6 → bêta — Plan 1c : honnêteté UI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Supprimer les derniers contrôles UI trompeurs / non fonctionnels de GUI V6 pour la bêta, et garantir le comportement honnête attendu d'un outil médical (config établissement réellement chargée, avertissement avant de dégrader la détection, erreurs localisables, échange de config par email recâblé).

Architecture : Chaque correctif est isolé derrière une fonction pure testable sans display ni modèle (résolution de chemin, décision de confirmation, formatage de message, export/import JSON), le widget se contentant de câbler cette fonction. Aucune logique de détection n'est touchée (le gating catégories est déjà livré en Plan 1b). On suit le pattern V5 existant pour la config externe (Pseudonymisation_Gui_V5._resolve_config) et le format d'échange JSON consommé par scripts/merge_params.merge_params.

Tech Stack : Python 3.10-3.12, customtkinter (CTk), tkinter.messagebox/filedialog, pytest (tests purs + pytest.importorskip("customtkinter") pour les rares smokes widget), PyYAML.

Portée (spec chantier D — docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md) :

  • Task 1 — P1-4 : config_path (dictionnaires.yml) réellement résolu et chargé en frozen.
  • Task 2 — Confirmation NER off (décision Dom 2026-06-29) : dialogue avant de tomber en regex-only.
  • Task 3 — P1-5 : erreurs / quarantaine localisables + bouton « Ouvrir le dossier ».
  • Task 4 — P1-3 : Import / Export de configuration recâblés (workflow email V5).
  • Task 5 — P1-1 : dropzone honnête (cliquable) — DnD natif différé.
  • Task 6 (optionnel) — P1-6 : validation d'inscriptibilité du dossier de sortie.
  • Task 7 (optionnel) — P1-11 : provenance audit original="docTR""OnnxTR".

Hors portée (différé, acté) : DnD natif tkinterdnd2 (lib native tkdnd à bundler — repoussé pour ne pas alourdir le build torch-free du Plan 3, voir Task 5) ; P2-1/P2-2 (progression/cartes format) traités au mieux mais non bloquants.

Convention de commit : un commit atomique par task (core+test ensemble), préfixe conventionnel, finir par Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Brancher sur feature/q1-quarantine-mvp, pousser sur gitea (jamais git add -A — stager par chemin).

Commande de test de référence : .venv/bin/python -m pytest tests/unit/test_gui_v6_*.py -q (+ gate synthetic_regression avant push).


Task 1 : P1-4 — Config externe dictionnaires.yml réellement résolue en frozen

Problème : AnonymisationApp crée UsageTab sans config_path (gui_v6/app.py:186-193), donc to_engine_settings(self._config_path) reçoit None (tab_usage.py:176). En frozen, le dictionnaires.yml éditable à côté de l'EXE n'est jamais chargé → personnalisations établissement ignorées.

Files:

  • Create: gui_v6/config_paths.py

  • Create: tests/unit/test_gui_v6_config_paths.py

  • Modify: gui_v6/app.py (import + __init__ + appel UsageTab(...))

  • Step 1 : Écrire les tests (échouent — module absent)

tests/unit/test_gui_v6_config_paths.py :

"""Résolution du dictionnaires.yml externe éditable (P1-4).

Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations

from pathlib import Path

import gui_v6.config_paths as cp


def _make_bundle(tmp_path: Path) -> Path:
    bundle = tmp_path / "bundle"
    (bundle / "config").mkdir(parents=True)
    (bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
    return bundle


def test_dev_returns_repo_config_when_present(monkeypatch):
    # En dev (non frozen) : pointe la config embarquée si elle existe.
    monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
    path = cp.resolve_user_config_path()
    assert path is not None
    assert path.name == "dictionnaires.yml"
    assert path.exists()


def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
    bundle = _make_bundle(tmp_path)
    exe_dir = tmp_path / "exe"
    exe_dir.mkdir()
    monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
    monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
    monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)

    out = cp.resolve_user_config_path()
    expected = exe_dir / "config" / "dictionnaires.yml"
    assert out == expected
    assert expected.exists()  # copié depuis le bundle au 1er lancement
    assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"


def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
    bundle = _make_bundle(tmp_path)
    exe_dir = tmp_path / "exe"
    (exe_dir / "config").mkdir(parents=True)
    user_cfg = exe_dir / "config" / "dictionnaires.yml"
    user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
    monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
    monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
    monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)

    out = cp.resolve_user_config_path()
    assert out == user_cfg
    # Ne JAMAIS écraser la perso établissement existante.
    assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
  • Step 2 : Lancer → vérifier l'échec

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py -q Expected: FAIL (ModuleNotFoundError: No module named 'gui_v6.config_paths').

  • Step 3 : Implémenter gui_v6/config_paths.py
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).

En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations

import shutil
import sys
from pathlib import Path
from typing import Optional

_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"


def _frozen() -> bool:
    return bool(getattr(sys, "frozen", False))


def _bundled_config() -> Path:
    """Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
    if _frozen():
        base = Path(getattr(sys, "_MEIPASS"))
    else:
        base = Path(__file__).resolve().parent.parent
    return base / _CONFIG_RELATIVE


def resolve_user_config_path() -> Optional[Path]:
    """Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.

    - dev : la config du dépôt (éditable en place) ;
    - frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
      embarquée au premier lancement si absente, sans jamais écraser une config
      existante (perso établissement).

    Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
    config runtime par défaut).
    """
    if not _frozen():
        bundled = _bundled_config()
        return bundled if bundled.exists() else None

    user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
    if user_cfg.exists():
        return user_cfg
    try:
        user_cfg.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(_bundled_config(), user_cfg)
        return user_cfg
    except Exception:
        bundled = _bundled_config()
        return bundled if bundled.exists() else None
  • Step 4 : Câbler dans gui_v6/app.py

En tête (après les imports gui_v6), ajouter :

from gui_v6.config_paths import resolve_user_config_path

Dans AnonymisationApp.__init__, après self._config = ConfigState() :

        self._user_config_path = resolve_user_config_path()

Dans _create_tab, branche "use", ajouter le kwarg à UsageTab(...) :

            return UsageTab(
                self._content,
                palette=p,
                config_provider=lambda: self._config,
                config_path=self._user_config_path,
                on_theme_change=self.set_theme,
                current_theme=self._theme_name,
                usage_reporter=self._report_usage,
            )
  • Step 5 : Lancer → vérifier le succès + non-régression socle

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py tests/unit/test_gui_v6_app_shell.py -q Expected: PASS (3 nouveaux + app_shell inchangés).

  • Step 6 : Commit
git add gui_v6/config_paths.py gui_v6/app.py tests/unit/test_gui_v6_config_paths.py
git commit -m "feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)"

Task 2 : Confirmation avant désactivation du NER (regex-only) — décision Dom 2026-06-29

Problème : Couper le toggle CamemBERT-bio (tab_config.py:393-395, callback _on_ner:893) bascule en anonymisation regex seule sans avertissement. Sur un outil médical, on confirme explicitement.

Files:

  • Modify: gui_v6/tabs/tab_config.py (constante + helper pur + _on_ner)

  • Create: tests/unit/test_gui_v6_ner_confirm.py

  • Step 1 : Écrire les tests (échouent — symbole absent)

tests/unit/test_gui_v6_ner_confirm.py :

"""Confirmation avant de désactiver le NER (regex-only) — outil médical.

Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
injecté (pas de messagebox réel, pas de display).
"""
from __future__ import annotations

from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable


def test_confirm_true_when_user_accepts():
    assert confirm_ner_disable(lambda: True) is True


def test_confirm_false_when_user_declines():
    assert confirm_ner_disable(lambda: False) is False


def test_warning_text_is_explicit_for_medical_use():
    txt = NER_DISABLE_WARNING.lower()
    # L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
    assert "règles" in txt or "regex" in txt
    assert "nom" in txt
    assert "recommand" in txt  # « fortement recommandé »
  • Step 2 : Lancer → vérifier l'échec

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py -q Expected: FAIL (ImportError: cannot import name 'confirm_ner_disable').

  • Step 3 : Ajouter la constante + le helper pur dans tab_config.py

Après les imports (sous from tkinter import filedialog, messagebox), au niveau module :

NER_DISABLE_WARNING = (
    "Vous allez désactiver le moteur d'intelligence artificielle "
    "(CamemBERT-bio).\n\n"
    "Sans lui, la détection des NOMS de personnes repose uniquement sur des "
    "règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
    "les documents.\n\n"
    "Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
    "Confirmer la désactivation ?"
)


def confirm_ner_disable(asker) -> bool:
    """Décision de désactivation du NER.

    ``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
    injectée pour rester testable sans display. Retourne True si l'utilisateur
    CONFIRME la désactivation (regex-only), False sinon.
    """
    return bool(asker())
  • Step 4 : Câbler _on_ner (intercepter le passage à OFF)

Remplacer _on_ner (tab_config.py:893-894) par :

    def _on_ner(self) -> None:
        new_value = self._tog_ner.get()
        if not new_value:
            confirmed = confirm_ner_disable(
                lambda: messagebox.askyesno(
                    "Moteur de détection", NER_DISABLE_WARNING, icon="warning"
                )
            )
            if not confirmed:
                # Refus : rétablir l'affichage du switch et garder le NER actif.
                self._tog_ner.var.set(True)
                self._state.use_local_ner = True
                return
        self._state.use_local_ner = new_value
  • Step 5 : Lancer → vérifier le succès

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py tests/unit/test_gui_v6_config_mockup_sections.py -q Expected: PASS (3 nouveaux + sections inchangées).

  • Step 6 : Commit
git add gui_v6/tabs/tab_config.py tests/unit/test_gui_v6_ner_confirm.py
git commit -m "feat(gui): confirmation explicite avant anonymisation regex-only (NER off)"

Task 3 : P1-5 — Erreurs / quarantaine localisables + « Ouvrir le dossier »

Problème : En fin de run, _finish (tab_usage.py:271-283) n'indique pas OÙ trouver les documents livrés ni que les documents en échec/quarantaine ne sont pas anonymisés. Le testeur reste bloqué.

Files:

  • Create: gui_v6/fsutil.py (ouverture cross-plateforme du dossier)

  • Create: tests/unit/test_gui_v6_result_hint.py

  • Modify: gui_v6/tabs/tab_usage.py (helper pur failure_hint, mémoriser le dossier de sortie, afficher hint + bouton)

  • Step 1 : Écrire les tests (échouent)

tests/unit/test_gui_v6_result_hint.py :

"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier.

Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager``
dispatch vers la bonne commande OS (monkeypatchée).
"""
from __future__ import annotations

from pathlib import Path

import gui_v6.fsutil as fsutil
from gui_v6.processing_runner import RunSummary
from gui_v6.tabs.tab_usage import failure_hint


def test_no_hint_when_all_ok():
    s = RunSummary(total=3, succeeded=3, failed=0)
    assert failure_hint(s, Path("/out")) is None


def test_hint_when_failures_mentions_output_dir():
    s = RunSummary(total=3, succeeded=2, failed=1)
    hint = failure_hint(s, Path("/out/anonymise"))
    assert hint is not None
    assert "/out/anonymise" in hint
    # Honnêteté : préciser que les échecs ne sont PAS anonymisés.
    assert "pas" in hint.lower()


def test_hint_when_stopped():
    s = RunSummary(total=3, succeeded=1, failed=0, stopped=True)
    assert failure_hint(s, Path("/out")) is not None


def test_no_hint_without_output_dir():
    s = RunSummary(total=1, succeeded=0, failed=1)
    assert failure_hint(s, None) is None


def test_open_in_file_manager_dispatches(monkeypatch):
    calls = {}
    monkeypatch.setattr(fsutil.sys, "platform", "linux")
    monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args))
    fsutil.open_in_file_manager(Path("/out"))
    assert calls["args"][0] == "xdg-open"
    assert calls["args"][1] == "/out"
  • Step 2 : Lancer → vérifier l'échec

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py -q Expected: FAIL (ModuleNotFoundError: gui_v6.fsutil / failure_hint absent).

  • Step 3 : Implémenter gui_v6/fsutil.py
"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme).

Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI).
"""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path


def open_in_file_manager(path) -> None:
    """Ouvre ``path`` dans l'explorateur de fichiers du système."""
    target = str(Path(path))
    try:
        if sys.platform.startswith("win"):
            import os

            os.startfile(target)  # type: ignore[attr-defined]  # noqa: S606
        elif sys.platform == "darwin":
            subprocess.Popen(["open", target])
        else:
            subprocess.Popen(["xdg-open", target])
    except Exception:
        pass
  • Step 4 : Ajouter failure_hint (pur) dans tab_usage.py

Au niveau module (après _HELP_USAGE) :

def failure_hint(summary, output_dir) -> str | None:
    """Message localisant les documents livrés, ou None si run nominal.

    Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
    ne sont donc pas écrits dans le dossier de sortie.
    """
    if summary is None or output_dir is None:
        return None
    if summary.failed == 0 and not getattr(summary, "stopped", False):
        return None
    return (
        f"Documents anonymisés écrits dans : {output_dir}\n"
        "Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
        "n'ont pas été écrits."
    )
  • Step 5 : Mémoriser le dossier de sortie effectif + afficher hint/bouton

Dans _start, après run_runner, run_output_dir = self._build_run_runner() :

        self._last_output_dir = run_output_dir or default_output_dir(self._input_path)

Dans _finish, à la fin (après self._show_results(summary)), ajouter l'appel :

        self._show_failure_hint(summary)

Puis ajouter la méthode (après _show_results) :

    def _show_failure_hint(self, summary) -> None:
        hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
        if hint is None:
            return
        p = self._p
        row = ctk.CTkFrame(self._rsec, fg_color="transparent")
        row.pack(fill="x", padx=16, pady=(0, 12))
        ctk.CTkLabel(
            row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
            anchor="w", justify="left",
        ).pack(side="left", fill="x", expand=True)
        ui_kit.secondary_button(
            row, p, "📂 Ouvrir le dossier",
            command=lambda: open_in_file_manager(self._last_output_dir),
        ).pack(side="right")

Ajouter en tête de tab_usage.py :

from gui_v6.fsutil import open_in_file_manager
  • Step 6 : Lancer → vérifier le succès

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py tests/unit/test_gui_v6_processing_runner.py -q Expected: PASS.

  • Step 7 : Commit
git add gui_v6/fsutil.py gui_v6/tabs/tab_usage.py tests/unit/test_gui_v6_result_hint.py
git commit -m "feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)"

Task 4 : P1-3 — Import / Export de configuration recâblés (workflow email V5)

Problème : Les boutons Partage sont des _mockup_button désactivés « (à venir) » (tab_config.py:847,855), alors que l'échange de config par email (export JSON → merge_params → renvoi YAML) est un workflow produit clé (CLAUDE.md). En V6, les listes whitelist/blacklist vivent dans le profil actif (_pro_term_lists, tab_config.py:721-722,747-748).

Décision de modèle (à confirmer en revue) : l'export produit le format V5 consommé par merge_params : {"version", "date_export", "whitelist_phrases", "blacklist_force_mask_terms"}, alimenté par les listes du profil actuellement chargé dans l'éditeur (self._pro_term_lists). L'import fusionne le JSON reçu dans le dictionnaires.yml utilisateur résolu (Task 1) via merge_params.

Files:

  • Create: gui_v6/config_share.py (sérialisation pure export/import)

  • Create: tests/unit/test_gui_v6_config_share.py

  • Modify: gui_v6/tabs/tab_config.py (_build_partage : vrais boutons + handlers ; passer config_path)

  • Modify: gui_v6/app.py (passer config_path=self._user_config_path à ConfigTab)

  • Step 1 : Écrire les tests (échouent)

tests/unit/test_gui_v6_config_share.py :

"""Export / import de configuration (P1-3) — format compatible merge_params.

Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations

import json
from pathlib import Path

from gui_v6.config_share import build_export_payload, import_config_file


def test_export_payload_has_v5_schema():
    payload = build_export_payload(
        whitelist=["Dr Métier", "Service ORL"],
        blacklist=["DUPONT"],
        version="2026.06.29",
    )
    assert payload["version"] == "2026.06.29"
    assert "date_export" in payload
    assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
    assert payload["blacklist_force_mask_terms"] == ["DUPONT"]


def test_export_payload_is_json_serializable():
    payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
    json.dumps(payload)  # ne doit pas lever


def test_import_merges_into_user_config(tmp_path, monkeypatch):
    # Config utilisateur YAML minimale.
    cfg = tmp_path / "dictionnaires.yml"
    cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
    # JSON reçu d'un établissement.
    incoming = tmp_path / "recu.json"
    incoming.write_text(
        json.dumps({
            "version": "1", "date_export": "2026-06-29",
            "whitelist_phrases": ["Nouveau"],
            "blacklist_force_mask_terms": ["MASQUERMOI"],
        }),
        encoding="utf-8",
    )
    added = import_config_file(incoming, cfg)
    assert added is True
    import yaml
    merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
    assert "Existant" in merged["whitelist_phrases"]
    assert "Nouveau" in merged["whitelist_phrases"]
    assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]


def test_import_returns_false_when_nothing_new(tmp_path):
    cfg = tmp_path / "dictionnaires.yml"
    cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
    incoming = tmp_path / "recu.json"
    incoming.write_text(
        json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
        encoding="utf-8",
    )
    assert import_config_file(incoming, cfg) is False
  • Step 2 : Lancer → vérifier l'échec

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py -q Expected: FAIL (ModuleNotFoundError: gui_v6.config_share).

  • Step 3 : Implémenter gui_v6/config_share.py
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).

- ``build_export_payload`` : produit le dict V5 (consommé par
  ``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
  utilisateur, sans écraser l'existant (réutilise ``merge_params``).

Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable

EXPORT_VERSION = "1"


def build_export_payload(
    whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
    """Construit la charge utile d'export au format consommé par merge_params."""
    return {
        "version": version,
        "date_export": datetime.now(timezone.utc).isoformat(),
        "whitelist_phrases": [str(t) for t in whitelist],
        "blacklist_force_mask_terms": [str(t) for t in blacklist],
    }


def _yaml_lists(config_path: Path) -> tuple[set, set]:
    import yaml

    cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
    wl = set(cfg.get("whitelist_phrases", []) or [])
    bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
    return wl, bl


def import_config_file(json_path, config_path) -> bool:
    """Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
    config a changé, False si rien de nouveau.

    Fusion autonome (union des listes, jamais d'écrasement) — volontairement
    SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
    sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
    """
    import json
    import yaml

    json_path = Path(json_path)
    config_path = Path(config_path)
    before_wl, before_bl = _yaml_lists(config_path)

    data = json.loads(json_path.read_text(encoding="utf-8"))
    incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
    incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}

    after_wl = before_wl | incoming_wl
    after_bl = before_bl | incoming_bl
    if after_wl == before_wl and after_bl == before_bl:
        return False

    cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
    cfg["whitelist_phrases"] = sorted(after_wl)
    cfg.setdefault("blacklist", {})
    cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
    config_path.write_text(
        yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
        encoding="utf-8",
    )
    return True

Note implémentation : fusion autonome (pas d'import de scripts/), donc robuste en frozen. Le format JSON (whitelist_phrases / blacklist_force_mask_terms) reste celui consommé par scripts/merge_params.py:52-58 → compatibilité email V5 préservée.

  • Step 4 : Recâbler _build_partage (vrais boutons)

Remplacer les deux self._mockup_button(...) de _build_partage (tab_config.py:847,855) par de vrais boutons :

        ui_kit.secondary_button(
            export, p, "⬇ Exporter (.json)", command=self._on_export_config
        ).pack(anchor="w", padx=12, pady=(0, 12))
        ...
        ui_kit.secondary_button(
            import_card, p, "⬆ Importer (.json)", command=self._on_import_config
        ).pack(anchor="w", padx=12, pady=(0, 12))

Ajouter les handlers (section « callbacks réglages », après _on_manual_mask_template) :

    def _on_export_config(self) -> None:
        from gui_v6.config_share import build_export_payload
        import json

        lists = getattr(self, "_pro_term_lists", {})
        wl = lists["whitelist"].terms() if "whitelist" in lists else []
        bl = lists["blacklist"].terms() if "blacklist" in lists else []
        path = filedialog.asksaveasfilename(
            title="Exporter la configuration", defaultextension=".json",
            filetypes=[("Configuration JSON", "*.json")],
        )
        if not path:
            return
        try:
            payload = build_export_payload(wl, bl)
            Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
            messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
        except Exception as exc:  # pragma: no cover - chemin UI
            messagebox.showerror("Export", f"Échec de l'export : {exc}")

    def _on_import_config(self) -> None:
        from gui_v6.config_share import import_config_file

        if self._config_path is None:
            messagebox.showerror("Import", "Aucune configuration cible résolue.")
            return
        path = filedialog.askopenfilename(
            title="Importer une configuration",
            filetypes=[("Configuration JSON", "*.json")],
        )
        if not path:
            return
        try:
            changed = import_config_file(path, self._config_path)
            if changed:
                messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
            else:
                messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
        except Exception as exc:  # pragma: no cover - chemin UI
            messagebox.showerror("Import", f"Échec de l'import : {exc}")
  • Step 5 : Donner à ConfigTab le config_path

Dans ConfigTab.__init__, accepter et stocker config_path (signature : ajouter config_path: Path | None = None, et self._config_path = config_path). Dans gui_v6/app.py:_create_tab, branche "cfg" :

        if key == "cfg":
            return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
  • Step 6 : Lancer → vérifier le succès + non-régression

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py tests/unit/test_gui_v6_config_mockup_sections.py tests/unit/test_gui_v6_app_shell.py -q Expected: PASS (le test test_config_mockup_sections reste vert : ne pas changer CONFIG_MOCKUP_SECTIONS, seulement le câblage des boutons Partage).

  • Step 7 : Commit
git add gui_v6/config_share.py gui_v6/tabs/tab_config.py gui_v6/app.py tests/unit/test_gui_v6_config_share.py
git commit -m "feat(gui): recâbler import/export de configuration par email (P1-3)"

Task 5 : P1-1 — Dropzone honnête (cliquable)

Problème : La zone « Choisissez vos fichiers » (tab_usage.py:103-115) suggère un glisser-déposer inexistant. Le DnD natif (tkinterdnd2) impose la lib native tkdnd à bundler dans le frozen → différé (ne pas alourdir le build torch-free du Plan 3). Fix bêta : rendre la zone cliquable (ouvre le sélecteur de fichier) + libellé honnête. Les boutons Fichier/Dossier existants restent.

Files:

  • Modify: gui_v6/tabs/tab_usage.py (rendre la dropzone cliquable + libellé)

  • Step 1 : Rendre la dropzone cliquable + libellé honnête

Dans _build, remplacer le label « Choisissez vos fichiers » et lier le clic. Après la création de dz (tab_usage.py:103-106), lier le clic de la zone et de ses labels à _pick_file :

        dz.bind("<Button-1>", lambda _e: self._pick_file())
        ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
        zone_lbl = ctk.CTkLabel(dz, text="Cliquez pour choisir un fichier", text_color=p["text"], font=ui_kit.font(14))
        zone_lbl.pack()
        zone_lbl.bind("<Button-1>", lambda _e: self._pick_file())
        ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))

(la ligne ctk.CTkLabel(dz, text="⬆️"...) d'origine est remplacée par le bloc ci-dessus ; les boutons acts restent inchangés).

  • Step 2 : Smoke import (pas de régression d'import)

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q && .venv/bin/python Pseudonymisation_Gui_V6.py --self-test Expected: PASS + GUI V6 self-test OK.

  • Step 3 : Commit
git add gui_v6/tabs/tab_usage.py
git commit -m "feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)"

Task 6 (optionnel) : P1-6 — Valider l'inscriptibilité du dossier de sortie

Problème : Un dossier de sortie en lecture seule fait échouer chaque document avec un message cryptique (processing_runner.py:215 mkdir puis échec par doc). Tester l'inscriptibilité en amont donne un message clair unique.

Files:

  • Modify: gui_v6/processing_runner.py (_run_impl : test d'écriture amont)

  • Create/Modify: tests/unit/test_gui_v6_processing_runner.py (nouveau test)

  • Step 1 : Test (échoue)

Ajouter à tests/unit/test_gui_v6_processing_runner.py :

def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
    from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
    src = tmp_path / "in"
    src.mkdir()
    (src / "a.txt").write_text("x", encoding="utf-8")
    out = tmp_path / "ro"
    out.mkdir()

    def boom(*a, **k):
        raise PermissionError("read-only")

    monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
    runner = ProcessingRunner(process_fn=lambda d, o: {})
    import pytest
    with pytest.raises(OutputNotWritableError):
        runner.run(src, out)
  • Step 2 : Lancer → vérifier l'échec

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py::test_run_fails_fast_when_output_not_writable -q Expected: FAIL (OutputNotWritableError absent).

  • Step 3 : Implémenter le garde-fou

Dans gui_v6/processing_runner.py, ajouter la classe d'erreur (près des dataclasses) :

class OutputNotWritableError(RuntimeError):
    """Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""

Dans _run_impl, après le calcul de out_root (:186) et avant la boucle, vérifier une fois :

        try:
            out_root.mkdir(parents=True, exist_ok=True)
            probe = out_root / ".anon_write_test"
            probe.write_text("", encoding="utf-8")
            probe.unlink()
        except Exception as exc:
            raise OutputNotWritableError(
                f"Dossier de sortie non inscriptible : {out_root} ({exc})"
            ) from exc
  • Step 4 : Lancer → vérifier le succès + non-régression runner

Run: .venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q Expected: PASS (nouveau + existants).

Note UI : UsageTab._handle_event/work() capture déjà Exception → le message d'OutputNotWritableError s'affiche dans le journal. Vérifier qu'il remonte lisible (pas de doublon par document).

  • Step 5 : Commit
git add gui_v6/processing_runner.py tests/unit/test_gui_v6_processing_runner.py
git commit -m "feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)"

Task 7 (optionnel) : P1-11 — Provenance audit docTROnnxTR

Problème : L'audit JSONL trace original="docTR" (anonymizer_core_refactored_onnx.py:5356) alors que l'OCR est OnnxTR depuis la migration. Trace périmée visible dans le livrable client.

Files:

  • Modify: anonymizer_core_refactored_onnx.py:5356

  • Test: gate synthetic_regression (pas de nouveau test dédié — provenance cosmétique).

  • Step 1 : Localiser et corriger

Run: .venv/bin/python - <<'PY'\nimport re,io\np="anonymizer_core_refactored_onnx.py"\ns=open(p,encoding="utf-8").read()\nprint([l for l in s.splitlines() if 'docTR' in l and 'original' in l][:3])\nPY Remplacer la valeur de provenance "docTR" par "OnnxTR" à la ligne d'audit identifiée (uniquement la chaîne de provenance OCR, ne pas toucher aux commentaires/docstrings cosmétiques P2-3).

  • Step 2 : Non-régression

Run: .venv/bin/python -m pytest tests/unit -q -k "synthetic_regression or audit" Expected: PASS (sortie d'anonymisation inchangée ; seule la provenance change).

  • Step 3 : Commit
git add anonymizer_core_refactored_onnx.py
git commit -m "fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)"

Clôture du Plan 1c

  • Suite complète + gate

Run: .venv/bin/python -m pytest tests/unit -q (attendu : 436 + nouveaux tests, 0 régression) puis le gate synthetic_regression du dépôt.

  • Self-test GUI

Run: .venv/bin/python Pseudonymisation_Gui_V6.py --self-test Expected: GUI V6 self-test OK.

  • Revue + push

Revue Qwen courte recommandée (1c = honnêteté UI, hors cœur sécurité masquage — pas de revue bloquante comme 1b, mais signaler la décision « confirmation NER » et le modèle export profil-actif). Puis push gitea/feature/q1-quarantine-mvp. Mettre à jour la mémoire projet gui_v6_beta_prod_chantier.md.


Self-Review (auteur du plan)

Couverture spec (chantier D) : P1-2 = déjà livré (Plan 1b, hors périmètre ici) ✓ ; P1-1 = Task 5 (cliquable, DnD différé — déviation assumée vs spec « tkinterdnd2 », à valider Dom) ; P1-3 = Task 4 ; P1-4 = Task 1 ; P1-5 = Task 3 ; confirmation NER = Task 2 (décision Dom 2026-06-29) ; P1-6 = Task 6 (optionnel) ; P1-11 = Task 7 (optionnel). P2-1/P2-2 non traités (best-effort, non bloquants) — signalé.

Placeholders : aucun « TODO / à compléter » ; chaque step porte le code réel.

Cohérence des types/symboles : resolve_user_config_path (Task 1) réutilisé en Task 4 (Step 5) ; failure_hint/open_in_file_manager (Task 3) cohérents avec leur test ; build_export_payload/import_config_file (Task 4) ⇄ schéma merge_params (whitelist_phrases / blacklist_force_mask_terms) vérifié contre scripts/merge_params.py:52-58. self._tog_ner.var confirmé exposé par _mini_toggle (tab_config.py:1107).

Hypothèses à confirmer en revue :

  1. Task 4 exporte les listes du profil chargé dans l'éditeur (_pro_term_lists). Si l'attendu est « toutes les listes globales » ou « le profil par défaut », ajuster la source des listes.
  2. Task 5 dévie de la spec (DnD natif → cliquable). Justification : éviter la lib native tkdnd dans le build torch-free. À confirmer.

(Hypothèse « scripts importable » levée : la fusion d'import est désormais autonome, frozen-safe — vérifié scripts/__init__.py absent et scripts/ non bundlé.)