Module isolé, 3 signaux indépendants : - screen_static : CLIP similarity > 0.99 sur N captures consécutives - action_repeat : N actions identiques (type+coords) - retry_threshold : retried_actions >= seuil Premier signal positif → LoopVerdict.detected=True (caller responsable de la bascule en paused_need_help). Configurable env vars : RPA_LOOP_DETECTOR_ENABLED (kill-switch), RPA_LOOP_SCREEN_STATIC_N/THRESHOLD, RPA_LOOP_ACTION_REPEAT_N, RPA_LOOP_RETRY_THRESHOLD. Tests : 8 cas (chaque signal isolé, kill-switch, embedder absent, exception). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
# 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()
|