docs(coordination): protocole de coordination + décisions + inbox + log + vision

- docs/coordination/ : README, decisions (no-ui, pivots MVP), inbox Claude/Qwen/Dom, archive, log, etat-projet
- docs/installation/ : procédure SmartScreen
- docs/reflexions/ : vision fonctionnelle avant prod

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 16:31:06 +02:00
parent 94233c3538
commit c4adb8db00
37 changed files with 4171 additions and 0 deletions

View File

@@ -0,0 +1,464 @@
---
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

View File

@@ -0,0 +1,572 @@
---
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)

View File

@@ -0,0 +1,145 @@
---
from: qwen
to: dom
date: 2026-05-29T10:30:00+02:00
topic: analyse-regression-grand
status: open
references:
- file: data/stopwords_manuels.txt:549
- file: anonymizer_core_refactored_onnx.py:2725
- file: data/insee/noms_famille_frequents.txt:43233
- commit: 13730d1
- tests: tests/unit/test_leak_scanner.py
priority: high
---
# Analyse régression — fuite "GRAND" (17 occurrences)
## Contexte
L'évaluation qualité sur le commit `13730d1` détecte **17 occurrences de "GRAND"** non masquées dans le document `trackare-05012965-23060770_05012965_23060770.pseudonymise.txt`. Score qualité : 99.8/100 (régression de -0.3 sur leak_score vs baseline).
---
## Cause racine identifiée
**Le mot `"grand"` est présent dans `data/stopwords_manuels.txt` à la ligne 549.**
Ce mot est chargé dans `_MEDICAL_STOP_WORDS_SET` (ligne 474-475 du core).
### Pourquoi "grand" a été ajouté aux stopwords
Probablement pour filtrer des expressions médicales comme "grand axe", "grande courbure", "grande taille" — termes anatomiques légitimes qui ne doivent pas être masqués.
### Pourquoi c'est un problème
**"GRAND" est aussi un nom de famille INSEE valide et courant** :
- Présent dans `data/insee/noms_famille_france.txt` (ligne 97117)
- Présent dans `data/insee/noms_famille_frequents.txt` (ligne 43233)
---
## Mécanisme de la fuite
Le patient s'appelle **Romain BILLON-GRAND**, et le médecin traitant est **DR. [NOM]-GRAND**.
Dans le fichier de sortie, les 17 occurrences non masquées apparaissent sous deux formes :
1. **`DR. [NOM]-GRAND`** — nom du docteur dans les en-têtes de prescriptions
2. **`[NOM]-GRAND`** — dans les tableaux de prescriptions
### Pourquoi le nom composé "BILLON-GRAND" est masqué mais "GRAND" seul ne l'est pas
Le pipeline traite "BILLON-GRAND" comme un **token unique** (pas de split sur le tiret dans `_extract_trackare_identity._add_name`). Le nom composé est détecté via le contexte `DR.` et masqué correctement.
Mais dans les tableaux Trackare, le formatage fait que **"GRAND" se retrouve seul sur une ligne**, séparé de "[NOM]-" par un saut de ligne :
```
DR. [NOM]-
GRAND
```
Quand le traitement ligne par ligne (`_mask_line_by_line`) rencontre "GRAND" seul :
```python
# Ligne ~2725 du core
if tok.lower() in _MEDICAL_STOP_WORDS_SET:
continue # ← "grand" est skipé, jamais proposé au masquage
```
**"grand" étant dans les stopwords, il est filtré et jamais masqué.**
---
## Les 2 occurrences de "grande" — faux positif
Le document contient aussi 2 occurrences de `"grande"` (lignes 2969, 2974) :
- `plus grande epaisseur` (radiologie/anatomie)
- `plus grande epaisseur` (radiologie)
Ces occurrences sont **légitimes** — ce sont des adjectifs médicaux. Elles ne doivent **PAS** être masquées.
---
## Fix proposé
### Option 1 (simple) : retirer "grand" des stopwords
**Action** : supprimer la ligne 549 de `data/stopwords_manuels.txt`.
**Impact** : "GRAND" en MAJUSCULES sera détecté comme nom INSEE et masqué. "grande" en minuscules dans un contexte médical ("plus grande epaisseur") ne sera pas affecté car le matching INSEE se fait sur les tokens en MAJUSCULES.
**Risque** : "grand" en minuscule dans d'autres contextes pourrait être détecté comme faux positif (ex: "grand axe"). Mais le mécanisme de validation croisée (`_cross_validate_name_candidates`) exige un contexte fort (DR., MAJUSCULES, etc.) pour masquer — un "grand" isolé en minuscule ne sera pas masqué.
**Recommandation** : ✅ **Option 1 retenue**. C'est le fix le plus sûr.
### Option 2 (complexe) : stopwords contextuels
Transformer les stopwords single-word en patterns contextuels :
- "grand" → ne filtrer que si suivi de "axe", "courbure", "epaisseur", etc.
- Sinon, laisser le pipeline INSEE faire son travail
**Risque** : complexité accrue, maintenance difficile. Pas adapté au sprint MVP.
### Option 3 (hybride) : whitelist/blacklist INSEE
Ajouter une couche qui dit : "si un token est dans INSEE ET dans les stopwords, la priorité INSEE l'emporte".
**Impact** : changement de logique dans `_cross_validate_name_candidates`. Risque de faux positifs sur d'autres mots ambigus.
---
## Tests à ajouter
```python
# tests/unit/test_c8_grand_regression.py
def test_grand_insee_name_is_masked():
"""GRAND (nom INSEE) en MAJUSCULES doit être masqué même si présent dans stopwords."""
...
def test_grande_medical_not_masked():
"""'plus grande epaisseur' ne doit pas être masqué (adjectif médical)."""
...
def test_stopword_no_longer_blocks_insee_names():
"""Après retrait de 'grand' des stopwords, les noms INSEE ambigus sont traités."""
...
```
---
## Résumé
| Aspect | Détail |
|---|---|
| **Cause racine** | `"grand"` dans `data/stopwords_manuels.txt:549` filtre les noms INSEE "GRAND" |
| **Mécanisme** | `_mask_line_by_line` skip les tokens dont `.lower()` est dans `_MEDICAL_STOP_WORDS_SET` |
| **Impact** | 17 fuites réelles (nom propre BILLON-GRAND) |
| **Fix recommandé** | Retirer `"grand"` de `data/stopwords_manuels.txt` — 1 ligne à supprimer |
| **Risque du fix** | Minimal — le pipeline INSEE exige un contexte fort pour masquer |
| **Effort** | 5 minutes (1 ligne + 3 tests) |
---
— Qwen

View File

@@ -0,0 +1,94 @@
# Tests C-8 — Régression fuite "GRAND"
# Cause racine : "grand" dans data/stopwords_manuels.txt:549 filtrait le nom INSEE
# Fix : supprimer "grand" des stopwords + vérification que les noms INSEE ambigus sont masqués
import pytest
import sys
import os
# Ajout du path parent pour imports du core
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
class TestGrandInseeRegression:
"""Tests de non-régression pour la fuite du nom INSEE "GRAND"."""
def test_grand_insee_name_is_masked(self):
"""GRAND (nom INSEE) en MAJUSCULES après contexte DR. doit être masqué."""
# Importer la fonction de masquage du core
# Après le fix, "grand" n'est plus dans les stopwords
# Donc "DR. GRAND" ou "DR. BILLON-GRAND" → GRAND doit être masqué
text = "DR. GRAND a prescrit un traitement."
# Résultat attendu : "DR. [NOM] a prescrit un traitement." ou similaire
# Le placeholder exact dépend du profil (standard_local → [NOM])
# On teste que "GRAND" n'apparaît plus en clair dans le texte
from anonymizer_core_refactored_onnx import anonymise_document_regex
# On construit une config minimale avec les stopwords mis à jour
# Ce test nécessitera l'infra de test du core
# Pour l'instant, on marque le test avec la logique attendue
assert "GRAND" not in result.upper() or "[NOM]" in result or "[PERSONNE]" in result
def test_grande_medical_not_masked(self):
"""'plus grande epaisseur' ne doit pas être masqué (adjectif médical)."""
# "grande" en minuscules dans un contexte anatomique est légitime
# Le masquage INSEE ne s'applique qu'aux tokens en MAJUSCULES
text = "La plus grande epaisseur de la paroi est de 12 mm."
# Résultat attendu : texte inchangé (aucun PII détecté)
assert "grande epaisseur" in result.lower()
assert "[NOM]" not in result
def test_stopword_no_longer_blocks_insee_names(self):
"""Après retrait de 'grand' des stopwords, les noms INSEE ambigus sont traités."""
# Vérifier que "grand" n'est PLUS dans _MEDICAL_STOP_WORDS_SET
from anonymizer_core_refactored_onnx import _MEDICAL_STOP_WORDS_SET
assert "grand" not in _MEDICAL_STOP_WORDS_SET, \
"'grand' doit être retiré des stopwords médicaux (C-8)"
def test_grand_compose_name_masked(self):
"""Un nom composé contenant GRAND doit être masqué intégralement."""
# Cas original de la fuite : BILLON-GRAND
text = "Patient : BILLON-GRAND Romain, né le..."
# Résultat attendu : "Patient : [NOM]-[NOM] Romain, né le..."
# ou "Patient : [NOM] Romain, né le..." (masquage du composé)
assert "BILLON-GRAND" not in result
assert "GRAND" not in result
def test_grand_isolated_uppercase_masked(self):
"""GRAND seul en MAJUSCULES (après saut de ligne) doit être masqué."""
# C'est le cas exact de la fuite Trackare :
# DR. [NOM]-
# GRAND
text = "DR. RAYNAL-\nGRAND sans injection."
# Résultat attendu : GRAND masqué car nom INSEE en MAJUSCULES
assert "GRAND" not in result
class TestStopwordsIntegrity:
"""Tests d'intégrité sur la liste de stopwords médicaux."""
def test_no_insee_names_in_stopwords(self):
"""Aucun nom de famille INSEE fréquent ne doit être dans les stopwords."""
# Charger les stopwords
from anonymizer_core_refactored_onnx import _MEDICAL_STOP_WORDS_SET
# Charger les noms INSEE fréquents
insee_path = os.path.join(
os.path.dirname(__file__), '..', '..', 'data', 'insee',
'noms_famille_frequents.txt'
)
if os.path.exists(insee_path):
with open(insee_path) as f:
insee_names = {line.strip().lower() for line in f if line.strip()}
# Intersection
overlap = _MEDICAL_STOP_WORDS_SET & insee_names
assert not overlap, \
f"Noms INSEE trouvés dans les stopwords (supprimer : {overlap})"
def test_stopwords_file_no_duplicates(self):
"""Le fichier stopwords_manuels.txt ne doit pas contenir de doublons."""
stopwords_path = os.path.join(
os.path.dirname(__file__), '..', '..', 'data', 'stopwords_manuels.txt'
)
with open(stopwords_path) as f:
lines = [l.strip() for l in f if l.strip() and not l.startswith('#')]
assert len(lines) == len(set(lines)), \
f"Doublons dans stopwords_manuels.txt : {len(lines) - len(set(lines))}"

View File

@@ -0,0 +1,78 @@
---
from: qwen
to: dom
date: 2026-06-02T15:30:00+02:00
topic: ejnaini-investigation
status: open
priority: normal
references:
- file: inbox/for-qwen/2026-06-02_15-00_claude_status-paranames-taches.md
- file: corpus_validation/trackare-BA127127-23135726_BA127127_23135726.pseudonymise.txt
- file: regression_tests/baseline/trackare-BA127127-23135726_BA127127_23135726.pseudonymise.txt
---
# Investigation EJNAINI — 7 fuites dans trackare-BA127127
## Résultat
**Root cause identifié : le nom composé "NOCENT-EJNAINI" est coupé par un saut de ligne.**
Le format Trackare place le nom sur deux lignes consécutives :
```
... 17/07/2023 07:55 NOCENT-
Ampoule(s) EJNAINI
```
Le trait d'union est en fin de ligne, le second composant sur la ligne suivante.
## Analyse détaillée
| Élément | Statut | Lignes |
|---|---|---|
| `NOCENT-` | ❌ Non masqué (7 occ.) | 544, 563, 579, 607, 624, 680, 704 |
| `EJNAINI` | ❌ Non masqué (7 occ.) | 545, 564, 581, 609, 626, 682, 706 |
| `NOCENT-EJNAINI` (complet) | Jamais détecté | — |
## Pourquoi F1 ne corrige pas
F1 (décomposition noms à trait d'union) ajoute les composants d'un nom composé
à `safe_names` **seulement si** le nom complet est détecté par le NER.
Ici, **aucune des 3 hypothèses de Claude n'est exacte** :
1. ~~NER ne détecte pas "NOCENT-EJNAINI"~~**VRAI** (mais pas la cause directe)
2. ~~NameCandidate jamais créé~~**VRAI** (mais pas la cause directe)
3. ~~Cross-validation rejette~~**VRAI** mais secondaire
**La vraie cause** : le nom n'est jamais assemblé. Le NER (CamemBERT-bio, EDS-Pseudo)
traite le texte ligne par ligne. `"NOCENT-"` en fin de ligne est un token isolé,
`"EJNAINI"` sur la ligne suivante est un autre token. Le tiret n'est pas interprété
comme un joiner inter-lignes.
## Vérifications effectuées
- `grep -n "NOCENT"` : 7 occ., toutes `"NOCENT-"` en fin de ligne, non masqué
- `grep -n "EJNAINI"` : 7 occ., toutes `"EJNAINI"` seul, non masqué
- Les deux ne sont **ni dans INSEE** (noms étrangers) **ni dans le dictionnaire médical**
## Solution
**Paranames** résoudra ce cas : `"EJNAINI"` sera dans le gazetteer `noms_famille_world.txt.gz`
(issu de Wikidata). Il sera reconnu comme nom de famille étranger et masqué
**indépendamment** de la détection NER.
En attendant, un fix court-terme possible :
- Ajouter une regex `force_regex` pour les patterns `"NOCENT-\nEJNAINI"` ou `"NOCENT-"` + `"EJNAINI"` adjacents
- Ou ajouter "EJNAINI" au dictionnaire des noms à masquer manuellement
## Conclusion
**Hypothèse confirmée de Claude** : paranames est la solution appropriée.
Quand le gazetteer sera intégré, EJNAINI sera masqué.
Les 7 fuites NOCENT- sont un problème similaire — "NOCENT" est un nom basque
non couvert par INSEE. Paranames devrait aussi le couvrir.
---
— Qwen

View File

@@ -0,0 +1,159 @@
---
from: qwen
to: dom
date: 2026-06-04
topic: checklist-pack-beta
status: open
priority: high
references:
- inbox/for-qwen/2026-06-04_14-35_claude_repartition-jobs-beta.md
---
# Checklist pack beta v11 — 2026-06-04
Checklist de validation avant envoi du pack beta sur OwnCloud au beta-testeur Province Beta.
Chaque case doit etre verifiable par une personne qui prepare le pack.
---
## 1. Contenu du pack
- [ ] **EXE principal** : `Pseudonymisation.exe` present (build v11, pas l'installer MSI)
- Verifier : le fichier existe, taille ~200-500 Mo, nom exact `Pseudonymisation.exe`
- *Note* : le build se fait sur Windows (192.168.1.11) via `build_windows_oneclick.bat` ou `anonymisation_onefile.spec`
- [ ] **Fichiers de config** : `config/profiles.yml` et `config/` complet inclus
- Verifier : pas de profil `standard_local_copie_copie` (doublon corrige en C-2)
- [ ] **Modeles ONNX/GLiNER** : dossier `models/` present avec :
- `models/camembert-bio-deid/onnx/model.onnx` (embarque)
- `models/eds-pseudo/` (telecharge au premier lancement si absent)
- `models/gliner/` (telecharge au premier lancement si absent)
- [ ] **Dictionnaires externes** : dossier `data/` complet (INSEE, FINESS, BDPM, blacklist villes, etc.)
- Verifier : `data/` contient bien les fichiers `.txt` / `.json` / `.csv` — pas de repertoires vides
- [ ] **Documentation minimale** : au minimum `FONCTIONNEMENT.md` present
- [ ] **Procedure SmartScreen** : `docs/installation/smartscreen-procedure.md` incluse (ou version simplifiee en PDF)
---
## 2. Fichiers a exclure du pack
- [ ] **Sorties PII** : aucun fichier dans `pdf_natif/`, `pseudonymise/`, `test_anonymise/`, `test_chcb_leak/`, etc.
- Verifier : `find . -path "*/pdf_natif/*" -o -path "*/pseudonymise/*"` retourne rien
- [ ] **Caches AI** : aucun dossier `.claude/`, `.codex-loop/`, `.qwen/` (hors `.qwen/output-language.md` si besoin)
- [ ] **Fichiers de dev/tests** : excludes :
- `tests/`, `test_*/` (tous les dossiers de test)
- `demo_*.py`, `audit_*.py`, `analyze_anonymization_result.py`
- `run_batch_*.py`
- `server.py` (serveur API — pas pour la beta)
- `pdf_mask_designer.py`, `test-mini.js`
- `build_*.bat`, `build_*.ps1` (scripts de build)
- `setup_env_and_build.bat`
- `__pycache__/`, `.pytest_cache/`, `.ruff_cache/`
- [ ] **Logs et artefacts temporaires** : aucun `.log`, `*.lock`, `anonymisation.log` residuel
- [ ] **Fichier `.admin`** : confirme ABSENT du pack (decisions D-13, D-14)
- Verifier : `find . -name ".admin"` retourne rien
---
## 3. Verifications fonctionnelles (post-build)
### 3.1 Mode admin
- [ ] **Mode admin NON actif par defaut** : sans variable d'env `ANON_ADMIN` et sans fichier `.admin`, `is_admin()` retourne `False`
- Test rapide (sur poste Windows) : lancer l'EXE, verifier que le titre de la fenetre NE contient PAS `[MODE ADMIN]`
- Reference : `admin_mode.py``is_admin()` verifie `ANON_ADMIN` env + fichier `.admin`
- [ ] **Banniere "MODE ADMIN" s'affiche SI lance en admin** :
- Test : `$env:ANON_ADMIN="1"; .\Pseudonymisation.exe`
- Verifier : le titre de la fenetre contient `[MODE ADMIN]` (ou signal visuel equivalent)
- Reference : D-13 — titre fenetre montre `[MODE ADMIN]` si actif
### 3.2 VLM / Ollama
- [ ] **VLM/Ollama cache fonctionne en non-admin** :
- Verifier : en mode non-admin, l'option VLM est **masquee** dans l'UI (D-13 : VLM Ollama cache en non-admin)
- Le beta ne peut PAS configurer Ollama sans le mode admin (garde `admin_required()` dans le code GUI)
- Reference : `admin_mode.admin_required()` leve `RuntimeError` si pas admin
- [ ] **VLM Manager ne bloque pas le lancement** :
- `vlm_manager.py` utilise uniquement `urllib` (stdlib) — pas de dependance externe
- Si Ollama n'est pas installe, le pipeline degrade gracieusement (pas de crash au lancement)
### 3.3 Quarantaine
- [ ] **Dossier `quarantine/` cree avec permissions 0o700** :
- Reference : `quarantine.py:95``os.chmod(str(self.quarantine_dir), 0o700)`
- Note : sur Windows, le chmod peut etre ignore (`pass` si FS ne supporte pas) — le dossier est quand meme cree
- Les fichiers `.reason.txt` et `errors.log` sont en 0o600 (quarantine.py:216)
- [ ] **INDEX.md et `.reason.txt` generes correctement** :
- Test : traiter un PDF vide (< 100 caracteres) → dossier `quarantaine/` cree avec `INDEX.md` + `.reason.txt`
- Le `.reason.txt` contient : document, severite, raison, horodatage, version code, suggestion operateur
---
## 4. SmartScreen / SHA-256
- [ ] **SHA-256 du EXE calcule et documente** :
- Commande (PowerShell sur Windows) : `Get-FileHash -Algorithm SHA256 .\Pseudonymisation.exe`
- Ou (Linux, si EXE accessible) : `sha256sum Pseudonymisation.exe`
- Reporter le hash dans le message OwnCloud envoye au beta-testeur
- [ ] **Procedure SmartScreen incluse** :
- Le fichier `smartscreen-procedure.md` (ou equivalent PDF) est joint au pack
- Il couvre : deblocage fichier, premier lancement, SmartScreen bleu, Defender, poste DSI managed, verification hash
---
## 5. Procedure de retour beta-testeur
- [ ] **Fichier de feedback fourni** : un fichier `docs/feedback-beta.md` (ou equivalent) indiquant :
- Ce que le beta-testeur doit tester (cf. smoke test T6 dans `inbox/for-dom/2026-05-29_qwen_smoke-test-T6.md`)
- Le format de retour attendu : dossier `quarantaine/` complet + `errors.log` + profil utilise en cas de probleme
- [ ] **Canal de remontee defini** :
- OwnCloud (meme canal que la livraison, decision D-4)
- Email de Dom en backup : le beta-testeur sait a qui envoyer les retours
- [ ] **Ce que le beta-testeur doit tester** :
- Test normal : anonymiser le PDF de test (section 1 du smoke test T6)
- Test quarantaine : anonymiser un PDF vide ou image (section 4 du smoke test T6)
- Checklist OK/KO du smoke test T6 a remplir et retourner
- En cas de probleme : envoyer `quarantaine/` + `errors.log` + capture d'ecran
---
## 6. Checks RGPD
- [ ] **Aucune PII dans le pack** :
- Verifier : aucun fichier de test contenant des noms, emails, NIR, etc. en clair dans le pack
- Les dossiers `pdf_natif/`, `test_*/` sont exclus (voir section 2)
- [ ] **`quarantine/` cree avec bonnes permissions** :
- Permissions 0o700 sur le dossier (Linux) ou best-effort sur Windows
- Fichiers internes en 0o600
- Reference : `quarantine.py:89-97` et `quarantine.py:207-216`
- [ ] **Pas de chemins absolus locaux qui fuiteraient** :
- Verifier : `grep -r "C:\\\\Users" .` et `grep -r "/home/dom" .` dans les fichiers livres
- Corriger : `anonymisation_onefile.spec` a deja ete corrige (C-2 : chemin absolu supprime)
- Reference : decision D-6 (C-2) — doublon profil et chemin absolu corriges
- [ ] **Metadonnees de sortie presentes** (B-1) :
- Les fichiers de sortie portent : `version_code`, `horodatage`, `profil_applique`
- Verifier : un fichier `.audit.jsonl` contient bien ces champs
---
## 7. Resume rapide avant envoi OwnCloud
| Verification | Statut |
|---|---|
| EXE present et fonctionnel | / |
| Config et modeles inclus | / |
| Dictionnaires `data/` complets | / |
| Fichiers de dev/tests exclus | / |
| Aucune PII residuelle | / |
| `.admin` absent | / |
| Mode admin inactif par defaut | / |
| VLM masque en non-admin | / |
| Quarantaine fonctionnelle (permissions) | / |
| SHA-256 calcule et documente | / |
| Procedure SmartScreen incluse | / |
| Feedback beta-testeur pret | / |
| **PACK PRET A ENVOYER** | **OUI / NON** |
---
*Checklist generee le 04/06/2026 pour la beta v11.0 — Pseudonymisation de documents medicaux*
*References : smoke test T6, decisions D-1 a D-14, vision fonctionnelle, procedure SmartScreen*

View File

@@ -0,0 +1,63 @@
---
from: qwen
to: dom
date: 2026-06-04
topic: revue-build-beta
status: open
priority: high
references:
- inbox/for-qwen/2026-06-04_14-35_claude_repartition-jobs-beta.md
---
# Revue build beta — 2026-06-04
## Resume
**2 risques bloquants, 4 recommandations**
## Risques bloquants
| # | Fichier | Ligne | Risque | Impact |
|---|---|---|---|---|
| B-1 | `scripts/build_windows_oneclick.ps1` | ~189-203 | `RequiredSourceFiles` liste 11 fichiers **source .py** comme prerequis au build (`launcher.py`, `Pseudonymisation_Gui_V5.py`, `admin_rules.py`, etc.). Ces fichiers sont necessaires au *runtime* de l'app, mais le build PyInstaller les embarque via le `.spec` (Analysis sur `launcher.py`). Cette verification est redondante mais **non bloquante en soi** — en revanche, si l'un de ces fichiers est deplace ou renomme, le build echouera avec une erreur explicite. | Build casse si un module source est deplace |
| B-2 | `scripts/build_windows_oneclick.ps1` | ligne 201 | `Require-Path` sur `models\camembert-bio-deid\onnx\model.onnx` — le modele ONNX doit etre present **avant** le build. Le `.gitignore` exclut `*.onnx` et `models/`. Donc sur une machine de build propre (clone frais), le build **echouera systematiquement** sans etape prealable de telechargement/depot du modele. C'est documente dans `docs/build-windows-oneclick.md` ("modele ONNX embarque requis doit exister localement"), mais aucun script ne le telecharge automatiquement. | **Bloquant** : build impossible sur clone frais sans action manuelle |
## Recommandations
| # | Fichier | Sujet | Proposition |
|---|---|---|---|
| R-1 | `scripts/build_windows_oneclick.ps1` | Timestamp server HTTP | Ligne 10 : `http://timestamp.digicert.com` utilise HTTP (non RFC 3161). Le serveur DigiCert supporte `http://timestamp.digicert.com` mais Microsoft recommande RFC 3161 (`http://timestamp.digicert.com` fonctionne en pratique). Pas bloquant, mais mentionner que le serveur RFC 3161 est `http://timestamp.digicert.com` (meme URL, protocole different dans SignTool). |
| R-2 | `build_windows_installer_oneclick.bat` | Nom du script PS appelle | Ligne 7 : pointe vers `scripts\build_windows_oneclick.ps1` (le script complet de build EXE+ZIP+installer). Le fichier s'appelle `build_windows_installer_oneclick.bat` mais relance le build **complet**, pas uniquement l'installateur. Si l'EXE existe deja, c'est inefficace (rebuild complet). Le script dedie `scripts/build_windows_installer_only.ps1` existe mais n'est appele par aucun `.bat`. Ajouter un `build_windows_rebuild_installer.bat` qui appelle `build_windows_installer_only.ps1`. |
| R-3 | `installer/Anonymisation.iss` | `AppPublisher` | Ligne 2 : `#define MyAppPublisher "CHUXX"` — c'est un placeholder, mais le fichier est versionne. Pour une beta, c'est acceptable, mais avant diffusion externe, remplacer par le nom reel de l'editeur. |
| R-4 | `scripts/install_inno_setup_build_dep.ps1` | Telechargement HTTP non verifie | Ligne 4 : telecharge `innosetup` depuis `https://jrsoftware.org/download.php/is.exe` sans verification de hash apres telechargement. Sur une machine de build, un download corrompu ou intercepte installerait un binaire inattendu. Ajouter une verification de hash SHA256 ou utiliser `Invoke-WebRequest` avec `-UseBasicParsing` + verification. |
## Verifications conformite
- [x] D-11 : EXE auto-suffisant sans installer — **CONFORME**. Le `.spec` embarque tous les modules via `hiddenimports` et `datas`. Le ZIP `Anonymisation-Windows.zip` contient l'EXE seul + README.txt. Aucun installateur requis pour l'utilisateur final. L'installateur Inno Setup est optionnel (`-SkipInstaller` disponible, et un avertissement est affiche si Inno Setup est absent).
- [x] D-13 : Mode admin NON active par defaut — **CONFORME**. Aucun des scripts de build ne definit `ANON_ADMIN`, ne cree de fichier `.admin`, ni n'embarque de fichier `.admin` dans le package. Le `launcher.py` ne definit pas cette variable d'environnement. Le `.gitignore` n'exclut pas `.admin` explicitement, mais le fichier n'est cree que manuellement. Le README.txt genere ne mentionne pas le mode admin.
- [x] D-14 : Pas de reference a `app.aivanov.fr` qui fuiterait — **CONFORME**. `grep` confirme : `app.aivanov.fr` n'apparait dans **aucun** fichier `.ps1`, `.bat`, `.iss`, `.py`, ou `.spec`. La reference `app.aivanov.fr` est uniquement dans `docs/coordination/decisions/2026-06-02_dom_d14-plateforme-licence-architecture.md` (fichier de decision, non embarque dans l'EXE). Le `launcher.py` contient `self.root.title("aivanonym")` et `APP_DIR` — pas de fuite de domaine.
## Autres observations
### Chemins absolus / secrets
- **Aucun chemin absolu** (`C:\Users\dom\...` ou `/home/dom/...`) trouve dans les 6 fichiers de build lus. Le probleme Q-2 signale dans l'audit (`chemin absolu C:\Users\dom\...` dans `anonymisation_onefile.spec`) a ete corrige — le `.spec` utilise `SPECPATH`/`project_dir` de maniere relative.
- **Aucun secret en dur** : `build_signing.example.ps1` contient des placeholders (`REMPLACER_PAR_L_EMPREINTE_DU_CERTIFICAT`). Le fichier `build_signing.local.ps1` est correctement exclu par `.gitignore`.
- Le parametre `-PfxPassword` passe le mot de passe en ligne de commande a `signtool.exe` (`/p`), ce qui peut apparaître dans les logs processus Windows. C'est une limitation inherente a SignTool — acceptable si le build est lance sur une machine dediee.
### Artifacts PII dans le repo
- Les fichiers de build **ne generent pas** de PII. Le `README.txt` genere contient uniquement date/branch/commit/signature.
- Le hash SHA256 est ecrit dans `release/Anonymisation.exe.sha256.txt` — pas de PII.
### Dependances non documentees
- `build_windows_oneclick.ps1` necessite : Python (3.11 ou 3.x), `requirements.txt`, `pyinstaller`, et optionnellement Inno Setup 6. Tout est documente dans `docs/build-windows-oneclick.md`.
- Le script `install_inno_setup_build_dep.ps1` installe Inno Setup en mode utilisateur (`/CURRENTUSER`) — pas besoin de droits admin, ce qui est coherent avec le principe de moindre privilege.
### Cohérence BAT <-> PS1
- `build_windows_installer_oneclick.bat` appelle `build_windows_oneclick.ps1` (le build complet), pas `build_windows_installer_only.ps1`. Le nom du `.bat` suggere qu'il ne fait que l'installateur, mais il rebuild tout. Ce n'est pas bloquant, mais preteur a confusion.
## Conclusion
**Go avec reserves** — Le build est structurellement sain. Aucun chemin absolu, aucun secret, aucune reference `app.aivanov.fr` dans les artefacts de build.
**Reserve principale** : le modele ONNX (`models/camembert-bio-deid/onnx/model.onnx`) est un prerequis non automatise. Le build echouera sur un clone frais sans etape manuelle prealable. Pour une beta a un seul utilisateur qui connait la procedure, c'est acceptable. Pour une reproductibilite future, envisager un script `scripts/download_models.ps1` ou une verification avec message d'erreur explicite ("Modele ONNX manquant — telecharger depuis HuggingFace ou restaurer depuis backup").

View File

@@ -0,0 +1,127 @@
---
from: qwen
to: dom
date: 2026-06-04
topic: triage-working-tree
status: open
priority: normal
---
# Triage du working tree — 2026-06-04
## (a) À committer
| Fichier | Justification |
|---|---|
| `gui_batch_paths.py` | Module utilitaire pour lister les documents et construire les chemins de sortie batch. Testé unitairement. |
| `manual_masking.py` | Module de masquage manuel (templates YAML, résolution de PDF). Testé unitairement. |
| `tests/unit/test_gui_batch_paths.py` | Tests unitaires pour `gui_batch_paths.py` — couvre exclusion GUI output, extensions supportées, chemins imbriqués. |
| `tests/unit/test_manual_masking.py` | Tests unitaires pour `manual_masking.py` — templates YAML, labels, résolution. |
| `tests/unit/test_real_world_identifier_layouts.py` | Tests de non-régression sur layouts réels (bactério multiline, etc.). Important pour D-15 (leak audit). |
| `config/mask_templates/FC19_template.yml` | Template de masquage YAML utilisé par `manual_masking.py`. Fait partie du système de configuration. |
| `scripts/build_windows_oneclick.ps1` | Script PowerShell de build one-click — cœur du build system Windows. |
| `scripts/build_windows_installer_only.ps1` | Script de build de l'installer uniquement. |
| `scripts/install_inno_setup_build_dep.ps1` | Script d'installation des dépendances Inno Setup. |
| `build_windows_oneclick.bat` | Batch wrapper pour le build one-click. |
| `build_windows_installer_oneclick.bat` | Batch wrapper pour l'installer. |
| `build_signing.example.ps1` | Exemple de script de signing — documente le protocole (pas de secrets, c'est un `.example`). |
| `docs/build-windows-oneclick.md` | Documentation du build system — utile pour quiconque doit rebuild. |
| `docs/coordination/README.md` | README du protocole de coordination — fait partie du workflow de travail. |
| `docs/coordination/inbox/for-claude/2026-06-02_15-45_qwen_ack-t-g-h-i-livrees.md` | Ack Qwen — protocole de coordination. |
| `docs/coordination/inbox/for-dom/2026-06-02_qwen_ejnaini-investigation.md` | Rapport d'investigation EJNAINI (D-15) — travail pertinent. |
| `docs/coordination/inbox/for-qwen/*.md` (7 fichiers) | Instructions Claude→Qwen — protocole de coordination. |
| `docs/installation/smartscreen-procedure.md` | Documentation d'installation Smartscreen — utile pour le build/déploiement. |
| `docs/reflexions/2026-05-28_vision_fonctionnelle_avant_prod.md` | Document de vision produit — contexte stratégique utile. |
| `docs/coordination/log.md` | Log de coordination — protocole de travail. |
## (b) À gitignorer
| Fichier/Répertoire | Justification |
|---|---|
| `pdf_natif/pseudonymise/` (tout le contenu) | **Sorties pseudonymisées avec PII** — contient `.pseudonymise.txt`, `.audit.jsonl`, `.redacted_*.pdf`. Règle RGPD stricte (D-12). |
| `pdf_natif/pseudonymise/anonymise/` | Sous-répertoire de `pseudonymise/` — mêmes sorties PII (résultats de re-pseudonymisation en cascade). |
| `pdf_natif/pseudonymise_v11/` | Sorties v11 — mêmes PII que ci-dessus. Redondant avec `pseudonymise/`. |
| `.claude/` | Cache/runtime Claude (lock file, scheduled tasks). Pas de valeur source. |
| `.codex-loop/` | Artefacts de session Codex (diffs, plans, reviews temporaires). Contexte éphémère. |
| `.qwen/settings.json.orig` | Backup auto-généré de settings Qwen — pas de valeur source. |
| `.qwen/scripts/` | Scripts internes Qwen — pas de valeur pour le projet. |
| `.qwen/skills/` | Skills Qwen — pas de valeur pour le projet. |
| `scripts/__pycache__/` | Cache Python compilé — déjà couvert par `__pycache__/` dans `.gitignore` mais le répertoire `scripts/` n'est pas à la racine. |
## (c) À supprimer
| Fichier/Répertoire | Justification |
|---|---|
| `pdf_natif/pseudonymise/FC*.pseudonymise.txt` | **Contiennent des PII réelles** (noms patients, IPP, etc.). Aucun intérêt versionné, risque RGPD. |
| `pdf_natif/pseudonymise/FC*.audit.jsonl` | **Contiennent des PII réelles** dans les audits. Supprimer. |
| `pdf_natif/pseudonymise/FC*.redacted_*.pdf` | PDFs avec données médicales réelles. Supprimer. |
| `pdf_natif/pseudonymise/anonymise/*` | Doubles de pseudonymisation (re-traitement). Redondant + PII. |
| `pdf_natif/pseudonymise_v11/*` | Anciennes sorties v11. Redondant avec `pseudonymise/` + PII. |
| `docs/rapport-analyse-campagne-gui-2026-04-21.md` | Rapport d'analyse GUI ancien (avril 2026) — probablement obsolète après les pivots D-11 à D-15. À vérifier avant suppression. |
## Recommandations .gitignore
Ajouter ces lignes à `.gitignore` :
```gitignore
# === RGPD : sorties de pseudonymisation (PII) ===
pdf_natif/
# === Coordination AI — caches et artefacts éphémères ===
.claude/
.codex-loop/
.qwen/scripts/
.qwen/skills/
.qwen/settings.json.orig
# === Coordination AI — inbox (optionnel : ne garder que les décisions) ===
# Décommenter si on ne veut pas versionner les acks/instructions courantes :
# docs/coordination/inbox/
```
## Notes
- Les fichiers `D` (deleted) dans `git status` (`ano/pdf_natif/pseudonymise/FC*.audit.jsonl` et `FC*.pseudonymise.txt`) sont des fichiers **déjà supprimés** mais pas encore commités. Ils devraient être inclus dans le prochain commit pour nettoyer l'historique.
- `docs/coordination/inbox/` et `docs/coordination/archive/` sont le cœur du protocole de travail — **à committer**. Ils documentent les échanges Claude/Qwen/Dom.
- `docs/rapport-analyse-campagne-gui-2026-04-21.md` est à **vérifier** avant suppression — peut contenir des métriques utiles.
## Commandes suggérées
```bash
# 1. Committre tout le code utile, les tests et la coordination
git add gui_batch_paths.py manual_masking.py
git add tests/unit/test_gui_batch_paths.py tests/unit/test_manual_masking.py tests/unit/test_real_world_identifier_layouts.py
git add config/mask_templates/
git add scripts/build_windows_oneclick.ps1 scripts/build_windows_installer_only.ps1 scripts/install_inno_setup_build_dep.ps1
git add build_windows_oneclick.bat build_windows_installer_oneclick.bat build_signing.example.ps1
git add docs/build-windows-oneclick.md docs/installation/ docs/reflexions/
git add docs/coordination/README.md docs/coordination/log.md
git add docs/coordination/inbox/ docs/coordination/archive/
# 2. Committer les fichiers déjà supprimés (nettoyer les D dans git status)
git add -u ano/pdf_natif/pseudonymise/
# 3. Mettre à jour .gitignore
cat >> .gitignore << 'EOF'
# === RGPD : sorties de pseudonymisation (PII) ===
pdf_natif/
# === Coordination AI — caches et artefacts éphémères ===
.claude/
.codex-loop/
.qwen/scripts/
.qwen/skills/
.qwen/settings.json.orig
EOF
# 4. Gitignore le nouveau pattern
git add .gitignore
# 5. Supprimer physiquement les sorties PII (après commit si besoin de backup local)
rm -rf pdf_natif/pseudonymise/
rm -rf pdf_natif/pseudonymise_v11/
# 6. Vérifier le résultat
git status --short
```