From 3b592dd867fd9ed131f45e893ef9067c20ba14ed Mon Sep 17 00:00:00 2001 From: Dom Date: Thu, 25 Jun 2026 10:47:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20signature=20de=20trajectoire=20PI?= =?UTF-8?q?I-safe=20+=20normalis=C3=A9e=20(R1/R2=20amend=C3=A9s,=20QG=20Qw?= =?UTF-8?q?en)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/execution/trajectory_signature.py | 46 ++++++++++++++++++++++++- tests/unit/test_trajectory_signature.py | 42 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/core/execution/trajectory_signature.py b/core/execution/trajectory_signature.py index e4555c370..ac4c6ede0 100644 --- a/core/execution/trajectory_signature.py +++ b/core/execution/trajectory_signature.py @@ -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}" diff --git a/tests/unit/test_trajectory_signature.py b/tests/unit/test_trajectory_signature.py index 9417087ce..4d0929b6c 100644 --- a/tests/unit/test_trajectory_signature.py +++ b/tests/unit/test_trajectory_signature.py @@ -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)