Files
rpa_vision_v3/docs/recherche/AXE_B2_VALIDATOR_PATTERN.md

42 KiB
Raw Blame History

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 : https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py#L2609

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).

# 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 : https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal.j2

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_infothoughtsuser_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 : https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal-with-termination.j2

Ajoute un 3e statut explicite terminate + une classification des échecs en 12 catégories :

"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 :

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

# 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 (https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/) :

  • 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 : https://browser-use.com/posts/our-browser-agent-evaluation-system (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 : https://github.com/OpenAdaptAI/OpenAdapt/wiki/OpenAdapt-Architecture-(draft), https://github.com/OpenAdaptAI/openadapt-evals.

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 : https://browser-use.com/posts/our-browser-agent-evaluation-system, https://github.com/browser-use/browser-use.

  • 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 :
{
  "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 : https://docs.anthropic.com/en/docs/build-with-claude/computer-use.

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 : https://openai.com/index/operator-system-card/.

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, https://opencua.xlang.ai/) entraîne avec « reflective Chain-of-Thought reasoning » mais pas de check externe.

3.5. Cradle (BAAI, Kunlun Tech) — Self-Reflection module

Source : https://github.com/BAAI-Agents/Cradle, https://arxiv.org/pdf/2403.03186.

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 : https://wopee.io/blog/screenshot-comparison-algorithms-visual-testing/ — 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 https://github.com/facebookresearch/dinov2, https://medium.com/aimonks/clip-vs-dinov2-in-image-similarity-6fa5aa7ed8c6. 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_anchorclick 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_anchorclick button="double" C. OCR title-bar (déjà câblé) + B. OCR ROI A. LLM-as-judge 200 ms + 2 s
right_click_anchorclick button="right" J. Dialog presence (menu contextuel attendu) B. OCR ROI sur menu 150 ms
type_texttype 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_shortcutkey_combo C. OCR title-bar OU J. Dialog presence selon raccourci A. LLM-as-judge en cas de doute 200 ms
scroll_to_anchorscroll F. CLIP cos-sim before/after ROI cible visible D. pHash global change ≠ 0 100 ms
wait_for_anchorwait 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) :

# 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)

# 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.
# 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) :

# 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) :

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)

browser-use (agentic judge)

OpenAdapt (Evaluation-Driven Feedback)

Anthropic Computer Use & OpenAI Operator

Cradle (BAAI)

Visual diff / VLM-as-judge / LLM-as-judge

EDDOps (Evaluation-Driven Development & Operations)

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.pyverify_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.