feat(core): gates texte par catégorie sur toutes les passes (P1-2/F-2/F-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
tests/unit/test_core_category_gating_behavior.py
Normal file
260
tests/unit/test_core_category_gating_behavior.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Plan 1b — Task 3 (P1-2 / F-2 + F-5) : gating TEXTE par catégorie.
|
||||
|
||||
Vérifie que, quand une des 7 catégories toggleables est décochée
|
||||
(``cfg["disabled_kinds"]``), la valeur de cette catégorie ressort EN CLAIR
|
||||
dans le texte produit, SANS jamais démasquer une autre catégorie encore
|
||||
activée (pas de fuite croisée) et SANS régression quand rien n'est désactivé.
|
||||
|
||||
Entrées RÉELLES fabriquées à partir des vraies regex du moteur (aucun mock).
|
||||
NIR valide (clé modulo 97) calculé : body 1850578006084 → clé 91.
|
||||
"""
|
||||
import re
|
||||
|
||||
import anonymizer_core_refactored_onnx as core
|
||||
|
||||
|
||||
# --- Échantillons clairs par catégorie (1 PII de la catégorie cible) ---------
|
||||
# Chaque échantillon est validé : masqué quand la catégorie est activée.
|
||||
_SAMPLES = {
|
||||
"NOM": ("Nom de famille : DUPONT", "DUPONT", "[NOM]"),
|
||||
"DATE_NAISSANCE": ("Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
|
||||
"ETAB": ("Etablissement : EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
|
||||
"ADRESSE": ("Domicile : 13 rue des Lilas", "rue des Lilas", "[ADRESSE]"),
|
||||
"NIR": ("NIR 185057800608491", "185057800608491", "[NIR]"),
|
||||
"TEL": ("Tel : 0612345678", "0612345678", "[TEL]"),
|
||||
"ADHERENT": ("N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
|
||||
}
|
||||
|
||||
# Une catégorie « témoin » différente, toujours activée, dont le placeholder doit
|
||||
# rester présent (anti-fuite croisée). On choisit NIR comme témoin sauf pour la
|
||||
# catégorie cible NIR (témoin = TEL).
|
||||
_WITNESS = {
|
||||
"NOM": ("NIR 185057800608491", "[NIR]"),
|
||||
"DATE_NAISSANCE": ("NIR 185057800608491", "[NIR]"),
|
||||
"ETAB": ("NIR 185057800608491", "[NIR]"),
|
||||
"ADRESSE": ("NIR 185057800608491", "[NIR]"),
|
||||
"NIR": ("Tel : 0612345678", "[TEL]"),
|
||||
"TEL": ("NIR 185057800608491", "[NIR]"),
|
||||
"ADHERENT": ("NIR 185057800608491", "[NIR]"),
|
||||
}
|
||||
|
||||
_SEVEN = ["NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"]
|
||||
|
||||
|
||||
def _run(text, disabled):
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = set(disabled)
|
||||
return core.anonymise_document_regex([text], [], cfg)
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cat", _SEVEN)
|
||||
def test_disabled_category_left_in_clear_witness_masked(cat):
|
||||
"""La catégorie décochée ressort en clair ; le témoin reste masqué."""
|
||||
target_line, clear_value, target_ph = _SAMPLES[cat]
|
||||
witness_line, witness_ph = _WITNESS[cat]
|
||||
text = target_line + "\n" + witness_line
|
||||
|
||||
res = _run(text, {cat})
|
||||
out = res.text_out
|
||||
|
||||
# 1) la valeur de la catégorie décochée doit être EN CLAIR
|
||||
assert clear_value in out, (
|
||||
f"{cat} décochée : '{clear_value}' devrait être en clair.\nout={out!r}")
|
||||
# 2) son placeholder ne doit PAS apparaître
|
||||
assert target_ph not in out, (
|
||||
f"{cat} décochée : '{target_ph}' ne devrait pas apparaître.\nout={out!r}")
|
||||
# 3) le témoin (autre catégorie activée) doit RESTER masqué
|
||||
assert witness_ph in out, (
|
||||
f"{cat} décochée : témoin {witness_ph} devrait rester masqué.\nout={out!r}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cat", _SEVEN)
|
||||
def test_enabled_category_still_masked(cat):
|
||||
"""Avec rien de désactivé, chaque catégorie reste masquée (non-régression)."""
|
||||
target_line, clear_value, target_ph = _SAMPLES[cat]
|
||||
res = _run(target_line, set())
|
||||
assert target_ph in res.text_out, (
|
||||
f"{cat} activée devrait être masquée.\nout={res.text_out!r}")
|
||||
|
||||
|
||||
def test_one_disabled_all_others_stay_masked():
|
||||
"""1 catégorie décochée : TOUTES les autres restent masquées (anti-fuite)."""
|
||||
text = "\n".join(s[0] for s in _SAMPLES.values())
|
||||
for off in _SEVEN:
|
||||
res = _run(text, {off})
|
||||
out = res.text_out
|
||||
# la catégorie décochée doit être en clair
|
||||
clear = _SAMPLES[off][1]
|
||||
assert clear in out, f"{off} décochée devrait être en clair.\nout={out!r}"
|
||||
# toutes les AUTRES doivent rester masquées
|
||||
for other in _SEVEN:
|
||||
if other == off:
|
||||
continue
|
||||
ph = _SAMPLES[other][2]
|
||||
assert ph in out, (
|
||||
f"{off} décochée NE doit PAS démasquer {other} ({ph}).\nout={out!r}")
|
||||
|
||||
|
||||
def test_baseline_all_enabled_byte_for_byte():
|
||||
"""disabled vide ⇒ sortie identique à un run sans la clé disabled_kinds."""
|
||||
text = "\n".join(s[0] for s in _SAMPLES.values())
|
||||
cfg_a = core.load_dictionaries(None)
|
||||
cfg_a["disabled_kinds"] = set()
|
||||
cfg_b = core.load_dictionaries(None) # pas de clé du tout
|
||||
out_a = core.anonymise_document_regex([text], [], cfg_a).text_out
|
||||
out_b = core.anonymise_document_regex([text], [], cfg_b).text_out
|
||||
assert out_a == out_b
|
||||
# et tout est bien masqué
|
||||
for _line, _clear, ph in _SAMPLES.values():
|
||||
assert ph in out_a
|
||||
|
||||
|
||||
# --- selective_rescan : filet de sécurité, doit aussi gater ------------------
|
||||
@pytest.mark.parametrize("cat,line,clear,ph", [
|
||||
("TEL", "Joindre au 0612345678", "0612345678", "[TEL]"),
|
||||
("NIR", "Secu 185057800608491", "185057800608491", "[NIR]"),
|
||||
("ADRESSE", "13 rue des Lilas ici", "rue des Lilas", "[ADRESSE]"),
|
||||
("DATE_NAISSANCE", "Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
|
||||
("ETAB", "Etablissement EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
|
||||
("ADHERENT", "N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
|
||||
])
|
||||
def test_selective_rescan_gates_disabled(cat, line, clear, ph):
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = {cat}
|
||||
out = core.selective_rescan(line, cfg=cfg)
|
||||
assert clear in out, f"rescan {cat} décochée : '{clear}' devrait rester clair.\nout={out!r}"
|
||||
assert ph not in out, f"rescan {cat} décochée : {ph} ne devrait pas apparaître.\nout={out!r}"
|
||||
|
||||
|
||||
def test_selective_rescan_empty_disabled_byte_for_byte():
|
||||
"""selective_rescan : disabled vide == aucune clé (non-régression)."""
|
||||
line = ("Joindre au 0612345678, Secu 185057800608491, "
|
||||
"13 rue des Lilas, Né le 12/03/1950, EHPAD Solemnis")
|
||||
cfg_none = core.load_dictionaries(None)
|
||||
cfg_empty = core.load_dictionaries(None)
|
||||
cfg_empty["disabled_kinds"] = set()
|
||||
assert core.selective_rescan(line, cfg=cfg_none) == core.selective_rescan(line, cfg=cfg_empty)
|
||||
|
||||
|
||||
def test_selective_rescan_enabled_still_masks():
|
||||
"""Non-régression rescan : rien désactivé ⇒ masque tout."""
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = set()
|
||||
line = "Joindre au 0612345678 et Secu 185057800608491"
|
||||
out = core.selective_rescan(line, cfg=cfg)
|
||||
assert "[TEL]" in out and "[NIR]" in out
|
||||
assert "0612345678" not in out and "185057800608491" not in out
|
||||
|
||||
|
||||
# --- NER per-hit (F-5) : _mask_with_hf -------------------------------------
|
||||
def test_mask_with_hf_per_hit_gating():
|
||||
"""NOM décoché : l'entité PER ressort en clair, l'ORG (ETAB) reste masquée."""
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = {"NOM"}
|
||||
text = "Le patient Martin suivi par Hopital Saint-Louis"
|
||||
ents = [
|
||||
{"word": "Martin", "entity_group": "PER"},
|
||||
{"word": "Hopital Saint-Louis", "entity_group": "ORG"},
|
||||
]
|
||||
audit = []
|
||||
out = core._mask_with_hf(text, ents, cfg, audit)
|
||||
assert "Martin" in out, f"NOM décoché : Martin devrait rester clair.\nout={out!r}"
|
||||
assert "[NOM]" not in out
|
||||
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
|
||||
|
||||
|
||||
def test_mask_with_hf_no_disabled_masks_all():
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = set()
|
||||
text = "Le patient Martin"
|
||||
ents = [{"word": "Martin", "entity_group": "PER"}]
|
||||
out = core._mask_with_hf(text, ents, cfg, [])
|
||||
assert "[NOM]" in out and "Martin" not in out
|
||||
|
||||
|
||||
# --- NER per-hit (F-5) : _mask_with_eds_pseudo -----------------------------
|
||||
def test_mask_with_eds_pseudo_per_hit_gating():
|
||||
"""NOM décoché : entité EDS NOM en clair, HOPITAL (ETAB) reste masquée."""
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = {"NOM"}
|
||||
text = "Compte rendu Bernardo signe a Belledonne"
|
||||
ents = [
|
||||
{"word": "Bernardo", "entity_group": "NOM", "eds_mapped_key": "NOM", "score": 0.99},
|
||||
{"word": "Belledonne", "entity_group": "HOPITAL", "eds_mapped_key": "ETAB", "score": 0.99},
|
||||
]
|
||||
out = core._mask_with_eds_pseudo(text, ents, cfg, [])
|
||||
assert "Bernardo" in out, f"NOM décoché : Bernardo devrait rester clair.\nout={out!r}"
|
||||
assert "[NOM]" not in out
|
||||
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
|
||||
|
||||
|
||||
# --- VLM per-hit (F-2) : _apply_vlm gating helper --------------------------
|
||||
def test_vlm_kind_gating_is_per_hit():
|
||||
"""Le gating VLM s'évalue par hit via _category_of(kind)."""
|
||||
import vlm_manager
|
||||
# NOM décoché : VLM_NOM doit être filtré, VLM_ETAB conservé.
|
||||
nom_kind, _ = vlm_manager.VLM_CATEGORY_MAP["NOM"]
|
||||
etab_kind, _ = vlm_manager.VLM_CATEGORY_MAP["ETABLISSEMENT"]
|
||||
assert core._category_of(nom_kind) == "NOM"
|
||||
assert core._category_of(etab_kind) == "ETAB"
|
||||
|
||||
|
||||
# === Régression AUDIT-LEVEL (revue qualité : fuite PDF FAX avec TEL décoché) ===
|
||||
# Le burn PDF (vector+raster) dérive UNIQUEMENT de anon.audit. Un type non
|
||||
# toggleable dont l'unique site de détection tombait dans (ou en aval d')un bloc
|
||||
# gaté ne produisait plus de hit audit → numéro VISIBLE dans le PDF livré, même
|
||||
# si le .txt paraissait propre. Ces tests assertent sur anon.audit, pas le texte.
|
||||
|
||||
def _audit_kinds(text, disabled):
|
||||
"""Lance le constructeur d'audit (anonymise_document_regex) et renvoie les hits."""
|
||||
cfg = core.load_dictionaries(None)
|
||||
cfg["disabled_kinds"] = set(disabled)
|
||||
return core.anonymise_document_regex([text], [], cfg).audit
|
||||
|
||||
|
||||
def _has_hit(audit, kind, placeholder=None):
|
||||
for h in audit:
|
||||
if h.kind == kind and (placeholder is None or h.placeholder == placeholder):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line,fax_value", [
|
||||
("Fax : 0512345678", "0512345678"),
|
||||
("Télécopie : 05 12 34 56 78", "05 12 34 56 78"),
|
||||
("Télécopieur : 0512345678", "0512345678"),
|
||||
])
|
||||
def test_fax_audit_hit_survives_tel_disabled(line, fax_value):
|
||||
"""FAX (non toggleable) DOIT rester dans anon.audit quand TEL est décoché.
|
||||
C'est le test qui échouait avant le correctif de découplage FAX (fuite PDF)."""
|
||||
audit = _audit_kinds(line, {"TEL"})
|
||||
# Un hit FAX doit exister (kind ET placeholder), pour que le burn PDF le masque.
|
||||
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
|
||||
f"FAX absent de l'audit avec TEL décoché → fuite PDF.\n"
|
||||
f"line={line!r}\naudit={[(h.kind, h.original) for h in audit]}")
|
||||
# La valeur ne doit pas survivre déguisée en hit TEL non plus.
|
||||
assert not _has_hit(audit, "TEL"), "Un fax ne doit pas devenir un hit TEL."
|
||||
|
||||
|
||||
def test_fax_audit_hit_present_when_nothing_disabled():
|
||||
"""Non-régression : FAX produit bien un hit audit sur le chemin par défaut."""
|
||||
audit = _audit_kinds("Fax : 0512345678", set())
|
||||
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"])
|
||||
|
||||
|
||||
def test_tel_audit_hit_dropped_when_tel_disabled():
|
||||
"""Cohérence : un vrai TÉLÉPHONE (toggleable) sort bien de l'audit si TEL décoché."""
|
||||
audit = _audit_kinds("Tel : 0612345678", {"TEL"})
|
||||
assert not _has_hit(audit, "TEL"), "TEL décoché ⇒ pas de hit TEL (numéro laissé clair)."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("off", ["NOM", "ADRESSE", "NIR", "ADHERENT", "ETAB", "DATE_NAISSANCE"])
|
||||
def test_fax_audit_survives_any_unrelated_toggle(off):
|
||||
"""Général : le non toggleable FAX reste dans l'audit quel que soit le toggle décoché."""
|
||||
audit = _audit_kinds("Fax : 0512345678", {off})
|
||||
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
|
||||
f"FAX absent de l'audit avec {off} décoché.\n"
|
||||
f"audit={[(h.kind, h.original) for h in audit]}")
|
||||
Reference in New Issue
Block a user