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>
This commit is contained in:
Dom
2026-04-09 21:03:25 +02:00
parent 72a9651b94
commit 99041f0117
21 changed files with 7810 additions and 110 deletions

View File

@@ -1,20 +1,24 @@
# agent_v0/server_v1/replay_verifier.py
"""
ReplayVerifier — Vérification post-action pour le replay de workflows.
ReplayVerifier — Vérification post-action (Critic) pour le replay de workflows.
Compare les screenshots avant/après une action pour détecter si elle a eu
un effet visible. Utilisé par l'API de replay pour décider si une action
a réussi ou si un retry est nécessaire.
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é")
Stratégies de vérification :
1. Différence d'image globale (avant == après → probablement rien ne s'est passé)
2. Zone locale autour du clic (si l'action est un clic)
3. Détection de texte apparu (si l'action est une frappe)
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, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -35,9 +39,13 @@ class VerificationResult:
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]:
return {
d = {
"verified": self.verified,
"confidence": round(self.confidence, 3),
"changes_detected": self.changes_detected,
@@ -46,6 +54,11 @@ class VerificationResult:
"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:
@@ -345,3 +358,275 @@ class ReplayVerifier:
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"],
)