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/server_v1/__init__.py
Normal file
0
agent_v0/server_v1/__init__.py
Normal file
1964
agent_v0/server_v1/api_stream.py
Normal file
1964
agent_v0/server_v1/api_stream.py
Normal file
File diff suppressed because it is too large
Load Diff
306
agent_v0/server_v1/live_session_manager.py
Normal file
306
agent_v0/server_v1/live_session_manager.py
Normal 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()))
|
||||
347
agent_v0/server_v1/replay_verifier.py
Normal file
347
agent_v0/server_v1/replay_verifier.py
Normal 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}%)"
|
||||
),
|
||||
)
|
||||
253
agent_v0/server_v1/session_worker.py
Normal file
253
agent_v0/server_v1/session_worker.py
Normal 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 "")
|
||||
)
|
||||
964
agent_v0/server_v1/stream_processor.py
Normal file
964
agent_v0/server_v1/stream_processor.py
Normal 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,
|
||||
}
|
||||
54
agent_v0/server_v1/visual_wait.py
Normal file
54
agent_v0/server_v1/visual_wait.py
Normal 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
|
||||
143
agent_v0/server_v1/vm_controller.py
Normal file
143
agent_v0/server_v1/vm_controller.py
Normal 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
|
||||
172
agent_v0/server_v1/worker_stream.py
Normal file
172
agent_v0/server_v1/worker_stream.py
Normal 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()
|
||||
Reference in New Issue
Block a user