feat(core): signature de trajectoire PII-safe + normalisée (R1/R2 amendés, QG Qwen)

Anonymisation déterministe de la cible par regex DÉDIÉES (email/date/tél/IPP →
tokens) avant hashing : deux sessions sur le même champ (patients/dates
différents) → même signature. Normalisation casse/accents/espaces (logique
action_executor._norm_text, redéfinie localement pour rester léger).

Choix QG Qwen (2026-06-25) : PAS de pii_blur (il protège les dates qu'on veut
neutraliser), PAS de NER (un hash d'identité doit être déterministe/portable
labo↔DGX). Noms propres sans titre non gérés (stratégie b ; gate = audit
agrégat by_text DGX avant prod). R2 fallback coords RETIRÉ (casserait F1).
R3 (machine_id hors hash) déjà conforme.

TDD: +4 tests (RED→GREEN, 9/9). Primitive non wirée (0 consommateur runtime)
→ changement de calcul sans impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-25 10:47:18 +02:00
parent c9b7cdabb7
commit 3b592dd867
2 changed files with 87 additions and 1 deletions

View File

@@ -11,15 +11,59 @@ passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `
"""
import hashlib
import re
import unicodedata
from typing import Any, Iterable, Mapping
_FIELD_SEP = "\x1f" # sépare action_type et target dans une étape
_STEP_SEP = "\x1e" # sépare les étapes
# --- Cible stable : anonymisation PII + normalisation déterministes ----------
# Verdict QG Qwen (2026-06-25) : regex DÉDIÉES à la signature (PAS `pii_blur`,
# qui protège les dates alors qu'ici on les NEUTRALISE), PAS de NER (un hash
# d'identité doit être déterministe et identique labo↔DGX, donc indépendant
# d'un modèle versionné). Les noms propres sans titre ne sont pas neutralisés
# ici (stratégie « (b) » : impact 0 sur l'audit labo ; gate = audit agrégat
# `by_text` DGX avant prod, ajouter une regex ciblée si des noms apparaissent).
_WS_RE = re.compile(r"\s+")
# Ordre d'application : motifs structurés d'abord, identifiant numérique long
# en dernier (sinon il mangerait des fragments de date/téléphone).
_RE_EMAIL = re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
_RE_DATE = re.compile(r"\b\d{1,4}[/.\-]\d{1,2}[/.\-]\d{1,4}\b")
_RE_PHONE = re.compile(r"\b(?:\+?33|0)\s?[1-9](?:[\s.\-]?\d{2}){4}\b")
_RE_LONGNUM = re.compile(r"\d{6,}") # IPP / NIR collé / autre identifiant long
def _anonymize_pii(text: str) -> str:
"""Neutralise la PII structurée par des tokens stables : deux sessions sur le
même champ (patients/dates différents) → même texte cible → même signature."""
text = _RE_EMAIL.sub("[email]", text)
text = _RE_DATE.sub("[date]", text)
text = _RE_PHONE.sub("[tel]", text)
text = _RE_LONGNUM.sub("[ipp]", text)
return text
def _norm_text(text: str) -> str:
"""Normalisation déterministe (même logique que `action_executor._norm_text`,
redéfinie ici pour garder ce module léger et sans effet de bord d'import) :
minuscules, suppression des accents (NFKD), espaces normalisés."""
if not text:
return ""
text = text.replace(" ", " ").strip().lower()
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return _WS_RE.sub(" ", text).strip()
def _normalize_target(target: str) -> str:
"""Cible stable : PII neutralisée PUIS normalisée (casse/accents/espaces)."""
return _norm_text(_anonymize_pii(target))
def _normalize_step(step: Mapping[str, Any]) -> str:
action_type = str(step.get("action_type", "unknown")).strip().lower()
target = str(step.get("target", "")).strip()
target = _normalize_target(str(step.get("target", "")))
return f"{action_type}{_FIELD_SEP}{target}"

View File

@@ -57,3 +57,45 @@ def test_returns_sha256_hex():
sig = trajectory_signature([{"action_type": "mouse_click", "target": "x"}])
assert len(sig) == 64
assert all(c in "0123456789abcdef" for c in sig)
# ---------------------------------------------------------------------------
# R1/R2 amendés — verdict Qwen 2026-06-25 : normalisation déterministe + PII
# neutralisée par regex DÉDIÉES (pas de pii_blur, pas de NER). Stabilité
# labo/DGX = portabilité de la signature. Noms sans titre : stratégie (b)
# (impact 0 en labo, gate = audit agrégat DGX avant prod).
# ---------------------------------------------------------------------------
def test_target_normalized_case_and_accents():
"""Q2 : casse et accents ne changent pas la signature (même cible sémantique)."""
a = [{"action_type": "mouse_click", "target": "Valider"}]
b = [{"action_type": "mouse_click", "target": "VALIDER"}]
c = [{"action_type": "mouse_click", "target": "validér"}]
assert trajectory_signature(a) == trajectory_signature(b) == trajectory_signature(c)
def test_pii_ipp_neutralized():
"""R1 : deux IPP différents sur le même champ → MÊME signature (PII neutralisée).
Et une cible sans identifiant reste discriminée."""
a = [{"action_type": "mouse_click", "target": "Patient IPP 25012257"}]
b = [{"action_type": "mouse_click", "target": "Patient IPP 30045678"}]
assert trajectory_signature(a) == trajectory_signature(b)
c = [{"action_type": "mouse_click", "target": "Patient liste"}]
assert trajectory_signature(a) != trajectory_signature(c)
def test_pii_date_neutralized():
"""R1 : deux dates différentes → MÊME signature."""
a = [{"action_type": "mouse_click", "target": "RDV du 12/05/2026"}]
b = [{"action_type": "mouse_click", "target": "RDV du 03/11/2025"}]
assert trajectory_signature(a) == trajectory_signature(b)
def test_pii_phone_and_email_neutralized():
"""R1 : téléphone (FR) et email neutralisés (deux valeurs distinctes → même sig)."""
tel_a = [{"action_type": "text_input", "target": "tel 06 12 34 56 78"}]
tel_b = [{"action_type": "text_input", "target": "tel 07 98 76 54 32"}]
assert trajectory_signature(tel_a) == trajectory_signature(tel_b)
mail_a = [{"action_type": "text_input", "target": "mail jean.dupont@chu.fr"}]
mail_b = [{"action_type": "text_input", "target": "mail m.martin@chu.fr"}]
assert trajectory_signature(mail_a) == trajectory_signature(mail_b)