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

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