Files
rpa_vision_v3/agent_v0/agent_v1/network/streamer.py
Dom ae65be2555 chore: ajouter agent_v0/ au tracking git (était un repo embarqué)
Suppression du .git embarqué dans agent_v0/ — le code est maintenant
tracké normalement dans le repo principal.
Inclut : agent_v1 (client), server_v1 (streaming), lea_ui (chat client)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:12:23 +01:00

399 lines
15 KiB
Python

# agent_v1/network/streamer.py
"""
Streaming temps réel pour Agent V1.
Exploite la fibre pour envoyer les événements au fur et à mesure.
Endpoints serveur (api_stream.py, port 5005) :
POST /api/v1/traces/stream/register — enregistrer la session
POST /api/v1/traces/stream/event — événement temps réel
POST /api/v1/traces/stream/image — screenshot (full ou crop)
POST /api/v1/traces/stream/finalize — clôturer et construire le workflow
Robustesse (P0-2) :
- Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives)
- Health-check périodique (30s) pour recovery du flag _server_available
- Compression JPEG qualité 85 pour les images (réduction ~5-10x)
- Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine
"""
import io
import logging
import queue
import threading
import time
import requests
from PIL import Image
from ..config import STREAMING_ENDPOINT
logger = logging.getLogger(__name__)
# Paramètres de retry
MAX_RETRIES = 3
RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel
# Paramètres de health-check
HEALTH_CHECK_INTERVAL_S = 30
# Paramètres de compression
JPEG_QUALITY = 85
# Taille max de la queue (backpressure)
QUEUE_MAX_SIZE = 100
# Types d'événements à ne jamais dropper
PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"}
class TraceStreamer:
def __init__(self, session_id: str, machine_id: str = "default"):
self.session_id = session_id
self.machine_id = machine_id # Identifiant machine pour le multi-machine
self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
self.running = False
self._thread = None
self._health_thread = None
self._server_available = True # Désactivé après trop d'échecs
def start(self):
"""Démarrer le streaming et enregistrer la session côté serveur."""
self.running = True
self._register_session()
# Thread principal d'envoi
self._thread = threading.Thread(target=self._stream_loop, daemon=True)
self._thread.start()
# Thread de health-check pour recovery
self._health_thread = threading.Thread(
target=self._health_check_loop, daemon=True
)
self._health_thread.start()
logger.info(f"Streamer pour {self.session_id} démarré")
def stop(self):
"""Arrêter le streaming et finaliser la session côté serveur.
Attend que la queue se vide (max 30s) avant de finaliser,
pour que toutes les images soient envoyées au serveur.
"""
self.running = False
# Attendre que la queue se vide (les images doivent être envoyées)
if self._thread:
drain_start = time.time()
while not self.queue.empty() and (time.time() - drain_start) < 30:
time.sleep(0.5)
if not self.queue.empty():
logger.warning(
f"Queue non vide après 30s ({self.queue.qsize()} items restants)"
)
self._thread.join(timeout=5.0)
if self._health_thread:
self._health_thread.join(timeout=2.0)
self._finalize_session()
logger.info(f"Streamer pour {self.session_id} arrêté")
def push_event(self, event_data: dict):
"""Enfile un événement pour envoi immédiat.
Si la queue est pleine (backpressure), les heartbeat sont droppés
tandis que les événements utilisateur (click, key, scroll, action)
et screenshots sont toujours conservés.
"""
self._enqueue_with_backpressure("event", event_data)
def push_image(self, image_path: str, screenshot_id: str):
"""Enfile une image pour envoi asynchrone."""
if not image_path:
return # Ignorer les chemins vides (heartbeat sans changement)
self._enqueue_with_backpressure("image", (image_path, screenshot_id))
# =========================================================================
# Backpressure — gestion de la queue bornée
# =========================================================================
def _enqueue_with_backpressure(self, item_type: str, data):
"""Ajouter un item à la queue avec gestion du backpressure.
Quand la queue est pleine :
- Les événements prioritaires (click, key, action, screenshot) sont
ajoutés en bloquant brièvement (0.5s)
- Les heartbeat sont silencieusement droppés
"""
is_priority = self._is_priority_item(item_type, data)
try:
self.queue.put_nowait((item_type, data))
except queue.Full:
if is_priority:
# Événement prioritaire : on attend un peu pour l'ajouter
try:
self.queue.put((item_type, data), timeout=0.5)
except queue.Full:
logger.warning(
f"Queue pleine — événement prioritaire droppé "
f"(type={item_type})"
)
else:
# Heartbeat ou événement non-critique : on drop silencieusement
logger.debug(
f"Queue pleine — heartbeat/non-prioritaire droppé "
f"(type={item_type})"
)
def _is_priority_item(self, item_type: str, data) -> bool:
"""Vérifie si un item est prioritaire (ne doit pas être droppé).
Les images sont toujours prioritaires. Pour les événements,
on regarde le type d'événement (click, key, scroll, action).
"""
if item_type == "image":
return True
if item_type == "event" and isinstance(data, dict):
event_type = data.get("type", "").lower()
return event_type in PRIORITY_EVENT_TYPES
return False
# =========================================================================
# Boucle d'envoi
# =========================================================================
def _stream_loop(self):
"""Boucle d'envoi asynchrone (thread daemon)."""
consecutive_failures = 0
while self.running or not self.queue.empty():
try:
item_type, data = self.queue.get(timeout=0.5)
success = False
if item_type == "event":
success = self._send_with_retry(self._send_event, data)
elif item_type == "image":
success = self._send_with_retry(self._send_image, *data)
self.queue.task_done()
if success:
consecutive_failures = 0
else:
consecutive_failures += 1
if consecutive_failures >= 10:
logger.warning(
"10 échecs consécutifs — serveur marqué indisponible"
)
self._server_available = False
consecutive_failures = 0
except queue.Empty:
continue
except Exception as e:
logger.error(f"Erreur Streaming Loop: {e}")
# =========================================================================
# Retry avec backoff exponentiel
# =========================================================================
def _send_with_retry(self, send_fn, *args) -> bool:
"""Tente l'envoi avec retry et backoff exponentiel.
3 tentatives max avec délais de 1s, 2s, 4s entre chaque.
Retourne True si l'envoi a réussi, False sinon.
"""
# Première tentative (sans délai)
if send_fn(*args):
return True
# Retries avec backoff
for attempt, delay in enumerate(RETRY_DELAYS, start=1):
if not self.running:
# On arrête les retries si le streamer est en cours d'arrêt
break
logger.debug(
f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..."
)
time.sleep(delay)
if send_fn(*args):
logger.debug(f"Retry {attempt} réussi")
return True
logger.debug(f"Envoi échoué après {MAX_RETRIES} retries")
return False
# =========================================================================
# Health-check périodique pour recovery
# =========================================================================
def _health_check_loop(self):
"""Vérifie périodiquement si le serveur est redevenu disponible.
Toutes les 30s, tente un GET /stats. Si le serveur répond,
remet _server_available = True et ré-enregistre la session.
"""
while self.running:
time.sleep(HEALTH_CHECK_INTERVAL_S)
if not self.running:
break
if self._server_available:
# Serveur déjà disponible, rien à faire
continue
# Tenter un health-check
try:
resp = requests.get(
f"{STREAMING_ENDPOINT}/stats",
timeout=3,
)
if resp.ok:
logger.info(
"Health-check OK — serveur redevenu disponible, "
"ré-enregistrement de la session"
)
self._server_available = True
self._register_session()
except Exception:
logger.debug("Health-check échoué — serveur toujours indisponible")
# =========================================================================
# Compression JPEG
# =========================================================================
def _compress_image_to_jpeg(self, path: str) -> tuple:
"""Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire.
Retourne un tuple (bytes_io, content_type, filename_suffix).
Si la compression échoue, renvoie le fichier original en PNG.
"""
try:
img = Image.open(path)
# Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha)
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
buf.seek(0)
return buf, "image/jpeg", ".jpg"
except FileNotFoundError:
# Fichier introuvable — propager l'erreur (pas de fallback possible)
logger.warning(f"Fichier image introuvable pour compression : {path}")
raise
except Exception as e:
logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}")
return None, None, None
# =========================================================================
# Envois HTTP
# =========================================================================
def _register_session(self):
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
try:
resp = requests.post(
f"{STREAMING_ENDPOINT}/register",
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
timeout=3,
)
if resp.ok:
logger.info(
f"Session {self.session_id} enregistrée sur le serveur "
f"(machine={self.machine_id})"
)
self._server_available = True
else:
logger.warning(f"Enregistrement session échoué: {resp.status_code}")
except Exception as e:
logger.debug(f"Serveur indisponible pour register: {e}")
self._server_available = False
def _finalize_session(self):
"""Finaliser la session (construction du workflow côté serveur).
IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available.
C'est la dernière chance de sauver les données de la session.
"""
try:
resp = requests.post(
f"{STREAMING_ENDPOINT}/finalize",
params={
"session_id": self.session_id,
"machine_id": self.machine_id,
},
timeout=30, # Le build workflow peut prendre du temps
)
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
else:
logger.warning(f"Finalisation échouée: {resp.status_code}")
except Exception as e:
logger.debug(f"Finalisation échouée: {e}")
def _send_event(self, event: dict) -> bool:
"""Envoyer un événement au serveur (avec identifiant machine)."""
if not self._server_available:
return False
try:
payload = {
"session_id": self.session_id,
"timestamp": time.time(),
"event": event,
"machine_id": self.machine_id,
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/event",
json=payload,
timeout=2,
)
return resp.ok
except Exception as e:
logger.debug(f"Streaming Event échoué: {e}")
return False
def _send_image(self, path: str, shot_id: str) -> bool:
"""Envoyer un screenshot au serveur, compressé en JPEG.
Utilise un context manager pour le fallback PNG afin d'éviter
les fuites de descripteurs de fichier.
"""
if not self._server_available:
return False
try:
# Tenter la compression JPEG (réduction ~5-10x vs PNG)
jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path)
params = {
"session_id": self.session_id,
"shot_id": shot_id,
"machine_id": self.machine_id,
}
if jpeg_buf is not None:
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
files = {
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
files=files,
params=params,
timeout=5,
)
return resp.ok
else:
# Fallback : envoi PNG original avec context manager
with open(path, "rb") as f:
files = {
"file": (f"{shot_id}.png", f, "image/png")
}
resp = requests.post(
f"{STREAMING_ENDPOINT}/image",
files=files,
params=params,
timeout=5,
)
return resp.ok
except Exception as e:
logger.debug(f"Streaming Image échoué: {e}")
return False