diff --git a/core/execution/observe_reason_act.py b/core/execution/observe_reason_act.py index ee4616683..88bec871a 100644 --- a/core/execution/observe_reason_act.py +++ b/core/execution/observe_reason_act.py @@ -1313,6 +1313,26 @@ Règles: # Stocker le pHash post-action pour le réflexe check du step suivant self._last_post_phash = post.phash + # --- 4b. Vérification titre OCR (non-bloquante, ~120ms) --- + _action_type = step.get('action_type', '') + if _action_type in ('double_click_anchor', 'click_anchor') and pre.screenshot and post.screenshot: + try: + from core.grounding.title_verifier import TitleVerifier + _tv = TitleVerifier() + _tv_result = _tv.verify_action(pre.screenshot, post.screenshot, _action_type) + if not _tv_result['success']: + print(f"⚠️ [ORA/titre] {_tv_result['reason']} → retry") + # Retry : recliquer + time.sleep(0.5) + self.act(decision, step) + time.sleep(0.3) + post = self.observe() + self._last_post_phash = post.phash + elif _tv_result['changed']: + print(f"✅ [ORA/titre] '{_tv_result['title_after'][:40]}'") + except Exception as _tv_err: + print(f"⚠️ [ORA/titre] Erreur: {_tv_err}") + # --- 5. Vérifier --- verification = self.verify(pre, post, decision) diff --git a/core/grounding/title_verifier.py b/core/grounding/title_verifier.py new file mode 100644 index 000000000..dce14b828 --- /dev/null +++ b/core/grounding/title_verifier.py @@ -0,0 +1,158 @@ +""" +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)', + } + + def _get_ocr(self): + """Lazy load de la fonction OCR.""" + if self._ocr_fn is not None: + return self._ocr_fn + + 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: + try: + from core.extraction.field_extractor import FieldExtractor + extractor = FieldExtractor() + self._ocr_fn = extractor.extract_text_from_image + return self._ocr_fn + except ImportError: + return None