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:
Dom
2026-03-18 11:12:23 +01:00
parent af83552923
commit ae65be2555
82 changed files with 15616 additions and 0 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
"""
LiveSessionManager — Gestion d'état des sessions de streaming avec persistance disque.
Accumule les événements et screenshots reçus de l'Agent V1 en temps réel.
Persiste les sessions sur disque (JSON) pour survivre aux redémarrages serveur.
Fournit la conversion vers RawSession pour le traitement batch (GraphBuilder).
"""
import json
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class LiveSessionState:
"""État d'une session active en mémoire."""
session_id: str
machine_id: str = "default" # Identifiant machine (multi-machine)
events: List[Dict[str, Any]] = field(default_factory=list)
shot_paths: Dict[str, str] = field(default_factory=dict) # shot_id -> file_path
last_window_info: Dict[str, str] = field(default_factory=lambda: {"title": "Unknown", "app_name": "unknown"})
created_at: datetime = field(default_factory=datetime.now)
last_activity: datetime = field(default_factory=datetime.now)
finalized: bool = False
# Compteur des titres de fenêtre vus → contextualisation automatique
window_titles_seen: Dict[str, int] = field(default_factory=dict)
app_names_seen: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"session_id": self.session_id,
"machine_id": self.machine_id,
"events": self.events,
"shot_paths": self.shot_paths,
"last_window_info": self.last_window_info,
"created_at": self.created_at.isoformat(),
"last_activity": self.last_activity.isoformat(),
"finalized": self.finalized,
"window_titles_seen": self.window_titles_seen,
"app_names_seen": self.app_names_seen,
}
@classmethod
def from_dict(cls, data: dict) -> 'LiveSessionState':
return cls(
session_id=data["session_id"],
machine_id=data.get("machine_id", "default"),
events=data.get("events", []),
shot_paths=data.get("shot_paths", {}),
last_window_info=data.get("last_window_info", {"title": "Unknown", "app_name": "unknown"}),
created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(),
last_activity=datetime.fromisoformat(data["last_activity"]) if data.get("last_activity") else datetime.now(),
finalized=data.get("finalized", False),
window_titles_seen=data.get("window_titles_seen", {}),
app_names_seen=data.get("app_names_seen", {}),
)
class LiveSessionManager:
"""Gère les sessions live en mémoire côté serveur avec persistance disque."""
def __init__(self, persist_dir: str = "data/streaming_sessions"):
self._sessions: Dict[str, LiveSessionState] = {}
self._lock = threading.Lock()
self._persist_dir = Path(persist_dir)
self._persist_dir.mkdir(parents=True, exist_ok=True)
self._dirty: set = set() # Sessions modifiées depuis la dernière sauvegarde
self._persist_counter = 0 # Compteur pour limiter la fréquence de persistance
self._persist_interval = 10 # Persister toutes les N modifications
# Charger les sessions persistées au démarrage
self._load_persisted_sessions()
def _load_persisted_sessions(self):
"""Charger les sessions sauvegardées au démarrage."""
count = 0
for session_file in sorted(self._persist_dir.glob("sess_*.json")):
try:
with open(session_file, 'r', encoding='utf-8') as f:
data = json.load(f)
session = LiveSessionState.from_dict(data)
self._sessions[session.session_id] = session
count += 1
except Exception as e:
logger.warning(f"Impossible de charger la session {session_file.name}: {e}")
if count:
logger.info(f"{count} session(s) restaurée(s) depuis {self._persist_dir}")
def _persist_session(self, session_id: str):
"""Sauvegarder une session sur disque (appelé périodiquement)."""
session = self._sessions.get(session_id)
if not session:
return
try:
filepath = self._persist_dir / f"{session_id}.json"
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(session.to_dict(), f, ensure_ascii=False)
except Exception as e:
logger.debug(f"Erreur persistance session {session_id}: {e}")
def _maybe_persist(self, session_id: str):
"""Persister si le compteur atteint l'intervalle."""
self._dirty.add(session_id)
self._persist_counter += 1
if self._persist_counter >= self._persist_interval:
self._persist_counter = 0
for sid in list(self._dirty):
self._persist_session(sid)
self._dirty.clear()
def flush(self):
"""Forcer la persistance de toutes les sessions dirty."""
with self._lock:
for sid in list(self._dirty):
self._persist_session(sid)
self._dirty.clear()
def register_session(self, session_id: str, machine_id: str = "default") -> LiveSessionState:
with self._lock:
if session_id not in self._sessions:
self._sessions[session_id] = LiveSessionState(
session_id=session_id,
machine_id=machine_id,
)
logger.info(f"Session enregistrée: {session_id} (machine={machine_id})")
self._persist_session(session_id)
else:
# Mettre à jour le machine_id si la session existe déjà
# (cas de re-register après redémarrage agent)
if machine_id != "default":
self._sessions[session_id].machine_id = machine_id
return self._sessions[session_id]
def get_session(self, session_id: str) -> Optional[LiveSessionState]:
with self._lock:
return self._sessions.get(session_id)
def get_or_create(self, session_id: str, machine_id: str = "default") -> LiveSessionState:
with self._lock:
if session_id not in self._sessions:
self._sessions[session_id] = LiveSessionState(
session_id=session_id,
machine_id=machine_id,
)
elif machine_id != "default":
self._sessions[session_id].machine_id = machine_id
return self._sessions[session_id]
def add_event(self, session_id: str, event_data: Dict[str, Any]) -> None:
session = self.get_or_create(session_id)
with self._lock:
session.events.append(event_data)
session.last_activity = datetime.now()
# Extraire le contexte fenêtre si présent
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
self._maybe_persist(session_id)
def add_screenshot(self, session_id: str, shot_id: str, file_path: str) -> None:
session = self.get_or_create(session_id)
with self._lock:
session.shot_paths[shot_id] = file_path
session.last_activity = datetime.now()
self._maybe_persist(session_id)
def finalize(self, session_id: str) -> Optional[LiveSessionState]:
with self._lock:
session = self._sessions.get(session_id)
if session:
session.finalized = True
self._persist_session(session_id)
return session
def remove_session(self, session_id: str) -> None:
with self._lock:
self._sessions.pop(session_id, None)
# Supprimer aussi le fichier persisté
filepath = self._persist_dir / f"{session_id}.json"
filepath.unlink(missing_ok=True)
def to_raw_session(self, session_id: str) -> Optional[dict]:
"""Convertir une session live en dict compatible RawSession."""
session = self.get_session(session_id)
if not session:
return None
import platform
import socket
# Construire les événements au format RawSession
events = []
for evt in session.events:
window_info = {
"title": evt.get("window_title", session.last_window_info.get("title", "")),
"app_name": evt.get("app_name", session.last_window_info.get("app_name", "unknown")),
}
events.append({
"t": evt.get("timestamp", 0),
"type": evt.get("type", "unknown"),
"window": window_info,
"screenshot_id": evt.get("screenshot_id"),
})
# Construire les screenshots au format RawSession
screenshots = []
for shot_id, path in sorted(session.shot_paths.items()):
# Ne garder que les full screenshots pour le GraphBuilder
if "_crop" in shot_id:
continue
screenshots.append({
"screenshot_id": shot_id,
"relative_path": path,
"captured_at": datetime.now().isoformat(),
})
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]},
},
"user": {"id": "remote_agent"},
"context": {
"workflow": session.last_window_info.get("title", ""),
"tags": "streaming,agent_v1",
"machine_id": session.machine_id,
},
"started_at": session.created_at.isoformat(),
"ended_at": datetime.now().isoformat(),
"events": events,
"screenshots": screenshots,
}
@property
def active_session_count(self) -> int:
with self._lock:
return sum(1 for s in self._sessions.values() if not s.finalized)
@property
def session_ids(self) -> List[str]:
with self._lock:
return list(self._sessions.keys())
def get_sessions_by_machine(self, machine_id: str) -> List[LiveSessionState]:
"""Retourner toutes les sessions d'une machine donnée."""
with self._lock:
return [
s for s in self._sessions.values()
if s.machine_id == machine_id
]
def cleanup_old_sessions(self, max_age_hours: int = 24) -> int:
"""Supprimer de la mémoire les sessions finalisées plus vieilles que max_age_hours.
Ne supprime PAS les fichiers sur disque (juste la RAM).
Les sessions non finalisées (actives) ne sont jamais nettoyées.
Args:
max_age_hours: Age maximum en heures avant nettoyage (défaut: 24h)
Returns:
Nombre de sessions nettoyées
"""
from datetime import timedelta
cutoff = datetime.now() - timedelta(hours=max_age_hours)
to_remove = []
with self._lock:
for sid, session in self._sessions.items():
if session.finalized and session.last_activity < cutoff:
to_remove.append(sid)
for sid in to_remove:
del self._sessions[sid]
self._dirty.discard(sid)
if to_remove:
logger.info(
f"Nettoyage mémoire : {len(to_remove)} session(s) finalisée(s) "
f"supprimée(s) (> {max_age_hours}h) — fichiers conservés sur disque"
)
return len(to_remove)
def get_machine_ids(self) -> List[str]:
"""Retourner la liste des identifiants machines uniques."""
with self._lock:
return list(set(s.machine_id for s in self._sessions.values()))

View File

@@ -0,0 +1,347 @@
# agent_v0/server_v1/replay_verifier.py
"""
ReplayVerifier — Vérification post-action pour le replay de workflows.
Compare les screenshots avant/après une action pour détecter si elle a eu
un effet visible. Utilisé par l'API de replay pour décider si une action
a réussi ou si un retry est nécessaire.
Stratégies de vérification :
1. Différence d'image globale (avant == après → probablement rien ne s'est passé)
2. Zone locale autour du clic (si l'action est un clic)
3. Détection de texte apparu (si l'action est une frappe)
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# Seuils de détection configurables
DEFAULT_GLOBAL_CHANGE_THRESHOLD = 0.005 # 0.5% de pixels différents = changement détecté
DEFAULT_LOCAL_CHANGE_THRESHOLD = 0.02 # 2% de la zone locale doit changer pour un clic
DEFAULT_LOCAL_RADIUS_PCT = 0.05 # 5% de la taille d'image autour du point de clic
DEFAULT_PIXEL_DIFF_THRESHOLD = 30 # Différence minimale par canal pour compter un pixel comme "changé"
@dataclass
class VerificationResult:
"""Résultat de vérification d'une action de replay."""
verified: bool # L'action semble avoir fonctionné
confidence: float # 0.0-1.0
changes_detected: bool # Des pixels ont changé
change_area_pct: float # % de l'image qui a changé (0.0-100.0)
suggestion: str # "retry", "skip", "abort", "continue"
detail: str = "" # Description humaine du résultat
local_change_pct: float = 0.0 # % de changement dans la zone locale (si applicable)
def to_dict(self) -> Dict[str, Any]:
return {
"verified": self.verified,
"confidence": round(self.confidence, 3),
"changes_detected": self.changes_detected,
"change_area_pct": round(self.change_area_pct, 3),
"suggestion": self.suggestion,
"detail": self.detail,
"local_change_pct": round(self.local_change_pct, 3),
}
class ReplayVerifier:
"""Vérifie que les actions de replay ont produit l'effet attendu."""
def __init__(
self,
global_change_threshold: float = DEFAULT_GLOBAL_CHANGE_THRESHOLD,
local_change_threshold: float = DEFAULT_LOCAL_CHANGE_THRESHOLD,
local_radius_pct: float = DEFAULT_LOCAL_RADIUS_PCT,
pixel_diff_threshold: int = DEFAULT_PIXEL_DIFF_THRESHOLD,
):
self.global_change_threshold = global_change_threshold
self.local_change_threshold = local_change_threshold
self.local_radius_pct = local_radius_pct
self.pixel_diff_threshold = pixel_diff_threshold
def verify_action(
self,
action: Dict[str, Any],
result: Dict[str, Any],
screenshot_before: Optional[str] = None,
screenshot_after: Optional[str] = None,
) -> VerificationResult:
"""
Compare les screenshots avant/après pour détecter si l'action a eu un effet.
Stratégies :
1. Différence d'image (si avant == après, l'action n'a probablement rien fait)
2. Si l'action est un clic, vérifier que la zone autour du clic a changé
3. Si l'action est une frappe, vérifier que du texte est apparu
Args:
action: L'action exécutée (type, x_pct, y_pct, text, etc.)
result: Le résultat rapporté par l'Agent V1 (success, error, etc.)
screenshot_before: Chemin du screenshot avant l'action (optionnel)
screenshot_after: Chemin du screenshot après l'action (optionnel)
Returns:
VerificationResult avec la conclusion et la suggestion de suite
"""
# Si l'agent a rapporté une erreur explicite, pas besoin de vérifier visuellement
if not result.get("success", True):
return VerificationResult(
verified=False,
confidence=0.9,
changes_detected=False,
change_area_pct=0.0,
suggestion="retry",
detail=f"Action échouée: {result.get('error', 'erreur inconnue')}",
)
# Si pas de screenshots, on ne peut pas vérifier
if not screenshot_before or not screenshot_after:
return VerificationResult(
verified=True,
confidence=0.3,
changes_detected=True, # On ne sait pas, on assume que ça a marché
change_area_pct=0.0,
suggestion="continue",
detail="Vérification impossible (pas de screenshots avant/après)",
)
# Charger les images
try:
img_before, img_after = self._load_images(screenshot_before, screenshot_after)
except Exception as e:
logger.warning(f"Impossible de charger les screenshots: {e}")
return VerificationResult(
verified=True,
confidence=0.2,
changes_detected=True,
change_area_pct=0.0,
suggestion="continue",
detail=f"Erreur chargement images: {e}",
)
# Vérifier les dimensions
if img_before.size != img_after.size:
# Résolutions différentes = probablement un changement d'écran
return VerificationResult(
verified=True,
confidence=0.7,
changes_detected=True,
change_area_pct=100.0,
suggestion="continue",
detail="Résolution d'écran modifiée (changement de contexte)",
)
# 1. Calcul de la différence globale
global_change_pct = self._compute_global_diff(img_before, img_after)
# 2. Calcul de la différence locale (zone autour du clic si applicable)
action_type = action.get("type", "")
local_change_pct = 0.0
if action_type in ("click", "type") and "x_pct" in action and "y_pct" in action:
local_change_pct = self._compute_local_diff(
img_before, img_after,
action["x_pct"], action["y_pct"],
)
# 3. Décision
return self._decide(
action_type=action_type,
global_change_pct=global_change_pct,
local_change_pct=local_change_pct,
)
def _load_images(self, path_before: str, path_after: str):
"""Charger deux images PIL depuis des chemins fichier ou base64."""
from PIL import Image
img_before = self._load_single_image(path_before)
img_after = self._load_single_image(path_after)
return img_before, img_after
def _load_single_image(self, source: str):
"""Charger une image depuis un chemin fichier ou une string base64."""
from PIL import Image
# Détection base64 (commence par /9j pour JPEG ou iVBOR pour PNG en base64)
if source.startswith(("/9j", "iVBOR", "data:image")):
import base64
import io
# Retirer le préfixe data:image/...;base64, si présent
if source.startswith("data:image"):
source = source.split(",", 1)[1]
img_bytes = base64.b64decode(source)
return Image.open(io.BytesIO(img_bytes)).convert("RGB")
else:
return Image.open(source).convert("RGB")
def _compute_global_diff(self, img_before, img_after) -> float:
"""
Calculer le pourcentage de pixels qui ont changé significativement.
Returns:
Pourcentage de pixels changés (0.0-100.0)
"""
import numpy as np
arr_before = np.array(img_before, dtype=np.int16)
arr_after = np.array(img_after, dtype=np.int16)
# Différence absolue par canal, puis max par pixel
diff = np.abs(arr_after - arr_before)
max_diff_per_pixel = diff.max(axis=2) # (H, W)
# Compter les pixels dont la différence dépasse le seuil
changed_pixels = (max_diff_per_pixel > self.pixel_diff_threshold).sum()
total_pixels = max_diff_per_pixel.size
return (changed_pixels / total_pixels) * 100.0
def _compute_local_diff(
self,
img_before,
img_after,
x_pct: float,
y_pct: float,
) -> float:
"""
Calculer le pourcentage de changement dans une zone locale autour d'un point.
Args:
img_before, img_after: Images PIL (même taille)
x_pct, y_pct: Coordonnées du point en pourcentage (0.0-1.0)
Returns:
Pourcentage de pixels changés dans la zone locale (0.0-100.0)
"""
import numpy as np
w, h = img_before.size
cx = int(x_pct * w)
cy = int(y_pct * h)
radius_x = int(self.local_radius_pct * w)
radius_y = int(self.local_radius_pct * h)
# Borner la zone au cadre de l'image
x1 = max(0, cx - radius_x)
y1 = max(0, cy - radius_y)
x2 = min(w, cx + radius_x)
y2 = min(h, cy + radius_y)
if x2 <= x1 or y2 <= y1:
return 0.0
# Extraire les zones locales
crop_before = img_before.crop((x1, y1, x2, y2))
crop_after = img_after.crop((x1, y1, x2, y2))
arr_before = np.array(crop_before, dtype=np.int16)
arr_after = np.array(crop_after, dtype=np.int16)
diff = np.abs(arr_after - arr_before)
max_diff = diff.max(axis=2)
changed = (max_diff > self.pixel_diff_threshold).sum()
total = max_diff.size
return (changed / total) * 100.0 if total > 0 else 0.0
def _decide(
self,
action_type: str,
global_change_pct: float,
local_change_pct: float,
) -> VerificationResult:
"""
Prendre une décision basée sur les métriques de changement.
Logique :
- Changement global > seuil → action vérifiée (confiance haute)
- Changement local > seuil (pour clic/frappe) → action vérifiée (confiance moyenne)
- Aucun changement → action non vérifiée, suggestion retry
- Changement massif (>50%) → possible popup/erreur, marquer pour attention
"""
global_threshold_pct = self.global_change_threshold * 100
local_threshold_pct = self.local_change_threshold * 100
has_global_change = global_change_pct > global_threshold_pct
has_local_change = local_change_pct > local_threshold_pct
# Cas 1 : Changement massif (possible popup/erreur/crash)
if global_change_pct > 50.0:
return VerificationResult(
verified=True,
confidence=0.6,
changes_detected=True,
change_area_pct=global_change_pct,
local_change_pct=local_change_pct,
suggestion="continue",
detail=(
f"Changement massif détecté ({global_change_pct:.1f}%) — "
"possible changement de contexte (popup, nouvelle page)"
),
)
# Cas 2 : Changement global détecté
if has_global_change:
confidence = min(0.9, 0.5 + global_change_pct / 100.0)
return VerificationResult(
verified=True,
confidence=confidence,
changes_detected=True,
change_area_pct=global_change_pct,
local_change_pct=local_change_pct,
suggestion="continue",
detail=f"Changement global détecté ({global_change_pct:.2f}%)",
)
# Cas 3 : Pas de changement global, mais changement local (clic/frappe)
if has_local_change and action_type in ("click", "type"):
confidence = min(0.7, 0.3 + local_change_pct / 100.0)
return VerificationResult(
verified=True,
confidence=confidence,
changes_detected=True,
change_area_pct=global_change_pct,
local_change_pct=local_change_pct,
suggestion="continue",
detail=(
f"Changement local détecté ({local_change_pct:.2f}%) "
f"autour de ({action_type})"
),
)
# Cas 4 : Pas de changement (key_combo, wait)
# Pour les raccourcis clavier et attentes, l'absence de changement
# n'est pas forcément un problème (ex: Ctrl+C ne change pas l'écran)
if action_type in ("key_combo", "wait"):
return VerificationResult(
verified=True,
confidence=0.4,
changes_detected=False,
change_area_pct=global_change_pct,
local_change_pct=local_change_pct,
suggestion="continue",
detail=(
f"Aucun changement visible pour {action_type} "
"(normal pour ce type d'action)"
),
)
# Cas 5 : Aucun changement détecté pour un clic/frappe → suspect
return VerificationResult(
verified=False,
confidence=0.6,
changes_detected=False,
change_area_pct=global_change_pct,
local_change_pct=local_change_pct,
suggestion="retry",
detail=(
f"Aucun changement détecté après {action_type} "
f"(global={global_change_pct:.3f}%, local={local_change_pct:.3f}%)"
),
)

View File

@@ -0,0 +1,253 @@
# agent_v0/server_v1/session_worker.py
"""
SessionWorker — Traitement asynchrone des sessions finalisées en arrière-plan.
Résout le problème de finalize qui retourne "insufficient_data" : l'analyse VLM
prend plusieurs minutes, et le client n'attend plus. Le worker traite les sessions
à son rythme et notifie quand le workflow est prêt.
Tourne dans un thread daemon. Traite une session à la fois.
Pour chaque session :
1. Analyse les screenshots via ScreenAnalyzer + VLM
2. Construit le workflow via GraphBuilder
3. Sauvegarde le workflow
4. Notifie que c'est prêt (callback)
"""
import logging
import threading
import time
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger("session_worker")
class SessionWorker:
"""Worker qui traite les sessions finalisées en arrière-plan.
Tourne dans un thread daemon. Traite une session à la fois.
Pour chaque session :
1. Analyse les screenshots via ScreenAnalyzer + VLM
2. Construit le workflow via GraphBuilder
3. Sauvegarde le workflow
4. Notifie que c'est prêt (callback on_complete)
"""
def __init__(self, processor, poll_interval: int = 10):
"""
Args:
processor: Instance de StreamProcessor partagée avec l'API.
poll_interval: Intervalle de polling en secondes quand la queue est vide.
"""
from .stream_processor import StreamProcessor
self._processor: StreamProcessor = processor
self._queue: List[str] = [] # session_ids à traiter
self._lock = threading.Lock()
self._running = False
self._current_session: Optional[str] = None
self._current_progress: Optional[Dict[str, Any]] = None
self._on_complete: Optional[Callable[[str, Dict[str, Any]], None]] = None
self._poll_interval = poll_interval
# Historique des traitements (succès et échecs)
self._completed: List[Dict[str, Any]] = []
self._failed: List[Dict[str, Any]] = []
self._thread: Optional[threading.Thread] = None
def start(self):
"""Démarre le worker dans un thread daemon."""
if self._running:
logger.warning("[WORKER] Déjà en cours d'exécution")
return
self._running = True
self._thread = threading.Thread(
target=self._process_loop,
name="SessionWorker",
daemon=True,
)
self._thread.start()
logger.info("[WORKER] Démarré — traitement asynchrone des sessions finalisées")
def stop(self):
"""Arrête proprement le worker."""
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=10)
logger.info("[WORKER] Arrêté")
def enqueue(self, session_id: str):
"""Ajoute une session à la file d'attente.
Évite les doublons : si la session est déjà dans la queue ou en cours
de traitement, elle n'est pas ré-ajoutée.
"""
with self._lock:
if session_id in self._queue:
logger.info(f"[WORKER] Session {session_id} déjà dans la queue, skip")
return
if self._current_session == session_id:
logger.info(f"[WORKER] Session {session_id} en cours de traitement, skip")
return
# Vérifier si déjà traitée avec succès
for item in self._completed:
if item.get("session_id") == session_id:
logger.info(f"[WORKER] Session {session_id} déjà traitée avec succès, skip")
return
self._queue.append(session_id)
logger.info(
f"[WORKER] Session {session_id} ajoutée à la queue "
f"(position {len(self._queue)})"
)
def get_status(self) -> Dict[str, Any]:
"""Retourne l'état complet du worker."""
with self._lock:
return {
"running": self._running,
"queue_length": len(self._queue),
"queue": list(self._queue),
"current_session": self._current_session,
"current_progress": dict(self._current_progress) if self._current_progress else None,
"completed_count": len(self._completed),
"completed": list(self._completed[-10:]), # 10 derniers
"failed_count": len(self._failed),
"failed": list(self._failed[-10:]), # 10 derniers
}
def _dequeue(self) -> Optional[str]:
"""Retire et retourne le prochain session_id de la queue."""
with self._lock:
if self._queue:
return self._queue.pop(0)
return None
def _process_loop(self):
"""Boucle principale — prend la prochaine session et la traite."""
logger.info("[WORKER] Boucle de traitement démarrée")
while self._running:
session_id = self._dequeue()
if session_id:
self._process_session(session_id)
else:
time.sleep(self._poll_interval)
def _process_session(self, session_id: str):
"""Traite une session complète (analyse screenshots + build workflow).
Utilise StreamProcessor.reprocess_session() qui :
1. Liste les screenshots shot_*_full.png sur disque
2. Appelle process_screenshot() pour chaque (VLM + CLIP)
3. Appelle finalize_session() pour construire le workflow
"""
with self._lock:
self._current_session = session_id
self._current_progress = {
"session_id": session_id,
"status": "starting",
"started_at": datetime.now().isoformat(),
"screenshots_total": 0,
"screenshots_processed": 0,
}
logger.info(f"[WORKER] === Début traitement session {session_id} ===")
start_time = time.time()
try:
result = self._processor.reprocess_session(
session_id,
progress_callback=self._update_progress,
)
elapsed = time.time() - start_time
if result.get("error"):
# Erreur pendant le traitement
logger.error(
f"[WORKER] Échec traitement session {session_id} "
f"après {elapsed:.1f}s : {result['error']}"
)
with self._lock:
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":
# Pas assez de screenshots valides
logger.warning(
f"[WORKER] Session {session_id} : données insuffisantes "
f"({result.get('states_count', 0)} states) après {elapsed:.1f}s"
)
with self._lock:
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:
# Succès
logger.info(
f"[WORKER] Session {session_id} traitée avec succès en {elapsed:.1f}s | "
f"workflow={result.get('workflow_id', '?')} | "
f"{result.get('nodes', 0)} nodes, {result.get('edges', 0)} edges"
)
with self._lock:
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(),
})
# Callback de notification
if self._on_complete:
try:
self._on_complete(session_id, result)
except Exception as e:
logger.error(f"[WORKER] Erreur callback on_complete: {e}")
except Exception as e:
elapsed = time.time() - start_time
logger.error(
f"[WORKER] Exception inattendue pour session {session_id} "
f"après {elapsed:.1f}s : {e}",
exc_info=True,
)
with self._lock:
self._failed.append({
"session_id": session_id,
"error": f"exception: {e}",
"elapsed_seconds": round(elapsed, 1),
"timestamp": datetime.now().isoformat(),
})
finally:
with self._lock:
self._current_session = None
self._current_progress = None
logger.info(f"[WORKER] === Fin traitement session {session_id} ===")
def _update_progress(self, session_id: str, current: int, total: int, shot_id: str = ""):
"""Callback de progression appelé par reprocess_session."""
with self._lock:
if self._current_progress:
self._current_progress["screenshots_total"] = total
self._current_progress["screenshots_processed"] = current
self._current_progress["status"] = "processing"
self._current_progress["current_shot"] = shot_id
logger.info(
f"[WORKER] Session {session_id} : screenshot {current}/{total}"
+ (f" ({shot_id})" if shot_id else "")
)

View File

@@ -0,0 +1,964 @@
"""
StreamProcessor — Pont entre le streaming Agent V1 et le core pipeline RPA Vision V3.
Orchestre les composants core (ScreenAnalyzer, CLIP, FAISS, GraphBuilder)
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 logging
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
from .live_session_manager import LiveSessionManager
logger = logging.getLogger(__name__)
class StreamProcessor:
"""
Processeur de streaming qui connecte les données Agent V1 au core pipeline.
Cycle de vie :
1. register_session() — crée l'état mémoire
2. process_event() — accumule événements, extrait contexte fenêtre
3. process_screenshot() — analyse via ScreenAnalyzer + CLIP embedding
4. finalize_session() — construit le Workflow via GraphBuilder (DBSCAN)
"""
def __init__(self, data_dir: str = "data/training"):
self.data_dir = Path(data_dir)
persist_dir = str(self.data_dir / "streaming_sessions")
self.session_manager = LiveSessionManager(persist_dir=persist_dir)
self._lock = threading.Lock()
# Core components (chargés paresseusement pour éviter les imports lourds au démarrage)
self._screen_analyzer = None
self._clip_embedder = None
self._state_embedding_builder = None # P0-3 : pipeline d'embedding unifié (fusion multi-modale)
self._faiss_manager = None
self._initialized = False
# Lock pour l'accès concurrent aux données de session (screen_states, embeddings, workflows)
self._data_lock = threading.Lock()
# Résultats d'analyse par session
self._screen_states: Dict[str, list] = {} # session_id -> List[ScreenState]
self._embeddings: Dict[str, list] = {} # session_id -> List[np.ndarray]
# Workflows construits (pour le matching)
self._workflows: Dict[str, Any] = {}
# Charger les workflows existants depuis le disque
self._load_persisted_workflows()
def _load_persisted_workflows(self):
"""Charger les workflows sauvegardés depuis le disque au démarrage.
Scanne le dossier workflows/ principal et les sous-dossiers par machine
(workflows/{machine_id}/) pour la rétrocompatibilité.
"""
workflows_dir = self.data_dir / "workflows"
if not workflows_dir.exists():
return
try:
from core.models.workflow_graph import Workflow
count = 0
# Charger les workflows du dossier racine (rétrocompatibilité)
for wf_file in sorted(workflows_dir.glob("*.json")):
try:
wf = Workflow.load_from_file(wf_file)
self._workflows[wf.workflow_id] = wf
count += 1
except Exception as e:
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
# Charger les workflows des sous-dossiers par machine
for machine_dir in sorted(workflows_dir.iterdir()):
if not machine_dir.is_dir():
continue
for wf_file in sorted(machine_dir.glob("*.json")):
try:
wf = Workflow.load_from_file(wf_file)
# Stocker le machine_id dans les métadonnées du workflow
if not hasattr(wf, '_machine_id'):
wf._machine_id = machine_dir.name
self._workflows[wf.workflow_id] = wf
count += 1
except Exception as e:
logger.warning(f"Impossible de charger {wf_file.name}: {e}")
if count:
logger.info(f"{count} workflow(s) chargé(s) depuis {workflows_dir}")
except ImportError:
logger.debug("core.models.workflow_graph non disponible, skip chargement")
def _ensure_initialized(self):
"""Charger les composants core GPU si pas encore fait."""
if self._initialized:
return
with self._lock:
if self._initialized:
return
logger.info("Initialisation des composants core (GPU)...")
try:
from core.pipeline.screen_analyzer import ScreenAnalyzer
self._screen_analyzer = ScreenAnalyzer(session_id="stream_server")
logger.info(" ScreenAnalyzer prêt")
except Exception as e:
logger.error(f" Erreur init ScreenAnalyzer: {e}")
self._screen_analyzer = None
try:
from core.embedding.clip_embedder import CLIPEmbedder
self._clip_embedder = CLIPEmbedder()
logger.info(" CLIPEmbedder prêt (singleton, ne sera plus rechargé)")
except Exception as e:
logger.error(f" Erreur init CLIPEmbedder: {e}")
self._clip_embedder = None
# P0-3 : Initialiser le StateEmbeddingBuilder pour unifier l'espace d'embedding
# Utilise le même CLIPEmbedder (pas de rechargement du modèle) + FusionEngine
# pour produire des vecteurs fusionnés (image+text+title+ui) identiques à GraphBuilder
try:
from core.embedding.state_embedding_builder import StateEmbeddingBuilder
if self._clip_embedder is not None:
# Injecter le CLIPEmbedder déjà chargé pour éviter un double chargement
self._state_embedding_builder = StateEmbeddingBuilder(
embedders={
"image": self._clip_embedder,
"text": self._clip_embedder,
"title": self._clip_embedder,
"ui": self._clip_embedder,
},
output_dir=self.data_dir / "embeddings",
use_clip=False, # Pas besoin, on fournit les embedders directement
)
else:
# Fallback : laisser le builder créer son propre CLIPEmbedder
self._state_embedding_builder = StateEmbeddingBuilder(
output_dir=self.data_dir / "embeddings",
use_clip=True,
)
logger.info(" StateEmbeddingBuilder prêt (fusion multi-modale unifiée)")
except Exception as e:
logger.warning(f" StateEmbeddingBuilder non disponible, fallback CLIP pur: {e}")
self._state_embedding_builder = None
try:
from core.embedding.faiss_manager import FAISSManager
self._faiss_manager = FAISSManager(
dimensions=512,
index_type="Flat",
metric="cosine",
)
logger.info(" FAISSManager prêt (512 dims, cosine)")
except Exception as e:
logger.error(f" Erreur init FAISSManager: {e}")
self._faiss_manager = None
self._initialized = True
logger.info("Composants core initialisés.")
# =========================================================================
# Événements
# =========================================================================
def process_event(self, session_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]:
"""Enregistrer un événement dans la session live."""
self.session_manager.add_event(session_id, event_data)
return {"status": "event_recorded", "session_id": session_id}
# =========================================================================
# Screenshots
# =========================================================================
def process_screenshot(self, session_id: str, shot_id: str, file_path: str) -> Dict[str, Any]:
"""
Analyser un screenshot full via le core pipeline.
1. ScreenAnalyzer → ScreenState (OCR, UI detection)
2. StateEmbeddingBuilder → vecteur fusionné 512d (image+text+title+ui)
Même espace d'embedding que GraphBuilder (P0-3)
Fallback : CLIP embed_image() si StateEmbeddingBuilder échoue
3. FAISS indexation → matching temps réel
"""
self._ensure_initialized()
self.session_manager.add_screenshot(session_id, shot_id, file_path)
result = {
"shot_id": shot_id,
"session_id": session_id,
"state_id": None,
"ui_elements_count": 0,
"text_detected": 0,
"embedding_indexed": False,
"match": None,
}
# 1. Construire le ScreenState
if self._screen_analyzer is None:
logger.warning("ScreenAnalyzer non disponible, skip analyse")
return result
session = self.session_manager.get_session(session_id)
window_info = session.last_window_info if session else {}
try:
screen_state = self._screen_analyzer.analyze(
screenshot_path=file_path,
window_info=window_info,
)
result["state_id"] = screen_state.screen_state_id
result["ui_elements_count"] = len(screen_state.ui_elements)
result["text_detected"] = len(
getattr(screen_state.perception, "detected_text", [])
)
# Stocker le ScreenState pour le build final
with self._data_lock:
if session_id not in self._screen_states:
self._screen_states[session_id] = []
self._screen_states[session_id].append(screen_state)
logger.info(
f"Screenshot analysé: {shot_id} | "
f"{result['ui_elements_count']} UI elements, "
f"{result['text_detected']} textes"
)
except Exception as e:
logger.error(f"Erreur analyse screenshot {shot_id}: {e}")
return result
# 2. Construire l'embedding fusionné via StateEmbeddingBuilder (P0-3)
# Utilise le même pipeline que GraphBuilder : fusion image+text+title+ui
# pour garantir que les vecteurs FAISS sont dans le même espace d'embedding
embedding_vector = None
if self._state_embedding_builder is not None:
try:
state_embedding = self._state_embedding_builder.build(screen_state)
# Récupérer le vecteur fusionné depuis le StateEmbedding
fused_vec = state_embedding.get_vector()
if fused_vec is not None:
embedding_vector = fused_vec.astype(np.float32)
logger.debug(
f"Embedding fusionné multi-modal calculé pour {shot_id} "
f"(dim={embedding_vector.shape[0]})"
)
except Exception as e:
logger.warning(
f"StateEmbeddingBuilder échoué pour {shot_id}: {e}, "
f"fallback sur CLIP pur"
)
# Fallback 1 : embedding pré-calculé dans le ScreenState (si disponible)
if embedding_vector is None:
if hasattr(screen_state, "perception") and screen_state.perception:
emb_ref = getattr(screen_state.perception, "embedding", None)
if emb_ref and hasattr(emb_ref, "vector") and emb_ref.vector is not None:
embedding_vector = np.array(emb_ref.vector, dtype=np.float32)
# Fallback 2 : utiliser le CLIPEmbedder singleton (embedding image seul)
if embedding_vector is None and self._clip_embedder is not None:
try:
from PIL import Image
pil_image = Image.open(file_path)
embedding_vector = self._clip_embedder.embed_image(pil_image)
except Exception as e:
logger.debug(f"CLIP embedding échoué: {e}")
if embedding_vector is not None:
# Stocker pour le build final
with self._data_lock:
if session_id not in self._embeddings:
self._embeddings[session_id] = []
self._embeddings[session_id].append(embedding_vector)
# 3. Indexer dans FAISS
if self._faiss_manager is not None:
try:
self._faiss_manager.add_embedding(
embedding_id=screen_state.screen_state_id,
vector=embedding_vector,
metadata={
"session_id": session_id,
"shot_id": shot_id,
"window_title": window_info.get("title", ""),
},
)
result["embedding_indexed"] = True
except Exception as e:
logger.error(f"Erreur FAISS indexation: {e}")
# 4. Matching temps réel contre les workflows connus
with self._data_lock:
has_workflows = bool(self._workflows)
if embedding_vector is not None and has_workflows:
result["match"] = self._try_match(embedding_vector)
return result
def process_crop(self, session_id: str, shot_id: str, file_path: str) -> Dict[str, Any]:
"""
Enregistrer un crop (400x400). Pas d'analyse ScreenAnalyzer
(un crop est un fragment, pas un écran complet).
"""
self.session_manager.add_screenshot(session_id, shot_id, file_path)
return {"status": "crop_stored", "shot_id": shot_id}
# =========================================================================
# Finalisation
# =========================================================================
def finalize_session(self, session_id: str) -> Dict[str, Any]:
"""
Construire un Workflow depuis les données accumulées.
Utilise le GraphBuilder du core avec les ScreenStates et embeddings
collectés pendant le streaming.
"""
self._ensure_initialized()
session = self.session_manager.finalize(session_id)
if not session:
return {"error": f"Session {session_id} non trouvée"}
with self._data_lock:
states = list(self._screen_states.get(session_id, []))
embeddings = list(self._embeddings.get(session_id, []))
if len(states) < 2:
logger.warning(
f"Session {session_id}: seulement {len(states)} states, "
f"pas assez pour construire un workflow"
)
return {
"session_id": session_id,
"status": "insufficient_data",
"states_count": len(states),
"min_required": 2,
}
# Convertir en RawSession pour le GraphBuilder
raw_dict = self.session_manager.to_raw_session(session_id)
if not raw_dict:
return {"error": "Conversion RawSession échouée"}
try:
from core.models.raw_session import RawSession
raw_session = RawSession.from_dict(raw_dict)
except Exception as e:
logger.error(f"Erreur construction RawSession: {e}")
# Fallback : construire manuellement
try:
raw_session = self._build_raw_session_fallback(session, raw_dict)
except Exception as e2:
return {"error": f"Erreur RawSession: {e2}"}
# Construire le workflow via GraphBuilder
try:
from core.graph.graph_builder import GraphBuilder
n = len(states)
min_reps = 2 if n < 10 else 3 if n <= 30 else min(5, n // 10)
builder = GraphBuilder(
min_pattern_repetitions=min_reps,
clustering_eps=0.15,
clustering_min_samples=2,
)
# Nommer le workflow intelligemment à partir des titres de fenêtre
workflow_name = self._generate_workflow_name(session_id)
# Injecter les ScreenStates pré-calculés pour éviter de re-analyser
workflow = builder.build_from_session(
raw_session,
workflow_name=workflow_name,
precomputed_states=states,
)
with self._data_lock:
self._workflows[workflow.workflow_id] = workflow
# Persister sur disque (dans le dossier de la machine source)
machine_id = session.machine_id if hasattr(session, 'machine_id') else "default"
saved_path = self._persist_workflow(workflow, session_id, machine_id=machine_id)
# Stocker le machine_id dans le workflow pour le filtrage
workflow._machine_id = machine_id
# Récupérer les métadonnées applicatives de la session
session_state = self.session_manager.get_session(session_id)
app_context = {}
if session_state:
app_context = {
"window_titles": dict(session_state.window_titles_seen),
"app_names": dict(session_state.app_names_seen),
"primary_app": sorted(
session_state.app_names_seen.items(),
key=lambda x: -x[1]
)[0][0] if session_state.app_names_seen else None,
"multi_app": len(session_state.app_names_seen) >= 3,
}
result = {
"session_id": session_id,
"machine_id": machine_id,
"status": "workflow_built",
"workflow_id": workflow.workflow_id,
"workflow_name": workflow_name,
"nodes": len(workflow.nodes),
"edges": len(workflow.edges),
"states_analyzed": len(states),
"embeddings_indexed": len(embeddings),
"saved_path": str(saved_path) if saved_path else None,
"app_context": app_context,
}
logger.info(
f"Workflow construit: '{workflow_name}' ({workflow.workflow_id}) | "
f"{result['nodes']} nodes, {result['edges']} edges"
+ (f" | apps: {list(app_context.get('app_names', {}).keys())}" if app_context.get('app_names') else "")
)
# Libérer la mémoire des données de session (peuvent être lourdes)
self._cleanup_session_data(session_id)
return result
except Exception as e:
logger.error(f"Erreur construction workflow: {e}")
return {"error": f"GraphBuilder: {e}", "session_id": session_id}
# =========================================================================
# Matching
# =========================================================================
def _try_match(self, embedding_vector: np.ndarray) -> Optional[Dict[str, Any]]:
"""Matcher un embedding contre les workflows connus."""
if self._faiss_manager is None or self._faiss_manager.index.ntotal == 0:
return None
try:
results = self._faiss_manager.search_similar(
query_vector=embedding_vector,
k=1,
min_similarity=0.85,
)
if results:
best = results[0]
return {
"matched_id": best.embedding_id,
"similarity": round(best.similarity, 4),
"metadata": best.metadata,
}
except Exception as e:
logger.debug(f"Erreur matching: {e}")
return None
# =========================================================================
# Retraitement (appelé par le SessionWorker)
# =========================================================================
def reprocess_session(
self,
session_id: str,
progress_callback=None,
) -> Dict[str, Any]:
"""Retraiter une session finalisée : analyser tous les screenshots puis construire le workflow.
Utilisé par le SessionWorker pour traiter les sessions en arrière-plan.
Cherche les fichiers shot_*_full.png sur disque, les analyse un par un
via process_screenshot(), puis appelle finalize_session() pour construire
le workflow.
Args:
session_id: Identifiant de la session à retraiter.
progress_callback: Callable(session_id, current, total, shot_id) pour la progression.
Returns:
Dict avec le résultat de finalize_session() ou un dict d'erreur.
"""
logger.info(f"Retraitement de la session {session_id}")
# Trouver le dossier de la session sur disque
# Les screenshots peuvent être dans :
# - data/training/live_sessions/{session_id}/shots/
# - data/training/live_sessions/{machine_id}/{session_id}/shots/
session_dir = self._find_session_dir(session_id)
if not session_dir:
return {"error": f"Dossier session {session_id} introuvable sur disque"}
shots_dir = session_dir / "shots"
if not shots_dir.exists():
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:
return {
"error": f"Aucun screenshot shot_*_full.png trouvé dans {shots_dir}",
"session_id": session_id,
}
total = len(full_shots)
logger.info(
f"Session {session_id} : {total} screenshots full à analyser "
f"dans {shots_dir}"
)
# S'assurer que la session est enregistrée dans le session_manager
self.session_manager.get_or_create(session_id)
# Nettoyer les données en mémoire (au cas où un traitement précédent a échoué)
with self._data_lock:
self._screen_states.pop(session_id, None)
self._embeddings.pop(session_id, None)
# Analyser chaque screenshot full
errors = 0
for i, shot_file in enumerate(full_shots):
shot_id = shot_file.stem # ex: "shot_0001_full"
file_path = str(shot_file)
if progress_callback:
try:
progress_callback(session_id, i + 1, total, shot_id)
except Exception:
pass
try:
result = self.process_screenshot(session_id, shot_id, file_path)
if result.get("state_id") is None:
logger.warning(
f"Screenshot {shot_id} : analyse échouée (pas de state_id)"
)
errors += 1
except Exception as e:
logger.error(f"Erreur analyse screenshot {shot_id}: {e}")
errors += 1
# Vérifier combien de states ont été produits
with self._data_lock:
states_count = len(self._screen_states.get(session_id, []))
logger.info(
f"Session {session_id} : {states_count}/{total} screenshots analysés "
f"({errors} erreurs)"
)
# Construire le workflow via finalize_session()
# Note: finalize() du session_manager a déjà été appelé quand la session
# a été marquée comme finalisée. On n'a pas besoin de le refaire.
# finalize_session() utilise les screen_states accumulés.
result = self.finalize_session(session_id)
return result
def _find_session_dir(self, session_id: str) -> Optional[Path]:
"""Trouver le dossier d'une session sur disque.
Cherche dans :
1. data/training/live_sessions/{session_id}/
2. data/training/live_sessions/{machine_id}/{session_id}/ (multi-machine)
"""
# Chemin direct
direct = self.data_dir / session_id
if direct.is_dir() and (direct / "shots").exists():
return direct
# Chercher dans les sous-dossiers (machine_id)
parent = self.data_dir
if parent.exists():
for subdir in parent.iterdir():
if subdir.is_dir():
candidate = subdir / session_id
if candidate.is_dir() and (candidate / "shots").exists():
return candidate
# Chercher aussi dans le parent du data_dir (cas où data_dir = streaming_sessions)
parent_parent = self.data_dir.parent
if parent_parent.exists() and parent_parent != self.data_dir:
direct2 = parent_parent / session_id
if direct2.is_dir() and (direct2 / "shots").exists():
return direct2
for subdir in parent_parent.iterdir():
if subdir.is_dir() and subdir.name != self.data_dir.name:
candidate = subdir / session_id
if candidate.is_dir() and (candidate / "shots").exists():
return candidate
return None
def find_pending_sessions(self) -> List[str]:
"""Trouver les sessions finalisées qui n'ont pas encore été traitées.
Une session est "pending" si :
- Elle est marquée comme finalisée dans le session_manager
- Elle a 0 ScreenStates en mémoire (jamais analysée ou analyse perdue)
- Elle a des screenshots full sur disque
Returns:
Liste de session_ids à traiter.
"""
pending = []
for sid in self.session_manager.session_ids:
session = self.session_manager.get_session(sid)
if session is None:
continue
if not session.finalized:
continue
# Vérifier si des states existent déjà
with self._data_lock:
states_count = len(self._screen_states.get(sid, []))
if states_count > 0:
continue
# Vérifier si un workflow existe déjà pour cette session
# (parcourir les workflows et checker la session_id dans les métadonnées)
with self._data_lock:
has_workflow = any(
getattr(wf, '_source_session', None) == sid
for wf in self._workflows.values()
)
if has_workflow:
continue
# Vérifier qu'il y a des screenshots full sur disque
session_dir = self._find_session_dir(sid)
if session_dir:
shots_dir = session_dir / "shots"
if shots_dir.exists():
full_shots = list(shots_dir.glob("shot_*_full.png"))
if full_shots:
logger.info(
f"Session pending trouvée : {sid} "
f"({len(full_shots)} screenshots full)"
)
pending.append(sid)
return pending
def _cleanup_session_data(self, session_id: str):
"""Libérer la mémoire des ScreenStates et embeddings après finalization."""
with self._data_lock:
states = self._screen_states.pop(session_id, [])
embeddings = self._embeddings.pop(session_id, [])
logger.info(
f"Mémoire libérée pour {session_id}: "
f"{len(states)} states, {len(embeddings)} embeddings"
)
# =========================================================================
# Helpers
# =========================================================================
def _generate_workflow_name(self, session_id: str) -> str:
"""
Générer un nom de tâche lisible et humain à partir des titres de fenêtre.
Analyse les titres vus pendant la session pour extraire :
- L'application principale (la plus fréquente)
- Le contexte documentaire (après le tiret dans le titre)
- Une description d'action déduite du contexte
Exemples de résultats :
"Chrome - Facturation DPI""Chrome — Facturation DPI"
"Excel - Budget_2026.xlsx""Excel — Budget 2026"
3 apps → "Chrome, Excel et Word"
Aucun contexte → "Tâche du 17 mars à 14h"
"""
import re
session = self.session_manager.get_session(session_id)
if not session:
return self._fallback_task_name()
titles = session.window_titles_seen
apps = session.app_names_seen
if not titles and not apps:
return self._fallback_task_name()
# Trier par fréquence décroissante
sorted_titles = sorted(titles.items(), key=lambda x: -x[1])
sorted_apps = sorted(apps.items(), key=lambda x: -x[1])
# Extraire le nom d'app depuis le titre le plus fréquent
primary_title = sorted_titles[0][0] if sorted_titles else ""
primary_app = sorted_apps[0][0] if sorted_apps else ""
# Nettoyer le nom d'application pour l'affichage humain
app_display = self._humanize_app_name(primary_app) if primary_app else ""
# Extraire la partie contextuelle du titre (après/avant le séparateur)
context_part = ""
for sep in [" - ", "", " ", " | ", ": "]:
if sep in primary_title:
parts = primary_title.split(sep)
if len(parts) >= 2:
candidates = [p.strip() for p in parts]
app_lower = primary_app.lower()
context_candidates = [
c for c in candidates
if app_lower not in c.lower()
and c.lower() not in app_lower
]
if context_candidates:
context_part = context_candidates[0]
else:
context_part = candidates[0]
break
# Construire le nom lisible
distinct_apps = [a for a, _ in sorted_apps if a.lower() not in ("unknown", "explorer")]
if len(distinct_apps) >= 3:
# Multi-app : "Chrome, Excel et Word"
app_names = [self._humanize_app_name(a) for a in distinct_apps[:3]]
if len(app_names) == 3:
name = f"{app_names[0]}, {app_names[1]} et {app_names[2]}"
else:
name = " et ".join(app_names)
elif context_part:
# Nettoyer le contexte pour le rendre lisible
clean_context = re.sub(r'[<>:"/\\|?*\[\]]', '', context_part)
# Retirer les extensions de fichier courantes
clean_context = re.sub(r'\.(xlsx?|csv|docx?|pdf|txt)$', '', clean_context, flags=re.IGNORECASE)
# Remplacer les underscores par des espaces
clean_context = clean_context.replace('_', ' ').strip()[:40]
if app_display:
name = f"{app_display} \u2014 {clean_context}"
else:
name = clean_context
elif app_display:
name = f"{app_display} \u2014 session"
else:
name = self._fallback_task_name()
# Dédoublonner si une tâche avec ce nom existe déjà
base_name = name
counter = 1
with self._data_lock:
existing_names = {
getattr(w, 'name', '') for w in self._workflows.values()
}
while name in existing_names:
counter += 1
name = f"{base_name} ({counter})"
return name
@staticmethod
def _fallback_task_name() -> str:
"""Générer un nom de tâche par défaut basé sur la date et l'heure."""
now = datetime.now()
# Noms de mois en français
mois = [
"", "janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
]
return f"Tâche du {now.day} {mois[now.month]} à {now.hour}h{now.minute:02d}"
@staticmethod
def _humanize_app_name(app_name: str) -> str:
"""Convertir un nom d'application technique en nom lisible.
Exemples :
"notepad.exe""Bloc-notes"
"chrome.exe""Chrome"
"WindowsTerminal""Terminal"
"""
import re
# Supprimer l'extension .exe et les chemins
name = app_name.split("\\")[-1].split("/")[-1]
name = re.sub(r'\.exe$', '', name, flags=re.IGNORECASE).strip()
# Dictionnaire de noms humains pour les applications courantes
app_human_names = {
"notepad": "Bloc-notes",
"notepad++": "Notepad++",
"chrome": "Chrome",
"msedge": "Edge",
"firefox": "Firefox",
"explorer": "Explorateur",
"windowsterminal": "Terminal",
"cmd": "Invite de commandes",
"powershell": "PowerShell",
"excel": "Excel",
"winword": "Word",
"powerpnt": "PowerPoint",
"outlook": "Outlook",
"teams": "Teams",
"code": "VS Code",
"searchhost": "Recherche",
"applicationframehost": "Application",
"calc": "Calculatrice",
"mspaint": "Paint",
"snippingtool": "Capture d'écran",
}
name_lower = name.lower()
if name_lower in app_human_names:
return app_human_names[name_lower]
# Capitaliser le nom si pas dans le dictionnaire
return name.capitalize() if name else "Application"
@staticmethod
def _clean_app_name(app_name: str) -> str:
"""Nettoyer un nom d'application pour l'utiliser dans un nom de workflow."""
import re
# Supprimer l'extension .exe et les chemins
name = app_name.split("\\")[-1].split("/")[-1]
name = re.sub(r'\.exe$', '', name, flags=re.IGNORECASE)
# Capitaliser
name = name.strip().capitalize()
# Supprimer les caractères spéciaux
name = re.sub(r'[^a-zA-Z0-9àâäéèêëïîôùûüÿçÀÂÄÉÈÊËÏÎÔÙÛÜŸÇ_]', '', name)
return name or "App"
def _persist_workflow(self, workflow, session_id: str, machine_id: str = "default") -> Optional[Path]:
"""Sauvegarder le workflow JSON sur disque.
Les workflows sont sauvegardés dans un sous-dossier par machine :
data/training/workflows/{machine_id}/wf_xxx.json
Cela permet de distinguer les workflows appris sur des machines différentes.
"""
try:
# Dossier par machine (ou racine pour "default")
if machine_id and machine_id != "default":
workflows_dir = self.data_dir / "workflows" / machine_id
else:
workflows_dir = self.data_dir / "workflows"
workflows_dir.mkdir(parents=True, exist_ok=True)
filepath = workflows_dir / f"{workflow.workflow_id}.json"
workflow.save_to_file(filepath)
# Stocker le machine_id dans le workflow pour référence
if not hasattr(workflow, '_machine_id'):
workflow._machine_id = machine_id
logger.info(f"Workflow sauvegardé: {filepath} (machine={machine_id})")
return filepath
except Exception as e:
logger.error(f"Erreur sauvegarde workflow {session_id}: {e}")
return None
def _build_raw_session_fallback(self, session, raw_dict):
"""Construire un RawSession manuellement si from_dict échoue."""
from core.models.raw_session import RawSession, Event, Screenshot, RawWindowContext
events = []
for evt_dict in raw_dict.get("events", []):
window_data = evt_dict.get("window", {"title": "", "app_name": "unknown"})
window = RawWindowContext(
title=window_data.get("title", ""),
app_name=window_data.get("app_name", "unknown"),
)
events.append(Event(
t=evt_dict.get("t", 0.0),
type=evt_dict.get("type", "unknown"),
window=window,
data={k: v for k, v in evt_dict.items()
if k not in ("t", "type", "window", "screenshot_id")},
screenshot_id=evt_dict.get("screenshot_id"),
))
screenshots = []
for ss_dict in raw_dict.get("screenshots", []):
screenshots.append(Screenshot(
screenshot_id=ss_dict["screenshot_id"],
relative_path=ss_dict.get("relative_path", ss_dict.get("path", "")),
captured_at=ss_dict.get("captured_at", datetime.now().isoformat()),
))
return RawSession(
session_id=session.session_id,
agent_version="agent_v1_stream",
environment=raw_dict.get("environment", {}),
user=raw_dict.get("user", {"id": "remote_agent"}),
context=raw_dict.get("context", {}),
started_at=session.created_at,
ended_at=datetime.now(),
events=events,
screenshots=screenshots,
)
def list_sessions(self, machine_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Lister les sessions avec leur état.
Args:
machine_id: Si fourni, filtre par machine. Si None, retourne toutes les sessions.
"""
sessions = []
for sid in self.session_manager.session_ids:
session = self.session_manager.get_session(sid)
if session is None:
continue
# Filtre par machine si demandé
if machine_id and session.machine_id != machine_id:
continue
with self._data_lock:
states_count = len(self._screen_states.get(sid, []))
embeddings_count = len(self._embeddings.get(sid, []))
sessions.append({
"session_id": session.session_id,
"machine_id": session.machine_id,
"events_count": len(session.events),
"screenshots_count": len(session.shot_paths),
"states_count": states_count,
"embeddings_count": embeddings_count,
"last_window": session.last_window_info,
"created_at": session.created_at.isoformat(),
"last_activity": session.last_activity.isoformat(),
"finalized": session.finalized,
})
return sessions
def list_workflows(self, machine_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Lister les workflows construits.
Args:
machine_id: Si fourni, filtre par machine. Si None, retourne tous les workflows.
"""
with self._data_lock:
workflows_snapshot = list(self._workflows.items())
result = []
for wf_id, wf in workflows_snapshot:
wf_machine = getattr(wf, '_machine_id', 'default')
# Filtre par machine si demandé
if machine_id and wf_machine != machine_id:
continue
result.append({
"workflow_id": wf_id,
"machine_id": wf_machine,
"nodes": len(wf.nodes) if hasattr(wf, "nodes") else 0,
"edges": len(wf.edges) if hasattr(wf, "edges") else 0,
"name": getattr(wf, "name", wf_id),
})
return result
@property
def stats(self) -> Dict[str, Any]:
"""Statistiques du processeur."""
with self._data_lock:
total_workflows = len(self._workflows)
return {
"active_sessions": self.session_manager.active_session_count,
"total_sessions": len(self.session_manager.session_ids),
"total_workflows": total_workflows,
"faiss_vectors": self._faiss_manager.index.ntotal if self._faiss_manager else 0,
"initialized": self._initialized,
}

View File

@@ -0,0 +1,54 @@
# server_v1/visual_wait.py
"""
Module de patience visuelle pour le Stagiaire.
Permet d'attendre l'apparition d'un élément UI avant d'agir.
"""
import time
import logging
from pathlib import Path
from .vm_controller import VMController
# On suppose l'existence du moteur de vision dans core
# from core.vision.vlm_service import VLMService
logger = logging.getLogger("visual_wait")
class VisualWaitManager:
def __init__(self, vm_controller: VMController):
self.vm = vm_controller
# self.vlm = VLMService()
def wait_for_element(self, element_id: str, target_embedding, timeout: int = 30, threshold: float = 0.85):
"""
Attend qu'un élément visuel soit détecté à l'écran de la VM.
"""
logger.info(f"⏳ Patience visuelle : attente de '{element_id}'...")
start_time = time.time()
while (time.time() - start_time) < timeout:
# 1. Capture de l'état actuel de la VM (via le flux vidéo/SPICE)
# current_screen = self.vm.get_current_frame()
# 2. Comparaison avec l'élément attendu (via l'IA)
# score = self.vlm.compare(current_screen, target_embedding)
score = 0.0 # Placeholder simulation
if score >= threshold:
logger.info(f"✅ Élément '{element_id}' détecté ! Score: {score}")
return True
logger.debug(f" ... toujours en attente (score actuel: {score})")
time.sleep(1) # On ne surcharge pas le GPU
logger.warning(f"❌ Timeout : Élément '{element_id}' non trouvé après {timeout}s.")
return False
def wait_for_stable_screen(self, duration: float = 2.0, threshold: float = 0.98):
"""
Attend que l'écran de la VM arrête de bouger (fin d'une animation ou chargement).
"""
logger.info("⏳ Attente de stabilisation de l'écran...")
# Logique de comparaison de hashs successifs
time.sleep(duration)
return True

View File

@@ -0,0 +1,143 @@
# server_v1/vm_controller.py
"""
Contrôleur de VM Windows via libvirt (virsh).
Injection d'événements HID (souris/clavier) au niveau de l'hyperviseur.
C'est le "bras armé" du Stagiaire pour l'exécution GHOST (sans agent).
"""
import subprocess
import logging
import time
logger = logging.getLogger("vm_controller")
class VMController:
def __init__(self, domain_name: str):
self.domain_name = domain_name
def start_vm(self):
"""Démarre la VM si elle est éteinte."""
try:
logger.info(f"🚀 Démarrage de la VM {self.domain_name}...")
subprocess.run(f"virsh start {self.domain_name}", shell=True, check=True)
return True
except Exception as e:
logger.error(f"❌ Impossible de démarrer la VM: {e}")
return False
def stop_vm(self, force=False):
"""Arrête la VM proprement (ou force l'arrêt)."""
cmd = "destroy" if force else "shutdown"
try:
logger.info(f"🛑 Arrêt de la VM {self.domain_name} ({cmd})...")
subprocess.run(f"virsh {cmd} {self.domain_name}", shell=True, check=True)
return True
except Exception as e:
logger.error(f"❌ Erreur lors de l'arrêt: {e}")
return False
def get_status(self) -> str:
"""Retourne l'état actuel de la VM (running, shut off, etc.)."""
try:
res = subprocess.check_output(f"virsh domstate {self.domain_name}", shell=True)
return res.decode().strip()
except:
return "unknown"
def create_checkpoint(self, checkpoint_name: str = "before_workflow"):
"""Crée un snapshot de la VM pour pouvoir revenir en arrière en cas d'erreur."""
try:
logger.info(f"📸 Création du checkpoint '{checkpoint_name}' pour {self.domain_name}...")
# On utilise --atomic pour garantir l'intégrité
subprocess.run(f"virsh snapshot-create-as {self.domain_name} {checkpoint_name} --atomic", shell=True, check=True)
return True
except Exception as e:
logger.error(f"❌ Échec de création du checkpoint: {e}")
return False
def restore_checkpoint(self, checkpoint_name: str = "before_workflow"):
"""Restaure la VM à un état précédent instantanément."""
try:
logger.warning(f"🔄 Restauration du checkpoint '{checkpoint_name}' pour {self.domain_name}...")
# On force la restauration
subprocess.run(f"virsh snapshot-revert {self.domain_name} {checkpoint_name} --force", shell=True, check=True)
return True
except Exception as e:
logger.error(f"❌ Échec de la restauration: {e}")
return False
def inject_click(self, x_pct: float, y_pct: float, button: str = "left"):
"""
Injecte un clic de souris aux coordonnées proportionnelles (0.0-1.0).
Utilise l'interface QEMU via virsh pour la précision absolue.
"""
try:
# Note: Pour QEMU/KVM, on utilise souvent l'interface moniteur 'qemu-monitor-command'
# pour envoyer des coordonnées absolues si une tablette USB est présente (évite le drift).
# Exemple de commande QEMU Monitor pour un clic absolu
# (nécessite que la VM ait un périphérique tablette USB configuré)
cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_set 0 0 0 {x_pct} {y_pct}'"
subprocess.run(cmd, shell=True, check=True)
# Simulation du clic bouton
click_cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_button 1'" # 1 = Left
subprocess.run(click_cmd, shell=True, check=True)
time.sleep(0.05)
release_cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_button 0'"
subprocess.run(release_cmd, shell=True, check=True)
logger.info(f"🖱️ Clic GHOST injecté dans {self.domain_name} à ({x_pct}, {y_pct})")
except Exception as e:
logger.error(f"❌ Erreur Injection Clic: {e}")
def inject_text(self, text: str):
"""
Injecte du texte dans la VM en traduisant les caractères en séquences de touches.
Gère les majuscules (via Shift) et les caractères standards.
"""
try:
logger.info(f"⌨️ Saisie GHOST dans {self.domain_name} : '{text}'")
for char in text:
self._send_char(char)
# Petit délai pour simuler une frappe humaine et éviter la saturation du buffer
time.sleep(0.02)
except Exception as e:
logger.error(f"❌ Erreur Injection Texte: {e}")
def inject_key_combo(self, keys: list):
"""
Exécute une combinaison de touches (ex: ['ctrl', 'alt', 'delete']).
"""
try:
combo = "+".join(keys)
cmd = f"virsh sendkey {self.domain_name} {combo}"
subprocess.run(cmd, shell=True, check=True)
logger.info(f"⌨️ Combo GHOST : {combo}")
except Exception as e:
logger.error(f"❌ Erreur Combo: {e}")
def _send_char(self, char: str):
"""Traduit un caractère unique en commande virsh sendkey."""
# Mapping des caractères spéciaux pour virsh
special_map = {
" ": "space", "\n": "enter", "\t": "tab", ".": "dot",
",": "comma", "-": "minus", "_": "underscore", "/": "slash"
}
if char in special_map:
key = special_map[char]
cmd = f"virsh sendkey {self.domain_name} {key}"
elif char.isupper():
key = char.lower()
cmd = f"virsh sendkey {self.domain_name} shift+{key}"
else:
key = char
cmd = f"virsh sendkey {self.domain_name} {key}"
subprocess.run(cmd, shell=True, check=True)
if __name__ == "__main__":
# Test rapide sur une VM de démo
controller = VMController("win10_demo")
# controller.inject_click(0.5, 0.5) # Clic au centre

View File

@@ -0,0 +1,172 @@
# agent_v0/server_v1/worker_stream.py
"""
Worker de Streaming Temps Réel — délègue au StreamProcessor (core pipeline).
Surveille les sessions live, analyse screenshots et crops via ScreenAnalyzer + CLIP,
et met à jour le graphe d'intention en temps réel.
Tous les calculs GPU tournent sur le serveur (RTX 5070).
"""
import logging
import threading
import time
from pathlib import Path
from typing import Set
from .stream_processor import StreamProcessor
logger = logging.getLogger("worker_stream")
class StreamWorker:
"""
Worker qui surveille les sessions live et délègue au StreamProcessor.
Deux modes de fonctionnement :
- Polling (start) : boucle qui surveille le dossier live_sessions
- Direct (process_*) : appelé directement par l'API pour traitement immédiat
"""
def __init__(self, live_dir: str = "data/training/live_sessions", processor: StreamProcessor = None):
self.live_dir = Path(live_dir)
self.live_dir.mkdir(parents=True, exist_ok=True)
self.running = False
self.processed_files: Set[str] = set()
# StreamProcessor partagé (créé si non fourni)
self.processor = processor or StreamProcessor(data_dir=str(self.live_dir))
self._thread: threading.Thread = None
def start(self, blocking: bool = True):
"""Démarrer le worker en mode polling."""
self.running = True
logger.info("StreamWorker démarré — surveillance des sessions live.")
if blocking:
self._poll_loop()
else:
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def stop(self):
"""Arrêter proprement le worker."""
self.running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
logger.info("StreamWorker arrêté.")
def _poll_loop(self):
"""Boucle de polling pour les sessions live sur disque."""
while self.running:
try:
self._check_live_sessions()
time.sleep(0.5)
except KeyboardInterrupt:
self.stop()
except Exception as e:
logger.error(f"Erreur worker loop: {e}")
def _check_live_sessions(self):
"""Parcourir les sessions en cours pour trouver du travail."""
if not self.live_dir.exists():
return
for session_path in self.live_dir.iterdir():
if session_path.is_dir():
self._process_session_incremental(session_path)
def _process_session_incremental(self, session_path: Path):
"""Analyser les nouveaux éléments d'une session active."""
session_id = session_path.name
shots_dir = session_path / "shots"
# Enregistrer la session si pas encore fait
self.processor.session_manager.get_or_create(session_id)
# Traiter les nouveaux screenshots full
for shot_file in sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []:
file_key = str(shot_file)
if file_key in self.processed_files:
continue
shot_id = shot_file.stem
if "_crop" in shot_id:
result = self.processor.process_crop(session_id, shot_id, str(shot_file))
logger.debug(f"Crop traité: {shot_id}")
elif shot_id.startswith("heartbeat_") or shot_id.startswith("focus_") or shot_id.startswith("res_shot_"):
# Pas d'analyse GPU pour les heartbeats, focus et res_shot
self.processor.session_manager.add_screenshot(session_id, shot_id, str(shot_file))
elif shot_id.startswith("shot_") and "_full" in shot_id:
result = self.processor.process_screenshot(session_id, shot_id, str(shot_file))
logger.info(
f"Screenshot analysé: {shot_id} | "
f"{result.get('ui_elements_count', 0)} UI, "
f"{result.get('text_detected', 0)} textes"
)
else:
# Autres screenshots non reconnus : stocker sans analyser
self.processor.session_manager.add_screenshot(session_id, shot_id, str(shot_file))
self.processed_files.add(file_key)
# Traiter les événements
event_file = session_path / "live_events.jsonl"
if event_file.exists():
self._ingest_events(session_id, event_file)
def _ingest_events(self, session_id: str, event_file: Path):
"""Lire et ingérer les événements depuis un fichier JSONL."""
import json
event_key = f"{session_id}:events:{event_file.stat().st_size}"
if event_key in self.processed_files:
return
try:
with open(event_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
event_data = json.loads(line)
self.processor.process_event(session_id, event_data)
except json.JSONDecodeError:
continue
self.processed_files.add(event_key)
except Exception as e:
logger.error(f"Erreur lecture événements {event_file}: {e}")
# =========================================================================
# API directe (appelé par api_stream.py)
# =========================================================================
def process_screenshot_direct(self, session_id: str, shot_id: str, file_path: str):
"""Traitement direct d'un screenshot (appelé par l'API)."""
return self.processor.process_screenshot(session_id, shot_id, file_path)
def process_crop_direct(self, session_id: str, shot_id: str, file_path: str):
"""Traitement direct d'un crop (appelé par l'API)."""
return self.processor.process_crop(session_id, shot_id, file_path)
def process_event_direct(self, session_id: str, event_data: dict):
"""Traitement direct d'un événement (appelé par l'API)."""
return self.processor.process_event(session_id, event_data)
def finalize_session(self, session_id: str):
"""Finaliser une session et construire le workflow."""
return self.processor.finalize_session(session_id)
@property
def stats(self):
return self.processor.stats
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [STREAM-WORKER] %(message)s",
)
worker = StreamWorker()
worker.start()