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>
633 lines
25 KiB
Python
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"],
|
|
)
|