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>
This commit is contained in:
0
agent_v0/agent_v1/network/__init__.py
Normal file
0
agent_v0/agent_v1/network/__init__.py
Normal file
398
agent_v0/agent_v1/network/streamer.py
Normal file
398
agent_v0/agent_v1/network/streamer.py
Normal file
@@ -0,0 +1,398 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user