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,25 @@ 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.
# Sur Linux/Mac : no-op silencieux.
# 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:
# Fallback pour Windows < 8.1 (API plus ancienne)
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
AGENT_VERSION = "1.0.0"
# Identifiant unique de la machine (utilisé pour le multi-machine)
@@ -34,7 +53,7 @@ MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
SESSIONS_ROOT = BASE_DIR / "sessions"
# Paramètres Vision (Crops pour qwen3-vl)
TARGETED_CROP_SIZE = (400, 400)
TARGETED_CROP_SIZE = (150, 150)
SCREENSHOT_QUALITY = 85
# Floutage des données sensibles (conformité AI Act)
@@ -52,6 +71,22 @@ PERF_MONITOR_INTERVAL_S = 30
LOGS_DIR = BASE_DIR / "logs"
LOG_FILE = LOGS_DIR / "agent_v1.log"
# --- Métadonnées système (capturées au chargement du module) ---
# Utilisées pour la bannière de démarrage et le diagnostic.
# Import tardif pour éviter les dépendances circulaires.
try:
from .vision.system_info import get_dpi_scale, get_os_theme, get_monitor_info
_monitor_index, _monitors = get_monitor_info()
_primary = _monitors[0] if _monitors else {"width": 1920, "height": 1080}
SCREEN_RESOLUTION = (_primary["width"], _primary["height"])
DPI_SCALE = get_dpi_scale()
OS_THEME = get_os_theme()
except Exception:
# Fallback silencieux si les métadonnées ne sont pas disponibles
SCREEN_RESOLUTION = (1920, 1080)
DPI_SCALE = 100
OS_THEME = "unknown"
# Création des dossiers
os.makedirs(SESSIONS_ROOT, exist_ok=True)
os.makedirs(LOGS_DIR, exist_ok=True)

View File

@@ -10,11 +10,20 @@ Fonctionnalités :
- Buffer de saisie texte : accumule les frappes et émet un événement
text_input après 500ms d'inactivité clavier
- Surveillance du focus fenêtre
NOTE DPI : Les coordonnees retournees par pynput dependent du DPI awareness
du process. Quand SetProcessDpiAwareness(2) est appele (dans config.py),
pynput retourne des coordonnees en pixels PHYSIQUES. Les metadonnees
screen_metadata (resolution via mss) sont aussi en pixels physiques.
Ceci garantit que la normalisation pos/resolution est coherente.
Sans DPI awareness, pynput retourne des coordonnees LOGIQUES mais mss
retourne des pixels physiques, ce qui cause une erreur de normalisation.
"""
import threading
import time
import logging
import platform
from typing import Callable, Optional, List, Dict, Any, Tuple
from pynput import mouse, keyboard
from pynput.mouse import Button
@@ -22,10 +31,14 @@ 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 ..monitoring.system import SystemMonitor
logger = logging.getLogger(__name__)
# Détection Windows une seule fois au chargement du module
IS_WINDOWS = platform.system() == "Windows"
# Délai d'inactivité avant flush du buffer texte (en secondes)
TEXT_FLUSH_DELAY = 0.5
# Délai max entre deux clics pour un double-clic (en secondes)
@@ -57,6 +70,11 @@ class EventCaptorV1:
self._text_start_pos: Optional[Tuple[int, int]] = None
# Timer pour le flush après inactivité
self._text_flush_timer: Optional[threading.Timer] = None
# Compteur de génération pour éviter qu'un timer obsolète ne flush
# un buffer en cours de remplissage (race condition). Incrémenté
# à chaque reset du timer. Le timer ne flush que si la génération
# n'a pas changé.
self._text_flush_generation: int = 0
# Dernière position connue de la souris (pour associer le texte
# au champ dans lequel l'utilisateur tape)
self._last_mouse_pos: Tuple[int, int] = (0, 0)
@@ -65,6 +83,17 @@ class EventCaptorV1:
# Dernier clic : (x, y, timestamp, button)
self._last_click: Optional[Tuple[int, int, float, str]] = None
# --- Buffer de raw_keys (press/release bruts avec vk codes) ---
# Accumule chaque press/release pour le replay exact (solution AZERTY).
# Vidé en même temps que le text_buffer ou à l'émission d'un key_combo.
self._raw_key_buffer: List[Dict[str, Any]] = []
# --- Métadonnées système (DPI, résolution, moniteur, thème, langue) ---
# Capturées au démarrage puis rafraîchies à chaque changement de focus.
# Injectées dans chaque événement via le champ "screen_metadata".
self._screen_metadata: Dict[str, Any] = {}
self._screen_metadata_lock = threading.Lock()
def start(self):
self.running = True
self.mouse_listener = mouse.Listener(
@@ -80,6 +109,9 @@ class EventCaptorV1:
self.mouse_listener.start()
self.keyboard_listener.start()
# Capture initiale des métadonnées système
self._refresh_screen_metadata()
# Thread de surveillance du focus fenêtre (Proactif)
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
self._focus_thread.start()
@@ -131,6 +163,7 @@ class EventCaptorV1:
"pos": (x, y),
"timestamp": now,
}
self._inject_screen_metadata(event)
self.on_event(event)
# Réinitialiser pour éviter un triple-clic = 2 double-clics
self._last_click = None
@@ -144,6 +177,7 @@ class EventCaptorV1:
"pos": (x, y),
"timestamp": now,
}
self._inject_screen_metadata(event)
self.on_event(event)
def _on_scroll(self, x, y, dx, dy):
@@ -168,7 +202,106 @@ class EventCaptorV1:
return key.name
return str(key)
# Ensemble des touches considérées comme modificateurs purs.
# Utilisé pour ne PAS émettre de key_combo quand seuls des
# modificateurs sont enfoncés (évite le bruit).
_MODIFIER_KEYS = {
Key.ctrl, Key.ctrl_l, Key.ctrl_r,
Key.alt, Key.alt_l, Key.alt_r,
Key.shift, Key.shift_l, Key.shift_r,
Key.cmd, Key.cmd_l, Key.cmd_r,
}
_MODIFIER_KEY_NAMES = {
"ctrl", "ctrl_l", "ctrl_r",
"alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"cmd", "cmd_l", "cmd_r",
}
@staticmethod
def _vk_to_char(vk_code: int) -> Optional[str]:
"""Convertir un virtual key code en caractère réel (AZERTY-aware).
Utilise ToUnicodeEx avec le layout clavier actif pour obtenir
le bon caractère même pour les touches AltGr, Shift+chiffres,
et autres combinaisons spécifiques au layout (AZERTY, QWERTZ, etc.).
Ne fonctionne que sur Windows. Retourne None sur Linux/Mac.
"""
if not IS_WINDOWS:
return None
try:
import ctypes
import ctypes.wintypes as wt
user32 = ctypes.windll.user32
kbd_state = (ctypes.c_ubyte * 256)()
user32.GetKeyboardState(kbd_state)
buf = (ctypes.c_wchar * 8)()
scan = user32.MapVirtualKeyW(vk_code, 0)
# Layout du thread de la fenêtre active (gère AZERTY, QWERTZ, etc.)
hwnd = user32.GetForegroundWindow()
tid = user32.GetWindowThreadProcessId(hwnd, None)
hkl = user32.GetKeyboardLayout(tid)
n = user32.ToUnicodeEx(vk_code, scan, kbd_state, buf, 8, 0, hkl)
if n > 0:
return buf[0]
except Exception:
pass
return None
def _is_altgr_producing_char(self, key) -> Optional[str]:
"""Détecte si la combinaison actuelle est AltGr+touche produisant un caractère.
Sur Windows AZERTY, AltGr est envoyé comme Ctrl+Alt par pynput.
Cette méthode vérifie si Ctrl+Alt est enfoncé et que la touche
produit un caractère imprimable via le layout clavier.
Ex: AltGr+é → ~, AltGr+( → {, AltGr+à → @
Retourne le caractère produit ou None si ce n'est pas un AltGr valide.
"""
if not IS_WINDOWS:
return None
# AltGr = Ctrl+Alt (sans Win) sur Windows
if self.modifiers != {"ctrl", "alt"} and self.modifiers != {"ctrl", "alt", "shift"}:
return None
# Ne s'applique qu'aux touches non-modificatrices
if key in self._MODIFIER_KEYS:
return None
# Essayer de résoudre le caractère via ToUnicodeEx
# Le keyboard state inclut déjà Ctrl+Alt (= AltGr) grâce à GetKeyboardState
vk = getattr(key, 'vk', None)
if vk is not None:
char = self._vk_to_char(vk)
if char is not None and len(char) == 1 and (char.isprintable() and char != ' '):
return char
return None
@staticmethod
def _encode_key(key) -> Dict[str, Any]:
"""Encode un objet pynput Key/KeyCode en dictionnaire sérialisable.
Utilisé pour constituer le buffer raw_keys (séquence press/release
exacte avec virtual key codes) qui permet un replay fidèle
indépendant du layout clavier (AZERTY, QWERTZ, etc.).
"""
if isinstance(key, KeyCode):
return {"kind": "vk", "vk": key.vk, "char": key.char}
if isinstance(key, Key):
return {"kind": "key", "name": key.name}
return {"kind": "unknown", "str": str(key)}
def _on_press(self, key):
# TOUJOURS enregistrer le press brut dans le buffer raw_keys
with self._text_lock:
self._raw_key_buffer.append({
"action": "press",
**self._encode_key(key),
})
# Gestion des touches modificatrices
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.add("ctrl")
@@ -176,26 +309,54 @@ class EventCaptorV1:
self.modifiers.add("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.add("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.add("win")
# --- Combos avec modificateur (sauf Shift seul) ---
# Shift seul n'est pas un « vrai » modificateur pour les combos :
# Shift+a = 'A' = saisie texte, pas un raccourci.
# On considère un combo seulement si Ctrl ou Alt est enfoncé.
has_real_modifier = self.modifiers & {"ctrl", "alt"}
# On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
if has_real_modifier:
# --- Détection AltGr (Windows AZERTY) ---
# Sur Windows, AltGr est envoyé comme Ctrl+Alt par le système.
# Avant de traiter comme un key_combo, vérifier si c'est
# AltGr qui produit un caractère imprimable (@, #, {, }, etc.)
altgr_char = self._is_altgr_producing_char(key)
if altgr_char is not None:
# C'est un caractère AltGr → router vers le buffer texte
with self._text_lock:
if not self._text_buffer:
self._text_start_pos = self._last_mouse_pos
self._text_buffer.append(altgr_char)
self._reset_flush_timer()
return
key_name = self._get_key_name(key)
if key_name and key_name not in ("ctrl", "alt", "shift"):
# Ne PAS émettre de combo si c'est un modificateur seul
# (ex: appui sur Ctrl sans autre touche = pas de combo)
if key_name and key_name not in self._MODIFIER_KEY_NAMES:
# Un combo interrompt la saisie texte en cours
self._flush_text_buffer()
# Attacher les raw_keys accumulés (press des modificateurs + press de la touche)
with self._text_lock:
raw_keys = list(self._raw_key_buffer)
# NB: on ne clear pas encore — le release va suivre et sera
# capturé pour le prochain buffer. On prend un snapshot.
event = {
"type": "key_combo",
"keys": list(self.modifiers) + [key_name],
"raw_keys": raw_keys,
"timestamp": time.time(),
}
self._inject_screen_metadata(event)
self.on_event(event)
# Reset le buffer raw_keys après émission du combo
with self._text_lock:
self._raw_key_buffer.clear()
return
# --- Saisie texte (pas de Ctrl/Alt enfoncé) ---
# --- Saisie texte (pas de Ctrl/Alt/Win enfoncé) ---
self._handle_text_key(key)
def _handle_text_key(self, key):
@@ -217,6 +378,7 @@ class EventCaptorV1:
if key == Key.esc:
# Annuler la saisie en cours
self._text_buffer.clear()
self._raw_key_buffer.clear()
self._text_start_pos = None
self._cancel_flush_timer()
return
@@ -234,31 +396,65 @@ class EventCaptorV1:
self._reset_flush_timer()
return
elif isinstance(key, KeyCode) and key.char is not None:
elif isinstance(key, KeyCode):
# Caractère alphanumérique / ponctuation
# pynput renvoie déjà le bon caractère selon le layout
# (AZERTY inclus) — on ne convertit rien.
if not self._text_buffer:
self._text_start_pos = self._last_mouse_pos
self._text_buffer.append(key.char)
self._reset_flush_timer()
char = key.char
# AZERTY Windows : quand key.char est None (Shift+chiffres,
# dead keys, etc.), utiliser ToUnicodeEx avec le layout clavier
# actif pour obtenir le vrai caractère traduit par Windows.
if char is None and IS_WINDOWS:
vk = getattr(key, 'vk', None)
if vk is not None:
char = self._vk_to_char(vk)
if char is not None and len(char) == 1:
if not self._text_buffer:
self._text_start_pos = self._last_mouse_pos
self._text_buffer.append(char)
self._reset_flush_timer()
return
# key.char None et pas de vk exploitable → ignorer
return
else:
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
return
# Si on arrive ici, c'est Enter ou Tab → flush immédiat
# Si on arrive ici, c'est Enter ou Tab → flush le buffer en cours
# puis émettre le caractère spécial comme text_input séparé
self._flush_text_buffer()
# Émettre Enter comme "\n" et Tab comme "\t" pour ne pas perdre
# les retours à la ligne dans la saisie.
# Attacher les raw_keys restants (press de Enter/Tab, le release suivra)
with self._text_lock:
raw_keys = list(self._raw_key_buffer)
self._raw_key_buffer.clear()
special_char = "\n" if key == Key.enter else "\t"
event = {
"type": "text_input",
"text": special_char,
"pos": list(self._last_mouse_pos) if self._last_mouse_pos else [0, 0],
"timestamp": time.time(),
}
if raw_keys:
event["raw_keys"] = raw_keys
self.on_event(event)
def _reset_flush_timer(self):
"""Réarme le timer de flush après chaque frappe.
Doit être appelé avec self._text_lock déjà acquis.
Utilise un compteur de génération pour garantir que seul le
dernier timer programmé puisse effectivement flush le buffer.
"""
if self._text_flush_timer is not None:
self._text_flush_timer.cancel()
self._text_flush_generation += 1
gen = self._text_flush_generation
self._text_flush_timer = threading.Timer(
TEXT_FLUSH_DELAY, self._flush_text_buffer
TEXT_FLUSH_DELAY, self._flush_text_buffer_if_current, args=(gen,)
)
self._text_flush_timer.daemon = True
self._text_flush_timer.start()
@@ -272,18 +468,30 @@ class EventCaptorV1:
self._text_flush_timer.cancel()
self._text_flush_timer = None
def _flush_text_buffer_if_current(self, generation: int):
"""Appelé par le timer. Ne flush que si la génération correspond
à celle du timer en cours (= pas de frappe entre-temps)."""
with self._text_lock:
if generation != self._text_flush_generation:
# Un timer plus récent a été programmé, celui-ci est obsolète
return
self._flush_text_buffer()
def _flush_text_buffer(self):
"""Émet un événement text_input avec le contenu du buffer, puis
le vide. Thread-safe — peut être appelé depuis le timer, le
listener souris ou le listener clavier."""
with self._text_lock:
if not self._text_buffer:
# Rien à émettre
# Rien à émettre — purger aussi les raw_keys orphelins
self._raw_key_buffer.clear()
self._cancel_flush_timer()
return
text = "".join(self._text_buffer)
pos = self._text_start_pos or self._last_mouse_pos
raw_keys = list(self._raw_key_buffer)
self._text_buffer.clear()
self._raw_key_buffer.clear()
self._text_start_pos = None
self._cancel_flush_timer()
@@ -295,32 +503,75 @@ class EventCaptorV1:
"pos": pos,
"timestamp": time.time(),
}
logger.debug(f"text_input émis : {len(text)} caractères")
# Attacher les raw_keys pour le replay exact (solution AZERTY)
if raw_keys:
event["raw_keys"] = raw_keys
self._inject_screen_metadata(event)
logger.debug(f"text_input émis : {len(text)} caractères, {len(raw_keys)} raw_keys")
self.on_event(event)
def _on_release(self, key):
# TOUJOURS enregistrer le release brut dans le buffer raw_keys
with self._text_lock:
self._raw_key_buffer.append({
"action": "release",
**self._encode_key(key),
})
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
self.modifiers.discard("ctrl")
elif key in (Key.alt, Key.alt_l, Key.alt_r):
self.modifiers.discard("alt")
elif key in (Key.shift, Key.shift_l, Key.shift_r):
self.modifiers.discard("shift")
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
self.modifiers.discard("win")
# ----------------------------------------------------------------
# Métadonnées système
# ----------------------------------------------------------------
def _refresh_screen_metadata(self):
"""Rafraîchit le cache des métadonnées système.
Appelé au démarrage et à chaque changement de focus fenêtre.
Thread-safe — peut être appelé depuis le thread focus.
"""
try:
metadata = get_screen_metadata()
with self._screen_metadata_lock:
self._screen_metadata = metadata
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
except Exception as e:
logger.error(f"Erreur refresh métadonnées système : {e}")
def _inject_screen_metadata(self, event: Dict[str, Any]) -> Dict[str, Any]:
"""Injecte les métadonnées système cachées dans un événement."""
with self._screen_metadata_lock:
if self._screen_metadata:
event["screen_metadata"] = self._screen_metadata.copy()
return event
def _watch_window_focus(self):
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""
# Importation relative simple
from ..window_info_crossplatform import get_active_window_info
while self.running:
try:
info = get_active_window_info()
if info and info != self.last_window:
# Rafraîchir les métadonnées (la fenêtre a peut-être
# changé de moniteur, de taille, etc.)
self._refresh_screen_metadata()
event = {
"type": "window_focus_change",
"from": self.last_window,
"to": info,
"timestamp": time.time()
}
self._inject_screen_metadata(event)
self.last_window = info
self.on_event(event)
except Exception as e:

View File

@@ -6,17 +6,28 @@ 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
from pynput.keyboard import Controller as KeyboardController, Key, KeyCode
logger = logging.getLogger(__name__)
@@ -68,6 +79,20 @@ class ActionExecutorV1:
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."""
@@ -180,11 +205,30 @@ 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":
# Si visual_mode est activé, le resolve DOIT réussir.
# Pas de fallback blind — on arrête le replay si la cible
# n'est pas trouvée visuellement. C'est un RPA VISUEL.
if visual_mode and not result.get("visual_resolved"):
result["success"] = False
result["error"] = "Visual resolve échoué — cible non trouvée à l'écran"
print(f" [ERREUR] Visual resolve échoué — STOP (pas de clic blind)")
logger.error(f"Action {action_id} : visual resolve échoué, replay stoppé")
return result
real_x = int(x_pct * width)
real_y = int(y_pct * height)
button = action.get("button", "left")
mode = "VISUAL" if result["visual_resolved"] else "BLIND"
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
print(
f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> "
f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}"
@@ -198,7 +242,10 @@ class ActionExecutorV1:
elif action_type == "type":
text = action.get("text", "")
raw_keys = action.get("raw_keys")
print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)")
if raw_keys:
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
if x_pct > 0 and y_pct > 0:
real_x = int(x_pct * width)
@@ -206,16 +253,26 @@ 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)
if raw_keys:
self._replay_raw_keys(raw_keys)
else:
# Fallback copier-coller (anciens enregistrements sans raw_keys)
self._type_text(text)
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "key_combo":
keys = action.get("keys", [])
raw_keys = action.get("raw_keys")
print(f" [KEY_COMBO] Touches: {keys}")
self._execute_key_combo(keys)
if raw_keys:
print(f" [KEY_COMBO] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
self._replay_raw_keys(raw_keys)
else:
# Fallback (anciens enregistrements sans raw_keys)
self._execute_key_combo(keys)
print(f" [KEY_COMBO] Termine.")
logger.info(f"Replay key_combo : {keys}")
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
elif action_type == "scroll":
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
@@ -235,6 +292,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"])
@@ -242,8 +318,33 @@ class ActionExecutorV1:
result["success"] = True
# Capturer un screenshot post-action
time.sleep(0.5)
# ---- Verification post-action : l'ecran a-t-il change ? ----
# Verifie UNIQUEMENT, ne tente PAS de gerer les popups
# (Enter/Escape perturbent l'application).
# Signale l'echec honnêtement — le serveur decide du retry.
if needs_screen_check and hash_before:
screen_changed = self._wait_for_screen_change(
hash_before, timeout_ms=3000
)
if not screen_changed:
result["success"] = False
result["warning"] = "no_screen_change"
result["error"] = "Ecran inchange apres l'action"
print(
f" [ECHEC] Ecran inchange apres {action_type}"
f"l'action n'a pas eu d'effet visible"
)
logger.warning(
f"Action {action_id} ({action_type}) : ecran inchange "
f"— action sans effet visible"
)
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:
@@ -257,62 +358,136 @@ class ActionExecutorV1:
fallback_x: float, fallback_y: float,
screen_width: int, screen_height: int,
) -> dict:
"""
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
"""Résoudre la position d'un clic visuellement.
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.
"""
import requests
Stratégie VLM-DIRECT : appelle Ollama directement depuis l'agent
(pas via le serveur streaming) pour éviter les timeouts quand le
serveur est occupé par le worker.
1. VLM direct (screenshot + crop → Ollama) ~3-8s
2. Serveur streaming (fallback si Ollama échoue)
"""
import requests as _requests
import json as _json
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
if not screenshot_b64:
logger.warning("Capture screenshot echouee pour visual resolve")
return None
# ---- VLM DIRECT (Ollama) ----
vlm_result = self._vlm_direct_resolve(screenshot_b64, target_spec)
if vlm_result and vlm_result.get("resolved"):
return vlm_result
# ---- FALLBACK : serveur streaming ----
print(" [VISUAL] VLM direct echoue, fallback serveur...")
try:
# Capturer à 1280px max — assez pour le template matching
# et raisonnable pour le transfert réseau (~200-400Ko)
screenshot_b64 = self._capture_screenshot_b64(
max_width=1280,
quality=75,
)
if not screenshot_b64:
logger.warning("Capture screenshot echouee pour visual resolve")
return None
print(
f" [VISUAL] Envoi screenshot ({len(screenshot_b64) // 1024} Ko) "
f"au serveur pour resolution..."
)
# Appel au serveur
resolve_url = f"{server_url}/traces/stream/replay/resolve_target"
payload = {
"session_id": "", # Pas critique pour la resolution
"session_id": "",
"screenshot_b64": screenshot_b64,
"target_spec": target_spec,
"fallback_x_pct": fallback_x,
"fallback_y_pct": fallback_y,
"screen_width": screen_width,
"screen_height": screen_height,
"strict_mode": True,
}
resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
resp = _requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=30)
if resp.ok:
data = resp.json()
method = data.get("method", "?")
resolved = data.get("resolved", False)
print(
f" [VISUAL] Reponse serveur : resolved={resolved}, "
f"method={method}, score={data.get('score', 'N/A')}"
)
print(f" [VISUAL] Serveur : resolved={data.get('resolved')}, method={data.get('method')}")
return data
else:
logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as e:
logger.warning(f"Visual resolve serveur echoue: {e}")
return None
def _vlm_direct_resolve(self, screenshot_b64: str, target_spec: dict) -> dict:
"""Appeler Ollama directement pour trouver l'élément à l'écran."""
import requests as _requests
import json as _json
import re
anchor_b64 = target_spec.get("anchor_image_base64", "")
vlm_description = target_spec.get("vlm_description", "")
by_text = target_spec.get("by_text", "")
window_title = target_spec.get("window_title", "")
if not anchor_b64 and not vlm_description:
return None
# Prompt
if anchor_b64 and vlm_description:
prompt = f"""The first image is the current screen. The second image shows the element to find.
{vlm_description}
Return the CENTER coordinates as percentage of the FIRST image dimensions.
Return ONLY JSON: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}
If not found: {{"x_pct": null, "y_pct": null, "confidence": 0.0}}"""
elif vlm_description:
prompt = f"""{vlm_description}
Return coordinates as percentage: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
else:
prompt = f"""Find the element shown in the second image on the first image.
Return coordinates: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
images = [screenshot_b64]
if anchor_b64:
images.append(anchor_b64)
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
ollama_url = f"http://{ollama_host}:11434/api/chat"
payload = {
"model": os.environ.get("RPA_VLM_MODEL", "qwen3-vl:8b"),
"messages": [
{"role": "system", "content": "You are a UI element locator. Output raw JSON only."},
{"role": "user", "content": prompt, "images": images},
{"role": "assistant", "content": "{"},
],
"stream": False,
"think": False,
"options": {"temperature": 0.1, "num_predict": 100, "num_ctx": 2048},
}
try:
print(f" [VLM-DIRECT] Appel Ollama ({ollama_host}:11434)...")
start = time.time()
resp = _requests.post(ollama_url, json=payload, timeout=30)
elapsed = time.time() - start
if not resp.ok:
print(f" [VLM-DIRECT] HTTP {resp.status_code} ({elapsed:.1f}s)")
return None
except requests.exceptions.Timeout:
logger.warning("Visual resolve timeout (30s)")
content = "{" + resp.json().get("message", {}).get("content", "")
print(f" [VLM-DIRECT] Réponse en {elapsed:.1f}s : {content[:80]}")
# Parser JSON
match = re.search(r'\{[^}]+\}', content)
if not match:
return None
data = _json.loads(match.group())
x = data.get("x_pct")
y = data.get("y_pct")
conf = data.get("confidence", 0)
if x is None or y is None or conf < 0.3:
print(f" [VLM-DIRECT] Non trouvé (conf={conf})")
return None
if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
print(f" [VLM-DIRECT] Hors limites ({x}, {y})")
return None
print(f" [VLM-DIRECT] TROUVÉ ({x:.3f}, {y:.3f}) conf={conf:.2f} en {elapsed:.1f}s")
return {"resolved": True, "method": "vlm_direct", "x_pct": x, "y_pct": y, "score": conf}
except _requests.exceptions.Timeout:
print(" [VLM-DIRECT] Timeout 30s")
return None
except Exception as e:
logger.warning(f"Visual resolve echoue: {e}")
print(f" [VLM-DIRECT] Erreur: {e}")
return None
def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool:
@@ -347,8 +522,19 @@ class ActionExecutorV1:
)
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:
@@ -360,7 +546,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}")
@@ -374,10 +560,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', '?')
@@ -412,6 +594,7 @@ class ActionExecutorV1:
"action_id": result["action_id"],
"success": result["success"],
"error": result.get("error"),
"warning": result.get("warning"),
"screenshot": result.get("screenshot"),
}
try:
@@ -438,10 +621,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.
@@ -500,6 +840,50 @@ class ActionExecutorV1:
for mod in reversed(modifiers):
self.keyboard.release(mod)
def _replay_raw_keys(self, raw_keys: list):
"""Rejouer une séquence press/release exacte via virtual key codes.
Utilise KeyCode.from_vk() pour reconstituer les touches à partir
de leur vk code, ce qui garantit un replay fidèle indépendant du
layout clavier (AZERTY, QWERTZ, etc.).
Chaque événement raw_key est un dict avec :
- "action": "press" ou "release"
- "kind": "vk" (touche avec virtual key code) ou "key" (touche spéciale pynput)
- "vk": int (si kind == "vk")
- "name": str (si kind == "key", ex: "ctrl_l", "enter")
- "char": str ou None (si kind == "vk", informatif)
"""
for event in raw_keys:
key = self._decode_raw_key(event)
if key is None:
continue
action = event.get("action", "")
if action == "press":
self.keyboard.press(key)
elif action == "release":
self.keyboard.release(key)
else:
logger.warning(f"Action raw_key inconnue : {action}")
continue
time.sleep(0.01) # Petit délai entre chaque événement
@staticmethod
def _decode_raw_key(data: dict):
"""Décoder un événement raw_key en objet pynput (Key ou KeyCode).
Retourne None si le décodage échoue (touche inconnue).
"""
kind = data.get("kind", "")
if kind == "key":
name = data.get("name", "")
return getattr(Key, name, None)
if kind == "vk":
vk = data.get("vk")
if vk is not None:
return KeyCode.from_vk(vk)
return None
def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str:
"""
Capturer l'ecran et retourner le screenshot en base64.
@@ -512,8 +896,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
@@ -530,5 +918,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 ""

View File

@@ -14,7 +14,10 @@ import uuid
import time
import logging
import threading
from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS
from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME,
)
from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1
from .network.streamer import TraceStreamer
@@ -103,6 +106,14 @@ class AgentV1:
self._capture_server = CaptureServer()
self._capture_server.start()
# Bannière de démarrage avec métadonnées système
logger.info(
f"Agent V1 v{AGENT_VERSION} | Machine={self.machine_id} | "
f"Ecran={SCREEN_RESOLUTION[0]}x{SCREEN_RESOLUTION[1]} | "
f"DPI={DPI_SCALE}% | Theme={OS_THEME} | "
f"Serveur={SERVER_URL}"
)
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
self.ui = SmartTrayV1(
self.start_session,
@@ -142,8 +153,9 @@ class AgentV1:
# Watchdog de Commandes (GHOST Replay — legacy fichier)
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
# Boucle de polling replay (P0-5 — pull depuis le serveur)
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
# Note: la boucle de polling replay est déjà lancée dans __init__ (ligne 102)
# 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...")
@@ -159,7 +171,7 @@ class AgentV1:
else:
cmd_path = str(BASE_DIR / "command.json")
while self.running:
while self.running and self.session_id:
# Ne pas traiter les commandes fichier pendant un replay serveur
if self._replay_active:
time.sleep(1)
@@ -197,8 +209,11 @@ class AgentV1:
time.sleep(REPLAY_POLL_INTERVAL)
continue
# Utiliser la session active ou un ID par défaut pour le replay
poll_session = self.session_id or f"agent_{self.user_id}"
# TOUJOURS utiliser un session_id stable pour le replay.
# L'enregistrement et le replay sont indépendants : le serveur
# envoie les actions sur agent_{user_id}, pas sur la session
# d'enregistrement (sess_xxx).
poll_session = f"agent_{self.user_id}"
# Log periodique pour confirmer que la boucle tourne (toutes les 60s)
poll_count += 1
@@ -290,18 +305,40 @@ class AgentV1:
time.sleep(5)
def stop_session(self):
self.running = False
# Arrêter la capture et le streaming de la session d'enregistrement
if self.captor: self.captor.stop()
if self.streamer: self.streamer.stop()
logger.info(f"Session {self.session_id} terminée.")
# Reset le session_id pour que le poll replay utilise l'ID stable
self.session_id = None
# Reset le backoff de l'executor pour reprendre le polling immédiatement
if self._executor:
self._executor._poll_backoff = self._executor._poll_backoff_min
self._executor._server_available = True
if hasattr(self._executor, '_last_conn_error_logged'):
self._executor._last_conn_error_logged = False
# NE PAS mettre self.running = False ici !
# self.running contrôle la boucle _replay_poll_loop (permanente).
# Seule la sortie du programme doit le mettre à False.
# Les boucles _heartbeat_loop et _command_watchdog_loop vérifieront
# self.session_id pour savoir si elles doivent fonctionner.
logger.info(
f"Session arrêtée — replay poll actif avec session="
f"agent_{self.user_id}"
)
_last_heartbeat_hash: str = ""
def _heartbeat_loop(self):
"""Capture périodique pour donner du contexte au stagiaire.
Déduplication : n'envoie que si l'écran a changé.
Tourne tant que session_id est défini (= enregistrement actif).
"""
while self.running:
while self.running and self.session_id:
try:
full_path = self.vision.capture_full_context("heartbeat")
if full_path:

View File

@@ -413,10 +413,8 @@ class ChatWindow:
buttons = [
("\U0001f393 Apprenez-moi", self._on_quick_record),
("\u25b6\ufe0f Lancer", self._on_quick_tasks),
("\U0001f4ca Donn\u00e9es", self._on_quick_import),
("\u25b6\ufe0f Lancer une t\u00e2che", self._on_quick_tasks),
("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop),
("\u2753 Aide", self._on_quick_help),
]
for text, cmd in buttons:

View File

@@ -0,0 +1,195 @@
# agent_v1/vision/system_info.py
"""
Capture des metadonnees systeme pour enrichir les evenements.
Collecte DPI, resolution, fenetre active, moniteur, theme OS et langue.
Les fonctions Windows (ctypes.windll, winreg) ont des fallbacks gracieux
pour Linux/Mac.
"""
import platform
import locale
import logging
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Cache du systeme d'exploitation pour eviter les appels repetes
_SYSTEM = platform.system()
def get_dpi_scale() -> int:
"""Retourne le facteur DPI en % (100 = normal, 150 = haute resolution).
Windows : ctypes.windll.user32.GetDpiForSystem()
Linux/Mac : fallback 100
NOTE : Le process DOIT deja etre DPI-aware (via SetProcessDpiAwareness(2)
appele dans config.py) pour que GetDpiForSystem retourne le vrai DPI.
"""
if _SYSTEM == "Windows":
try:
import ctypes
dpi = ctypes.windll.user32.GetDpiForSystem()
return round(dpi * 100 / 96) # 96 DPI = 100%
except Exception as e:
logger.debug(f"Impossible de lire le DPI Windows : {e}")
return 100
return 100 # Linux/Mac fallback
def get_window_bounds() -> Optional[List[int]]:
"""Retourne [x, y, width, height] de la fenetre active.
Windows : ctypes GetWindowRect(GetForegroundWindow())
Linux/Mac : fallback None
"""
if _SYSTEM == "Windows":
try:
import ctypes
import ctypes.wintypes
hwnd = ctypes.windll.user32.GetForegroundWindow()
if not hwnd:
return None
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
return [
rect.left,
rect.top,
rect.right - rect.left,
rect.bottom - rect.top,
]
except Exception as e:
logger.debug(f"Impossible de lire les bounds fenetre : {e}")
return None
# Linux : tentative via xdotool
if _SYSTEM == "Linux":
try:
import subprocess
wid = subprocess.check_output(
["xdotool", "getactivewindow"],
stderr=subprocess.DEVNULL,
).decode().strip()
geom = subprocess.check_output(
["xdotool", "getwindowgeometry", "--shell", wid],
stderr=subprocess.DEVNULL,
).decode()
# Parse "X=...\nY=...\nWIDTH=...\nHEIGHT=..."
vals: Dict[str, int] = {}
for line in geom.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
vals[k.strip()] = int(v.strip())
if {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
return [vals["X"], vals["Y"], vals["WIDTH"], vals["HEIGHT"]]
except Exception:
pass
return None
def get_monitor_info() -> Tuple[int, List[Dict[str, int]]]:
"""Retourne (monitor_index, liste_moniteurs).
Chaque moniteur : {width, height, x, y}
monitor_index : index du moniteur contenant la fenetre active
"""
monitors: List[Dict[str, int]] = []
active_index = 0
try:
import mss
with mss.mss() as sct:
for mon in sct.monitors[1:]: # Skip le moniteur virtuel (index 0)
monitors.append({
"width": mon["width"],
"height": mon["height"],
"x": mon["left"],
"y": mon["top"],
})
except Exception as e:
logger.debug(f"mss indisponible, resolution par defaut : {e}")
monitors = [{"width": 1920, "height": 1080, "x": 0, "y": 0}]
# Determiner quel moniteur contient la fenetre active
bounds = get_window_bounds()
if bounds and len(monitors) > 1:
wx, wy = bounds[0], bounds[1]
for i, mon in enumerate(monitors):
if (mon["x"] <= wx < mon["x"] + mon["width"]
and mon["y"] <= wy < mon["y"] + mon["height"]):
active_index = i
break
return active_index, monitors
def get_os_theme() -> str:
"""Retourne 'light', 'dark' ou 'unknown'."""
if _SYSTEM == "Windows":
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
)
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
winreg.CloseKey(key)
return "light" if value == 1 else "dark"
except Exception as e:
logger.debug(f"Impossible de lire le theme Windows : {e}")
return "unknown"
# Linux : tentative via gsettings (GNOME)
if _SYSTEM == "Linux":
try:
import subprocess
result = subprocess.check_output(
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
stderr=subprocess.DEVNULL,
).decode().strip().strip("'\"")
if "dark" in result.lower():
return "dark"
elif "light" in result.lower() or "default" in result.lower():
return "light"
except Exception:
pass
return "unknown"
def get_os_language() -> str:
"""Retourne le code langue (fr, en, de, etc.)."""
try:
lang = locale.getdefaultlocale()[0] # ex: 'fr_FR'
if lang:
return lang[:2] # ex: 'fr'
except Exception:
pass
return "unknown"
def get_screen_metadata() -> Dict[str, Any]:
"""Capture toutes les metadonnees systeme en une fois.
Appelee une fois au demarrage + a chaque changement de focus.
Resultat injecte dans les evenements envoyes au serveur.
"""
monitor_index, monitors = get_monitor_info()
primary = monitors[0] if monitors else {"width": 1920, "height": 1080}
return {
"dpi_scale": get_dpi_scale(),
"monitor_index": monitor_index,
"monitors": monitors,
"screen_resolution": [primary["width"], primary["height"]],
"window_bounds": get_window_bounds(),
"os_theme": get_os_theme(),
"os_language": get_os_language(),
}

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 ""

File diff suppressed because it is too large Load Diff

View File

@@ -158,16 +158,35 @@ class LiveSessionManager:
session.events.append(event_data)
session.last_activity = datetime.now()
# Extraire le contexte fenêtre si présent
# Format 1 : {"window": {"title": ..., "app_name": ...}} (Python agent)
# Format 2 : {"window_title": "...", "screen_resolution": [w, h]} (Rust agent)
window = event_data.get("window")
if window and isinstance(window, dict):
session.last_window_info = window
# Accumuler les titres/apps pour le nommage automatique
title = window.get("title", "").strip()
app_name = window.get("app_name", "").strip()
if title and title != "Unknown":
session.window_titles_seen[title] = session.window_titles_seen.get(title, 0) + 1
if app_name and app_name != "unknown":
session.app_names_seen[app_name] = session.app_names_seen.get(app_name, 0) + 1
elif event_data.get("window_title"):
# Format Rust agent : extraire le titre et la résolution
info = {
"title": event_data["window_title"],
"app_name": session.last_window_info.get("app_name", "unknown"),
}
# Propager la résolution si fournie par l'agent
screen_res = event_data.get("screen_resolution")
if screen_res and isinstance(screen_res, list) and len(screen_res) == 2:
info["screen_resolution"] = screen_res
# Propager les métadonnées d'environnement graphique
for meta_key in ("dpi_scale", "monitor_index", "window_bounds",
"monitors", "os_theme", "os_language"):
meta_val = event_data.get(meta_key)
if meta_val is not None:
info[meta_key] = meta_val
session.last_window_info = info
# Accumuler les titres/apps pour le nommage automatique
title = session.last_window_info.get("title", "").strip()
app_name = session.last_window_info.get("app_name", "").strip()
if title and title != "Unknown":
session.window_titles_seen[title] = session.window_titles_seen.get(title, 0) + 1
if app_name and app_name != "unknown":
session.app_names_seen[app_name] = session.app_names_seen.get(app_name, 0) + 1
self._maybe_persist(session_id)
def add_screenshot(self, session_id: str, shot_id: str, file_path: str) -> None:
@@ -227,16 +246,41 @@ class LiveSessionManager:
"captured_at": datetime.now().isoformat(),
})
# Résolution réelle depuis les events (envoyée par l'agent Rust/Python),
# fallback sur 1920x1080 si non disponible
screen_res = session.last_window_info.get("screen_resolution", [1920, 1080])
# Métadonnées d'environnement graphique dynamiques
screen_info: Dict[str, Any] = {"primary_resolution": screen_res}
dpi_scale = session.last_window_info.get("dpi_scale")
if dpi_scale is not None:
screen_info["dpi_scale"] = dpi_scale
monitors = session.last_window_info.get("monitors")
if monitors is not None:
screen_info["monitors"] = monitors
monitor_index = session.last_window_info.get("monitor_index")
if monitor_index is not None:
screen_info["monitor_index"] = monitor_index
env_info: Dict[str, Any] = {
"os": platform.system().lower(),
"hostname": socket.gethostname(),
"machine_id": session.machine_id,
"screen": screen_info,
}
# Propager os_theme / os_language si disponibles
os_theme = session.last_window_info.get("os_theme")
if os_theme is not None:
env_info["os_theme"] = os_theme
os_language = session.last_window_info.get("os_language")
if os_language is not None:
env_info["os_language"] = os_language
return {
"schema_version": "rawsession_v1",
"session_id": session.session_id,
"agent_version": "agent_v1_stream",
"environment": {
"os": platform.system().lower(),
"hostname": socket.gethostname(),
"machine_id": session.machine_id,
"screen": {"primary_resolution": [1920, 1080]},
},
"environment": env_info,
"user": {"id": "remote_agent"},
"context": {
"workflow": session.last_window_info.get("title", ""),

View File

@@ -0,0 +1,397 @@
# agent_v0/server_v1/run_worker.py
"""
Worker VLM autonome — tourne dans un process Python SEPARE du serveur HTTP.
Résout le problème du GIL : le serveur HTTP (FastAPI) reste réactif car le
VLM (ScreenAnalyzer, CLIP, FAISS, GraphBuilder) tourne dans ce process dédié.
Usage:
python -m agent_v0.server_v1.run_worker
Architecture :
Process 1 : Serveur HTTP (FastAPI, port 5005) — distribue les replays, reçoit events/images
Process 2 : Ce worker — analyse VLM des sessions finalisées
Process 3 : Ollama (port 11434) — LLM local
Communication inter-process par fichiers (pas de Redis) :
- _worker_queue.txt : liste des session_ids à traiter (ajoutés par le serveur HTTP)
- _replay_active.lock : quand présent, le worker se suspend (le GPU est utilisé par le replay)
Le worker :
1. Scanne _worker_queue.txt pour trouver les sessions à traiter
2. Vérifie _replay_active.lock avant chaque screenshot (priorité au replay)
3. Traite les sessions une par une (VLM + CLIP + GraphBuilder)
4. Sauvegarde les workflows JSON sur disque
5. Se suspend quand un replay est actif (libère le GPU)
"""
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("vlm_worker")
# Chemins de base (relatifs au working directory = racine du projet)
ROOT_DIR = Path(__file__).parent.parent.parent
DATA_DIR = ROOT_DIR / "data" / "training"
LIVE_SESSIONS_DIR = DATA_DIR / "live_sessions"
QUEUE_FILE = DATA_DIR / "_worker_queue.txt"
REPLAY_LOCK = DATA_DIR / "_replay_active.lock"
# Intervalle de polling quand la queue est vide (secondes)
POLL_INTERVAL = 10
# Intervalle de vérification du replay lock (secondes)
REPLAY_CHECK_INTERVAL = 2
# Timeout max d'attente du replay lock avant reprise forcée (secondes)
REPLAY_WAIT_TIMEOUT = 120
class VLMWorker:
"""Worker VLM autonome qui traite les sessions finalisées.
Tourne en boucle infinie dans un process séparé du serveur HTTP.
Communique via le filesystem :
- Lit les session_ids depuis _worker_queue.txt
- Vérifie _replay_active.lock pour se suspendre
- Écrit les workflows dans data/training/workflows/
"""
def __init__(self):
self._running = False
self._processor = None # Initialisé au premier besoin (lazy loading GPU)
self._current_session: Optional[str] = None
# Stats
self._stats: Dict[str, int] = {
"sessions_processed": 0,
"sessions_failed": 0,
"sessions_skipped": 0,
"total_screenshots_analyzed": 0,
}
self._completed: List[Dict] = []
self._failed: List[Dict] = []
def _get_processor(self):
"""Lazy init du StreamProcessor (charge les modèles GPU au premier appel)."""
if self._processor is None:
logger.info("Initialisation du StreamProcessor (chargement GPU)...")
from .stream_processor import StreamProcessor
self._processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
logger.info("StreamProcessor initialisé.")
return self._processor
def start(self):
"""Boucle principale du worker."""
self._running = True
logger.info(
"VLM Worker démarré — surveillance de %s",
QUEUE_FILE,
)
logger.info(" Replay lock : %s", REPLAY_LOCK)
logger.info(" Sessions dir : %s", LIVE_SESSIONS_DIR)
logger.info(" Poll interval : %ds", POLL_INTERVAL)
while self._running:
try:
# Vérifier si un replay est actif
if self._is_replay_active():
self._wait_for_replay_end()
continue
# Lire la prochaine session de la queue
session_id = self._read_next_session()
if session_id:
self._process_session(session_id)
else:
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
logger.info("Interruption clavier, arrêt du worker.")
self._running = False
except Exception as e:
logger.error("Erreur dans la boucle principale : %s", e, exc_info=True)
time.sleep(5) # Éviter une boucle d'erreurs rapide
logger.info("VLM Worker arrêté.")
def stop(self):
"""Arrêt propre du worker."""
self._running = False
logger.info("Arrêt demandé.")
# =========================================================================
# Queue management (fichier _worker_queue.txt)
# =========================================================================
def _read_next_session(self) -> Optional[str]:
"""Lit et retire le premier session_id de la queue.
Format du fichier : une ligne par session_id.
Retire la ligne traitée de façon atomique (réécriture complète).
"""
if not QUEUE_FILE.exists():
return None
try:
lines = QUEUE_FILE.read_text(encoding="utf-8").strip().splitlines()
if not lines:
return None
# Prendre le premier session_id non vide
session_id = None
remaining = []
for line in lines:
line = line.strip()
if not line:
continue
if session_id is None:
session_id = line
else:
remaining.append(line)
# Réécrire le fichier sans la première ligne (atomique via rename)
tmp_file = QUEUE_FILE.with_suffix(".tmp")
if remaining:
tmp_file.write_text(
"\n".join(remaining) + "\n",
encoding="utf-8",
)
else:
tmp_file.write_text("", encoding="utf-8")
tmp_file.rename(QUEUE_FILE)
if session_id:
logger.info(
"Session déqueuée : %s (%d restantes dans la queue)",
session_id,
len(remaining),
)
return session_id
except Exception as e:
logger.error("Erreur lecture queue %s : %s", QUEUE_FILE, e)
return None
# =========================================================================
# Replay lock (_replay_active.lock)
# =========================================================================
def _is_replay_active(self) -> bool:
"""Vérifie si un replay est en cours (fichier lock présent)."""
return REPLAY_LOCK.exists()
def _wait_for_replay_end(self):
"""Attend que le replay se termine (suppression du fichier lock).
Timeout de sécurité : REPLAY_WAIT_TIMEOUT secondes max.
"""
start = time.time()
logger.info(
"Replay actif détecté (%s), worker en pause...",
REPLAY_LOCK,
)
while self._running and REPLAY_LOCK.exists():
elapsed = time.time() - start
if elapsed > REPLAY_WAIT_TIMEOUT:
logger.warning(
"Timeout d'attente du replay (%ds), reprise forcée.",
REPLAY_WAIT_TIMEOUT,
)
break
time.sleep(REPLAY_CHECK_INTERVAL)
elapsed = time.time() - start
if elapsed > 0.5:
logger.info("Replay terminé, worker reprend après %.1fs de pause.", elapsed)
# =========================================================================
# Traitement d'une session
# =========================================================================
def _process_session(self, session_id: str):
"""Traite une session complète (analyse VLM + construction workflow)."""
self._current_session = session_id
logger.info("=== Début traitement session %s ===", session_id)
start_time = time.time()
try:
proc = self._get_processor()
# Vérifier que le dossier session existe
session_dir = proc._find_session_dir(session_id)
if not session_dir:
logger.error(
"Dossier session %s introuvable, skip.",
session_id,
)
self._stats["sessions_skipped"] += 1
return
shots_dir = session_dir / "shots"
full_shots = sorted(shots_dir.glob("shot_*_full.png")) if shots_dir.exists() else []
if not full_shots:
logger.warning(
"Aucun screenshot full dans %s, skip.",
shots_dir,
)
self._stats["sessions_skipped"] += 1
return
logger.info(
"Session %s : %d screenshots full à analyser dans %s",
session_id,
len(full_shots),
shots_dir,
)
# Utiliser reprocess_session du StreamProcessor
# qui fait : ScreenAnalyzer + CLIP + FAISS + GraphBuilder
result = proc.reprocess_session(
session_id,
progress_callback=self._progress_callback,
)
elapsed = time.time() - start_time
if result.get("error"):
logger.error(
"Échec session %s après %.1fs : %s",
session_id,
elapsed,
result["error"],
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": result["error"],
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
elif result.get("status") == "insufficient_data":
logger.warning(
"Session %s : données insuffisantes (%d states) après %.1fs",
session_id,
result.get("states_count", 0),
elapsed,
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": "insufficient_data",
"states_count": result.get("states_count", 0),
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
else:
logger.info(
"Session %s traitée en %.1fs | workflow=%s | %d nodes, %d edges",
session_id,
elapsed,
result.get("workflow_id", "?"),
result.get("nodes", 0),
result.get("edges", 0),
)
self._stats["sessions_processed"] += 1
self._stats["total_screenshots_analyzed"] += result.get("states_analyzed", 0)
self._completed.append({
"session_id": session_id,
"workflow_id": result.get("workflow_id"),
"workflow_name": result.get("workflow_name"),
"nodes": result.get("nodes", 0),
"edges": result.get("edges", 0),
"states_analyzed": result.get("states_analyzed", 0),
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
except Exception as e:
elapsed = time.time() - start_time
logger.error(
"Exception inattendue pour session %s après %.1fs : %s",
session_id,
elapsed,
e,
exc_info=True,
)
self._stats["sessions_failed"] += 1
self._failed.append({
"session_id": session_id,
"error": f"exception: {e}",
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
finally:
self._current_session = None
logger.info("=== Fin traitement session %s ===", session_id)
def _progress_callback(self, session_id: str, current: int, total: int, shot_id: str = ""):
"""Callback de progression appelé par reprocess_session.
Vérifie aussi le replay lock entre chaque screenshot.
"""
logger.info(
"Session %s : screenshot %d/%d%s",
session_id,
current,
total,
f" ({shot_id})" if shot_id else "",
)
# Vérifier si un replay est devenu actif pendant le traitement
if self._is_replay_active():
logger.info(
"Replay détecté pendant l'analyse de %s, pause...",
session_id,
)
self._wait_for_replay_end()
def main():
"""Point d'entrée du worker VLM autonome."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [VLM-WORKER] %(levelname)s %(message)s",
)
# Réduire le bruit des loggers tiers
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
# Créer les dossiers nécessaires
DATA_DIR.mkdir(parents=True, exist_ok=True)
LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
worker = VLMWorker()
# Gestion propre des signaux
def _handle_signal(signum, frame):
logger.info("Signal %s reçu, arrêt en cours...", signal.Signals(signum).name)
worker.stop()
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# Afficher l'état au démarrage
print(f"\n{'='*60}")
print(f" VLM Worker — Process séparé du serveur HTTP")
print(f" Queue : {QUEUE_FILE}")
print(f" Lock : {REPLAY_LOCK}")
print(f" PID : {os.getpid()}")
print(f"{'='*60}\n")
worker.start()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff