perf: 1 appel VLM par screenshot + sélection intelligente + Rust auto-launch Léa

Analyse VLM :
- 1 seul appel VLM par screenshot au lieu de 30 (~15s vs 6.5min)
- Sélection screenshots par hash perceptuel (3-4 utiles sur 12)
- Fallback classification individuelle si appel unique échoue
- Estimation : ~1min par workflow au lieu de 78min

Rust agent :
- Léa (Edge mode app) s'ouvre automatiquement au démarrage
- Plus besoin de systray pour lancer le chat
- Fix URL chat /chat → /

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-19 00:26:29 +01:00
parent 90ee91caf9
commit 24a947b51d
6 changed files with 661 additions and 296 deletions

View File

@@ -11,6 +11,7 @@ Inclut les endpoints de replay pour renvoyer des ordres d'exécution à l'Agent
import json
import logging
import os
import secrets
import threading
import time
import uuid
@@ -19,7 +20,7 @@ from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
from fastapi import BackgroundTasks, Depends, FastAPI, File, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
@@ -53,7 +54,120 @@ except ImportError:
_gesture_catalog = None
logger = logging.getLogger("api_stream")
app = FastAPI(title="RPA Vision V3 - Streaming API v1")
# =========================================================================
# Authentification par token Bearer (sécurité HIGH)
# =========================================================================
# Le token est lu depuis l'environnement ou généré au démarrage.
# Tous les endpoints requièrent le header Authorization: Bearer <token>,
# sauf /health, /docs et /openapi.json (publics).
API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32))
# Endpoints publics (pas besoin de token)
_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"}
async def _verify_token(request: Request):
"""Middleware de vérification du token API Bearer."""
if request.url.path in _PUBLIC_PATHS:
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != API_TOKEN:
raise HTTPException(status_code=401, detail="Token API invalide")
# =========================================================================
# Rate limiting en mémoire (sécurité HIGH)
# =========================================================================
_rate_limits: Dict[str, list] = defaultdict(list)
_RATE_LIMIT_WINDOW = 60 # secondes
_RATE_LIMITS = {
"/api/v1/traces/stream/replay": 10, # 10 replays par minute
"/api/v1/traces/stream/replay/raw": 10,
"/api/v1/traces/stream/replay/single": 30, # 30 actions Copilot par minute
"/api/v1/traces/stream/finalize": 5,
"/api/v1/traces/stream/image": 200, # 200 images par minute (heartbeats)
}
def _check_rate_limit(endpoint: str, client_ip: str) -> bool:
"""Vérifie si le client a dépassé la limite de requêtes."""
key = f"{endpoint}:{client_ip}"
now = time.time()
# Nettoyer les entrées expirées
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < _RATE_LIMIT_WINDOW]
limit = _RATE_LIMITS.get(endpoint, 100)
if len(_rate_limits[key]) >= limit:
return False
_rate_limits[key].append(now)
return True
# =========================================================================
# Validation des actions de replay (sécurité HIGH)
# =========================================================================
_ALLOWED_ACTION_TYPES = {
"click", "type", "key_combo", "scroll", "wait",
"file_open", "file_save", "file_close", "file_new", "file_dialog",
"double_click", "right_click", "drag",
}
_MAX_ACTION_TEXT_LENGTH = 10000
_MAX_KEYS_PER_COMBO = 10
# Touches autorisées dans les key_combo (modificateurs + touches spéciales + caractères simples)
_KNOWN_KEY_NAMES = {
"enter", "return", "tab", "escape", "esc", "backspace", "delete", "space",
"up", "down", "left", "right", "home", "end", "page_up", "page_down",
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
"ctrl", "ctrl_l", "ctrl_r", "alt", "alt_l", "alt_r",
"shift", "shift_l", "shift_r",
"cmd", "win", "super", "super_l", "super_r", "windows", "meta",
"insert", "print_screen", "caps_lock", "num_lock",
}
def _validate_replay_action(action: dict) -> Optional[str]:
"""Valide une action de replay. Retourne un message d'erreur ou None si valide."""
action_type = action.get("type", "")
# Vérifier le type d'action
if action_type not in _ALLOWED_ACTION_TYPES:
return f"Type d'action non autorisé : '{action_type}'. Autorisés : {sorted(_ALLOWED_ACTION_TYPES)}"
# Vérifier la longueur du texte
text = action.get("text", "")
if isinstance(text, str) and len(text) > _MAX_ACTION_TEXT_LENGTH:
return f"Texte trop long ({len(text)} > {_MAX_ACTION_TEXT_LENGTH} caractères)"
# Vérifier les touches
keys = action.get("keys", [])
if isinstance(keys, list):
if len(keys) > _MAX_KEYS_PER_COMBO:
return f"Trop de touches ({len(keys)} > {_MAX_KEYS_PER_COMBO})"
for key in keys:
key_lower = str(key).lower()
# Accepter les caractères simples (a-z, 0-9, ponctuation) et les noms connus
if len(str(key)) == 1 or key_lower in _KNOWN_KEY_NAMES:
continue
return f"Touche inconnue : '{key}'"
# Vérifier les coordonnées normalisées
for coord_name in ("x_pct", "y_pct"):
val = action.get(coord_name)
if val is not None:
try:
val_f = float(val)
if not (0.0 <= val_f <= 1.0):
return f"Coordonnée {coord_name}={val_f} hors limites [0.0, 1.0]"
except (TypeError, ValueError):
return f"Coordonnée {coord_name} invalide : {val}"
return None # Valide
app = FastAPI(
title="RPA Vision V3 - Streaming API v1",
dependencies=[Depends(_verify_token)],
)
# CORS — origines autorisées (VWB frontend, Agent Chat, Dashboard)
# Configurable via variable d'environnement CORS_ORIGINS (séparées par des virgules)
@@ -75,6 +189,23 @@ app.add_middleware(
allow_headers=["Content-Type", "Authorization"],
)
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
"""Middleware de rate limiting sur les endpoints sensibles."""
path = request.url.path
if path in _RATE_LIMITS:
client_ip = request.client.host if request.client else "unknown"
if not _check_rate_limit(path, client_ip):
from fastapi.responses import JSONResponse
logger.warning(f"Rate limit dépassé : {path} par {client_ip}")
return JSONResponse(
status_code=429,
content={"detail": f"Trop de requêtes. Limite : {_RATE_LIMITS[path]}/{_RATE_LIMIT_WINDOW}s"},
)
return await call_next(request)
# Dossier des sessions live
ROOT_DIR = Path(__file__).parent.parent.parent
LIVE_SESSIONS_DIR = ROOT_DIR / "data" / "training" / "live_sessions"
@@ -222,11 +353,26 @@ def _cleanup_replay_states():
logger.info(f"Nettoyage replay states : {len(to_delete)} entrées supprimées")
@app.get("/health")
async def health_check():
"""Endpoint de santé (public, pas besoin de token)."""
return {"status": "healthy", "version": "1.0.0"}
@app.on_event("startup")
async def startup():
"""Démarrer le worker, le session_worker et charger les workflows existants."""
global _cleanup_running, _cleanup_thread
# Afficher le token API au démarrage pour que l'utilisateur puisse configurer l'agent
_token_source = "env RPA_API_TOKEN" if os.environ.get("RPA_API_TOKEN") else "auto-généré"
logger.info(f"API Token ({_token_source}): {API_TOKEN}")
print(f"\n{'='*60}")
print(f" API Token ({_token_source}):")
print(f" {API_TOKEN}")
print(f" Configurer l'agent : export RPA_API_TOKEN={API_TOKEN}")
print(f"{'='*60}\n")
worker.start(blocking=False)
# Charger les workflows existants depuis le disque
@@ -411,13 +557,18 @@ _gpu_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="gpu_analys
def _image_hash(file_path: str) -> str:
"""Hash rapide d'une image pour détecter les doublons (~identiques)."""
"""Hash rapide d'une image pour détecter les doublons (~identiques).
Utilise 32x32 au lieu de 16x16 pour une meilleure discrimination
entre screenshots similaires mais pas identiques (ex: texte modifié
dans un champ, curseur déplacé, etc.).
"""
try:
from PIL import Image
import hashlib
img = Image.open(file_path)
# Réduire à 16x16 et convertir en niveaux de gris pour un hash perceptuel
thumb = img.resize((16, 16)).convert('L')
# Réduire à 32x32 et convertir en niveaux de gris pour un hash perceptuel
thumb = img.resize((32, 32)).convert('L')
return hashlib.md5(thumb.tobytes()).hexdigest()
except Exception:
return ""
@@ -1073,6 +1224,15 @@ async def start_raw_replay(request: RawReplayRequest):
"Réduisez le plan d'exécution."
)
# Validation de chaque action (sécurité HIGH)
for i, action in enumerate(actions):
error = _validate_replay_action(action)
if error:
raise HTTPException(
status_code=400,
detail=f"Action #{i} invalide : {error}"
)
# Auto-détection de la session Agent V1 (avec filtre machine optionnel)
if not session_id or session_id.startswith("chat_"):
active_session = _find_active_agent_session(machine_id=target_machine_id)
@@ -1141,6 +1301,11 @@ async def enqueue_single_action(request: SingleActionRequest):
action = dict(request.action)
target_machine_id = request.machine_id
# Validation de l'action (sécurité HIGH)
error = _validate_replay_action(action)
if error:
raise HTTPException(status_code=400, detail=f"Action invalide : {error}")
# Auto-détection de la session Agent V1 (avec filtre machine optionnel)
if not session_id or session_id.startswith("chat_"):
active_session = _find_active_agent_session(machine_id=target_machine_id)

View File

@@ -7,7 +7,9 @@ pour traiter en temps réel les screenshots et événements reçus via fibre.
Tous les calculs GPU tournent ici (serveur RTX 5070).
"""
import hashlib
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
@@ -506,17 +508,21 @@ class StreamProcessor:
return {"error": f"Dossier shots/ introuvable pour {session_id}"}
# Lister les screenshots full (shot_XXXX_full.png), triés par nom
full_shots = sorted(shots_dir.glob("shot_*_full.png"))
if not full_shots:
all_shots = sorted(shots_dir.glob("shot_*_full.png"))
if not all_shots:
return {
"error": f"Aucun screenshot shot_*_full.png trouvé dans {shots_dir}",
"session_id": session_id,
}
total = len(full_shots)
# Sélection intelligente : ne garder que les screenshots significatifs
# pour éviter d'analyser des captures redondantes (~identiques)
key_shots = self._select_key_screenshots(session_id, all_shots)
total_all = len(all_shots)
total = len(key_shots)
logger.info(
f"Session {session_id} : {total} screenshots full à analyser "
f"dans {shots_dir}"
f"Screenshots sélectionnés : {total}/{total_all} "
f"(déduplication perceptuelle) dans {shots_dir}"
)
# S'assurer que la session est enregistrée dans le session_manager
@@ -527,9 +533,9 @@ class StreamProcessor:
self._screen_states.pop(session_id, None)
self._embeddings.pop(session_id, None)
# Analyser chaque screenshot full
# Analyser chaque screenshot sélectionné
errors = 0
for i, shot_file in enumerate(full_shots):
for i, shot_file in enumerate(key_shots):
shot_id = shot_file.stem # ex: "shot_0001_full"
file_path = str(shot_file)
@@ -556,7 +562,7 @@ class StreamProcessor:
logger.info(
f"Session {session_id} : {states_count}/{total} screenshots analysés "
f"({errors} erreurs)"
f"({errors} erreurs, {total_all - total} skippés par dédup)"
)
# Construire le workflow via finalize_session()
@@ -566,6 +572,59 @@ class StreamProcessor:
result = self.finalize_session(session_id)
return result
def _select_key_screenshots(self, session_id: str, shot_paths: List[Path]) -> List[Path]:
"""Sélectionner uniquement les screenshots significatifs pour éviter les analyses redondantes.
Critères :
1. Garder le premier et le dernier screenshot (toujours)
2. Comparer chaque screenshot au précédent via hash perceptuel (32x32 grayscale)
3. Si l'image est identique au précédent → skip (même écran, pas de changement)
4. Privilégier les screenshots d'action (shot_*_full) vs heartbeat
Réduit typiquement 12 screenshots à 3-4 screenshots utiles.
"""
if len(shot_paths) <= 2:
return list(shot_paths)
from PIL import Image
selected = []
last_hash = None
for path in shot_paths:
basename = os.path.basename(str(path))
# Les screenshots d'action sont prioritaires
is_action = 'shot_' in basename and '_full' in basename
# Hash perceptuel : redimensionner à 32x32 en niveaux de gris
# Assez discriminant pour détecter les changements d'état de l'UI
try:
img = Image.open(str(path)).resize((32, 32)).convert('L')
current_hash = hashlib.md5(img.tobytes()).hexdigest()
except Exception as e:
logger.debug(f"Impossible de hasher {basename}: {e}")
# En cas d'erreur, inclure le screenshot par sécurité
selected.append(path)
continue
# Inclure si : premier screenshot, hash différent, ou screenshot d'action
if last_hash is None or current_hash != last_hash:
selected.append(path)
last_hash = current_hash
elif is_action:
# Action mais visuellement identique — skip quand même
# car l'état de l'écran n'a pas changé
logger.debug(f"Screenshot d'action {basename} identique au précédent, skip")
# Garantir que le premier et le dernier sont toujours inclus
if shot_paths[0] not in selected:
selected.insert(0, shot_paths[0])
if shot_paths[-1] not in selected:
selected.append(shot_paths[-1])
return selected
def _find_session_dir(self, session_id: str) -> Optional[Path]:
"""Trouver le dossier d'une session sur disque.