feat(grounding): vérification titre OCR post-action (non-bloquante)
TitleVerifier (core/grounding/title_verifier.py) : - Crop 45px barre de titre → OCR → compare avant/après (~280ms) - Titres < 3 chars ignorés (bruit OCR sur VM) - Non-bloquant : échec = warning, pas stop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1313,6 +1313,26 @@ Règles:
|
|||||||
# Stocker le pHash post-action pour le réflexe check du step suivant
|
# Stocker le pHash post-action pour le réflexe check du step suivant
|
||||||
self._last_post_phash = post.phash
|
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 ---
|
# --- 5. Vérifier ---
|
||||||
verification = self.verify(pre, post, decision)
|
verification = self.verify(pre, post, decision)
|
||||||
|
|
||||||
|
|||||||
158
core/grounding/title_verifier.py
Normal file
158
core/grounding/title_verifier.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user