Files
rpa_vision_v3/docs/recherche/SPEC_VALIDATOR_MATRICE.md

1320 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** : `<a class="tab">` 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=<label_du_tab>` (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.*