# 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` :

```python
"""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`**

```python
"""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 :

```python
from gui_v6.config_paths import resolve_user_config_path
```

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

```python
        self._user_config_path = resolve_user_config_path()
```

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

```python
            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**

```bash
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` :

```python
"""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 :

```python
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 :

```python
    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**

```bash
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` :

```python
"""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`**

```python
"""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`) :

```python
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()` :

```python
        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 :

```python
        self._show_failure_hint(summary)
```

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

```python
    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` :

```python
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**

```bash
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` :

```python
"""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`**

```python
"""É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 :

```python
        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`) :

```python
    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"` :

```python
        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**

```bash
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` :

```python
        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**

```bash
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` :

```python
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) :

```python
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 :

```python
        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**

```bash
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 `docTR` → `OnnxTR`

**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**

```bash
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é.)*
