""" core/grounding/title_verifier.py — Vérification post-action par titre de fenêtre Après chaque action (clic, double-clic), vérifie que la fenêtre active a changé de manière attendue en lisant le titre via OCR sur un crop de 45px en haut de l'écran. Léger (~120ms), non-bloquant (échec = warning + retry, pas stop). Utilisation : from core.grounding.title_verifier import TitleVerifier verifier = TitleVerifier() title = verifier.read_title(screenshot_pil) changed = verifier.has_title_changed(title_before, title_after) """ from __future__ import annotations import time from difflib import SequenceMatcher from typing import Optional class TitleVerifier: """Vérifie le titre de la fenêtre active via OCR sur crop.""" # Hauteur du crop pour la barre de titre Windows TITLE_BAR_HEIGHT = 45 def __init__(self): self._ocr_fn = None # Lazy load def read_title(self, screenshot_pil) -> str: """Lit le titre de la fenêtre active via OCR sur le crop supérieur. Args: screenshot_pil: Image PIL du screenshot complet. Returns: Texte du titre (peut être vide si OCR échoue). """ t0 = time.time() try: w, h = screenshot_pil.size # Crop la barre de titre (45px du haut) title_crop = screenshot_pil.crop((0, 0, w, min(self.TITLE_BAR_HEIGHT, h))) # OCR sur le petit crop ocr_fn = self._get_ocr() if ocr_fn is None: return "" text = ocr_fn(title_crop) dt = (time.time() - t0) * 1000 # Nettoyer le texte title = text.strip() if text else "" if title: print(f"📋 [TitleVerify] Titre lu: '{title[:60]}' ({dt:.0f}ms)") return title except Exception as e: print(f"⚠️ [TitleVerify] Erreur lecture titre: {e}") return "" def has_title_changed(self, title_before: str, title_after: str) -> bool: """Vérifie si le titre a changé de manière significative.""" if not title_before and not title_after: return False if not title_before or not title_after: return True # Un des deux est vide = changement # Comparaison fuzzy — les titres peuvent avoir des variations mineures ratio = SequenceMatcher(None, title_before.lower(), title_after.lower()).ratio() return ratio < 0.85 # Changement si < 85% similaire def verify_action( self, screenshot_before, screenshot_after, action_type: str, ) -> dict: """Vérifie qu'une action a produit l'effet attendu sur le titre. Args: screenshot_before: Screenshot PIL avant l'action. screenshot_after: Screenshot PIL après l'action. action_type: Type d'action ("double_click", "click", "type", "hotkey"). Returns: Dict avec success, title_before, title_after, changed. """ # Les actions qui ne changent pas le titre if action_type in ('type_text', 'keyboard_shortcut', 'wait_for_anchor', 'hover'): return { 'success': True, 'title_before': '', 'title_after': '', 'changed': False, 'reason': f"Action '{action_type}' — vérification titre non requise", } title_before = self.read_title(screenshot_before) title_after = self.read_title(screenshot_after) changed = self.has_title_changed(title_before, title_after) # Pour un double-clic (ouverture fichier/dossier), le titre DOIT changer # Mais seulement si les titres lus sont significatifs (> 3 chars) # docTR sur un crop 45px dans une VM peut donner du bruit ('o', 'a', etc.) if action_type in ('double_click_anchor',) and not changed: if len(title_before) > 3 and len(title_after) > 3: return { 'success': False, 'title_before': title_before, 'title_after': title_after, 'changed': False, 'reason': f"Double-clic sans changement de titre ('{title_after[:40]}')", } # Titres trop courts = bruit OCR, on ne peut pas conclure return { 'success': True, 'title_before': title_before, 'title_after': title_after, 'changed': False, 'reason': f"Titre trop court pour vérifier ('{title_after}')", } # Pour un clic simple, le changement est optionnel return { 'success': True, 'title_before': title_before, 'title_after': title_after, 'changed': changed, 'reason': 'Titre changé' if changed else 'Titre identique (acceptable)', } _easyocr_reader = None # Singleton partagé def _get_ocr(self): """Lazy load de la fonction OCR (EasyOCR prioritaire, fallback docTR).""" if self._ocr_fn is not None: return self._ocr_fn # EasyOCR (rapide, bonne qualité GUI) try: import easyocr import numpy as np if TitleVerifier._easyocr_reader is None: TitleVerifier._easyocr_reader = easyocr.Reader( ['fr', 'en'], gpu=True, verbose=False ) def _easyocr_extract_text(img): results = TitleVerifier._easyocr_reader.readtext(np.array(img)) return ' '.join(r[1] for r in results if r[1].strip()) self._ocr_fn = _easyocr_extract_text return self._ocr_fn except ImportError: pass # Fallback docTR try: import sys sys.path.insert(0, 'visual_workflow_builder/backend') from services.ocr_service import ocr_extract_text self._ocr_fn = ocr_extract_text return self._ocr_fn except ImportError: return None