docs/CARTOGRAPHY.md :
- Carte complète des 2 chemins d'exécution (Legacy vs ORA)
- 12 systèmes de grounding identifiés dont 3 morts
- Trace du champ target_text de la capture au clic
- Fonctions existantes non branchées (verify, recovery, ShadowLearningHook)
- Budget VRAM, fichiers critiques, règles de modification
Fix target_text ORA (observe_reason_act.py:217) :
- Détecte les target_text absurdes ("click_anchor")
- Appelle _describe_anchor_image() (VLM) pour décrire le crop
- Même logique que le legacy execute.py:893
Worker InfiGUI via fichiers /tmp :
- Communication par fichiers (pas subprocess pipes, pas HTTP)
- Process indépendant lancé avant le backend
- Résout le crash CUDA dans Flask/FastAPI/uvicorn
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
9.6 KiB
Python
254 lines
9.6 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
|
|
KNOWN_DIALOGS = {
|
|
"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"},
|
|
"confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"},
|
|
"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"},
|
|
"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"},
|
|
"overwrite": {"target": "Yes", "description": "Click Yes to overwrite"},
|
|
"écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"},
|
|
"already exists": {"target": "Yes", "description": "Click Yes, the file already exists"},
|
|
"existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà"},
|
|
"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"},
|
|
}
|
|
|
|
|
|
class DialogHandler:
|
|
"""Gestion intelligente des dialogues via titre + InfiGUI."""
|
|
|
|
GROUNDING_URL = "http://localhost:8200"
|
|
|
|
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 de localiser et cliquer sur le bouton."""
|
|
try:
|
|
import requests
|
|
import base64
|
|
import io
|
|
|
|
buf = io.BytesIO()
|
|
screenshot_pil.save(buf, format='JPEG', quality=85)
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
|
|
resp = requests.post(f"{self.GROUNDING_URL}/ground", json={
|
|
'target_text': target,
|
|
'target_description': description,
|
|
'image_b64': b64,
|
|
}, timeout=15)
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if data.get('x') is not None:
|
|
# Cliquer
|
|
import pyautogui
|
|
pyautogui.click(data['x'], data['y'])
|
|
return data
|
|
|
|
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
|