Files
rpa_vision_v3/agent_v0/server_v1/replay_verifier.py
Dom 99041f0117 feat: pipeline complet MACRO/MÉSO/MICRO — Critic, Observer, Policy, Recovery, Learning, Audit Trail, TaskPlanner
Architecture 3 niveaux implémentée et testée (137 tests unitaires + 21 visuels) :

MÉSO (acteur intelligent) :
- P0 Critic : vérification sémantique post-action via gemma4 (replay_verifier.py)
- P1 Observer : pré-analyse écran avant chaque action (api_stream.py /pre_analyze)
- P2 Grounding/Policy : séparation localisation (grounding.py) et décision (policy.py)
- P3 Recovery : rollback automatique Ctrl+Z/Escape/Alt+F4 (recovery.py)
- P4 Learning : apprentissage runtime avec boucle de consolidation (replay_learner.py)

MACRO (planificateur) :
- TaskPlanner : comprend les ordres en langage naturel via gemma4 (task_planner.py)
- Contexte métier TIM/CIM-10 pour les hôpitaux (domain_context.py)
- Endpoint POST /api/v1/task pour l'exécution par instruction

Traçabilité :
- Audit trail complet avec 18 champs par action (audit_trail.py)
- Endpoints GET /audit/history, /audit/summary, /audit/export (CSV)

Grounding :
- Fix parsing bbox_2d qwen2.5vl (pixels relatifs, pas grille 1000x1000)
- Benchmarks visuels sur captures réelles (3 approches : baseline, zoom, Citrix)
- Reproductibilité validée : variance < 0.008 sur 10 itérations

Sécurité :
- Tokens de production retirés du code source → .env.local
- Secret key aléatoire si non configuré
- Suppression logs qui leakent les tokens

Résultats : 80% de replay (vs 12.5% avant), 100% détection visuelle Citrix JPEG Q20

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:03:25 +02:00

633 lines
25 KiB
Python

# 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"],
)