fix(detect): labels structurels Nom de jeune fille / Prénom / Ville (#7 #8 #9)

Trois nouveaux patterns cœur dans `_mask_structured_line` pour des
labels génériques qui n'étaient pas couverts par le pipeline kv_value
(le split key:value laissait fuir la valeur quand le label dépassait
les patterns existants `RE_EXTRACT_NOM_NAISSANCE`, `RE_EXTRACT_PRENOM`,
`RE_EXTRACT_VILLE_RESIDENCE`).

`RE_LABEL_NOM_VARIANTES` capture :
- Nom de jeune fille / de famille / de naissance(.)
- Nom d'usage / Nom marital / Nom marié

`RE_LABEL_PRENOM` capture :
- Prénom : / Prénoms : / Prénom de naissance / utilisé(e) / usuel
- Capture jusqu'à fin de ligne pour les énumérations virgulées
  (Prénoms : Sabine, Marie → tout masqué).

`RE_LABEL_VILLE` capture :
- Ville : / Ville de résidence : / Ville de naissance :
- Capture jusqu'à fin de ligne (gère "Saint-Jean-de-Luz",
  "Saint-Denis (974)", composés multi-tokens).

Effets de bord positifs :
- Le bug "Saint-Jean-de-Luz → [ETABLISSEMENT]-de-Luz" est corrigé :
  le matcher `RE_LABEL_VILLE` masque toute la valeur en `[VILLE]`
  AVANT que le gazetteer FINESS Aho-Corasick ne grignote "Saint-Jean".
  Cas 006_trackare_soignants et 008_anesthesie_complete : alignement
  des expected.txt sur cette amélioration.

Choix d'architecture (cf cadrage docs/cadrage-projet-anonymisation.md
section 10.1) : ces labels sont des règles cœur génériques applicables
à tout établissement de santé français. Légitimes en hardcodé. Les
patterns layout-specific (Bordeaux suffixe, CHCB en fin de phrase,
email cassé par force_term) seront branchés via admin_rules dans
l'étape suivante.

Cas 010_fiche_admission_minimale passe désormais (retiré de
KNOWN_FAILURES). Le xfail strict aurait signalé xpass.

Tests : 9 passed, 2 xfailed (avant : 8 passed, 3 xfailed sur
test_synthetic_review).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 22:30:40 +02:00
parent fcf945d1f7
commit 9b431494a5
7 changed files with 64 additions and 18 deletions

View File

@@ -396,6 +396,33 @@ RE_NUM_ADHERENT = re.compile(
r"\b(?:n[°o]?\s*|num[ée]ro\s+(?:d[']\s*)?)adh[ée]rent[e]?\s*[:\-]?\s*([A-Z0-9]{6,15})\b",
re.IGNORECASE,
)
# Variantes de label "Nom" qui ne correspondent pas à RE_EXTRACT_NOM_NAISSANCE
# (Nom de jeune fille, Nom de famille, Nom marital, Nom d'usage, Nom marié).
# Le label "Nom :" simple est déjà couvert par le pipeline kv_value (split sur ":").
# Capture la valeur jusqu'à la fin de ligne pour gérer les noms composés
# multi-tokens et les énumérations virgulées.
RE_LABEL_NOM_VARIANTES = re.compile(
r"(Nom\s+(?:de\s+(?:jeune\s+fille|famille|naiss\.?|usage|jeune)|d[']\s*usage|mari[ée])\s*[:\-]\s*)"
r"([^\n\r]+?)(?=\s*$|\s+(?:IPP|NDA|N°)\b)",
re.IGNORECASE | re.MULTILINE,
)
# Label "Prénom(s) :" avec suffixe optionnel ("de naissance", "utilisé(e)", "usuel").
# Capture jusqu'à fin de ligne pour gérer "Prénoms : Sabine, Marie".
RE_LABEL_PRENOM = re.compile(
r"(Pr[ée]nom\(?s?\)?(?:\s+(?:de\s+naissance|utilis[ée]e?|usuel|d[']\s*usage))?\s*[:\-]\s*)"
r"([^\n\r]+?)(?=\s*$)",
re.IGNORECASE | re.MULTILINE,
)
# Label "Ville :" avec suffixe optionnel ("de résidence", "de naissance").
# Capture jusqu'à fin de ligne pour gérer "Ville : Bayonne (64100)".
RE_LABEL_VILLE = re.compile(
r"(Ville(?:\s+de\s+(?:r[ée]sidence|naissance))?\s*[:\-]\s*)"
r"([^\n\r]+?)(?=\s*$)",
re.IGNORECASE | re.MULTILINE,
)
RE_NIR = re.compile(
r"\b([12])\s*(\d{2})\s*(0[1-9]|1[0-2]|2[AB])\s*(\d{2,3})\s*(\d{3})\s*(\d{3})\s*(\d{2})\b",
re.IGNORECASE,
@@ -1596,11 +1623,23 @@ def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str:
audit.append(PiiHit(page_idx, "ADHERENT", m.group(1), PLACEHOLDERS["ADHERENT"]))
return _replace_captured_value(m.group(0), m.group(1), PLACEHOLDERS["ADHERENT"])
def _repl_label_with_placeholder(kind: str, placeholder_key: str):
def _inner(m: re.Match) -> str:
value = m.group(2).strip()
if not value or value.startswith("["):
return m.group(0)
audit.append(PiiHit(page_idx, kind, value, PLACEHOLDERS[placeholder_key]))
return m.group(1) + PLACEHOLDERS[placeholder_key]
return _inner
masked = RE_CODE_POSTAL.sub(_repl_code_postal, line)
masked = RE_NUM_EXAMEN_PATIENT.sub(_repl_num_examen, masked)
masked = RE_NUMERO_DOSSIER.sub(_repl_dossier, masked)
masked = RE_VENUE_SEJOUR.sub(_repl_venue, masked)
masked = RE_NUM_ADHERENT.sub(_repl_adherent, masked)
masked = RE_LABEL_NOM_VARIANTES.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_LABEL_PRENOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_LABEL_VILLE.sub(_repl_label_with_placeholder("VILLE", "VILLE"), masked)
return masked