fix(whitelist): GUI whitelist_phrases enfin lue et appliquée par le core
Bug majeur depuis l'externalisation : la GUI v5.4 écrivait whitelist_phrases
(clé racine), mais le core ne lisait que whitelist.sections_titres /
noms_maj_excepts (imbriqué). _apply_whitelist post-masquage était par ailleurs
désactivée (c157205) sans remplacement.
Correctif :
- load_dictionaries() lit whitelist_phrases et alimente deux sets globaux
(_WHITELIST_NEVER_MASK_TOKENS, _WHITELIST_NEVER_MASK_PHRASES). Mots-outils
(de, du, le...) écartés pour éviter blocages collatéraux.
- _apply_extracted_names : check whitelist en pré-masquage, prime sur les
force_names (ex: "DUPONT" reste visible même après "Dr DUPONT").
- process_pdf : filtrage final de l'audit avant redact_pdf_vector. Les hits
multi-mots dont au moins un sous-token est whitelist sont retirés.
- redact_pdf_vector : check whitelist sur les sous-mots cherchés
individuellement quand le multi-mots n'est pas trouvé sur la page.
Validé sur trackare-18007562-23054899 :
- Avec whitelist BELLEAU : 0 hit dans audit, 31 occurrences préservées dans PDF
- Sans whitelist : 0 occurrence dans PDF (non-régression OK)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1126,6 +1126,45 @@ class NameCandidate:
|
||||
|
||||
# ----------------- Config loader -----------------
|
||||
|
||||
# Whitelist absolue : tokens que l'utilisateur a déclarés "à ne JAMAIS masquer"
|
||||
# Alimenté par cfg["whitelist_phrases"] dans load_dictionaries().
|
||||
# Filtré à 2 niveaux : pré-masquage (_apply_extracted_names) et filtrage final
|
||||
# (avant redact_pdf_vector) pour neutraliser tout NOM/PER/ORG qui matcherait.
|
||||
_WHITELIST_NEVER_MASK_TOKENS: set = set()
|
||||
_WHITELIST_NEVER_MASK_PHRASES: set = set()
|
||||
|
||||
|
||||
_WHITELIST_FUNCTION_WORDS = {
|
||||
"de", "du", "des", "le", "la", "les", "et", "ou", "à", "a",
|
||||
"en", "un", "une", "au", "aux", "of", "the", "and",
|
||||
}
|
||||
|
||||
|
||||
def _load_whitelist_phrases(phrases) -> int:
|
||||
"""Tokenise les phrases whitelist et alimente les sets globaux.
|
||||
Retourne le nombre de tokens effectivement ajoutés.
|
||||
Les mots-outils (de, du, le...) ne sont pas indexés individuellement
|
||||
pour éviter de faux blocages, mais la phrase complète est conservée."""
|
||||
if not phrases:
|
||||
return 0
|
||||
added = 0
|
||||
for phrase in phrases:
|
||||
if not phrase or not str(phrase).strip():
|
||||
continue
|
||||
p = str(phrase).strip()
|
||||
_WHITELIST_NEVER_MASK_PHRASES.add(p.lower())
|
||||
for tok in re.split(r"[\s\-']+", p):
|
||||
tok = tok.strip(" .,;:!?()[]{}\"'«»")
|
||||
tok_lower = tok.lower()
|
||||
if len(tok) < 3:
|
||||
continue
|
||||
if tok_lower in _WHITELIST_FUNCTION_WORDS:
|
||||
continue
|
||||
_WHITELIST_NEVER_MASK_TOKENS.add(tok_lower)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def load_dictionaries(config_path: Optional[Path]) -> Dict[str, Any]:
|
||||
cfg = DEFAULTS_CFG.copy()
|
||||
if config_path and config_path.exists() and yaml is not None:
|
||||
@@ -1151,6 +1190,14 @@ def load_dictionaries(config_path: Optional[Path]) -> Dict[str, Any]:
|
||||
_VILLE_BLACKLIST.add(str(v).strip().upper())
|
||||
log.info("Villes blacklist YAML supplémentaires : %d", len(extra_villes))
|
||||
|
||||
# Whitelist absolue : termes/phrases que l'utilisateur a déclarés "à ne JAMAIS masquer"
|
||||
# Alimentée par la GUI v5.4 (clé racine whitelist_phrases du YAML).
|
||||
wl_phrases = cfg.get("whitelist_phrases", []) or []
|
||||
if wl_phrases:
|
||||
n_added = _load_whitelist_phrases(wl_phrases)
|
||||
log.info("Whitelist phrases chargées : %d phrases (%d tokens)",
|
||||
len(wl_phrases), n_added)
|
||||
|
||||
return cfg
|
||||
|
||||
# ----------------- Extraction -----------------
|
||||
@@ -2399,6 +2446,11 @@ def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_nam
|
||||
continue
|
||||
if n not in _force and n.lower() in _MEDICAL_STOP_WORDS_SET:
|
||||
continue
|
||||
# Whitelist absolue (configurée par l'utilisateur via la GUI) : prime sur tout,
|
||||
# y compris sur les force_names (Dr/Mme). Si l'établissement déclare un terme
|
||||
# comme "à ne jamais masquer", on respecte même s'il apparaît après "Dr".
|
||||
if n.lower() in _WHITELIST_NEVER_MASK_TOKENS:
|
||||
continue
|
||||
safe_names.add(n)
|
||||
# Ajouter un hit global (page=-1) par nom pour la redaction PDF raster
|
||||
# (un seul hit suffit — redact_pdf_raster cherche le token sur chaque page)
|
||||
@@ -3804,6 +3856,9 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
|
||||
if h.kind in _VECTOR_WHOLEWORD_KINDS or h.kind == "NOM_FORCE":
|
||||
if h.kind != "NOM_FORCE" and token.lower() in _MEDICAL_STOP_WORDS_SET:
|
||||
continue
|
||||
# Whitelist absolue : prime même sur NOM_FORCE
|
||||
if token.lower() in _WHITELIST_NEVER_MASK_TOKENS:
|
||||
continue
|
||||
if " " not in token:
|
||||
rects = _search_whole_word(page, token)
|
||||
if not rects and ocr_word_map and pno in ocr_word_map:
|
||||
@@ -3823,6 +3878,9 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
|
||||
word = word.strip(" .-'")
|
||||
if len(word) < 4 or word.lower() in _MEDICAL_STOP_WORDS_SET:
|
||||
continue
|
||||
# Whitelist absolue sur sous-mots
|
||||
if word.lower() in _WHITELIST_NEVER_MASK_TOKENS:
|
||||
continue
|
||||
rects.extend(_search_whole_word(page, word))
|
||||
if not rects and ocr_word_map and pno in ocr_word_map:
|
||||
rects.extend(_search_ocr_words(ocr_word_map[pno], word, page.rect))
|
||||
@@ -4519,9 +4577,42 @@ def process_pdf(
|
||||
)
|
||||
final_text = _RE_BRACKET_CLEAN.sub(r"\1", final_text)
|
||||
|
||||
# 6) Whitelist : DÉSACTIVÉ — l'approche post-masquage est défectueuse
|
||||
# (injecte des phrases whitelist au mauvais endroit quand [NOM] masque un vrai nom)
|
||||
# TODO: implémenter en pré-masquage (protéger les spans avant anonymisation)
|
||||
# 6) Whitelist absolue : filtrer les hits qui matchent un terme whitelist
|
||||
# de la GUI (clé YAML whitelist_phrases). Filet de sécurité après tous les
|
||||
# mécanismes de détection — empêche DUPONT (whitelist) d'être masqué dans
|
||||
# le PDF même s'il a été ajouté à l'audit par regex/NER/cross-validation.
|
||||
if _WHITELIST_NEVER_MASK_TOKENS:
|
||||
_NAME_LIKE_KINDS = {
|
||||
"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "NOM_FORCE", "NOM_INITIAL",
|
||||
"EDS_NOM", "EDS_PRENOM", "EDS_HOPITAL", "EDS_VILLE",
|
||||
"ETAB", "ETAB_GLOBAL", "ETAB_FINESS", "ADDR_FINESS",
|
||||
"NER_PER", "NER_ORG", "NER_LOC",
|
||||
"VILLE", "force_term", "force_term_GLOBAL",
|
||||
}
|
||||
before = len(anon.audit)
|
||||
kept = []
|
||||
removed_tokens: set = set()
|
||||
for h in anon.audit:
|
||||
if h.kind not in _NAME_LIKE_KINDS:
|
||||
kept.append(h); continue
|
||||
tok = (h.original or "").strip()
|
||||
if not tok:
|
||||
kept.append(h); continue
|
||||
tok_lower = tok.lower()
|
||||
# Phrase complète whitelist → retirer
|
||||
if tok_lower in _WHITELIST_NEVER_MASK_PHRASES:
|
||||
removed_tokens.add(tok); continue
|
||||
# Au moins un sous-token whitelist → retirer le hit (les sous-tokens
|
||||
# non-whitelist sont déjà couverts par d'autres hits si nécessaire)
|
||||
sub = [s for s in re.split(r"[\s\-']+", tok_lower) if s]
|
||||
if any(s in _WHITELIST_NEVER_MASK_TOKENS for s in sub):
|
||||
removed_tokens.add(tok); continue
|
||||
kept.append(h)
|
||||
anon.audit = kept
|
||||
if before != len(anon.audit):
|
||||
log.info("Whitelist : %d hit(s) filtré(s) (%s)",
|
||||
before - len(anon.audit),
|
||||
", ".join(sorted(removed_tokens)[:10]))
|
||||
|
||||
# Sauvegardes
|
||||
base = pdf_path.stem
|
||||
|
||||
Reference in New Issue
Block a user