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:
@@ -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, []
|
||||
|
||||
Reference in New Issue
Block a user