1320 lines
72 KiB
Markdown
1320 lines
72 KiB
Markdown
# 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.*
|