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:
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user