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>
257 lines
10 KiB
Python
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
|