feat(server): sanitize_event — assainissement PII au niveau event

sanitize_event(event, mapping) applique le principe « Léa apprend l'interface,
pas la donnée » (décision Dom 28/06) avant persistance :
- text_input -> contenu (text + raw_keys) remplacé par [SAISIE] (option b) :
  résout la fuite la plus grave (contenu médical) SANS NER ni détection ;
- titres de fenêtre (active_window_title + window/to/from.title) : identité
  patient tokenisée (anonymize_text), app/écran gardés ; cohérence par mapping.
Copie défensive (ne mute pas l'event d'origine). 4 tests (9 au total) verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-28 19:53:09 +02:00
parent 8e4d09594c
commit 30d8f65e9a
2 changed files with 98 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ Branche feat/push-log-dgx — assainissement PII clinique.
from __future__ import annotations
import copy
import re
from typing import Dict, List, Optional, Tuple
@@ -131,3 +132,43 @@ def anonymize_text(
)
entities.reverse()
return out, entities
# Conteneurs de titre de fenêtre dans les events (window_focus_change, clic, saisie).
_TITLE_CONTAINERS = ("window", "to", "from")
_PLACEHOLDER_SAISIE = "[SAISIE]"
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
# titre direct (heartbeat)
if isinstance(ev.get("active_window_title"), str):
ev["active_window_title"] = anonymize_text(
ev["active_window_title"], mapping=mapping
)[0]
# titres imbriqués (window / to / from)
for key in _TITLE_CONTAINERS:
sub = ev.get(key)
if isinstance(sub, dict) and isinstance(sub.get("title"), str):
sub["title"] = anonymize_text(sub["title"], mapping=mapping)[0]
return ev