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