---
from: claude
to: dom
date: 2026-05-29T11:15:00+02:00
topic: pseudocode-Q1-v2-consolide
status: open
references:
  - file: inbox/for-dom/2026-05-29_claude_pseudocode-Q1-quarantaine.md (v1 Claude)
  - file: inbox/for-dom/2026-05-29_qwen_review-pseudocode-Q1.md (review Qwen)
  - file: inbox/for-dom/2026-05-29_qwen_analyse-regression-grand.md (C-8 fix)
  - file: anonymizer_core_refactored_onnx.py
  - tests: tests/unit/test_q1_quarantine.py
priority: blocker
---

# Pseudo-code Q-1 v2 CONSOLIDÉ — version unique à coder

## Objet

Consolidation du pseudo-code Claude (v1) + review Qwen + vérification factuelle Claude. **Ce fichier est la version unique de référence pour le code.** Les 2 autres fichiers sont historiques.

---

## 1. Vérification factuelle sur l'inventaire des silences

### 1.1 `except: pass` STRICTS dans le core

Grep ciblé : `grep -B1 "^[[:space:]]*pass[[:space:]]*$" core | grep "except"` →

**6 cas uniquement** (pas ~20 comme estimé initialement) :

| # | Ligne | Fonction | Risque |
|---|---|---|---|
| 1 | `:1118` | extract_text — tables PyMuPDF | Tables manquantes → doc partiel mais texte principal OK |
| 2 | `:1128` | extract_text — layout-aware PyMuPDF | Fallback vers pdfplumber |
| 3 | `:1139` | extract_text — pdfplumber | Fallback vers pdfminer |
| 4 | `:1156` | extract_text — pdfminer | Fallback vers OCR docTR |
| 5 | **`:3938`** | **redact_pdf_vector → apply_redactions** | **PDF sort SANS rédaction** 🔴 |
| 6 | **`:4655`** | **process_pdf → redact_pdf_vector** | **Aucun PDF généré** 🔴 |

### 1.2 Précision sur la review Qwen

Qwen a proposé +5 cas manqués (A=4291 rescan, B=2725 stopwords, C=3857 search, D=4034 raster, E=1490 regex). **Vérification ligne par ligne : aucun de ces 5 n'est un `except: pass` strict.** Détail :
- L4291 : `final_text = selective_rescan(final_text, cfg=cfg)` — appel direct, pas dans try/except
- L2725 : `continue` dans un filtre de stopwords (légitime, lié au bug GRAND traité par C-8)
- L3857 : début de `def redact_pdf_vector(...)` — pas un except
- L4034 : `# Masquage total si FULL_PAGE_MASK` — pas un except
- L1490 : `context_before = line[...].lower()` — pas un except

→ **Ses ajouts ne sont pas retenus côté inventaire.** Ses autres recommandations (seuils, leak_scanner, B-1 clear, fallback raster, tests) sont valides et intégrées ci-dessous.

### 1.3 `except as e: pass` ou silences déguisés à traiter quand même

En plus des 6 `except: pass` purs, **7 chemins critiques** ont un `except as e:` sans logging utile ou avec dégradation silencieuse :

| # | Ligne | Contexte | Action |
|---|---|---|---|
| 7 | `:1225` | `_compile_user_regex` regex utilisateur invalide | **L** (warning + skip) |
| 8 | `:1242` | `force_mask_regex` compile | **L** (warning + skip) |
| 9 | `:3984` | `redact_pdf_raster` pyzbar codes-barres | **L** (debug, optionnel) |
| 10 | `:3991` | `redact_pdf_raster` font fallback | **L** (debug) |
| 11 | `:4137` | `process_pdf` extraction image rects | **L** (debug) |
| 12 | `:4202` | `process_pdf` VLM Ollama analyze_page | **L** (warning, optionnel) |
| 13 | `:4276` | `process_pdf` `_apply_vlm_on_scanned_pdf` | **L** (warning) |

---

## 2. Mapping action final (13 cas)

| Cas | Action | Comportement |
|---|---|---|
| 1-4 (extraction) | **L** + tracker `extraction_passes_failed` | Logger warning, fallback continue, comptabiliser pour rapport |
| 5 (3938 apply_redactions) | **`raise`** | Remonter exception pour que process_pdf flag Q-PDF |
| 6 (4655 redact_pdf_vector) | **Q-PDF** | Flag quarantaine partielle (texte sort) + tenter fallback raster (cf §4) |
| 7-8 (regex compile) | **L** | Warning utilisateur (regex YAML invalide) |
| 9-10 (pyzbar/font) | **L** (debug) | Dégradation acceptable |
| 11-13 (image/VLM) | **L** (warning) | Dégradation gracieuse VLM optionnel |

**Bonus à ajouter (pas un `except` existant) :**
- **B-3 Pré-flight** : si `extracted_chars < SEUIL_TEXTE_MINI` → **Q-DOC**
- **Rescan check** : si `_count_residual_pii(final_text) > SEUIL_RESCAN_RESIDUEL` → **Q-DOC**

---

## 3. Décisions tranchées A/B/C/D + nouveaux

| ID | Sujet | Décision finale | Source |
|---|---|---|---|
| A | Texte Q-PDF localisation | **`output_dir/` uniquement + copie en `quarantaine/` pour autoportance** | accord Qwen, refute Claude v1 |
| B | Fallback raster si vector rate | **Oui, flag `pdf_vector_fallback_to_raster` levé même si raster OK** | accord Claude+Qwen |
| C1 | `SEUIL_TEXTE_MINI` | **100** (pas 50) | argument Qwen accepté |
| C2 | `SEUIL_RESCAN_RESIDUEL` | **0** (tolérance zéro) | argument Qwen accepté |
| D | `_count_residual_pii` | **Réutiliser `evaluation/leak_scanner.py`** | argument Qwen accepté |
| E | B-1 metadata source PDF | **`doc.metadata.clear()` explicite + check assertion** | argument Qwen accepté |
| F | Garde-fou NER low confidence | **Reporté v11.5** — pas dans le scope 99% RGPD primaire MVP | décision Claude |
| G | Check OCR low quality | **Reporté v11.5** — complexité non justifiée pour MVP | décision Claude |
| H | Check tables vides | **Inclus** (1 ligne, coût nul) | accord |

---

## 4. Nouvelle API à introduire

### 4.1 Module `quarantine.py` (collocated avec core)

```python
# quarantine.py
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Literal
from datetime import datetime
import json
import shutil
import traceback

SEUIL_TEXTE_MINI = 100
SEUIL_RESCAN_RESIDUEL = 0
QUARANTINE_DIR_NAME = "quarantaine"


@dataclass
class QuarantineEntry:
    doc_name: str
    reason: str               # code court (cf §5)
    detail: str               # message libre
    timestamp: str
    severity: Literal["partial", "full"]
    flags: list[str] = field(default_factory=list)
    stacktrace: Optional[str] = None
    extracted_chars: int = 0


class QuarantineManager:
    """Une instance par batch. Centralise tous les flags + génère INDEX.md."""

    def __init__(self, output_dir: Path, app_version: str, commit_sha: str, profile_name: str):
        self.output_dir = output_dir
        self.quarantine_dir = output_dir / QUARANTINE_DIR_NAME
        self.app_version = app_version
        self.commit_sha = commit_sha
        self.profile_name = profile_name
        self.entries: list[QuarantineEntry] = []
        self._errors_log_path = output_dir / "errors.log"

    def flag(self, doc_name: str, reason: str, detail: str,
             severity: Literal["partial", "full"],
             *, exc: Optional[Exception] = None,
             extracted_chars: int = 0,
             flags: Optional[list[str]] = None) -> QuarantineEntry:
        """Crée une entrée + écrit .reason.txt + append errors.log."""
        self.quarantine_dir.mkdir(exist_ok=True)
        entry = QuarantineEntry(
            doc_name=doc_name,
            reason=reason,
            detail=detail,
            timestamp=datetime.now().astimezone().isoformat(),
            severity=severity,
            flags=flags or [reason],
            stacktrace=traceback.format_exc() if exc else None,
            extracted_chars=extracted_chars,
        )
        self.entries.append(entry)
        self._write_reason_txt(entry)
        self._append_errors_log(entry)
        return entry

    def has_full_quarantine(self, doc_name: str) -> bool:
        return any(e.doc_name == doc_name and e.severity == "full" for e in self.entries)

    def finalize(self) -> None:
        """Écrit quarantaine/INDEX.md à la fin du batch."""
        if not self.entries:
            return
        # ... génération INDEX.md cf §6

    def _write_reason_txt(self, entry: QuarantineEntry) -> None:
        ...  # cf §6

    def _append_errors_log(self, entry: QuarantineEntry) -> None:
        ...  # cf §6
```

### 4.2 Classe `DocLogger` (B-2 sans GUI)

```python
class DocLogger:
    """Logger fichier par document. Append-only. Pas de buffer."""
    def __init__(self, log_path: Path):
        self.log_path = log_path

    def _write(self, level: str, msg: str) -> None:
        ts = datetime.now().astimezone().isoformat()
        with open(self.log_path, "a", encoding="utf-8") as f:
            f.write(f"{ts} [{level}] {msg}\n")

    def info(self, msg: str) -> None: self._write("INFO", msg)
    def warning(self, msg: str) -> None: self._write("WARNING", msg)
    def error(self, msg: str) -> None: self._write("ERROR", msg)
```

---

## 5. Codes de raison normalisés

| Code | Sévérité | Sens |
|---|---|---|
| `preflight_text_too_short` | full | B-3 — extracted_chars < 100 |
| `extraction_total_failure` | full | Toutes les passes d'extraction ont échoué |
| `rescan_residual_pii` | full | Rescan détecte ≥ 1 PII résiduelle |
| `pdf_redaction_failed` | partial | `redact_pdf_vector` rate (vector + raster fallback aussi) |
| `pdf_vector_fallback_to_raster` | partial | Vector raté, raster OK (qualité moindre) |
| `regex_user_invalid` | partial | Regex YAML utilisateur invalide skippée |
| `vlm_unavailable` | log only | VLM Ollama indisponible (acceptable) |

---

## 6. Format des fichiers de sortie

### 6.1 `quarantaine/<docname>.reason.txt`

```
Document       : doc_partial
Sévérité       : partial
Raison         : pdf_redaction_failed
Détail         : page.apply_redactions() raised RuntimeError: 'invalid encryption dictionary'
Horodatage     : 2026-05-30T14:32:11+02:00
Version code   : 0.11.0 (commit abc1234)
Profil appliqué: standard_local
Caractères extraits : 4823
Flags          : pdf_redaction_failed, pdf_vector_fallback_to_raster
Suggestion     : voir <output_dir>/<doc>.pseudonymise.txt pour le texte anonymisé;
                 le PDF d'origine peut nécessiter un déverrouillage.

--- stack trace ---
Traceback (most recent call last):
  ...
RuntimeError: invalid encryption dictionary
```

### 6.2 `quarantaine/INDEX.md`

Généré par `QuarantineManager.finalize()` :

```markdown
# Quarantaine — batch 2026-05-30 14:25

**Documents traités** : 50
**Quarantaine totale** : 2 (texte non livré)
**Quarantaine partielle** : 3 (texte OK, PDF en erreur)
**Taux** : 10.0%

## Quarantaine totale (full)

| Document | Raison | Caractères extraits | Action recommandée |
|---|---|---|---|
| doc_scan_raté | preflight_text_too_short | 12 | Vérifier OCR, ré-essayer avec docTR forcé |
| doc_residuel | rescan_residual_pii | 4520 | Inspection manuelle, fix regex/whitelist |

## Quarantaine partielle (partial)

| Document | Raison | Texte livré dans | Flags |
|---|---|---|---|
| doc_chiffré_1 | pdf_redaction_failed | <output_dir>/doc_chiffré_1.pseudonymise.txt | pdf_redaction_failed |
| doc_corrompu | pdf_vector_fallback_to_raster | <output_dir>/doc_corrompu.pseudonymise.txt + .redacted.pdf (raster) | pdf_vector_fallback_to_raster |

## Contexte batch

- Version : 0.11.0 (commit abc1234)
- Profil appliqué : standard_local
- Horodatage : 2026-05-30T14:25:00+02:00
```

### 6.3 `errors.log` — JSON-lines (B-2 cumulatif batch)

```jsonl
{"ts":"2026-05-30T14:25:34+02:00","doc":"doc_chiffré_1","level":"WARNING","category":"redaction.vector","msg":"apply_redactions failed: invalid encryption","severity":"partial"}
{"ts":"2026-05-30T14:26:12+02:00","doc":"doc_scan_raté","level":"ERROR","category":"preflight.text_too_short","msg":"Only 12 chars extracted","severity":"full"}
```

### 6.4 `<docname>.log` — humain (B-2 par doc)

```
2026-05-30T14:25:32+02:00 [INFO] extraction.layout_aware: 12 pages, 4823 chars
2026-05-30T14:25:33+02:00 [INFO] ner.eds_pseudo: 14 entities (avg conf 0.92)
2026-05-30T14:25:33+02:00 [INFO] ner.camembert: 12 entities
2026-05-30T14:25:34+02:00 [INFO] regex.pii: 3 hits (EMAIL, TEL, RPPS)
2026-05-30T14:25:34+02:00 [WARNING] redaction.vector: apply_redactions failed: invalid encryption
2026-05-30T14:25:34+02:00 [INFO] quarantine.flag: pdf_redaction_failed (partial)
2026-05-30T14:25:34+02:00 [INFO] output.text: doc_chiffré_1.pseudonymise.txt (4823 chars)
```

---

## 7. B-1 — Métadonnées sortie

### 7.1 `.audit.jsonl` — entrée `type=metadata` (1ère ligne)

```json
{"type":"metadata","app_version":"0.11.0","build_date":"2026-05-31","commit_sha":"abc1234","processed_at":"2026-05-30T14:25:32+02:00","profile_applied":"standard_local","document_name":"doc_ok","quarantine_flags":[]}
```

Champs source :
- `app_version`, `build_date`, `commit_sha` ← `build_info.py` (existant)
- `processed_at` ← `datetime.now().astimezone().isoformat()`
- `profile_applied` ← param `process_pdf`
- `quarantine_flags` ← `QuarantineManager` en fin de traitement

### 7.2 XMP métadonnées du PDF rédigé

**Dans `redact_pdf_vector` ET `redact_pdf_raster`, avant `doc.save(...)` :**

```python
# CRITIQUE — clear pour éviter fuite de l'auteur/titre du PDF source
doc.set_metadata({})

# Puis poser nos propres métadonnées
doc.set_metadata({
    "creator": f"Pseudonymisation v{APP_VERSION}",
    "producer": f"Pseudonymisation v{APP_VERSION} commit {COMMIT_SHA[:7]}",
    "title": f"Document anonymisé",  # PAS le nom original
    "subject": f"Pseudonymisation médicale - profil {profile_name}",
    "keywords": f"pseudonymisation; commit={COMMIT_SHA}; profile={profile_name}; ts={processed_at}",
    "author": "",         # vide explicite
    "creationDate": "",   # ne pas hériter
    "modDate": "",
})

# Garde-fou — vérifier que rien ne reste de la source
final_meta = doc.metadata or {}
for key in ("author", "title"):
    val = final_meta.get(key, "")
    assert "Pseudonymisation" in val or val == "" or val == "Document anonymisé", \
        f"PII leak suspectée dans XMP {key}: {val!r}"
```

---

## 8. B-3 Pré-flight texte vide

Dans `process_pdf`, juste après extraction :

```python
extracted_chars = sum(len(p) for p in pages_text)
doc_logger.info(f"extraction: {extracted_chars} chars, {len(pages_text)} pages, ocr={used_ocr}")

if extracted_chars < SEUIL_TEXTE_MINI:
    quarantine_mgr.flag(
        doc_name=pdf_path.stem,
        reason="preflight_text_too_short",
        detail=f"Only {extracted_chars} chars (seuil={SEUIL_TEXTE_MINI})",
        severity="full",
        extracted_chars=extracted_chars,
    )
    shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
    doc_logger.warning(f"preflight FAILED: {extracted_chars} < {SEUIL_TEXTE_MINI}")
    return {"quarantine_flags": ["preflight_text_too_short"]}
```

---

## 9. `_count_residual_pii` — réutiliser leak_scanner

**Ne pas réinventer.** Le fichier `evaluation/leak_scanner.py` contient déjà toutes les regex (EMAIL, TEL, NIR, IBAN, FINESS, IPP, RPPS, dates, adresses) + détection noms INSEE.

```python
from evaluation.leak_scanner import (
    RE_EMAIL, RE_TEL, RE_NIR, RE_IBAN,
    RE_FINESS, RE_RPPS, RE_DATE_NAISSANCE,
    detect_insee_names_in_text,
)

def _count_residual_pii(text: str) -> int:
    """Compte les PII résiduelles. Réutilise leak_scanner.py."""
    count = 0
    count += len(RE_EMAIL.findall(text))
    count += len(RE_TEL.findall(text))
    count += len(RE_NIR.findall(text))
    count += len(RE_IBAN.findall(text))
    count += len(RE_FINESS.findall(text))
    count += len(RE_RPPS.findall(text))
    count += len(RE_DATE_NAISSANCE.findall(text))
    count += len(detect_insee_names_in_text(text, threshold="high"))
    return count
```

*Note : si l'API exacte de leak_scanner diffère, adapter — l'idée est : zéro duplication.*

---

## 10. Diff conceptuel `process_pdf` (orchestration globale)

```python
def process_pdf(pdf_path, output_dir, quarantine_mgr, profile_name, ...) -> dict:
    doc_log = DocLogger(output_dir / f"{pdf_path.stem}.log")
    doc_log.info(f"start: {pdf_path.name}")
    flags = []

    # 1. Extraction (les except internes sont déjà log+continue)
    try:
        pages_text, tables_lines, used_ocr, ocr_word_map = \
            extract_text_with_fallback_ocr(pdf_path)
    except Exception as e:
        quarantine_mgr.flag(pdf_path.stem, "extraction_total_failure",
                            str(e), "full", exc=e)
        shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
        doc_log.error(f"extraction failed: {e}")
        return {"quarantine_flags": ["extraction_total_failure"]}

    # 2. B-3 Pré-flight
    extracted_chars = sum(len(p) for p in pages_text)
    if extracted_chars < SEUIL_TEXTE_MINI:
        quarantine_mgr.flag(pdf_path.stem, "preflight_text_too_short",
                            f"{extracted_chars} chars", "full",
                            extracted_chars=extracted_chars)
        shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
        return {"quarantine_flags": ["preflight_text_too_short"]}

    # H. Check tables vides (info only)
    if tables_lines and sum(sum(len(r) for r in t) for t in tables_lines) == 0:
        doc_log.warning("tables extracted but empty")

    # 3. Anonymisation (inchangé)
    anon = anonymise_document_regex(pages_text, tables_lines, cfg=cfg,
                                     ocr_word_map=ocr_word_map)
    doc_log.info(f"anonymisation: {len(anon.audit)} hits")

    # 4. Rescan + check résiduel (Q-DOC si rate)
    final_text = selective_rescan(anon.text, cfg=cfg)
    residual_count = _count_residual_pii(final_text)
    doc_log.info(f"rescan: {residual_count} residual PII")
    if residual_count > SEUIL_RESCAN_RESIDUEL:  # = 0
        quarantine_mgr.flag(pdf_path.stem, "rescan_residual_pii",
                            f"{residual_count} residual PII", "full")
        shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
        (quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.partial.json").write_text(
            json.dumps([h.__dict__ for h in anon.audit], indent=2)
        )
        return {"quarantine_flags": ["rescan_residual_pii"]}

    # 5. Sortie texte + audit (B-1 metadata)
    text_path = output_dir / f"{pdf_path.stem}.pseudonymise.txt"
    text_path.write_text(final_text)
    audit_path = output_dir / f"{pdf_path.stem}.audit.jsonl"
    _write_audit_with_metadata(audit_path, anon.audit, profile_name,
                                quarantine_flags=[])
    doc_log.info("text + audit written")

    # 6. Rédaction PDF vector (Q-PDF si rate) + fallback raster
    pdf_vector_path = output_dir / f"{pdf_path.stem}.redacted.pdf"
    pdf_vector_ok = False
    try:
        redact_pdf_vector(pdf_path, anon.audit, pdf_vector_path,
                          ocr_word_map=ocr_word_map,
                          profile_name=profile_name)
        pdf_vector_ok = True
        doc_log.info("PDF vector redaction OK")
    except Exception as e:
        flags.append("pdf_redaction_failed")
        doc_log.warning(f"PDF vector failed: {e}")

        # B — Fallback raster (Décision B)
        try:
            redact_pdf_raster(pdf_path, anon.audit, pdf_vector_path,
                              ocr_word_map=ocr_word_map,
                              profile_name=profile_name)
            flags.append("pdf_vector_fallback_to_raster")
            doc_log.info("PDF raster fallback OK")
        except Exception as e2:
            doc_log.error(f"PDF raster fallback also failed: {e2}")

        quarantine_mgr.flag(pdf_path.stem, "pdf_redaction_failed",
                            str(e), "partial", exc=e, flags=flags.copy())

        # A — Copier le texte en quarantaine pour autoportance (Décision A finalisée)
        shutil.copy(text_path, quarantine_mgr.quarantine_dir / text_path.name)

    return {
        "text": str(text_path),
        "audit": str(audit_path),
        "pdf_vector": str(pdf_vector_path) if pdf_vector_ok or "pdf_vector_fallback_to_raster" in flags else None,
        "quarantine_flags": flags,
    }
```

**Changement clé ligne 3938** dans `redact_pdf_vector` :

```python
# AVANT
try:
    page.apply_redactions()
except Exception:
    pass  # silence catastrophique

# APRÈS
try:
    page.apply_redactions()
except Exception as e:
    log.warning(f"apply_redactions failed on page {page.number}: {e}")
    raise  # remonte pour Q-PDF flag
```

---

## 11. Tests à écrire

### 11.1 Existants à dégeler (`tests/unit/test_q1_quarantine.py`, déjà créé par Claude)

- 10 tests `xfail strict` → retirer `xfail` au fur et à mesure

### 11.2 Nouveaux tests (Qwen) à ajouter dans le même fichier

```python
# 1. test_quarantine_index_md_format — INDEX.md généré au bon format
# 2. test_errors_log_json_lines — chaque ligne d'errors.log = JSON valide
# 3. test_doc_log_per_document — chaque doc a son .log
# 4. test_xmp_metadata_no_source_leak — métadonnées source PDF non copiées
# 5. test_preflight_text_too_short_boundary — tester à 99, 100, 101 chars
# 6. test_pdf_vector_fallback_to_raster_flag — flag levé même si raster OK
# 7. test_residual_pii_zero_tolerance — seuil 0 → flag même 1 PII résiduelle
```

### 11.3 Test C-8 (régression GRAND, séparé)

```python
# tests/unit/test_c8_grand_regression.py
# - test_grand_insee_name_is_masked
# - test_grande_medical_not_masked
# - test_stopword_no_longer_blocks_insee_names
```

---

## 12. Ordre de codage révisé (vendredi)

| Étape | Effort | Livrable |
|---|---|---|
| 1. Créer `quarantine.py` (dataclass + manager + DocLogger) | 1h30 | Module testable isolément |
| 2. C-8 : retirer `"grand"` de `data/stopwords_manuels.txt:549` + 3 tests | 30 min | Régression GRAND fixée |
| 3. Patch `redact_pdf_vector:3938` (`raise` au lieu de `pass`) | 15 min | Pas de PDF silencieux non rédigé |
| 4. Patch `process_pdf:4655` (try/flag + fallback raster) | 1h30 | Q-PDF différentielle |
| 5. B-3 pré-flight dans `process_pdf` | 30 min | Q-DOC sur texte vide |
| 6. Rescan check + `_count_residual_pii` (réutiliser leak_scanner) | 1h | Q-DOC sur PII résiduelles |
| 7. B-1 metadata `.audit.jsonl` + XMP avec clear() | 1h | Traçabilité version+commit |
| 8. Dégeler les 17 tests Q-1 (10 + 7 ajouts) | 2h | Tests verts |
| 9. Run complet `evaluate_quality.py` audit_30 — vérifier 99.8 → 100 | 30 min | Validation MVP |

**Total : ~9h** — gros mais faisable sur le vendredi avec début matin.

---

## 13. Ce qu'on NE TOUCHE PAS (D-10)

- ❌ `Pseudonymisation_Gui_V5.py`
- ❌ Pop-up, boutons, titre fenêtre, status bar
- ❌ `manual_masking.py`, `pdf_mask_designer.py` (reportés v11.5)
- ❌ Audit admin_rules (reporté v11.5)

---

## 14. Constantes à ajouter

Dans `config_defaults.py` (ou en tête du core) :

```python
# Q-1 Quarantaine
SEUIL_TEXTE_MINI = 100         # B-3 préflight
SEUIL_RESCAN_RESIDUEL = 0      # Tolérance zéro après rescan
QUARANTINE_DIR_NAME = "quarantaine"
```

---

— Claude (consolidé Qwen)
