70 KiB
AXE D2 — Deep dive « chaîne popup plus propre » (impl. production-ready)
Date : 2026-05-24
Auteur : agent recherche (dispatch Claude Opus 4.7 1M)
Périmètre : Compléter AXE_D2_DIALOG_POPUP.md par une implémentation prête à coller du package core/dialog/, le câblage exact, la décision orphelin vs actif, la coordination avec le Validator B2, et l'outillage test offline.
Statut : brief de recherche. Lecture seule sur le code existant. Aucune modification proposée à committer.
Prérequis lecture : AXE_D2_DIALOG_POPUP.md (matrice modal→action et taxonomie déjà couvertes), AXE_B2_VALIDATOR_PATTERN.md (interface Verdict/FailureCategory), LESSONS_LEARNED_GHT_2026-05.md §🔴.
1. TL;DR + recommandation immédiate
Le code existant fait déjà 70 % du travail, mais éparpillé en 4 endroits :
agent_v0/agent_v1/core/system_dialog_guard.py— détection multi-signal (ClassName UIA, processus, titre) UAC/CredUI/SmartScreen. Excellent. Fail-closed implémenté.core/grounding/dialog_handler.py—KNOWN_DIALOGSmétier +_click_via_infigui+_click_via_ocr. Réutilisable tel quel.core/grounding/title_verifier.py— OCR titre 45 px, ~120 ms. Réutilisable tel quel.agent_v0/agent_v1/core/executor.py—_handle_popup_vlm(actif, 4 sites d'appel),_handle_possible_popup(orphelin clavier, 0 site),_KNOWN_RUNTIME_DIALOGS(côté client, 1 entrée).
Recommandation immédiatement actionnable (1 jour) :
- Créer un nouveau package
core/dialog/(côté serveur, pas client — pour mutualiser avecdialog_handler.pydéjà serveur-side) qui wrappe les composants existants derrière 3 classes :ChangeDetector,DialogClassifier,DialogResolver. - Garder
_handle_popup_vlmcôté client (Léa Windows) mais le faire déléguer la décision politique au serveur via un endpointPOST /api/v1/dialog/resolve. Le client devient un exécuteur (capture + click), le serveur orchestre la cascade détection→classif→politique. - Supprimer
_handle_possible_popup(orphelin, antipattern Tab+Enter+Esc aveugle qui violefeedback_100pct_visual.md). Référencé 0 fois, code mort. - Câbler 3 sites d'appel dans
executor.py:1108(Observer pre-resolve, déjà câblé via_handle_popup_vlm),executor.py:1262(Policy post-grounding failed, déjà câblé), et 1 nouveau site post-action click (entre L:2270 et L:2475) — actuellement aucune vérif modal après un click réussi côté visuel mais ayant ouvert un dialog métier. - Coordination Validator B2 :
Validator.validate()retourneVerdict.TERMINATE+FailureCategory.UNEXPECTED_DIALOG→api_streamappelleDialogResolver.resolve()qui retourne soitauto_dismissed(replay continue) soitpause_supervised(replay stop avec event structuré).
Couverture estimée : ~85 % des modaux courants traités sans intervention humaine (métier sauvegarde/écrasement + permission navigateur déclarative + OK trivial). Les 15 % restants (UAC, Hello, SmartScreen, INCONNU) = pause supervisée par design healthtech.
Effort : 1 j pour MVP (DialogResolver côté serveur + 1 endpoint + suppression orphelin + 1 site câblé) → 1 sem pour matrice complète + tests offline → 1 mois pour bench injection + apprentissage catalogue.
2. Architecture finale du package core/dialog/
2.1. Arborescence
core/dialog/
├── __init__.py # Exports publics (DialogResolver, DialogEvent, Verdict)
├── signatures.py # KNOWN_DIALOGS étendu fr+en + signatures par catégorie
├── change_detector.py # ChangeDetector léger (~80 LOC)
├── classifier.py # DialogClassifier OCR + VLM fallback (~150 LOC)
├── resolver.py # DialogResolver routing par politique (~200 LOC)
└── events.py # DialogEvent dataclass + persistance audit
2.2. Responsabilités (séparation stricte)
| Module | Entrée | Sortie | Coût |
|---|---|---|---|
ChangeDetector |
screenshot_after PIL | ChangeSignal (bool is_modal + diagnostic) |
~50 ms |
DialogClassifier |
screenshot PIL + ocr_text optionnel | DialogType enum |
0-1.7 s |
DialogResolver |
screenshot + DialogType + workflow_ctx | DialogEvent (verdict + action) |
0-3 s |
Principe clé : chaque composant est appelable isolément, sans dépendance circulaire. Tests unitaires triviaux.
2.3. Interface publique (__init__.py)
"""core.dialog — Chaîne de gestion modaux & popups inattendus.
Stack en 3 couches :
ChangeDetector → DialogClassifier → DialogResolver
Politique healthtech (immutable) :
- JAMAIS d'auto-accept système (UAC/Hello/SmartScreen).
- JAMAIS de raccourci système inventé (pas de Tab+Enter+Esc aveugle).
- Catalogue déclaratif métier (KNOWN_DIALOGS) auto-dismiss explicite.
- Tout dialog inconnu → pause supervisée.
"""
from core.dialog.change_detector import ChangeDetector, ChangeSignal
from core.dialog.classifier import DialogClassifier, DialogType
from core.dialog.resolver import DialogResolver, Policy
from core.dialog.events import DialogEvent
__all__ = [
"ChangeDetector", "ChangeSignal",
"DialogClassifier", "DialogType",
"DialogResolver", "Policy",
"DialogEvent",
]
3. Code complet (copy-paste-ready, testé syntaxiquement)
3.1. signatures.py — catalogue étendu (~120 LOC)
"""core/dialog/signatures.py — Catalogue exhaustif des signatures dialog.
Étend `core/grounding/dialog_handler.KNOWN_DIALOGS` avec les catégories
système (UAC/Hello/SmartScreen) et navigateur (permissions). Source de
vérité unique pour la classification.
Ordre du dict = priorité de matching (popups modaux AVANT fenêtres parents,
voir commentaire `dialog_handler.KNOWN_DIALOGS`).
Toutes les signatures sont en MINUSCULES, l'OCR text doit être .lower()
avant matching. Caractères accentués préservés (EasyOCR fr les conserve).
"""
from __future__ import annotations
from enum import Enum
from typing import Dict, List, Tuple
class DialogType(str, Enum):
# Catégorie SYSTÈME — pause supervisée obligatoire (healthtech).
UAC = "uac"
HELLO = "windows_hello"
SMARTSCREEN = "defender_smartscreen"
DEFENDER = "windows_defender"
DRIVER = "driver_install"
CREDUI = "credential_prompt"
# Catégorie NAVIGATEUR — déclaratif workflow OU pause.
BROWSER_PERMISSION = "browser_permission"
BROWSER_SAVE_PASSWORD = "browser_save_password"
BROWSER_BLOCKED_PAGE = "browser_blocked_page"
# Catégorie MÉTIER — auto-dismiss déterministe via KNOWN_DIALOGS.
METIER_SAVE = "metier_save"
METIER_CONFIRM = "metier_confirm"
METIER_OVERWRITE = "metier_overwrite"
METIER_OK_TRIVIAL = "ok_trivial"
METIER_OK_SUSPECT = "ok_suspect" # mots-clés "supprimé/perdu" → pause
INCONNU = "inconnu"
# Politique par catégorie (immutable healthtech).
class Policy(str, Enum):
AUTO_DISMISS = "auto_dismiss" # OK trivial seulement
DECLARATIVE = "declarative" # catalog match → click
ASK_HUMAN = "ask_human" # pause supervisée
ESCALATE_SECURITY = "escalate_security" # pause + audit log full
POLICY_BY_TYPE: Dict[DialogType, Policy] = {
DialogType.UAC: Policy.ESCALATE_SECURITY,
DialogType.HELLO: Policy.ESCALATE_SECURITY,
DialogType.SMARTSCREEN: Policy.ESCALATE_SECURITY,
DialogType.DEFENDER: Policy.ESCALATE_SECURITY,
DialogType.DRIVER: Policy.ESCALATE_SECURITY,
DialogType.CREDUI: Policy.ESCALATE_SECURITY,
DialogType.BROWSER_PERMISSION: Policy.ASK_HUMAN, # sauf si déclaré workflow
DialogType.BROWSER_SAVE_PASSWORD: Policy.ASK_HUMAN,
DialogType.BROWSER_BLOCKED_PAGE: Policy.ASK_HUMAN,
DialogType.METIER_SAVE: Policy.DECLARATIVE,
DialogType.METIER_CONFIRM: Policy.DECLARATIVE,
DialogType.METIER_OVERWRITE: Policy.DECLARATIVE,
DialogType.METIER_OK_TRIVIAL: Policy.AUTO_DISMISS,
DialogType.METIER_OK_SUSPECT: Policy.ASK_HUMAN,
DialogType.INCONNU: Policy.ASK_HUMAN,
}
# Signatures texte (lowercase) → DialogType. Listes ordonnées car évaluées
# séquentiellement (premier match gagne). Les signatures les plus spécifiques
# en premier dans chaque catégorie.
SIGNATURES_BY_TYPE: Dict[DialogType, List[str]] = {
# ── SYSTÈME ─────────────────────────────────────────────────────────
DialogType.UAC: [
"contrôle de compte d'utilisateur",
"contrôle de compte dutilisateur", # OCR sans apostrophe
"user account control",
"voulez-vous autoriser cette application",
"do you want to allow this app",
"do you want to allow the following",
],
DialogType.HELLO: [
"windows hello",
"saisissez votre code pin",
"saisir votre code pin",
"enter your pin",
"touchez le capteur d'empreintes",
"touchez le lecteur",
"use your fingerprint",
"vérification de votre identité", # cf. feedback_auth_dialogs_runtime
"analysez votre doigt",
],
DialogType.SMARTSCREEN: [
"windows a protégé votre pc",
"windows a protégé votre ordinateur",
"windows protected your pc",
"defender smartscreen",
"smartscreen a empêché",
"informations complémentaires", # accompagne SmartScreen
"exécuter quand même",
"run anyway",
"éditeur inconnu",
"unknown publisher",
],
DialogType.DEFENDER: [
"windows defender",
"menace détectée",
"threat detected",
"virus detected",
],
DialogType.DRIVER: [
"installer ce pilote",
"install this driver",
"signature numérique du pilote",
],
DialogType.CREDUI: [
"sécurité windows",
"windows security",
"entrer les informations d'identification",
"enter your credentials",
"connectez-vous à votre compte",
"sign in to your account",
],
# ── NAVIGATEUR ──────────────────────────────────────────────────────
DialogType.BROWSER_PERMISSION: [
"souhaite utiliser votre microphone",
"souhaite utiliser votre caméra",
"souhaite utiliser votre micro",
"souhaite afficher des notifications",
"souhaite connaître votre position",
"wants to use your microphone",
"wants to use your camera",
"wants to show notifications",
"wants to know your location",
"autoriser l'utilisation",
"allow microphone",
"allow camera",
"autoriser les notifications",
],
DialogType.BROWSER_SAVE_PASSWORD: [
"voulez-vous enregistrer ce mot de passe",
"save password",
"enregistrer le mot de passe",
"voulez-vous que google chrome enregistre",
],
DialogType.BROWSER_BLOCKED_PAGE: [
"cette page web n'a pas répondu",
"cette page web ne répond pas",
"page unresponsive",
"this page isn't responding",
"tuer les pages",
"kill pages",
],
# ── MÉTIER ──────────────────────────────────────────────────────────
# Déjà couvert par core/grounding/dialog_handler.KNOWN_DIALOGS,
# importé dynamiquement par DialogClassifier (single source of truth).
}
# Blocklist pour OK trivial : si ces mots apparaissent, on REFUSE l'auto-dismiss
# et on escalade à l'humain. Action irréversible présumée.
SUSPECT_TOKENS_BLOCKLIST: Tuple[str, ...] = (
"supprimer définitivement", "delete permanently",
"perdu", "perdues", "lost",
"irréversible", "irreversible", "cannot be undone",
"vider la corbeille", "empty trash",
"formater", "format",
"effacer toutes", "erase all",
)
def is_suspect_ok(ocr_text: str) -> bool:
"""Retourne True si l'OCR contient un mot-clé bloquant l'auto-dismiss."""
text_lower = ocr_text.lower()
return any(token in text_lower for token in SUSPECT_TOKENS_BLOCKLIST)
3.2. change_detector.py — détecteur léger (~100 LOC)
"""core/dialog/change_detector.py — Détection rapide d'apparition de modal.
Cible : < 50 ms par appel. Combine 3 signaux composables :
1. Foreground window changed (Windows API, ~1 ms) — signal complémentaire.
2. Screenshot diff zone centrale vs périphérie (~10 ms numpy).
3. Secure desktop detection (~1 ms, écran ~noir UAC).
JAMAIS source unique. La décision finale repose sur composition (au moins
2 signaux concordants OU un signal très fort comme secure desktop).
cf. feedback_popup_vlm.md : GetForegroundWindow seul n'est pas fiable
(retourne 0 en SSH, popups modernes partagent hwnd parent).
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class ChangeSignal:
is_modal: bool # Verdict composite (décision finale)
foreground_changed: bool # Signal Windows API
diff_ratio_global: float # 0.0-1.0 sur tout l'écran
diff_ratio_central: float # 0.0-1.0 sur zone centrale
secure_desktop: bool # Écran type UAC (très assombri)
elapsed_ms: float
class ChangeDetector:
"""Détecte qu'un modal vient d'apparaître sans appeler le VLM."""
# Seuils empiriques. À calibrer post-bench (cf. plan §11).
DIFF_CENTRAL_THRESHOLD = 0.10 # >10% pixels modifiés zone centrale
DIFF_GLOBAL_MAX_FOR_MODAL = 0.40 # un modal = changement local, pas global
LUMINANCE_SECURE_DESKTOP = 50 # pixels < 50 / 255 = très sombre
SECURE_DESKTOP_RATIO = 0.60 # > 60 % écran assombri = UAC probable
def __init__(self):
self._last_screenshot = None
self._last_hwnd: Optional[int] = None
def detect(self, screenshot_pil) -> ChangeSignal:
"""Analyser le screenshot courant vs précédent. Idempotent."""
import numpy as np
t0 = time.time()
arr = np.asarray(screenshot_pil.convert("L"))
# Signal 1 : foreground window change
fg_changed = self._check_foreground_changed()
# Signal 2 : diff zoné central vs global
diff_global = 0.0
diff_central = 0.0
if self._last_screenshot is not None:
prev = np.asarray(self._last_screenshot.convert("L"))
if prev.shape == arr.shape:
diff = np.abs(prev.astype(int) - arr.astype(int))
diff_global = float((diff > 25).mean())
h, w = arr.shape
cy0, cy1 = h // 4, 3 * h // 4
cx0, cx1 = w // 4, 3 * w // 4
diff_central = float((diff[cy0:cy1, cx0:cx1] > 25).mean())
# Signal 3 : secure desktop UAC (écran très assombri global)
secure_desktop = (
float((arr < self.LUMINANCE_SECURE_DESKTOP).mean())
> self.SECURE_DESKTOP_RATIO
)
# Décision composite. Au moins 2 signaux OU secure desktop seul.
is_modal = secure_desktop or (
diff_central > self.DIFF_CENTRAL_THRESHOLD
and diff_global < self.DIFF_GLOBAL_MAX_FOR_MODAL
) or (fg_changed and diff_central > 0.05)
self._last_screenshot = screenshot_pil
elapsed_ms = (time.time() - t0) * 1000
signal = ChangeSignal(
is_modal=is_modal,
foreground_changed=fg_changed,
diff_ratio_global=diff_global,
diff_ratio_central=diff_central,
secure_desktop=secure_desktop,
elapsed_ms=elapsed_ms,
)
if is_modal:
logger.info(
"[CHANGE-DET] modal probable : fg=%s, diff_c=%.2f, "
"diff_g=%.2f, secure=%s (%.0fms)",
fg_changed, diff_central, diff_global, secure_desktop, elapsed_ms,
)
return signal
def reset(self) -> None:
"""Réinitialise l'état (utile entre 2 sessions de replay)."""
self._last_screenshot = None
self._last_hwnd = None
def _check_foreground_changed(self) -> bool:
"""Windows-only. Renvoie False ailleurs ou en cas d'erreur."""
try:
import ctypes
hwnd = int(ctypes.windll.user32.GetForegroundWindow())
except Exception:
return False
if hwnd == 0:
# SSH/Léa sans desktop accessible — signal inutilisable.
return False
changed = (self._last_hwnd is not None) and (hwnd != self._last_hwnd)
self._last_hwnd = hwnd
return changed
3.3. classifier.py — classification cascade (~170 LOC)
"""core/dialog/classifier.py — Classification d'un dialogue détecté.
Stratégie cascade :
1. OCR full-screen (EasyOCR fr+en, ~150 ms via singleton partagé).
2. Match signatures texte (signatures.SIGNATURES_BY_TYPE + KNOWN_DIALOGS).
3. Fallback VLM compact (qwen3-vl:8b via Ollama LAN, ~1.7 s).
4. Si toujours rien → DialogType.INCONNU (politique = ASK_HUMAN).
Le VLM compact est appelé UNIQUEMENT si signatures texte échouent ET
ChangeDetector a confirmé is_modal. Évite de bloquer la boucle replay.
"""
from __future__ import annotations
import logging
import re
import time
from dataclasses import dataclass
from typing import Optional
from core.dialog.signatures import (
SIGNATURES_BY_TYPE,
DialogType,
is_suspect_ok,
)
logger = logging.getLogger(__name__)
@dataclass
class ClassificationResult:
dialog_type: DialogType
confidence: float
method: str # "signature" | "known_dialogs" | "vlm" | "fallback"
ocr_text: str # texte brut OCR (pour audit)
elapsed_ms: float
class DialogClassifier:
"""Classifie un dialogue en type connu."""
# Modèle VLM compact — court output (max 50 tokens).
VLM_MODEL_DEFAULT = "qwen3-vl:8b"
VLM_TIMEOUT_S = 5.0
def __init__(self, ocr_fn=None, ollama_host: Optional[str] = None):
"""
ocr_fn: callable(PIL.Image) -> str. Si None, lazy-load EasyOCR fr+en.
ollama_host: hôte Ollama pour fallback VLM. Défaut env RPA_OLLAMA_HOST
ou "localhost".
"""
self._ocr = ocr_fn
self._easyocr_reader = None
import os
self._ollama_host = ollama_host or os.environ.get("RPA_OLLAMA_HOST", "localhost")
self._vlm_model = os.environ.get(
"RPA_DIALOG_CLASSIFIER_MODEL", self.VLM_MODEL_DEFAULT
)
def classify(self, screenshot_pil) -> ClassificationResult:
t0 = time.time()
# Étape 1 : OCR plein écran
ocr_text = self._read_ocr(screenshot_pil)
# Étape 2 : match signatures fr+en
dtype = self._match_signatures(ocr_text)
if dtype:
elapsed = (time.time() - t0) * 1000
return ClassificationResult(
dialog_type=dtype, confidence=0.9, method="signature",
ocr_text=ocr_text[:300], elapsed_ms=elapsed,
)
# Étape 3 : match catalogue métier existant (single source of truth)
dtype = self._match_known_dialogs(ocr_text)
if dtype:
elapsed = (time.time() - t0) * 1000
return ClassificationResult(
dialog_type=dtype, confidence=0.85, method="known_dialogs",
ocr_text=ocr_text[:300], elapsed_ms=elapsed,
)
# Étape 4 : OK trivial vs suspect (heuristique sans signature)
if self._looks_like_ok_trivial(ocr_text):
dtype = (DialogType.METIER_OK_SUSPECT if is_suspect_ok(ocr_text)
else DialogType.METIER_OK_TRIVIAL)
elapsed = (time.time() - t0) * 1000
return ClassificationResult(
dialog_type=dtype, confidence=0.6, method="heuristic",
ocr_text=ocr_text[:300], elapsed_ms=elapsed,
)
# Étape 5 : fallback VLM compact
dtype = self._classify_via_vlm(screenshot_pil)
elapsed = (time.time() - t0) * 1000
if dtype:
return ClassificationResult(
dialog_type=dtype, confidence=0.7, method="vlm",
ocr_text=ocr_text[:300], elapsed_ms=elapsed,
)
# Fallback ultime : INCONNU → ASK_HUMAN
return ClassificationResult(
dialog_type=DialogType.INCONNU, confidence=0.0, method="fallback",
ocr_text=ocr_text[:300], elapsed_ms=elapsed,
)
# ── Implémentations ────────────────────────────────────────────────
def _read_ocr(self, screenshot_pil) -> str:
if self._ocr is not None:
return self._ocr(screenshot_pil) or ""
# Lazy-load EasyOCR
try:
import numpy as np
if self._easyocr_reader is None:
import easyocr
self._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False,
)
results = self._easyocr_reader.readtext(np.array(screenshot_pil))
return ' '.join(r[1] for r in results if r[1].strip())
except Exception as e:
logger.warning("[CLASSIFIER] OCR failed: %s", e)
return ""
@staticmethod
def _match_signatures(ocr_text: str) -> Optional[DialogType]:
text_lower = ocr_text.lower()
if not text_lower:
return None
for dtype, signatures in SIGNATURES_BY_TYPE.items():
for sig in signatures:
if sig in text_lower:
logger.info("[CLASSIFIER] signature match '%s' → %s", sig, dtype)
return dtype
return None
@staticmethod
def _match_known_dialogs(ocr_text: str) -> Optional[DialogType]:
"""Réutilise core/grounding/dialog_handler.KNOWN_DIALOGS (source unique)."""
try:
from core.grounding.dialog_handler import KNOWN_DIALOGS
except Exception:
return None
text_lower = ocr_text.lower()
for key, info in KNOWN_DIALOGS.items():
if key in text_lower:
# Heuristique : target=Oui → confirm, target=Enregistrer → save
target = info.get("target", "").lower()
if target in ("oui", "yes"):
if "remplac" in key or "replace" in key or "écraser" in key:
return DialogType.METIER_OVERWRITE
return DialogType.METIER_CONFIRM
elif target in ("enregistrer", "save"):
return DialogType.METIER_SAVE
return DialogType.METIER_OK_TRIVIAL
return None
@staticmethod
def _looks_like_ok_trivial(ocr_text: str) -> bool:
"""Heuristique : 1 mot 'OK' isolé + court contexte = OK trivial."""
text_lower = ocr_text.lower()
if not re.search(r"\b(ok|fermer|close|ferme)\b", text_lower):
return False
# Si trop de texte, ce n'est probablement pas un simple OK
return len(ocr_text) < 400
def _classify_via_vlm(self, screenshot_pil) -> Optional[DialogType]:
"""Appel Ollama qwen3-vl:8b avec prompt français court.
Latence cible < 2 s. Sortie attendue : un mot parmi la liste enum.
Si format JSON requis, qwen3-vl:8b ignore parfois `format=json`
(cf. BENCH_SAFETY_CHECKS_2026-05-06 §résultats). On parse en regex.
"""
try:
import base64
import io
import requests
buf = io.BytesIO()
screenshot_pil.convert("RGB").save(buf, format="JPEG", quality=75)
img_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
prompt = (
"Cette capture montre un dialogue/popup. Classe-le en UN SEUL mot "
"parmi : uac, hello, smartscreen, browser_permission, metier_save, "
"metier_confirm, ok_trivial, inconnu. Réponds UNIQUEMENT le mot."
)
payload = {
"model": self._vlm_model,
"messages": [
{"role": "system",
"content": "Tu classes des dialogues Windows. Réponds en un mot."},
{"role": "user", "content": prompt, "images": [img_b64]},
],
"stream": False,
"options": {"num_predict": 20, "temperature": 0.0},
}
r = requests.post(
f"http://{self._ollama_host}:11434/api/chat",
json=payload, timeout=self.VLM_TIMEOUT_S,
)
r.raise_for_status()
response = r.json().get("message", {}).get("content", "").strip().lower()
# Parse : chercher un mot enum dans la réponse
for word in re.findall(r"[a-z_]+", response):
try:
return DialogType(word)
except ValueError:
continue
return None
except Exception as e:
logger.warning("[CLASSIFIER] VLM fallback failed: %s", e)
return None
3.4. resolver.py — politique et action (~180 LOC)
"""core/dialog/resolver.py — Application de la politique par catégorie.
Routing :
- SYSTÈME (UAC/Hello/SmartScreen/...) → escalation_pause_supervised()
- MÉTIER déclaré → résolution via dialog_handler existant (InfiGUI + OCR)
- INCONNU → pause par défaut, JAMAIS auto-dismiss
Cf. AXE_D2_DIALOG_POPUP.md §5 matrice modal → action (autoritative).
"""
from __future__ import annotations
import logging
import time
from typing import Any, Callable, Dict, Optional
from core.dialog.classifier import ClassificationResult, DialogClassifier
from core.dialog.change_detector import ChangeDetector, ChangeSignal
from core.dialog.events import DialogEvent
from core.dialog.signatures import (
POLICY_BY_TYPE,
DialogType,
Policy,
)
logger = logging.getLogger(__name__)
# Politique callbacks signature : (event, screenshot, workflow_ctx) -> bool resolved
PolicyCallback = Callable[[DialogEvent, Any, Dict], bool]
class DialogResolver:
"""Orchestre la chaîne ChangeDetector → Classifier → Politique → Action."""
def __init__(
self,
change_detector: Optional[ChangeDetector] = None,
classifier: Optional[DialogClassifier] = None,
on_pause_supervised: Optional[PolicyCallback] = None,
on_auto_dismiss: Optional[PolicyCallback] = None,
workflow_declared_handlers: Optional[Dict[DialogType, PolicyCallback]] = None,
):
"""
on_pause_supervised: callback appelé en cas d'ASK_HUMAN/ESCALATE.
Signature : (event, screenshot, ctx) -> bool.
Doit déclencher la pause dans api_stream/replay_engine.
on_auto_dismiss: callback pour OK trivial. Doit cliquer le bouton OK
via dialog_handler.handle_if_dialog OU pyautogui.
workflow_declared_handlers: si un workflow déclare anticiper un type
(ex. browser_permission "autoriser micro"),
on appelle ce handler en priorité.
"""
self._change_detector = change_detector or ChangeDetector()
self._classifier = classifier or DialogClassifier()
self._on_pause = on_pause_supervised or self._default_pause
self._on_dismiss = on_auto_dismiss or self._default_dismiss
self._workflow_handlers = workflow_declared_handlers or {}
def check_and_resolve(
self,
screenshot_pil,
workflow_context: Optional[Dict[str, Any]] = None,
force_classify: bool = False,
) -> Optional[DialogEvent]:
"""Point d'entrée principal.
Args:
screenshot_pil: capture courante (post-action ou pre-tick).
workflow_context: ctx avec step_idx, action_id, declared_dialogs, etc.
force_classify: bypass le ChangeDetector (utile pour B2 Validator
qui sait déjà que quelque chose cloche).
Returns:
DialogEvent si un dialog a été détecté et traité, None sinon.
"""
workflow_context = workflow_context or {}
t0 = time.time()
# Étape 1 : détection rapide (sauf bypass)
signal: Optional[ChangeSignal] = None
if not force_classify:
signal = self._change_detector.detect(screenshot_pil)
if not signal.is_modal:
return None
# Étape 2 : classification
classif = self._classifier.classify(screenshot_pil)
# Étape 3 : politique
policy = POLICY_BY_TYPE.get(classif.dialog_type, Policy.ASK_HUMAN)
# Étape 3bis : exception workflow déclaratif (ex. permission micro attendue)
declared = (workflow_context.get("declared_dialogs") or {}).get(
classif.dialog_type.value
)
if declared:
policy = Policy.DECLARATIVE
logger.info(
"[RESOLVER] %s : politique forcée DECLARATIVE (workflow ctx)",
classif.dialog_type,
)
event = DialogEvent(
dialog_type=classif.dialog_type,
policy_applied=policy,
confidence=classif.confidence,
classification_method=classif.method,
ocr_text=classif.ocr_text,
change_signal=signal,
workflow_step=workflow_context.get("step_idx"),
workflow_action_id=workflow_context.get("action_id"),
elapsed_ms=(time.time() - t0) * 1000,
)
# Étape 4 : action selon politique
resolved = self._apply_policy(event, screenshot_pil, workflow_context)
event.action_taken = "resolved" if resolved else "paused"
return event
# ── Politiques ─────────────────────────────────────────────────────
def _apply_policy(
self, event: DialogEvent, screenshot, ctx: Dict[str, Any],
) -> bool:
if event.policy_applied in (Policy.ESCALATE_SECURITY, Policy.ASK_HUMAN):
return self._on_pause(event, screenshot, ctx)
if event.policy_applied == Policy.AUTO_DISMISS:
return self._on_dismiss(event, screenshot, ctx)
if event.policy_applied == Policy.DECLARATIVE:
# Workflow-declared handler en priorité
handler = self._workflow_handlers.get(event.dialog_type)
if handler:
return handler(event, screenshot, ctx)
# Fallback : utiliser le dialog_handler existant (InfiGUI + OCR)
return self._default_declarative(event, screenshot, ctx)
# Default safe : pause
return self._on_pause(event, screenshot, ctx)
@staticmethod
def _default_pause(event: DialogEvent, screenshot, ctx: Dict) -> bool:
logger.warning(
"[RESOLVER] PAUSE SUPERVISÉE : %s (policy=%s, conf=%.2f) — "
"aucun callback on_pause fourni, écho info uniquement.",
event.dialog_type, event.policy_applied, event.confidence,
)
return False
@staticmethod
def _default_dismiss(event: DialogEvent, screenshot, ctx: Dict) -> bool:
"""Auto-dismiss OK trivial : utilise dialog_handler existant."""
try:
from core.grounding.dialog_handler import DialogHandler
handler = DialogHandler()
result = handler.handle_if_dialog(screenshot)
return bool(result.get("handled"))
except Exception as e:
logger.warning("[RESOLVER] auto_dismiss failed: %s", e)
return False
@staticmethod
def _default_declarative(event: DialogEvent, screenshot, ctx: Dict) -> bool:
"""Métier déclaratif : délégue au dialog_handler.KNOWN_DIALOGS."""
try:
from core.grounding.dialog_handler import DialogHandler
handler = DialogHandler()
result = handler.handle_if_dialog(screenshot)
if result.get("handled"):
logger.info(
"[RESOLVER] declarative resolved via dialog_handler: %s → %s",
result.get("title", "?"), result.get("action", "?"),
)
return True
return False
except Exception as e:
logger.warning("[RESOLVER] declarative failed: %s", e)
return False
3.5. events.py — audit (~40 LOC)
"""core/dialog/events.py — Event structuré pour audit + dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Optional
from core.dialog.signatures import DialogType, Policy
@dataclass
class DialogEvent:
dialog_type: DialogType
policy_applied: Policy
confidence: float
classification_method: str # "signature" | "vlm" | "known_dialogs" | ...
ocr_text: str
change_signal: Optional[Any] = None # ChangeSignal dataclass
workflow_step: Optional[int] = None
workflow_action_id: Optional[str] = None
action_taken: str = "unknown"
elapsed_ms: float = 0.0
screenshot_path: Optional[str] = None
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["dialog_type"] = self.dialog_type.value
d["policy_applied"] = self.policy_applied.value
if self.change_signal is not None:
d["change_signal"] = asdict(self.change_signal)
return d
4. Matrice modal → action finale
(complète celle de AXE_D2_DIALOG_POPUP.md §5, focalisée sur l'action concrète par le DialogResolver)
DialogType |
Politique | Signature match | Action DialogResolver |
Latence cible |
|---|---|---|---|---|
UAC |
ESCALATE_SECURITY | "contrôle de compte", "user account control" | on_pause(event) → replay_status=paused_need_help, audit critical, screenshot full |
50ms détect + 150ms OCR |
HELLO |
ESCALATE_SECURITY | "windows hello", "code pin", "vérification de votre identité" | identique UAC + tip pré-démo dans event.extra | 200ms |
SMARTSCREEN |
ESCALATE_SECURITY | "windows a protégé", "smartscreen" | identique + ref project_code_signing.md dans event |
200ms |
DEFENDER |
ESCALATE_SECURITY | "menace détectée", "threat detected" | identique | 200ms |
DRIVER |
ESCALATE_SECURITY | "installer ce pilote", "signature pilote" | identique | 200ms |
CREDUI |
ESCALATE_SECURITY | "sécurité windows", "entrer informations identification" | identique, vault Léa = orthogonal (gestion long terme) | 200ms |
BROWSER_PERMISSION |
DECLARATIVE (si workflow) sinon ASK_HUMAN | "souhaite utiliser microphone/caméra/...", "autoriser/bloquer" | si declared_dialogs[browser_permission] → workflow_handler ; sinon on_pause |
200ms + 50ms click |
BROWSER_SAVE_PASSWORD |
ASK_HUMAN | "enregistrer ce mot de passe" | on_pause (audit security) |
200ms |
BROWSER_BLOCKED_PAGE |
ASK_HUMAN | "page n'a pas répondu" | on_pause (réseau ou crash app) |
200ms |
METIER_SAVE |
DECLARATIVE | KNOWN_DIALOGS["voulez-vous enregistrer"] | _default_declarative → DialogHandler.handle_if_dialog → InfiGUI click "Enregistrer" |
200ms + 3s InfiGUI |
METIER_CONFIRM |
DECLARATIVE | KNOWN_DIALOGS["confirmer"] | identique, click "Oui" | 200ms + 3s |
METIER_OVERWRITE |
DECLARATIVE | KNOWN_DIALOGS["remplacer/écraser/already exists"] | identique, click "Oui" / "Yes" | 200ms + 3s |
METIER_OK_TRIVIAL |
AUTO_DISMISS | heuristique _looks_like_ok_trivial + pas suspect |
_default_dismiss → click "OK" via InfiGUI |
200ms + 3s |
METIER_OK_SUSPECT |
ASK_HUMAN | mots-clés SUSPECT_TOKENS_BLOCKLIST ("supprimé", "perdu", ...) |
on_pause (audit + screenshot full) |
200ms |
INCONNU |
ASK_HUMAN | aucun match signature ni VLM | on_pause (capture VLM pour enrichir catalogue post-démo) |
50ms + 150ms OCR + 1.7s VLM |
5. Wiring : sites d'appel exacts
5.1. Côté serveur — agent_v0/server_v1/api_stream.py
Initialisation (boot) :
# api_stream.py — au démarrage du module, à côté des autres init engines
from core.dialog import DialogResolver, ChangeDetector, DialogClassifier
from core.dialog.signatures import DialogType
_DIALOG_RESOLVER = None
def _get_dialog_resolver():
global _DIALOG_RESOLVER
if _DIALOG_RESOLVER is None:
_DIALOG_RESOLVER = DialogResolver(
change_detector=ChangeDetector(),
classifier=DialogClassifier(),
on_pause_supervised=_dialog_pause_supervised,
on_auto_dismiss=_dialog_auto_dismiss_via_lea,
)
return _DIALOG_RESOLVER
Endpoint nouveau :
# api_stream.py — nouveau endpoint qui sert le client Léa
@app.route("/api/v1/dialog/resolve", methods=["POST"])
def dialog_resolve():
"""Le client Léa nous envoie un screenshot suspect, on classifie et retourne.
Permet au client de ne pas dupliquer la logique de signatures.
Le client reste responsable de l'action click (résolution coords).
"""
payload = request.get_json(force=True)
screenshot_b64 = payload.get("screenshot")
workflow_ctx = payload.get("workflow_context", {})
img = _b64_to_pil(screenshot_b64)
resolver = _get_dialog_resolver()
event = resolver.check_and_resolve(img, workflow_ctx, force_classify=True)
if event is None:
return jsonify({"dialog_detected": False})
return jsonify({
"dialog_detected": True,
"event": event.to_dict(),
# Si DECLARATIVE : indiquer au client le bouton à cliquer
"click_target": _suggest_click_target_for(event),
})
Site 1 : post-REPORT action (le client a rapporté son résultat, on vérifie modal en sortie) :
# api_stream.py:report_action_result, ajouter APRÈS le pixel-diff actuel
# (juste avant le `return jsonify({"replay_status": ...})`)
if RPA_DIALOG_RESOLVER_ENABLED and screenshot_after_b64:
img_after = _b64_to_pil(screenshot_after_b64)
resolver = _get_dialog_resolver()
event = resolver.check_and_resolve(
img_after,
workflow_context={
"step_idx": current_step_idx,
"action_id": action_id,
"declared_dialogs": current_workflow.get("declared_dialogs", {}),
},
)
if event and event.policy_applied in (Policy.ASK_HUMAN, Policy.ESCALATE_SECURITY):
_set_replay_status_paused(
replay_id, reason=f"dialog:{event.dialog_type.value}",
evidence=event.to_dict(),
)
return jsonify({"replay_status": "paused_need_help",
"dialog_event": event.to_dict()})
Site 2 : coordination Validator B2 (quand B2 retourne TERMINATE + FailureCategory.UNEXPECTED_DIALOG) :
# api_stream.py, après l'appel Validator.validate() (cf. AXE_B2 §6.4)
if val.verdict == Verdict.TERMINATE and val.failure_category == FailureCategory.UNEXPECTED_DIALOG:
# Le Validator a détecté qu'un modal bloque, mais ne sait pas quoi en faire.
# Déléguer au DialogResolver pour classification + politique.
resolver = _get_dialog_resolver()
event = resolver.check_and_resolve(
img_after,
workflow_context={"step_idx": step_idx, "action_id": action_id,
"declared_dialogs": current_workflow.get("declared_dialogs", {})},
force_classify=True, # B2 sait déjà qu'il y a un modal
)
if event and event.policy_applied == Policy.DECLARATIVE and event.action_taken == "resolved":
# Modal métier auto-résolu (ex. "Enregistrer ?") → reprendre normalement
return jsonify({"replay_status": "in_progress",
"dialog_event": event.to_dict()})
else:
# Pause supervisée
_set_replay_status_paused(replay_id, reason=val.reasoning,
evidence=event.to_dict() if event else val.to_dict())
return jsonify({"replay_status": "paused_need_help"})
5.2. Côté client — agent_v0/agent_v1/core/executor.py
Décision : NE PAS dupliquer la classification côté client. Simplifier _handle_popup_vlm pour qu'il appelle /api/v1/dialog/resolve du serveur, puis exécute le click_target retourné.
Diff conceptuel (à valider Dom, lecture seule pour ce doc) :
# agent_v0/agent_v1/core/executor.py — _handle_popup_vlm
def _handle_popup_vlm(self) -> bool:
# ── SÉCURITÉ inchangée : refus absolu sur dialogue système ──
if self._check_and_pause_on_system_dialog(context="handle_popup_vlm"):
return False
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
if not screenshot_b64:
return False
- # Essayer la détection popup via le serveur d'abord
- from ..config import SERVER_URL, API_TOKEN
- if SERVER_URL:
- monitor = self.sct.monitors[1]
- sw, sh = monitor["width"], monitor["height"]
- server_result = self._server_resolve_target(
- SERVER_URL, screenshot_b64,
- {"vlm_description": "popup, dialog box, confirmation..."},
- 0.5, 0.5, sw, sh,
- )
- if server_result and server_result.get("resolved"):
- ...
+ # Nouveau : déléguer à DialogResolver serveur (single source of truth)
+ from ..config import SERVER_URL
+ if SERVER_URL:
+ try:
+ r = requests.post(
+ f"{SERVER_URL}/api/v1/dialog/resolve",
+ json={"screenshot": screenshot_b64,
+ "workflow_context": {
+ "action_id": self._current_action_id,
+ "declared_dialogs": self._current_declared_dialogs,
+ }},
+ headers=self._auth_headers(),
+ timeout=8.0,
+ )
+ if r.ok:
+ data = r.json()
+ if not data.get("dialog_detected"):
+ return False
+ event = data["event"]
+ if event["policy_applied"] in ("escalate_security", "ask_human"):
+ # Le serveur a déjà tracé l'event, on positionne juste
+ # le flag pause locale pour que le caller remonte.
+ self._system_dialog_pause = {
+ "category": event["dialog_type"],
+ "matched_signal": event["classification_method"],
+ "matched_value": event.get("ocr_text", "")[:80],
+ "reason": f"DialogResolver: {event['dialog_type']}",
+ "context": "handle_popup_vlm",
+ }
+ return False
+ # DECLARATIVE/AUTO_DISMISS → on a un click_target
+ target = data.get("click_target")
+ if target:
+ real_x = int(target["x_pct"] * sw)
+ real_y = int(target["y_pct"] * sh)
+ self._click((real_x, real_y), "left")
+ time.sleep(1.0)
+ return True
+ except Exception as e:
+ logger.warning("[POPUP-VLM] DialogResolver server failed: %s", e)
- # Fallback : VLM local identifie le bouton à cliquer
- button_text = self._vlm_identify_popup_button(screenshot_b64)
+ # Fallback (serveur indisponible) : VLM local existant
+ button_text = self._vlm_identify_popup_button(screenshot_b64)
...
6. Activation _handle_possible_popup orphelin — décision finale
Verdict : SUPPRIMER. 5 raisons :
- 0 site d'appel confirmé par grep — code mort (
executor.py:2960). - Antipattern strict
feedback_100pct_visual.md: tente Enter → Escape → Tab+Enter de manière aveugle. Or Échap dans un formulaire métier peut purger des données saisies ; Tab+Enter sur "Confirmer l'enregistrement" peut cliquer "Non" si le focus est dessus. - Antipattern
feedback_lea_reflexes_catalog.md: si on veut composer Enter/Escape, ça passe pargesture_catalog.py, pas par un handler ad hoc. - Couvert par
_handle_popup_vlmactif (4 sites d'appel) qui suit la cascade autorisée VLM → InfiGUI → OCR → click ciblé. - Confusion code : 2 méthodes avec noms similaires (
_handle_possible_popupvs_handle_popup_vlm) → dette technique. Nettoyage = 1 modification triviale.
Patch suggéré (lecture seule, à proposer à Dom) :
# agent_v0/agent_v1/core/executor.py
@@ -2956,73 +2956,8 @@
# =========================================================================
- # Gestion automatique des popups imprevues (legacy clavier)
+ # NOTE: _handle_possible_popup supprimé (orphelin, antipattern Tab+Enter aveugle).
+ # Cf. AXE_D2_DEEP_POPUP_CHAIN §6. Remplacé par chaîne DialogResolver serveur.
# =========================================================================
-
- def _handle_possible_popup(self) -> bool:
- """Tenter de gerer une popup imprevue.
- ...
- """
- hash_before = self._quick_screenshot_hash()
- ...
- return False
-
- def _press_key(self, key):
- ...
-
- def _press_tab_enter(self):
- ...
Vérifier après suppression que _press_key / _press_tab_enter ne sont pas appelés ailleurs (probable, à grep avant patch).
Note méthode : créer une DETTE-XXX dans DETTE_TECHNIQUE.md pour tracer la suppression et le replacement par DialogResolver. Cohérent avec feedback_no_rustine.md (corriger la cause = absence de chaîne unifiée, pas le symptôme).
7. Coordination Validator B2 ↔ DialogResolver — pseudo-code
Principe : le Validator détecte qu'un check post-action échoue ; il route vers DialogResolver pour comprendre si la cause est un modal et le résoudre.
# agent_v0/server_v1/api_stream.py — handler de REPORT
from agent_v0.server_v1.validator import Validator, Verdict, FailureCategory
from core.dialog import DialogResolver
from core.dialog.signatures import Policy
async def report_action_result(payload):
...
# Phase 1 : Validator B2 (matrice par action_type)
val = _validator.validate(
action=action, result=result,
screenshot_before=before, screenshot_after=after,
context=ctx,
)
# Phase 2 : si Validator suspecte un modal OU verdict TERMINATE → DialogResolver
needs_dialog_check = (
val.verdict == Verdict.TERMINATE
or val.failure_category in (
FailureCategory.UNEXPECTED_DIALOG,
FailureCategory.NO_VISUAL_CHANGE,
FailureCategory.WRONG_APPLICATION, # cas bug step 10 démo GHT
)
)
if needs_dialog_check:
resolver = _get_dialog_resolver()
event = resolver.check_and_resolve(
after_pil,
workflow_context={
"step_idx": ctx["step_idx"],
"action_id": action["action_id"],
"declared_dialogs": ctx.get("declared_dialogs", {}),
},
force_classify=(val.failure_category == FailureCategory.UNEXPECTED_DIALOG),
)
if event and event.policy_applied == Policy.DECLARATIVE and event.action_taken == "resolved":
# Modal métier auto-résolu → on RÉ-EXÉCUTE l'action originale
logger.info("[B2+D2] Dialog résolu (%s) → re-tentative action %s",
event.dialog_type, action["action_id"])
return jsonify({"replay_status": "retry_after_dialog",
"dialog_event": event.to_dict()})
if event and event.policy_applied in (Policy.ESCALATE_SECURITY, Policy.ASK_HUMAN):
return jsonify({"replay_status": "paused_need_help",
"dialog_event": event.to_dict(),
"validator_evidence": val.to_dict()})
# Phase 3 : Validator dit COMPLETE → continuer
if val.verdict == Verdict.COMPLETE:
return jsonify({"replay_status": "in_progress"})
# Phase 4 : Validator dit CONTINUE (effet pas encore visible) → re-vérifier
return jsonify({"replay_status": "wait_recheck", "recheck_ms": 1500})
Interface contractuelle :
- B2 produit
ValidationResult.failure_categorytypé (cf. AXE_B2 §6.1). - D2 consomme cette
failure_categorypour décider de lancer/skipper le ChangeDetector. - Boucle : D2 résout métier → renvoie
retry_after_dialog→ B2 re-valide après retry.
8. Heartbeat & state machine
8.1. Rythme d'invocation
| Phase Léa | Rythme DialogResolver.check_and_resolve |
Justification |
|---|---|---|
exec_action (action en cours côté client) |
jamais (le client ne capture pas) | Pas pertinent, pas de signal |
post_action_report (REPORT serveur) |
SYSTÉMATIQUE (1 fois par action) | Site primaire — cf. §5.1 Site 1 |
validator_b2 (Validator post-action déclenché) |
CONDITIONNEL (si TERMINATE/NO_VISUAL_CHANGE/UNEXPECTED_DIALOG) | Délégation §7 |
heartbeat_observe (tick observe_reason_act côté serveur) |
OPTIONNEL (toutes les 5 s pendant un wait long) | Capture un modal apparu pendant attente t2a/extract_text |
paused_state (Léa en pause manuelle) |
jamais | Inutile, humain a la main |
Total coût démo (40 steps, 2 min cible) :
- Post-REPORT : 40 × 50ms (ChangeDetector seul si pas de modal) = 2 s
- Si 5 modaux détectés sur la démo : +5 × 200ms (OCR+classif) = +1 s
- Si 1 modal INCONNU appelle VLM fallback : +1 × 1.7s = +1.7 s
- Total : ~5 s sur démo de 120 s = 4 % overhead. Acceptable.
8.2. State machine Léa simplifiée
┌──────────────┐
│ IDLE │
└──────┬───────┘
│ start_action
▼
┌──────────────┐
│ EXEC_ACTION │ (client capture+click)
└──────┬───────┘
│ report
▼
┌──────────────┐ ChangeDetector + Classifier
│ POST_REPORT │──────► + Validator B2
└──────┬───────┘
│
┌────────────┼────────────┬─────────────┐
▼ ▼ ▼ ▼
no_dialog metier_auto system_dialog unknown
continue resolved pause_super pause_super
│ │ │ │
└────────────┴───────┬────┴─────────────┘
▼
┌──────────────┐
│ NEXT_ACTION │
│ or PAUSED │
└──────────────┘
9. Test offline pytest — snippet complet
9.1. tests/unit/test_dialog_chain.py
"""tests/unit/test_dialog_chain.py — Tests offline chaîne DialogResolver.
Charge des screenshots fixture, vérifie la cascade complète :
ChangeDetector → DialogClassifier → DialogResolver → action.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List
import pytest
from PIL import Image
from core.dialog import ChangeDetector, DialogClassifier, DialogResolver
from core.dialog.signatures import DialogType, Policy
FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "dialogs"
def _load(name: str) -> Image.Image:
path = FIXTURE_DIR / name
if not path.exists():
pytest.skip(f"Fixture absente : {path}")
return Image.open(path).convert("RGB")
def _fake_ocr(text: str):
"""Helper : retourne une fonction OCR qui renvoie toujours `text`."""
return lambda img: text
# ── Tests ChangeDetector ───────────────────────────────────────────────
def test_change_detector_first_call_returns_no_change():
"""Premier appel : pas de référence précédente → is_modal=False."""
det = ChangeDetector()
img = Image.new("RGB", (1920, 1080), color=(128, 128, 128))
signal = det.detect(img)
assert signal.is_modal is False
assert signal.elapsed_ms < 100 # cible < 50ms, marge
def test_change_detector_detects_central_change():
"""Centre change beaucoup, périphérie stable → is_modal=True."""
det = ChangeDetector()
img_before = Image.new("RGB", (1920, 1080), color=(200, 200, 200))
det.detect(img_before) # 1er appel
img_after = img_before.copy()
# Modal centré 800×500 sombre
from PIL import ImageDraw
draw = ImageDraw.Draw(img_after)
draw.rectangle((560, 290, 1360, 790), fill=(50, 50, 50))
signal = det.detect(img_after)
assert signal.is_modal is True
assert signal.diff_ratio_central > 0.1
def test_change_detector_secure_desktop():
"""Écran majoritairement très sombre → UAC secure desktop."""
det = ChangeDetector()
img_dark = Image.new("RGB", (1920, 1080), color=(30, 30, 30))
signal = det.detect(img_dark)
assert signal.secure_desktop is True
assert signal.is_modal is True
# ── Tests DialogClassifier ─────────────────────────────────────────────
@pytest.mark.parametrize("ocr_text,expected", [
("Contrôle de compte d'utilisateur Voulez-vous autoriser", DialogType.UAC),
("User Account Control", DialogType.UAC),
("Windows Hello Saisissez votre code PIN", DialogType.HELLO),
("Touchez le capteur d'empreintes digitales", DialogType.HELLO),
("Windows a protégé votre PC", DialogType.SMARTSCREEN),
("Defender SmartScreen a empêché", DialogType.SMARTSCREEN),
("Souhaite utiliser votre microphone Autoriser Bloquer", DialogType.BROWSER_PERMISSION),
("Voulez-vous enregistrer ce mot de passe", DialogType.BROWSER_SAVE_PASSWORD),
])
def test_classifier_signature_match(ocr_text, expected):
classifier = DialogClassifier(ocr_fn=_fake_ocr(ocr_text))
img = Image.new("RGB", (100, 100))
result = classifier.classify(img)
assert result.dialog_type == expected
assert result.method == "signature"
def test_classifier_known_dialogs_fallback():
"""KNOWN_DIALOGS catalogue métier (single source of truth)."""
classifier = DialogClassifier(ocr_fn=_fake_ocr("Voulez-vous remplacer le fichier ?"))
img = Image.new("RGB", (100, 100))
result = classifier.classify(img)
assert result.dialog_type == DialogType.METIER_OVERWRITE
def test_classifier_unknown_no_vlm():
"""Texte non match + VLM absent (timeout) → INCONNU."""
classifier = DialogClassifier(ocr_fn=_fake_ocr("Zorglub flubbergrabben"))
classifier._ollama_host = "127.0.0.1:9999" # port impossible
img = Image.new("RGB", (100, 100))
result = classifier.classify(img)
assert result.dialog_type == DialogType.INCONNU
# ── Tests DialogResolver ───────────────────────────────────────────────
def test_resolver_uac_triggers_pause():
"""UAC → ESCALATE_SECURITY → on_pause callback appelé."""
calls = []
def on_pause(event, screenshot, ctx):
calls.append((event.dialog_type, event.policy_applied))
return False
img = Image.new("RGB", (1920, 1080), color=(30, 30, 30)) # dark = secure desktop
classifier = DialogClassifier(ocr_fn=_fake_ocr("Contrôle de compte d'utilisateur"))
resolver = DialogResolver(
change_detector=ChangeDetector(),
classifier=classifier,
on_pause_supervised=on_pause,
)
event = resolver.check_and_resolve(img)
assert event is not None
assert event.dialog_type == DialogType.UAC
assert event.policy_applied == Policy.ESCALATE_SECURITY
assert calls == [(DialogType.UAC, Policy.ESCALATE_SECURITY)]
def test_resolver_workflow_declared_browser_permission():
"""Permission micro DÉCLARÉE dans workflow → DECLARATIVE handler appelé."""
declared_calls = []
def declared_handler(event, screenshot, ctx):
declared_calls.append(event.dialog_type)
return True
img = Image.new("RGB", (1920, 1080))
classifier = DialogClassifier(ocr_fn=_fake_ocr("Souhaite utiliser votre microphone"))
resolver = DialogResolver(
change_detector=ChangeDetector(),
classifier=classifier,
workflow_declared_handlers={
DialogType.BROWSER_PERMISSION: declared_handler,
},
)
# Forcer is_modal pour éviter dépendance ChangeDetector
event = resolver.check_and_resolve(
img,
workflow_context={
"declared_dialogs": {"browser_permission": {"action": "allow"}},
},
force_classify=True,
)
assert event.policy_applied == Policy.DECLARATIVE
assert event.action_taken == "resolved"
assert declared_calls == [DialogType.BROWSER_PERMISSION]
def test_resolver_suspect_ok_escalates():
"""Mots-clés 'supprimer définitivement' → ASK_HUMAN même si OK trivial."""
img = Image.new("RGB", (1920, 1080))
classifier = DialogClassifier(
ocr_fn=_fake_ocr("OK pour supprimer définitivement ce fichier")
)
resolver = DialogResolver(classifier=classifier)
event = resolver.check_and_resolve(img, force_classify=True)
assert event.dialog_type == DialogType.METIER_OK_SUSPECT
assert event.policy_applied == Policy.ASK_HUMAN
9.2. Comment produire les fixtures tests/fixtures/dialogs/
PowerShell snippets pour générer chaque type de dialog sur le PC Windows Léa :
# fixture_uac.png — déclencher UAC via runas
Start-Process powershell -Verb RunAs
# (cliquer Non → screenshot pendant prompt)
# fixture_hello.png — déclencher Hello via gestionnaire identifiants
control.exe /name Microsoft.CredentialManager
# (cliquer "Ajouter un identifiant Windows" puis screenshot)
# fixture_smartscreen.png — télécharger un .exe non signé
Invoke-WebRequest -Uri "https://example.com/test.exe" -OutFile "$env:TEMP\test.exe"
& "$env:TEMP\test.exe"
# (SmartScreen popup → screenshot)
# fixture_browser_permission.png — page test microphone
Start-Process msedge "https://webcamtests.com"
# (autoriser micro → screenshot)
# fixture_metier_save.png — Bloc-notes non sauvé
Start-Process notepad
# (taper texte, Ctrl+W → "Voulez-vous enregistrer ?")
Sources publiques de captures (si Dom n'a pas d'accès Windows live pour générer) :
- Windows 11 UAC screenshots galerie Microsoft Learn — fixture statiques officielles.
- Defender SmartScreen UI samples — Microsoft GitHub docs.
- Notre
data/runner_captures/contient déjà des screenshots de replays —greper ceux qui ont émis un event "popup_handled" pour réutilisation.
Tests sans fixture (CI sans Windows) : les _fake_ocr(...) permettent de tester la cascade Signature → Classifier → Resolver sans aucun screenshot réel. Couvre 80 % de la logique. Les fixtures restent utiles pour ChangeDetector (qui dépend du diff pixel réel).
10. Patterns externes 2026 (compléments à AXE_D2 §3)
10.1. Skyvern dialog handling (vérif source mai 2026)
Issue #69 ouverte sept. 2024, toujours active : « Unable to interact with popup modals on costcotravel.com ». Skyvern délègue tout à son Validator post-action (cf. complete_verify analysé dans AXE_B2 §2.1). Pas de DialogResolver dédié → c'est leur point faible.
Source : Skyvern Issue #69, Prompting Guide — la doc officielle invite à décrire le popup attendu dans le prompt (« déclaratif workflow ») exactement comme notre declared_dialogs[...].
10.2. browser-use Issue #1996 (juin 2025, closed)
Source : browser-use Issue #1996 — issue fermée sans fix (« handling left to the LLM in the prompt »). Confirme que l'écosystème open source n'a pas de standard sur ce sujet. Notre approche signatures + VLM fallback est en avance.
10.3. Anthropic Computer Use 2026 — politique dialogs
Source : Computer Use API Docs, Claude Opus 4.6 system card fév. 2026 :
- Permission-first : Claude demande confirmation avant tout nouvel app.
- Classifiers anti-prompt-injection : screenshots suspects → demande confirmation user.
- Pas de UAC/Hello handling spécifique documenté. Anthropic se repose sur le fait que Claude identifie un dialog système et refuse implicitement d'y cliquer si pas explicitement instruit. Risqué : aucune garantie.
Notre approche system_dialog_guard.py (multi-signal ClassName UIA + processus + titre) est plus robuste que la self-reflection LLM.
10.4. OpenAI Operator / ChatGPT Agent — handover explicite
Source : ChatGPT Agent help, Operator system card :
- Sur CAPTCHA, login, paiement → « proactively asks the user to take over ».
- Pendant le takeover : screenshots OFF (protection credentials).
- Modèle CUA 2026 : OSWorld 45 % (vs 38 % preview).
Pattern transposable : notre pause_supervised doit ressembler à ce handover. Le dashboard VWB devrait afficher le screenshot figé au moment du modal (pas live) puis reprendre la capture après resolution humaine. Cohérent avec feedback_failure_is_learning.md.
10.5. Cradle (BAAI) — Self-Reflection module
Source : Cradle GitHub, arXiv 2403.03186 — agent jeu vidéo avec module Self-Reflection (+20.41 pts). N'a pas de DialogResolver spécifique car le contexte (jeu) n'a pas de modaux système Windows. Non transposable directement.
10.6. UAC secure desktop detection (Win32)
Source : Microsoft GetSystemMetrics docs, PyAutoGUI _pyautogui_win.py, Sigma rule UAC secure desktop disabled :
ctypes.windll.user32.GetSystemMetrics(SM_REMOTESESSION=0x1000)indique session distante (RDP/Citrix), pas secure desktop directement.- Pas d'API publique Win32 pour détecter le secure desktop UAC. Solution : screenshot luminance (déjà dans
ChangeDetector._check_foreground_changed). - Registry
PromptOnSecureDesktop=0désactive le secure desktop (option config Windows, à anticiper en démo).
11. Plan d'intégration gradué
11.1. Court terme — 1 jour (P0, avant prochaine démo)
But : MVP fonctionnel sans casser l'existant, kill-switch off par défaut.
- Créer
core/dialog/avec les 5 fichiers (§3) — 3 h. - Endpoint serveur
POST /api/v1/dialog/resolve(§5.1) — 1 h. - Câbler Site 1 (post-REPORT) avec env var
RPA_DIALOG_RESOLVER_ENABLED=falsepar défaut — 1 h. - Tests unit (
test_dialog_chain.py§9) sans fixtures réelles, juste_fake_ocr— 2 h. - Smoke test : démarrer Léa + serveur + workflow Demo_urgence_3_db avec flag
=true, mesurer latence (cible < 50 ms par check sans modal) — 1 h.
Livrable : DialogResolver disponible derrière flag. Démo inchangée si flag off.
11.2. Moyen terme — 1 semaine (P1)
- Migration
_handle_popup_vlmcôté client vers délégation serveur (§5.2 diff) — 3 h. - Suppression
_handle_possible_popuporphelin + grep cleanup_press_key/_press_tab_enter— 1 h. - Capture des 5 fixtures Windows (UAC/Hello/SmartScreen/permission/métier) via PowerShell (§9.2) — 2 h.
- Tests d'intégration avec fixtures réelles — 3 h.
- Coordination Validator B2 (§7) — 3 h.
- Dashboard VWB : panneau "Dialog events" par session (count par type + dernier ocr_text) — 1 j.
11.3. Long terme — 1 mois (P2)
- Bench injection : harness qui injecte UAC simulé / popup métier / SmartScreen pendant replay test, mesure detect→classify→resolve, taux pause vs auto-dismiss — 3 j.
- Apprentissage catalogue : chaque
DialogType.INCONNUenregistré dans BDD → revue Dom toutes les semaines → enrichitSIGNATURES_BY_TYPE(pattern OpenAdapt Evaluation-Driven Feedback) — continu. - Win32 UIA hook :
SetWindowsHookEx(WH_CBT)pour détecterHCBT_CREATEWNDd'une fenêtre modale → signal complémentaire au screenshot diff. Pertinence Citrix douteuse (UIA aveugle), à benchmarker — 2 j R&D. - DialogResolver pour Citrix : adapter détection (secure desktop UAC d'un client Citrix passe dans le framebuffer hôte) — 3 j.
- Synergie AXE_A5 (tokenisation écran) : si parser UI produit liste éléments interactifs, classification devient déterministe (matche label bouton) — dépend roadmap A5.
12. Sources (liens cliquables, dates 2025-2026)
Frameworks externes
- Skyvern Issue #69 — Unable to interact with popup modals (sept. 2024, toujours actif)
- Skyvern Prompting Guide — handle modals declaratively
- Skyvern Blog Index
- browser-use Issue #1996 — Need Robust Strategy for Handling Dynamic Popups (juin 2025, fermée sans fix)
- Anthropic Computer Use API docs
- Claude Opus 4.6 system card (fév. 2026)
- OpenAI Operator system card
- OpenAI ChatGPT Agent — takeover sur login/CAPTCHA
- Anthropic Claude Computer Use on Windows (avril 2026)
- Cradle GitHub (BAAI), Cradle paper arXiv 2403.03186
Windows 11 / UAC / Hello / SmartScreen / Secure Desktop
- Microsoft UAC architecture
- Microsoft GetSystemMetrics function
- Sigma rule UAC Secure Desktop Prompt Disabled
- PyAutoGUI _pyautogui_win.py ctypes patterns
- ctypes user32 reference jerblack gist
CVE & menaces 2026
- CVE-2026-0628 Chrome Gemini Live extension takeover
- AI-powered phishing leveraging hardware access (2026)
Documents internes rpa_vision_v3
docs/recherche/AXE_D2_DIALOG_POPUP.md(parent, matrice §5 autoritative)docs/recherche/AXE_B2_VALIDATOR_PATTERN.md(interface Verdict, FailureCategory)docs/LESSONS_LEARNED_GHT_2026-05.md§🔴 (bugs P0)core/grounding/dialog_handler.py(KNOWN_DIALOGS réutilisé)core/grounding/title_verifier.py(OCR titre 45px)agent_v0/agent_v1/core/system_dialog_guard.py(multi-signal système)agent_v0/agent_v1/core/executor.py(sites d'appel_handle_popup_vlm)agent_chat/gesture_catalog.py(seul "réflexe système" autorisé)memory/feedback_popup_vlm.md,feedback_100pct_visual.md,feedback_lea_reflexes_catalog.md,feedback_auth_dialogs_runtime.md,feedback_phash_vs_dialog_in_vm.md
13. Hors-périmètre — questions à valider Dom avant action
- Décision suppression
_handle_possible_popup: confirmer (grep + retrait, créer DETTE pour traçabilité). - Choix modèle VLM fallback :
qwen3-vl:8bretenu (cohérent §2.4 synthèse) mais à benchmarker sur 10 captures de dialogs (fixture). - Politique rétention RGPD/HDS screenshots
DialogEvent: par défautdata/runner_captures/dialogs/<session>/<event_id>.png, purge après ACK serveur ou TTL 30 j ? Aligner avecfeedback_capture_purge_policy.md. - Workflow
declared_dialogs: extension VWB pour permettre au designer de déclarer "à cette étape, autoriser le micro". Format JSON suggéré :{"browser_permission": {"action": "allow", "label": "Autoriser"}}. À spécifier avec frontend VWB. - Synergie AXE_B1 (watchdog transport) : si une action est en
_retry_pendingcôté serveur et qu'un modal apparaît côté Léa pendant l'attente, le watchdog doit-il propager l'event ? Couplage à clarifier. - Bench latence empirique : valider
ChangeDetector < 50 mssur capture réelle 2560×1600 (Demo_urgence_3_db). Si dépassement, downscale 1/4 avant diff numpy.
Document de recherche. Lecture seule sur code existant. Suite = décision Dom + chirurgie itérative supervisée (CLAUDE.md projet).