feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS

Pipeline replay visuel :
- VLM-first : l'agent appelle Ollama directement pour trouver les éléments
- Template matching en fallback (seuil strict 0.90)
- Stop immédiat si élément non trouvé (pas de clic blind)
- Replay depuis session brute (/replay-session) sans attendre le VLM
- Vérification post-action (screenshot hash avant/après)
- Gestion des popups (Enter/Escape/Tab+Enter)

Worker VLM séparé :
- run_worker.py : process distinct du serveur HTTP
- Communication par fichiers (_worker_queue.txt + _replay_active.lock)
- Le serveur HTTP ne fait plus jamais de VLM → toujours réactif
- Service systemd rpa-worker.service

Capture clavier :
- raw_keys (vk + press/release) pour replay exact indépendant du layout
- Fix AZERTY : ToUnicodeEx + AltGr detection
- Enter capturé comme \n, Tab comme \t
- Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites)
- Fusion text_input consécutifs, dédup key_combo

Sécurité & Internet :
- HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design)
- Token API fixe dans .env.local
- HTTP Basic Auth sur VWB
- Security headers (HSTS, CSP, nosniff)
- CORS domaines publics, plus de wildcard

Infrastructure :
- DPI awareness (SetProcessDpiAwareness) Python + Rust
- Métadonnées système (dpi_scale, window_bounds, monitors, os_theme)
- Template matching multi-scale [0.5, 2.0]
- Résolution dynamique (plus de hardcode 1920x1080)
- VLM prefill fix (47x speedup, 3.5s au lieu de 180s)

Modules :
- core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler
- core/federation/ : LearningPack export/import anonymisé, FAISS global
- deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt)

UX :
- Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant)
- Bibliothèque persistante (cache local + SQLite)
- Clustering hybride (titre fenêtre + DBSCAN)
- EdgeConstraints + PostConditions peuplés
- GraphBuilder compound actions (toutes les frappes)

Agent Rust :
- Token Bearer auth (network.rs)
- sysinfo.rs (DPI, résolution, window bounds via Win32 API)
- config.txt lu automatiquement
- Support Chrome/Brave/Firefox (pas que Edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-26 10:19:18 +01:00
parent fe5e0ba83d
commit d5deac3029
162 changed files with 25669 additions and 557 deletions

View File

@@ -8,6 +8,23 @@ import platform
import socket
from pathlib import Path
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
# travaillent en coordonnees physiques (pixels reels) au lieu de coordonnees logiques
# (virtualisees par le DPI scaling).
# Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 pour les API,
# ce qui cause des erreurs de positionnement pendant le replay.
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
if platform.system() == "Windows":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
except Exception:
try:
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
AGENT_VERSION = "1.0.0"
# Identifiant unique de la machine (utilisé pour le multi-machine)

View File

@@ -6,13 +6,25 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
Supporte deux modes :
- Watchdog fichier (command.json) — legacy
- Polling serveur (GET /replay/next) — mode replay P0-5
NOTE DPI : Ce module depend du DPI awareness configure dans config.py.
L'appel a SetProcessDpiAwareness(2) DOIT avoir ete fait avant l'import de
pynput et mss, sinon les coordonnees seront en pixels logiques (faux sur
les ecrans haute resolution avec DPI scaling > 100%).
"""
import base64
import hashlib
import io
import os
import time
import logging
# Forcer l'import de config AVANT pynput/mss pour garantir que le
# 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
import mss
from pynput.mouse import Button, Controller as MouseController
from pynput.keyboard import Controller as KeyboardController, Key
@@ -65,6 +77,28 @@ class ActionExecutorV1:
self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes)
self._poll_backoff_max = 30.0 # Delai maximal
self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec
# Token d'authentification API
self._api_token = os.environ.get("RPA_API_TOKEN", "")
# Log de la resolution physique pour le diagnostic DPI
self._log_screen_info()
def _log_screen_info(self):
"""Log la resolution physique de l'ecran au demarrage pour le diagnostic DPI."""
try:
monitor = self.sct.monitors[1]
w, h = monitor["width"], monitor["height"]
logger.info(
f"Executor initialise — resolution physique : {w}x{h} "
f"(mss monitors[1], DPI-aware process)"
)
except Exception as e:
logger.debug(f"Impossible de lire la resolution ecran : {e}")
def _auth_headers(self) -> dict:
"""Headers d'authentification Bearer pour les requetes au serveur."""
if self._api_token:
return {"Authorization": f"Bearer {self._api_token}"}
return {}
@property
def sct(self):
@@ -171,6 +205,15 @@ class ActionExecutorV1:
f"-> ({x_pct:.4f}, {y_pct:.4f})"
)
# ---- Hash AVANT l'action (pour verification post-action) ----
# Seules les actions click et key_combo sont verifiees : elles
# provoquent un changement visible de l'ecran (ouverture de fenetre,
# focus, etc.). Les actions type/wait/scroll ne sont pas verifiees.
needs_screen_check = action_type in ("click", "key_combo")
hash_before = ""
if needs_screen_check:
hash_before = self._quick_screenshot_hash()
if action_type == "click":
real_x = int(x_pct * width)
real_y = int(y_pct * height)
@@ -197,7 +240,7 @@ class ActionExecutorV1:
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
self._click((real_x, real_y), "left")
time.sleep(0.3)
self.keyboard.type(text)
self._type_text(text)
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)")
@@ -226,6 +269,25 @@ class ActionExecutorV1:
print(f" [WAIT] Termine.")
logger.info(f"Replay wait : {duration_ms}ms")
elif action_type == "verify_screen":
# Vérification visuelle entre les groupes du replay hybride.
# Pour l'instant, on fait un wait de 2s pour laisser l'écran
# se stabiliser. La vérification réelle sera faite par le
# pre-check côté serveur dans GET /replay/next.
expected_node = action.get("expected_node", "?")
timeout_ms = action.get("timeout_ms", 5000)
wait_s = min(timeout_ms / 1000.0, 2.0)
print(
f" [VERIFY] Attente verification ecran "
f"(node attendu: {expected_node}, wait={wait_s}s)"
)
time.sleep(wait_s)
print(f" [VERIFY] Termine (verification deferred au serveur).")
logger.info(
f"Replay verify_screen : node={expected_node}, "
f"wait={wait_s}s (verification serveur)"
)
else:
result["error"] = f"Type d'action inconnu : {action_type}"
logger.warning(result["error"])
@@ -233,8 +295,41 @@ class ActionExecutorV1:
result["success"] = True
# Capturer un screenshot post-action
time.sleep(0.5)
# ---- Verification post-action : l'ecran a-t-il change ? ----
if needs_screen_check and hash_before:
screen_changed = self._wait_for_screen_change(
hash_before, timeout_ms=5000
)
if not screen_changed:
# Ecran inchange — tenter de gerer une popup imprevue
# (dialogue de confirmation, erreur, etc.)
popup_handled = self._handle_possible_popup()
if popup_handled:
result["warning"] = "popup_handled"
print(
f" [OK] Popup geree automatiquement apres {action_type}"
)
logger.info(
f"Action {action_id} ({action_type}) : popup geree "
f"automatiquement"
)
else:
result["warning"] = "no_screen_change"
print(
f" [WARN] Ecran inchange apres {action_type}"
f"l'action n'a peut-etre pas eu d'effet"
)
logger.warning(
f"Action {action_id} ({action_type}) : ecran inchange "
f"apres 5s — possible echec silencieux"
)
else:
print(f" [OK] Changement d'ecran detecte apres {action_type}")
else:
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
time.sleep(0.5)
# Capturer un screenshot post-action (apres stabilisation)
result["screenshot"] = self._capture_screenshot_b64()
except Exception as e:
@@ -251,17 +346,18 @@ class ActionExecutorV1:
"""
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
Capture l'ecran en haute resolution (pas de downscale pour le template
matching), l'encode en base64 JPEG, et POST au endpoint
/replay/resolve_target. Retourne les coordonnees resolues.
Capture l'ecran en resolution native (pas de downscale, necessaire pour
le template matching precis cross-resolution), l'encode en base64 JPEG,
et POST au endpoint /replay/resolve_target. Retourne les coordonnees resolues.
"""
import requests
try:
# Capturer à 1280px max — assez pour le template matching
# et raisonnable pour le transfert réseau (~200-400Ko)
# Capturer à résolution native pour le template matching
# (le downscale nuit à la précision du matching quand les
# résolutions d'apprentissage et de replay diffèrent)
screenshot_b64 = self._capture_screenshot_b64(
max_width=1280,
max_width=0,
quality=75,
)
if not screenshot_b64:
@@ -283,9 +379,10 @@ class ActionExecutorV1:
"fallback_y_pct": fallback_y,
"screen_width": screen_width,
"screen_height": screen_height,
"strict_mode": True, # Replay = seuil strict 0.90 + YOLO
}
resp = requests.post(resolve_url, json=payload, timeout=60)
resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
if resp.ok:
data = resp.json()
method = data.get("method", "?")
@@ -333,12 +430,24 @@ class ActionExecutorV1:
resp = requests.get(
replay_next_url,
params={"session_id": session_id, "machine_id": machine_id},
headers=self._auth_headers(),
timeout=5,
)
if not resp.ok:
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
# Backoff sur erreur HTTP (serveur en erreur, route inconnue, etc.)
self._poll_backoff = min(
self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max,
)
return False
# Le serveur a repondu 200 — reset le backoff immediatement,
# meme s'il n'y a pas d'action en attente. Cela garantit que
# l'agent reprend un polling rapide des que le serveur est OK.
self._poll_backoff = self._poll_backoff_min
self._last_conn_error_logged = False
data = resp.json()
action = data.get("action")
if action is None:
@@ -350,7 +459,7 @@ class ActionExecutorV1:
self._poll_backoff * self._poll_backoff_factor,
self._poll_backoff_max,
)
if not hasattr(self, '_last_conn_error_logged'):
if not hasattr(self, '_last_conn_error_logged') or not self._last_conn_error_logged:
self._last_conn_error_logged = True
print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}")
logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}")
@@ -364,10 +473,6 @@ class ActionExecutorV1:
logger.error(f"Erreur poll GET : {e}")
return False
# Reset du flag d'erreur connexion et du backoff (on a reussi le GET)
self._last_conn_error_logged = False
self._poll_backoff = self._poll_backoff_min
# Phase 2 : Executer l'action et rapporter le resultat
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
action_type = action.get('type', '?')
@@ -402,12 +507,14 @@ class ActionExecutorV1:
"action_id": result["action_id"],
"success": result["success"],
"error": result.get("error"),
"warning": result.get("warning"),
"screenshot": result.get("screenshot"),
}
try:
resp2 = requests.post(
replay_result_url,
json=report,
headers=self._auth_headers(),
timeout=10,
)
if resp2.ok:
@@ -427,10 +534,167 @@ class ActionExecutorV1:
return True
# =========================================================================
# Gestion automatique des popups imprevues
# =========================================================================
def _handle_possible_popup(self) -> bool:
"""Tenter de gerer une popup imprevue.
Appelee quand l'ecran n'a pas change apres une action click ou key_combo,
ce qui peut indiquer l'apparition d'une popup modale (dialogue de
confirmation "Voulez-vous remplacer ?", erreur, etc.) qui bloque
l'interaction attendue.
Strategie simple (non bloquante, max ~3s) :
1. Essayer Enter (valide le bouton par defaut de la popup)
2. Si ca ne marche pas, essayer Escape (ferme la popup)
3. Si ca ne marche pas, essayer Tab + Enter (selectionne "Oui" puis valide)
ATTENTION : ne PAS appeler pour les actions 'type' (la saisie de texte
ne change pas forcement l'ecran de facon detectable).
Returns:
True si une popup a ete geree (l'ecran a change), False sinon.
"""
hash_before = self._quick_screenshot_hash()
if not hash_before:
return False
strategies = [
("Enter", lambda: self._press_key(Key.enter)),
("Escape", lambda: self._press_key(Key.esc)),
("Tab+Enter", lambda: self._press_tab_enter()),
]
for name, action_fn in strategies:
logger.info(f"Popup handler : tentative {name}")
print(f" [POPUP] Tentative : {name}")
action_fn()
# Attendre max 1s pour voir si l'ecran change (non bloquant)
changed = self._wait_for_screen_change(hash_before, timeout_ms=1000)
if changed:
logger.info(f"Popup handler : {name} a fonctionne (ecran change)")
print(f" [POPUP] {name} a fonctionne — popup geree")
return True
logger.info("Popup handler : aucune strategie n'a fonctionne")
print(" [POPUP] Aucune strategie n'a fonctionne")
return False
def _press_key(self, key):
"""Appuyer et relacher une touche unique."""
self.keyboard.press(key)
self.keyboard.release(key)
def _press_tab_enter(self):
"""Tab puis Enter (selectionner le bouton suivant puis valider)."""
self.keyboard.press(Key.tab)
self.keyboard.release(Key.tab)
time.sleep(0.1)
self.keyboard.press(Key.enter)
self.keyboard.release(Key.enter)
# =========================================================================
# Verification post-action (comparaison screenshots avant/apres)
# =========================================================================
def _quick_screenshot_hash(self) -> str:
"""Hash rapide du screenshot actuel (MD5 de l'image redimensionnee 64x64 en niveaux de gris).
Utilise une instance mss locale pour la thread-safety.
Retourne une chaine vide en cas d'erreur (PIL absent, etc.).
"""
try:
from PIL import Image
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner a 64x64 en niveaux de gris pour un hash perceptuel rapide
small = img.resize((64, 64)).convert("L")
return hashlib.md5(small.tobytes()).hexdigest()
except Exception as e:
logger.debug(f"Impossible de calculer le hash screenshot : {e}")
return ""
def _wait_for_screen_change(self, hash_before: str, timeout_ms: int = 5000) -> bool:
"""Attendre que l'ecran change apres une action (max timeout_ms).
Verifie toutes les 200ms si le hash du screenshot a change.
Retourne True si l'ecran a change, False si timeout atteint.
"""
if not hash_before:
return True # Pas de reference → considerer comme change
deadline = time.time() + timeout_ms / 1000
check_count = 0
while time.time() < deadline:
time.sleep(0.2) # 200ms entre chaque verification
current_hash = self._quick_screenshot_hash()
check_count += 1
if current_hash and current_hash != hash_before:
logger.info(f"Ecran change apres ~{check_count * 200}ms")
return True
logger.warning(
f"Ecran inchange apres {timeout_ms}ms ({check_count} verifications)"
)
return False
# =========================================================================
# Helpers
# =========================================================================
def _type_text(self, text: str):
"""Saisir du texte via copier-coller (methode principale) ou keyboard.type (fallback).
Le copier-coller via le presse-papiers est la methode principale car
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci").
Le copier-coller est agnostique du layout clavier.
"""
if not text:
return
clipboard_ok = False
try:
import pyperclip
# Sauvegarder le contenu actuel du presse-papiers
try:
old_clipboard = pyperclip.paste()
except Exception:
old_clipboard = None
pyperclip.copy(text)
# Ctrl+V pour coller
self.keyboard.press(Key.ctrl)
time.sleep(0.02)
self.keyboard.press('v')
self.keyboard.release('v')
self.keyboard.release(Key.ctrl)
time.sleep(0.1)
# Restaurer le presse-papiers original
if old_clipboard is not None:
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
clipboard_ok = True
logger.debug(f"Texte saisi via presse-papiers ({len(text)} chars)")
except ImportError:
logger.debug("pyperclip non disponible, fallback sur keyboard.type()")
except Exception as e:
logger.warning(f"Copier-coller echoue ({e}), fallback sur keyboard.type()")
if not clipboard_ok:
self.keyboard.type(text)
def _click(self, pos, button_name):
"""Deplacer la souris et cliquer.
@@ -501,8 +765,12 @@ class ActionExecutorV1:
try:
from PIL import Image
monitor = self.sct.monitors[1]
raw = self.sct.grab(monitor)
# Créer une instance mss locale (thread-safe)
# mss utilise des handles Windows thread-local (srcdc, memdc)
# qui ne peuvent pas être partagés entre threads
with mss.mss() as local_sct:
monitor = local_sct.monitors[1]
raw = local_sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
# Redimensionner si max_width > 0
@@ -519,5 +787,7 @@ class ActionExecutorV1:
logger.debug("PIL non disponible, pas de screenshot base64")
return ""
except Exception as e:
logger.debug(f"Capture screenshot base64 echouee : {e}")
logger.warning(f"Capture screenshot base64 echouee : {e}")
import traceback
traceback.print_exc()
return ""