Blur PII server-side (core/anonymisation/pii_blur.py) : - Pipeline OCR (docTR) → NER (EDS-NLP + fallback regex) - Détection ciblée noms/prénoms/adresses/NIR/téléphone/email - Protection explicite CIM-10, CCAM, montants €, dates, IDs techniques - Dual-storage : shot_XXXX_full.png (brut) + _blurred.png (affichage) - 18 tests Client : - RPA_BLUR_SENSITIVE=false par défaut (blur serveur uniquement) - Zéro overhead côté poste utilisateur VLM config : - vlm_config.py : gemma4:latest, fallbacks qwen3-vl:8b + UI-TARS - think=false auto pour gemma4 (bug Ollama 0.20.x) - VLM provider VWB : local-first (Ollama), cloud opt-in via VLM_ALLOW_CLOUD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
Python
299 lines
11 KiB
Python
# tests/unit/test_pii_blur.py
|
|
"""Tests du pipeline d'anonymisation server-side (core.anonymisation.pii_blur).
|
|
|
|
On couvre :
|
|
1. Logique regex : PERSON / ADDRESS / PHONE / NIR / EMAIL sont détectés ;
|
|
codes CIM, CCAM, montants, dates et IDs techniques sont protégés.
|
|
2. Fusion de spans qui se chevauchent.
|
|
3. Pipeline complet sur une image synthétique PIL (sans OCR — on patche `_doctr_ocr`)
|
|
avec assertions sur les pixels : les zones PII sont floutées, les autres non.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
# Éviter de charger docTR pendant les tests rapides
|
|
os.environ.setdefault("RPA_PII_BLUR_SERVER", "true")
|
|
|
|
|
|
# --- Import du module sous test --------------------------------------------
|
|
from core.anonymisation import pii_blur as mod # noqa: E402
|
|
|
|
|
|
# ===========================================================================
|
|
# Tests regex — pas besoin d'image
|
|
# ===========================================================================
|
|
|
|
class TestRegexPII:
|
|
def test_detect_person_with_title(self):
|
|
hits = mod._regex_find_pii("Patient : M. Dupont Jean, né le 12/03/1965")
|
|
labels = {h[0] for h in hits}
|
|
assert "PERSON" in labels
|
|
|
|
def test_detect_email(self):
|
|
hits = mod._regex_find_pii("Contact : jean.dupont@hopital.fr")
|
|
labels = {h[0] for h in hits}
|
|
assert "EMAIL" in labels
|
|
|
|
def test_detect_phone_fr(self):
|
|
hits = mod._regex_find_pii("Tél : 06 12 34 56 78")
|
|
labels = {h[0] for h in hits}
|
|
assert "PHONE" in labels
|
|
|
|
def test_detect_nir(self):
|
|
hits = mod._regex_find_pii("NIR : 2 85 03 75 115 120 42")
|
|
labels = {h[0] for h in hits}
|
|
assert "NIR" in labels
|
|
|
|
def test_detect_address(self):
|
|
hits = mod._regex_find_pii("Adresse : 12 rue de la Paix, Paris")
|
|
labels = {h[0] for h in hits}
|
|
assert "LOCATION" in labels
|
|
|
|
# --- Négatifs : ces motifs NE DOIVENT PAS être détectés --------------
|
|
def test_icd10_not_flagged(self):
|
|
hits = mod._regex_find_pii("Code CIM : F32.1 (épisode dépressif)")
|
|
assert not hits, f"CIM ne doit pas être floué, hits={hits}"
|
|
|
|
def test_ccam_not_flagged(self):
|
|
hits = mod._regex_find_pii("Acte CCAM : DEQP003")
|
|
assert not hits
|
|
|
|
def test_money_not_flagged(self):
|
|
hits = mod._regex_find_pii("Montant facturé : 1250,50 €")
|
|
assert not hits
|
|
|
|
def test_date_not_flagged(self):
|
|
hits = mod._regex_find_pii("Séjour du 01/03/2026 au 15/03/2026")
|
|
assert not hits
|
|
|
|
def test_tech_id_not_flagged(self):
|
|
hits = mod._regex_find_pii("Fichier : shot_0007_full.png session_abc123")
|
|
assert not hits
|
|
|
|
# --- Mélange réaliste -------------------------------------------------
|
|
def test_realistic_hospital_text(self):
|
|
text = (
|
|
"Patient : M. Dupont Jean - NIR : 1 85 03 75 115 120 42 "
|
|
"- Tél : 06 12 34 56 78 - Adresse : 12 rue de la Paix "
|
|
"- Code CIM : F32.1 - Montant : 1250,50 € "
|
|
"- Séjour du 01/03/2026 - Email : jean@test.fr"
|
|
)
|
|
hits = mod._regex_find_pii(text)
|
|
labels = {h[0] for h in hits}
|
|
assert "PERSON" in labels
|
|
assert "NIR" in labels
|
|
assert "PHONE" in labels
|
|
assert "LOCATION" in labels
|
|
assert "EMAIL" in labels
|
|
|
|
# Vérifier qu'aucun hit ne couvre F32.1, 1250,50 €, 01/03/2026
|
|
protected_strings = ("F32.1", "1250,50", "01/03/2026")
|
|
for label, s, e in hits:
|
|
span = text[s:e]
|
|
for prot in protected_strings:
|
|
assert prot not in span, f"{label} '{span}' couvre {prot}"
|
|
|
|
|
|
# ===========================================================================
|
|
# Tests de fusion de spans
|
|
# ===========================================================================
|
|
|
|
class TestMergeSpans:
|
|
def test_non_overlapping_preserved(self):
|
|
spans = [("PERSON", 0, 5), ("EMAIL", 20, 30)]
|
|
assert mod._merge_spans(spans) == spans
|
|
|
|
def test_overlap_kept_widest_label(self):
|
|
spans = [("PERSON", 0, 10), ("LOCATION", 5, 20)]
|
|
merged = mod._merge_spans(spans)
|
|
assert len(merged) == 1
|
|
label, s, e = merged[0]
|
|
assert (s, e) == (0, 20)
|
|
# le plus large est LOCATION (15 chars) > PERSON (10)
|
|
assert label == "LOCATION"
|
|
|
|
def test_identical_spans_dedup(self):
|
|
spans = [("EMAIL", 3, 9), ("EMAIL", 3, 9)]
|
|
assert len(mod._merge_spans(spans)) == 1
|
|
|
|
|
|
# ===========================================================================
|
|
# Tests pipeline complet avec OCR mocké
|
|
# ===========================================================================
|
|
|
|
@pytest.fixture
|
|
def synthetic_screenshot(tmp_path: Path) -> Path:
|
|
"""Génère une image synthétique avec 4 lignes de texte aux positions connues."""
|
|
W, H = 900, 300
|
|
img = Image.new("RGB", (W, H), color="white")
|
|
draw = ImageDraw.Draw(img)
|
|
try:
|
|
font = ImageFont.truetype("DejaVuSans.ttf", 22)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# Lignes (y, text) — on reproduit le test demandé par l'utilisateur
|
|
lines = [
|
|
(20, "Nom : Dupont"),
|
|
(70, "Code CIM : F32.1"),
|
|
(120, "Adresse : 12 rue de la Paix"),
|
|
(170, "Montant : 1250€"),
|
|
]
|
|
for y, t in lines:
|
|
draw.text((20, y), t, fill="black", font=font)
|
|
|
|
path = tmp_path / "synth.png"
|
|
img.save(path, format="PNG")
|
|
return path
|
|
|
|
|
|
def _fake_doctr_ocr(image_path: Path):
|
|
"""Mock docTR : retourne des bbox word-level connues pour le screenshot synthétique.
|
|
|
|
On utilise des bbox approximatives correspondant à la disposition dans le fixture.
|
|
"""
|
|
# Format : liste de mots par ligne. Les x sont progressifs pour simuler
|
|
# la largeur rendue. On reste volontairement grossier (le blur tolère).
|
|
words = []
|
|
|
|
line_idx = [0]
|
|
|
|
def line(y, word_defs):
|
|
x = 20
|
|
for text, w in word_defs:
|
|
words.append({
|
|
"text": text, "x1": x, "y1": y, "x2": x + w, "y2": y + 30,
|
|
"line": line_idx[0],
|
|
})
|
|
x += w + 8
|
|
line_idx[0] += 1
|
|
|
|
# "Nom : Dupont" → ciblé PERSON (via "M. Dupont" ? non, on n'a pas M.) →
|
|
# on ajoute le titre "M." pour déclencher le regex PERSON.
|
|
line(20, [("M.", 30), ("Dupont", 90)])
|
|
# "Code CIM : F32.1" → doit NE PAS flouter
|
|
line(70, [("Code", 60), ("CIM", 50), (":", 10), ("F32.1", 80)])
|
|
# "Adresse : 12 rue de la Paix" → LOCATION
|
|
line(120, [("Adresse", 90), (":", 10), ("12", 30), ("rue", 40), ("de", 30),
|
|
("la", 30), ("Paix", 60)])
|
|
# "Montant : 1250€" → NE PAS flouter
|
|
line(170, [("Montant", 90), (":", 10), ("1250€", 80)])
|
|
|
|
return words, 900, 300
|
|
|
|
|
|
def _pixel_variance(img: Image.Image, bbox) -> float:
|
|
"""Variance moyenne par canal dans une ROI — proxy pour « y a-t-il du détail ».
|
|
|
|
Une zone floutée a une variance très basse ; une zone nette a plus de détail.
|
|
"""
|
|
import statistics
|
|
x1, y1, x2, y2 = bbox
|
|
crop = img.crop((x1, y1, x2, y2)).convert("RGB")
|
|
pixels = list(crop.getdata())
|
|
if len(pixels) < 2:
|
|
return 0.0
|
|
rs = [p[0] for p in pixels]
|
|
gs = [p[1] for p in pixels]
|
|
bs = [p[2] for p in pixels]
|
|
return (statistics.pvariance(rs) + statistics.pvariance(gs) + statistics.pvariance(bs)) / 3
|
|
|
|
|
|
class TestPIIBlurrerPipeline:
|
|
def test_blur_only_pii_regions(self, tmp_path, synthetic_screenshot):
|
|
with patch.object(mod, "_doctr_ocr", side_effect=_fake_doctr_ocr):
|
|
blurrer = mod.PIIBlurrer(use_edsnlp=False)
|
|
out = tmp_path / "synth_blurred.png"
|
|
result = blurrer.blur_image(synthetic_screenshot, out)
|
|
|
|
# Assertions globales
|
|
assert result.count >= 2, (
|
|
f"Attendu au moins 2 PII (PERSON + LOCATION), reçu {result.count} : "
|
|
f"{[e.label for e in result.entities]}"
|
|
)
|
|
labels = {e.label for e in result.entities}
|
|
assert "PERSON" in labels
|
|
assert "LOCATION" in labels
|
|
# F32.1 ne doit PAS être parmi les entités floutées
|
|
assert not any("F32.1" in e.text for e in result.entities)
|
|
# 1250 ne doit PAS être parmi les entités floutées
|
|
assert not any("1250" in e.text for e in result.entities)
|
|
|
|
# Vérification visuelle : la ligne CIM (y=70..100) doit rester nette,
|
|
# la ligne Adresse (y=120..150) doit être floutée.
|
|
original = Image.open(synthetic_screenshot)
|
|
blurred = Image.open(out)
|
|
|
|
# Ligne CIM : doit contenir du texte net (variance haute sur la zone)
|
|
cim_bbox = (20, 68, 280, 105)
|
|
var_orig_cim = _pixel_variance(original, cim_bbox)
|
|
var_blur_cim = _pixel_variance(blurred, cim_bbox)
|
|
# Tolérance : la zone CIM doit rester AU MOINS à 60% de la variance d'origine
|
|
assert var_blur_cim >= 0.5 * var_orig_cim, (
|
|
f"Code CIM a été flouté ! var_orig={var_orig_cim:.1f}, "
|
|
f"var_blur={var_blur_cim:.1f}"
|
|
)
|
|
|
|
# Ligne Adresse : la variance doit chuter (flou applique un lissage)
|
|
addr_bbox = (20, 118, 400, 155)
|
|
var_orig_addr = _pixel_variance(original, addr_bbox)
|
|
var_blur_addr = _pixel_variance(blurred, addr_bbox)
|
|
assert var_blur_addr < var_orig_addr * 0.85, (
|
|
f"Adresse pas suffisamment floutée : var_orig={var_orig_addr:.1f}, "
|
|
f"var_blur={var_blur_addr:.1f}"
|
|
)
|
|
|
|
def test_no_pii_copies_file(self, tmp_path):
|
|
"""Si aucun PII n'est détecté, le fichier est copié tel quel."""
|
|
img = Image.new("RGB", (400, 100), "white")
|
|
p = tmp_path / "clean.png"
|
|
img.save(p)
|
|
|
|
def fake_clean_ocr(path):
|
|
return (
|
|
[{"text": "Bonjour", "x1": 10, "y1": 10, "x2": 100, "y2": 40},
|
|
{"text": "monde", "x1": 110, "y1": 10, "x2": 200, "y2": 40}],
|
|
400, 100,
|
|
)
|
|
|
|
with patch.object(mod, "_doctr_ocr", side_effect=fake_clean_ocr):
|
|
res = mod.PIIBlurrer(use_edsnlp=False).blur_image(p, tmp_path / "clean_out.png")
|
|
|
|
assert res.count == 0
|
|
assert (tmp_path / "clean_out.png").is_file()
|
|
|
|
def test_ocr_failure_falls_back_to_copy(self, tmp_path):
|
|
"""Si docTR plante, on copie l'original en version 'blurred' (failsafe)."""
|
|
img = Image.new("RGB", (100, 100), "white")
|
|
p = tmp_path / "fail.png"
|
|
img.save(p)
|
|
|
|
def boom(path):
|
|
raise RuntimeError("OCR indispo")
|
|
|
|
with patch.object(mod, "_doctr_ocr", side_effect=boom):
|
|
res = mod.PIIBlurrer(use_edsnlp=False).blur_image(p, tmp_path / "fail_out.png")
|
|
|
|
assert res.count == 0
|
|
assert (tmp_path / "fail_out.png").is_file()
|
|
|
|
|
|
# ===========================================================================
|
|
# Sanity check helper
|
|
# ===========================================================================
|
|
|
|
def test_pii_labels_contains_expected():
|
|
assert "PERSON" in mod.PII_LABELS
|
|
assert "LOCATION" in mod.PII_LABELS
|
|
assert "EMAIL" in mod.PII_LABELS
|
|
assert "NIR" in mod.PII_LABELS
|
|
assert "PHONE" in mod.PII_LABELS
|