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