diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index bd721c7..905739c 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -3565,8 +3565,9 @@ def _mask_ville_gazetteers(text: str) -> tuple: r"Fait)\s+(?:à\s+|de\s+|d['']\s*)?|" # "vers" (prép. géo directe) — NOTE: "sur" exclu car trop ambigu ("sur le plan") r"vers\s+|" - # Après code postal ou parenthèse ouvrante (adresse) + # Après code postal (déjà masqué OU encore en chiffres) ou parenthèse ouvrante r"\[CODE_POSTAL\]\s*|" + r"\b\d{5}\s+|" r"\(\s*|" # Contextes médicaux spécifiques d'adressage r"(?:urg(?:ences?)?\s+)|" @@ -3584,7 +3585,12 @@ def _mask_ville_gazetteers(text: str) -> tuple: ) # Collecter les matches Aho-Corasick - matches = [] + # Construire aussi un index des matches par position de début pour la passe + # "énumération" (passe 2) : une ville dont l'énumération précède un match confirmé + # doit être elle aussi masquée ("Bordeaux et Bayonne" → Bayonne via Bordeaux). + all_ac_hits: list = [] # [(start, end, orig_span), ...] — tous matches AC avant filtrage + confirmed_hits: set = set() # indices dans all_ac_hits qui ont passé le filtre contextuel + for end_idx, (norm_name, orig_name) in _VILLE_AC.iter(normalized): start_idx = end_idx - len(norm_name) + 1 # Vérifier frontières de mots (pas au milieu d'un mot) @@ -3603,8 +3609,21 @@ def _mask_ville_gazetteers(text: str) -> tuple: continue # Récupérer le texte original à cette position original_span = text[start_idx:end_idx + 1] + # Extension suffixe CEDEX : si la ville est suivie de " CEDEX" ou " CEDEX N", + # capturer l'ensemble (ex: "BAYONNE CEDEX" → match complet). + _cedex_match = re.match(r"\s+CEDEX(?:\s+\d+)?\b", text[end_idx + 1:end_idx + 20]) + if _cedex_match: + ext_len = _cedex_match.end() + end_idx_ext = end_idx + ext_len + original_span = text[start_idx:end_idx_ext + 1] + else: + end_idx_ext = end_idx word_count = len(orig_name.split()) - word_len = len(orig_name.strip()) + + # Enregistrer tous les hits (même sans contexte géo) pour la passe énumération + all_ac_hits.append((start_idx, end_idx_ext + 1, original_span)) + hit_idx = len(all_ac_hits) - 1 + # Stratégie contextuelle pour éviter les FP : # TOUJOURS exiger un contexte géographique (à, de, vers, habite, etc.) # sauf pour les villes composées avec trait d'union (Saint-Palais, @@ -3614,7 +3633,41 @@ def _mask_ville_gazetteers(text: str) -> tuple: before_ctx = text[max(0, start_idx - 40):start_idx] if not _RE_GEO_BEFORE.search(before_ctx): continue - matches.append((start_idx, end_idx + 1, original_span)) + confirmed_hits.add(hit_idx) + + # Passe 2 — énumérations : si deux hits AC sont liés par " et " ou ", ", + # se confirment mutuellement. Cas réels couverts : + # "travaille à Bordeaux et Bayonne" (ancre déjà confirmée propage) + # "Régions : Bordeaux, Bayonne, Biarritz" (aucune ancre, mais chaîne ≥2 villes + # gazetteer en énumération = forte présomption géographique) + # Itération à point fixe pour propager sur des chaînes longues. + def _enum_link(i: int, j: int) -> bool: + """Vrai si les hits i et j sont adjacents dans une énumération.""" + s_a, e_a, _ = all_ac_hits[i] + s_b, e_b, _ = all_ac_hits[j] + if s_b <= e_a: + return False + return bool(re.fullmatch(r"\s*(?:et|,)\s*", text[e_a:s_b])) + + changed = True + while changed: + changed = False + for i in range(len(all_ac_hits)): + for j in range(len(all_ac_hits)): + if i == j or not _enum_link(i, j): + continue + # Cas A : i confirmé → confirmer j + if i in confirmed_hits and j not in confirmed_hits: + confirmed_hits.add(j); changed = True + # Cas B : chaîne ≥2 en énumération sans ancre → confirmer les deux. + # Garde-fou : chaque hit doit avoir au moins 5 lettres (évite de + # masquer deux mots courts homonymes de communes côte à côte). + elif (i not in confirmed_hits and j not in confirmed_hits + and len(all_ac_hits[i][2].strip()) >= 5 + and len(all_ac_hits[j][2].strip()) >= 5): + confirmed_hits.add(i); confirmed_hits.add(j); changed = True + + matches = [all_ac_hits[i] for i in confirmed_hits] if not matches: return text, []