14 Commits

Author SHA1 Message Date
Dom
8e4d09594c feat(server): assainissement PII couche regex+structurelle (tokens typés cohérents)
pii_sanitizer.anonymize_text() remplace la PII par des tokens typés et
cohérents ([IPP_1], [AGE_1], [NOM_1]) : protège la donnée ET garde la structure
(type de champ) utile à l'apprentissage des variables. Sans modèle, déployable
partout. Filet regex (IPP/NIR/TEL/EMAIL/AGE, repris de anonymisation) + règles
structurelles cliniques (NOM (NAISSANCE) Prénom ; [Nom Prénom] PACS) + blacklist
logiciels anti-FP. 5 tests verts. Couche NER (noms libres) en complément ensuite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:08:43 +02:00
Dom
46ad5973d1 fix(agent_v1): assainissement PII des logs client a la source (push-log-DGX, brique 4)
Remplace dans les logs/print le contenu utilisateur brut par un equivalent
PII-safe via core/log_safe : titres de fenetre -> _title_hash, reponses VLM ->
[len,has_target], metadonnees -> _sanitize_metadata, chemins -> _path_ext,
workflow_name -> _title_hash. 8 fichiers (executor, recovery, captor, streamer,
main, capture_server, activity_panel, window_info_crossplatform).

Audit Qwen complete : ~17 fuites de titre multi-lignes + 2e fuite VLM (print)
non listees ont ete traitees ; localisation par contenu (refs Qwen derivees).

Preserve volontairement : prompts de grounding VLM (vlm_description) ou le titre
est load-bearing (resolution 100% vision) -> ne PAS hasher.
Differe : window_focus_change (verdict apprentissage).
En attente arbitrage Dom : button_text (~11 captions), patterns, champs detail.

py_compile 8/8 OK, imports OK, helper 6/6 vert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:42:40 +02:00
Dom
4a38000e74 feat(agent_v1): helpers logging PII-safe (push-log-DGX, brique 4)
Module agent_v1/core/log_safe.py — 3 helpers purs pour assainir les logs
client à la source : _title_hash (SHA1[:8], corrélation sans révéler),
_sanitize_metadata (drop title/active_window/window_title), _path_ext
(extension seule). 6 tests unitaires verts. Module inerte (non encore wired) ;
le branchement dans le code runtime suit en étape supervisée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:24:54 +02:00
Dom
2597ca9110 feat(server): endpoint GET /api/v1/agents/logs/{machine_id} (push-log-DGX, brique 3)
Route de diagnostic dashboard (read-only) : restitue les logs poussés par un
poste, rangés par machine_id. Bearer global ; volontairement sans garde fleet
(consultation d'un poste révoqué/en panne). limit=tail pour borner la réponse.
4 tests d'intégration verts ; store inchangé (briques 1-2 figées).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:47:08 +02:00
Dom
bbe897e614 feat(server): endpoint POST /api/v1/agents/logs (push-log-DGX, brique 2)
Reçoit un batch de logs client, range via AgentLogsStore par machine_id.
Garde-fous : auth Bearer (401), agent actif via _guard_agent_registry_access
(403 si révoqué/inconnu, + touch_last_seen), cap anti-flood 413 (G3 Qwen,
RPA_AGENT_LOGS_MAX_BATCH=1000). TDD 4/4 ; non-régression enroll 16/16.

refs DETTE-020 DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:25:14 +02:00
Dom
a29b7a2f21 feat(server): store de logs clients par machine_id (push-log-DGX, brique 1)
AgentLogsStore : append/read JSONL rangés par machine_id (fichier par jour),
anti path-traversal sur machine_id (entrée réseau), purge_old rétention 30j
(garde-fou G4 Qwen). TDD 3/3 vert. Pas encore wired (endpoint = brique 2).

refs DETTE-020 DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:14:28 +02:00
Dom
105ade959d chore(agent_v1): AGENT_VERSION configurable via RPA_AGENT_VERSION (amorce DETTE-022)
Permet d'identifier la version déployée par poste (préparation MAJ auto).
Inoffensif pour DETTE-021 ; nettoie le working tree avant déploiement Émilie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:50:58 +02:00
Dom
29cb466595 fix(lea): journalisation client vers fichier (DETTE-021)
setup_logging() branche un TimedRotatingFileHandler vers LOG_FILE (rotation
quotidienne + rétention 180j, Règlement IA Art.12) + console. Sous pythonw
(sans console), basicConfig->stderr était perdu => diagnostic terrain aveugle.
main.py appelle setup_logging au démarrage, avec fallback console si le fichier
est indisponible (ne jamais empêcher Léa de démarrer).

TDD: tests/unit/test_agent_v1_logging.py (3 tests RED->GREEN ; module chargé par
chemin pour éviter les imports lourds DETTE-011/013). py_compile main.py OK.

refs DETTE-021

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:44:31 +02:00
Dom
de73cbd404 docs(dette): DETTE-021 (logs client Léa non effectifs) + DETTE-022 (MAJ auto Léa)
DETTE-021: LOG_FILE défini mais jamais branché (basicConfig->stderr perdu sous
pythonw, dossier logs vide) -> diagnostic terrain aveugle + non-conformité
Règlement IA Art.12 (180j). Pendant client du DETTE-020.
DETTE-022: modif client = redéploiement manuel poste par poste -> dérange les
TIM, ne scale pas. Besoin MAJ auto/tâche de fond. Décision Dom 2026-06-25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:32:32 +02:00
Dom
1b491326be docs(dette): DETTE-020 (P1) — incidents silencieux, pas d'alerte composant critique HS
Grounder vLLM (rpa-vllm-grounder) trouvé en crash-loop (×3960) → bascule
silencieuse sur fallback Qwen2.5-VL, sans remontée dashboard/log/alerte.
Découvert par vérif manuelle runtime (DGX clinique, 2026-06-25). Dette = absence
de supervision/alerte des composants critiques (vLLM/Ollama/services rpa-*) ;
la cause SSL/offline du crash se corrige à part.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:37:18 +02:00
Dom
3b592dd867 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>
2026-06-25 10:47:18 +02:00
Dom
c9b7cdabb7 fix(core): signature de trajectoire stable malgre le moteur de grounding (by_text)
Some checks failed
tests / Lint (ruff + black) (push) Failing after 1m53s
tests / Tests unitaires (sans GPU) (push) Failing after 1m49s
tests / Tests sécurité (critique) (push) Has been skipped
Le champ by_role remontait la methode de detection (yolo/ocr/vlm), instable entre
sessions : deux apprentissages du meme parcours detectes differemment produisaient
deux signatures -> fusion (create-or-update) ratee. On sort by_role de la signature
et on s'appuie sur le texte semantique de la cible (by_text), independant du moteur
de grounding. Fallback quand by_text vide : titre de fenetre / description VLM.

Test TDD: test_signature_stable_despite_grounding_role_difference (RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:35:57 +02:00
Dom
74df0822e2 feat(core): adaptateur workflow->signature de trajectoire (BFS edges, cibles stables)
Extrait d'un workflow core (dict) la sequence ordonnee (action_type, target stable)
via traversee BFS depuis entry_nodes (comme le bridge d'import), en n'utilisant que
des champs stables (by_role/by_text/window) et en ignorant coords/IDs de noeuds.
Branche la primitive trajectory_signature sur de vrais workflows.

Test TDD: tests/unit/test_workflow_trajectory_signature.py (3 tests, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:22:30 +02:00
Dom
a86c1ebb83 feat(core): signature de trajectoire stable pour identite workflow (Phase 0, F1)
Primitive partagee (SP-4/SP-2/competences) : hashe la sequence ordonnee
(action_type, target) d'un parcours en ignorant les champs session-specifiques
(node_id, timestamp, coordonnees) -> deux apprentissages du meme parcours = meme
signature = base du create-or-update (decision F1). Le target stable peut etre
compose avec screen_signature() existante.

Test TDD: tests/unit/test_trajectory_signature.py (5 tests, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:14:23 +02:00
23 changed files with 1319 additions and 67 deletions

View File

@@ -27,7 +27,7 @@ if platform.system() == "Windows":
except Exception:
pass
AGENT_VERSION = "1.0.1"
AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")
# Identifiant unique de la machine (utilisé pour le multi-machine)
# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS

View File

@@ -32,6 +32,7 @@ from pynput.keyboard import Key, KeyCode
# Importation relative pour rester dans le module v1
from ..vision.capturer import VisionCapturer
from ..vision.system_info import get_screen_metadata
from .log_safe import _sanitize_metadata
# from ..monitoring.system import SystemMonitor
logger = logging.getLogger(__name__)
@@ -676,7 +677,7 @@ class EventCaptorV1:
metadata = get_screen_metadata()
with self._screen_metadata_lock:
self._screen_metadata = metadata
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
logger.debug(f"Métadonnées système rafraîchies : {_sanitize_metadata(metadata)}")
except Exception as e:
logger.error(f"Erreur refresh métadonnées système : {e}")

View File

@@ -26,6 +26,7 @@ from typing import Any, Dict, Optional
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
# Sans cela, pynput et mss utilisent des coordonnees logiques (virtualisees).
from ..config import MACHINE_ID as _ # noqa: F401 — side-effect import
from .log_safe import _title_hash
import mss
from pynput.mouse import Button, Controller as MouseController
@@ -862,7 +863,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
f"fenetre -> bouton '{button_text}' "
f"[{resolved.get('method', 'server')}]"
)
@@ -890,7 +891,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
f"fenetre -> bouton '{button_text}' [dialog_window_text_template]"
)
return handled
@@ -917,7 +918,7 @@ class ActionExecutorV1:
)
if handled:
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere par geometrie "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere par geometrie "
f"fenetre -> bouton '{button_text}'"
)
return handled
@@ -967,7 +968,7 @@ class ActionExecutorV1:
if not handled:
continue
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere via serveur "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere via serveur "
f"-> bouton '{button_text}' [{resolved.get('method', 'server')}]"
)
return handled
@@ -992,13 +993,13 @@ class ActionExecutorV1:
if not handled:
continue
logger.info(
f"[RUNTIME-DIALOG] '{current_title}' gere localement "
f"[RUNTIME-DIALOG] [title_hash={_title_hash(current_title)}] gere localement "
f"-> bouton '{button_text}' [dialog_text_template]"
)
return handled
logger.info(
f"[RUNTIME-DIALOG] Aucun bouton resolu pour '{current_title}'"
f"[RUNTIME-DIALOG] Aucun bouton resolu pour [title_hash={_title_hash(current_title)}]"
)
return None
@@ -1258,7 +1259,7 @@ class ActionExecutorV1:
if dialog_spec.get("skip_current_action_after_handle", False):
logger.info(
f"[RUNTIME-DIALOG] Dialogue '{current_title}' gere -> "
f"[RUNTIME-DIALOG] Dialogue [title_hash={_title_hash(current_title)}] gere -> "
f"action {action.get('action_id', 'unknown')} skippée"
)
return {
@@ -1587,7 +1588,7 @@ class ActionExecutorV1:
]
for pattern in popup_patterns:
if pattern in current_title:
logger.info(f"Observer : popup détectée par titre — '{current_title}'")
logger.info(f"Observer : popup détectée par titre — [title_hash={_title_hash(current_title)}]")
# On ne peut pas résoudre les coords juste par le titre
# → retourner popup sans coords, le caller fera handle_popup_vlm()
return {
@@ -1874,8 +1875,8 @@ class ActionExecutorV1:
)
else:
logger.warning(
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
f"actuel '{current_title}'"
f"[LEA] Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
f"actuel [title_hash={_title_hash(current_title)}]"
)
auto_result = self._maybe_handle_runtime_dialog_before_pause(
action=action,
@@ -1888,8 +1889,8 @@ class ActionExecutorV1:
if auto_result is not None:
return auto_result
print(
f" [PRÉ-VÉRIF] Fenêtre '{current_title}'"
f"attendu '{expected_title}' → mode apprentissage"
f" [PRÉ-VÉRIF] Fenêtre [title_hash={_title_hash(current_title)}]"
f"attendu [title_hash={_title_hash(expected_title)}] → mode apprentissage"
)
try:
self.notifier.replay_learning_mode(
@@ -1936,8 +1937,8 @@ class ActionExecutorV1:
# des coordonnées devenues invalides.
result["success"] = False
result["error"] = (
f"Fenêtre incorrecte : attendu '{expected_title}', "
f"actuel '{current_title}'"
f"Fenêtre incorrecte : attendu [title_hash={_title_hash(expected_title)}], "
f"actuel [title_hash={_title_hash(current_title)}]"
)
result["warning"] = "wrong_window"
result["target_description"] = expected_title
@@ -1945,11 +1946,11 @@ class ActionExecutorV1:
result["screenshot"] = self._capture_screenshot_b64()
logger.warning(
f"[LEA] Wrong window sans correction → pause "
f"(attendu '{expected_title}', actuel '{current_title}')"
f"(attendu [title_hash={_title_hash(expected_title)}], actuel [title_hash={_title_hash(current_title)}])"
)
return result
else:
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
logger.info(f"[LEA] Pré-vérif OK : [title_hash={_title_hash(current_title)}]")
# ── OBSERVER : pré-analyse écran avant résolution ──
# Détecte popups, dialogues, états inattendus AVANT de chercher la cible.
@@ -1964,8 +1965,8 @@ class ActionExecutorV1:
# Popup détectée AVANT la résolution — la fermer
popup_label = observation.get("popup_label", "popup")
popup_coords = observation.get("popup_coords")
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
print(f" [OBSERVER] Popup détectée : [title_hash={_title_hash(popup_label)}] — fermeture")
logger.info(f"Observer : popup [title_hash={_title_hash(popup_label)}] détectée avant résolution")
# ── SÉCURITÉ : refuser de cliquer sur un dialogue système ──
# Avant de suivre les coordonnées du serveur (VLM-based,
@@ -2365,8 +2366,8 @@ class ActionExecutorV1:
recheck_title = recheck_info.get("title", "")
if not _matches_expected_window(recheck_title):
logger.warning(
f"P0.9 transition instable : matched '{post_title}' "
f"puis '{recheck_title}' à T+0.5s ≠ '{expected_after}'"
f"P0.9 transition instable : matched [title_hash={_title_hash(post_title)}] "
f"puis [title_hash={_title_hash(recheck_title)}] à T+0.5s ≠ [title_hash={_title_hash(expected_after)}]"
)
matched = False
post_title = recheck_title
@@ -2376,19 +2377,19 @@ class ActionExecutorV1:
result["runtime_dialog"] = runtime_dialog_handled
print(
f" [POST-VÉRIF] Dialogue runtime géré "
f"→ retour '{post_title}'"
f"→ retour [title_hash={_title_hash(post_title)}]"
)
logger.info(
"POST-VÉRIF runtime dialog géré : '%s' -> '%s'",
runtime_dialog_handled.get("dialog_title", ""),
post_title,
"POST-VÉRIF runtime dialog géré : [title_hash=%s] -> [title_hash=%s]",
_title_hash(runtime_dialog_handled.get("dialog_title", "")),
_title_hash(post_title),
)
else:
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — '{post_title}'")
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : '{post_title}'")
print(f" [POST-VÉRIF] OK en {elapsed_wait:.1f}s — [title_hash={_title_hash(post_title)}]")
logger.info(f"POST-VÉRIF OK en {elapsed_wait:.1f}s : [title_hash={_title_hash(post_title)}]")
else:
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — '{post_title}''{expected_after}'")
logger.warning(f"POST-VÉRIF TIMEOUT : '{post_title}''{expected_after}'")
print(f" [POST-VÉRIF] TIMEOUT {max_wait}s — [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
logger.warning(f"POST-VÉRIF TIMEOUT : [title_hash={_title_hash(post_title)}] ≠ [title_hash={_title_hash(expected_after)}]")
if runtime_dialog_handled:
result["warning"] = (
f"runtime_dialog_handled_post_verify:{post_title}"
@@ -2396,9 +2397,9 @@ class ActionExecutorV1:
result["runtime_dialog"] = runtime_dialog_handled
logger.warning(
"POST-VÉRIF runtime dialog géré mais "
"fenêtre finale inattendue : '%s''%s'",
post_title,
expected_after,
"fenêtre finale inattendue : [title_hash=%s] ≠ [title_hash=%s]",
_title_hash(post_title),
_title_hash(expected_after),
)
# Contrôle strict : si success_strict, on STOP.
# On durcit aussi les vrais changements de fenêtre
@@ -2416,8 +2417,8 @@ class ActionExecutorV1:
if bool(action.get("success_strict")) or requires_transition:
result["success"] = False
result["error"] = (
f"Post-vérif échouée : fenêtre '{post_title}' "
f"au lieu de '{expected_after}'"
f"Post-vérif échouée : fenêtre [title_hash={_title_hash(post_title)}] "
f"au lieu de [title_hash={_title_hash(expected_after)}]"
)
result["warning"] = "wrong_window"
result["needs_human"] = True
@@ -2458,7 +2459,7 @@ class ActionExecutorV1:
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
self._type_text(text, paste=bool(action.get("paste", False)))
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
logger.info(f"Replay type : [{len(text)} chars] (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "key_combo":
keys = action.get("keys", [])
@@ -2524,12 +2525,12 @@ class ActionExecutorV1:
if not self._window_title_matches_any(current_title, patterns):
logger.warning(
"[LEA] verify_screen garde KO : attendu un titre "
"contenant %s, actuel '%s'",
patterns, current_title,
"contenant %s, actuel [title_hash=%s]",
patterns, _title_hash(current_title),
)
print(
f" [VERIFY] Garde titre KO "
f"(patterns={patterns}, actuel='{current_title}') "
f"(patterns={patterns}, actuel=[title_hash={_title_hash(current_title)}]) "
"→ apprentissage humain"
)
try:
@@ -2557,15 +2558,15 @@ class ActionExecutorV1:
result["error"] = (
f"verify_screen titre fenêtre KO : attendu "
f"un titre contenant {patterns}, "
f"actuel '{current_title}'"
f"actuel [title_hash={_title_hash(current_title)}]"
)
result["warning"] = "setup_guard_window_mismatch"
result["needs_human"] = True
result["screenshot"] = self._capture_screenshot_b64()
return result
logger.info(
"[LEA] verify_screen garde OK : '%s' matche %s",
current_title, patterns,
"[LEA] verify_screen garde OK : [title_hash=%s] matche %s",
_title_hash(current_title), patterns,
)
print(f" [VERIFY] Termine (verification deferred au serveur).")
@@ -3736,8 +3737,8 @@ Example: x_pct=0.50, y_pct=0.30"""
real_x = int(x_pct * sw)
real_y = int(y_pct * sh)
label = server_result.get("matched_element", {}).get("label", "popup")
print(f" [POPUP-SERVER] Popup détectée ! Clic sur '{label}' → ({real_x}, {real_y})")
logger.info(f"[POPUP-SERVER] Clic popup '{label}' à ({real_x}, {real_y})")
print(f" [POPUP-SERVER] Popup détectée ! Clic sur [title_hash={_title_hash(label)}] → ({real_x}, {real_y})")
logger.info(f"[POPUP-SERVER] Clic popup [title_hash={_title_hash(label)}] à ({real_x}, {real_y})")
self._click((real_x, real_y), "left")
time.sleep(1.0)
return True
@@ -3856,8 +3857,8 @@ Example: x_pct=0.50, y_pct=0.30"""
raw_content = resp.json().get("message", {}).get("content", "")
full_response = prefill + raw_content
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : {full_response.strip()}")
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : {full_response.strip()}")
print(f" [POPUP-VLM] Réponse en {elapsed:.1f}s : [len={len(full_response)}, has_target={'target' in full_response}]")
logger.info(f"[POPUP-VLM] Réponse VLM ({elapsed:.1f}s) : [len={len(full_response)}, has_target={'target' in full_response}]")
# Extraire le texte du bouton depuis la réponse
button_text = raw_content.strip().strip('"').strip("'").strip(".")
@@ -4172,7 +4173,7 @@ Example: x_pct=0.50, y_pct=0.30"""
try:
self.keyboard.type(char)
except Exception as e:
logger.debug(f"Impossible de taper '{char}': {e}")
logger.debug(f"Impossible de taper [1 char typed]: {e}")
# Délai humain entre les frappes (40-120ms)
time.sleep(random.uniform(0.04, 0.12))

View File

@@ -0,0 +1,48 @@
"""Helpers de logging PII-safe pour le client Léa (agent_v1).
Convention : ne jamais logger le contenu brut d'une variable utilisateur
(texte tapé, titre de fenêtre, nom de workflow, réponse VLM, chemin fichier).
Le remplacer par :
- une longueur ou un hash court (corrélation de diagnostic sans révéler) ;
- un dict de métadonnées filtré (sans titre / fenêtre active).
À importer dans tout module d'agent_v1 qui logge une donnée potentiellement
sensible. Branche feat/push-log-dgx — DETTE-020 (assainissement à la source).
"""
from __future__ import annotations
import hashlib
import os
def _title_hash(title: str) -> str:
"""Hash SHA1 tronqué (8 hex) d'un titre.
Corrélation stable (même titre → même hash → « même popup re-détectée »)
sans exposer le contenu. `errors="replace"` pour ne jamais lever sur un
encodage exotique (titres Windows multi-langues).
"""
return hashlib.sha1((title or "").encode("utf-8", errors="replace")).hexdigest()[:8]
# Clés de métadonnées susceptibles de contenir du contenu utilisateur (PII).
_PII_METADATA_KEYS = ("title", "active_window", "window_title")
def _sanitize_metadata(metadata: dict) -> dict:
"""Copie d'un dict de métadonnées sans les clés porteuses de PII.
Garde les champs techniques (resolution, dpi, theme, langue…), retire
titre / fenêtre active. Ne mute pas le dict d'origine.
"""
return {k: v for k, v in metadata.items() if k not in _PII_METADATA_KEYS}
def _path_ext(path: str) -> str:
"""Extension seule d'un chemin (ex. « .png »), sans nom ni dossier.
Un chemin peut nommer un patient ; l'extension suffit au diagnostic.
Chaîne vide si pas de chemin ou pas d'extension.
"""
return os.path.splitext(path)[1] if path else ""

View File

@@ -24,6 +24,8 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Optional
from .log_safe import _title_hash
logger = logging.getLogger(__name__)
@@ -168,8 +170,8 @@ class RecoveryEngine:
from ..window_info_crossplatform import get_active_window_info
active = get_active_window_info()
active_title = active.get("title", "")
logger.info(f"Recovery : Alt+F4 sur '{active_title}'")
print(f" [RECOVERY] Alt+F4 — fermeture de '{active_title}'")
logger.info(f"Recovery : Alt+F4 sur [title_hash={_title_hash(active_title)}]")
print(f" [RECOVERY] Alt+F4 — fermeture de [title_hash={_title_hash(active_title)}]")
except Exception:
logger.info("Recovery : Alt+F4 (fenêtre active inconnue)")
print(" [RECOVERY] Alt+F4 — fermeture fenêtre indésirable")
@@ -182,7 +184,7 @@ class RecoveryEngine:
return RecoveryResult(
action_taken=RecoveryAction.CLOSE_WINDOW,
success=True,
detail=f"Alt+F4 exécuté sur '{active_title if 'active_title' in dir() else '?'}'",
detail=f"Alt+F4 exécuté sur [title_hash={_title_hash(active_title) if 'active_title' in dir() else '?'}]",
)
elif strategy == RecoveryAction.CLICK_AWAY:

View File

@@ -0,0 +1,56 @@
"""Journalisation client Léa — DETTE-021.
Branche un handler **fichier** (`TimedRotatingFileHandler`) sur le logger racine,
en plus de la console. Sans cela, sous `pythonw.exe` (pas de console), les logs
partent sur stderr et sont **perdus** — diagnostic terrain impossible.
Rotation quotidienne + rétention `retention_days` (Règlement IA Art. 12 :
journalisation automatique + conservation minimum 180 j).
"""
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
_FMT = "%(asctime)s %(levelname)-7s %(name)-25s %(message)s"
def setup_logging(log_file, level=logging.INFO, retention_days=180):
"""Configure le logging racine : fichier (rotation quotidienne, `retention_days`
fichiers conservés) + console. **Idempotent** : ne réempile pas nos handlers.
Args:
log_file: chemin du fichier de log (`config.LOG_FILE` en prod).
level: niveau racine (INFO par défaut ; DEBUG géré par l'appelant).
retention_days: nb de fichiers quotidiens conservés (180 = Règlement IA Art. 12).
Returns:
Le `TimedRotatingFileHandler` créé.
"""
log_file = Path(log_file)
log_file.parent.mkdir(parents=True, exist_ok=True)
root = logging.getLogger()
root.setLevel(level)
# Idempotence : retirer nos propres handlers posés par un appel précédent.
for h in list(root.handlers):
if getattr(h, "_lea_managed", False):
h.close()
root.removeHandler(h)
file_handler = TimedRotatingFileHandler(
str(log_file), when="midnight", backupCount=retention_days, encoding="utf-8"
)
file_handler.setFormatter(logging.Formatter(_FMT, datefmt="%Y-%m-%d %H:%M:%S"))
file_handler.setLevel(level)
file_handler._lea_managed = True
root.addHandler(file_handler)
# Console conservée (utile en dev / si lancé avec une console).
console = logging.StreamHandler()
console.setFormatter(logging.Formatter(_FMT, datefmt="%H:%M:%S"))
console.setLevel(level)
console._lea_managed = True
root.addHandler(console)
return file_handler

View File

@@ -15,7 +15,7 @@ import time
import logging
import threading
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
)
@@ -29,6 +29,7 @@ from .ui.capture_server import CaptureServer
from .session.storage import SessionStorage
from .vision.capturer import VisionCapturer
from .finalize_contract import dispatch_finalize_result
from .core.log_safe import _title_hash
# Import optionnel du client serveur (pour le chat et les workflows)
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
@@ -43,11 +44,19 @@ except (ImportError, ValueError):
# Configuration du logging — format structuré et lisible pour un TIM
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
logging.basicConfig(
level=_log_level,
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
datefmt="%H:%M:%S",
)
# DETTE-021 : journaliser dans un FICHIER (rotation quotidienne + rétention 180 j,
# Règlement IA Art. 12). Sous `pythonw.exe` (sans console), un basicConfig→stderr
# serait perdu. Fallback console si le fichier est indisponible — ne JAMAIS
# empêcher Léa de démarrer pour un problème de log.
try:
from .logging_setup import setup_logging
setup_logging(LOG_FILE, level=_log_level, retention_days=LOG_RETENTION_DAYS)
except Exception:
logging.basicConfig(
level=_log_level,
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
datefmt="%H:%M:%S",
)
# Réduire le bruit de certaines libs
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
@@ -253,7 +262,7 @@ class AgentV1:
# Ne PAS en relancer une ici — deux threads poll simultanés causent
# une race condition où les actions sont consommées mais pas exécutées.
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
logger.info(f"Session {self.session_id} [wf_hash={_title_hash(workflow_name)}] sur machine {self.machine_id} en cours...")
def _command_watchdog_loop(self):
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""

View File

@@ -36,6 +36,7 @@ import requests
from PIL import Image
from ..config import API_TOKEN, BASE_DIR, STREAMING_ENDPOINT
from ..core.log_safe import _title_hash
from .persistent_buffer import MAX_ATTEMPTS, PersistentBuffer
@@ -138,7 +139,7 @@ class TraceStreamer:
target=self._buffer_drain_loop, daemon=True
)
self._drain_thread.start()
logger.info(f"Streamer pour {self.session_id} démarré")
logger.info(f"Streamer démarré")
def stop(self):
"""Arrêter le streaming et finaliser la session côté serveur.
@@ -166,7 +167,7 @@ class TraceStreamer:
self._drain_thread.join(timeout=2.0)
self._finalize_session()
logger.info(f"Streamer pour {self.session_id} arrêté")
logger.info(f"Streamer arrêté")
def push_event(self, event_data: dict):
"""Enfile un événement pour envoi immédiat.
@@ -632,7 +633,7 @@ class TraceStreamer:
self._check_redirect(resp, url)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
logger.info(f"Session finalisée [status={result.get('status')}, wf_hash={_title_hash(result.get('workflow_name',''))}]")
if self._on_finalize_result is not None:
try:
self._on_finalize_result(result)

View File

@@ -29,6 +29,8 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from ..core.log_safe import _title_hash
logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ class ActivityPanel:
)
self._notifier_changement()
self._rafraichir_ui()
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
logger.info(f"[ACTIVITY] Workflow démarré : [wf_hash={_title_hash(nom)}] ({nb_etapes} étapes)")
def mettre_a_jour(
self,

View File

@@ -27,6 +27,8 @@ import os
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from ..core.log_safe import _path_ext
logger = logging.getLogger(__name__)
CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
@@ -312,7 +314,7 @@ class _FileActionHandlerLocal:
})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
logger.info(f"Liste dossier [ext={_path_ext(path_str)}] : {len(files)} fichiers")
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
def _create_dir(self, params: dict) -> dict:
@@ -328,7 +330,7 @@ class _FileActionHandlerLocal:
target = _Path(path_str)
existed = target.exists()
target.mkdir(parents=True, exist_ok=True)
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
logger.info(f"Dossier [ext={_path_ext(path_str)}] {'existait deja' if existed else 'cree'}")
return {"created": not existed, "path": path_str, "already_existed": existed}
def _move_file(self, params: dict) -> dict:
@@ -350,7 +352,7 @@ class _FileActionHandlerLocal:
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
_shutil.move(src, dst)
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
logger.info(f"Fichier deplace : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
return {"moved": True, "source": src, "destination": dst}
def _copy_file(self, params: dict) -> dict:
@@ -376,7 +378,7 @@ class _FileActionHandlerLocal:
_shutil.copytree(src, dst)
else:
_shutil.copy2(src, dst)
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
logger.info(f"Fichier copie : [ext={_path_ext(src)}] -> [ext={_path_ext(dst)}]")
return {"copied": True, "source": src, "destination": dst}
def _sort_by_extension(self, params: dict) -> dict:
@@ -425,7 +427,7 @@ class _FileActionHandlerLocal:
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
f"Classement par extension [ext={_path_ext(source_dir_str)}] : {len(moved)} fichiers"
)
return {
"moved": moved,

View File

@@ -19,6 +19,8 @@ import platform
import subprocess
from typing import Any, Dict, Optional
from .core.log_safe import _title_hash
def _run_cmd(cmd: list[str]) -> Optional[str]:
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
@@ -372,7 +374,7 @@ if __name__ == "__main__":
for i in range(5):
info = get_active_window_info()
rect = get_active_window_rect()
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
print(f"[{i+1}] App: {info['app_name']:20s} | Title: [title_hash={_title_hash(info['title'])}]")
if rect:
print(f" Rect: {rect['rect']} | Size: {rect['size']}")
else:

View File

@@ -0,0 +1,77 @@
"""Store des logs poussés par les clients Léa (push-log-DGX).
Persiste les logs reçus du client, rangés par `machine_id`, pour consultation
au dashboard (diagnostic des postes sans AnyDesk). Stockage fichier JSONL
(un fichier par jour et par machine_id), rétention configurable.
DETTE-020/021 (observabilité). Branche feat/push-log-dgx.
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
# machine_id = entrée réseau → neutraliser tout caractère hors liste blanche
# (anti path-traversal : '/', '\\', '..' ne doivent pas s'échapper du base_dir).
_SAFE_MACHINE_ID_RE = re.compile(r"[^A-Za-z0-9._-]")
class AgentLogsStore:
"""Persiste et relit les logs clients rangés par machine_id (JSONL)."""
def __init__(self, base_dir: str | Path = "data/agent_logs"):
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
def _machine_dir(self, machine_id: str) -> Path:
safe = _SAFE_MACHINE_ID_RE.sub("_", machine_id or "").strip("._") or "unknown"
d = self.base_dir / safe
d.mkdir(parents=True, exist_ok=True)
return d
def append(self, machine_id: str, entries: list[dict]) -> int:
"""Ajoute un batch de logs pour un poste. Retourne le nb de lignes écrites."""
if not entries:
return 0
now = datetime.now(timezone.utc)
day_file = self._machine_dir(machine_id) / f"{now.date().isoformat()}.jsonl"
with day_file.open("a", encoding="utf-8") as f:
for entry in entries:
record = dict(entry)
record.setdefault("received_at", now.isoformat())
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return len(entries)
def read(self, machine_id: str) -> list[dict]:
"""Relit toutes les entrées d'un poste, triées par fichier (date) puis ordre d'écriture."""
d = self._machine_dir(machine_id)
out: list[dict] = []
for jsonl in sorted(d.glob("*.jsonl")):
with jsonl.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
out.append(json.loads(line))
return out
def purge_old(self, retention_days: int = 30, now: datetime | None = None) -> int:
"""Supprime les fichiers-jour antérieurs à la rétention. Retourne le nb supprimé.
Rétention basée sur la date encodée dans le nom du fichier (`YYYY-MM-DD.jsonl`),
pas sur le mtime (déterministe, non altérable). `now` injectable pour les tests.
"""
now = now or datetime.now(timezone.utc)
cutoff = (now - timedelta(days=retention_days)).date()
removed = 0
for jsonl in self.base_dir.rglob("*.jsonl"):
try:
file_date = datetime.strptime(jsonl.stem, "%Y-%m-%d").date()
except ValueError:
continue # nom inattendu → on ne touche pas
if file_date < cutoff:
jsonl.unlink()
removed += 1
return removed

View File

@@ -583,6 +583,17 @@ _AGENTS_DB_PATH = os.environ.get(
)
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
# push-log-DGX : store des logs poussés par les clients, rangés par machine_id
# (observabilité des postes sans AnyDesk — DETTE-020/021).
from .agent_logs_store import AgentLogsStore # noqa: E402
_AGENT_LOGS_DIR = os.environ.get(
"RPA_AGENT_LOGS_DIR", str(ROOT_DIR / "data" / "agent_logs")
)
# Garde-fou anti-flood (G3) : nb max d'entrées acceptées par batch.
_AGENT_LOGS_MAX_BATCH = int(os.environ.get("RPA_AGENT_LOGS_MAX_BATCH", "1000"))
agent_logs_store = AgentLogsStore(base_dir=_AGENT_LOGS_DIR)
def _agent_registry_has_entries() -> bool:
try:
@@ -1562,6 +1573,16 @@ class AgentUninstallRequest(BaseModel):
reason: Optional[str] = None
class AgentLogsRequest(BaseModel):
"""Batch de logs poussé par un client Léa (push-log-DGX).
`logs` = liste d'entrées {ts, level, logger, message} (format libre côté
serveur ; le client garantit le PII-safe avant push).
"""
machine_id: str
logs: list[dict] = []
# Thread de nettoyage périodique des replays terminés et sessions expirées
_cleanup_thread: Optional[threading.Thread] = None
_cleanup_running = False
@@ -7200,6 +7221,59 @@ async def agents_fleet():
}
@app.post("/api/v1/agents/logs")
async def agents_logs(request: AgentLogsRequest):
"""Réception des logs poussés par un client Léa (push-log-DGX).
Range les logs par machine_id (AgentLogsStore) pour consultation au
dashboard — diagnostic des postes sans AnyDesk. Mêmes garde-fous fleet
que stream/poll : un poste révoqué/inconnu est refusé (403).
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
if len(request.logs) > _AGENT_LOGS_MAX_BATCH:
raise HTTPException(
status_code=413,
detail={
"error": "batch_too_large",
"max_batch": _AGENT_LOGS_MAX_BATCH,
"received": len(request.logs),
},
)
# Bloque les postes révoqués/désinstallés + met à jour last_seen_at.
_guard_agent_registry_access(machine_id, endpoint="agents/logs")
received = agent_logs_store.append(machine_id, request.logs)
return {"status": "ok", "received": received, "machine_id": machine_id}
@app.get("/api/v1/agents/logs/{machine_id}")
async def get_agents_logs(machine_id: str, limit: int = 1000):
"""Lecture des logs poussés par un poste (push-log-DGX, brique 3).
Route de diagnostic dashboard : restitue les logs rangés par machine_id
(poste sans AnyDesk). Lecture admin read-only — volontairement SANS garde
fleet : on doit pouvoir consulter un poste révoqué ou en panne. Seul le
Bearer (dépendance globale `_verify_token`) protège l'accès.
`limit` borne la réponse aux N entrées les plus récentes (tail) pour éviter
de renvoyer plusieurs jours de logs d'un coup.
"""
entries = agent_logs_store.read(machine_id)
total = len(entries)
if limit and limit > 0:
entries = entries[-limit:]
return {
"machine_id": machine_id,
"total": total,
"count": len(entries),
"logs": entries,
}
# =========================================================================
# R2 MVP P0 — DialogResolver (catalogue centralisé des modaux runtime)
# Flag OFF par défaut. Activer en posant RPA_DIALOG_RESOLVER_ENABLED=true.

View File

@@ -0,0 +1,133 @@
"""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 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"(?<!\d)[12]\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{3}\s?\d{3}\s?\d{2}(?!\d)")
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
RE_TEL = re.compile(r"(?<!\d)(?:\+33\s?|0)\d(?:[ .\-]?\d){8}(?!\d)")
# Âge format « titre » (« 90 ans »), plus large que le regex prose de anonymisation.
RE_AGE = re.compile(r"\b(\d{1,3})\s*ans\b", re.IGNORECASE)
_MAJ = r"A-ZÉÈÀÂÊÎÔÛÄËÏÖÜÇ"
_MIN = r"a-zàâäéèêëïîôöùûüç"
# Format clinique « NOM (NOM_NAISSANCE) Prénom » (ex. « ROSSIGNOL (SOUBIE) Pierrette »).
RE_NOM_NAISSANCE = re.compile(
rf"\b[{_MAJ}][{_MAJ}\-']+\s+\([{_MAJ}][{_MAJ}\-']+\)\s+[{_MAJ}][{_MIN}\-']+\b"
)
# Patient entre crochets des fenêtres PACS (ex. « [DATTIN Alix] »), ≥ 2 tokens capitalisés.
RE_NOM_BRACKET = re.compile(
rf"\[((?:[{_MAJ}][\w{_MIN}'\-]*\s+){{1,3}}[{_MAJ}][\w{_MIN}'\-]*)\]"
)
# Ordre = priorité ; group = portion à remplacer (0 = match entier).
_DETECTORS: List[Tuple[re.Pattern, str, int]] = [
(RE_NOM_NAISSANCE, "NOM", 0),
(RE_NOM_BRACKET, "NOM", 0),
(RE_EMAIL, "EMAIL", 0),
(RE_NIR, "NIR", 0),
(RE_IPP, "IPP", 1),
(RE_TEL, "TEL", 0),
(RE_AGE, "AGE", 0),
]
# Anti-faux-positifs : termes logiciels/UI à ne jamais prendre pour un nom.
# (Sous-ensemble inline ; les gazetteers complets arrivent avec la couche NER.)
_SOFTWARE_BLACKLIST = {
"FIREFOX", "MOZILLA", "CHROME", "EDGE", "EXPERT", "SANTE", "SANTÉ", "PACS",
"CIM", "ARES", "EASILY", "CONSULTATION", "URGENCES", "SAISIE", "COURRIER",
"DOSSIER", "PATIENT", "FENETRE", "FENÊTRE", "GXD", "WINDOWS", "CITRIX",
}
def _normalize(etype: str, value: str) -> 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é = ordre des détecteurs, puis position)
spans.sort(key=lambda s: (s[0], s[1]))
accepted: List[Tuple[int, int, str, str]] = []
last_end = -1
for start, end, etype, value in spans:
if start >= last_end:
accepted.append((start, end, etype, value))
last_end = 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

View File

@@ -0,0 +1,156 @@
"""Signature de trajectoire — identité stable d'un parcours appris (décision F1).
Une trajectoire = séquence ordonnée d'actions sur des cibles stables. La signature
hashe uniquement `(action_type, target)` de chaque étape, dans l'ordre, en **ignorant
les champs session-spécifiques** (IDs de nœuds, timestamps, coordonnées). Deux
apprentissages du même parcours produisent donc la même signature → create-or-update.
Primitive partagée (Phase 0) : consommée par SP-4 (dédup/persist), SP-2 (rejeu) et le
cycle compétences (dédup des skills). Pour composer avec un descripteur d'écran stable,
passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `target`.
"""
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 = _normalize_target(str(step.get("target", "")))
return f"{action_type}{_FIELD_SEP}{target}"
def trajectory_signature(steps: Iterable[Mapping[str, Any]]) -> str:
"""Retourne la signature SHA-256 (hex, 64 car.) d'une séquence d'étapes.
Chaque étape est un mapping ; seuls `action_type` et `target` sont pris en compte.
Tous les autres champs (node_id, timestamp, coordonnées…) sont ignorés afin de
garantir la stabilité de la signature entre deux sessions du même parcours.
"""
canonical = _STEP_SEP.join(_normalize_step(step) for step in steps)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Adaptateur : workflow core (dict) → signature de trajectoire
# ---------------------------------------------------------------------------
def _stable_target(target: Any) -> str:
"""Descripteur de cible **stable** entre sessions.
S'appuie sur le texte sémantique de la cible (`by_text`), volontairement
indépendant du moteur de grounding : `by_role` peut valoir 'yolo'/'ocr'/'vlm'
(méthode de détection, instable entre sessions) et n'entre donc PAS dans la
signature. Fallback quand `by_text` est absent : titre de fenêtre / description VLM.
"""
if not isinstance(target, Mapping):
return ""
by_text = str(target.get("by_text") or "").strip()
if by_text:
return by_text
hints = target.get("context_hints")
if isinstance(hints, Mapping):
return str(hints.get("window_title") or hints.get("vlm_description") or "").strip()
return ""
def _ordered_edges(workflow: Mapping[str, Any]) -> list:
"""Edges dans l'ordre du parcours (BFS depuis entry_nodes), comme le bridge d'import."""
edges = list(workflow.get("edges") or [])
if not edges:
return []
by_from: dict = {}
for edge in edges:
by_from.setdefault((edge or {}).get("from_node"), []).append(edge)
entry = list(workflow.get("entry_nodes") or [])
nodes = workflow.get("nodes") or []
if not entry and nodes:
entry = [(nodes[0] or {}).get("node_id")]
if not entry:
return edges # pas de point d'entrée : ordre brut de la liste
ordered: list = []
seen_edges: set = set()
visited: set = set()
queue = list(entry)
while queue:
node = queue.pop(0)
if node in visited:
continue
visited.add(node)
for edge in by_from.get(node, []):
key = id(edge)
if key in seen_edges:
continue
seen_edges.add(key)
ordered.append(edge)
to_node = (edge or {}).get("to_node")
if to_node and to_node not in visited:
queue.append(to_node)
for edge in edges: # edges non atteints : ajout déterministe en fin
if id(edge) not in seen_edges:
ordered.append(edge)
return ordered
def workflow_step_descriptors(workflow: Mapping[str, Any]) -> list:
"""Séquence ordonnée de descripteurs `(action_type, target stable)` d'un workflow core."""
descriptors: list = []
for edge in _ordered_edges(workflow):
action = (edge or {}).get("action") or {}
descriptors.append({
"action_type": action.get("type", "unknown"),
"target": _stable_target(action.get("target")),
})
return descriptors
def workflow_trajectory_signature(workflow: Mapping[str, Any]) -> str:
"""Signature de trajectoire d'un workflow core (dict). Cf. `trajectory_signature`."""
return trajectory_signature(workflow_step_descriptors(workflow))

View File

@@ -35,6 +35,9 @@ P0 / P1 / P2 / P3 (alignées sur convention handoffs)
| DETTE-017 | 2026-06-12 | 2026-06-12 | P0 | OPEN | Auth Bearer **désactivée** (`RPA_AUTH_DISABLED=true`) sur streaming `5005` ET agent-chat `5004` du DGX, appliquée comme « fix » heartbeat B3 (rustine). Démontré inutile : les 3 tokens (DGX proc, DGX `.env.local`, Windows `.env`) sont identiques (SHA256 `43749362b1`, len 43) → l'auth peut être réactivée sans casser le heartbeat. Exposition `0.0.0.0:5004/5005` restreinte par iptables au seul poste `192.168.1.11` ; dashboard `5001` conserve son auth. **Exception temporaire validée par Dom (2026-06-12 09:35) pour test M2 local sur données factices.** ROLLBACK OBLIGATOIRE avant toute sortie clinique / données patient : `RPA_AUTH_DISABLED=false` dans `.env.local` DGX + `sudo systemctl restart rpa-streaming.service rpa-agent-chat.service` puis vérif (401 sans token / 200 avec / heartbeat maintenu). | docs/coordination/active/2026-06-12_0935_decision-dom-auth-off-exception-m2.md + alerte 2026-06-11_1535 |
| DETTE-018 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Garde-seuil inopérant sur le chemin grounding **legacy** : `_resolve_by_grounding` retourne `method="grounding_vlm"` (resolve_engine.py:1121, mode `RPA_GROUNDING_ENGINE` OFF), clé absente de `_RESOLUTION_MIN_SCORES` qui ne traite en **préfixe** que `memory_` (toutes les autres clés = match exact) → le Check-1 du validateur (seuil min de confiance) ne s'applique jamais à ce chemin. Le mode `qwen3vl_vllm` est lui correctement gardé (`method="grounding"`, clé exacte, seuil 0.60). Aligner le legacy (clé gardée ou renommage) tant que le mode legacy reste activable. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
| DETTE-019 | 2026-06-13 | 2026-06-27 | P2 | OPEN | Confiance grounding **figée à `0.85` en dur** dans le `return` de `_resolve_by_grounding` (resolve_engine.py:1128-1130 : `matched_element.confidence` et `score`), pour les DEUX modes (legacy et qwen3vl). Le garde-seuil (0.60) reçoit donc toujours 0.85 quel que soit le grounding réel → le filtre ne discrimine jamais la vraie qualité de localisation. Propager une confiance réelle (signal modèle/cascade) pour rendre le seuil opérant. | Découvert au câblage qwen3vl (commit 5c5ce747b) + validation E2E 2026-06-13 |
| DETTE-020 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Incidents silencieux — aucune détection/alerte des composants critiques d'inférence.** Un composant critique peut tomber sans alerte : `rpa-vllm-grounder.service` (grounder Qwen3-VL/vLLM) trouvé en **crash-loop (auto-restart, restart counter ×3960)** → le runtime a basculé **silencieusement** sur le fallback `qwen2.5vl:7b-rpa` (Ollama, ~×7 plus lent), avec une latence/contention accrue mais **aucune remontée visible** (ni dashboard, ni log d'alerte). Découvert uniquement par vérif manuelle au runtime (session 2026-06-25). La cause de CE crash (SSL HuggingFace au boot vs cache local — manque `HF_HUB_OFFLINE`) se corrige à part ; la dette ici = **le mode dégradé est silencieux**. Cible : health-check + supervision des composants critiques (grounder vLLM, Ollama, services `rpa-*`) avec **remontée VISIBLE** (dashboard 5001 / log d'alerte / notification) → une bascule en mode dégradé ne doit jamais passer inaperçue. ⚠️ Vérifier d'abord l'existant (module monitoring `:5003`) avant de construire. | session vérif runtime DGX clinique 2026-06-25 |
| DETTE-021 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Journalisation client Léa non effective.** `LOG_FILE` (`agent_v0/agent_v1/config.py:88``<install>/logs/agent_v1.log`) est défini mais **jamais branché** : aucun `FileHandler`/`addHandler` dans tout le client. Seul logging actif = `basicConfig` (`main.py:46`) → **stderr**, perdu car Léa tourne en `pythonw.exe` (sans console). Dossier `logs/` vide. Conséquences : (1) **diagnostic terrain aveugle** — impossible de tracer pourquoi Léa « disparaît » côté poste ; (2) **non-conformité Règlement IA Art. 12** (journalisation + conservation 180 j — citée dans le code mais non effective ; `LOG_RETENTION_DAYS` ne couvre que les *sessions*). Cible : brancher un `RotatingFileHandler`/`TimedRotating` vers `LOG_FILE` (rotation + purge 180 j, niveau INFO). ⚠️ modif client → **redéploiement** (cf. DETTE-022). Pendant client du DETTE-020 (observabilité serveur). | session diagnostic « disparition » Léa poste Émilie 2026-06-25 |
| DETTE-022 | 2026-06-25 | 2026-07-09 | P1 | OPEN | **Pas de mise à jour automatique du client Léa.** Toute modif du client (`agent_v0/agent_v1/**`) impose un **redéploiement manuel poste par poste** (Léa « gelée »). En clinique (5 postes, croissant), intervenir sur chaque poste à chaque correctif (ex. fix logging DETTE-021) **dérange les TIM et décourage l'adoption** (constat Dom). Cible : mécanisme de **MAJ auto / en tâche de fond** (auto-update silencieux, versionné, piloté serveur/dashboard, avec rollback), **zéro intervention sur le poste**. ⚠️ Vérifier d'abord l'existant côté enrôlement Fleet (dashboard build ZIP + token) avant de construire. | décision Dom 2026-06-25 (« on ne peut pas intervenir constamment sur les postes, on va décourager ») |
## Convention de référencement

View File

@@ -0,0 +1,192 @@
"""Tests d'intégration de l'endpoint POST /api/v1/agents/logs (push-log-DGX).
Le client Léa pousse ses logs (batch JSON) vers le DGX ; le serveur les range
par machine_id (AgentLogsStore) pour consultation au dashboard — diagnostic des
postes sans AnyDesk. Mêmes garde-fous fleet que stream/poll (agent actif).
Branche feat/push-log-dgx — DETTE-020/021.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
_TEST_API_TOKEN = "test_token_logs_endpoint_0123456789abcdef"
@pytest.fixture
def logs_client(monkeypatch, tmp_path):
"""Client FastAPI de test avec registre ET store de logs isolés sur disque."""
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
monkeypatch.setenv("RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db"))
from fastapi.testclient import TestClient
from agent_v0.server_v1 import api_stream
from agent_v0.server_v1.agent_registry import AgentRegistry
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
test_store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
monkeypatch.setattr(api_stream, "agent_logs_store", test_store, raising=False)
client = TestClient(api_stream.app, raise_server_exceptions=False)
yield client, _TEST_API_TOKEN, test_store
def _auth_headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _enroll(client, token, machine_id):
return client.post(
"/api/v1/agents/enroll",
json={"machine_id": machine_id, "user_name": machine_id},
headers=_auth_headers(token),
)
def test_post_logs_persists_for_active_agent(logs_client):
client, token, store = logs_client
_enroll(client, token, "lea-emilie-001")
payload = {
"machine_id": "lea-emilie-001",
"logs": [
{"ts": "2026-06-26T16:00:00", "level": "WARNING",
"logger": "agent_v1.core.executor", "message": "popup detectee"},
],
}
resp = client.post(
"/api/v1/agents/logs", json=payload, headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
assert resp.json()["received"] == 1
stored = store.read("lea-emilie-001")
assert len(stored) == 1
assert stored[0]["message"] == "popup detectee"
assert stored[0]["level"] == "WARNING"
def test_post_logs_without_token_returns_401(logs_client):
client, _, _ = logs_client
resp = client.post(
"/api/v1/agents/logs", json={"machine_id": "lea-001", "logs": []}
)
assert resp.status_code == 401
def test_post_logs_rejected_for_revoked_agent(logs_client):
"""Un poste révoqué ne peut plus pousser de logs (même garde-fou que stream/poll)."""
client, token, store = logs_client
_enroll(client, token, "lea-revoked")
client.post(
"/api/v1/agents/uninstall",
json={"machine_id": "lea-revoked", "reason": "admin_revoke"},
headers=_auth_headers(token),
)
resp = client.post(
"/api/v1/agents/logs",
json={"machine_id": "lea-revoked", "logs": [{"message": "x"}]},
headers=_auth_headers(token),
)
assert resp.status_code == 403, resp.text
assert resp.json()["detail"]["error"] == "agent_not_active"
assert store.read("lea-revoked") == [] # rien persisté
def test_post_logs_rejects_oversized_batch(logs_client):
"""Anti-flood (G3) : un batch trop volumineux est rejeté (413), rien persisté."""
client, token, store = logs_client
_enroll(client, token, "lea-flood")
big = [{"level": "INFO", "message": f"l{i}"} for i in range(1001)]
resp = client.post(
"/api/v1/agents/logs",
json={"machine_id": "lea-flood", "logs": big},
headers=_auth_headers(token),
)
assert resp.status_code == 413, resp.text
assert store.read("lea-flood") == []
# ---------------------------------------------------------------------------
# Brique 3 — lecture des logs par machine_id (route dashboard, read-only).
# Lecture admin/diagnostic : PAS de garde fleet (on veut justement pouvoir
# consulter un poste révoqué ou en panne) ; seul le Bearer protège.
# ---------------------------------------------------------------------------
def test_get_logs_returns_persisted_for_machine(logs_client):
"""GET /agents/logs/{machine_id} restitue les logs stockés, dans l'ordre."""
client, token, store = logs_client
store.append(
"lea-emilie-001",
[
{"ts": "2026-06-26T16:00:00", "level": "INFO", "message": "demarrage"},
{"ts": "2026-06-26T16:00:01", "level": "WARNING", "message": "popup"},
],
)
resp = client.get(
"/api/v1/agents/logs/lea-emilie-001", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["machine_id"] == "lea-emilie-001"
assert body["count"] == 2
assert body["total"] == 2
assert body["logs"][0]["message"] == "demarrage"
assert body["logs"][1]["level"] == "WARNING"
def test_get_logs_without_token_returns_401(logs_client):
client, _, _ = logs_client
resp = client.get("/api/v1/agents/logs/lea-emilie-001")
assert resp.status_code == 401
def test_get_logs_empty_for_unknown_machine(logs_client):
"""Un poste sans log remonte une liste vide (200), pas une erreur."""
client, token, _ = logs_client
resp = client.get(
"/api/v1/agents/logs/lea-inconnu", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["count"] == 0
assert body["total"] == 0
assert body["logs"] == []
def test_get_logs_limit_returns_tail(logs_client):
"""`limit` borne la réponse aux N entrées les plus récentes (tail)."""
client, token, store = logs_client
store.append(
"lea-tail",
[{"level": "INFO", "message": f"m{i}"} for i in range(5)],
)
resp = client.get(
"/api/v1/agents/logs/lea-tail?limit=2", headers=_auth_headers(token)
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 5
assert body["count"] == 2
assert [e["message"] for e in body["logs"]] == ["m3", "m4"]

View File

@@ -0,0 +1,78 @@
"""Tests unitaires du store de logs poussés par les clients Léa (push-log-DGX).
Le store persiste les logs reçus du client, rangés par `machine_id`, pour
consultation au dashboard (diagnostic des postes sans AnyDesk). Stockage
fichier (JSONL par machine_id), rétention configurable.
Branche : feat/push-log-dgx — DETTE-020/021 (observabilité).
"""
from __future__ import annotations
import sys
from pathlib import Path
# Racine projet pour les imports locaux (meme pattern que tests/integration)
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def test_append_then_read_roundtrip(tmp_path):
"""append() persiste un batch ; read() le restitue dans l'ordre."""
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
store = AgentLogsStore(base_dir=tmp_path / "agent_logs")
entries = [
{"ts": "2026-06-26T16:00:00", "level": "INFO",
"logger": "agent_v1.main", "message": "demarrage"},
{"ts": "2026-06-26T16:00:01", "level": "WARNING",
"logger": "agent_v1.core.executor", "message": "popup detectee"},
]
store.append("lea-emilie-001", entries)
got = store.read("lea-emilie-001")
assert len(got) == 2
assert got[0]["message"] == "demarrage"
assert got[0]["level"] == "INFO"
assert got[1]["level"] == "WARNING"
assert got[1]["logger"] == "agent_v1.core.executor"
def test_machine_id_path_traversal_stays_within_base(tmp_path):
"""Un machine_id malveillant (entrée réseau) ne doit jamais écrire hors du base_dir."""
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
base = (tmp_path / "agent_logs").resolve()
store = AgentLogsStore(base_dir=base)
store.append("../../../evil", [{"message": "pwn"}])
written = list(base.rglob("*.jsonl"))
assert written, "le batch doit être persisté SOUS base (pas d'évasion ni perte)"
for p in written:
assert base in p.resolve().parents, f"{p} échappe à {base}"
# Aucune fuite hors de base
assert not list(tmp_path.glob("evil*"))
def test_purge_old_removes_files_older_than_retention(tmp_path):
"""purge_old() supprime les fichiers-jour antérieurs à la rétention (G4 Qwen)."""
from datetime import datetime, timezone
from agent_v0.server_v1.agent_logs_store import AgentLogsStore
base = tmp_path / "agent_logs"
store = AgentLogsStore(base_dir=base)
mdir = base / "lea-001"
mdir.mkdir(parents=True)
(mdir / "2026-05-01.jsonl").write_text('{"message": "vieux"}\n', encoding="utf-8")
(mdir / "2026-06-26.jsonl").write_text('{"message": "recent"}\n', encoding="utf-8")
now = datetime(2026, 6, 26, tzinfo=timezone.utc)
removed = store.purge_old(retention_days=30, now=now)
remaining = {p.name for p in mdir.glob("*.jsonl")}
assert remaining == {"2026-06-26.jsonl"}
assert removed == 1

View File

@@ -0,0 +1,74 @@
"""TDD — DETTE-021 : journalisation client Léa effective (vers fichier).
Aujourd'hui `LOG_FILE` est défini (`agent_v0/agent_v1/config.py`) mais jamais
branché ; `basicConfig` écrit sur stderr — perdu car Léa tourne en `pythonw.exe`
(sans console). On veut une fonction `setup_logging()` qui branche un handler
FICHIER avec rotation quotidienne + rétention (Règlement IA Art. 12, 180 j).
Le module est chargé par chemin (importlib) pour ne dépendre d'aucun import
lourd du package client (cf. DETTE-011/013).
"""
import importlib.util
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
_MOD_PATH = Path(__file__).resolve().parents[2] / "agent_v0" / "agent_v1" / "logging_setup.py"
def _load_setup_logging():
spec = importlib.util.spec_from_file_location("lea_logging_setup", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.setup_logging
def _cleanup_root():
root = logging.getLogger()
for h in list(root.handlers):
if getattr(h, "_lea_managed", False):
h.close()
root.removeHandler(h)
def test_setup_logging_ecrit_dans_le_fichier(tmp_path):
"""Les logs doivent atterrir dans LOG_FILE (et plus seulement sur stderr)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file, level=logging.INFO)
logging.getLogger("lea.test").info("message de diagnostic")
for h in logging.getLogger().handlers:
h.flush()
assert log_file.exists(), "le fichier de log doit être créé"
assert "message de diagnostic" in log_file.read_text(encoding="utf-8")
finally:
_cleanup_root()
def test_setup_logging_rotation_et_retention(tmp_path):
"""Rotation quotidienne + rétention configurable (180 j par défaut — Art. 12)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file, retention_days=180)
handlers = [h for h in logging.getLogger().handlers
if isinstance(h, TimedRotatingFileHandler)]
assert handlers, "un TimedRotatingFileHandler doit être branché"
assert handlers[0].backupCount == 180
finally:
_cleanup_root()
def test_setup_logging_idempotent(tmp_path):
"""Appels répétés n'empilent pas les handlers fichier (pas de doublon)."""
log_file = tmp_path / "agent_v1.log"
setup_logging = _load_setup_logging()
try:
setup_logging(log_file=log_file)
setup_logging(log_file=log_file)
file_handlers = [h for h in logging.getLogger().handlers
if isinstance(h, TimedRotatingFileHandler)]
assert len(file_handlers) == 1, "pas de handler fichier en double"
finally:
_cleanup_root()

View File

@@ -0,0 +1,73 @@
"""Tests unitaires des helpers de logging PII-safe du client Léa (agent_v1).
Assainissement des logs à la source : on ne logge jamais le contenu brut
(titres de fenêtre, noms de workflow, chemins, métadonnées sensibles). On le
remplace par un hash court stable, une longueur, ou un dict filtré.
Branche feat/push-log-dgx — DETTE-020 (assainissement PII des logs, brique 4).
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
_HEX8 = re.compile(r"^[0-9a-f]{8}$")
def test_title_hash_is_short_stable_hex():
from agent_v0.agent_v1.core.log_safe import _title_hash
h = _title_hash("Dossier MOREL Catherine")
assert _HEX8.match(h), f"attendu 8 hex, obtenu {h!r}"
assert h == _title_hash("Dossier MOREL Catherine") # déterministe
def test_title_hash_never_reveals_raw_title():
"""Propriété PII centrale : le hash ne contient jamais le contenu brut."""
from agent_v0.agent_v1.core.log_safe import _title_hash
title = "Dossier MOREL Catherine"
h = _title_hash(title)
assert title not in h
assert "MOREL" not in h
def test_title_hash_distinguishes_different_titles():
from agent_v0.agent_v1.core.log_safe import _title_hash
assert _title_hash("popup A") != _title_hash("popup B")
def test_title_hash_handles_empty_and_non_ascii():
from agent_v0.agent_v1.core.log_safe import _title_hash
assert _HEX8.match(_title_hash(""))
assert _HEX8.match(_title_hash("Éléonore — café ☕"))
def test_sanitize_metadata_drops_pii_keys_keeps_technical():
from agent_v0.agent_v1.core.log_safe import _sanitize_metadata
meta = {
"resolution": "1920x1080", "dpi": 96, "theme": "dark",
"title": "Dossier Dupont", "active_window": "Medicare", "window_title": "x",
}
safe = _sanitize_metadata(meta)
assert safe == {"resolution": "1920x1080", "dpi": 96, "theme": "dark"}
assert meta.get("title") == "Dossier Dupont" # original non muté
def test_path_ext_returns_extension_only():
from agent_v0.agent_v1.core.log_safe import _path_ext
assert _path_ext("/home/tim/Dossier Dupont 1980.png") == ".png"
assert "Dupont" not in _path_ext("/x/Dupont.png")
assert _path_ext("") == ""
assert _path_ext("/no/ext/here") == ""

View File

@@ -0,0 +1,81 @@
"""Tests de l'assainissement PII des données capturées (titres, texte, OCR).
Couche 1 (sans modèle) : filet regex sur la PII structurée (IPP, NIR, TEL,
EMAIL, AGE) + règles structurelles cliniques (NOM (NAISSANCE) Prénom ;
[Nom Prénom] des fenêtres PACS), avec tokens TYPÉS et COHÉRENTS ([IPP_1]…).
Réutilise l'approche du projet `anonymisation` (placeholders + regex). La
couche NER (noms libres) viendra en complément. Cas réels remontés en clinique
le 28/06 (anonymisés ici par construction). Branche feat/push-log-dgx.
"""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def test_ipp_et_age_tokenises():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
titre = "VIOLA (VIOLA) Liliane 90 ans - IPP: 168246 - Expert Sante - Mozilla Firefox"
out, ents = anonymize_text(titre)
assert "168246" not in out, out # IPP retiré
assert "[IPP_1]" in out
assert "90 ans" not in out # âge retiré
assert "[AGE_1]" in out
# le nom format clinique « NOM (NAISSANCE) Prénom » est tokenisé
assert "VIOLA" not in out and "Liliane" not in out, out
assert "[NOM_1]" in out
# le logiciel n'est pas pris pour de la PII
assert "Firefox" in out and "Expert Sante" in out
types = {e["type"] for e in ents}
assert {"IPP", "AGE", "NOM"} <= types
def test_nom_entre_crochets_pacs():
"""Le PACS met le patient entre crochets : `[DATTIN Alix]`."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
titre = "GXD5 Pacs 4.0.4.307 CIM ARES - [DATTIN Alix] - Mozilla Firefox"
out, _ = anonymize_text(titre)
assert "DATTIN" not in out and "Alix" not in out, out
assert "[NOM_1]" in out
assert "Pacs" in out and "Firefox" in out # contexte logiciel préservé
def test_coherence_meme_ipp_meme_token():
"""Même valeur PII -> même token (sur un mapping partagé de session)."""
from agent_v0.server_v1.pii_sanitizer import anonymize_text
mapping: dict = {}
o1, _ = anonymize_text("IPP: 168246 ouvert", mapping=mapping)
o2, _ = anonymize_text("dossier IPP: 168246 fermé", mapping=mapping)
o3, _ = anonymize_text("IPP: 270020 autre", mapping=mapping)
assert "[IPP_1]" in o1 and "[IPP_1]" in o2 # même patient -> même token
assert "[IPP_2]" in o3 # patient différent -> token différent
assert "270020" not in o3
def test_email_et_telephone():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
out, _ = anonymize_text("contact j.dupont@chu.fr / 06 12 34 56 78")
assert "@chu.fr" not in out and "[EMAIL_1]" in out
assert "06 12 34 56 78" not in out and "[TEL_1]" in out
def test_texte_sans_pii_inchange():
from agent_v0.server_v1.pii_sanitizer import anonymize_text
t = "Expert Sante - Consultation - Mozilla Firefox"
out, ents = anonymize_text(t)
assert out == t
assert ents == []

View File

@@ -0,0 +1,101 @@
"""TDD — signature de trajectoire (Phase 0 ; primitive partagée SP-4 / SP-2 / compétences).
Propriété centrale : la signature identifie une TRAJECTOIRE (séquence d'actions sur des
cibles stables). Elle doit être **stable entre sessions** — donc indépendante des champs
session-spécifiques (IDs de nœuds, timestamps, coordonnées). C'est ce qui rend le
create-or-update (décision F1) possible : deux apprentissages du même parcours = même id.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.execution.trajectory_signature import trajectory_signature
def test_deterministic_same_sequence():
steps = [
{"action_type": "mouse_click", "target": "menu Fichier"},
{"action_type": "text_input", "target": "champ recherche"},
]
assert trajectory_signature(steps) == trajectory_signature(steps)
def test_ignores_session_specific_fields():
"""Deux sessions du MÊME parcours (mêmes action_type+target) mais IDs de nœuds /
timestamps / coords différents → MÊME signature."""
session_a = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_abc", "timestamp": 1000, "x": 12, "y": 34},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_def", "timestamp": 1100, "x": 50, "y": 60},
]
session_b = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_zzz", "timestamp": 9000, "x": 99, "y": 88},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_yyy", "timestamp": 9100, "x": 11, "y": 22},
]
assert trajectory_signature(session_a) == trajectory_signature(session_b)
def test_order_sensitive():
a = [{"action_type": "mouse_click", "target": "A"},
{"action_type": "text_input", "target": "B"}]
b = list(reversed(a))
assert trajectory_signature(a) != trajectory_signature(b)
def test_target_discriminates():
a = [{"action_type": "mouse_click", "target": "bouton Valider"}]
b = [{"action_type": "mouse_click", "target": "bouton Annuler"}]
assert trajectory_signature(a) != trajectory_signature(b)
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)

View File

@@ -0,0 +1,86 @@
"""TDD — adaptateur Workflow → signature de trajectoire (Phase 0, lot 2).
Branche la primitive `trajectory_signature` sur un vrai workflow core (dict).
Doit : traverser les edges dans l'ordre du parcours (BFS depuis entry_nodes), et
n'extraire que des descripteurs de cible **stables** (by_role/by_text/window),
en ignorant coords (`by_position`) et IDs de nœuds session-spécifiques.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.execution.trajectory_signature import workflow_trajectory_signature
def _edge(from_node, to_node, action_type, *, by_role="", by_text="", by_position=None):
target = {"by_role": by_role, "by_text": by_text}
if by_position is not None:
target["by_position"] = by_position
return {
"from_node": from_node,
"to_node": to_node,
"action": {"type": action_type, "target": target},
}
def test_signature_stable_across_sessions():
"""Même parcours, IDs de nœuds + coords différents → même signature."""
session_a = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [
_edge("n1", "n2", "mouse_click", by_role="button", by_text="Fichier", by_position=[0.1, 0.2]),
_edge("n2", "n3", "text_input", by_text="recherche", by_position=[0.5, 0.6]),
],
}
session_b = {
"entry_nodes": ["a1"],
"nodes": [{"node_id": "a1"}, {"node_id": "a2"}, {"node_id": "a3"}],
"edges": [
_edge("a1", "a2", "mouse_click", by_role="button", by_text="Fichier", by_position=[0.9, 0.8]),
_edge("a2", "a3", "text_input", by_text="recherche", by_position=[0.05, 0.04]),
],
}
assert workflow_trajectory_signature(session_a) == workflow_trajectory_signature(session_b)
def test_signature_differs_on_different_target():
base = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="button", by_text="Valider")],
}
other = {
"entry_nodes": ["n1"],
"nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="button", by_text="Annuler")],
}
assert workflow_trajectory_signature(base) != workflow_trajectory_signature(other)
def test_signature_follows_edge_chain_not_list_order():
"""L'ordre vient de la chaîne from→to (BFS), pas de l'ordre brut de la liste."""
e1 = _edge("n1", "n2", "mouse_click", by_text="A")
e2 = _edge("n2", "n3", "text_input", by_text="B")
ordered = {"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [e1, e2]}
scrambled = {"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}, {"node_id": "n3"}],
"edges": [e2, e1]} # liste inversée, même chaîne
assert workflow_trajectory_signature(ordered) == workflow_trajectory_signature(scrambled)
def test_signature_stable_despite_grounding_role_difference():
"""`by_role` peut porter le moteur de grounding (yolo/ocr/vlm) — instable entre
sessions. La signature doit rester identique si seul `by_role` change → elle
s'appuie sur le texte sémantique `by_text`, pas sur la méthode de détection."""
wf_yolo = {
"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="yolo", by_text="Fichier")],
}
wf_ocr = {
"entry_nodes": ["n1"], "nodes": [{"node_id": "n1"}, {"node_id": "n2"}],
"edges": [_edge("n1", "n2", "mouse_click", by_role="ocr", by_text="Fichier")],
}
assert workflow_trajectory_signature(wf_yolo) == workflow_trajectory_signature(wf_ocr)