diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 68484fd..c796c15 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -1829,6 +1829,12 @@ def _mask_admin_label(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[s def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str: + # Plan 1b (P1-2/F-2/F-5) — catégories décochées (les 7 toggles). Vide ⇒ no-op + # byte-for-byte. Chaque sous-bloc d'une catégorie toggleable est sauté si sa + # catégorie est désactivée (la valeur reste alors EN CLAIR dans le texte). + # Les kinds non toggleables (EMAIL, IBAN, FINESS, IPP, VILLE, …) → None ⇒ + # default-deny ⇒ TOUJOURS masqués. + disabled = cfg.get("disabled_kinds") or set() # EMAIL avant les overrides : les force_terms (ex: CHUXX) casseraient sinon l'adresse def _repl_email(m: re.Match) -> str: audit.append(PiiHit(page_idx, "EMAIL", m.group(0), PLACEHOLDERS["EMAIL"])) @@ -1857,14 +1863,15 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict return raw # faux positif, on ne masque pas audit.append(PiiHit(page_idx, "NIR", raw, PLACEHOLDERS["NIR"])) return PLACEHOLDERS["NIR"] - line = RE_NIR.sub(_repl_nir, line) # NIR 13 chiffres sans clé, STRICTEMENT après label (pas de validation modulo # possible sans la clé ; l'ancre label suffit à éviter les faux positifs). def _repl_nir_no_key(m: re.Match) -> str: val = m.group(1) audit.append(PiiHit(page_idx, "NIR", val, PLACEHOLDERS["NIR"])) return m.group(0).replace(val, PLACEHOLDERS["NIR"]) - line = RE_NIR_NO_KEY.sub(_repl_nir_no_key, line) + if "NIR" not in disabled: + line = RE_NIR.sub(_repl_nir, line) + line = RE_NIR_NO_KEY.sub(_repl_nir_no_key, line) # FAX (label-ancré) AVANT TEL : un numéro de fax doit devenir [FAX], pas [TEL]. def _repl_fax(m: re.Match) -> str: @@ -1877,9 +1884,10 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict def _repl_tel(m: re.Match) -> str: audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"])) return PLACEHOLDERS["TEL"] - line = RE_TEL_SLASH.sub(_repl_tel, line) # slash d'abord (plus spécifique) - line = RE_TEL.sub(_repl_tel, line) - line = RE_TEL_COMPACT.sub(_repl_tel, line) + if "TEL" not in disabled: + line = RE_TEL_SLASH.sub(_repl_tel, line) # slash d'abord (plus spécifique) + line = RE_TEL.sub(_repl_tel, line) + line = RE_TEL_COMPACT.sub(_repl_tel, line) # IBAN def _repl_iban(m: re.Match) -> str: @@ -1905,13 +1913,14 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict def _repl_date_naissance(m: re.Match) -> str: audit.append(PiiHit(page_idx, "DATE_NAISSANCE", m.group(0), PLACEHOLDERS["DATE_NAISSANCE"])) return PLACEHOLDERS["DATE_NAISSANCE"] - line = RE_DATE_NAISSANCE.sub(_repl_date_naissance, line) # « Né en 1972 » (année seule de naissance) → [DATE_NAISSANCE] def _repl_date_naissance_annee(m: re.Match) -> str: val = m.group(1) audit.append(PiiHit(page_idx, "DATE_NAISSANCE", val, PLACEHOLDERS["DATE_NAISSANCE"])) return m.group(0).replace(val, PLACEHOLDERS["DATE_NAISSANCE"]) - line = RE_DATE_NAISSANCE_ANNEE.sub(_repl_date_naissance_annee, line) + if "DATE_NAISSANCE" not in disabled: + line = RE_DATE_NAISSANCE.sub(_repl_date_naissance, line) + line = RE_DATE_NAISSANCE_ANNEE.sub(_repl_date_naissance_annee, line) # DATE générique — désactivé : seules les dates de naissance sont masquées # def _repl_date(m: re.Match) -> str: @@ -1919,23 +1928,23 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict # return PLACEHOLDERS["DATE"] # line = RE_DATE.sub(_repl_date, line) - # ADRESSE + # ADRESSE — la catégorie « Adresses » couvre voie, BP et code postal + # (décision Dom 2026-06-26 : CODE_POSTAL suit le toggle ADRESSE). def _repl_adresse(m: re.Match) -> str: audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) return PLACEHOLDERS["ADRESSE"] - line = RE_ADRESSE.sub(_repl_adresse, line) - # BOITE POSTALE (BP) def _repl_bp(m: re.Match) -> str: audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) return PLACEHOLDERS["ADRESSE"] - line = RE_BP.sub(_repl_bp, line) - # CODE_POSTAL def _repl_code_postal(m: re.Match) -> str: audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"])) return PLACEHOLDERS["CODE_POSTAL"] - line = RE_CODE_POSTAL.sub(_repl_code_postal, line) + if "ADRESSE" not in disabled: + line = RE_ADRESSE.sub(_repl_adresse, line) + line = RE_BP.sub(_repl_bp, line) + line = RE_CODE_POSTAL.sub(_repl_code_postal, line) # AGE def _repl_age(m: re.Match) -> str: @@ -1959,13 +1968,13 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict def _repl_lieu_dit(m: re.Match) -> str: audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) return PLACEHOLDERS["ADRESSE"] - line = RE_ADRESSE_LIEU_DIT.sub(_repl_lieu_dit, line) - - # Lieux-dits courants seuls sur une ligne (ex: "Le BOURG", "Le Village") - line = RE_LIEU_DIT_SEUL.sub( - lambda m: (audit.append(PiiHit(page_idx, "ADRESSE", m.group(1), PLACEHOLDERS["ADRESSE"])) or PLACEHOLDERS["ADRESSE"]), - line, - ) + if "ADRESSE" not in disabled: + line = RE_ADRESSE_LIEU_DIT.sub(_repl_lieu_dit, line) + # Lieux-dits courants seuls sur une ligne (ex: "Le BOURG", "Le Village") + line = RE_LIEU_DIT_SEUL.sub( + lambda m: (audit.append(PiiHit(page_idx, "ADRESSE", m.group(1), PLACEHOLDERS["ADRESSE"])) or PLACEHOLDERS["ADRESSE"]), + line, + ) # N° EPISODE / Episode N. (pieds de page Trackare) def _repl_episode(m: re.Match) -> str: @@ -1990,26 +1999,29 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict audit.append(PiiHit(page_idx, "ADHERENT", val, PLACEHOLDERS["ADHERENT"])) full = m.group(0) return full[:full.find(val)] + PLACEHOLDERS["ADHERENT"] - line = RE_NUM_ADHERENT.sub(_repl_adherent, line) - line = RE_NUM_MUTUELLE.sub(_repl_adherent, line) + if "ADHERENT" not in disabled: + line = RE_NUM_ADHERENT.sub(_repl_adherent, line) + line = RE_NUM_MUTUELLE.sub(_repl_adherent, line) # Établissements de santé (EHPAD Chicago, SSR Anonyme, Hôpital de Chicago, etc.) def _repl_etab(m: re.Match) -> str: audit.append(PiiHit(page_idx, "ETAB", m.group(0), PLACEHOLDERS["ETAB"])) return PLACEHOLDERS["ETAB"] - line = RE_ETABLISSEMENT.sub(_repl_etab, line) - line = RE_HOPITAL_VILLE.sub(_repl_etab, line) + if "ETAB" not in disabled: + line = RE_ETABLISSEMENT.sub(_repl_etab, line) + line = RE_HOPITAL_VILLE.sub(_repl_etab, line) - # Établissements par gazetteer Aho-Corasick FINESS (116K noms distinctifs) - # Note: _mask_finess_establishments() construit l'automate en lazy au premier appel - line, finess_matched = _mask_finess_establishments(line, return_matched_names=True) - for matched_name in finess_matched: - audit.append(PiiHit(page_idx, "ETAB_FINESS", matched_name, PLACEHOLDERS["ETAB"])) + # Établissements par gazetteer Aho-Corasick FINESS (116K noms distinctifs) + # Note: _mask_finess_establishments() construit l'automate en lazy au premier appel + line, finess_matched = _mask_finess_establishments(line, return_matched_names=True) + for matched_name in finess_matched: + audit.append(PiiHit(page_idx, "ETAB_FINESS", matched_name, PLACEHOLDERS["ETAB"])) # Adresses par gazetteer Aho-Corasick FINESS (28K noms de voie) - line, addr_matched = _mask_finess_addresses(line, return_matched_names=True) - for matched_addr in addr_matched: - audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"])) + if "ADRESSE" not in disabled: + line, addr_matched = _mask_finess_addresses(line, return_matched_names=True) + for matched_addr in addr_matched: + audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"])) # Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R D E ..." # Les lettres majuscules séparées par des espaces échappent à toute détection normale. @@ -2028,7 +2040,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict "CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE", "EHPAD", "SSR", "USLD", "CHU", "CHRU", } - spaced_matches = list(_RE_SPACED_TEXT.finditer(line)) + spaced_matches = list(_RE_SPACED_TEXT.finditer(line)) if "ETAB" not in disabled else [] if spaced_matches: # Vérifier si au moins un segment contient un mot-clé d'établissement has_etab_keyword = False @@ -2068,7 +2080,8 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict return full_match audit.append(PiiHit(page_idx, "ETAB", full_match, PLACEHOLDERS["MASK"])) return PLACEHOLDERS["MASK"] - line = RE_SERVICE.sub(_repl_service, line) + if "ETAB" not in disabled: + line = RE_SERVICE.sub(_repl_service, line) # Ville en en-tête de courrier : "Chicago, le 12/03/2024" → masquer la ville # Le contexte "Mot, le [date]" est fiable (virgule obligatoire) @@ -2128,49 +2141,57 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict audit.append(PiiHit(page_idx, "NOM", cleaned, PLACEHOLDERS["NOM"])) return raw.replace(cleaned, PLACEHOLDERS["NOM"]) - line = RE_PERSON_CONTEXT.sub(_repl_person_ctx, line) - # Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer la lettre def _repl_civilite_init(m: re.Match) -> str: prefix = m.group(1) lettre = m.group(2) audit.append(PiiHit(page_idx, "NOM", lettre, PLACEHOLDERS["NOM"])) return prefix + PLACEHOLDERS["NOM"] - line = RE_CIVILITE_INITIALE.sub(_repl_civilite_init, line) - # Passe supplémentaire : noms dans des listes virgulées après "Dr" - # ex: "le Dr DUVAL, MACHELART, LAZARO" → masquer chaque nom - for m in RE_DR_COMMA_LIST.finditer(line): - fragment = m.group(0) - # Extraire les segments séparés par des virgules (sauf le premier qui inclut "Dr") - parts = [p.strip() for p in fragment.split(",")] - for part in parts: - # Extraire les tokens nom de chaque segment - for tok in _NAME_TOKEN_RE.findall(part): - if tok in wl_sections or len(tok) <= 3: - continue - if _stop_rx.fullmatch(tok): - continue - if tok not in line: - continue - # Vérifier qu'il n'est pas déjà masqué - if f"[{tok}]" in line or tok in {v for v in PLACEHOLDERS.values()}: - continue - audit.append(PiiHit(page_idx, "NOM", tok, PLACEHOLDERS["NOM"])) - line = re.sub(rf"\b{re.escape(tok)}\b", PLACEHOLDERS["NOM"], line) + if "NOM" not in disabled: + line = RE_PERSON_CONTEXT.sub(_repl_person_ctx, line) + line = RE_CIVILITE_INITIALE.sub(_repl_civilite_init, line) + + # Passe supplémentaire : noms dans des listes virgulées après "Dr" + # ex: "le Dr DUVAL, MACHELART, LAZARO" → masquer chaque nom + for m in RE_DR_COMMA_LIST.finditer(line): + fragment = m.group(0) + # Extraire les segments séparés par des virgules (sauf le premier qui inclut "Dr") + parts = [p.strip() for p in fragment.split(",")] + for part in parts: + # Extraire les tokens nom de chaque segment + for tok in _NAME_TOKEN_RE.findall(part): + if tok in wl_sections or len(tok) <= 3: + continue + if _stop_rx.fullmatch(tok): + continue + if tok not in line: + continue + # Vérifier qu'il n'est pas déjà masqué + if f"[{tok}]" in line or tok in {v for v in PLACEHOLDERS.values()}: + continue + audit.append(PiiHit(page_idx, "NOM", tok, PLACEHOLDERS["NOM"])) + line = re.sub(rf"\b{re.escape(tok)}\b", PLACEHOLDERS["NOM"], line) return line -def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int) -> str: +def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int, + disabled: Optional[Set[str]] = None) -> str: """Masque les TEL, EMAIL, ADRESSE, CODE_POSTAL même dans la partie 'clé' d'une ligne clé:valeur. - Nécessaire car des lignes comme '13 avenue ... CHICAGO - Tel : 0XXX' sont splitées sur ':'.""" + Nécessaire car des lignes comme '13 avenue ... CHICAGO - Tel : 0XXX' sont splitées sur ':'. + Plan 1b (P1-2/F-2) : TEL et ADRESSE sont gatés par catégorie (EMAIL → toujours masqué). + FAX (non toggleable) est masqué+audité INCONDITIONNELLEMENT, hors gate TEL.""" + disabled = disabled or set() + # FAX d'abord et SANS condition : si le numéro+libellé fax atterrit côté clé. + key = _mask_fax_unconditional(key, audit, page_idx) def _repl_tel(m: re.Match) -> str: audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"])) return PLACEHOLDERS["TEL"] - key = RE_TEL_SLASH.sub(_repl_tel, key) - key = RE_TEL.sub(_repl_tel, key) - key = RE_TEL_COMPACT.sub(_repl_tel, key) + if "TEL" not in disabled: + key = RE_TEL_SLASH.sub(_repl_tel, key) + key = RE_TEL.sub(_repl_tel, key) + key = RE_TEL_COMPACT.sub(_repl_tel, key) def _repl_email(m: re.Match) -> str: audit.append(PiiHit(page_idx, "EMAIL", m.group(0), PLACEHOLDERS["EMAIL"])) return PLACEHOLDERS["EMAIL"] @@ -2179,16 +2200,17 @@ def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int) -> str: def _repl_adresse(m: re.Match) -> str: audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) return PLACEHOLDERS["ADRESSE"] - key = RE_ADRESSE.sub(_repl_adresse, key) # CODE_POSTAL (inclut la ville) def _repl_cp(m: re.Match) -> str: audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"])) return PLACEHOLDERS["CODE_POSTAL"] - key = RE_CODE_POSTAL.sub(_repl_cp, key) - # FINESS adresses Aho-Corasick - key, addr_matched = _mask_finess_addresses(key, return_matched_names=True) - for matched_addr in addr_matched: - audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"])) + if "ADRESSE" not in disabled: + key = RE_ADRESSE.sub(_repl_adresse, key) + key = RE_CODE_POSTAL.sub(_repl_cp, key) + # FINESS adresses Aho-Corasick + key, addr_matched = _mask_finess_addresses(key, return_matched_names=True) + for matched_addr in addr_matched: + audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"])) return key @@ -2200,8 +2222,12 @@ def _replace_captured_value(full_match: str, captured_value: str, placeholder: s return full_match[:start] + placeholder + full_match[end:] -def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str: - """Masque les champs structurés dont la détection dépend du libellé de la ligne.""" +def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int, + disabled: Optional[Set[str]] = None) -> str: + """Masque les champs structurés dont la détection dépend du libellé de la ligne. + Plan 1b (P1-2/F-2) : CODE_POSTAL→ADRESSE, ADHERENT, et les libellés NOM sont + gatés par catégorie. DOSSIER/NDA/VILLE → toujours masqués (non toggleables).""" + disabled = disabled or set() def _repl_code_postal(m: re.Match) -> str: original = m.group(1) or m.group(2) or m.group(0) @@ -2250,27 +2276,54 @@ def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str: audit.append(PiiHit(page_idx, "NOM_INITIAL", m.group(3), PLACEHOLDERS["NOM"])) return m.group(1) + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"] - masked = RE_CODE_POSTAL.sub(_repl_code_postal, line) + # CODE_POSTAL → catégorie ADRESSE (décision Dom 2026-06-26). + masked = line + if "ADRESSE" not in disabled: + masked = RE_CODE_POSTAL.sub(_repl_code_postal, masked) + # DOSSIER / NDA → toujours masqués (non toggleables). 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_NUM_MUTUELLE.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_NOM_PROFESSIONNEL.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) - masked = RE_LABEL_STAFF_ROLE_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) - masked = RE_HEADER_CROP_EPI_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) - masked = RE_STANDALONE_COMPOUND_PERSON_LINE.sub(_repl_whole_line_with_placeholder("NOM_FORCE", "NOM"), masked) - masked = RE_MODIFIED_BY_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) - masked = RE_REF_INITIALS_INLINE.sub(_repl_ref_initials, masked) + # N° adhérent → catégorie ADHERENT. + if "ADHERENT" not in disabled: + masked = RE_NUM_ADHERENT.sub(_repl_adherent, masked) + masked = RE_NUM_MUTUELLE.sub(_repl_adherent, masked) + # Libellés NOM (NOM_FORCE / NOM_INITIAL) → catégorie NOM. + if "NOM" not in disabled: + 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_NOM_PROFESSIONNEL.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) + masked = RE_LABEL_STAFF_ROLE_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) + masked = RE_HEADER_CROP_EPI_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) + masked = RE_STANDALONE_COMPOUND_PERSON_LINE.sub(_repl_whole_line_with_placeholder("NOM_FORCE", "NOM"), masked) + masked = RE_MODIFIED_BY_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) + masked = RE_REF_INITIALS_INLINE.sub(_repl_ref_initials, masked) + # Ville → toujours masquée (non toggleable). masked = RE_LABEL_VILLE.sub(_repl_label_with_placeholder("VILLE", "VILLE"), masked) return masked +def _mask_fax_unconditional(line: str, audit: List[PiiHit], page_idx: int) -> str: + """FAX est NON toggleable (`_category_of("FAX")` → None) ⇒ toujours masqué ET + inscrit à l'audit, indépendamment du toggle TEL. ``RE_FAX`` est ancré au libellé + ("Fax :"/"Télécopie :") collé au numéro : il doit donc tourner sur la LIGNE + COMPLÈTE, avant le split clé/valeur (qui sépare le libellé du numéro et + empêcherait toute détection). Le hit FAX doit atteindre ``anon.audit`` pour que + le burn PDF (vector+raster, dérivé de l'audit) masque le numéro.""" + def _repl_fax(m: re.Match) -> str: + num = m.group(1) + audit.append(PiiHit(page_idx, "FAX", num, PLACEHOLDERS["FAX"])) + return m.group(0).replace(num, PLACEHOLDERS["FAX"]) + return RE_FAX.sub(_repl_fax, line) + + def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str: + disabled = cfg.get("disabled_kinds") or set() + # FAX non toggleable : masquage+audit sur la ligne complète AVANT toute autre + # passe (le split clé/valeur sépare « Fax » du numéro → détection impossible). + line = _mask_fax_unconditional(line, audit, page_idx) line = _mask_admin_label(line, audit, page_idx, cfg) - structured_line = _mask_structured_line(line, audit, page_idx) + structured_line = _mask_structured_line(line, audit, page_idx, disabled) if structured_line != line: return structured_line parts = SPLITTER.split(line, maxsplit=1) @@ -2281,7 +2334,7 @@ def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict # probablement du narratif, pas un libellé `Label : valeur`. if len(parts) == 2 and parts[1].strip() and len(parts[0].split()) <= 5: key, value = parts - masked_key = _mask_critical_in_key(key, audit, page_idx) + masked_key = _mask_critical_in_key(key, audit, page_idx, disabled) masked_val = _mask_line_by_regex(value, audit, page_idx, cfg) return f"{masked_key.strip()} : {masked_val.strip()}" return _mask_line_by_regex(line, audit, page_idx, cfg) @@ -2967,8 +3020,13 @@ def _cross_validate_name_candidates( return validated_names, validated_force_names -def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None) -> str: - """Remplace globalement chaque nom extrait dans le texte.""" +def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None, + disabled: Optional[Set[str]] = None) -> str: + """Remplace globalement chaque nom extrait dans le texte. + Plan 1b (P1-2/F-2) : si la catégorie NOM est décochée, ne masque RIEN + (les noms restent en clair). No-op aussi quand ``names`` est vide.""" + if disabled and "NOM" in disabled: + return text placeholder = PLACEHOLDERS["NOM"] _force = force_names or set() @@ -3063,10 +3121,17 @@ def _apply_trackare_hits_to_text(text: str, audit: List[PiiHit], cfg: Dict[str, kind = rule.get("kind") if kind: _APPLY_KINDS.add(str(kind)) + # Plan 1b (P1-2/F-2) : ne pas réappliquer dans le texte les hits dont la + # catégorie est décochée (ex: NIR, ou un kind admin mappé à une des 7). + # Default-deny : si _category_of renvoie None (kind non toggleable), on + # masque toujours. No-op byte-for-byte quand disabled est vide. + disabled = (cfg or {}).get("disabled_kinds") or set() # Collecter les valeurs à remplacer, groupées par placeholder replacements: Dict[str, str] = {} # original → placeholder for h in audit: if h.kind in _APPLY_KINDS and h.original and len(h.original.strip()) >= 4: + if disabled and _category_of(h.kind) in disabled: + continue replacements[h.original.strip()] = h.placeholder # Remplacer les plus longs d'abord (éviter les remplacements partiels) for original in sorted(replacements, key=len, reverse=True): @@ -3103,6 +3168,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] if _is_practitioner_council_recoding_form(full_raw): cfg = dict(cfg) cfg["_preserve_practitioner_council_ogc"] = True + # Plan 1b (P1-2/F-2/F-5) — catégories décochées (7 toggles). Vide ⇒ no-op + # byte-for-byte. Chaque passe de masquage TEXTE saute sa catégorie si décochée. + disabled = cfg.get("disabled_kinds") or set() extracted_names, doc_force_names, doc_candidates = _extract_document_names(full_raw, cfg) # Phase 0b : si document Trackare, extraction renforcée des PII structurés @@ -3178,8 +3246,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] r"(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})", re.IGNORECASE, ) - for m in _RE_DATE_NAISSANCE_MULTILINE.finditer(full_raw): - audit.append(PiiHit(-1, "DATE_NAISSANCE", m.group(1), PLACEHOLDERS["DATE_NAISSANCE"])) + if "DATE_NAISSANCE" not in disabled: + for m in _RE_DATE_NAISSANCE_MULTILINE.finditer(full_raw): + audit.append(PiiHit(-1, "DATE_NAISSANCE", m.group(1), PLACEHOLDERS["DATE_NAISSANCE"])) # Phase 0e : IPP multiline (N°Ipp :\n20023294 ou I.P.P. :\nS1032021) _RE_IPP_MULTILINE = re.compile( @@ -3197,8 +3266,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] r"(\d(?:[\s.\-]?\d){12})\b", re.IGNORECASE, ) - for m in _RE_NIR_NO_KEY_MULTILINE.finditer(full_raw): - audit.append(PiiHit(-1, "NIR", m.group(1), PLACEHOLDERS["NIR"])) + if "NIR" not in disabled: + for m in _RE_NIR_NO_KEY_MULTILINE.finditer(full_raw): + audit.append(PiiHit(-1, "NIR", m.group(1), PLACEHOLDERS["NIR"])) # Phase 0f : numéro d'accession / d'examen en en-tête de labo ou imagerie # Ex: @@ -3254,13 +3324,15 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] def _repl_etab_linebreak(m: re.Match, _page=i) -> str: audit.append(PiiHit(_page, "ETAB", m.group(0), PLACEHOLDERS["ETAB"])) return PLACEHOLDERS["ETAB"] - page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt) + if "ETAB" not in disabled: + page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt) def _repl_iao_multiline(m: re.Match, _page=i) -> str: value = m.group(2).strip() audit.append(PiiHit(_page, "NOM_FORCE", value, PLACEHOLDERS["NOM"])) return m.group(1) + PLACEHOLDERS["NOM"] - page_txt = RE_TRACKARE_IAO_MULTILINE_VALUE.sub(_repl_iao_multiline, page_txt) + if "NOM" not in disabled: + page_txt = RE_TRACKARE_IAO_MULTILINE_VALUE.sub(_repl_iao_multiline, page_txt) lines = page_txt.splitlines() masked = [_kv_value_only_mask(ln, audit, i, cfg) for ln in lines] @@ -3285,7 +3357,8 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] # Phase 2 : application globale des noms extraits (rattrapage) # Utilise all_names (validé par NER-first si disponible, sinon extracted_names original) if all_names: - text_out = _apply_extracted_names(text_out, all_names, audit, force_names=all_force_names) + text_out = _apply_extracted_names(text_out, all_names, audit, force_names=all_force_names, + disabled=disabled) # Phase 2b : application globale des PiiHit (EPISODE, RPPS, FINESS) text_out = _apply_trackare_hits_to_text(text_out, audit, cfg) @@ -3297,6 +3370,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str] def _mask_with_hf(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str: # remplace via regex sur les 'word' détectés (approche pragmatique) keep_org_gpe = bool((cfg.get("whitelist", {}) or {}).get("org_gpe_keep", False)) + # Plan 1b (P1-2/F-5) : gating PER-HIT (jamais skip toute la fonction, sinon + # on perdrait les catégories encore actives). Default-deny via _category_of. + disabled = cfg.get("disabled_kinds") or set() def repl_once(s: str, old: str, new: str) -> str: return re.sub(rf"\b{re.escape(old)}\b", new, s) out = text @@ -3307,11 +3383,15 @@ def _mask_with_hf(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], au if len(w) <= 2: # trop court continue if grp in {"PER", "PERSON"}: + if disabled and _category_of("NER_PER") in disabled: # catégorie NOM décochée + continue audit.append(PiiHit(-1, "NER_PER", w, PLACEHOLDERS["NOM"])) out = repl_once(out, w, PLACEHOLDERS["NOM"]) elif grp in {"ORG"}: if keep_org_gpe: continue + if disabled and _category_of("NER_ORG") in disabled: # catégorie ETAB décochée + continue audit.append(PiiHit(-1, "NER_ORG", w, PLACEHOLDERS["ETAB"])) out = repl_once(out, w, PLACEHOLDERS["ETAB"]) elif grp in {"LOC"}: @@ -3368,6 +3448,9 @@ def apply_hf_ner_on_narrative(text_out: str, cfg: Dict[str, Any], manager: Optio def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str: """Masque les entités détectées par EDS-Pseudo en utilisant le mapping eds_mapped_key.""" + # Plan 1b (P1-2/F-5) : gating PER-HIT via la catégorie du kind EDS_{label}. + # Jamais de skip global (sinon perte des catégories actives). Default-deny. + disabled = cfg.get("disabled_kinds") or set() def repl_once(s: str, old: str, new: str) -> str: return re.sub(rf"\b{re.escape(old)}\b", new, s) out = text @@ -3439,6 +3522,9 @@ def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, continue if w.upper() in _STRUCTURAL_WORDS: continue + # Gating per-hit (F-5) : catégorie décochée → laisser en clair. + if disabled and _category_of(f"EDS_{label}") in disabled: + continue placeholder = PLACEHOLDERS.get(mapped_key, PLACEHOLDERS["MASK"]) audit.append(PiiHit(-1, f"EDS_{label}", w, placeholder)) out = repl_once(out, w, placeholder) @@ -4319,6 +4405,11 @@ def _apply_whitelist(text: str, phrases: List[str], audit: List[PiiHit]) -> str: def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: """Rescan de sécurité : re-détecte les PII critiques qui auraient échappé au premier passage.""" + # Plan 1b (P1-2/F-2) — filet de sécurité aussi gaté par catégorie : une + # catégorie décochée ne doit pas être re-masquée ici (sinon la valeur, + # laissée en clair plus haut, serait masquée par le rescan). Vide ⇒ no-op + # byte-for-byte. Default-deny conservé pour tout kind non toggleable. + disabled = (cfg or {}).get("disabled_kinds") or set() # enlève TABLES du scope def strip_tables(s: str): kept = [] @@ -4345,33 +4436,38 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: # espacé soit consommé par RE_TEL. def _rescan_nir(m: re.Match) -> str: return PLACEHOLDERS["NIR"] if validate_nir(m.group(0)) else m.group(0) - protected = RE_NIR.sub(_rescan_nir, protected) - protected = RE_NIR_NO_KEY.sub(PLACEHOLDERS["NIR"], protected) # 13 chiffres label-ancré + if "NIR" not in disabled: + protected = RE_NIR.sub(_rescan_nir, protected) + protected = RE_NIR_NO_KEY.sub(PLACEHOLDERS["NIR"], protected) # 13 chiffres label-ancré # FAX avant TEL pour que le numéro de fax devienne [FAX] et non [TEL]. protected = RE_FAX.sub(PLACEHOLDERS["FAX"], protected) - protected = RE_TEL_SLASH.sub(PLACEHOLDERS["TEL"], protected) - protected = RE_TEL.sub(PLACEHOLDERS["TEL"], protected) - protected = RE_TEL_COMPACT.sub(PLACEHOLDERS["TEL"], protected) + if "TEL" not in disabled: + protected = RE_TEL_SLASH.sub(PLACEHOLDERS["TEL"], protected) + protected = RE_TEL.sub(PLACEHOLDERS["TEL"], protected) + protected = RE_TEL_COMPACT.sub(PLACEHOLDERS["TEL"], protected) protected = RE_IBAN.sub(PLACEHOLDERS["IBAN"], protected) # X-L2 — identifiants jusque-là non rescannés (fuite si vus 1 fois puis répétés) : protected = RE_RIB.sub(PLACEHOLDERS["IBAN"], protected) protected = RE_BIC.sub(PLACEHOLDERS["IBAN"], protected) protected = RE_ADELI.sub(PLACEHOLDERS["ADELI"], protected) protected = RE_OGC.sub(PLACEHOLDERS["OGC"], protected) - protected = RE_NUM_ADHERENT.sub(PLACEHOLDERS["ADHERENT"], protected) - protected = RE_NUM_MUTUELLE.sub(PLACEHOLDERS["ADHERENT"], protected) + if "ADHERENT" not in disabled: + protected = RE_NUM_ADHERENT.sub(PLACEHOLDERS["ADHERENT"], protected) + protected = RE_NUM_MUTUELLE.sub(PLACEHOLDERS["ADHERENT"], protected) # Nouvelles regex : dates de naissance, dates, adresses, codes postaux - protected = RE_DATE_NAISSANCE.sub(PLACEHOLDERS["DATE_NAISSANCE"], protected) + if "DATE_NAISSANCE" not in disabled: + protected = RE_DATE_NAISSANCE.sub(PLACEHOLDERS["DATE_NAISSANCE"], protected) # protected = RE_DATE.sub(PLACEHOLDERS["DATE"], protected) # désactivé - protected = RE_ADRESSE.sub(PLACEHOLDERS["ADRESSE"], protected) - protected = RE_ADRESSE_LIEU_DIT.sub(PLACEHOLDERS["ADRESSE"], protected) - protected = RE_BP.sub(PLACEHOLDERS["ADRESSE"], protected) def _rescan_code_postal(m: re.Match) -> str: if m.group(1): return _replace_captured_value(m.group(0), m.group(1), PLACEHOLDERS["CODE_POSTAL"]) return PLACEHOLDERS["CODE_POSTAL"] - protected = RE_CODE_POSTAL.sub(_rescan_code_postal, protected) + if "ADRESSE" not in disabled: + protected = RE_ADRESSE.sub(PLACEHOLDERS["ADRESSE"], protected) + protected = RE_ADRESSE_LIEU_DIT.sub(PLACEHOLDERS["ADRESSE"], protected) + protected = RE_BP.sub(PLACEHOLDERS["ADRESSE"], protected) + protected = RE_CODE_POSTAL.sub(_rescan_code_postal, protected) # N° Episode protected = RE_EPISODE.sub(PLACEHOLDERS["EPISODE"], protected) # N° venue / séjour @@ -4386,30 +4482,33 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: def _rescan_finess(m: re.Match) -> str: return PLACEHOLDERS["FINESS"] if m.group(1).upper() in _FINESS_NUMBERS else m.group(0) protected = RE_BARE_9DIGITS.sub(_rescan_finess, protected) - # Établissements (regex) - protected = RE_ETABLISSEMENT.sub(PLACEHOLDERS["ETAB"], protected) - protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected) - # Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs) - protected = _mask_finess_establishments(protected) - # Adresses (gazetteer Aho-Corasick FINESS — 28K noms de voie) - protected = _mask_finess_addresses(protected) - # Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT] - _re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]') - _spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS", - "CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE", - "CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE", - "EHPAD", "SSR", "USLD", "CHU", "CHRU"} - for m_sp in _re_spaced.finditer(protected): - collapsed = m_sp.group(0).replace(" ", "").upper() - if any(kw in collapsed for kw in _spaced_kw): - protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1) + # Établissements (regex + gazetteer + texte espacé) → catégorie ETAB. + if "ETAB" not in disabled: + protected = RE_ETABLISSEMENT.sub(PLACEHOLDERS["ETAB"], protected) + protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected) + # Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs) + protected = _mask_finess_establishments(protected) + # Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT] + _re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]') + _spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS", + "CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE", + "CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE", + "EHPAD", "SSR", "USLD", "CHU", "CHRU"} + for m_sp in _re_spaced.finditer(protected): + collapsed = m_sp.group(0).replace(" ", "").upper() + if any(kw in collapsed for kw in _spaced_kw): + protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1) + # Adresses (gazetteer Aho-Corasick FINESS — 28K noms de voie) → catégorie ADRESSE. + if "ADRESSE" not in disabled: + protected = _mask_finess_addresses(protected) # Villes (gazetteer Aho-Corasick — INSEE + FINESS) if _VILLE_AC is None: _build_ville_ac() if _VILLE_AC is not None: protected, _ = _mask_ville_gazetteers(protected) - # Services hospitaliers - protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) + # Services hospitaliers → catégorie ETAB. + if "ETAB" not in disabled: + protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) # Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules) _re_lieu_rescan = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)(\S.+)") protected = _re_lieu_rescan.sub(lambda m: m.group(1) + PLACEHOLDERS["VILLE"], protected) @@ -4433,20 +4532,21 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: if not clean: return raw return raw.replace(span, PLACEHOLDERS["NOM"]) - protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected) - # Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer - protected = RE_CIVILITE_INITIALE.sub( - lambda m: m.group(1) + PLACEHOLDERS["NOM"], protected - ) - # Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]" - _re_init_nom = re.compile(r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ])\.[\s\-]*(\[NOM\])') - protected = _re_init_nom.sub(r'[NOM] \2', protected) - # Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]" - _re_ref_init = re.compile(r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b') - protected = _re_ref_init.sub( - lambda m: m.group(0)[:m.group(0).index(m.group(1))] + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"], - protected, - ) + if "NOM" not in disabled: + protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected) + # Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer + protected = RE_CIVILITE_INITIALE.sub( + lambda m: m.group(1) + PLACEHOLDERS["NOM"], protected + ) + # Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]" + _re_init_nom = re.compile(r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ])\.[\s\-]*(\[NOM\])') + protected = _re_init_nom.sub(r'[NOM] \2', protected) + # Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]" + _re_ref_init = re.compile(r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b') + protected = _re_ref_init.sub( + lambda m: m.group(0)[:m.group(0).index(m.group(1))] + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"], + protected, + ) res = list(protected) for start, end, payload in kept: res[start:end] = list(payload) @@ -5056,10 +5156,14 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp # ----------------- VLM pour PDFs scannés ----------------- -def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: OcrWordMap, vlm_manager) -> None: +def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: OcrWordMap, vlm_manager, + disabled: Optional[Set[str]] = None) -> None: """Utilise un VLM (Ollama) pour détecter visuellement les PII sur chaque page d'un PDF scanné. Les entités détectées sont ajoutées à anon.audit et au texte pseudonymisé. - Auto-rotation : si une page a peu de mots OCR, essaie 4 orientations.""" + Auto-rotation : si une page a peu de mots OCR, essaie 4 orientations. + Plan 1b (P1-2/F-2) : gating PER-HIT via _category_of(kind). Une catégorie + décochée n'est ni ajoutée à l'audit ni masquée dans le texte. Default-deny.""" + disabled = disabled or set() from vlm_manager import VLM_CATEGORY_MAP doc = fitz.open(str(pdf_path)) # Collecter les PII déjà détectés pour contexte VLM @@ -5103,6 +5207,10 @@ def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: Oc if cat not in VLM_CATEGORY_MAP: continue kind, placeholder_key = VLM_CATEGORY_MAP[cat] + # Gating per-hit (F-2) : catégorie décochée → laisser en clair + # (ni audit, ni texte, ni raster). Default-deny si non toggleable. + if disabled and _category_of(kind) in disabled: + continue placeholder = PLACEHOLDERS.get(placeholder_key, PLACEHOLDERS["MASK"]) if cat in _SPLIT_CATS: @@ -5229,13 +5337,17 @@ def process_pdf( if ocr_used and vlm_manager is not None and VlmManager is not None: try: if vlm_manager.is_loaded(): - _apply_vlm_on_scanned_pdf(pdf_path, anon, ocr_word_map, vlm_manager) + _apply_vlm_on_scanned_pdf(pdf_path, anon, ocr_word_map, vlm_manager, + disabled=(cfg.get("disabled_kinds") or set())) _perf_mark("vlm_scan") except Exception: pass # dégradation gracieuse # 2) NER (optionnel) — sur le narratif final_text = anon.text_out + # Plan 1b (P1-2/F-2) — catégories décochées pour les passes post-masquage + # (cleanups + propagation globale). Vide ⇒ no-op byte-for-byte. + _disabled_cats = cfg.get("disabled_kinds") or set() hf_hits: List[PiiHit] = [] if use_hf and ner_manager is not None and ner_manager.is_loaded(): # Détecter le type de manager et appeler la bonne fonction @@ -5263,7 +5375,8 @@ def process_pdf( return m.group(0) anon.audit.append(PiiHit(-1, "NOM_GLOBAL", tok, PLACEHOLDERS["NOM"])) return m.group(1) + PLACEHOLDERS["NOM"] - final_text = _re_nom_orphan.sub(_clean_nom_orphan, final_text) + if "NOM" not in _disabled_cats: + final_text = _re_nom_orphan.sub(_clean_nom_orphan, final_text) # 3b) Nettoyage post-masquage : codes postaux orphelins (5 chiffres collés à un placeholder) # et téléphones fragmentés sur plusieurs lignes @@ -5271,7 +5384,8 @@ def process_pdf( def _clean_cp_orphan(m): anon.audit.append(PiiHit(-1, "CODE_POSTAL", m.group(2), PLACEHOLDERS["CODE_POSTAL"])) return m.group(1) + PLACEHOLDERS["CODE_POSTAL"] - final_text = _re_cp_orphan.sub(_clean_cp_orphan, final_text) + if "ADRESSE" not in _disabled_cats: # CODE_POSTAL suit le toggle ADRESSE + final_text = _re_cp_orphan.sub(_clean_cp_orphan, final_text) # Téléphones fragmentés : "0X XX XX XX\nXX" coupé en fin de ligne (ligne suivante immédiate) _re_tel_frag = re.compile(r"((?:\+33\s?|0)\d(?:[ .-]?\d){6,7})\s*\n\s*(\d{2}(?!\d))") @@ -5281,8 +5395,6 @@ def process_pdf( anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"])) return PLACEHOLDERS["TEL"] + "\n" return m.group(0) - final_text = _re_tel_frag.sub(_clean_tel_frag, final_text) - # Téléphones incomplets en fin de ligne (8 ou 9 chiffres au format 0X XX XX XX) : masquer la partie visible _re_tel_partial = re.compile(r"(?