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:
Dom
2026-04-10 14:25:40 +02:00
parent cecdf417b7
commit e66629ce1a
2 changed files with 168 additions and 40 deletions

View File

@@ -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}'")

View File

@@ -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'
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