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*)?|"
|
r"Fait)\s+(?:à\s+|de\s+|d['']\s*)?|"
|
||||||
# "vers" (prép. géo directe) — NOTE: "sur" exclu car trop ambigu ("sur le plan")
|
# "vers" (prép. géo directe) — NOTE: "sur" exclu car trop ambigu ("sur le plan")
|
||||||
r"vers\s+|"
|
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"\[CODE_POSTAL\]\s*|"
|
||||||
|
r"\b\d{5}\s+|"
|
||||||
r"\(\s*|"
|
r"\(\s*|"
|
||||||
# Contextes médicaux spécifiques d'adressage
|
# Contextes médicaux spécifiques d'adressage
|
||||||
r"(?:urg(?:ences?)?\s+)|"
|
r"(?:urg(?:ences?)?\s+)|"
|
||||||
@@ -3584,7 +3585,12 @@ def _mask_ville_gazetteers(text: str) -> tuple:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Collecter les matches Aho-Corasick
|
# 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):
|
for end_idx, (norm_name, orig_name) in _VILLE_AC.iter(normalized):
|
||||||
start_idx = end_idx - len(norm_name) + 1
|
start_idx = end_idx - len(norm_name) + 1
|
||||||
# Vérifier frontières de mots (pas au milieu d'un mot)
|
# Vérifier frontières de mots (pas au milieu d'un mot)
|
||||||
@@ -3603,8 +3609,21 @@ def _mask_ville_gazetteers(text: str) -> tuple:
|
|||||||
continue
|
continue
|
||||||
# Récupérer le texte original à cette position
|
# Récupérer le texte original à cette position
|
||||||
original_span = text[start_idx:end_idx + 1]
|
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_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 :
|
# Stratégie contextuelle pour éviter les FP :
|
||||||
# TOUJOURS exiger un contexte géographique (à, de, vers, habite, etc.)
|
# TOUJOURS exiger un contexte géographique (à, de, vers, habite, etc.)
|
||||||
# sauf pour les villes composées avec trait d'union (Saint-Palais,
|
# 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]
|
before_ctx = text[max(0, start_idx - 40):start_idx]
|
||||||
if not _RE_GEO_BEFORE.search(before_ctx):
|
if not _RE_GEO_BEFORE.search(before_ctx):
|
||||||
continue
|
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:
|
if not matches:
|
||||||
return text, []
|
return text, []
|
||||||
|
|||||||
Reference in New Issue
Block a user