From e66629ce1a191b8e96d051d6ec94042dc7b09b26 Mon Sep 17 00:00:00 2001 From: Dom Date: Fri, 10 Apr 2026 14:25:40 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20filtre=20UIA-aware=20+=20polling=20pr?= =?UTF-8?q?=C3=A9-v=C3=A9rif=20tol=C3=A9rant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent_v0/agent_v1/core/executor.py | 86 +++++++++++++------- core/workflow/ir_builder.py | 122 ++++++++++++++++++++++++++--- 2 files changed, 168 insertions(+), 40 deletions(-) diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py index 5d8a642d9..d68f572d5 100644 --- a/agent_v0/agent_v1/core/executor.py +++ b/agent_v0/agent_v1/core/executor.py @@ -526,38 +526,66 @@ class ActionExecutorV1: ) if expected_title and expected_title != "unknown_window": 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 - is_lea_window = est_fenetre_lea(current_title) - if not title_match and not is_lea_window: - logger.warning( - f"[LEA] Fenêtre incorrecte : attendu '{expected_title}', " - f"actuel '{current_title}'" + # Polling court pour laisser le temps à la fenêtre de + # se stabiliser (évite les faux négatifs sur transitions + # rapides : menu qui se ferme, taskbar qui perd le focus, etc.) + 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}'") - # Notification utilisateur en français naturel - 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 - elif is_lea_window: - logger.info("[LEA] Fenêtre de Léa détectée — ignorée, on continue") + if title_match: + break + # Sinon on retente un peu au cas où la fenêtre + # est en cours de transition + time.sleep(0.3) + + if not title_match: + if is_lea_window: + # Si après 5 essais on est encore sur Léa, + # on ignore (l'utilisateur a Léa au premier plan) + 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: logger.info(f"[LEA] Pré-vérif OK : '{current_title}'") diff --git a/core/workflow/ir_builder.py b/core/workflow/ir_builder.py index 5621f1ad7..9303177c1 100644 --- a/core/workflow/ir_builder.py +++ b/core/workflow/ir_builder.py @@ -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