72 KiB
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, wiringapi_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=Truerapporté 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)
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_afterdoit ê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
expectedest déjà dans la zone active avant le clic (champscreenshot_beforeexploité, 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_anchoravecby_text="×"+ bbox) - Raccourci clavier
Ctrl+W(Chrome, Edge, VS Code) ouCtrl+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)
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 tabetafter.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
clickest 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_anchoravecby_text="Enregistrer") - Menu Fichier > Enregistrer
Trois signaux observables typiques :
- Disparition d'un marqueur « modifié » : la title-bar passe de
Mon doc * - AppàMon doc - App(étoile, dot, ou[modified]retiré). - 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.
- 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)
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...puisEnregistré. 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)
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
clicksur un bouton non modal, le SSIM dans la bbox restera élevé parce que le reste de l'app n'a pas bougé. → Heuristique :dialog_bboxdoit ê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
OcrRoiCheckerles 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).
# 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 :
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) :
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 :
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.
# 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).
# 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.
# 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)
# 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 :
# 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) :
# 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.pyligne 2609, promptcheck-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 dansreplay_verifier.verify_with_criticqui forcesemantic_verified ∈ {True, False, None}). - Source : browser-use 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)→OcrRoiCheckerouTitleBarCheckerto_have_url(pattern)→ bug step 10 :OcrRoiCheckerURL bar avec SUSPECT_TOKENSto_have_title(re.compile(r'.*Easily.*'))→TitleBarCheckerto_be_hidden(dialog)→DialogClosedChecker
- Limite : repose sur DOM, pas applicable à Easily Assure (pas d'API accessibility).
- Sources : Playwright Python LocatorAssertions, Playwright Assertions guide BrowserStack 2026.
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 — casclose_tab,dialog_button)staleness_of(élément retiré du DOM — pas applicable nous)text_to_be_present_in_element(notreOcrRoiCheckeréquivalent)element_to_be_clickable(pas applicable — nous, c'est avant l'action)
- Apport : pattern de polling avec timeout, à adopter pour
wait_for_anchoretpause_for_human(déjà câblé QW4). - Mapping :
invisibility_of_element_located→DialogClosedChecker/TabAbsenceChecker. - Sources : Selenium 4.44 expected_conditions Python, Selenium waits documentation.
8.5. SikuliX — exists(), wait(), waitVanish(), onAppear(), onVanish()
- Approche : visual pattern matching,
exists(image, timeout)retourneMatch|None,waitVanish(image, timeout)attend qu'une image disparaisse,onAppear/onVanish/onChangeregistre des observateurs sur uneRegion. - Apport : précédent historique direct du « 100% vision ». Le pattern
waitVanishest exactement notreDialogClosedChecker/TabAbsenceChecker. La notion deRegion(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, SikuliX FAQ wait/exists.
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
PixelDiffCheckercomme pré-filtre 15 ms (déjà existant viareplay_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 :
# 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 routinghandoff_dialog_handlersoit 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 dansreplay_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.pycomplete_verify - Skyvern prompt
check-user-goal.j2 - Skyvern prompt
check-user-goal-with-termination.j2 - Skyvern 2.0 blog post (WebVoyager 85.85%)
- browser-use evaluation system blog
- OpenAdapt architecture wiki
Patterns assertion test (équivalent Validator)
- Playwright Python LocatorAssertions
- Playwright assertions guide
- Selenium 4.44
expected_conditions(Python) - Selenium waits documentation
- SikuliX Region documentation
- SikuliX
exists/waitFAQ - SikuliX
waitVanish
Image similarity / pixel diff
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.