"""Assainissement PII des données capturées (titres de fenêtre, texte saisi, OCR). Côté serveur. Remplace la PII par des **tokens typés et cohérents** (`[IPP_1]`, `[AGE_1]`, `[NOM_1]`…) : on protège la donnée **et** on garde la structure (champ de type NOM/IPP) utile à l'apprentissage des variables. Couche 1 (ce module, sans modèle) : filet **regex** sur la PII structurée (IPP, NIR, téléphone, email, âge) + règles **structurelles** des titres cliniques (`NOM (NAISSANCE) Prénom`, `[Nom Prénom]` des fenêtres PACS). Regex réutilisées du projet `anonymisation`. Couche 2 (à venir) : NER CamemBERT-bio (ONNX) pour les noms libres que la couche 1 ne capte pas — branchée plus tard, ce module marche sans. Branche feat/push-log-dgx — assainissement PII clinique. """ from __future__ import annotations import copy import re from typing import Dict, List, Optional, Tuple # --- Filet regex (réutilisé de anonymisation/anonymizer_core_refactored_onnx.py) --- RE_IPP = re.compile(r"\b(?:I\.?P\.?P\.?|IPP|N°\s*Ipp)\s*[:\-]?\s*([A-Za-z0-9]{6,})\b", re.IGNORECASE) RE_NIR = re.compile(r"(? str: """Clé de cohérence : même entité -> même token.""" if etype in ("IPP", "NIR", "TEL"): return re.sub(r"\s+", "", value) if etype == "EMAIL": return value.lower() return re.sub(r"\s+", " ", value).strip().upper() def _is_blacklisted_name(value: str) -> bool: toks = [t for t in re.split(r"[^\wÀ-ÿ]+", value) if t] return bool(toks) and all(t.upper() in _SOFTWARE_BLACKLIST for t in toks) def _assign_token(mapping: Dict, etype: str, norm: str) -> str: key = (etype, norm) if key in mapping: return mapping[key] n = 1 + sum(1 for k in mapping if isinstance(k, tuple) and k[0] == etype) token = f"[{etype}_{n}]" mapping[key] = token return token def anonymize_text( text: str, *, mapping: Optional[Dict] = None ) -> Tuple[str, List[Dict]]: """Remplace la PII de `text` par des tokens typés cohérents. `mapping` : table de cohérence partagée (ex. à l'échelle d'une session) — la même valeur PII reçoit le même token d'un appel à l'autre. Mutée en place ; si None, une table locale est utilisée. Retourne `(texte_assaini, entités)` où chaque entité = `{"type", "original", "token", "start", "end"}` (positions dans le texte source). """ if not text: return text, [] if mapping is None: mapping = {} # 1) collecte des candidats (start, end, type, valeur) spans: List[Tuple[int, int, str, str]] = [] for pattern, etype, group in _DETECTORS: for m in pattern.finditer(text): start, end = m.span(group) if start == end: continue value = m.group(group) if etype == "NOM" and _is_blacklisted_name(value): continue spans.append((start, end, etype, value)) # 2) résolution des chevauchements (priorité = rang détecteur, puis -longueur) # _DETECTORS est ordonné par priorité ; le rang dans cette liste détermine # qui gagne quand deux patterns chevauchent. Plus prioritaire + plus long # = accepté en premier, les plus courts/moins prioritaires sont éliminés. # Fix FN « Dossier VIOLA (VIOLA) Liliane » : RE_PRENOM_NOM captait # « Dossier VIOLA » (rang 2) et bloquait RE_NOM_NAISSANCE « VIOLA (VIOLA) # Liliane » (rang 0, plus prioritaire et plus long). det_rank = {p: i for i, (p, _, _) in enumerate(_DETECTORS)} spans.sort(key=lambda s: (det_rank.get(s[2], 999), -(s[1] - s[0]), s[0])) occupied: List[Tuple[int, int]] = [] accepted: List[Tuple[int, int, str, str]] = [] for start, end, etype, value in spans: if all(start >= oe or end <= os for os, oe in occupied): accepted.append((start, end, etype, value)) occupied.append((start, end)) # 3) substitution (de droite à gauche pour préserver les indices) entities: List[Dict] = [] out = text for start, end, etype, value in sorted(accepted, key=lambda s: s[0], reverse=True): token = _assign_token(mapping, etype, _normalize(etype, value)) out = out[:start] + token + out[end:] entities.append( {"type": etype, "original": value, "token": token, "start": start, "end": end} ) entities.reverse() return out, entities # Clés portant un titre de fenêtre, où qu'elles soient imbriquées dans l'event # (top-level `active_window_title`, `window/to/from.title`, et surtout # `vision_info.window_capture.window_title` — blind spot signalé par Qwen). _TITLE_KEYS = ("title", "window_title", "active_window_title") _PLACEHOLDER_SAISIE = "[SAISIE]" def _walk_titles(obj, mapping: Dict) -> None: """Parcourt récursivement l'event et assainit toute valeur de titre de fenêtre.""" if isinstance(obj, dict): for k, v in obj.items(): if k in _TITLE_KEYS and isinstance(v, str): obj[k] = anonymize_text(v, mapping=mapping)[0] else: _walk_titles(v, mapping) elif isinstance(obj, list): for item in obj: _walk_titles(item, mapping) def sanitize_event(event: Dict, *, mapping: Optional[Dict] = None) -> Dict: """Assainit un event capturé avant persistance (copie, ne mute pas l'original). Principe « Léa apprend l'interface, pas la donnée » (décision Dom 28/06) : - `text_input` : le **contenu tapé** (`text`, `raw_keys`) = donnée de santé → remplacé par `[SAISIE]` (on garde le champ, pas la valeur — option b) ; - **titres de fenêtre** (`active_window_title`, et `title` dans `window`/`to`/ `from`) : l'**identité patient** est tokenisée, l'app/écran est gardé (contexte d'apprentissage), via `anonymize_text` + `mapping` partagé (cohérence). """ if mapping is None: mapping = {} ev = copy.deepcopy(event) # text_input : on ne garde pas le contenu if ev.get("type") == "text_input": for k in ("text", "raw_keys"): if ev.get(k) not in (None, ""): ev[k] = _PLACEHOLDER_SAISIE # tous les titres de fenêtre, où qu'ils soient imbriqués # (active_window_title, window/to/from.title, vision_info.window_capture.window_title…) _walk_titles(ev, mapping) return ev def sanitize_log_entries( entries: List[Dict], *, mapping: Optional[Dict] = None ) -> List[Dict]: """Assainit un batch de log-entries reçues d'un client Léa avant persistance. Pour chaque entrée, renvoie une **copie** où les champs texte porteurs de PII sont passés par `anonymize_text` : - `message` (str) : assaini par `anonymize_text`. - `logger` (str) : assaini de la même façon (peut porter un chemin patient). - `ts` et `level` : préservés à l'identique, jamais touchés. Un `mapping` partagé est utilisé pour **toutes** les entrées du batch afin de garantir la cohérence des tokens (même PII → même token). Si `mapping` est None, un mapping local est créé et partagé entre toutes les entrées du batch. Tolère les valeurs absentes, None ou non-str sans lever d'exception. N'utilise que `anonymize_text` — aucune regex supplémentaire. """ if not entries: return [] if mapping is None: mapping = {} result: List[Dict] = [] for entry in entries: item = copy.copy(entry) # copie superficielle suffit (valeurs scalaires) for field in ("message", "logger"): v = item.get(field) if isinstance(v, str): item[field] = anonymize_text(v, mapping=mapping)[0] result.append(item) return result # Clés d'un workflow core portant du texte potentiellement PII : cible OCR # (`by_text`), noms d'écrans/labels dérivés des titres. Le contenu saisi est # déjà neutralisé à la source (sanitize_event → [SAISIE]). _WORKFLOW_TEXT_KEYS = ("by_text", "name", "label") def _walk_workflow_text(obj, mapping: Dict) -> None: """Parcourt un workflow core et tokenise la PII des champs texte (cibles, noms).""" if isinstance(obj, dict): for k, v in obj.items(): if k in _WORKFLOW_TEXT_KEYS and isinstance(v, str) and v: obj[k] = anonymize_text(v, mapping=mapping)[0] else: _walk_workflow_text(v, mapping) elif isinstance(obj, list): for item in obj: _walk_workflow_text(item, mapping) def sanitize_workflow_dict(workflow_dict: Dict, *, mapping: Optional[Dict] = None) -> Dict: """Assainit un workflow core (JSON appris) avant import/persistance en DB VWB. Tokenise la PII des champs texte (cible OCR `by_text`, noms d'écrans, labels) via `anonymize_text`, en gardant l'interface intacte (« Léa apprend l'interface, pas la donnée »). Copie — l'original n'est pas muté. Limite (couche 1) : ne capte que la PII structurée (IPP, NOM clinique…) ; les noms libres relèvent de la couche 2 NER. """ if mapping is None: mapping = {} wf = copy.deepcopy(workflow_dict) _walk_workflow_text(wf, mapping) return wf