# AXE B2 — Pattern Planner-Actor-Validator & validation sémantique post-action **Date :** 2026-05-23 **Auteur :** agent recherche dispatché (Claude Opus 4.7 1M) **Statut :** livrable de recherche, lecture seule, AUCUNE modification de code **Lien dépendances :** AXE_A4 (OCR), AXE_A5 (tokenisation écran — déjà rédigé), AXE_B4 (ORA observe_reason_act) --- ## 1. TL;DR + recommandation **Constat.** Skyvern (12k stars, SOTA 85.85 % WebVoyager) formalise le **Validator** comme un agent à part entière, séparé du Planner et de l'Actor. Son rôle : après chaque step, prendre une nouvelle capture, demander à un LLM (avec image + DOM élagué) si l'objectif courant est atteint, sinon renvoyer `continue` / `terminate`. C'est exactement ce qui manque à rpa_vision_v3 : VWB = Planner statique, Léa = Actor, et `replay_verifier.py` est un pixel-diff global qui n'a aucune notion de **sémantique** (« est-ce que l'onglet Imagerie de l'app Easily est maintenant actif ? »). Le bug archétype step 10 démo GHT (« Imagerie » cliqué dans le bandeau Edge, REPORT success=True) tient **uniquement** à cette absence : pHash global voit du mouvement → conclut OK. Un Validator visuel par step le détecterait en 1-3 s. **Recommandation design pour rpa_vision_v3** (justifiée §6, §9) : 1. **Garder** `replay_verifier.verify_action` (pixel) comme pré-filtre 10 ms. 2. **Réactiver et étendre** `verify_with_critic` déjà câblé (§6) en lui passant un `expected_result` **typé** par action. 3. **Ajouter un `Validator` pluggable** côté serveur, qui choisit la stratégie de check selon `action_type` (matrice §5). Implémentation Python = ~250 LOC. 4. **Pour le bug step 10 précisément** : `click_anchor` doit déclencher une vérif OCR-ROI **autour du point cliqué** (rayon 60 px) ET une vérif title-bar (déjà fait par `core/grounding/title_verifier.py`). Si la ROI contient le mot Edge / le mot URL / un domaine `.com`, c'est un faux clic → retry, pas continue. 5. **Latence cible** : pixel 10 ms, OCR-ROI 100 ms, LLM-judge 2-3 s. Ne lancer le LLM-judge que si pixel **OU** OCR-ROI suspect. Le pattern Skyvern est directement adoptable. Le code Skyvern (Python, AGPL-3.0) montre que le Validator c'est **5 prompts Jinja2 + 1 méthode `complete_verify` + 1 dataclass `CompleteVerifyResult`**. Pas plus. --- ## 2. Skyvern Validator détaillé (code source 23 mai 2026) ### 2.1. Méthode `complete_verify` (extraite verbatim de `skyvern/forge/agent.py:2609-2730`) Source : Le Validator chez Skyvern n'est pas un sous-processus exotique : c'est **une coroutine LLM** appelée après l'Actor, à chaque step où il n'y a pas déjà une `DecisiveAction` (= action terminale émise par l'Actor lui-même). ```python # skyvern/forge/agent.py (résumé condensé du flux) async def complete_verify( self, page: Page, scraped_page: ScrapedPage, task: Task, step: Step ) -> CompleteVerifyResult: # 1. RE-SCRAPE la page (DOM élagué + screenshots), pas la version utilisée par l'Actor scraped_page_refreshed = await scraped_page.refresh(draw_boxes=False, scroll=scroll) # 2. Construit le prompt avec : navigation_goal, payload, complete_criterion, # action_history, elements parsés, datetime template_name = "check-user-goal-with-termination" if use_termination_prompt else "check-user-goal" verification_prompt = load_prompt_with_elements( element_tree_builder=scraped_page_refreshed, template_name=template_name, navigation_goal=task.navigation_goal, navigation_payload=task.navigation_payload, complete_criterion=task.complete_criterion, terminate_criterion=task.terminate_criterion, action_history=actions_and_results_str, local_datetime=..., ) # 3. Appel LLM avec screenshots — un handler LLM dédié possible # via flag PostHog USE_CHECK_USER_GOAL_HANDLER_FOR_VERIFICATION verification_result = await llm_api_handler( prompt=verification_prompt, step=step, screenshots=scraped_page_refreshed.screenshots, prompt_name=prompt_name, ) # 4. Parse JSON strict → 3 verdicts possibles result = CompleteVerifyResult.model_validate(verification_result) if result.is_complete: verification_status = VerificationStatus.complete elif result.is_terminate: verification_status = VerificationStatus.terminate else: verification_status = VerificationStatus.continue_step # 5. Trace OTEL : verification.status, verification.template, verification.reasoning_kind span.set_attribute("verification.status", verification_status.value) record_verification_span_attrs(span, result.thoughts) return result ``` **Trois verdicts uniquement** : `complete` / `terminate` / `continue_step`. Pas de `success_partial` ni de `retry_silent`. C'est volontaire : la décision est forcée binaire. Le `check_user_goal_complete` (lignes 2736+) wrap `complete_verify` et le convertit en `CompleteAction` ou `TerminateAction` pour l'orchestrateur. ### 2.2. Le prompt `check-user-goal.j2` (verbatim, fetch direct du repo) Source : ```jinja Your are here to help the user determine if the user has completed their goal on the web{{ " according to the complete criterion" if complete_criterion else "" }}. Use the content of the elements parsed from the page,{{ "" if without_screenshots else " the screenshots of the page," }} the user goal and user details to determine whether the {{ "complete criterion has been met" if complete_criterion else "user goal has been completed" }} or not. Make sure to ONLY return the JSON object in this format with no additional text before or after it: { "page_info": str, // Think step by step. Describe all the useful information in the page related to the user goal. "thoughts": str, // Think step by step. What information makes you believe whether user goal has completed or not. Use information you see on the site to explain. "user_goal_achieved": bool // True if the user goal has been completed, false otherwise. } User Goal: {{ navigation_goal }} User Details: {{ navigation_payload }} Action History: {{ action_history }} Elements on the page: {{ elements }} Current datetime, ISO format: {{ local_datetime }} ``` **Points clés** : - Sortie JSON stricte, parsée par Pydantic `CompleteVerifyResult.model_validate`. - Trois infos données au modèle : (a) screenshots, (b) elements parsés du DOM, (c) action_history textuelle. Multi-modal. - Le `page_info` → `thoughts` → `user_goal_achieved` impose une chain-of-thought structurée. C'est ce qui rend l'erreur diagnosticable. ### 2.3. Le prompt `check-user-goal-with-termination.j2` (expérimental, verbatim) Source : Ajoute un 3e statut explicite `terminate` + une **classification des échecs** en 12 catégories : ```jinja "status": str, // Must be one of three values: "complete", "terminate", or "continue". "failure_categories": array // Only populate when status is "terminate". Classify the root cause. [{ "category": str, // ANTI_BOT_DETECTION | BROWSER_ERROR | NAVIGATION_FAILURE | // PAGE_LOAD_TIMEOUT | AUTH_FAILURE | LLM_REASONING_ERROR | // CREDENTIAL_ERROR | ELEMENT_NOT_FOUND | WRONG_PAGE_STATE | // DATA_EXTRACTION_FAILURE | INFRASTRUCTURE_ERROR | UNKNOWN "confidence_float": float, "reasoning": str }] Important: Think carefully about the difference between "terminate" and "continue": - "terminate" = impossible to achieve, stop trying - "continue" = not done yet, but achievable with more steps ``` **À retenir** : Skyvern est très conservateur sur `terminate` (« only when CLEAR, EXPLICIT, UNAMBIGUOUS evidence »). C'est aligné avec le feedback `feedback_failure_is_learning.md` de Dom : échec ≠ stop avec erreur, c'est pause supervisée. ### 2.4. Quand le Validator se déclenche Extrait `agent.py:1929-1971` : ```python enable_parallel_verification = False if ( not has_decisive_action # l'Actor n'a pas déjà émis un COMPLETE and not task_completes_on_download and not isinstance(task_block, ActionBlock) and complete_verification # flag global activable par-task and (task.navigation_goal or task.complete_criterion) ): # Géré par feature flag PostHog disable_user_goal_check = await app.EXPERIMENTATION_PROVIDER.is_feature_enabled_cached( "DISABLE_USER_GOAL_CHECK", task.task_id, ... ) enable_parallel_verification = not disable_user_goal_check ``` → **Le Validator tourne à CHAQUE step** par défaut (« deferred to handle_completed_step »). C'est désactivable par task ou globalement, mais l'état par défaut est ON. Skyvern accepte le coût LLM par step parce qu'un faux succès rend l'agent inutilisable. ### 2.5. Contrat de données ```python # skyvern/forge/sdk/schemas/tasks.py (déduit du code agent.py) class CompleteVerifyResult(BaseModel): page_info: str thoughts: str is_complete: bool is_terminate: bool = False status: str | None = None # "complete" | "terminate" | "continue" failure_categories: list[FailureCategory] = [] ``` ### 2.6. Latence et coût D'après le post Skyvern 2.0 () : - Un step moyen prend 2-10 s. - Validator = appel LLM séparé (souvent un GPT-4o-mini ou Claude Haiku), 1-3 s. - ROI = sans Validator, accuracy 68.7 % WebVoyager ; avec Validator, **85.85 %**. Le delta de +17 points en accuracy justifie largement la latence. Source : (browser-use rapporte +17 pts également : 45 → 68.7 → 85.85 selon Planner/Validator). --- ## 3. Tour d'horizon Validator dans 5 autres frameworks ### 3.1. OpenAdapt — Evaluation-Driven Feedback Source : , . OpenAdapt formalise le concept au niveau **Process Graph** (graphe de steps avec arêtes = critères de complétion) : - **Code-based validation** : LLM génère du Python qui vérifie une condition d'état (présence d'un message de confirmation, état d'un bouton, etc.). Code stocké, ré-exécuté à chaque replay. - **Model-based validation** : LMM (Large Multimodal Model) reçoit le screenshot courant + `completion_criteria` formulés en langage naturel → bool. Particularité : si la validation échoue, OpenAdapt **bascule en mode recording** automatiquement → l'utilisateur démontre la suite → la trace devient training data. C'est l'« Evaluation-Driven Feedback ». Le sous-package `openadapt-evals` expose `evaluate_agent_on_benchmark`. ### 3.2. browser-use — agentic judge Source : , . - LLM judge intégré dans le code agent, **tourne après `done`** ET « can also double as a real-time validation layer during regular use ». - Modèle : `gemini-2.5-flash`. Accuracy juge vs labels humains : 87 %. - Sortie JSON stricte : ```json { "reasoning": "Analysis covering what worked, failures, trajectory quality, tool usage, output quality", "verdict": "true|false", "failure_reason": "Max 5 sentences explanation if failed", "impossible_task": "true|false", "reached_captcha": "true|false" } ``` - Philosophie : **simple prompts and absolute True/False verdicts work best**. Complex rubrics → indecisive judging. ### 3.3. Anthropic Computer Use Source : . Anthropic CU n'a pas de Validator nommé. Boucle minimaliste : `screenshot → action → screenshot → ...` jusqu'à ce que Claude lui-même décide qu'il a fini. **Validation = self-reflection implicite du modèle dans son raisonnement**. → Acceptable parce que Claude est puissant. **Pas applicable à rpa_vision_v3** où l'Actor n'est pas un LLM agentique mais un exécutant déterministe (Léa). Il faut un Validator externe. ### 3.4. OpenAI Operator / CUA Source : . Idem Anthropic CU : pas de Validator séparé. Le modèle CUA fait perception → reasoning → action en boucle. Selon le system card : « If it encounters challenges or makes mistakes, Operator can leverage its reasoning capabilities to self-correct ». Pas formalisé. OpenCUA (open-source, ) entraîne avec « reflective Chain-of-Thought reasoning » mais pas de check externe. ### 3.5. Cradle (BAAI, Kunlun Tech) — Self-Reflection module Source : , . Cradle décompose explicitement en 6 modules dont **Self-Reflection** : > « Through this module, the agent assesses previous actions to understand their outcomes, evaluate successes or failures, and adjust behavior accordingly. » Mesure : +20.41 points sur tâches « professional domain » vs baselines. Mais c'est un agent jeu/applications, pas RPA déclaratif → moins directement transposable. ### 3.6. Tableau récap | Framework | Validator nommé ? | Modalité | Modèle | Latence | Verdict format | |---|---|---|---|---|---| | Skyvern 2.0 | **Oui** (`complete_verify`) | VLM + DOM élagué | GPT-4o ou handler dédié | 1-3 s | JSON `is_complete/is_terminate/status` | | OpenAdapt | Oui (Process Graph) | LMM ou Python généré | Configurable | n/a | bool + falls back to recording | | browser-use | Oui (agentic judge) | VLM + DOM | gemini-2.5-flash | 1-2 s | JSON `verdict/failure_reason` | | Anthropic CU | Non (implicite) | Self-reflection | Claude lui-même | inclus | continuation libre | | OpenAI Operator | Non (implicite) | Self-reflection | CUA | inclus | continuation libre | | Cradle | Oui (Self-Reflection) | LMM | GPT-4V | 2-5 s | text reasoning | **Convergence forte** : les 3 frameworks RPA matures (Skyvern, OpenAdapt, browser-use) ont un Validator **explicite, JSON-strict, multi-modal (VLM + structure DOM)**. Les agents généralistes (CU, Operator) délèguent au LLM agentique. Pour rpa_vision_v3 avec Actor déterministe = camp Skyvern. --- ## 4. Taxonomie des approches de validation post-action | Approche | Coût | Précision | Faux-positifs | Quand l'utiliser | |---|---|---|---|---| | **A. LLM-as-judge (full VLM)** | 1-5 s | Très haute (sémantique) | Faibles | Validation finale de step / cas ambigus | | **B. OCR ROI** (texte attendu autour du clic) | 80-200 ms | Haute si texte connu | Sensible OCR errors | Tabs, boutons, libellés | | **C. OCR title-bar** (titre fenêtre) | ~120 ms (déjà câblé) | Moyenne | Bruit OCR sur petits crops | Navigation fenêtre / ouverture appli | | **D. Visual diff pHash global** | 10 ms | Très basse (juste « ça a bougé ») | Énormes | Pré-filtre `nothing-happened` | | **E. Visual diff pHash ROI** | 20 ms | Moyenne | Moyens | Détection focus tab (changement souligné) | | **F. CLIP features cos-sim** | 50-200 ms | Moyenne | Confond visuellement proches | Reconnaissance d'écran connu | | **G. DINOv2 features** | 100-300 ms | Haute (self-supervised, plus robuste que CLIP) | Faibles | Comparaison patches précis | | **H. LPIPS** | 100 ms | Haute (perceptual) | Moyens | Vérif après animations / transitions | | **I. Window-focus check** (win32 API ou OCR titlebar) | <50 ms | Très haute | Quasi nuls | Vérif que la bonne app est devant | | **J. Dialog presence detect** | OCR + template | Très haute | Faibles | Détection popups bloquantes | | **K. JSON schema validation** (extraction) | <10 ms | Déterministe | nuls | `extract_text`, `t2a_decision` | **Source visual diff** : — pHash est positionné comme « pre-filter, not a comparator ». Les VLM sont positionnés comme « triage layer on top of pixel diffs, not as the comparator itself ». Exactement le design pixel→sémantique déjà câblé dans `replay_verifier.verify_with_critic`. **Pour DINOv2 / LPIPS / CLIP** : sources , . DINOv2 produit des features visuelles plus discriminantes que CLIP pour comparer deux crops d'UI (CLIP est entraîné texte↔image, pas pour le pixel-perfect). --- ## 5. Matrice type d'action → check recommandé pour rpa_vision_v3 Aligné avec `reference_vwb_action_types.md` (memory) et `_ALLOWED_ACTION_TYPES` de `replay_engine.py`. | Action VWB (Léa) | Check primaire | Check secondaire (si primaire ambigu) | Budget latence | |---|---|---|---| | `click_anchor` → `click` | **B. OCR ROI** (rayon 60 px) + **I. Window focus** | A. LLM-as-judge si OCR ne trouve pas le label | 100 ms + 2 s si escalation | | `double_click_anchor` → `click button="double"` | **C. OCR title-bar** (déjà câblé) + **B. OCR ROI** | A. LLM-as-judge | 200 ms + 2 s | | `right_click_anchor` → `click button="right"` | **J. Dialog presence** (menu contextuel attendu) | B. OCR ROI sur menu | 150 ms | | `type_text` → `type` | **B. OCR ROI** : le texte tapé est-il visible dans la ROI ? | A. LLM-as-judge si texte tronqué | 100 ms | | `type_secret` | **D. pHash ROI** (vérifier qu'un input s'est rempli, pas le contenu) | — | 20 ms | | `keyboard_shortcut` → `key_combo` | **C. OCR title-bar** OU **J. Dialog presence** selon raccourci | A. LLM-as-judge en cas de doute | 200 ms | | `scroll_to_anchor` → `scroll` | **F. CLIP cos-sim** before/after ROI cible visible | D. pHash global change ≠ 0 | 100 ms | | `wait_for_anchor` → `wait` | **B. OCR ROI** : l'ancre est-elle visible ? | A. LLM-as-judge | 100 ms | | `extract_text` | **K. JSON schema** : type str, longueur > 0, langue fr ratio | A. LLM-as-judge sur le contenu plausibilité | 10 ms + 2 s si plausibilité requise | | `extract_text_scroll` | K + **A. LLM-as-judge** si plusieurs pages | — | 10 ms + 2 s | | `extract_table` | **K. JSON schema** : ≥ 1 row, headers attendus si fournis | A. LLM-as-judge | 10 ms | | `screenshot_evidence` | — (action passive) | I. Window focus | <50 ms | | `t2a_decision` | **K. JSON schema** strict (decision ∈ {UHCD, FORFAIT, NA}, JSON parseable) | — | 10 ms | | `pause_for_human` | **Checklist QW4** (déjà fait, `SafetyChecksProvider`) | — | n/a | | `db_save_data` | **K. Schema row sauvée** (SELECT verify) | — | <50 ms | | `import_excel`, `db_read_data` | **K. Schema rows** | — | <50 ms | | `visual_condition` | **A. LLM-as-judge** sur la condition formulée | — | 2 s | | `ai_ocr`, `ai_summarize`, etc. | **K. JSON schema** + **A. plausibilité** | — | 10 ms + 2 s | **Principe directeur** : la plupart des actions ont un check pas-cher (OCR ROI, JSON) qui suffit dans 90 % des cas. Le LLM-as-judge (2 s) ne tire qu'en escalation, ou sur les actions à risque élevé (`click_anchor` sur cibles ambiguës, `t2a_decision`, `visual_condition`). --- ## 6. Design d'un Validator pluggable — code copy-paste-ready ### 6.1. Interface À placer dans `agent_v0/server_v1/validator.py` (nouveau fichier, complète `replay_verifier.py` existant) : ```python # agent_v0/server_v1/validator.py """ Validator — vérification sémantique post-action pluggable. Inspiré de Skyvern (Planner-Actor-Validator). Combine pixel-diff existant (replay_verifier.py) avec une couche sémantique typée par action_type. Trois verdicts possibles, calque sur Skyvern : - COMPLETE → l'action a eu l'effet voulu, passer au step suivant - CONTINUE → l'effet n'est pas encore visible, re-vérifier après wait - TERMINATE → l'action a échoué de manière irrécupérable (pause supervisée) """ from __future__ import annotations import logging from dataclasses import dataclass, field from enum import Enum from typing import Any, Callable, Dict, Optional, Protocol logger = logging.getLogger(__name__) class Verdict(str, Enum): COMPLETE = "complete" CONTINUE = "continue" TERMINATE = "terminate" class FailureCategory(str, Enum): WRONG_TARGET = "wrong_target" # cliqué ailleurs (ex. bug step 10) NO_VISUAL_CHANGE = "no_visual_change" # action sans effet UNEXPECTED_DIALOG = "unexpected_dialog" # popup bloque WRONG_APPLICATION = "wrong_application" # focus sur mauvaise app (Edge vs Easily) OCR_TEXT_MISSING = "ocr_text_missing" # texte attendu absent SCHEMA_INVALID = "schema_invalid" # JSON/extract invalide UNKNOWN = "unknown" @dataclass class ValidationResult: verdict: Verdict confidence: float # 0.0-1.0 check_used: str # "ocr_roi" | "llm_judge" | "title_bar" | ... elapsed_ms: float reasoning: str = "" failure_category: Optional[FailureCategory] = None raw_evidence: Dict[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: return { "verdict": self.verdict.value, "confidence": round(self.confidence, 3), "check_used": self.check_used, "elapsed_ms": round(self.elapsed_ms, 1), "reasoning": self.reasoning, "failure_category": self.failure_category.value if self.failure_category else None, "raw_evidence": self.raw_evidence, } class ActionChecker(Protocol): """Contrat d'un checker spécifique par action_type.""" name: str budget_ms: float def check( self, action: Dict[str, Any], result: Dict[str, Any], screenshot_before: Optional[str], screenshot_after: Optional[str], context: Dict[str, Any], ) -> ValidationResult: ... class Validator: """Orchestrateur : route action_type → checker, gère l'escalation.""" def __init__( self, checkers: Dict[str, list[ActionChecker]], default_checker: ActionChecker, escalation_checker: Optional[ActionChecker] = None, escalation_threshold: float = 0.5, ): """ checkers: mapping action_type → liste de checkers à essayer en ordre. default_checker: fallback si action_type pas dans le mapping. escalation_checker: typiquement un LLM-as-judge, lancé si confidence < seuil. """ self._checkers = checkers self._default = default_checker self._escalation = escalation_checker self._escalation_threshold = escalation_threshold def validate( self, action: Dict[str, Any], result: Dict[str, Any], screenshot_before: Optional[str] = None, screenshot_after: Optional[str] = None, context: Optional[Dict[str, Any]] = None, ) -> ValidationResult: context = context or {} action_type = action.get("type", "") candidates = self._checkers.get(action_type, [self._default]) last_result: Optional[ValidationResult] = None for checker in candidates: res = checker.check(action, result, screenshot_before, screenshot_after, context) last_result = res # Si verdict net + confiance haute → renvoyer if res.confidence >= self._escalation_threshold and res.verdict != Verdict.CONTINUE: return res # Escalation LLM-as-judge si fourni if self._escalation and last_result and last_result.confidence < self._escalation_threshold: logger.info( "Validator escalation LLM-judge (last_conf=%.2f, check=%s)", last_result.confidence, last_result.check_used, ) esc = self._escalation.check(action, result, screenshot_before, screenshot_after, context) # On combine : si LLM contredit, LLM prime (sa confiance est bornée à 0.9) return esc return last_result or ValidationResult( verdict=Verdict.CONTINUE, confidence=0.3, check_used="no_checker", elapsed_ms=0.0, reasoning="Aucun checker n'a produit de verdict", ) ``` ### 6.2. Exemple de checker : `OcrRoiChecker` (pour click_anchor) ```python # agent_v0/server_v1/checkers/ocr_roi.py import time from typing import Any, Dict, Optional from PIL import Image from agent_v0.server_v1.validator import ( ActionChecker, ValidationResult, Verdict, FailureCategory, ) class OcrRoiChecker: """Vérifie que le texte attendu apparaît dans la ROI autour du clic. Spécifiquement conçu pour résoudre le bug step 10 : si on a cliqué sur 'Imagerie', la ROI 60px doit contenir 'Imagerie'. Si elle contient 'Edge' ou 'urgence.labs.laurinebazin.design', on a cliqué dans le bandeau navigateur → failure. """ name = "ocr_roi" budget_ms = 200.0 # Mots suspects = on a cliqué hors-app SUSPECT_TOKENS = {"edge", "chrome", "firefox", "http", "https", ".com", ".fr", "favoris", "favorite", "onglet", "tab "} def __init__(self, ocr_fn, radius_px: int = 60): self._ocr = ocr_fn # callable(PIL.Image) -> str self._radius = radius_px def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() expected_text = action.get("by_text") or context.get("expected_text", "") x_pct = action.get("x_pct") y_pct = action.get("y_pct") if not screenshot_after or x_pct is None or y_pct is None: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.2, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="ROI indéfinie (pas de coords ou pas de screenshot)", ) img = self._load_image(screenshot_after) w, h = img.size cx, cy = int(x_pct * w), int(y_pct * h) r = self._radius roi = img.crop((max(0, cx - r), max(0, cy - r), min(w, cx + r), min(h, cy + r))) text = (self._ocr(roi) or "").lower() expected_lower = expected_text.lower().strip() elapsed_ms = (time.time() - t0) * 1000 # 1) Vérif : un token suspect (navigateur) dans la ROI → faux clic for suspect in self.SUSPECT_TOKENS: if suspect in text and suspect not in expected_lower: return ValidationResult( verdict=Verdict.TERMINATE, confidence=0.85, check_used=self.name, elapsed_ms=elapsed_ms, failure_category=FailureCategory.WRONG_APPLICATION, reasoning=f"Token navigateur '{suspect}' dans ROI clic — cible probablement hors-app", raw_evidence={"roi_text": text[:200], "expected": expected_lower}, ) # 2) Vérif : le texte attendu est dans la ROI ? if expected_lower and expected_lower in text: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.9, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Texte '{expected_lower[:40]}' trouvé dans ROI", raw_evidence={"roi_text": text[:200]}, ) # 3) Pas trouvé mais pas suspect non plus → confiance basse, escalation return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.4, check_used=self.name, elapsed_ms=elapsed_ms, failure_category=FailureCategory.OCR_TEXT_MISSING, reasoning=f"Texte '{expected_lower[:40]}' non trouvé dans ROI", raw_evidence={"roi_text": text[:200]}, ) @staticmethod def _load_image(source: str) -> Image.Image: # Délégué à replay_verifier._load_single_image, ou copy-paste équivalent from agent_v0.server_v1.replay_verifier import ReplayVerifier return ReplayVerifier()._load_single_image(source) ``` ### 6.3. Intégration avec `replay_verifier.py` existant Le `replay_verifier.verify_with_critic` couvre déjà 80 % du besoin LLM-as-judge (étape sémantique VLM). Il suffit de : 1. Le wrapper dans un `LlmJudgeChecker` qui implémente `ActionChecker`. 2. L'utiliser comme `escalation_checker` du `Validator`. ```python # agent_v0/server_v1/checkers/llm_judge.py import time from agent_v0.server_v1.replay_verifier import ReplayVerifier from agent_v0.server_v1.validator import ( ActionChecker, ValidationResult, Verdict, FailureCategory, ) class LlmJudgeChecker: """Wrapper autour de ReplayVerifier.verify_with_critic (VLM gemma4).""" name = "llm_judge" budget_ms = 3000.0 def __init__(self, verifier: ReplayVerifier): self._verifier = verifier def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() expected = context.get("expected_result", "") intention = context.get("action_intention", "") workflow_ctx = context.get("workflow_context", "") critic = self._verifier.verify_with_critic( action=action, result=result, screenshot_before=screenshot_before, screenshot_after=screenshot_after, expected_result=expected, action_intention=intention, workflow_context=workflow_ctx, ) elapsed_ms = (time.time() - t0) * 1000 if critic.semantic_verified is True: verdict = Verdict.COMPLETE conf = max(critic.confidence, 0.7) elif critic.semantic_verified is False: verdict = Verdict.TERMINATE conf = 0.8 else: verdict = Verdict.CONTINUE conf = 0.4 return ValidationResult( verdict=verdict, confidence=conf, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=critic.semantic_detail or critic.detail, raw_evidence={"pixel_change_pct": critic.change_area_pct, "semantic_verified": critic.semantic_verified}, ) ``` ### 6.4. Câblage côté `api_stream.py` (post-action) Pseudo-diff (NE PAS appliquer, juste pour montrer le point d'insertion) : ```python # agent_v0/server_v1/api_stream.py — handler de REPORT from agent_v0.server_v1.validator import Validator, Verdict from agent_v0.server_v1.checkers.ocr_roi import OcrRoiChecker from agent_v0.server_v1.checkers.llm_judge import LlmJudgeChecker # Init au boot _validator = Validator( checkers={ "click": [OcrRoiChecker(ocr_fn=_easyocr_fn)], "type": [OcrRoiChecker(ocr_fn=_easyocr_fn)], "key_combo": [TitleBarChecker()], # voir core/grounding/title_verifier.py # ... }, default_checker=PixelDiffChecker(), # wrapper ReplayVerifier.verify_action escalation_checker=LlmJudgeChecker(ReplayVerifier()), escalation_threshold=0.55, ) # Dans report_action_result, après le pixel-diff actuel async def report_action_result(payload): ... if RPA_VALIDATOR_ENABLED: # kill-switch env var val = _validator.validate( action=action, result=result, screenshot_before=before, screenshot_after=after, context={"expected_text": action.get("by_text"), "expected_result": step.get("expected_result", ""), "action_intention": step.get("label", ""), "workflow_context": f"step {step_idx}/{total_steps}"}, ) if val.verdict == Verdict.TERMINATE: # Pause supervisée, pas stop avec error (cf. feedback_failure_is_learning) _enter_paused_state(reason=val.reasoning, evidence=val.to_dict()) elif val.verdict == Verdict.CONTINUE: # Re-vérifier après wait, ou retry _schedule_recheck(action_id, after_ms=1500) # COMPLETE → continue normalement ``` --- ## 7. Application au bug step 10 démo GHT **Rappel du bug** (cf. `REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md`) : step 10 « cliquer onglet Imagerie », OCR-DIRECT renvoie centre de la rangée de tabs → le clic tombe **dans la URL bar Edge** (au-dessus). pHash global voit du changement → REPORT success=True. Cascade. **Avec le Validator proposé** : 1. Action `click_anchor` (`by_text="Imagerie"`, `x_pct=0.23`, `y_pct=0.28`). 2. Léa rapporte success après mouseclick. Screenshot_after capturé. 3. `Validator.validate(action_type="click", ...)` route vers `OcrRoiChecker`. 4. ROI 60 px autour de (0.23, 0.28) → réellement la URL bar. 5. EasyOCR du crop renvoie texte type : `« urgence.labs.laurinebazin.design/aiva-urgence/dossier.html#imagerie »` 6. Token `.com` ou `https` détecté → **`Verdict.TERMINATE`** avec `FailureCategory.WRONG_APPLICATION`. 7. Reasoning : « Token navigateur 'https' dans ROI clic — cible probablement hors-app ». 8. `api_stream` entre en pause supervisée avec `evidence={roi_text, expected}`. Dom voit dans le dashboard ce qui s'est mal passé. Pas d'enchainement vers step 11. **Latence ajoutée** : 100-200 ms (EasyOCR sur 120×120 px). **Négligeable** vs. les 6 s passés à enchaîner 5 steps faux et à entrer en pause supervisée 33 s plus tard. **Effet secondaire bénéfique** : le même mécanisme attrape : - Clics sur popups Windows (Hello / UAC) → ROI contient « Sécurité Windows » → TERMINATE. - Clics sur le menu démarrer ou la barre des tâches. - Tout clic qui tombe dans une zone système non prévue. --- ## 8. Budget latence par check — qu'accepter en démo ? Hypothèse démo GHT (40 steps, 2 min de pipeline cible) : | Check | Latence | × 40 steps | Acceptable démo ? | |---|---|---|---| | Pixel diff global (existant) | 10 ms | 0.4 s | ✅ ON par défaut | | OCR ROI EasyOCR | 100-200 ms | 4-8 s | ✅ ON sur `click`, `type` | | OCR title-bar (existant) | 120 ms | 4.8 s | ✅ ON sur navigation | | Schema validation (JSON) | <10 ms | 0.4 s | ✅ ON sur `extract_*`, `t2a_decision` | | LLM-judge gemma4 critic | 2-3 s | 80-120 s | ⚠️ SEULEMENT en escalation | | LLM-judge cloud (Claude Haiku) | 1-2 s | 40-80 s | ⚠️ SEULEMENT en escalation | | DINOv2 features ROI | 150 ms | 6 s | ❓ pas nécessaire pour démo | **Recommandation budget** : - Démo : pixel + OCR ROI + title-bar + schema = ~10 s de latence cumulée sur 40 steps. Acceptable. - LLM-judge escalation déclenché ~5 fois max par démo = 10 s ajoutés. Tolérable si placé sur les steps à risque (clics ambigus sur tabs). - DINOv2 hors-périmètre démo. À benchmarker post-démo. **Kill-switch** obligatoire (cf. QW Suite Mai, conventions Dom) : ```bash RPA_VALIDATOR_ENABLED=true # active la couche entière RPA_VALIDATOR_LLM_JUDGE_ENABLED=true # active escalation LLM (coûteuse) RPA_VALIDATOR_OCR_ROI_RADIUS=60 # tunable RPA_VALIDATOR_ESCALATION_THRESHOLD=0.55 ``` --- ## 9. Plan d'intégration gradué ### 9.1. Court terme — 1 jour, faisable avant prochaine démo (P0) **But** : éliminer la classe « clic hors-app silencieusement success=True ». 1. Créer `agent_v0/server_v1/validator.py` (squelette §6.1) — 1 h. 2. Créer `OcrRoiChecker` (§6.2) — 2 h. 3. Wrapper `LlmJudgeChecker` autour de `verify_with_critic` existant (§6.3) — 30 min. 4. Ajouter hook dans `api_stream.report_action_result` derrière `RPA_VALIDATOR_ENABLED=false` par défaut — 2 h. 5. Tests : - Unit : ROI text matching, suspect tokens, escalation logic — 2 h. - Integration : rejouer step 10 sur fixture screenshot — 1 h. 6. Démo interne avec `RPA_VALIDATOR_ENABLED=true` sur Demo_urgence_3_db, mesure latence + faux positifs — 1 h. **Livrable** : pas de régression démo si flag off ; quand on, le bug step 10 est attrapé en TERMINATE → pause supervisée. ### 9.2. Moyen terme — 1-2 semaines (P1) **But** : matrice complète action → check (§5). 1. `TitleBarChecker` adapté de `core/grounding/title_verifier.py` existant — 2 h. 2. `JsonSchemaChecker` pour `extract_text`, `t2a_decision`, `extract_table` — 4 h. 3. `DialogPresenceChecker` réutilisant la cascade de modaux VM (`feedback_phash_vs_dialog_in_vm.md`) — 4 h. 4. `PixelDiffChecker` (wrapper de l'existant) avec verdict adapté au contrat Verdict — 2 h. 5. Câblage de la matrice complète selon §5 — 4 h. 6. Dashboard : panneau « Validator stats » par session — pourcentage COMPLETE / CONTINUE / TERMINATE, top failure_categories — 1 j. ### 9.3. Long terme — post-démo (P2) 1. Évaluer **DINOv2** vs OCR ROI sur fixtures GHT : meilleur signal pour distinguer « tab activé vs tab survolé » ? Bench 100 steps. 2. Migration LLM-judge de gemma4:e4b (local) vers un handler dédié — séparer le « LLM décisionnel T2A » du « LLM judge ». Skyvern expose `USE_CHECK_USER_GOAL_HANDLER_FOR_VERIFICATION` qui sépare déjà. 3. Apprentissage : enregistrer dans `TargetMemoryStore` chaque verdict TERMINATE pour produire du training data (pattern OpenAdapt « success traces become new training data »). 4. Re-planification : si TERMINATE répété → renvoyer info au Planner pour ajuster le workflow (cf. Skyvern « reporting any errors / tweaks back to the Planner so it can make adjustments in real-time »). Pour rpa_vision_v3 : signaler à VWB que l'ancre est foireuse → suggestion recapture. --- ## 10. Sources avec liens ### Skyvern (Planner-Actor-Validator) - Blog Skyvern 2.0 — (annonce de l'archi Planner-Actor-Validator, score WebVoyager 85.85 %) - GitHub repo — - `agent.py` (méthode `complete_verify`) — ligne 2609 (au 23 mai 2026) - Prompt `check-user-goal.j2` — - Prompt `check-user-goal-with-termination.j2` — - Prompt `decisive-criterion-validate.j2` — - Hacker News show — ### browser-use (agentic judge) - Blog « Our browser agent evaluation system » — - AGENTS.md — ### OpenAdapt (Evaluation-Driven Feedback) - GitHub OpenAdapt — - GitHub openadapt-evals — - Wiki architecture — ### Anthropic Computer Use & OpenAI Operator - Operator system card — - OpenCUA (open foundations CUA, xLANG / HKU) — - Computer Use 2026 review — ### Cradle (BAAI) - Paper arXiv 2403.03186 — - GitHub — - Project page — ### Visual diff / VLM-as-judge / LLM-as-judge - « Screenshot Comparison Algorithms » — (pHash positionné comme pre-filter, VLM comme triage layer) - DINOv2 (Meta) — - CLIP vs DINOv2 image similarity — - « Aha Moment Revisited: Are VLMs Truly Capable of Self Verification » (arXiv 2506.17417) — - Vision-Language Model Verifier (review) — - LLM-as-a-Judge guide 2026 — - « Why Success is Lying to You: The 2026 Agent Eval Stack » — ### EDDOps (Evaluation-Driven Development & Operations) - Paper arXiv 2411.13768 (v3, 2026) — ### Doc interne rpa_vision_v3 (référencée) - `docs/INSPIRATION_FRAMEWORKS_2026-05-10.md` §3.1 — Planner-Actor-Validator - `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` — bug archétype step 10 - `docs/BUG_PRECHECK_SPATIAL_BLINDNESS_2026-05-08.md` — DETTE-001 - `agent_v0/server_v1/replay_verifier.py` — `verify_with_critic` déjà câblé - `core/grounding/title_verifier.py` — TitleVerifier déjà câblé - Memory `reference_vwb_action_types.md` — matrice action_types VWB --- ## 11. Dépendances avec autres axes - **AXE_A4 (OCR)** : `OcrRoiChecker` repose sur EasyOCR/docTR rapides. Si AXE_A4 livre un OCR ROI < 100 ms calibré sur petits crops, le check primaire devient ultra-fiable. **Bloquant** : qualité OCR sur crop 120×120 px. - **AXE_A5 (tokenisation écran)** : si on a un parseur UI type OmniParser qui renvoie une liste d'éléments avec bbox + label, le check ROI devient déterministe (matche `target == element_at_point(cx, cy).label`). **Forte synergie** : un Validator + un tokenizer = on rentre dans le territoire Skyvern 2.0. - **AXE_B4 (ORA)** : ORA peut consommer les `ValidationResult` du Validator comme signal d'observation. Si TERMINATE → ORA ré-observe et propose une re-action. Le Validator devient l'œil de l'Actor. - **DETTE-008** (pre-check VLM par-clic désactivé par `if False:`) : ce Validator est sa version refaite-proprement. La désactivation actuelle est juste, mais le besoin reste — c'est ce livrable. - **DETTE-001** (pre-check OCR spatialement aveugle) : `OcrRoiChecker` avec `radius_px=60` est exactement l'Option B mentionnée dans la note de Dom. Réduire radius + bboxes individuelles = même direction. --- *Document de recherche, lecture seule. Aucune décision d'implémentation prise par cet axe — décision relève de Dom et d'un planning de réintégration coordonné avec AXE_A4, A5, B4.*