fix: filtre UIA-aware + polling pré-vérif tolérant
Filtre d'événements parasites basé sur la CIBLE UIA : - Un clic n'est filtré que si son uia_snapshot indique que l'élément cliqué (ou un parent) est dans la fenêtre de Léa. - Avant : on filtrait sur window.title qui pouvait être "Lea" même quand le clic visait la taskbar (Léa au premier plan). - Après : on regarde où va VRAIMENT le clic via parent_path UIA. Extraction du expected_window depuis le parent_path UIA : - Priorité au nom de la fenêtre racine du parent_path (plus fiable). - Fallback sur window.title si pas de snapshot UIA ou pas de racine. - Les fenêtres Léa sont neutralisées (effective_title=""). Pré-vérif avec polling tolérant (executor.py) : - 5 tentatives avec 300ms entre chaque (total 1.5s max). - Ignore les transitions "unknown_window" et fenêtre Léa. - Évite les faux négatifs sur fenêtres en cours de changement. Note : le filtrage reste basé sur des heuristiques. Un tri intelligent par gemma4 au build reste à implémenter pour gérer les workflows enregistrés avec des actions parasites (mail, chat, etc.). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -526,38 +526,66 @@ class ActionExecutorV1:
|
|||||||
)
|
)
|
||||||
if expected_title and expected_title != "unknown_window":
|
if expected_title and expected_title != "unknown_window":
|
||||||
from ..window_info_crossplatform import get_active_window_info
|
from ..window_info_crossplatform import get_active_window_info
|
||||||
current_info = get_active_window_info()
|
|
||||||
current_title = current_info.get("title", "")
|
|
||||||
|
|
||||||
current_app = _app_name(current_title)
|
|
||||||
expected_app = _app_name(expected_title)
|
|
||||||
title_match = (
|
|
||||||
current_app == expected_app
|
|
||||||
or expected_title.lower() in current_title.lower()
|
|
||||||
or current_title.lower() in expected_title.lower()
|
|
||||||
)
|
|
||||||
# Ignorer la fenêtre de Léa elle-même (overlay agent)
|
|
||||||
# On utilise `messages.est_fenetre_lea` centralisé pour la
|
|
||||||
# cohérence avec les autres modules (tests, activity panel).
|
|
||||||
from ..ui.messages import est_fenetre_lea
|
from ..ui.messages import est_fenetre_lea
|
||||||
is_lea_window = est_fenetre_lea(current_title)
|
|
||||||
|
|
||||||
if not title_match and not is_lea_window:
|
# Polling court pour laisser le temps à la fenêtre de
|
||||||
logger.warning(
|
# se stabiliser (évite les faux négatifs sur transitions
|
||||||
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
|
# rapides : menu qui se ferme, taskbar qui perd le focus, etc.)
|
||||||
f"actuel '{current_title}'"
|
current_title = ""
|
||||||
|
title_match = False
|
||||||
|
is_lea_window = False
|
||||||
|
for attempt in range(5):
|
||||||
|
current_info = get_active_window_info()
|
||||||
|
current_title = current_info.get("title", "")
|
||||||
|
|
||||||
|
# Si on tombe sur Léa elle-même → on attend un peu
|
||||||
|
if est_fenetre_lea(current_title):
|
||||||
|
is_lea_window = True
|
||||||
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Si on tombe sur unknown_window → on attend aussi
|
||||||
|
if not current_title or current_title == "unknown_window":
|
||||||
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_app = _app_name(current_title)
|
||||||
|
expected_app = _app_name(expected_title)
|
||||||
|
title_match = (
|
||||||
|
current_app == expected_app
|
||||||
|
or expected_title.lower() in current_title.lower()
|
||||||
|
or current_title.lower() in expected_title.lower()
|
||||||
)
|
)
|
||||||
print(f" [PRÉ-VÉRIF] STOP — fenêtre '{current_title}' ≠ attendu '{expected_title}'")
|
if title_match:
|
||||||
# Notification utilisateur en français naturel
|
break
|
||||||
try:
|
# Sinon on retente un peu au cas où la fenêtre
|
||||||
self.notifier.replay_wrong_window(current_title, expected_title)
|
# est en cours de transition
|
||||||
except Exception:
|
time.sleep(0.3)
|
||||||
pass
|
|
||||||
result["success"] = False
|
if not title_match:
|
||||||
result["error"] = f"Fenêtre incorrecte: '{current_title}' (attendu: '{expected_title}')"
|
if is_lea_window:
|
||||||
return result
|
# Si après 5 essais on est encore sur Léa,
|
||||||
elif is_lea_window:
|
# on ignore (l'utilisateur a Léa au premier plan)
|
||||||
logger.info("[LEA] Fenêtre de Léa détectée — ignorée, on continue")
|
logger.info("[LEA] Fenêtre de Léa persistante — ignorée, on continue")
|
||||||
|
elif not current_title or current_title == "unknown_window":
|
||||||
|
# unknown_window persistant : on continue avec un
|
||||||
|
# warning, UIA décidera peut-être
|
||||||
|
logger.warning(
|
||||||
|
f"[LEA] Fenêtre active inconnue — on tente quand même"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', "
|
||||||
|
f"actuel '{current_title}'"
|
||||||
|
)
|
||||||
|
print(f" [PRÉ-VÉRIF] STOP — fenêtre '{current_title}' ≠ attendu '{expected_title}'")
|
||||||
|
try:
|
||||||
|
self.notifier.replay_wrong_window(current_title, expected_title)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = f"Fenêtre incorrecte: '{current_title}' (attendu: '{expected_title}')"
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
|
logger.info(f"[LEA] Pré-vérif OK : '{current_title}'")
|
||||||
|
|
||||||
|
|||||||
@@ -121,13 +121,60 @@ class IRBuilder:
|
|||||||
return ir
|
return ir
|
||||||
|
|
||||||
def _filter_events(self, events: List[Dict]) -> List[Dict]:
|
def _filter_events(self, events: List[Dict]) -> List[Dict]:
|
||||||
"""Filtrer les événements parasites (heartbeat, focus_change, etc.)."""
|
"""Filtrer les événements parasites.
|
||||||
|
|
||||||
|
Exclusions :
|
||||||
|
1. Types d'événements de bruit (heartbeat, focus_change, action_result)
|
||||||
|
2. Clics dont la CIBLE UIA est dans Léa elle-même
|
||||||
|
(via uia_snapshot.parent_path — on vérifie où va le clic, pas d'où
|
||||||
|
il vient). Un clic "sur la taskbar" peut avoir window.title="Léa"
|
||||||
|
si Léa avait le focus, mais sa cible UIA est la taskbar.
|
||||||
|
"""
|
||||||
ignored_types = {"heartbeat", "focus_change", "action_result", "window_focus_change"}
|
ignored_types = {"heartbeat", "focus_change", "action_result", "window_focus_change"}
|
||||||
|
lea_markers = (
|
||||||
|
"léa", "lea -", "léa -", "lea —", "léa —",
|
||||||
|
"lea assistante", "léa assistante",
|
||||||
|
"agent v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _uia_target_is_lea(uia_snapshot: dict) -> bool:
|
||||||
|
"""L'élément UIA cliqué est-il dans la fenêtre de Léa ?"""
|
||||||
|
if not uia_snapshot:
|
||||||
|
return False
|
||||||
|
# Vérifier le nom de l'élément lui-même
|
||||||
|
name = (uia_snapshot.get("name", "") or "").lower()
|
||||||
|
if any(m in name for m in lea_markers):
|
||||||
|
return True
|
||||||
|
# Vérifier les parents
|
||||||
|
for parent in uia_snapshot.get("parent_path", []):
|
||||||
|
p_name = (parent.get("name", "") or "").lower()
|
||||||
|
if any(m in p_name for m in lea_markers):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
filtered_lea = 0
|
||||||
for raw_evt in events:
|
for raw_evt in events:
|
||||||
evt = raw_evt.get("event", raw_evt)
|
evt = raw_evt.get("event", raw_evt)
|
||||||
if evt.get("type", "") not in ignored_types:
|
evt_type = evt.get("type", "")
|
||||||
result.append(evt)
|
if evt_type in ignored_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filtrer uniquement les clics dont la CIBLE est dans Léa
|
||||||
|
# (pas les clics depuis Léa vers l'extérieur)
|
||||||
|
if evt_type == "mouse_click":
|
||||||
|
uia = evt.get("uia_snapshot") or {}
|
||||||
|
if _uia_target_is_lea(uia):
|
||||||
|
filtered_lea += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(evt)
|
||||||
|
|
||||||
|
if filtered_lea > 0:
|
||||||
|
logger.info(
|
||||||
|
f"IRBuilder: {filtered_lea} clic(s) filtré(s) "
|
||||||
|
f"(cible UIA dans la fenêtre Léa)"
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _attach_window_expectations(self, ir: WorkflowIR, events: List[Dict]) -> None:
|
def _attach_window_expectations(self, ir: WorkflowIR, events: List[Dict]) -> None:
|
||||||
@@ -143,20 +190,73 @@ class IRBuilder:
|
|||||||
- expected_window_before : titre de la fenêtre AU MOMENT du clic
|
- expected_window_before : titre de la fenêtre AU MOMENT du clic
|
||||||
- expected_window_after : titre de la fenêtre du PROCHAIN click
|
- expected_window_after : titre de la fenêtre du PROCHAIN click
|
||||||
|
|
||||||
Les fenêtres génériques (unknown_window, vide) sont ignorées.
|
Filtre critique : la fenêtre de Léa elle-même n'est JAMAIS une
|
||||||
|
fenêtre cible valide (c'est l'overlay agent, pas l'app métier).
|
||||||
|
Les fenêtres "unknown_window" et les titres vides sont ignorés.
|
||||||
"""
|
"""
|
||||||
|
def _is_valid_target_window(title: str) -> bool:
|
||||||
|
"""Un titre de fenêtre est valide comme expected_window_* si :
|
||||||
|
- non vide, non "unknown_window"
|
||||||
|
- pas la fenêtre de Léa elle-même
|
||||||
|
"""
|
||||||
|
if not title or title == "unknown_window":
|
||||||
|
return False
|
||||||
|
title_lower = title.lower()
|
||||||
|
lea_markers = (
|
||||||
|
"léa", "lea -", "léa -", "lea —", "léa —",
|
||||||
|
"lea assistante", "léa assistante",
|
||||||
|
"agent v1",
|
||||||
|
)
|
||||||
|
for marker in lea_markers:
|
||||||
|
if marker in title_lower:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract_uia_root_window(uia_snapshot: dict) -> str:
|
||||||
|
"""Extraire le nom de la fenêtre racine depuis un snapshot UIA.
|
||||||
|
|
||||||
|
Le parent_path contient la hiérarchie de l'élément cliqué.
|
||||||
|
La première entrée avec control_type="fenêtre" est la fenêtre
|
||||||
|
qui CONTIENT l'élément cliqué — c'est la vraie cible.
|
||||||
|
"""
|
||||||
|
if not uia_snapshot:
|
||||||
|
return ""
|
||||||
|
for parent in uia_snapshot.get("parent_path", []):
|
||||||
|
ct = (parent.get("control_type", "") or "").lower()
|
||||||
|
if ct in ("fenêtre", "window"):
|
||||||
|
name = (parent.get("name", "") or "").strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
return ""
|
||||||
|
|
||||||
# Extraire la séquence des événements actionables avec leurs titres
|
# Extraire la séquence des événements actionables avec leurs titres
|
||||||
|
# Source de vérité pour les clics : parent_path UIA (où va vraiment
|
||||||
|
# le clic), sinon window.title (fallback).
|
||||||
|
# Pour les type/key_combo : window.title uniquement.
|
||||||
event_sequence: List[Dict[str, Any]] = []
|
event_sequence: List[Dict[str, Any]] = []
|
||||||
for evt in events:
|
for evt in events:
|
||||||
t = evt.get("type", "")
|
t = evt.get("type", "")
|
||||||
if t not in ("mouse_click", "text_input", "key_combo", "key_press", "scroll"):
|
if t not in ("mouse_click", "text_input", "key_combo", "key_press", "scroll"):
|
||||||
continue
|
continue
|
||||||
title = evt.get("window", {}).get("title", "") or ""
|
|
||||||
event_sequence.append({"type": t, "title": title})
|
# Titre de référence : priorité à la cible UIA pour les clics
|
||||||
|
effective_title = ""
|
||||||
|
if t == "mouse_click":
|
||||||
|
uia = evt.get("uia_snapshot") or {}
|
||||||
|
uia_root = _extract_uia_root_window(uia)
|
||||||
|
if uia_root and _is_valid_target_window(uia_root):
|
||||||
|
effective_title = uia_root
|
||||||
|
|
||||||
|
# Fallback sur window.title
|
||||||
|
if not effective_title:
|
||||||
|
raw_title = evt.get("window", {}).get("title", "") or ""
|
||||||
|
if _is_valid_target_window(raw_title):
|
||||||
|
effective_title = raw_title
|
||||||
|
|
||||||
|
event_sequence.append({"type": t, "title": effective_title})
|
||||||
|
|
||||||
# Aligner avec les actions du workflow
|
# Aligner avec les actions du workflow
|
||||||
# Les actions dans le workflow sont dans le même ordre que les événements
|
flat_actions: List[tuple] = []
|
||||||
flat_actions: List[tuple] = [] # (step_idx, action_idx, action)
|
|
||||||
for si, step in enumerate(ir.steps):
|
for si, step in enumerate(ir.steps):
|
||||||
for ai, action in enumerate(step.actions):
|
for ai, action in enumerate(step.actions):
|
||||||
if action.type in ("click", "type", "key_combo"):
|
if action.type in ("click", "type", "key_combo"):
|
||||||
@@ -168,14 +268,14 @@ class IRBuilder:
|
|||||||
for i in range(n):
|
for i in range(n):
|
||||||
si, ai, action = flat_actions[i]
|
si, ai, action = flat_actions[i]
|
||||||
title_now = event_sequence[i]["title"]
|
title_now = event_sequence[i]["title"]
|
||||||
if title_now and title_now != "unknown_window":
|
if title_now:
|
||||||
action.expected_window_before = title_now
|
action.expected_window_before = title_now
|
||||||
|
|
||||||
# Chercher le prochain événement avec un titre valide
|
# Chercher le prochain événement avec un titre valide
|
||||||
|
# Et qui est DIFFÉRENT du titre actuel (sinon pas de transition à vérifier)
|
||||||
for j in range(i + 1, len(event_sequence)):
|
for j in range(i + 1, len(event_sequence)):
|
||||||
next_title = event_sequence[j]["title"]
|
next_title = event_sequence[j]["title"]
|
||||||
if next_title and next_title != "unknown_window":
|
if next_title and next_title != title_now:
|
||||||
# Ne pas mettre "after" si c'est le même titre (pas de transition)
|
|
||||||
action.expected_window_after = next_title
|
action.expected_window_after = next_title
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user