Files
rpa_vision_v3/core/grounding/dialog_handler.py
Dom 487bcb8618 feat(execution): cascade post-raccourci pilotée par DialogHandler/OCR
Le pHash global 8x8 sur écran 1920x1080 ne détecte pas l'ouverture d'un
dialog modal dans une VM QEMU (un dialog 800x500 couvre ~3 pixels pHash,
distance Hamming typique = 1-2, sous le seuil de 3). Découvert sur Win11/
Notepad : Ctrl+Shift+S ouvrait bien le dialog mais Léa abortait à tort.

_handle_post_shortcut() poll désormais DialogHandler.handle_if_dialog()
toutes les 500ms (EasyOCR + KNOWN_DIALOGS). 8s pour le premier dialog,
3s de stabilité entre dialogs successifs, 60s budget total.

KNOWN_DIALOGS réordonné : popups modaux (confirmer/remplacer/écraser)
prioritaires sur fenêtres parents (enregistrer sous/save as) car l'OCR
full-screen capte les deux simultanément.

DialogHandler bascule sur UITarsGrounder subprocess one-shot (au lieu
du serveur HTTP localhost:8200 qui n'existait plus). InfiGUI worker,
think_arbiter et ui_tars_grounder alignés sur le même contrat.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-26 20:19:39 +02:00

257 lines
10 KiB
Python

"""
core/grounding/dialog_handler.py — Gestion intelligente des dialogues
Quand un dialogue inattendu apparaît (pHash change après une action) :
1. Lire le titre de la fenêtre (EasyOCR crop 45px, ~130ms)
2. Si titre connu (Enregistrer sous, Confirmer, etc.) → action connue
3. Demander à InfiGUI de cliquer sur le bon bouton (~3s)
4. Vérifier que le dialogue a disparu (pHash)
Pas de patterns prédéfinis pour les boutons. InfiGUI comprend
visuellement le dialogue et clique au bon endroit.
Utilisation :
from core.grounding.dialog_handler import DialogHandler
handler = DialogHandler()
result = handler.handle_if_dialog(screenshot_pil)
if result['handled']:
print(f"Dialogue '{result['title']}' géré → {result['action']}")
"""
from __future__ import annotations
import time
from typing import Any, Dict, Optional
# Titres connus → quelle action demander à InfiGUI.
#
# IMPORTANT — ordre du dict = priorité de matching.
# L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup
# modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière
# "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les
# fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas
# le focus.
KNOWN_DIALOGS = {
# ── Popups modaux de confirmation (priorité HAUTE) ──────────────────
"voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"},
"do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"},
"already exists": {"target": "Yes", "description": "Click Yes, the file already exists"},
"remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"},
"replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"},
"écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"},
"overwrite": {"target": "Yes", "description": "Click Yes to overwrite"},
"confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"},
"confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"},
# ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ───────
"erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"},
"error": {"target": "OK", "description": "Click OK to close the error message"},
"avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"},
"warning": {"target": "OK", "description": "Click OK to close the warning"},
# ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─
"voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"},
"do you want to save": {"target": "Save", "description": "Click Save to save changes"},
"enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"},
"save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"},
}
class DialogHandler:
"""Gestion intelligente des dialogues via titre + InfiGUI."""
def __init__(self):
self._easyocr_reader = None
def handle_if_dialog(
self,
screenshot_pil,
previous_title: str = "",
) -> Dict[str, Any]:
"""Vérifie si l'écran montre un dialogue et le gère.
Args:
screenshot_pil: Screenshot PIL actuel.
previous_title: Titre de la fenêtre avant l'action (pour comparaison).
Returns:
Dict avec 'handled' (bool), 'title', 'action', 'position'.
"""
t0 = time.time()
# 1. Lire le titre de la fenêtre
title = self._read_title(screenshot_pil)
if not title or len(title) < 3:
return {'handled': False, 'title': '', 'reason': 'Titre illisible'}
print(f"🔍 [Dialog] Titre lu: '{title}'")
# 2. Chercher si c'est un dialogue connu
matched_dialog = None
for key, action_info in KNOWN_DIALOGS.items():
if key in title.lower():
matched_dialog = (key, action_info)
break
if not matched_dialog:
# Pas un dialogue connu — le workflow continue normalement
return {'handled': False, 'title': title, 'reason': 'Pas un dialogue connu'}
dialog_key, action_info = matched_dialog
target = action_info['target']
description = action_info['description']
print(f"🧠 [Dialog] Dialogue détecté: '{dialog_key}' → clic '{target}'")
# 3. Demander à InfiGUI de cliquer sur le bouton
click_result = self._click_via_infigui(
target, description, screenshot_pil
)
dt = (time.time() - t0) * 1000
if click_result:
print(f"✅ [Dialog] Clic '{target}' à ({click_result['x']}, {click_result['y']}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}'",
'position': (click_result['x'], click_result['y']),
'time_ms': dt,
}
else:
# InfiGUI n'a pas trouvé le bouton — essayer le clic direct via OCR
print(f"⚠️ [Dialog] InfiGUI n'a pas trouvé '{target}', essai OCR direct")
ocr_result = self._click_via_ocr(target, screenshot_pil)
dt = (time.time() - t0) * 1000
if ocr_result:
print(f"✅ [Dialog] OCR clic '{target}' à ({ocr_result[0]}, {ocr_result[1]}) ({dt:.0f}ms)")
return {
'handled': True,
'title': title,
'dialog_type': dialog_key,
'action': f"click '{target}' (OCR)",
'position': ocr_result,
'time_ms': dt,
}
print(f"❌ [Dialog] Impossible de cliquer '{target}' ({dt:.0f}ms)")
return {
'handled': False,
'title': title,
'dialog_type': dialog_key,
'reason': f"Bouton '{target}' introuvable",
'time_ms': dt,
}
# ------------------------------------------------------------------
# Lecture titre
# ------------------------------------------------------------------
def _read_title(self, screenshot_pil) -> str:
"""Lit TOUT le texte visible via EasyOCR full-screen (~500ms).
En VM QEMU, la barre de titre Windows est à l'intérieur du framebuffer,
pas en haut absolu de l'écran. On fait l'OCR full-screen et on cherche
les mots-clés des dialogues connus dans le texte complet.
"""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return ""
results = reader.readtext(np.array(screenshot_pil))
full_text = ' '.join(r[1] for r in results if r[1].strip())
return full_text
except Exception as e:
print(f"⚠️ [Dialog] Erreur lecture écran: {e}")
return ""
# ------------------------------------------------------------------
# Clic via InfiGUI (serveur grounding)
# ------------------------------------------------------------------
def _click_via_infigui(
self, target: str, description: str, screenshot_pil
) -> Optional[Dict]:
"""Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton."""
try:
from core.grounding.ui_tars_grounder import UITarsGrounder
grounder = UITarsGrounder.get_instance()
result = grounder.ground(
target_text=target,
target_description=description,
screen_pil=screenshot_pil,
)
if result and result.x is not None:
import pyautogui
pyautogui.click(result.x, result.y)
return {'x': result.x, 'y': result.y}
return None
except Exception as e:
print(f"⚠️ [Dialog/InfiGUI] Erreur: {e}")
return None
# ------------------------------------------------------------------
# Clic via OCR (fallback rapide)
# ------------------------------------------------------------------
def _click_via_ocr(self, target: str, screenshot_pil) -> Optional[tuple]:
"""Cherche le bouton par OCR et clique dessus."""
try:
import numpy as np
reader = self._get_easyocr()
if reader is None:
return None
results = reader.readtext(np.array(screenshot_pil))
target_lower = target.lower()
matches = []
for (bbox_pts, text, conf) in results:
if target_lower in text.lower() or text.lower() in target_lower:
x = int(sum(p[0] for p in bbox_pts) / 4)
y = int(sum(p[1] for p in bbox_pts) / 4)
matches.append((x, y, text))
if matches:
# Prendre le match le plus bas (boutons = bas du dialogue)
best = max(matches, key=lambda m: m[1])
import pyautogui
pyautogui.click(best[0], best[1])
return (best[0], best[1])
return None
except Exception as e:
print(f"⚠️ [Dialog/OCR] Erreur: {e}")
return None
# ------------------------------------------------------------------
# EasyOCR singleton
# ------------------------------------------------------------------
def _get_easyocr(self):
if self._easyocr_reader is not None:
return self._easyocr_reader
try:
import easyocr
self._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
return self._easyocr_reader
except ImportError:
return None