feat(ville): énumérations + CP nu + suffixe CEDEX dans règle contextuelle

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:37:55 +02:00
parent e6f3853426
commit 83769f6e63

View File

@@ -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, []