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:
@@ -121,13 +121,60 @@ class IRBuilder:
|
||||
return ir
|
||||
|
||||
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"}
|
||||
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 = []
|
||||
filtered_lea = 0
|
||||
for raw_evt in events:
|
||||
evt = raw_evt.get("event", raw_evt)
|
||||
if evt.get("type", "") not in ignored_types:
|
||||
result.append(evt)
|
||||
evt_type = evt.get("type", "")
|
||||
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
|
||||
|
||||
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_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
|
||||
# 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]] = []
|
||||
for evt in events:
|
||||
t = evt.get("type", "")
|
||||
if t not in ("mouse_click", "text_input", "key_combo", "key_press", "scroll"):
|
||||
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
|
||||
# Les actions dans le workflow sont dans le même ordre que les événements
|
||||
flat_actions: List[tuple] = [] # (step_idx, action_idx, action)
|
||||
flat_actions: List[tuple] = []
|
||||
for si, step in enumerate(ir.steps):
|
||||
for ai, action in enumerate(step.actions):
|
||||
if action.type in ("click", "type", "key_combo"):
|
||||
@@ -168,14 +268,14 @@ class IRBuilder:
|
||||
for i in range(n):
|
||||
si, ai, action = flat_actions[i]
|
||||
title_now = event_sequence[i]["title"]
|
||||
if title_now and title_now != "unknown_window":
|
||||
if title_now:
|
||||
action.expected_window_before = title_now
|
||||
|
||||
# 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)):
|
||||
next_title = event_sequence[j]["title"]
|
||||
if next_title and next_title != "unknown_window":
|
||||
# Ne pas mettre "after" si c'est le même titre (pas de transition)
|
||||
if next_title and next_title != title_now:
|
||||
action.expected_window_after = next_title
|
||||
break
|
||||
|
||||
|
||||
Reference in New Issue
Block a user