# agent_v0/server_v1/loop_detector.py """LoopDetector composite — détection de stagnation de Léa pendant un replay (QW2). Trois signaux indépendants : - screen_static : N captures consécutives avec CLIP similarity > seuil - action_repeat : N actions consécutives identiques (type + coords) - retry_threshold : nombre de retries cumulés >= seuil Un seul signal positif → verdict.detected=True. Le serveur bascule alors le replay en paused_need_help avec pause_reason explicite. Désactivable via env var RPA_LOOP_DETECTOR_ENABLED=0. """ import logging import os from dataclasses import dataclass, field from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @dataclass class LoopVerdict: detected: bool = False reason: str = "" signal: str = "" # "screen_static" | "action_repeat" | "retry_threshold" | "" evidence: Dict[str, Any] = field(default_factory=dict) def _env_int(name: str, default: int) -> int: try: return int(os.environ.get(name, default)) except (TypeError, ValueError): return default def _env_float(name: str, default: float) -> float: try: return float(os.environ.get(name, default)) except (TypeError, ValueError): return default def _env_bool_enabled(name: str) -> bool: val = os.environ.get(name, "1").strip().lower() return val not in ("0", "false", "no", "off", "") def _cosine_similarity(a, b) -> float: """Similarité cosine entre deux vecteurs (listes ou np.array). Robuste vecteur nul.""" import numpy as np av = np.asarray(a, dtype=np.float32).flatten() bv = np.asarray(b, dtype=np.float32).flatten() na, nb = float(np.linalg.norm(av)), float(np.linalg.norm(bv)) if na < 1e-8 or nb < 1e-8: return 0.0 return float(np.dot(av, bv) / (na * nb)) class LoopDetector: def __init__(self, clip_embedder=None): self.clip_embedder = clip_embedder def evaluate( self, state: Dict[str, Any], screenshots: List[Any], actions: List[Dict[str, Any]], ) -> LoopVerdict: """Évalue les 3 signaux. Retourne le premier déclenché. Args: state: replay_state (utilisé pour retried_actions) screenshots: anneau d'embeddings CLIP (les N derniers) actions: anneau des N dernières actions exécutées """ if not _env_bool_enabled("RPA_LOOP_DETECTOR_ENABLED"): return LoopVerdict(detected=False) # Signal A : screen_static verdict = self._check_screen_static(screenshots) if verdict.detected: return verdict # Signal B : action_repeat verdict = self._check_action_repeat(actions) if verdict.detected: return verdict # Signal C : retry_threshold verdict = self._check_retry_threshold(state) if verdict.detected: return verdict return LoopVerdict(detected=False) def _check_screen_static(self, screenshots: List[Any]) -> LoopVerdict: n_required = _env_int("RPA_LOOP_SCREEN_STATIC_N", 4) threshold = _env_float("RPA_LOOP_SCREEN_STATIC_THRESHOLD", 0.99) if self.clip_embedder is None or len(screenshots) < n_required: return LoopVerdict() try: recent = screenshots[-n_required:] # Embed chaque capture via le CLIP embedder (peut lever) embeddings = [self.clip_embedder.embed_image(img) for img in recent] sims = [_cosine_similarity(embeddings[i], embeddings[i + 1]) for i in range(len(embeddings) - 1)] min_sim = min(sims) if min_sim > threshold: return LoopVerdict( detected=True, reason="loop_detected", signal="screen_static", evidence={"min_similarity": round(min_sim, 4), "n_captures": n_required, "threshold": threshold}, ) except Exception as e: logger.warning("LoopDetector signal_A erreur (%s) — signal inerte ce tick", e) return LoopVerdict() def _check_action_repeat(self, actions: List[Dict[str, Any]]) -> LoopVerdict: n_required = _env_int("RPA_LOOP_ACTION_REPEAT_N", 3) if len(actions) < n_required: return LoopVerdict() recent = actions[-n_required:] def _signature(a: Dict[str, Any]) -> tuple: return (a.get("type"), a.get("x_pct"), a.get("y_pct")) sigs = [_signature(a) for a in recent] if all(s == sigs[0] for s in sigs): return LoopVerdict( detected=True, reason="loop_detected", signal="action_repeat", evidence={"signature": sigs[0], "count": n_required}, ) return LoopVerdict() def _check_retry_threshold(self, state: Dict[str, Any]) -> LoopVerdict: threshold = _env_int("RPA_LOOP_RETRY_THRESHOLD", 3) retried = int(state.get("retried_actions", 0)) if retried >= threshold: return LoopVerdict( detected=True, reason="loop_detected", signal="retry_threshold", evidence={"retried_actions": retried, "threshold": threshold}, ) return LoopVerdict()