# SPEC OPÉRATIONNELLE — Matrice « action → signal qui fait foi » **Date :** 2026-05-24 **Auteur :** agent recherche dispatché (Claude Opus 4.7 1M) **Statut :** spec opérationnelle, lecture seule, **AUCUNE modification de code**. **Parents :** - `docs/recherche/AXE_B2_VALIDATOR_PATTERN.md` (architecture Skyvern + design Validator pluggable) - `docs/recherche/AXE_B2_DEEP_VALIDATOR.md` (code Checkers production-ready, wiring `api_stream.py`) **Périmètre.** Pour chaque type d'action exécutable côté Léa (cf. `_ALLOWED_ACTION_TYPES` `replay_engine.py:35-48` et `reference_vwb_action_types.md`), **dire précisément quel signal doit faire foi** pour valider qu'elle a réussi. Pas une étude théorique, une matrice opérationnelle : ce qu'il faut **mesurer**, sur **quelle zone**, avec **quel seuil**, **combien de millisecondes** pour le décider, et **quel Checker** du package `core/validation/` implémenter. Pas de code à appliquer ici — uniquement des snippets copy-paste pour les nouveaux Checkers qui sortent de ce que B2/B2_DEEP a déjà décrit. --- ## 1. TL;DR — l'insight central et le tableau-résumé ### 1.1. Insight > **Le pixel-diff global ment, le titre fenêtre est aveugle aux SPA, l'OCR-ROI ne suffit > pas seul.** Chaque type d'action a un signal *spatial et sémantique* qui prouve son > effet. Ce signal est différent selon l'action : > > - **click_anchor / switch_tab** → le **mot attendu** apparaît dans une ROI 80 px autour > du point cliqué ET l'indicateur visuel d'activation (souligné, fond surligné, mise > en gras) change dans une bande étroite sous le label. > - **close_tab** → le label de l'onglet *fermé* **disparaît** de la barre de tabs > (SSIM-ROI before/after sur la barre + OCR confirme absence). > - **save** → une **transition d'état** observable : disparition d'un marqueur > « modifié » (étoile dans la title-bar, indicateur dirty) OU apparition d'un toast > « Enregistré » sous 3 s. > - **dialog_button** → le **dialog disparaît** (zone modale du screenshot before n'est > plus là dans after) ET le focus retourne à l'app underlying. > > Ne **jamais** se fier au seul `success=True` rapporté par l'agent : c'est la cause > exacte du bug step 10 démo GHT (`REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md`). ### 1.2. Tableau-résumé une page | Action VWB / cas | Signal **primaire** (fait foi) | Latence cible | Coût | |---|---|---:|---| | **`click_anchor`** | OcrRoiChecker 80 px : mot attendu présent ET aucun token suspect (`https`, `edge`) | 80 ms | OCR | | **`switch_tab`** (cas explicite Dom) | OcrRoiChecker 120×40 sur barre de tabs + SSIM-ROI bande sous-tab > 0.05 | 100 ms | OCR + SSIM | | **`close_tab`** (cas explicite Dom) | TabAbsenceChecker : label cible **absent** de la barre de tabs after | 120 ms | OCR | | **`save`** (cas explicite Dom) | SaveSuccessChecker : `*` disparu de title-bar OU toast « Enregistré » détecté ≤ 3 s | 150 ms (poll) | OCR title + ROI bas | | **`dialog_button`** (cas explicite Dom) | DialogClosedChecker : la modale du before n'est plus dans after (SSIM-ROI + OCR absence) | 100 ms | SSIM + OCR | | **`double_click_anchor`** | TitleBarChecker (changement titre fenêtre) + OcrRoiChecker | 200 ms | OCR title + OCR ROI | | **`right_click_anchor`** | DialogPresenceChecker : menu contextuel apparu sous le curseur | 150 ms | OCR + SSIM | | **`hover_anchor`** | PixelDiffChecker ROI ≤ 200 px autour du curseur : tooltip a changé | 30 ms | SSIM | | **`type_text`** / **`type_secret`** | OcrRoiChecker 120 px : texte tapé visible dans la zone du caret | 120 ms | OCR | | **`keyboard_shortcut`** | dépend du raccourci : voir §4.5 (Ctrl+S → SaveSuccessChecker, Ctrl+W → close_tab, etc.) | variable | variable | | **`scroll_to_anchor`** | OcrRoiChecker : ancre visible dans la viewport | 100 ms | OCR | | **`wait_for_anchor`** | OcrRoiChecker : ancre présente (sinon CONTINUE poll) | 100 ms | OCR | | **`extract_text`** / **`extract_text_scroll`** | JsonSchemaChecker : str non vide + langue fr + len > 50 | 10 ms | déterministe | | **`extract_table`** | JsonSchemaChecker : ≥ 1 row, schema headers attendus si fournis | 10 ms | déterministe | | **`t2a_decision`** | JsonSchemaChecker strict : `decision ∈ enum`, `justification` non vide | 10 ms | déterministe | | **`paste_and_execute`** | PixelDiffChecker ROI input (caret) + escalation LlmJudge si ambigu | 50 ms + escalation | SSIM | | **`screenshot_evidence`** | TitleBarChecker (la bonne app est devant) — pas de side-effect | 130 ms | OCR title | | **`pause_for_human`** | hors-scope (QW4 ChecklistPanel, déjà câblé) | 0 | — | | **`db_save_data`** / **`db_read_data`** / **`import_excel`** | JsonSchemaChecker : SELECT vérifie nb_rows attendu | <50 ms | SQLite | | **`visual_condition`** | LlmJudgeChecker sur la condition formulée | 2.5 s | LLM-judge | | **`drag_drop_anchor`** | OcrRoiChecker ROI destination + PixelDiffChecker zone source | 200 ms | OCR + SSIM | | **`select_option`** (combobox) | OcrRoiChecker : valeur choisie visible dans le champ collapsed | 100 ms | OCR | | **`check_checkbox`** / **`radio_button`** | PixelDiffChecker 24×24 px autour du widget : transition cochée/décochée | 20 ms | SSIM | | **`open_app`** / **`open_url`** | TitleBarChecker : nouveau title attendu présent | 130 ms | OCR title | **Latence cumulée budget démo MOREL 46 steps** : ~10 s ajoutés, négligeable face aux 30-60 s gagnés en évitant un blocage step 10 (cf. `AXE_B2_DEEP_VALIDATOR.md` §4). --- ## 2. Matrice principale dense Aligné avec `_ALLOWED_ACTION_TYPES` (`replay_engine.py:35-48`), TYPE_MAP VWB→Léa (`dag_execute.py:1172-1216`) et la palette frontend (`types.ts:98-274`). | Action VWB | Type Léa transmis | Signal **primaire** (fait foi) | Signal **secondaire** (confirmation) | Fallback si primaire indisponible | Verdicts possibles | Latence cible | Coût | Checker `core/validation/` | |---|---|---|---|---|---|---:|---|---| | `click_anchor` | `click` | **OcrRoiChecker** 80 px : `by_text` présent dans crop autour de `actual_position` | TitleBarChecker (titre fenêtre stable, app correcte) | LlmJudgeChecker `verify_with_critic` (escalation 2.5 s) | COMPLETE, WRONG_APPLICATION, WRONG_TARGET, OCR_TEXT_MISSING, UI_LOADING | 80 ms (+2.5 s rare) | OCR EasyOCR | `checkers/ocr_roi.py` | | `double_click_anchor` | `click button="double"` | **TitleBarChecker** (changement titre = nouvelle vue/fenêtre) | OcrRoiChecker 80 px sur zone double-cliquée | LlmJudgeChecker | COMPLETE, NO_VISUAL_CHANGE, WRONG_TARGET | 200 ms | OCR title + OCR ROI | `checkers/title_bar.py` + `ocr_roi.py` | | `right_click_anchor` | `click button="right"` | **DialogPresenceChecker** : menu contextuel apparu sous (cx, cy) | OcrRoiChecker sur items menu si `expected_menu_items` fourni | PixelDiffChecker ROI 300×300 autour curseur | COMPLETE, NO_VISUAL_CHANGE, UNEXPECTED_DIALOG | 150 ms | OCR + SSIM | `checkers/dialog_presence.py` (P1) | | `hover_anchor` | aucun mapping direct (Léa : `move`) | **PixelDiffChecker ROI** 200 px autour curseur : SSIM < 0.98 (tooltip apparu) | OcrRoiChecker si `expected_tooltip_text` fourni | — | COMPLETE, NO_VISUAL_CHANGE | 30 ms | SSIM | `checkers/pixel_diff.py` (existant) | | `drag_drop_anchor` | `drag` (whitelisté, pas de handler Léa actuel) | **OcrRoiChecker** ROI destination 80 px : item attendu visible | PixelDiffChecker zone source (item parti) | LlmJudgeChecker | COMPLETE, WRONG_TARGET, NO_VISUAL_CHANGE | 200 ms | OCR + SSIM | `ocr_roi.py` + `pixel_diff.py` | | `scroll_to_anchor` | `scroll` | **OcrRoiChecker** : ancre attendue (`by_text`) visible dans la viewport | PixelDiffChecker global change_pct > 5% | — | COMPLETE, NO_VISUAL_CHANGE (déjà en bas) | 100 ms | OCR | `ocr_roi.py` | | `focus_anchor` | `focus_anchor` (whitelist côté serveur partielle) | **PixelDiffChecker** 30 px autour widget : caret/highlight apparu | OcrRoiChecker confirmation label widget | — | COMPLETE, WRONG_TARGET | 30 ms | SSIM | `pixel_diff.py` | | `type_text` | `type` | **OcrRoiChecker** 120 px autour caret : texte tapé présent (ratio tokens ≥ 50%) | PixelDiffChecker ROI input (a changé) | LlmJudgeChecker si texte > 50 c | COMPLETE, OCR_TEXT_MISSING (typing failed), SCHEMA_INVALID (caractères spéciaux ratés) | 120 ms | OCR | `ocr_roi.py` (radius_px=120) | | `type_secret` | `type` | **PixelDiffChecker** ROI input : un input s'est rempli (jamais OCR sur le contenu) | — | — | COMPLETE, NO_VISUAL_CHANGE | 20 ms | SSIM | `pixel_diff.py` | | `keyboard_shortcut` (`Ctrl+S`) | `key_combo` | **SaveSuccessChecker** (§4.3) | TitleBarChecker | — | voir §4.3 | 150 ms | OCR + ROI | `checkers/save_success.py` (NEW §5.2) | | `keyboard_shortcut` (`Ctrl+W`, `Ctrl+F4`) | `key_combo` | **TabAbsenceChecker** (§4.2) | — | — | voir §4.2 | 120 ms | OCR | `checkers/tab_active.py` (NEW §5.1) | | `keyboard_shortcut` (`Ctrl+Tab`, `Ctrl+PgDn`) | `key_combo` | **TabActiveChecker** (§4.1) | — | — | voir §4.1 | 120 ms | OCR + SSIM | `checkers/tab_active.py` (NEW §5.1) | | `keyboard_shortcut` (`Home`, `End`, `PgUp/Dn`) | `key_combo` | **PixelDiffChecker** global change_pct > 5% (scroll observable) | — | — | COMPLETE, NO_VISUAL_CHANGE | 15 ms | SSIM | `pixel_diff.py` | | `keyboard_shortcut` (`Alt+F4`, `Esc`) | `key_combo` | **DialogClosedChecker** (§4.4) si dialog avant ; sinon TitleBarChecker | — | LlmJudgeChecker | voir §4.4 | 100 ms | SSIM + OCR | `checkers/dialog_closed.py` (NEW §5.3) | | `keyboard_shortcut` (autre) | `key_combo` | **TitleBarChecker** | LlmJudgeChecker | — | COMPLETE, NO_VISUAL_CHANGE | 130 ms | OCR title | `title_bar.py` | | `wait_for_anchor` | `wait` | **OcrRoiChecker** : ancre présente | PixelDiffChecker (écran stable, fin animation) | — | COMPLETE, CONTINUE (poll), UI_LOADING | 100 ms | OCR | `ocr_roi.py` | | `extract_text` | server-side | **JsonSchemaChecker** : `str`, `len > 50`, ratio lettres ≥ 0.5 | LlmJudgeChecker plausibilité si `len < 50` | — | COMPLETE, SCHEMA_INVALID | 10 ms (+2.5 s rare) | déterministe | `checkers/json_schema.py` | | `extract_text_scroll` | server-side | JsonSchemaChecker + concat check | LlmJudgeChecker si plusieurs pages | — | COMPLETE, SCHEMA_INVALID | 10 ms | déterministe | `json_schema.py` | | `extract_table` | server-side | **JsonSchemaChecker** : `list[str]`, `len ≥ 1`, headers attendus si fournis | OcrRoiChecker re-check headers | — | COMPLETE, SCHEMA_INVALID | 10 ms | déterministe | `json_schema.py` | | `screenshot_evidence` | (action passive) | **TitleBarChecker** (bonne app devant) | — | — | COMPLETE (passive) | 130 ms | OCR title | `title_bar.py` | | `t2a_decision` | server-side | **JsonSchemaChecker** strict (decision ∈ enum, justification ≥ 10 c, confiance ∈ [0,1]) | — | — | COMPLETE, SCHEMA_INVALID | 10 ms | déterministe | `json_schema.py` | | `pause_for_human` | server-side | **Checklist QW4** SafetyChecksProvider | — | — | (hors-scope Validator) | 0 ms | — | — | | `visual_condition` | server-side | **LlmJudgeChecker** sur condition formulée | — | — | COMPLETE, TERMINATE | 2.5 s | LLM | `llm_judge.py` | | `db_save_data` | server-side | **JsonSchemaChecker** + SELECT count rows attendus | — | — | COMPLETE, SCHEMA_INVALID | <50 ms | SQLite | `json_schema.py` extension | | `db_read_data` / `import_excel` | server-side | **JsonSchemaChecker** rows ≥ 1 | — | — | COMPLETE, SCHEMA_INVALID | <50 ms | SQLite | `json_schema.py` | | `db_foreach` | server-side | (boucle, validation par itération via les actions internes) | — | — | — | n/a | — | — | | `paste_and_execute` | server-side bypass `ydotool` | **PixelDiffChecker** ROI input | OcrRoiChecker sur contenu collé si possible | LlmJudgeChecker | COMPLETE, NO_VISUAL_CHANGE | 50 ms (+2.5 s rare) | SSIM | `pixel_diff.py` | | `ai_ocr` / `ai_summarize` / `ai_extract` / `ai_classify` / `ai_analyze_text` / `ai_custom` | server-side | JsonSchemaChecker (str/dict non vide) + LlmJudgeChecker plausibilité | — | — | COMPLETE, SCHEMA_INVALID | 10 ms (+2.5 s) | déterministe + LLM | `json_schema.py` + `llm_judge.py` | | `llm_generate` / `llm_analyze` / `llm_translate` / `llm_extract_data` | server-side | JsonSchemaChecker (str non vide) | — | — | COMPLETE, SCHEMA_INVALID | 10 ms | déterministe | `json_schema.py` | | `verify_element_exists` | server-side ou client | **OcrRoiChecker** sur ancre | PixelDiffChecker | — | COMPLETE, OCR_TEXT_MISSING | 100 ms | OCR | `ocr_roi.py` | | `verify_text_content` | server-side ou client | **OcrRoiChecker** stricte sur `expected_text` | — | — | COMPLETE, OCR_TEXT_MISSING | 100 ms | OCR | `ocr_roi.py` | | `download_to_folder` | server-side | **FileSystemChecker** : fichier présent dans dossier cible | — | — | COMPLETE, NO_VISUAL_CHANGE | 20 ms | FS | nouveau, trivial | | `file_list_dir` / `file_create_dir` / `file_move` / `file_copy` | server-side | FileSystemChecker équivalent | — | — | COMPLETE | <20 ms | FS | trivial | **Note sur les actions whitelisted mais sans handler Léa** : `double_click`, `right_click`, `drag`, `file_open`, `file_save`, `file_close`, `file_new`, `file_dialog` sont dans `_ALLOWED_ACTION_TYPES` mais retournent « Type d'action inconnu » côté Léa (cf. `reference_vwb_action_types.md`). Le Validator ne doit donc **jamais** être appelé sur ces types — sauf si elles sont remappées par TYPE_MAP (ce qui est le cas pour `double_click` et `right_click` via `click + button=...`). --- ## 3. Fiches détaillées — les 4 cas explicitement listés par Dom ### 3.1. Fiche `switch_tab` (changer d'onglet dans l'app — cas du bug step 10) #### Description précise Action visant à changer d'onglet dans une app multi-tab (Easily Assure, navigateur, IDE, PDV). Trois sous-cas : - **Tab d'application native** : barre de tabs en haut d'une fenêtre Windows (Edge, VS Code). - **Tab interne d'une SPA** : `` en plein contenu (Easily Assure, JIRA). - **Tab MDI** : sous-fenêtre dans une app MDI (Outlook, Excel). L'action transmise à Léa est un `click` avec `by_text=` (cas commun `click_anchor` → `click`). Aucun type `switch_tab` natif dans VWB aujourd'hui — c'est toujours un `click_anchor` dont l'ancre est un tab. #### Signaux disponibles | Côté | Signal | Disponibilité | |---|---|---| | **Client (Léa Windows)** | screenshot_after (PNG), `actual_position` (x_pct, y_pct effectivement cliqué), `keystroke_count`, `mouse_delta_ms` | ✅ déjà câblé via `report_action_result` | | **Client (Léa Windows)** | titre fenêtre `GetForegroundWindow().title` | ⚠ pas remonté actuellement — à ajouter au REPORT (déjà fait pour pHash via `core/grounding/title_verifier.py:25-175`) | | **Serveur** | screenshot_before, target_spec (`by_text`), anchor.bbox (cosmétique, non utilisé en mode strict) | ✅ déjà disponible | | **Serveur** | OCR docTR singleton sur screenshot_after | ✅ déjà chargé (`title_verifier._get_ocr`) | #### Algorithme de validation (pseudocode 12 lignes) ```python def validate_switch_tab(action, result, screenshot_after, ctx) -> ValidationResult: # 1. ROI 120×40 px centrée sur (actual_position.x_pct, actual_position.y_pct) roi = crop(screenshot_after, center=actual_pos, w=120, h=40) text_in_roi = ocr(roi).lower() expected = action["by_text"].lower() # 2. PRIORITÉ ABSOLUE : token suspect navigateur/système → bug step 10 for sus in {"https", "edge", "chrome", "favoris", "barre d'adresse"}: if sus in text_in_roi and sus not in expected: return TERMINATE, WRONG_APPLICATION, conf=0.9 # 3. Match exact ou partiel (accents/casse normalisés) if strip_accents(expected) in strip_accents(text_in_roi): # 4. CONFIRMATION : indicateur visuel d'activation (underline, bg active) # bande 4 px sous le label dans le screenshot_after vs before diff_band = ssim_roi(screenshot_before, screenshot_after, bbox=(actual_pos.x-30, actual_pos.y+18, 60, 4)) if diff_band < 0.95: # la bande a changé → tab activé return COMPLETE, conf=0.95 return COMPLETE, conf=0.75 # match texte OK, animation peut-être ratée return CONTINUE, OCR_TEXT_MISSING, conf=0.4 # → escalation LLM judge ``` #### Cas limites - **Animation tab** : Easily Assure n'anime pas, mais Edge/Chrome ont une animation 200 ms sur l'underline. → Bande étroite testée 200 ms après le clic (`screenshot_after` doit être pris ≥ 200 ms après l'action ; à confirmer côté agent Léa). - **Lazy load** : Easily ne charge pas le contenu du tab tant qu'on clique pas. Si on vérifie le contenu sous les tabs, on peut tomber sur un spinner. **Ne valider que la zone du tab lui-même**, pas le contenu sous. - **Popup intermédiaire** : Easily peut afficher « Voulez-vous sauvegarder les modifications ? » avant de switcher. Le Validator doit alors retourner CONTINUE avec `UNEXPECTED_DIALOG` → handoff DialogHandler (chaîne D2). Détection : DialogPresenceChecker parallèle. - **Tab déjà actif** : si on clique sur le tab actuellement actif, le screenshot ne change pas. → Vérifier d'abord si `expected` est déjà dans la zone active *avant* le clic (champ `screenshot_before` exploité, optimisation P1). #### Reproductible offline ? ✅ **Oui**. Fixture déjà disponible : `/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/data/anchors/anchor_0438bd2d9bdd_1778161174_full.png` Cf. `AXE_B2_DEEP_VALIDATOR.md` §7 (snippet `scripts/repro_bug_step10_validator.py`) qui démontre : - SCENARIO 1 (clic dans URL bar Edge) → TERMINATE / WRONG_APPLICATION - SCENARIO 2 (clic correct Imagerie) → COMPLETE --- ### 3.2. Fiche `close_tab` (fermer un onglet) #### Description précise Action de fermeture d'un onglet via : - Clic sur le « × » de l'onglet (`click_anchor` avec `by_text="×"` + bbox) - Raccourci clavier `Ctrl+W` (Chrome, Edge, VS Code) ou `Ctrl+F4` (apps MDI Windows) Le tab fermé **disparaît visuellement** de la barre de tabs, et un autre tab prend le focus (ou le navigateur se ferme si c'était le dernier). #### Signaux disponibles | Côté | Signal | Disponibilité | |---|---|---| | **Client** | screenshot_after, `tab_label_attendu` (variable workflow ou paramètre step) | ✅ | | **Serveur** | screenshot_before, OCR docTR, expected `tab_label_attendu` | ✅ | | **Serveur** | nombre de tabs détectés before/after (compté via OCR sur barre de tabs) | déterministe une fois OCR appliqué | #### Algorithme de validation (pseudocode 14 lignes) ```python def validate_close_tab(action, result, screenshot_before, screenshot_after, ctx) -> ValidationResult: # 1. ROI barre de tabs (haut de la fenêtre app, hauteur 50 px) # Stratégie : on prend la zone autour de actual_pos.y, largeur full roi_before = crop(screenshot_before, y=actual_pos.y-25, h=50, x=0, w=full_w) roi_after = crop(screenshot_after, y=actual_pos.y-25, h=50, x=0, w=full_w) # 2. OCR liste des labels visibles labels_before = set(strip_accents(t.lower()) for t in ocr_words(roi_before)) labels_after = set(strip_accents(t.lower()) for t in ocr_words(roi_after)) target = strip_accents(ctx["tab_label_attendu"].lower()) # 3. Le label cible doit être DANS before et ABSENT de after in_before = target in labels_before in_after = target in labels_after if in_before and not in_after: # 4. SSIM-ROI sur la barre confirme un changement ssim_score = ssim_roi(roi_before, roi_after) if ssim_score < 0.95: return COMPLETE, conf=0.92 return COMPLETE, conf=0.70 # OCR dit OK mais SSIM trop stable, bizarre if in_after: return TERMINATE, WRONG_TARGET, conf=0.85, reason="Le tab attendu est toujours présent après close" if not in_before: return CONTINUE, conf=0.3, reason="Label cible absent du before — ROI ou paramètre erroné" ``` #### Cas limites - **Dernier tab fermé** : navigateur se ferme → écran change radicalement, OCR after ne trouve plus la barre de tabs. → Fallback : si `before contains tab` et `after.window_title != before.window_title` → COMPLETE. - **Confirmation « modifications non sauvegardées »** : popup intermédiaire identique à switch_tab → routing vers DialogHandler. - **Tab épinglé** : un tab pinned ne se ferme pas via × visible mais via menu contextuel. Si l'action `click` est censée fermer un pinned tab → comportement non spécifié, à TERMINATE conservativement. - **Multi-window** : si le tab fermé était dans une fenêtre secondaire, le screenshot serveur (capturé via `mss.monitors[1]`) peut ne pas voir la bonne fenêtre. Couvert par la dette ouverte « coord client Léa Y cassé » (`LESSONS_LEARNED_GHT_2026-05.md`). #### Reproductible offline ? 🟡 **Partiellement**. Pas de fixture before/after dédiée close_tab dans le repo. Création nécessaire : capturer 2 screenshots Easily (before avec 5 tabs, after avec 4 tabs). À demander dans le smoke test P0. --- ### 3.3. Fiche `save` (enregistrer) #### Description précise Action de sauvegarde via : - Raccourci `Ctrl+S` (universel) - Clic sur bouton « Enregistrer » (`click_anchor` avec `by_text="Enregistrer"`) - Menu Fichier > Enregistrer Trois signaux observables typiques : 1. **Disparition d'un marqueur « modifié »** : la title-bar passe de `Mon doc * - App` à `Mon doc - App` (étoile, dot, ou `[modified]` retiré). 2. **Apparition d'un toast** : « Enregistré », « Saved », « Modifications enregistrées » dans une bande inférieure ou supérieure, généralement 1-3 s avant de disparaître. 3. **Disparition d'un bouton « Enregistrer » désactivé→désactivé** : certaines apps (Easily) désactivent le bouton après save jusqu'à la prochaine modification. #### Signaux disponibles | Côté | Signal | Disponibilité | |---|---|---| | **Serveur** | screenshot_before/after | ✅ | | **Serveur** | OCR title-bar (déjà câblé `TitleVerifier`) | ✅ `core/grounding/title_verifier.py:80` | | **Serveur** | OCR bande inférieure (zone toasts, ~150 px depuis le bas) | nouveau crop | | **Client** | aucune télémétrie spécifique save | — | #### Algorithme de validation (pseudocode 15 lignes) ```python def validate_save(action, result, screenshot_before, screenshot_after, ctx) -> ValidationResult: # SIGNAL 1 — marqueur « modifié » disparu de la title-bar title_before = ocr_title_bar(screenshot_before) # ~40 px du haut title_after = ocr_title_bar(screenshot_after) dirty_markers = ["*", "•", "[modified]", "[modifié]", " - modifié"] had_marker = any(m in title_before for m in dirty_markers) still_dirty = any(m in title_after for m in dirty_markers) if had_marker and not still_dirty: return COMPLETE, conf=0.92, evidence={"signal": "dirty_marker_cleared"} # SIGNAL 2 — toast « Enregistré » détecté en bas/haut de la fenêtre # NB: poll 3 captures espacées de 500 ms si screenshot_after est unique toast_keywords = ["enregistré", "enregistrement", "saved", "modifications enregistrées", "sauvegardé", "successfully"] for region in [crop_top_30pct(screenshot_after), crop_bottom_15pct(screenshot_after)]: text = strip_accents(ocr(region).lower()) if any(kw in text for kw in toast_keywords): return COMPLETE, conf=0.88, evidence={"signal": "toast", "matched_kw": kw} # SIGNAL 3 — si on connaît le bouton Enregistrer (ancré), check son état if ctx.get("save_button_anchor"): is_disabled = check_button_disabled(screenshot_after, ctx["save_button_anchor"]) if is_disabled and ctx.get("save_button_enabled_before"): return COMPLETE, conf=0.80, evidence={"signal": "save_button_disabled"} # AUCUN SIGNAL : action peut être un échec silencieux OU app sans marqueur explicite return CONTINUE, conf=0.4, reason="Aucun signal de save observable — escalation LLM" ``` #### Cas limites - **App sans dirty marker ni toast** : Easily Assure n'affiche peut-être ni l'un ni l'autre. → Le Validator passe en `CONTINUE conf=0.4` → escalation LlmJudgeChecker qui demande au VLM « Y a-t-il un indice que la sauvegarde a réussi ? ». À benchmarker sur fixtures Easily réelles. - **Save échoue silencieusement** (espace disque, permission) : aucun toast n'apparaît, ou un toast d'erreur (« Erreur d'enregistrement »). Le Validator doit aussi rechercher les keywords d'erreur (`erreur`, `échec`, `failed`, `permission`) → TERMINATE WRONG_TARGET avec haute conf si trouvé. - **Save async** : certaines apps (Office 365, Notion) sauvent en background, indicateur passe à `Sauvegarde en cours...` puis `Enregistré`. Polling 3 captures × 500 ms. - **Save = nouvelle fenêtre « Enregistrer sous »** : si le doc n'avait jamais été sauvé, Ctrl+S ouvre un dialog. → Validator détecte UNEXPECTED_DIALOG → routing vers DialogHandler pour remplir le nom de fichier. #### Reproductible offline ? 🟡 **Partiellement**. Fixtures nécessaires : - Capture Easily *before* save (avec marqueur dirty) - Capture Easily *after* save (sans marqueur, ou avec toast) - Capture Easily *error* (toast d'erreur) Sinon, on peut tester sur fixtures Office/VS Code disponibles publiquement. --- ### 3.4. Fiche `dialog_button` (cliquer un bouton dans un dialog) #### Description précise Action de clic sur un bouton standard d'une boîte de dialogue modale (OK, Annuler, Oui, Non, Confirmer, Fermer). Le dialog peut être : - Standard Windows (MessageBox, UAC, Windows Hello, Save As) - Custom in-app (modal Bootstrap, modal Easily Assure, modal Citrix) L'effet observable : **le dialog disparaît** de l'écran et le focus retourne à la fenêtre underlying. #### Signaux disponibles | Côté | Signal | Disponibilité | |---|---|---| | **Serveur** | screenshot_before (avec dialog visible), screenshot_after | ✅ | | **Serveur** | bbox du dialog dans before (détectée par DialogPresenceChecker, OmniParser, ou heuristique « rectangle sombre + bouton » `feedback_popup_vlm.md`) | partiel — à câbler | | **Client** | titre fenêtre (`GetForegroundWindow().title` après clic) | ⚠ à remonter dans REPORT | #### Algorithme de validation (pseudocode 12 lignes) ```python def validate_dialog_button(action, result, screenshot_before, screenshot_after, ctx) -> ValidationResult: # 1. Détecter la bbox du dialog dans screenshot_before # Heuristique : ROI centrale 60% width × 50% height OU bbox fournie par # DialogPresenceChecker upstream (sauvée dans replay_state) dialog_bbox = ctx.get("dialog_bbox") or detect_dialog_heuristic(screenshot_before) if dialog_bbox is None: return CONTINUE, conf=0.3, reason="Pas de dialog détecté avant — contexte manquant" # 2. Comparer screenshot_after sur la même bbox before_crop = crop(screenshot_before, dialog_bbox) after_crop = crop(screenshot_after, dialog_bbox) ssim_score = ssim(before_crop, after_crop) # 3. Si SSIM > 0.95 → le dialog est toujours là (le bouton n'a rien fait) if ssim_score > 0.95: return TERMINATE, WRONG_TARGET, conf=0.85, reason="Dialog toujours visible après clic" # 4. OCR confirmation : le titre du dialog (souvent en haut) n'est plus présent dialog_title_before = ocr_top_strip(before_crop) text_after_full = ocr(screenshot_after).lower() if dialog_title_before and strip_accents(dialog_title_before.lower()) not in text_after_full: return COMPLETE, conf=0.92 # 5. Dialog parti mais peut-être remplacé par un autre (cascade Easily) return COMPLETE, conf=0.75, reason="Dialog disparu, à vérifier nouveau dialog éventuel" ``` #### Cas limites - **Cascade de dialogs** : cliquer OK sur le 1er ouvre un 2e dialog (très fréquent dans Easily). → Validator dit COMPLETE pour le 1er, le step suivant doit gérer le 2e. - **Bouton « Annuler » mais l'app a quand même sauvé** : sémantique du dialog # signal visuel. Le Validator ne peut pas juger l'intention métier — il vérifie seulement la disparition. La vérification métier est de la responsabilité du Planner (VWB). - **Dialog non modal** (notification, toast) : techniquement pas un dialog_button. Si l'action est `click` sur un bouton non modal, le SSIM dans la bbox restera élevé parce que le reste de l'app n'a pas bougé. → Heuristique : `dialog_bbox` doit être au centre et < 80% écran, sinon ce n'est pas un vrai dialog modal. - **UAC / Windows Hello / Sécurité Windows** : ces dialogs sont **hors fenêtre app** (système). Le pattern `OcrRoiChecker` les détecte via SUSPECT_TOKENS (« sécurité windows », « user account control » — cf. `AXE_B2_DEEP_VALIDATOR.md` §3.3 ligne 257). Si présents dans before → routing spécifique (`feedback_auth_dialogs_runtime.md`). #### Reproductible offline ? 🟡 **Partiellement**. Fixtures nécessaires : - Capture Easily *with* dialog (confirmation modifications non sauvées) - Capture Easily *after* OK clicked Couverture indirecte par fixtures UAC standard Windows (à acquérir lors de smoke P1). --- ## 4. Fiches secondaires (action standard, validation déjà couverte ou simple) ### 4.1. `click_anchor` (cas non-tab) Couvert exhaustivement dans `AXE_B2_DEEP_VALIDATOR.md` §3.3 (`OcrRoiChecker`). Signal primaire = OCR ROI 80 px ; détection SUSPECT_TOKENS pour bug step 10 ; match expected. ### 4.2. `type_text` Variante d'`OcrRoiChecker` avec `radius_px=120` (zone input plus large que zone bouton). ```python # Variante d'OcrRoiChecker pour type_text checker_type_text = OcrRoiChecker( ocr_fn=easyocr_singleton, radius_px=120, # input plus large expected_min_confidence=0.85, # match plus tolérant (caret peut couper un caractère) ) # action["by_text"] = texte que Léa devait taper (ex. "25003284") # ROI autour de actual_position == zone du champ input # verdict COMPLETE si tokens du texte tapé présents à 50%+ ``` ### 4.3. `extract_text` / `extract_text_scroll` `JsonSchemaChecker` (`AXE_B2_DEEP_VALIDATOR.md` §3.5). Schema : ```python class ExtractTextResult(BaseModel): value: str = Field(min_length=1, max_length=50000) @field_validator("value") @classmethod def must_have_letters(cls, v: str) -> str: if not any(c.isalpha() for c in v): raise ValueError("aucune lettre — vraisemblablement vide") return v ``` Validation déterministe < 10 ms. Si `len(value) < 50` → escalation LlmJudge pour plausibilité. ### 4.4. `t2a_decision` Schema strict (cf. `AXE_B2_DEEP_VALIDATOR.md` §3.5) : ```python class T2aDecisionResult(BaseModel): decision: Literal["UHCD", "FORFAIT", "FORFAIT_URGENCE", "NA", "INCONNU"] justification: str = Field(min_length=10, max_length=5000) confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0) ``` ### 4.5. `keyboard_shortcut` Routing selon le raccourci (table de correspondance dans le Validator) : | Raccourci (`keys`) | Checker primaire | Notes | |---|---|---| | `["ctrl", "s"]` | `SaveSuccessChecker` (§5.2) | universal save | | `["ctrl", "w"]` ou `["ctrl", "f4"]` | `TabAbsenceChecker` | fermeture tab | | `["ctrl", "tab"]` ou `["ctrl", "page_down"]` | `TabActiveChecker` | navigation tab | | `["ctrl", "home"]` ou `["ctrl", "end"]` | `PixelDiffChecker` global | scroll observable | | `["alt", "f4"]` | `DialogClosedChecker` ou TitleBarChecker (fenêtre fermée) | | | `["escape"]` | `DialogClosedChecker` si dialog avant ; sinon NoOpChecker | | | `["ctrl", "c"]` / `["ctrl", "x"]` / `["ctrl", "v"]` | clipboard, **pas de check visuel** (sauf pour `v` qui appelle OcrRoi) | | | `["enter"]` / `["tab"]` (focus next) | `PixelDiffChecker` ROI 100 px autour caret | | | autre | `TitleBarChecker` | fallback générique | Code de dispatch : ```python def select_checker_for_key_combo(action: dict) -> ActionChecker: keys = [k.lower() for k in action.get("keys", [])] keys_set = set(keys) if keys_set == {"ctrl", "s"}: return _save_checker if keys_set in ({"ctrl", "w"}, {"ctrl", "f4"}): return _tab_absence_checker if keys_set in ({"ctrl", "tab"}, {"ctrl", "page_down"}, {"ctrl", "page_up"}): return _tab_active_checker if keys_set & {"home", "end", "page_up", "page_down"}: return _pixel_diff_checker if keys_set == {"alt", "f4"} or keys_set == {"escape"}: return _dialog_closed_checker return _title_bar_checker ``` --- ## 5. Nouveaux Checkers à ajouter au package `core/validation/` Tous s'inscrivent dans l'architecture posée par `AXE_B2_DEEP_VALIDATOR.md` §2 : Protocol `ActionChecker`, retournent `ValidationResult`, exceptions catchées au niveau orchestrateur. ### 5.1. `core/validation/checkers/tab_active.py` (NEW) Pour `switch_tab` (via `click_anchor` sur un tab) et `keyboard_shortcut` Ctrl+Tab. Combine OcrRoiChecker + détection bande indicateur d'activation. ```python # core/validation/checkers/tab_active.py """TabActiveChecker / TabAbsenceChecker — pour switch_tab et close_tab. TabActiveChecker : confirme que le tab visé est activé (label présent + bande indicateur a changé sous le label dans une ROI étroite). TabAbsenceChecker : confirme qu'un tab a disparu de la barre de tabs. """ from __future__ import annotations import time import unicodedata from typing import Any, Callable, Dict, Optional from core.validation.result import ValidationResult, Verdict, FailureCategory def _strip_accents(s: str) -> str: return "".join( c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c) ).lower().strip() class TabActiveChecker: """Vérifie qu'un tab a été activé après un click_anchor sur un tab. Signal 1 (OBLIGATOIRE) : label du tab attendu présent dans ROI 120×40 autour du point cliqué. Signal 2 (RENFORCEMENT) : bande 4 px sous le label a changé entre before/after (indicateur d'activation : underline, fond, bold). Si signal 1 OK + signal 2 OK → COMPLETE conf=0.95 Si signal 1 OK + signal 2 manquant → COMPLETE conf=0.75 Si signal 1 KO → CONTINUE (escalation LLM judge) """ name = "tab_active" budget_ms = 150.0 SUSPECT_TOKENS = ( "edge", "chrome", "firefox", "https", "http", ".com", ".fr", "favoris", "barre d'adresse", "sécurité windows", ) def __init__( self, ocr_fn: Callable, roi_w_px: int = 120, roi_h_px: int = 40, underline_strip_offset_y_px: int = 18, # px sous le centre du label underline_strip_h_px: int = 4, ssim_change_threshold: float = 0.95, # < 0.95 = bande a changé ): self._ocr = ocr_fn self._w = roi_w_px self._h = roi_h_px self._strip_dy = underline_strip_offset_y_px self._strip_h = underline_strip_h_px self._ssim_thr = ssim_change_threshold def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() target_spec = action.get("target_spec") or {} expected = action.get("by_text") or target_spec.get("by_text") or context.get("expected_text", "") actual_pos = result.get("actual_position") or {} x_pct = actual_pos.get("x_pct") or action.get("x_pct") y_pct = actual_pos.get("y_pct") or action.get("y_pct") if not screenshot_after or x_pct is None or y_pct is None or not expected: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.2, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="ROI indéfinie pour TabActiveChecker", ) from PIL import Image from agent_v0.server_v1.replay_verifier import ReplayVerifier rv = ReplayVerifier() img_after = rv._load_single_image(screenshot_after) w, h = img_after.size cx, cy = int(x_pct * w), int(y_pct * h) # ROI 120×40 centrée sur le clic roi = img_after.crop(( max(0, cx - self._w // 2), max(0, cy - self._h // 2), min(w, cx + self._w // 2), min(h, cy + self._h // 2), )) try: text = self._ocr(roi) or "" except Exception as exc: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.1, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning=f"OCR erreur: {exc}", ) text_norm = _strip_accents(text) expected_norm = _strip_accents(expected) # Signal négatif : token suspect → WRONG_APPLICATION for sus in self.SUSPECT_TOKENS: if sus in text_norm and sus not in expected_norm: return ValidationResult( verdict=Verdict.TERMINATE, confidence=0.9, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, failure_category=FailureCategory.WRONG_APPLICATION, reasoning=f"Token suspect '{sus}' dans ROI tab — hors-app", raw_evidence={"roi_text": text[:200], "expected": expected}, ) # Signal positif : label attendu présent if expected_norm not in text_norm: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.4, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, failure_category=FailureCategory.OCR_TEXT_MISSING, reasoning=f"Label '{expected[:40]}' absent de la ROI tab", raw_evidence={"roi_text": text[:200]}, ) # Renforcement : SSIM bande indicateur 4 px sous le label ssim_score = None if screenshot_before: try: img_before = rv._load_single_image(screenshot_before) strip_bbox = ( max(0, cx - 30), max(0, cy + self._strip_dy), min(w, cx + 30), min(h, cy + self._strip_dy + self._strip_h), ) strip_before = img_before.crop(strip_bbox) strip_after = img_after.crop(strip_bbox) ssim_score = self._ssim_pair(strip_before, strip_after) except Exception: ssim_score = None elapsed_ms = (time.time() - t0) * 1000 if ssim_score is not None and ssim_score < self._ssim_thr: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.95, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Label '{expected}' trouvé + bande indicateur a changé (SSIM={ssim_score:.2f})", raw_evidence={"roi_text": text[:200], "ssim_strip": ssim_score}, ) return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.75, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Label '{expected}' trouvé (sans renfort SSIM)", raw_evidence={"roi_text": text[:200], "ssim_strip": ssim_score}, ) @staticmethod def _ssim_pair(img_a, img_b) -> float: """SSIM grayscale entre 2 PIL.Image.""" import numpy as np from skimage.metrics import structural_similarity as ssim a = np.array(img_a.convert("L")) b = np.array(img_b.convert("L")) if a.shape != b.shape: return 1.0 # ne peut pas comparer → on suppose pas de changement return float(ssim(a, b)) class TabAbsenceChecker: """Vérifie qu'un tab a DISPARU de la barre de tabs (close_tab).""" name = "tab_absence" budget_ms = 150.0 def __init__(self, ocr_fn: Callable, tab_bar_h_px: int = 50): self._ocr = ocr_fn self._h = tab_bar_h_px def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() target_label = context.get("tab_label_attendu") or action.get("by_text", "") actual_pos = result.get("actual_position") or {} y_pct = actual_pos.get("y_pct") or action.get("y_pct") if not screenshot_before or not screenshot_after or not target_label or y_pct is None: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.2, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="Contexte insuffisant (label, before, after, y_pct requis)", ) from agent_v0.server_v1.replay_verifier import ReplayVerifier rv = ReplayVerifier() img_b = rv._load_single_image(screenshot_before) img_a = rv._load_single_image(screenshot_after) w, h = img_a.size cy = int(y_pct * h) # ROI : barre de tabs (largeur full, hauteur tab_bar_h centrée sur le clic) bbox = (0, max(0, cy - self._h // 2), w, min(h, cy + self._h // 2)) text_before = _strip_accents(self._ocr(img_b.crop(bbox)) or "") text_after = _strip_accents(self._ocr(img_a.crop(bbox)) or "") target_norm = _strip_accents(target_label) in_before = target_norm in text_before in_after = target_norm in text_after elapsed_ms = (time.time() - t0) * 1000 if in_before and not in_after: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.92, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Tab '{target_label}' disparu de la barre", raw_evidence={"text_before": text_before[:200], "text_after": text_after[:200]}, ) if in_after: return ValidationResult( verdict=Verdict.TERMINATE, confidence=0.85, check_used=self.name, elapsed_ms=elapsed_ms, failure_category=FailureCategory.WRONG_TARGET, reasoning=f"Tab '{target_label}' toujours présent après close", raw_evidence={"text_after": text_after[:200]}, ) return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.3, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Tab '{target_label}' absent même du before — ROI ou label erroné", raw_evidence={"text_before": text_before[:200]}, ) ``` ### 5.2. `core/validation/checkers/save_success.py` (NEW) Pour `save` (Ctrl+S ou clic bouton Enregistrer). ```python # core/validation/checkers/save_success.py """SaveSuccessChecker — confirme qu'une action save a eu l'effet voulu. Signaux dans l'ordre de priorité : 1. Marqueur « modifié » disparu de la title-bar (`*`, `•`, `[modified]`) 2. Toast « Enregistré » / « Saved » apparu dans bande haute ou basse 3. Keyword d'erreur (`erreur`, `failed`) → TERMINATE """ from __future__ import annotations import time import unicodedata from typing import Any, Callable, Dict, Optional from core.validation.result import ValidationResult, Verdict, FailureCategory def _strip_accents(s: str) -> str: return "".join( c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c) ).lower().strip() class SaveSuccessChecker: name = "save_success" budget_ms = 200.0 DIRTY_MARKERS = ("*", "•", "[modified]", "[modifié]", " - modifié", " modifié") SUCCESS_KEYWORDS = ( "enregistré", "enregistrement effectué", "modifications enregistrées", "saved", "sauvegardé", "successfully saved", "document enregistré", ) ERROR_KEYWORDS = ( "erreur d'enregistrement", "échec enregistrement", "save failed", "permission denied", "disk full", "impossible d'enregistrer", ) def __init__( self, ocr_fn: Callable, title_bar_h_px: int = 40, toast_top_h_px: int = 200, toast_bottom_h_px: int = 150, ): self._ocr = ocr_fn self._title_h = title_bar_h_px self._toast_top_h = toast_top_h_px self._toast_bot_h = toast_bottom_h_px def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() if not screenshot_after: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.2, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="screenshot_after manquant", ) from agent_v0.server_v1.replay_verifier import ReplayVerifier rv = ReplayVerifier() img_a = rv._load_single_image(screenshot_after) w, h = img_a.size # SIGNAL 3 : keyword d'erreur (priorité, on veut détecter tôt) full_text = _strip_accents(self._ocr(img_a) or "") for err in self.ERROR_KEYWORDS: if err in full_text: return ValidationResult( verdict=Verdict.TERMINATE, confidence=0.92, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, failure_category=FailureCategory.WRONG_TARGET, reasoning=f"Keyword d'erreur détecté : '{err}'", raw_evidence={"matched_error": err}, ) # SIGNAL 1 : marqueur dirty disparu if screenshot_before: img_b = rv._load_single_image(screenshot_before) title_before = (self._ocr(img_b.crop((0, 0, w, self._title_h))) or "").lower() title_after = (self._ocr(img_a.crop((0, 0, w, self._title_h))) or "").lower() had_marker = any(m in title_before for m in self.DIRTY_MARKERS) still_dirty = any(m in title_after for m in self.DIRTY_MARKERS) if had_marker and not still_dirty: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.92, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="Marqueur dirty disparu de la title-bar", raw_evidence={"title_before": title_before[:120], "title_after": title_after[:120]}, ) # SIGNAL 2 : toast success en haut ou bas for region_name, bbox in [ ("toast_top", (0, 0, w, self._toast_top_h)), ("toast_bottom", (0, h - self._toast_bot_h, w, h)), ]: text = _strip_accents(self._ocr(img_a.crop(bbox)) or "") for kw in self.SUCCESS_KEYWORDS: if kw in text: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.88, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning=f"Toast '{kw}' détecté ({region_name})", raw_evidence={"matched_kw": kw, "region": region_name}, ) # Aucun signal → escalation LLM return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.4, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="Aucun signal de save observable — escalation LLM judge", ) ``` ### 5.3. `core/validation/checkers/dialog_closed.py` (NEW) Pour `dialog_button` (clic OK/Annuler/Oui/Non) et raccourcis Esc/Alt+F4 sur un dialog. ```python # core/validation/checkers/dialog_closed.py """DialogClosedChecker — confirme qu'un dialog modal a été fermé. Stratégie : SSIM-ROI sur la bbox du dialog (issue de screenshot_before). Si SSIM < 0.95 (zone a changé) ET le titre du dialog n'est plus dans after → COMPLETE. Si SSIM > 0.95 (zone identique) → TERMINATE (le bouton n'a rien fait). """ from __future__ import annotations import time import unicodedata from typing import Any, Callable, Dict, Optional from core.validation.result import ValidationResult, Verdict, FailureCategory def _strip_accents(s: str) -> str: return "".join( c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c) ).lower().strip() class DialogClosedChecker: name = "dialog_closed" budget_ms = 200.0 def __init__( self, ocr_fn: Callable, ssim_change_threshold: float = 0.95, # ROI heuristique par défaut : centre 60% x 50% default_bbox_rel: tuple = (0.20, 0.25, 0.80, 0.75), ): self._ocr = ocr_fn self._ssim_thr = ssim_change_threshold self._default_bbox = default_bbox_rel def check(self, action, result, screenshot_before, screenshot_after, context) -> ValidationResult: t0 = time.time() if not screenshot_before or not screenshot_after: return ValidationResult( verdict=Verdict.CONTINUE, confidence=0.2, check_used=self.name, elapsed_ms=(time.time() - t0) * 1000, reasoning="screenshots before/after requis", ) from agent_v0.server_v1.replay_verifier import ReplayVerifier rv = ReplayVerifier() img_b = rv._load_single_image(screenshot_before) img_a = rv._load_single_image(screenshot_after) w, h = img_a.size # bbox du dialog : passée par contexte (issue de DialogPresenceChecker upstream) # ou heuristique centrale par défaut dialog_bbox = context.get("dialog_bbox") if dialog_bbox is None: x0, y0, x1, y1 = self._default_bbox dialog_bbox = (int(x0 * w), int(y0 * h), int(x1 * w), int(y1 * h)) crop_b = img_b.crop(dialog_bbox) crop_a = img_a.crop(dialog_bbox) # SSIM sur la bbox import numpy as np from skimage.metrics import structural_similarity as ssim arr_b = np.array(crop_b.convert("L")) arr_a = np.array(crop_a.convert("L")) ssim_score = float(ssim(arr_b, arr_a)) if arr_b.shape == arr_a.shape else 1.0 elapsed_ms = (time.time() - t0) * 1000 # SSIM élevé → dialog toujours là if ssim_score > self._ssim_thr: return ValidationResult( verdict=Verdict.TERMINATE, confidence=0.85, check_used=self.name, elapsed_ms=elapsed_ms, failure_category=FailureCategory.WRONG_TARGET, reasoning=f"Dialog toujours présent (SSIM={ssim_score:.2f} > {self._ssim_thr})", raw_evidence={"ssim": ssim_score, "bbox": list(dialog_bbox)}, ) # SSIM faible : confirmer par OCR — titre du dialog disparu du after ? title_strip = crop_b.crop((0, 0, crop_b.width, max(20, crop_b.height // 4))) title_before = _strip_accents(self._ocr(title_strip) or "")[:80] full_after = _strip_accents(self._ocr(img_a) or "") if title_before and len(title_before) > 5 and title_before not in full_after: return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.92, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Dialog fermé (SSIM={ssim_score:.2f}, titre absent du after)", raw_evidence={"ssim": ssim_score, "dialog_title_before": title_before}, ) return ValidationResult( verdict=Verdict.COMPLETE, confidence=0.75, check_used=self.name, elapsed_ms=elapsed_ms, reasoning=f"Dialog disparu (SSIM={ssim_score:.2f}, titre OCR ambigu)", raw_evidence={"ssim": ssim_score}, ) ``` ### 5.4. Câblage dans le `Validator` (extension du dispatcher §6.1 de `AXE_B2_DEEP_VALIDATOR.md`) ```python # Pseudo-init lazy à intégrer dans api_stream.py:3447 (cf. AXE_B2_DEEP §6.1) from core.validation.checkers.tab_active import TabActiveChecker, TabAbsenceChecker from core.validation.checkers.save_success import SaveSuccessChecker from core.validation.checkers.dialog_closed import DialogClosedChecker _validator_v2 = Validator( checkers={ # click_anchor générique "click": [OcrRoiChecker(ocr_fn=_ocr, radius_px=80)], # type_text "type": [OcrRoiChecker(ocr_fn=_ocr, radius_px=120)], # key_combo — dispatch interne par select_checker_for_key_combo (cf. §4.5) # Implémentation : un MetaChecker qui choisit le sous-checker selon action["keys"] "key_combo": [KeyComboDispatcher( save_checker=SaveSuccessChecker(_ocr), tab_active_checker=TabActiveChecker(_ocr), tab_absence_checker=TabAbsenceChecker(_ocr), dialog_closed_checker=DialogClosedChecker(_ocr), pixel_diff_checker=PixelDiffChecker(_replay_verifier), title_bar_checker=TitleBarChecker(), )], # extract_*, t2a_decision, db_* "extract_text": [JsonSchemaChecker()], "extract_text_scroll": [JsonSchemaChecker()], "extract_table": [JsonSchemaChecker()], "t2a_decision": [JsonSchemaChecker()], # screenshot_evidence (passive) "screenshot_evidence": [TitleBarChecker()], # paste_and_execute (bypass NoMachine) "paste_and_execute": [PixelDiffChecker(_replay_verifier)], }, default_checkers=[PixelDiffChecker(_replay_verifier)], escalation_checker=LlmJudgeChecker(_replay_verifier), accept_confidence=0.7, escalate_below_confidence=0.55, ) ``` Le `KeyComboDispatcher` est un mini-router qui implémente `ActionChecker` : ```python # core/validation/checkers/key_combo_dispatcher.py """KeyComboDispatcher — route un keyboard_shortcut vers le sous-checker adapté.""" class KeyComboDispatcher: name = "key_combo_dispatcher" budget_ms = 250.0 def __init__(self, save_checker, tab_active_checker, tab_absence_checker, dialog_closed_checker, pixel_diff_checker, title_bar_checker): self._save = save_checker self._tab_active = tab_active_checker self._tab_absence = tab_absence_checker self._dialog_closed = dialog_closed_checker self._pixel = pixel_diff_checker self._title = title_bar_checker def check(self, action, result, screenshot_before, screenshot_after, context): keys = {k.lower() for k in action.get("keys", [])} # Routing if keys == {"ctrl", "s"}: return self._save.check(action, result, screenshot_before, screenshot_after, context) if keys in ({"ctrl", "w"}, {"ctrl", "f4"}): return self._tab_absence.check(action, result, screenshot_before, screenshot_after, context) if keys in ({"ctrl", "tab"}, {"ctrl", "page_down"}, {"ctrl", "page_up"}): return self._tab_active.check(action, result, screenshot_before, screenshot_after, context) if keys & {"home", "end", "page_up", "page_down"}: return self._pixel.check(action, result, screenshot_before, screenshot_after, context) if keys == {"alt", "f4"} or keys == {"escape"}: return self._dialog_closed.check(action, result, screenshot_before, screenshot_after, context) # fallback : titre fenêtre return self._title.check(action, result, screenshot_before, screenshot_after, context) ``` --- ## 6. Confidence scoring et agrégation ### 6.1. Règles par type d'action | Action | Acceptation SUCCESS direct | Escalation LLM judge | Échec direct (TERMINATE) | |---|---|---|---| | `click_anchor` | primaire ≥ 0.85 | 0.50 ≤ primaire < 0.85 | primaire = TERMINATE conf ≥ 0.85 (suspect token) | | `switch_tab` (`TabActiveChecker`) | primaire ≥ 0.85 OU (label OK 0.75 + SSIM bande change) | 0.50 ≤ primaire < 0.85 | suspect token conf ≥ 0.85 | | `close_tab` (`TabAbsenceChecker`) | primaire ≥ 0.85 (label disparu) | 0.50 ≤ primaire < 0.85 | label encore là conf ≥ 0.80 | | `save` (`SaveSuccessChecker`) | primaire ≥ 0.85 (marqueur OU toast) | 0.40 ≤ primaire < 0.85 (aucun signal) | keyword erreur conf ≥ 0.90 | | `dialog_button` (`DialogClosedChecker`) | primaire ≥ 0.85 (SSIM change + titre absent) | 0.55 ≤ primaire < 0.85 | SSIM stable conf ≥ 0.80 | | `type_text` | OCR ROI ≥ 0.80 | 0.50 ≤ primaire < 0.80 | OCR vide conf ≥ 0.80 | | `extract_text` / `t2a_decision` | schema valide conf = 0.95 | n/a (schema déterministe) | schema invalide conf = 0.90 | | `keyboard_shortcut` (autre) | TitleBarChecker conf ≥ 0.70 | 0.40 ≤ primaire < 0.70 | rare | | `paste_and_execute` | SSIM-ROI change conf ≥ 0.70 | 0.40 ≤ primaire < 0.70 | SSIM stable conf ≥ 0.75 | | `screenshot_evidence` | TitleBarChecker conf ≥ 0.50 (action passive) | rarement | jamais | | `visual_condition` | LlmJudge conf ≥ 0.70 | n/a (déjà LLM) | LlmJudge dit faux conf ≥ 0.80 | ### 6.2. Stratégie d'agrégation multi-checker Quand plusieurs checkers s'exécutent en cascade (ex. `double_click_anchor` = TitleBarChecker puis OcrRoiChecker) : ```python # Pseudocode logique d'agrégation def aggregate(results: list[ValidationResult]) -> ValidationResult: # Règle 1 : si UN checker dit TERMINATE conf ≥ 0.85 → TERMINATE for r in results: if r.verdict == Verdict.TERMINATE and r.confidence >= 0.85: return r # Règle 2 : si UN checker dit COMPLETE conf ≥ accept_threshold → COMPLETE completes = [r for r in results if r.verdict == Verdict.COMPLETE and r.confidence >= 0.7] if completes: return max(completes, key=lambda r: r.confidence) # Règle 3 : tous CONTINUE → escalation LLM return None # → escalation ``` ### 6.3. Seuils par défaut - `accept_confidence = 0.70` (Validator orchestrateur) - `escalate_below_confidence = 0.55` - Plafond `LlmJudgeChecker` : `0.9` (jamais 1.0, on garde une marge d'incertitude) --- ## 7. Anti-patterns à éviter | Anti-pattern | Cas concret | Pourquoi c'est faux | Solution | |---|---|---|---| | **pHash global** comme signal `switch_tab` | bug step 10 GHT : pHash voit du mouvement (URL bar Edge change pixel), conclut SUCCESS | Aucune information *spatiale* — un clic dans la URL bar change l'écran sans switcher le tab | OcrRoiChecker 80 px (`AXE_B2_DEEP_VALIDATOR.md` §3.3) | | **Title-bar seule** sur SPA | switch_tab dans Easily Assure (SPA Edge) — le titre Edge ne change pas | Les SPA ne changent pas le titre fenêtre du navigateur lors d'un switch interne | OCR ROI sur tab + SSIM bande indicateur | | **`actual_position` retourné par l'agent = success** | Léa renvoie `actual_position=(0.23, 0.155)` et `success=True` même quand elle a cliqué dans la URL bar | L'agent ne sait pas distinguer un clic réussi d'un clic raté — il sait juste qu'il a envoyé un MouseEvent | Override server-side avec OcrRoiChecker | | **Vérifier le contenu sous les tabs** pour `switch_tab` | Easily lazy-load → on voit un spinner → conclut UI_LOADING / faux échec | Le contenu n'est pas synchrone avec l'activation du tab | Ne vérifier que la zone du tab lui-même (indicateur d'activation) | | **SSIM global pour `dialog_button`** | Si on compare le screenshot entier before/after, le SSIM est dominé par la zone underlying (90% inchangée) → SSIM = 0.97 même si dialog parti | Le signal pertinent est local à la bbox du dialog | SSIM-ROI sur `dialog_bbox` | | **Pixel-diff global pour `save`** | Save peut ne changer que 4 px (marqueur dirty disparu) → pHash dit « écran identique » | Le signal de save est extrêmement localisé | SaveSuccessChecker OCR title-bar + ROI toasts | | **Vérifier `success=True` envoyé par client** sans cross-check serveur | bug 8 mai : Léa fait timeout, action perdue, mais ZÉRO REPORT remonte ; ou pire, REPORT success=True après clic raté | Le client ne peut pas observer ses propres erreurs spatiales (il a cliqué là où on lui a dit) | Server-side Validator obligatoire | | **OCR sur image entière** pour valider un clic local | OCR EasyOCR sur 2560×1600 = 1-2 s, et on perd la localisation (le texte attendu existe peut-être ailleurs) | Localisation perdue + coût élevé | OCR sur crop ROI | | **LLM judge en SUCCESS path** | Appeler `verify_with_critic` à chaque action = +80 s sur 40 steps | Coût rédhibitoire en démo | LLM judge en escalation uniquement (cf. `AXE_B2_DEEP_VALIDATOR.md` §4) | | **Confondre `t2a_decision = NA`** avec échec | `decision="NA"` est un verdict métier valide (cas sans T2A), pas une erreur | Le LLM peut renvoyer "NA" légitimement | Schema accepte "NA" comme valeur valide | | **Ne pas catcher l'exception OCR** | OCR crash sur crop 0×0 ou mémoire → tout le Validator crash → on perd la trace du verdict | Robustesse | Try/except dans chaque Checker (cf. `AXE_B2_DEEP_VALIDATOR.md` §3.7 ligne 720 `try: checker.check`) | | **Considérer `paste_and_execute` comme `type_text`** | `ydotool Ctrl+V` ne tape pas char par char, l'OCR ROI peut voir un texte différent | Le bypass NoMachine colle d'un coup, vérification par diff pixel suffit | PixelDiffChecker primaire | | **Verdict CONTINUE infini** | Si tous les checkers disent CONTINUE et l'escalation aussi, on est coincé | Boucle | `route_verdict` doit imposer `max_rechecks=2` puis TERMINATE (cf. `AXE_B2_DEEP_VALIDATOR.md` §5) | --- ## 8. Précédents externes (fiches courtes) ### 8.1. Skyvern — `complete_verify` générique - **Approche** : 1 seul check VLM par step, prompt Jinja2 `check-user-goal.j2`, sortie JSON stricte `{is_complete, is_terminate}`. - **Apport** : modèle Planner-Actor-Validator formalisé, prompt verbatim disponible. - **Limite pour nous** : 1 appel LLM par step = 2-3 s × 46 steps = 90-140 s. Inapplicable en démo sans matrice par type d'action. - **Adoption** : on garde le pattern Validator-as-component mais on remplace le LLM par des Checkers spécialisés sauf en escalation. - Source : [`agent.py` ligne 2609](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py), prompt [`check-user-goal.j2`](https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal.j2). ### 8.2. browser-use — agentic judge - **Approche** : LLM judge (gemini-2.5-flash) appelé après `done`, sortie JSON `{verdict, failure_reason}`, philosophie « simple prompts, absolute True/False ». - **Apport** : démonstration que les prompts simples + verdicts binaires battent les rubrics complexes (87% accord humain). - **Limite pour nous** : appliqué qu'en fin d'agent, pas par step ; pas de matrice par type d'action. - **Adoption** : on retient la philosophie binaire pour `LlmJudgeChecker` (déjà fait dans `replay_verifier.verify_with_critic` qui force `semantic_verified ∈ {True, False, None}`). - Source : [browser-use evaluation system](https://browser-use.com/posts/our-browser-agent-evaluation-system). ### 8.3. Playwright Python — `expect()` assertions - **Approche** : assertions auto-retry avec timeout par défaut 5 s. Méthodes principales : `to_have_text()`, `to_be_visible()`, `to_be_disabled()`, `to_have_url()`, `to_have_title()`. - **Apport** : équivalent direct de notre matrice par type d'action — chaque action a une assertion typée. - **Mapping pour notre cas** : - `to_have_text(label)` → `OcrRoiChecker(by_text=label)` (notre version sans DOM) - `to_be_visible(selector)` → `OcrRoiChecker` ou `TitleBarChecker` - `to_have_url(pattern)` → bug step 10 : `OcrRoiChecker` URL bar avec SUSPECT_TOKENS - `to_have_title(re.compile(r'.*Easily.*'))` → `TitleBarChecker` - `to_be_hidden(dialog)` → `DialogClosedChecker` - **Limite** : repose sur DOM, pas applicable à Easily Assure (pas d'API accessibility). - Sources : [Playwright Python LocatorAssertions](https://playwright.dev/python/docs/api/class-locatorassertions), [Playwright Assertions guide BrowserStack 2026](https://www.browserstack.com/guide/playwright-assertions). ### 8.4. Selenium `WebDriverWait` + `expected_conditions` - **Approche** : pattern `wait.until(EC.condition(...))` avec polling 500 ms jusqu'à timeout. Conditions clés : - `visibility_of_element_located` (apparition) - `invisibility_of_element_located` (disparition — cas `close_tab`, `dialog_button`) - `staleness_of` (élément retiré du DOM — pas applicable nous) - `text_to_be_present_in_element` (notre `OcrRoiChecker` équivalent) - `element_to_be_clickable` (pas applicable — nous, c'est avant l'action) - **Apport** : pattern de polling avec timeout, à adopter pour `wait_for_anchor` et `pause_for_human` (déjà câblé QW4). - **Mapping** : `invisibility_of_element_located` → `DialogClosedChecker` / `TabAbsenceChecker`. - Sources : [Selenium 4.44 expected_conditions Python](https://www.selenium.dev/selenium/docs/api/py/selenium_webdriver_support/selenium.webdriver.support.expected_conditions.html), [Selenium waits documentation](https://www.selenium.dev/documentation/webdriver/support_features/expected_conditions/). ### 8.5. SikuliX — `exists()`, `wait()`, `waitVanish()`, `onAppear()`, `onVanish()` - **Approche** : visual pattern matching, `exists(image, timeout)` retourne `Match|None`, `waitVanish(image, timeout)` attend qu'une image disparaisse, `onAppear/onVanish/onChange` registre des observateurs sur une `Region`. - **Apport** : précédent historique direct du « 100% vision ». Le pattern `waitVanish` est exactement notre `DialogClosedChecker` / `TabAbsenceChecker`. La notion de `Region` (ROI typée) est aussi celle qu'on adopte. - **Limite** : pas de notion de confidence multi-signal, pas de LLM judge en escalation. - **Adoption** : on calque les noms (`Active`, `Absence`, `Closed`, `Presence`) sur la sémantique SikuliX (`Appear`, `Vanish`) pour cohérence. - Sources : [SikuliX Region documentation](https://sikulix-2014.readthedocs.io/en/latest/region.html), [SikuliX FAQ wait/exists](https://answers.launchpad.net/sikuli/+question/693582). ### 8.6. (Bonus) PyImageSearch / AutoIt patterns post-action - **Approche** : screenshot diff + template re-match. Pas formalisé en framework. - **Apport** : confirme que la combinaison « pixel diff (rapide) + template match (localisation) » est le couteau-suisse historique. - **Adoption** : on garde `PixelDiffChecker` comme pré-filtre 15 ms (déjà existant via `replay_verifier.verify_action`). --- ## 9. Liens avec autres specs ouvertes (à valider par Dom) ### 9.1. Lien avec `spec_transport` (à rédiger ou en cours) Le Validator agit **après** réception du REPORT du client. Si la couche transport perd un REPORT (cas du timeout client 5 s, bug 8 mai cause #1), le Validator **ne tourne jamais**. Implications : - Le watchdog `_retry_pending` (`AXE_B1_DEEP_WATCHDOG.md`) est **prérequis** pour que le Validator soit utile dans tous les cas. - Si on passe en SSE/WebSocket (fix structurel `REPLAY_BLOCAGE_NOTES_MEDICALES §5`), le REPORT devient un message push fiable, le Validator tourne systématiquement. - En attendant, prévoir un check serveur-only « si pas de REPORT après 30 s, capturer screenshot serveur + valider hors-client » (mais cela suppose un screenshot serveur via VNC/RDP/mss côté Léa — pas en place aujourd'hui). ### 9.2. Lien avec `spec_popups` (chaîne D2, à rédiger ou en cours) Le `FailureCategory.UNEXPECTED_DIALOG` du Validator est l'entrée du dispatcher D2 : ```python # route_verdict (cf. AXE_B2_DEEP_VALIDATOR.md §5) if fc == FailureCategory.UNEXPECTED_DIALOG: return {"action": "handoff_dialog_handler", ...} ``` Implications : - Le `DialogPresenceChecker` (P1) DOIT être implémenté avant que le routing `handoff_dialog_handler` soit effectif. - Pour `dialog_button` (cette spec §3.4), on suppose que la **bbox du dialog est déjà connue** (issue de DialogPresenceChecker upstream, sauvée dans `replay_state`). Sinon fallback heuristique centrale 60×50%. - Cascade Easily Assure (popup intermédiaire avant switch_tab) : à traiter dans D2, pas ici. ### 9.3. Lien avec watchdog `_retry_pending` (`AXE_B1`) Couvert §9.1. Le Validator + le watchdog sont **orthogonaux** : le watchdog corrige la cause primaire (HTTP timeout silencieux), le Validator corrige la cause aggravante (clic hors-zone validé success=True). --- ## 10. Cas explicitement non couverts (besoin de décision Dom) | Action | Pourquoi flou | Décision attendue | |---|---|---| | `paste_and_execute` (bypass NoMachine VM Citrix) | Le ydotool injecte dans la VM, le screenshot serveur ne voit pas forcément le résultat (NoMachine pixel intermédiaire) | OCR-ROI ou check via SSH dans la VM ? | | `screenshot_evidence` | Action passive sans effet UI — comment valider la "qualité" du screenshot ? | Suffit-il de vérifier que la bonne app est devant (TitleBar) ou exiger un crop net (Laplacian variance > 100) ? | | `pause_for_human` en mode autonome | Aujourd'hui silencieusement ignorée (`api_stream.py:3011-3017`). Si autonome, le Validator doit-il forcer SUCCESS sans vérification ? Ou pause pour humain quand même via SafetyChecksProvider QW4 ? | Stratégie globale autonome vs supervisé | | `t2a_decision` retournant `decision="NA"` | Verdict métier valide vs erreur du LLM (cas DPI incomplet) | Le Validator doit-il escalader vers un LlmJudgeChecker spécifique « ce DPI permet-il une T2A » ? Hors-scope médical Claude (mémoire `feedback_anonymisation_stricte.md` + Amina) | | Tab **déjà actif** au moment du clic | Le screenshot ne change pas (idempotent) — le Validator dit NO_VISUAL_CHANGE alors que c'est OK | Pré-check : si `expected` déjà dans la zone active *avant* le clic → COMPLETE direct. Optimisation P1. | | `drag_drop_anchor` | Le whitelist serveur l'accepte mais Léa n'a pas de handler — cas test ? | Désactiver l'action côté VWB tant que le handler n'existe pas, ou ajouter le handler Léa | | Fenêtre cachée derrière une autre (off-screen partial) | OCR voit le texte sur le screenshot mais la fenêtre n'a peut-être pas le focus pour réagir aux events | Ajouter une vérif `GetForegroundWindow().title` au REPORT | | Animation longue (>1 s) | Le screenshot_after est pris trop tôt, l'effet n'est pas encore observable | Le `wait_after_action_ms` configurable par type d'action (déjà partiellement câblé pour SCROLL_PAUSE_MS=500 ms `replay_engine.py:64`) | | Multi-écran (`monitor_index`) | Le crop ROI est calculé sur le screenshot du moniteur principal — si l'action est sur moniteur 2, on rate | Couvert par QW1 MonitorRouter, à confirmer compatibilité avec OCR-ROI | --- ## 11. Sources cliquables ### Frameworks 2026 (Validator pattern) - [Skyvern `agent.py` `complete_verify`](https://github.com/Skyvern-AI/skyvern/blob/main/skyvern/forge/agent.py) - [Skyvern prompt `check-user-goal.j2`](https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal.j2) - [Skyvern prompt `check-user-goal-with-termination.j2`](https://raw.githubusercontent.com/Skyvern-AI/skyvern/main/skyvern/forge/prompts/skyvern/check-user-goal-with-termination.j2) - [Skyvern 2.0 blog post (WebVoyager 85.85%)](https://www.skyvern.com/blog/skyvern-2-0-state-of-the-art-web-navigation-with-85-8-on-webvoyager-eval/) - [browser-use evaluation system blog](https://browser-use.com/posts/our-browser-agent-evaluation-system) - [OpenAdapt architecture wiki](https://github.com/OpenAdaptAI/OpenAdapt/wiki/OpenAdapt-Architecture-\(draft\)) ### Patterns assertion test (équivalent Validator) - [Playwright Python LocatorAssertions](https://playwright.dev/python/docs/api/class-locatorassertions) - [Playwright assertions guide](https://www.browserstack.com/guide/playwright-assertions) - [Selenium 4.44 `expected_conditions` (Python)](https://www.selenium.dev/selenium/docs/api/py/selenium_webdriver_support/selenium.webdriver.support.expected_conditions.html) - [Selenium waits documentation](https://www.selenium.dev/documentation/webdriver/support_features/expected_conditions/) - [SikuliX Region documentation](https://sikulix-2014.readthedocs.io/en/latest/region.html) - [SikuliX `exists` / `wait` FAQ](https://answers.launchpad.net/sikuli/+question/693582) - [SikuliX `waitVanish`](https://answers.launchpad.net/sikuli/+question/176416) ### Image similarity / pixel diff - [scikit-image SSIM](https://scikit-image.org/docs/stable/api/skimage.metrics.html#skimage.metrics.structural_similarity) - [Pydantic v2 validation](https://docs.pydantic.dev/latest/concepts/json/) - [Screenshot Comparison Algorithms — Wopee.io](https://wopee.io/blog/screenshot-comparison-algorithms-visual-testing/) ### Doc interne (lecture seule) - Parent : `docs/recherche/AXE_B2_VALIDATOR_PATTERN.md` - Parent (deep) : `docs/recherche/AXE_B2_DEEP_VALIDATOR.md` - OCR / template / pHash : `docs/recherche/AXE_A4_OCR_TEMPLATE_PHASH.md` - Synthèse : `docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md` - Bug archétype : `docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md` - Reference action_types : `~/.claude/projects/-home-dom-ai-rpa-vision-v3/memory/reference_vwb_action_types.md` - Reference templating : `~/.claude/projects/-home-dom-ai-rpa-vision-v3/memory/reference_vwb_templating.md` - Whitelist serveur : `agent_v0/server_v1/replay_engine.py:35-48` - Title verifier : `core/grounding/title_verifier.py:25-175` - OCR-DIRECT : `agent_v0/server_v1/resolve_engine.py:1447-1527` --- *Spec opérationnelle, lecture seule. Aucune modification de code appliquée. Décision d'implémentation des nouveaux Checkers relève de Dom au cas par cas, en cohérence avec le calendrier P0/P1/P2 défini par `AXE_B2_DEEP_VALIDATOR.md` §11.*