---
from: claude
to: dom
date: 2026-05-29T08:50:00+02:00
topic: pseudocode-Q1-quarantaine
status: open
references:
  - decision: decisions/2026-05-28_dom_mvp-livraison-mardi.md
  - decision: decisions/2026-05-28_dom_no-ui-changes.md
  - file: anonymizer_core_refactored_onnx.py
  - tests: tests/unit/test_q1_quarantine.py
priority: blocker
---

# Pseudo-code Q-1 — Quarantaine différentielle (Plan B Claude)

## Contexte

Qwen muet depuis 14h (probable interruption en plein output). Plan B activé : Claude rédige le pseudo-code. Tu codes vendredi.

**Périmètre :** sécuriser les chemins critiques de rédaction PDF + ajouter B-1 (métadonnées) et B-3 (pré-flight) dans le même patch, **sans toucher à la GUI** (D-10).

**Principe fondateur :** un document n'est livré « anonymisé » que si **toutes** les étapes critiques ont réussi. Sinon → quarantaine différentielle (texte si OK, PDF en quarantaine si rédaction rate, doc entier en quarantaine si pré-flight ou rescan critique).

---

## 1. Inventaire des `except Exception: pass` à modifier

Sur 40 `except Exception` dans `anonymizer_core_refactored_onnx.py`, **13 sont critiques** pour Q-1. Les autres (imports optionnels, fallbacks de police, etc.) restent en l'état.

Légende action :
- **L** = log seulement (dégradation acceptable, fallback existe)
- **Q-PDF** = log + flag quarantaine du PDF (texte sort, PDF en quarantaine)
- **Q-DOC** = log + quarantaine doc entier (texte vide, rescan résiduel critique)

| # | Fichier:ligne | Fonction | Contexte | Action |
|---|---|---|---|---|
| 1 | `anonymizer_core_refactored_onnx.py:1118` | `extract_text_with_fallback_ocr` | extraction tables PyMuPDF | **L** (info, tables fallback) |
| 2 | `:1128` | `extract_text_with_fallback_ocr` | extraction layout-aware PyMuPDF | **L** (warning) + tracker `extraction_passes_failed` |
| 3 | `:1139` | `extract_text_with_fallback_ocr` | extraction pdfplumber | **L** (warning) + tracker |
| 4 | `:1156` | `extract_text_with_fallback_ocr` | extraction pdfminer | **L** (warning) + tracker |
| 5 | `:1225` | `_compile_user_regex` | regex utilisateur YAML invalide | **L** (warning) + add to `errors.log` (Q-4 sandboxing à v11.5) |
| 6 | `:1242` | `force_mask_regex` compile | idem | **L** (warning) |
| 7 | **`:3938`** | **`redact_pdf_vector`** | **`page.apply_redactions()` échoue** | **Q-PDF** (CRITIQUE) |
| 8 | `:3984` | `redact_pdf_raster` | pyzbar codes-barres | **L** (debug, optionnel) |
| 9 | `:3991` | `redact_pdf_raster` | police DejaVu fallback | **L** (debug) |
| 10 | `:4137` | `process_pdf` | extraction image rects par page | **L** (debug) |
| 11 | `:4202` | `process_pdf` | VLM Ollama analyze_page | **L** (warning, VLM optionnel) |
| 12 | `:4276` | `process_pdf` | `_apply_vlm_on_scanned_pdf` | **L** (warning, dégradation gracieuse) |
| 13 | **`:4655`** | **`process_pdf`** | **`redact_pdf_vector()` orchestration** | **Q-PDF** (CRITIQUE) |

**Bonus à ajouter (pas un `except: pass` existant) :**
- Après `final_text = selective_rescan(final_text, cfg=cfg)` ligne 4291 → ajouter un **rescan_check** qui compte les PII résiduelles. Si > seuil → **Q-DOC**.
- Avant tout traitement, **B-3 pré-flight** : si `sum(len(p) for p in pages_text) < SEUIL_TEXTE_MINI` → **Q-DOC** direct.

---

## 2. Nouvelle API à introduire

### 2.1 Dataclass `QuarantineEntry`

Dans un nouveau module `quarantine.py` (collocated avec le core) :

```python
@dataclass
class QuarantineEntry:
    doc_name: str                # nom de base sans extension
    reason: str                  # code court (preflight_text_too_short, pdf_redaction_failed, rescan_residual_pii, regex_user_invalid)
    detail: str                  # message libre
    timestamp: str               # ISO 8601
    flags: list[str]             # peut contenir plusieurs raisons cumulées
    severity: Literal["partial", "full"]
                                 # partial = seul le PDF en quarantaine (texte OK)
                                 # full = doc entier en quarantaine
    stacktrace: Optional[str]    # si exception, le tb.format_exc()
    extracted_chars: int         # nb caractères extraits (utile pour preflight)
```

### 2.2 Classe `QuarantineManager` (1 instance par batch)

```python
class QuarantineManager:
    def __init__(self, output_dir: Path):
        self.output_dir = output_dir
        self.quarantine_dir = output_dir / "quarantaine"
        self.entries: list[QuarantineEntry] = []
        self._errors_log_path = output_dir / "errors.log"

    def flag(self, doc_name, reason, detail, severity, *, exc=None, extracted_chars=0):
        # Crée l'entrée + écrit .reason.txt + append errors.log
        ...

    def has_full_quarantine(self, doc_name) -> bool:
        # Le doc est en quarantaine totale (pas de sortie attendue)
        ...

    def finalize(self):
        # Écrit quarantaine/INDEX.md à la fin du batch
        ...
```

### 2.3 Helper module-level

```python
SEUIL_TEXTE_MINI = 50  # caractères — sous ce seuil = OCR raté ou doc vide
SEUIL_RESCAN_RESIDUEL = 3  # nb de matches regex post-rescan acceptables (0 idéal)
```

---

## 3. Structure dossier sortie

```
<output_dir>/
├── errors.log                          # cumulatif batch (B-2)
├── doc_ok.pseudonymise.txt
├── doc_ok.audit.jsonl                  # avec entrée type=metadata (B-1)
├── doc_ok.redacted.pdf                 # XMP metadata (B-1)
├── doc_ok.log                          # log par doc (B-2)
└── quarantaine/
    ├── INDEX.md                        # généré à la fin du batch
    ├── doc_partial.reason.txt          # Q-PDF (partial)
    ├── doc_partial.pseudonymise.txt    # texte OK, sort aussi en quarantaine/
    │                                   # pour traçabilité (ou seulement dans output_dir ?)
    ├── doc_full.reason.txt             # Q-DOC (full)
    ├── doc_full.original.pdf           # copie source pour ré-essai
    └── doc_full.partial.json           # PII détectées avant l'échec
```

**Décision à prendre par toi :** pour les Q-PDF (partial), le texte `.pseudonymise.txt` sort dans `output_dir` (avec drapeau dans `INDEX.md`) **OU** dupliqué en `quarantaine/` pour faciliter le repérage. **Mon avis :** texte uniquement dans `output_dir`, INDEX.md liste le problème PDF — sinon doublon source de confusion.

---

## 4. Format des fichiers

### 4.1 `<docname>.reason.txt` (humain-lisible)

```
Document       : doc_partial
Sévérité       : partial (le PDF de sortie n'a pas pu être généré, le texte anonymisé est disponible)
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)
Caractères extraits : 4823
Suggestion opérateur : ré-essayer manuellement avec un PDF non chiffré, ou consulter le .pseudonymise.txt

--- stack trace ---
<traceback>
```

### 4.2 `quarantaine/INDEX.md` (généré en fin de batch)

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

Documents en quarantaine totale (texte non livré) : **2**
Documents en quarantaine partielle (texte OK, PDF non rédigé) : **3**

## Quarantaine totale

| Document | Raison | Action recommandée |
|---|---|---|
| doc_scan_raté | preflight_text_too_short | Vérifier OCR, ré-essayer avec docTR forcé |
| doc_grand_residuel | rescan_residual_pii | Inspection manuelle, fix regex ou whitelist |

## Quarantaine partielle (PDF uniquement)

| Document | Raison | Texte livré dans |
|---|---|---|
| doc_chiffré_1 | pdf_redaction_failed | <output_dir>/doc_chiffré_1.pseudonymise.txt |
| doc_chiffré_2 | pdf_redaction_failed | <output_dir>/doc_chiffré_2.pseudonymise.txt |
| doc_annot_corrompue | pdf_redaction_failed | <output_dir>/doc_annot_corrompue.pseudonymise.txt |

## Contexte batch

- Version : 0.11.0 (commit abc1234)
- Profil appliqué : standard_local
- Documents traités : 50
- Documents OK : 45
- Taux quarantaine : 10.0%
```

### 4.3 `<docname>.log` (B-2)

Format simple, append-only :

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

### 4.4 `errors.log` (cumulatif batch)

Une seule ligne par erreur, format JSON ligne pour parsing facile :

```
{"ts": "2026-05-30T14:25:34+02:00", "doc": "doc_chiffré_1", "level": "WARNING", "category": "redaction.vector", "msg": "page.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 (seuil=50)", "severity": "full"}
```

---

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

### 5.1 Entrée `type=metadata` dans `.audit.jsonl`

À ajouter **en première ligne** de chaque `.audit.jsonl` :

```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",
  "quarantine_flags": [],
  "document_name": "doc_ok"
}
```

Source des champs :
- `app_version`, `build_date`, `commit_sha` → depuis `build_info.py` (déjà existant)
- `processed_at` → `datetime.now().isoformat()`
- `profile_applied` → param de `process_pdf`
- `quarantine_flags` → rempli par `QuarantineManager` en fin de traitement du doc

### 5.2 XMP metadata du PDF rédigé

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

```python
doc.set_metadata({
    "creator": f"Pseudonymisation v{APP_VERSION}",
    "producer": f"Pseudonymisation v{APP_VERSION} commit {COMMIT_SHA[:7]}",
    "title": f"{original_filename} (anonymisé)",
    "subject": f"Pseudonymisation médicale - profil {profile_name}",
    "keywords": f"pseudonymisation; commit={COMMIT_SHA}; profile={profile_name}; ts={processed_at}",
    # NE PAS mettre dans author : c'est le nom original peut contenir des données patient
})
```

**Garde-fou** : ne **JAMAIS** copier `author`, `subject`, `keywords` du PDF source dans la sortie — risque de fuite (nom patient en métadonnée).

---

## 6. B-3 — Pré-flight texte vide

Dans `process_pdf`, juste après `extract_text_with_fallback_ocr` :

```python
pages_text, tables_lines, used_ocr, ocr_word_map = extract_text_with_fallback_ocr(pdf_path)
extracted_chars = sum(len(p) for p in pages_text)

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 extracted from {len(pages_text)} pages (seuil={SEUIL_TEXTE_MINI})",
        severity="full",
        extracted_chars=extracted_chars,
    )
    # Copier le PDF original dans quarantaine/
    shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
    return  # Ne PAS sortir de fichier anonymisé pour ce doc
```

---

## 7. Diff conceptuel `process_pdf`

```python
def process_pdf(pdf_path, output_dir, quarantine_mgr, profile_name, ...) -> dict:
    """
    Returns: {
        "text": "...",
        "audit": [...],
        "pdf_vector": "path or None",
        "pdf_raster": "path or None",
        "quarantine_flags": [...],
    }
    """
    doc_log = DocLogger(output_dir / f"{pdf_path.stem}.log")
    doc_log.info(f"start processing {pdf_path.name}")

    # === 1. Extraction ===
    try:
        pages_text, tables_lines, used_ocr, ocr_word_map = extract_text_with_fallback_ocr(pdf_path)
    except Exception as e:
        # Aucune passe d'extraction n'a réussi
        quarantine_mgr.flag(pdf_path.stem, "extraction_total_failure",
                            str(e), severity="full", exc=e)
        doc_log.error(f"extraction failed: {e}")
        shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
        return {"quarantine_flags": ["extraction_total_failure"]}

    # === 2. B-3 Pré-flight texte vide ===
    extracted_chars = sum(len(p) for p in pages_text)
    doc_log.info(f"extracted {extracted_chars} chars from {len(pages_text)} pages, ocr={used_ocr}")

    if extracted_chars < SEUIL_TEXTE_MINI:
        quarantine_mgr.flag(pdf_path.stem, "preflight_text_too_short",
                            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_log.warning(f"preflight FAILED: only {extracted_chars} chars")
        return {"quarantine_flags": ["preflight_text_too_short"]}

    # === 3. Anonymisation regex/NER (inchangé sur le fond) ===
    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 ===
    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:
        quarantine_mgr.flag(pdf_path.stem, "rescan_residual_pii",
                            f"{residual_count} residual PII after rescan",
                            severity="full")
        shutil.copy(pdf_path, quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.original.pdf")
        # Sauver les PII détectées pour analyse
        (quarantine_mgr.quarantine_dir / f"{pdf_path.stem}.partial.json").write_text(
            json.dumps([h.__dict__ for h in anon.audit], indent=2)
        )
        doc_log.error(f"rescan FAILED: {residual_count} residual PII")
        return {"quarantine_flags": ["rescan_residual_pii"]}

    # === 5. Sortie texte + audit (avec B-1) ===
    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(f"text + audit written")

    # === 6. Rédaction PDF (Q-PDF si échec) ===
    pdf_vector_path = output_dir / f"{pdf_path.stem}.redacted.pdf"
    flags = []
    try:
        redact_pdf_vector(pdf_path, anon.audit, pdf_vector_path,
                          ocr_word_map=ocr_word_map,
                          metadata={"profile": profile_name, "commit": COMMIT_SHA})
        doc_log.info(f"PDF vector redaction OK")
    except Exception as e:
        quarantine_mgr.flag(pdf_path.stem, "pdf_redaction_failed",
                            str(e), severity="partial", exc=e)
        flags.append("pdf_redaction_failed")
        doc_log.warning(f"PDF vector redaction FAILED: {e}")
        # Ne PAS lever — le texte est OK, on continue

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

**Changement crucial ligne 4655 :** au lieu de `try: redact_pdf_vector(...); outputs["pdf_vector"] = ... except: pass` silencieux, on a une vraie gestion d'erreur avec flag de quarantaine.

**Changement ligne 3938 :** dans `redact_pdf_vector` lui-même :
```python
try:
    page.apply_redactions()
except Exception as e:
    log.warning(f"apply_redactions failed on page {page.number}: {e}")
    raise  # Remonter pour que process_pdf flag la quarantaine
```

Au lieu de `pass` silencieux qui laissait passer le PDF sans rédaction.

---

## 8. Helper `_count_residual_pii`

À ajouter dans le core :

```python
def _count_residual_pii(text: str) -> int:
    """Compte les PII résiduelles après anonymisation/rescan.
    Utilise les regex de leak_scanner.py existant."""
    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))
    # Et les noms INSEE en MAJUSCULES (cas GRAND, MARTIN, etc.)
    for token in re.findall(r"\b[A-ZÀ-Ÿ]{4,}\b", text):
        if token.lower() in _INSEE_NOMS_FAMILLE:
            count += 1
    return count
```

---

## 9. Notes implémentation pour toi

### 9.1 Ordre de codage suggéré (vendredi)

1. **Matin :** créer `quarantine.py` (dataclass + manager) — 1h
2. **Matin :** modifier `redact_pdf_vector:3938` pour `raise` au lieu de `pass` — 30 min
3. **Matin :** modifier `process_pdf:4655` avec le pattern try/flag — 1h
4. **Matin :** ajouter B-3 pré-flight dans `process_pdf` — 30 min
5. **Après-midi :** ajouter rescan_check + `_count_residual_pii` — 1h
6. **Après-midi :** modifier `redact_pdf_vector` et `redact_pdf_raster` pour XMP metadata — 30 min
7. **Après-midi :** ajouter entrée `type=metadata` dans `.audit.jsonl` — 30 min
8. **Après-midi :** ajouter `DocLogger` simple (B-2) — 30 min
9. **Soir :** dégeler les tests `test_q1_quarantine.py` (retirer `xfail`) et faire passer — 2h

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

- ❌ `Pseudonymisation_Gui_V5.py`
- ❌ Pas de pop-up, pas de nouveau bouton
- ❌ Pas de modif titre fenêtre

### 9.3 Variables d'env / constantes ajoutées

Dans `config_defaults.py` (ou en haut du core) :
```python
SEUIL_TEXTE_MINI = 50         # B-3 préflight
SEUIL_RESCAN_RESIDUEL = 3     # Q-DOC sur rescan
QUARANTINE_DIR_NAME = "quarantaine"
```

À mettre éventuellement configurable via YAML dans `dictionnaires.yml` plus tard.

### 9.4 Tests qui changent de statut

Les 10 tests dans `tests/unit/test_q1_quarantine.py` (déjà créés par moi) :
- 9 tests en `@pytest.mark.xfail(strict=True)` → à dégeler 1 par 1 au fur et à mesure
- 1 test (`test_happy_path_no_quarantine_created_if_no_failure`) doit passer en premier

### 9.5 Points à valider avec moi avant code

- **Décision A** : pour Q-PDF (partial), le `.pseudonymise.txt` sort dans `output_dir` uniquement, ou aussi en copie dans `quarantaine/` ?
  - Mon avis : `output_dir` uniquement, mentionné dans `INDEX.md`
- **Décision B** : si `redact_pdf_vector` rate, on essaie quand même `redact_pdf_raster` en fallback ?
  - Mon avis : oui, ça augmente la robustesse — mais flag partial reste levé même si raster réussit
- **Décision C** : seuils `SEUIL_TEXTE_MINI=50` et `SEUIL_RESCAN_RESIDUEL=3` — à confirmer sur le corpus audit_30
  - Mon avis : démarrer avec 50/3, ajuster après mesure réelle

Réponds-moi sur A/B/C avant de coder.

---

## 10. Risque résiduel après Q-1

Avec ce patch implémenté, le pire scénario devient :
- Le rescan + check résiduel passe (pas de PII détectable)
- Mais une PII non couverte par les regex/NER reste dans le doc

→ Le risque est ramené à ce qu'on **savait pas détecter** (problème d'algo), pas à des **bugs silencieux** (problème d'engineering). C'est l'amélioration RGPD attendue.

---

— Claude
