From 83769f6e63974d5749c3549d581470e2fb4b8591 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Wed, 15 Apr 2026 09:37:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(ville):=20=C3=A9num=C3=A9rations=20+=20CP?= =?UTF-8?q?=20nu=20+=20suffixe=20CEDEX=20dans=20r=C3=A8gle=20contextuelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois trous de détection identifiés par l'audit de règles : 1. Énumération "Bordeaux et Bayonne" / "Bordeaux, Bayonne, Biarritz" : la règle contextuelle _RE_GEO_BEFORE n'acceptait que des déclencheurs directs (à, de, hôpital de, urgences de…). Dans une énumération, la 2ème ville+ échappait. Nouvelle passe 2 : propagation mutuelle entre hits AC adjacents liés par " et " ou ", ". Itération à point fixe pour chaînes longues. Garde-fou : chaque hit ≥ 5 lettres pour éviter FP sur communes courtes homonymes. 2. Code postal encore en chiffres : _RE_GEO_BEFORE n'acceptait que [CODE_POSTAL] déjà masqué. Ajout de `\b\d{5}\s+` comme déclencheur pour couvrir l'ordre dans lequel _mask_ville_gazetteers est appelée avant le masquage du code postal. 3. Suffixe CEDEX : "BAYONNE CEDEX" capturait BAYONNE seul. Extension automatique de la capture pour inclure " CEDEX" et " CEDEX N" adjacents. Cas validés : - "travaille à Bordeaux et Bayonne" → [VILLE] et [VILLE] - "Régions : Bordeaux, Bayonne, Biarritz" → 3× [VILLE] (chaîne sans ancre) - "64109 BAYONNE CEDEX" → [VILLE] (capture CEDEX inclus) - "charge", "médecin et patient" → aucun FP Non-régression : 122 hits sur trackare-18007562. Après ce fix, on peut retirer BAYONNE, BAYONNE CEDEX du YAML force_mask_terms. Co-Authored-By: Claude Opus 4.6 (1M context) --- anonymizer_core_refactored_onnx.py | 61 ++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) 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, []