# agent_v0/server_v1/replay_verifier.py """ ReplayVerifier — Vérification post-action (Critic) pour le replay de workflows. Deux niveaux de vérification : 1. PIXEL : Différence d'image avant/après (rapide, ~10ms) - L'écran a-t-il changé ? Où ? De combien ? 2. SÉMANTIQUE : VLM évalue si le résultat correspond à l'attendu (~2-5s) - L'action a-t-elle eu l'EFFET voulu ? (pas juste "des pixels ont bougé") Le niveau pixel existait déjà. Le niveau sémantique (Critic) est le chaînon manquant identifié par comparaison avec Claude Computer Use et OpenAdapt. Ref: docs/VISION_RPA_INTELLIGENT.md — étape VERIFY du pipeline. """ import logging import os import time from dataclasses import dataclass, field from typing import Any, Dict, List, 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) # Critic sémantique (VLM) semantic_verified: Optional[bool] = None # None = pas de vérif sémantique semantic_detail: str = "" # Explication du VLM semantic_elapsed_ms: float = 0.0 # Temps de la vérif sémantique def to_dict(self) -> Dict[str, Any]: d = { "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), } if self.semantic_verified is not None: d["semantic_verified"] = self.semantic_verified d["semantic_detail"] = self.semantic_detail d["semantic_elapsed_ms"] = round(self.semantic_elapsed_ms, 1) return d 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}%)" ), ) # ========================================================================= # Critic sémantique — VLM évalue si le résultat correspond à l'attendu # ========================================================================= def verify_with_critic( self, action: Dict[str, Any], result: Dict[str, Any], screenshot_before: Optional[str] = None, screenshot_after: Optional[str] = None, expected_result: str = "", action_intention: str = "", workflow_context: str = "", ) -> VerificationResult: """Vérification complète : pixel + sémantique (Critic). Étape 1 : Vérification pixel (rapide, ~10ms) — l'écran a-t-il changé ? Étape 2 : Vérification sémantique (VLM, ~2-5s) — le changement est-il le bon ? La vérification sémantique n'est lancée que si : - expected_result est fourni (description de l'état attendu après l'action) - La vérification pixel a détecté un changement (sinon, pas besoin du VLM) Args: action: L'action exécutée result: Le résultat rapporté par l'agent screenshot_before: Screenshot avant l'action (base64) screenshot_after: Screenshot après l'action (base64) expected_result: Description de l'état attendu après l'action action_intention: Ce que l'action était censée faire workflow_context: Contexte global (progression, objectif) """ # Étape 1 : vérification pixel (existante) pixel_result = self.verify_action( action=action, result=result, screenshot_before=screenshot_before, screenshot_after=screenshot_after, ) # Pas de description attendue → retourner le résultat pixel seul if not expected_result: return pixel_result # Si aucun changement pixel ET suggestion retry → pas besoin du VLM if not pixel_result.changes_detected and pixel_result.suggestion == "retry": return pixel_result # Étape 2 : vérification sémantique via VLM semantic = self._verify_semantic( screenshot_before=screenshot_before, screenshot_after=screenshot_after, expected_result=expected_result, action_intention=action_intention, workflow_context=workflow_context, ) if semantic is None: # VLM indisponible → garder le résultat pixel seul return pixel_result # Fusionner les résultats pixel + sémantique return self._merge_results(pixel_result, semantic) def _verify_semantic( self, screenshot_before: Optional[str], screenshot_after: Optional[str], expected_result: str, action_intention: str = "", workflow_context: str = "", ) -> Optional[Dict[str, Any]]: """Appeler le VLM pour évaluer sémantiquement le résultat de l'action. Utilise gemma4 en mode texte+images (Docker port 11435) pour analyser les screenshots avant/après et dire si le résultat attendu est atteint. Sur Citrix (image plate), c'est la SEULE façon de vérifier intelligemment si une action a eu l'effet voulu. Returns: Dict avec {"verified": bool, "detail": str, "elapsed_ms": float} ou None si le VLM est indisponible. """ import requests as _requests if not screenshot_after: return None gemma4_port = os.environ.get("GEMMA4_PORT", "11435") gemma4_url = f"http://localhost:{gemma4_port}/api/chat" # Construire le prompt Critic context_parts = [] if action_intention: context_parts.append(f"Action effectuée : {action_intention}") if workflow_context: context_parts.append(f"Contexte : {workflow_context}") context_str = "\n".join(context_parts) # Deux images : avant et après images = [] prompt_images = "" if screenshot_before and screenshot_after: images = [screenshot_before, screenshot_after] prompt_images = ( "Image 1 = écran AVANT l'action.\n" "Image 2 = écran APRÈS l'action.\n" ) elif screenshot_after: images = [screenshot_after] prompt_images = "Image = écran APRÈS l'action.\n" prompt = ( f"Tu es le VÉRIFICATEUR d'un robot RPA. Tu dois dire si l'action a réussi.\n\n" f"{prompt_images}" f"{context_str}\n\n" f"Résultat attendu : {expected_result}\n\n" f"Est-ce que le résultat attendu est visible à l'écran ?\n" f"Réponds EXACTEMENT dans ce format :\n" f"VERDICT: OUI ou NON\n" f"RAISON: explication courte (1 ligne)" ) # Injecter le contexte métier si disponible from .domain_context import get_domain_context domain = get_domain_context(os.environ.get("RPA_DOMAIN", "generic")) messages = [] if domain.system_prompt: messages.append({"role": "system", "content": domain.system_prompt}) messages.append({"role": "user", "content": prompt, "images": images}) try: t_start = time.time() resp = _requests.post( gemma4_url, json={ "model": "gemma4:e4b", "messages": messages, "stream": False, "think": True, "options": {"temperature": 0.1, "num_predict": 800}, }, timeout=30, ) elapsed_ms = (time.time() - t_start) * 1000 if not resp.ok: logger.warning(f"Critic VLM HTTP {resp.status_code}") return None content = resp.json().get("message", {}).get("content", "").strip() # Parser le verdict verified = None detail = content for line in content.split("\n"): line_upper = line.strip().upper() if line_upper.startswith("VERDICT:"): verdict_text = line_upper.replace("VERDICT:", "").strip() if "OUI" in verdict_text or "YES" in verdict_text: verified = True elif "NON" in verdict_text or "NO" in verdict_text: verified = False elif line_upper.startswith("RAISON:"): detail = line.strip().replace("RAISON:", "").strip() if verified is None: # Fallback : chercher OUI/NON dans le texte brut upper = content.upper() if "OUI" in upper and "NON" not in upper: verified = True elif "NON" in upper: verified = False else: logger.warning(f"Critic VLM réponse non parsable : {content[:100]}") return None logger.info( f"Critic VLM : {'OUI' if verified else 'NON'} en {elapsed_ms:.0f}ms — {detail[:80]}" ) return { "verified": verified, "detail": detail, "elapsed_ms": elapsed_ms, } except _requests.Timeout: logger.warning("Critic VLM timeout (30s)") return None except Exception as e: logger.warning(f"Critic VLM erreur : {e}") return None def _merge_results( self, pixel: VerificationResult, semantic: Dict[str, Any], ) -> VerificationResult: """Fusionner les résultats pixel et sémantique. Matrice de décision : - Pixel OK + Semantic OK → vérifié (confiance haute) - Pixel OK + Semantic NON → INATTENDU (l'écran a changé mais pas comme prévu) - Pixel NON + Semantic OK → vérifié quand même (le VLM voit le résultat) - Pixel NON + Semantic NON → échec (retry) """ sem_ok = semantic["verified"] pix_ok = pixel.changes_detected if pix_ok and sem_ok: # Tout concorde — confiance maximale return VerificationResult( verified=True, confidence=min(0.95, pixel.confidence + 0.2), changes_detected=True, change_area_pct=pixel.change_area_pct, local_change_pct=pixel.local_change_pct, suggestion="continue", detail=f"Pixel OK + Critic OK : {semantic['detail']}", semantic_verified=True, semantic_detail=semantic["detail"], semantic_elapsed_ms=semantic["elapsed_ms"], ) elif pix_ok and not sem_ok: # L'écran a changé mais pas dans le bon sens → INATTENDU # C'est le cas le plus important : popup, erreur, mauvaise fenêtre return VerificationResult( verified=False, confidence=0.7, changes_detected=True, change_area_pct=pixel.change_area_pct, local_change_pct=pixel.local_change_pct, suggestion="retry", detail=f"Pixel OK mais Critic NON : {semantic['detail']}", semantic_verified=False, semantic_detail=semantic["detail"], semantic_elapsed_ms=semantic["elapsed_ms"], ) elif not pix_ok and sem_ok: # Peu de pixels ont changé mais le VLM dit que le résultat est bon # Ex: focus sur un onglet déjà visible (changement subtil) return VerificationResult( verified=True, confidence=0.6, changes_detected=False, change_area_pct=pixel.change_area_pct, local_change_pct=pixel.local_change_pct, suggestion="continue", detail=f"Pixel inchangé mais Critic OK : {semantic['detail']}", semantic_verified=True, semantic_detail=semantic["detail"], semantic_elapsed_ms=semantic["elapsed_ms"], ) else: # Rien n'a changé et le VLM confirme → échec return VerificationResult( verified=False, confidence=0.8, changes_detected=False, change_area_pct=pixel.change_area_pct, local_change_pct=pixel.local_change_pct, suggestion="retry", detail=f"Pixel inchangé + Critic NON : {semantic['detail']}", semantic_verified=False, semantic_detail=semantic["detail"], semantic_elapsed_ms=semantic["elapsed_ms"], )