snapshot: WIP 5j replay reliability (B1 watchdog + dialog handlers + grounding drift)

Snapshot avant correction du blocage relance Léa (3 incidents 24h: SSH refusé,
polls morts ×2). Point de rollback stable.

Contenu:
- agent_v1/core/executor.py: 5 patchs dialog handling (saveas drift, close_tab
  hotkey fallback, confirm_save Unicode apostrophe, foreground dialog
  recontextualization, runtime_dialog in-loop) + helpers normalize_window_hint,
  requires_post_verify_window_transition
- agent_v1/core/grounding.py: garde drift template fix (fallback_x/y plumbed)
- server_v1/replay_watchdog.py (NEW): orphan watchdog B1, scan 10s timeout 30s
- server_v1/api_stream.py: dispatched_action plumbing, watchdog lifespan,
  metrics endpoint
- server_v1/replay_engine.py: _schedule_retry préserve original_action +
  dispatched_action
- stream_processor.py: gardes _infer_tab_switch_target (no false switch_tab
  on save_as dialog open) + _attach_expected_window_before
- tests/integration: test_replay_watchdog.py (8 cas), test_stream_processor.py
- tests/unit: test_executor_verify_window_guard.py (start_button, close_tab,
  runtime_dialog, post_verify, transition fallbacks)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-24 16:48:37 +02:00
parent 5ea4960e65
commit 7df51d2c79
47 changed files with 9811 additions and 451 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,142 @@ class GroundingEngine:
"""
self._executor = executor
@staticmethod
def _should_scope_to_active_window(target_spec: Dict[str, Any]) -> bool:
"""Déterminer si le grounding doit être limité à la fenêtre active."""
if str(target_spec.get("screen_scope", "")).strip().lower() == "full_screen":
return False
by_role = str(target_spec.get("by_role", "")).strip().lower()
if by_role in {"start_button"}:
return False
return True
@staticmethod
def _targets_lea_window(target_spec: Dict[str, Any]) -> bool:
"""Déterminer si la cible pointe explicitement vers l'UI de Léa."""
try:
from ..ui.messages import est_fenetre_lea
except Exception:
return False
context_hints = target_spec.get("context_hints") or {}
hints = [
target_spec.get("window_title", ""),
context_hints.get("window_title", ""),
target_spec.get("vlm_description", ""),
target_spec.get("by_text", ""),
]
return any(est_fenetre_lea(str(hint)) for hint in hints if hint)
@staticmethod
def _is_plausible_window_rect(
rect: Optional[List[int]],
title: str,
screen_width: int,
screen_height: int,
) -> bool:
"""Valider qu'un rect actif ressemble à une vraie fenêtre utilisable.
Rejette explicitement les zones système "bar-like" (taskbar, systray)
et les titres inconnus/bruités. Le grounding ne doit jamais se
contraindre à une zone non validée.
"""
if not rect or len(rect) != 4:
return False
try:
from ..ui.messages import est_fenetre_bruit
except Exception:
def est_fenetre_bruit(_title: str) -> bool:
return not _title or _title.strip().lower() == "unknown_window"
w = rect[2] - rect[0]
h = rect[3] - rect[1]
title_clean = str(title or "").strip()
if w <= 50 or h <= 50:
return False
title_lower = title_clean.lower()
is_unknown_title = not title_clean or title_lower == "unknown_window"
if not is_unknown_title and est_fenetre_bruit(title_clean):
return False
# Une zone très plate, surtout en bas d'écran et très large, est
# typiquement une barre des tâches / systray, pas une vraie fenêtre.
# On réduit le seuil de hauteur à 120px pour ne pas rejeter les petits modaux.
is_bar_like = (
h < 120
or (w > 0.9 * screen_width and h < 0.15 * screen_height)
)
# Exception : si le titre contient un mot-clé de dialogue connu,
# on considère que c'est plausible même si c'est petit.
keywords = ["enregistrer sous", "save as", "voulez-vous", "confirm", "attention", "error", "erreur"]
if any(k in title_lower for k in keywords):
return h >= 80 # Un dialogue fait au moins 80px (titre + bouton)
return not is_bar_like
@staticmethod
def _visual_scope_hints(target_spec: Dict[str, Any]) -> List[str]:
"""Construire des indices textuels à chercher dans le crop fenêtre."""
hints: List[str] = []
raw_hints = [
target_spec.get("window_title", ""),
(target_spec.get("context_hints") or {}).get("window_title", ""),
target_spec.get("by_text", ""),
]
for raw in raw_hints:
text = str(raw or "").strip()
if not text:
continue
text = text.lstrip("*").strip()
variants = [text]
for sep in (" ", " - ", ""):
if sep in text:
variants.extend(part.strip().lstrip("*") for part in text.split(sep))
for variant in variants:
if variant and len(variant) >= 3 and variant not in hints:
hints.append(variant)
return hints
def _window_crop_matches_target_visually(
self,
screenshot_b64: str,
target_spec: Dict[str, Any],
) -> bool:
"""Vérifier visuellement qu'un crop contraint contient la bonne cible.
Principe: ne jamais faire confiance au rect système seul. Si aucun
indice textuel n'est disponible, on laisse passer le crop plausible
pour ne pas sur-bloquer les cibles purement iconiques.
"""
hints = self._visual_scope_hints(target_spec)
if not hints:
return True
finder = getattr(self._executor, "_find_text_on_screen", None)
if not callable(finder):
return True
for hint in hints:
try:
if finder(screenshot_b64, hint):
logger.info(
"Grounding fenêtre validé visuellement via '%s'",
hint,
)
return True
except Exception as e:
logger.debug("Validation visuelle du crop échouée pour '%s': %s", hint, e)
logger.info(
"Grounding plein écran : crop fenêtre rejeté par validation visuelle "
"(hints=%s)",
hints,
)
return False
def locate(
self,
server_url: str,
@@ -128,35 +264,63 @@ class GroundingEngine:
t_start = time.time()
# ── Capture contrainte à la fenêtre active ──
# Le grounding ne voit QUE la fenêtre attendue — pas la taskbar,
# pas le systray, pas les autres apps. Comme un humain qui regarde
# l'application sur laquelle il travaille.
window_rect = None
try:
from ..window_info_crossplatform import get_active_window_rect
win_info = get_active_window_rect()
if win_info and win_info.get("rect"):
r = win_info["rect"] # [left, top, right, bottom]
# Validation : fenêtre visible et pas minuscule
w = r[2] - r[0]
h = r[3] - r[1]
if w > 50 and h > 50:
window_rect = {
"left": max(0, r[0]),
"top": max(0, r[1]),
"width": min(w, screen_width),
"height": min(h, screen_height),
}
logger.info(
f"Grounding contraint à la fenêtre : "
f"{window_rect['width']}x{window_rect['height']} "
f"à ({window_rect['left']}, {window_rect['top']})"
)
except Exception as e:
logger.debug(f"Pas de window rect disponible : {e}")
active_title = ""
if self._should_scope_to_active_window(target_spec):
# ── Capture contrainte à la fenêtre active ──
# Le grounding ne voit QUE la fenêtre attendue — pas la taskbar,
# pas le systray, pas les autres apps. Comme un humain qui regarde
# l'application sur laquelle il travaille.
try:
from ..window_info_crossplatform import get_active_window_rect
from ..ui.messages import est_fenetre_lea
win_info = get_active_window_rect()
if win_info and win_info.get("rect"):
active_title = str(win_info.get("title", "") or "")
if est_fenetre_lea(active_title) and not self._targets_lea_window(target_spec):
logger.info(
"Grounding plein écran : fenêtre active Léa ignorée pour "
"cible externe (%s)",
target_spec.get("by_text", "") or target_spec.get("by_role", ""),
)
win_info = None
if win_info and win_info.get("rect"):
r = win_info["rect"] # [left, top, right, bottom]
if self._is_plausible_window_rect(r, active_title, screen_width, screen_height):
w = r[2] - r[0]
h = r[3] - r[1]
window_rect = {
"left": max(0, r[0]),
"top": max(0, r[1]),
"width": min(w, screen_width),
"height": min(h, screen_height),
}
logger.info(
f"Grounding contraint à la fenêtre : "
f"{window_rect['width']}x{window_rect['height']} "
f"à ({window_rect['left']}, {window_rect['top']})"
)
else:
logger.info(
"Grounding plein écran : rect actif rejeté "
"(title='%s', rect=%s)",
active_title,
r,
)
except Exception as e:
logger.debug(f"Pas de window rect disponible : {e}")
else:
logger.info(
"Grounding plein écran pour by_role='%s'",
target_spec.get("by_role", ""),
)
screenshot_b64 = self._capture_window_or_screen(window_rect)
if window_rect and screenshot_b64:
if not self._window_crop_matches_target_visually(screenshot_b64, target_spec):
window_rect = None
screenshot_b64 = self._capture_window_or_screen(None)
if not screenshot_b64:
return GroundingResult(
found=False, detail="Capture screenshot échouée",
@@ -186,6 +350,18 @@ class GroundingEngine:
result.elapsed_ms = (time.time() - t_start) * 1000
return result
if target_spec.get("allow_position_fallback"):
if 0.0 <= fallback_x <= 1.0 and 0.0 <= fallback_y <= 1.0:
return GroundingResult(
found=True,
x_pct=fallback_x,
y_pct=fallback_y,
method="position_fallback",
score=0.2,
detail="fallback positionnel explicite",
elapsed_ms=(time.time() - t_start) * 1000,
)
return GroundingResult(
found=False,
detail=f"Toutes les stratégies ont échoué ({', '.join(strategies)})",
@@ -258,7 +434,12 @@ class GroundingEngine:
anchor_b64 = target_spec.get("anchor_image_base64", "")
if anchor_b64:
raw = self._executor._template_match_anchor(
screenshot_b64, anchor_b64, screen_width, screen_height,
screenshot_b64,
anchor_b64,
screen_width,
screen_height,
fallback_x_pct=fallback_x,
fallback_y_pct=fallback_y,
)
if raw and raw.get("resolved"):
return GroundingResult(

View File

@@ -0,0 +1,39 @@
"""Dispatch léger du contrat enrichi de /finalize côté agent."""
from __future__ import annotations
import logging
from typing import Any, Dict
logger = logging.getLogger(__name__)
def dispatch_finalize_result(ui: Any, payload: Dict[str, Any], replay_name: str) -> None:
"""Router le résultat de /finalize vers la bonne surface UI agent."""
if not isinstance(payload, dict):
return
replay_request = payload.get("replay_request") or {}
replay_launch = payload.get("replay_launch") or {}
if replay_launch.get("status") == "started":
logger.info("Replay direct déjà lancé par le serveur après finalize")
return
if not payload.get("replay_ready") or not replay_request:
return
if replay_launch.get("status") == "failed":
logger.warning(
"Auto-replay serveur échoué après finalize, proposition manuelle"
)
if ui is None or not hasattr(ui, "offer_finalize_replay"):
logger.info("UI indisponible pour proposer un test immédiat")
return
ui.offer_finalize_replay(
replay_request,
replay_name or "la tâche que vous venez d'enregistrer",
)

View File

@@ -28,6 +28,7 @@ from .ui.chat_window import ChatWindow
from .ui.capture_server import CaptureServer
from .session.storage import SessionStorage
from .vision.capturer import VisionCapturer
from .finalize_contract import dispatch_finalize_result
# Import optionnel du client serveur (pour le chat et les workflows)
# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1)
@@ -80,6 +81,7 @@ class AgentV1:
self._executor = None
# Flag pour indiquer qu'un replay est en cours (eviter les conflits)
self._replay_active = False
self._last_recording_name = ""
# Etat partage entre systray et chat (source de verite unique)
self._state = AgentState()
@@ -210,12 +212,14 @@ class AgentV1:
time.sleep(30) # Vérifier toutes les 30s
def start_session(self, workflow_name):
self._last_recording_name = workflow_name
self.session_id = f"sess_{time.strftime('%Y%m%dT%H%M%S')}_{uuid.uuid4().hex[:6]}"
self.session_dir = self.storage.get_session_dir(self.session_id)
self.vision = VisionCapturer(str(self.session_dir))
self.streamer = TraceStreamer(self.session_id, machine_id=self.machine_id)
self.streamer.set_on_finalize_result(self._on_finalize_result)
self.captor = EventCaptorV1(self._on_event_bridge)
# Initialiser l'executeur partage
@@ -325,6 +329,15 @@ class AgentV1:
# pour enchainer les actions du workflow
time.sleep(0.2)
else:
if getattr(self._executor, "_replay_paused", False):
if not self._replay_active:
self._replay_active = True
self.ui.set_replay_active(True)
self._state.set_replay_active(True)
poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL)
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
continue
# Pas d'action en attente — utiliser le backoff de l'executor
# (augmente si le serveur est indisponible, reset a 1s sinon)
if self._replay_active:
@@ -429,6 +442,11 @@ class AgentV1:
f"agent_{self.user_id}"
)
def _on_finalize_result(self, payload: dict) -> None:
"""Réagir au contrat enrichi de /finalize côté agent."""
replay_name = self._last_recording_name or "la tâche que vous venez d'enregistrer"
dispatch_finalize_result(self.ui, payload, replay_name)
_last_heartbeat_hash: str = ""
def _heartbeat_loop(self):

View File

@@ -30,6 +30,7 @@ import os
import queue
import threading
import time
from typing import Callable, Optional
import requests
from PIL import Image
@@ -95,6 +96,11 @@ class TraceStreamer:
# Initialisé paresseusement pour ne pas payer le coût SQLite en dehors
# d'un streaming actif.
self._buffer: PersistentBuffer | None = None
self._on_finalize_result: Optional[Callable[[dict], None]] = None
def set_on_finalize_result(self, callback: Optional[Callable[[dict], None]]) -> None:
"""Définir un callback appelé avec le payload JSON de /finalize."""
self._on_finalize_result = callback
def _get_buffer(self) -> PersistentBuffer:
"""Retourne le buffer persistant, en l'initialisant au besoin."""
@@ -621,6 +627,14 @@ class TraceStreamer:
if resp.ok:
result = resp.json()
logger.info(f"Session finalisée: {result}")
if self._on_finalize_result is not None:
try:
self._on_finalize_result(result)
except Exception as cb_error:
logger.warning(
"Callback finalize ignoré après erreur: %s",
cb_error,
)
else:
logger.warning(f"Finalisation échouée: {resp.status_code}")
except Exception as e:

View File

@@ -158,14 +158,25 @@ class CaptureHandler(BaseHTTPRequestHandler):
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
t0 = time.perf_counter()
try:
import mss
from PIL import Image
from ..vision.capturer import (
capture_foreground_window_image,
capture_screen_image,
)
with mss.mss() as sct:
monitor = sct.monitors[1] # ecran principal
raw = sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
_monitor, img, meta = capture_screen_image()
if img is None:
img, win_meta = capture_foreground_window_image()
meta.update(win_meta)
if img is None:
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.error("Erreur capture : aucun backend exploitable (%s)", meta)
self._send_json(503, {
"error": "capture_unavailable",
"source": meta.get("backend", "unknown"),
"capture_ms": round(elapsed_ms),
"diagnostics": meta,
})
return
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
@@ -180,15 +191,22 @@ class CaptureHandler(BaseHTTPRequestHandler):
img_b64 = base64.b64encode(buf.getvalue()).decode()
elapsed_ms = (time.perf_counter() - t0) * 1000
logger.info(f"Capture {img.width}x{img.height} en {elapsed_ms:.0f}ms")
logger.info(
"Capture %sx%s via %s en %.0fms",
img.width,
img.height,
meta.get("backend", "unknown"),
elapsed_ms,
)
self._send_json(200, {
"image": img_b64,
"width": img.width,
"height": img.height,
"format": "jpeg",
"source": "windows_live",
"source": meta.get("backend", "windows_live"),
"capture_ms": round(elapsed_ms),
"diagnostics": meta,
})
except Exception as e:

View File

@@ -894,6 +894,34 @@ class ChatWindow:
except Exception:
logger.debug("clear chat history silenced", exc_info=True)
@staticmethod
def _compute_paused_bubble_height(reason_str: str) -> tuple:
"""Calcule la hauteur du Text (en lignes) + si une scrollbar est
nécessaire pour le message d'une bulle paused.
Patch 22 mai 2026 — fix troncature : on prend en compte les \\n
explicites (les `reason` serveur peuvent lister plusieurs
candidats avec un saut de ligne par item) en plus de la longueur
en caractères, et on active la scrollbar dès que le cap est
atteint pour éviter que du contenu disparaisse silencieusement.
Retourne ``(height_lines, needs_scrollbar)``.
"""
if not reason_str:
return 2, False
text = str(reason_str)
# Estimation : ~60 chars/ligne effectifs avec wraplength.
wrapped_lines = (len(text) // 60) + 1
explicit_lines = text.count("\n") + 1
estimated = max(wrapped_lines, explicit_lines)
cap = 12
height = max(2, min(cap, estimated))
# Scrollbar dès que le cap est atteint OU contenu long (filet
# textuel : ≥ 200 chars implique souvent un débordement visuel
# même quand les lignes brutes sont peu nombreuses).
needs_scroll = (estimated >= cap) or (len(text) > 200)
return height, needs_scroll
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
@@ -923,22 +951,23 @@ class ChatWindow:
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
# Patch 22 mai 2026 : prendre en compte les \n explicites (titres
# fenêtre / patterns) et activer la scrollbar dès que le cap de
# hauteur est atteint — sinon les bulles de pause étaient
# tronquées visuellement sans aucun ascenseur visible.
reason_str = str(reason)
# Estimation simple : ~70 chars/ligne avec wraplength
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
height_lines, needs_scroll = self._compute_paused_bubble_height(reason_str)
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_text = tk.Text(
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=approx_lines,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=height_lines,
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
)
reason_text.insert("1.0", reason_str)
reason_text.configure(state="disabled")
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Scrollbar interne uniquement si le contenu déborde (long messages)
if len(reason_str) > 280:
if needs_scroll:
reason_scroll = tk.Scrollbar(
msg_frame, orient=tk.VERTICAL,
command=reason_text.yview, width=8,
@@ -1019,27 +1048,40 @@ class ChatWindow:
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
arrière-plan ; si la connexion est tombée, on log un warning visible.
Fallback HTTP 22 mai 2026 : si le bus SocketIO est déconnecté, on
retombe sur un POST direct ``/replay/{id}/resume`` via
``server_client``. Si les deux échouent, on ré-active les boutons
et on saute l'auto-hide pour permettre à l'utilisateur de
réessayer manuellement (sinon le replay reste figé côté serveur).
"""
if not replay_id:
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
return
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.resume_replay(replay_id)
# Feedback immédiat : disable boutons + message
emitted, channel = self._dispatch_paused_action(
replay_id,
bus_method="resume_replay",
client_method="resume_replay",
)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("→ Reprise demandée…")
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
else:
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
logger.warning("paused_bubble: bus déconnecté, resume non émis")
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
logger.info(
"paused_bubble: replay_resume émis pour %s via %s",
replay_id, channel,
)
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
return
# Échec sur les deux canaux : laisser l'utilisateur réessayer.
self._update_paused_feedback("⚠ Serveur injoignable — réessayez")
self._enable_paused_buttons()
logger.warning(
"paused_bubble: bus et HTTP indisponibles, resume non émis "
"pour %s", replay_id,
)
def _on_paused_abort(self, replay_id: str) -> None:
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
@@ -1048,17 +1090,30 @@ class ChatWindow:
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
locale la bulle restait coincée — c'était la cause de "Annuler ne
fonctionne pas" rapportée par Dom).
Fallback HTTP 22 mai 2026 : symétrique de ``_on_paused_resume`` —
si le bus est déconnecté, POST direct ``/replay/{id}/cancel``.
L'abort ferme la bulle localement quelle que soit l'issue (l'état
serveur sera réconcilié au prochain poll /replay/next).
"""
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.abort_replay(replay_id)
emitted, channel = self._dispatch_paused_action(
replay_id,
bus_method="abort_replay",
client_method="abort_replay",
)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("✗ Annulé")
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
logger.info(
"paused_bubble: replay_abort émis pour %s via %s",
replay_id, channel,
)
else:
self._update_paused_feedback("✗ Annulé (bus indisponible)")
logger.warning("paused_bubble: bus déconnecté, abort non émis")
self._update_paused_feedback("✗ Annulé (serveur injoignable)")
logger.warning(
"paused_bubble: bus et HTTP indisponibles, abort non émis "
"pour %s", replay_id,
)
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
self._close_active_paused_bubble(reason="abort_local")
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
@@ -1068,6 +1123,34 @@ class ChatWindow:
except Exception:
logger.debug("auto-hide on abort silenced", exc_info=True)
def _dispatch_paused_action(
self,
replay_id: str,
bus_method: str,
client_method: str,
) -> tuple:
"""Envoyer une action de bulle paused via bus puis fallback HTTP.
Retourne ``(emitted, channel)`` où ``channel`` vaut ``"bus"``,
``"http"`` ou ``""`` (aucun chemin n'a abouti).
"""
if self._bus is not None and getattr(self._bus, "connected", False):
try:
if getattr(self._bus, bus_method)(replay_id):
return True, "bus"
except Exception:
logger.debug("paused_bubble: bus %s silenced", bus_method, exc_info=True)
if self._server_client is not None and hasattr(self._server_client, client_method):
try:
if getattr(self._server_client, client_method)(replay_id):
return True, "http"
except Exception:
logger.debug(
"paused_bubble: server_client %s silenced",
client_method, exc_info=True,
)
return False, ""
def _disable_paused_buttons(self) -> None:
if not self._active_paused_bubble:
return
@@ -1077,6 +1160,19 @@ class ChatWindow:
except Exception:
logger.debug("disable paused buttons silenced", exc_info=True)
def _enable_paused_buttons(self) -> None:
"""Ré-activer les boutons Continuer/Annuler de la bulle paused
active. Appelé quand l'envoi a échoué sur tous les canaux —
l'utilisateur doit pouvoir réessayer manuellement.
"""
if not self._active_paused_bubble:
return
try:
self._active_paused_bubble["btn_resume"].config(state="normal")
self._active_paused_bubble["btn_abort"].config(state="normal")
except Exception:
logger.debug("enable paused buttons silenced", exc_info=True)
def _update_paused_feedback(self, text: str) -> None:
if not self._active_paused_bubble:
return

View File

@@ -504,6 +504,100 @@ class SmartTrayV1:
threading.Thread(target=_replay, daemon=True).start()
def _launch_replay_request(
self,
replay_request: Dict[str, Any],
replay_name: str,
) -> None:
"""Lance un replay direct depuis un payload `replay_request` serveur."""
endpoint = (replay_request or {}).get("endpoint", "")
session_id = (replay_request or {}).get("session_id", "")
machine_id = (replay_request or {}).get("machine_id") or self.machine_id
if endpoint != "/api/v1/traces/stream/replay-session" or not session_id:
logger.warning("Replay request non supporté: %s", replay_request)
self._notifier.notify(
"Léa",
"Je ne peux pas lancer ce test automatique pour le moment.",
)
return
def _replay():
if self.server_client is None:
return
with self._state_lock:
self._replay_active = True
self._update_icon()
self._notifier.notify(
"Léa",
f"Le système d'intelligence artificielle exécute la "
f"tâche '{replay_name}' sur votre écran.",
)
try:
import requests
auth_headers = {}
if self.server_client is not None:
auth_headers = self.server_client._auth_headers()
resp = requests.post(
f"{self.server_client._stream_base}{endpoint}",
params={
"session_id": session_id,
"machine_id": machine_id,
},
headers=auth_headers,
timeout=30,
allow_redirects=False,
)
if resp.ok:
logger.info(
"Replay direct démarré pour session %s (machine=%s)",
session_id,
machine_id,
)
else:
self._notifier.notify(
"Léa",
"Hmm, le serveur a refusé le test immédiat.",
)
except Exception as e:
logger.error("Erreur lancement replay direct : %s", e)
self._notifier.notify(
"Léa",
f"Oups, un problème : {e}",
)
finally:
with self._state_lock:
self._replay_active = False
self._update_icon()
threading.Thread(target=_replay, daemon=True).start()
def offer_finalize_replay(
self,
replay_request: Dict[str, Any],
replay_name: str,
) -> None:
"""Proposer à l'utilisateur de tester immédiatement la tâche apprise."""
if not replay_request or not replay_request.get("session_id"):
return
def _offer():
self._notifier.notify(
"Léa",
f"J'ai compris la tâche '{replay_name}'. Voulez-vous la tester ?",
)
if not _ask_consent(
"Léa — Test immédiat",
f"J'ai compris la tâche '{replay_name}'. "
"Voulez-vous la tester maintenant ?",
):
return
self._launch_replay_request(replay_request, replay_name)
threading.Thread(target=_offer, daemon=True).start()
def _on_emergency_stop(self, _icon=None, _item=None) -> None:
"""Arret d'urgence — stoppe TOUTES les activites de l'agent immediatement.

View File

@@ -15,7 +15,7 @@ import time
import logging
import hashlib
import platform
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageFilter, ImageStat
import mss
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
@@ -86,6 +86,337 @@ def _enrich_with_monitor_info(payload: dict) -> dict:
payload["monitors_geometry"] = _get_monitors_geometry()
return payload
# Garde dimensions monitor (démo GHT 19 mai 2026) : mss.monitors[1] peut
# retourner intermittemment des dims tronquées (cas observé 2560×60). Utiliser
# ces dims pour normaliser des coords empoisonne la mémoire (TargetMemoryStore).
MIN_MONITOR_WIDTH = 200
MIN_MONITOR_HEIGHT = 200
MONITOR_MAX_ATTEMPTS = 2
MONITOR_RETRY_DELAY_S = 0.05
BLACK_FRAME_MEAN_MAX = 1.0
BLACK_FRAME_STDDEV_MAX = 1.0
BLACK_FRAME_MAX_LUMA = 3
def _is_monitor_sane(monitor) -> bool:
"""True si les dims du monitor sont au-dessus du seuil de plausibilité."""
if not isinstance(monitor, dict):
return False
w = monitor.get("width", 0) or 0
h = monitor.get("height", 0) or 0
return w >= MIN_MONITOR_WIDTH and h >= MIN_MONITOR_HEIGHT
def _dim_str(monitor) -> str:
"""Représentation courte WxH pour les logs (gère monitor=None)."""
if not isinstance(monitor, dict):
return "?x?"
return f"{monitor.get('width', '?')}x{monitor.get('height', '?')}"
def _acquire_safe_grab(max_attempts: int = MONITOR_MAX_ATTEMPTS,
retry_delay_s: float = MONITOR_RETRY_DELAY_S,
allow_secondary_fallback: bool = True):
"""Ouvre mss et capture un monitor avec dimensions plausibles.
Stratégie en cascade :
1. À chaque tentative, ouvrir un nouveau `mss.mss()` (peut rafraîchir le
cache interne) et examiner monitors[1..n].
2. Préférer monitors[1] (écran principal physique). Si aberrant ET
`allow_secondary_fallback=True`, prendre le premier monitors[2..n]
sain avec un WARNING explicite.
3. Si `allow_secondary_fallback=False`, on n'accepte QUE monitors[1].
Utile pour les méthodes qui reçoivent des coordonnées (x, y) en
système écran composite : capturer un monitor secondaire produirait
une image saine mais décalée par rapport à ces coords.
4. Si aucune dim plausible : attendre `retry_delay_s` et retenter.
5. Après `max_attempts` infructueuses : log ERROR et retourner
(None, None) pour que l'appelant tombe en sortie d'erreur explicite.
Args:
max_attempts: nombre de tentatives mss avant abandon.
retry_delay_s: délai entre tentatives.
allow_secondary_fallback: si False, refuser monitors[2..n] (fail-closed
pour les méthodes coord-bearing).
Returns:
Tuple (monitor_dict, PIL.Image) si capture saine réussie,
(None, None) sinon.
"""
last_aberrant = None
secondary_seen = False # un monitor secondaire sain a été vu mais refusé
for attempt in range(max_attempts):
with mss.mss() as sct:
monitors = list(sct.monitors) if sct.monitors else []
chosen = None
chosen_idx = None
for idx in range(1, len(monitors)):
candidate = monitors[idx]
if not _is_monitor_sane(candidate):
last_aberrant = candidate
logger.warning(
"Monitor[%d] dims aberrantes (%s, seuil %dx%d) "
"— attempt %d/%d",
idx, _dim_str(candidate),
MIN_MONITOR_WIDTH, MIN_MONITOR_HEIGHT,
attempt + 1, max_attempts,
)
continue
# Monitor sain trouvé
if idx == 1 or allow_secondary_fallback:
chosen = candidate
chosen_idx = idx
break
# Sinon : sain mais secondaire interdit pour cet appelant
secondary_seen = True
logger.warning(
"Monitor[%d] sain (%s) mais fallback secondaire refusé "
"(allow_secondary_fallback=False) — capture cohérente "
"des coords impossible",
idx, _dim_str(candidate),
)
if chosen is not None:
if chosen_idx != 1 or attempt > 0:
logger.warning(
"Capture fallback : monitor[%d] dim=%s, attempt=%d",
chosen_idx, _dim_str(chosen), attempt + 1,
)
sct_img = sct.grab(chosen)
img = Image.frombytes(
"RGB", sct_img.size, sct_img.bgra, "raw", "BGRX",
)
return chosen, img
if attempt < max_attempts - 1:
time.sleep(retry_delay_s)
if secondary_seen and not allow_secondary_fallback:
logger.error(
"Capture abandonnée : monitor[1] aberrant après %d tentatives "
"(dernier vu %s) et fallback secondaire désactivé "
"pour préserver la cohérence des coordonnées",
max_attempts, _dim_str(last_aberrant),
)
else:
logger.error(
"Aucun monitor avec dims plausibles trouvé après %d tentatives "
"(dernier vu : %s, seuil %dx%d) — capture abandonnée",
max_attempts, _dim_str(last_aberrant),
MIN_MONITOR_WIDTH, MIN_MONITOR_HEIGHT,
)
return None, None
def _compute_luma_stats(img: Image.Image) -> Dict[str, float | int]:
"""Retourne des stats simples de luminance pour diagnostiquer un frame noir."""
gray = img.convert("L")
stat = ImageStat.Stat(gray)
min_luma, max_luma = gray.getextrema()
return {
"mean": round(float(stat.mean[0]) if stat.mean else 0.0, 2),
"stddev": round(float(stat.stddev[0]) if stat.stddev else 0.0, 2),
"min": int(min_luma),
"max": int(max_luma),
}
def _is_effectively_black(img: Image.Image) -> bool:
"""Heuristique fail-closed pour refuser un screenshot pratiquement noir."""
stats = _compute_luma_stats(img)
return (
stats["max"] <= BLACK_FRAME_MAX_LUMA
and stats["mean"] <= BLACK_FRAME_MEAN_MAX
and stats["stddev"] <= BLACK_FRAME_STDDEV_MAX
)
def _capture_via_imagegrab() -> Tuple[Optional[Dict[str, int]], Optional[Image.Image], Dict[str, Any]]:
"""Fallback Windows via Pillow/ImageGrab.
Utile quand `mss` retourne un frame noir alors que la session graphique
utilisateur reste visible.
"""
if _SYSTEM != "Windows":
return None, None, {"backend": "imagegrab", "error": "unsupported_platform"}
try:
from PIL import ImageGrab
except ImportError as exc:
return None, None, {"backend": "imagegrab", "error": str(exc)}
try:
img = ImageGrab.grab(all_screens=True)
except Exception as exc:
logger.warning("ImageGrab indisponible pour le fallback capture : %s", exc)
return None, None, {"backend": "imagegrab", "error": str(exc)}
monitor = {"left": 0, "top": 0, "width": img.width, "height": img.height}
return monitor, img, {
"backend": "imagegrab",
"luma": _compute_luma_stats(img),
}
def capture_screen_image(
allow_secondary_fallback: bool = True,
) -> Tuple[Optional[Dict[str, int]], Optional[Image.Image], Dict[str, Any]]:
"""Capture plein écran avec diagnostic noir + fallback Windows.
Returns:
(monitor, image, meta) où image peut être None si aucun backend plein
écran n'a produit une image exploitable.
"""
monitor, img = _acquire_safe_grab(
allow_secondary_fallback=allow_secondary_fallback
)
meta: Dict[str, Any] = {"backend": "mss"}
if img is not None:
meta["luma"] = _compute_luma_stats(img)
if not _is_effectively_black(img):
return monitor, img, meta
logger.warning(
"Capture mss quasi noire (%s) — tentative de fallback",
meta["luma"],
)
meta["mss_black_frame"] = True
else:
meta["mss_unavailable"] = True
fallback_monitor, fallback_img, fallback_meta = _capture_via_imagegrab()
if fallback_img is not None:
if not _is_effectively_black(fallback_img):
logger.warning(
"Capture fallback via ImageGrab (%sx%s)",
fallback_img.width,
fallback_img.height,
)
return fallback_monitor, fallback_img, fallback_meta
logger.warning(
"Capture ImageGrab quasi noire (%s)",
fallback_meta.get("luma"),
)
meta["imagegrab_black_frame"] = True
meta["imagegrab_error"] = fallback_meta.get("error")
return None, None, meta
def _capture_window_image_windows(
hwnd: int,
width: int,
height: int,
) -> Tuple[Optional[Image.Image], Dict[str, Any]]:
"""Capture une fenêtre Windows via PrintWindow.
Fallback utile quand la capture plein écran est noire mais que la fenêtre
active reste imprimable par l'API Win32.
"""
if _SYSTEM != "Windows":
return None, {"backend": "printwindow", "error": "unsupported_platform"}
try:
import ctypes
import win32gui
import win32ui
except ImportError as exc:
return None, {"backend": "printwindow", "error": str(exc)}
last_error = None
for flag in (3, 2, 0):
wnd_dc = None
src_dc = None
mem_dc = None
bmp = None
try:
wnd_dc = win32gui.GetWindowDC(hwnd)
if not wnd_dc:
raise RuntimeError("GetWindowDC a retourné 0")
src_dc = win32ui.CreateDCFromHandle(wnd_dc)
mem_dc = src_dc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(src_dc, width, height)
mem_dc.SelectObject(bmp)
result = ctypes.windll.user32.PrintWindow(
hwnd, mem_dc.GetSafeHdc(), flag
)
bits = bmp.GetBitmapBits(True)
img = Image.frombuffer(
"RGB", (width, height), bits, "raw", "BGRX", 0, 1
)
luma = _compute_luma_stats(img)
if result or not _is_effectively_black(img):
return img, {
"backend": f"printwindow:{flag}",
"printwindow_result": int(result),
"luma": luma,
}
except Exception as exc:
last_error = str(exc)
finally:
try:
if bmp is not None:
win32gui.DeleteObject(bmp.GetHandle())
except Exception:
pass
try:
if mem_dc is not None:
mem_dc.DeleteDC()
except Exception:
pass
try:
if src_dc is not None:
src_dc.DeleteDC()
except Exception:
pass
try:
if wnd_dc is not None:
win32gui.ReleaseDC(hwnd, wnd_dc)
except Exception:
pass
return None, {
"backend": "printwindow",
"error": last_error or "no_usable_frame",
}
def capture_foreground_window_image() -> Tuple[Optional[Image.Image], Dict[str, Any]]:
"""Capture la fenêtre au focus via API native si disponible."""
try:
from ..window_info_crossplatform import get_active_window_rect
rect_info = get_active_window_rect()
except Exception as exc:
return None, {"backend": "printwindow", "error": str(exc)}
if not rect_info:
return None, {"backend": "printwindow", "error": "active_window_unavailable"}
win_w, win_h = rect_info.get("size", [0, 0])
hwnd = rect_info.get("hwnd")
if not hwnd or win_w <= 0 or win_h <= 0:
return None, {
"backend": "printwindow",
"error": "active_window_handle_unavailable",
"title": rect_info.get("title", "unknown_window"),
}
img, meta = _capture_window_image_windows(hwnd, win_w, win_h)
if img is None:
return None, meta
meta.update(
{
"title": rect_info.get("title", "unknown_window"),
"app_name": rect_info.get("app_name", "unknown_app"),
"rect": rect_info.get("rect"),
"window_size": rect_info.get("size"),
"hwnd": hwnd,
}
)
return img, meta
class VisionCapturer:
def __init__(self, session_dir: str):
self.session_dir = session_dir
@@ -103,25 +434,35 @@ class VisionCapturer:
(utile pour le contextualisation des heartbeats côté serveur).
"""
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
_monitor, img, meta = capture_screen_image()
if img is None:
img, win_meta = capture_foreground_window_image()
if img is None:
logger.error(
"Capture plein contexte indisponible (meta=%s, window=%s)",
meta,
win_meta,
)
return ""
logger.warning(
"Capture plein contexte dégradée via fenêtre active (%s)",
win_meta.get("backend"),
)
# Détection de changement (pour Heartbeat)
if not force:
current_hash = self._compute_quick_hash(img)
if current_hash == self.last_img_hash:
return "" # Pas de changement, on économise la fibre
self.last_img_hash = current_hash
# Détection de changement (pour Heartbeat)
if not force:
current_hash = self._compute_quick_hash(img)
if current_hash == self.last_img_hash:
return "" # Pas de changement, on économise la fibre
self.last_img_hash = current_hash
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
return path
path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png")
img.save(path, "PNG", quality=SCREENSHOT_QUALITY)
return path
except Exception as e:
logger.error(f"Erreur Context Capture: {e}")
return ""
@@ -145,46 +486,62 @@ class VisionCapturer:
sont toujours retournés (fallback gracieux).
"""
try:
with mss.mss() as sct:
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
w, h = TARGETED_CROP_SIZE
left = max(0, x - w // 2)
top = max(0, y - h // 2)
crop_img = img.crop((left, top, left + w, top + h))
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
result = {"full": full_path, "crop": crop_path}
# --- Capture de la fenêtre active ---
# Ajout non-bloquant : enrichit le résultat avec l'image
# de la fenêtre seule + métadonnées (titre, rect, clic relatif)
window_info = self.capture_active_window(x, y, screenshot_id, full_img=img)
# Coords (x, y) sont en système écran composite ; cropper depuis
# un monitor secondaire (offset ≠ 0) produirait une image saine
# mais décalée → fail-closed sur fallback secondaire.
_monitor, img, meta = capture_screen_image(
allow_secondary_fallback=False
)
if img is None:
window_info = self.capture_active_window(
x, y, screenshot_id, full_img=None
)
if window_info:
result["window_capture"] = window_info
result = {"window_capture": window_info}
_enrich_with_monitor_info(result)
logger.warning(
"capture_dual dégradée: fenêtre active seule (%s)",
meta,
)
return result
return {}
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png")
return result
# Capture du Crop (Cœur de l'apprentissage qwen3-vl)
crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png")
w, h = TARGETED_CROP_SIZE
left = max(0, x - w // 2)
top = max(0, y - h // 2)
crop_img = img.crop((left, top, left + w, top + h))
if anonymize:
crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4))
# Floutage des données sensibles (conformité AI Act)
if BLUR_SENSITIVE:
blur_sensitive_regions(img)
blur_sensitive_regions(crop_img)
img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY)
crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY)
# Mise à jour du hash pour le prochain heartbeat
self.last_img_hash = self._compute_quick_hash(img)
result = {"full": full_path, "crop": crop_path}
# --- Capture de la fenêtre active ---
# Ajout non-bloquant : enrichit le résultat avec l'image
# de la fenêtre seule + métadonnées (titre, rect, clic relatif)
window_info = self.capture_active_window(x, y, screenshot_id, full_img=img)
if window_info:
result["window_capture"] = window_info
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
_enrich_with_monitor_info(result)
return result
except Exception as e:
logger.error(f"Erreur Dual Capture: {e}")
return {}
@@ -239,33 +596,54 @@ class VisionCapturer:
# Si le clic est en dehors de la fenêtre, on le signale mais on continue
click_inside = (0 <= click_rel_x <= win_w and 0 <= click_rel_y <= win_h)
window_img = None
# --- Crop de la fenêtre depuis le plein écran ---
if full_img is None:
# Pas de screenshot fourni — en capturer un (cas standalone)
# Pas de screenshot fourni — en capturer un (cas standalone).
# win_rect est en coords globales ; cropper depuis un monitor
# secondaire produirait une image décalée → fail-closed sur
# fallback secondaire.
try:
with mss.mss() as sct:
monitor = sct.monitors[1]
sct_img = sct.grab(monitor)
full_img = Image.frombytes(
"RGB", sct_img.size, sct_img.bgra, "raw", "BGRX"
)
_monitor, full_img, _meta = capture_screen_image(
allow_secondary_fallback=False
)
except Exception as e:
logger.error(f"Erreur capture plein écran pour fenêtre : {e}")
return None
full_img = None
# Borner le crop aux limites de l'image plein écran
img_w, img_h = full_img.size
crop_left = max(0, win_left)
crop_top = max(0, win_top)
crop_right = min(img_w, win_right)
crop_bottom = min(img_h, win_bottom)
if full_img is not None and not _is_effectively_black(full_img):
img_w, img_h = full_img.size
crop_left = max(0, win_left)
crop_top = max(0, win_top)
crop_right = min(img_w, win_right)
crop_bottom = min(img_h, win_bottom)
if crop_right <= crop_left or crop_bottom <= crop_top:
logger.debug("Fenêtre hors écran — skip capture fenêtre")
if crop_right > crop_left and crop_bottom > crop_top:
window_img = full_img.crop(
(crop_left, crop_top, crop_right, crop_bottom)
)
else:
logger.debug("Fenêtre hors écran — fallback natif si possible")
elif full_img is not None:
logger.warning(
"capture_active_window: screenshot plein écran noir, fallback natif"
)
if window_img is None and rect_info.get("hwnd"):
window_img, native_meta = _capture_window_image_windows(
rect_info["hwnd"], win_w, win_h
)
if window_img is not None:
logger.warning(
"capture_active_window via fallback natif (%s)",
native_meta.get("backend"),
)
if window_img is None:
logger.debug("Fenêtre hors écran ou capture native indisponible")
return None
window_img = full_img.crop((crop_left, crop_top, crop_right, crop_bottom))
# Floutage conformité AI Act
if BLUR_SENSITIVE:
blur_sensitive_regions(window_img)

View File

@@ -338,6 +338,50 @@ class LeaServerClient:
except Exception:
return None
def resume_replay(self, replay_id: str) -> bool:
"""Reprendre un replay en pause supervisée via HTTP direct.
Fallback du chemin SocketIO (`lea:replay_resume` → agent_chat)
utilisé quand le bus feedback est déconnecté au moment où
l'utilisateur clique « Continuer » dans la bulle paused.
Retourne True si le serveur streaming a accepté la reprise.
"""
if not replay_id:
return False
try:
import requests
resp = requests.post(
f"{self._stream_url}/traces/stream/replay/{replay_id}/resume",
headers=self._auth_headers(),
timeout=10,
)
return bool(resp.ok)
except Exception:
logger.debug("resume_replay HTTP silenced", exc_info=True)
return False
def abort_replay(self, replay_id: str) -> bool:
"""Annuler un replay en pause supervisée via HTTP direct.
Symétrique de ``resume_replay`` : fallback du chemin SocketIO
(`lea:replay_abort`) quand le bus feedback est déconnecté.
POSTe sur ``/replay/{id}/cancel`` côté serveur streaming.
"""
if not replay_id:
return False
try:
import requests
resp = requests.post(
f"{self._stream_url}/traces/stream/replay/{replay_id}/cancel",
headers=self._auth_headers(),
timeout=10,
)
return bool(resp.ok)
except Exception:
logger.debug("abort_replay HTTP silenced", exc_info=True)
return False
def report_action_result(
self,
session_id: str,

View File

@@ -61,7 +61,9 @@ MAX_ACTIONS_PER_REPLAY = 500 # Max actions par requête de replay
MAX_REPLAY_STATES = 1000 # Max entrées dans _replay_states
REPLAY_STATE_TTL_SECONDS = 3600 # Nettoyage auto des replays terminés après 1h
# Actions en cours de retry : action_id -> {"action": ..., "retry_count": N, "replay_id": ...}
# Actions in-flight / retry : action_id -> transport + retry metadata.
# `action` remains the semantic/original action for reporting/retry logic,
# while `dispatched_action` tracks the exact payload last sent to Lea.
_retry_pending: Dict[str, Dict[str, Any]] = {}
# Callbacks d'erreur par replay_id : replay_id -> callback_url
@@ -207,12 +209,14 @@ from .replay_engine import (
_MAX_ACTION_TEXT_LENGTH,
_MAX_KEYS_PER_COMBO,
_KNOWN_KEY_NAMES,
_auto_launch_replay_after_finalize,
_validate_replay_action,
_APP_LAUNCH_COMMANDS,
_APP_VISUAL_SEARCH,
_SETUP_IGNORE_APPS,
_extract_required_apps_from_events,
_extract_required_apps_from_workflow,
_trim_redundant_setup_events,
_resolve_launch_command,
_infer_app_from_window_titles,
_get_visual_search_info,
@@ -475,6 +479,19 @@ def _clear_replay_lock():
logger.error(f"Erreur suppression replay lock : {e}")
def _memory_window_title_for_action(action_meta: Dict[str, Any]) -> str:
"""Résoudre le meilleur window_title disponible pour la mémoire persistante."""
action_meta = action_meta or {}
target_spec = action_meta.get("target_spec") or {}
context_hints = target_spec.get("context_hints") or {}
return (
action_meta.get("expected_window_before", "")
or target_spec.get("window_title", "")
or context_hints.get("window_title", "")
or action_meta.get("window_title", "")
)
def _get_worker_queue_status() -> Dict[str, Any]:
"""Retourne l'état de la queue du worker VLM (pour le monitoring)."""
queue = []
@@ -544,6 +561,34 @@ _machine_replay_target: Dict[str, str] = {}
_replay_states: Dict[str, Dict[str, Any]] = {}
def _remove_queued_action_duplicates(session_id: str, action_id: str) -> int:
"""Retirer d'une queue les copies exactes d'une action déjà acquittée.
Le watchdog peut re-pousser une action orpheline en tête de queue. Si le
report original arrive juste après, cette copie resend doit être jetée,
sinon Léa ré-exécute la même action avec le même `action_id` et peut
toggler l'état UI (ex: touche Windows qui referme Démarrer).
"""
if not session_id or not action_id:
return 0
queue = _replay_queues.get(session_id, [])
if not queue:
return 0
filtered: List[Dict[str, Any]] = []
removed = 0
for queued_action in queue:
queued_id = str((queued_action or {}).get("action_id", "") or "")
if queued_id == action_id:
removed += 1
continue
filtered.append(queued_action)
if removed:
_replay_queues[session_id] = filtered
return removed
class StreamEvent(BaseModel):
session_id: str
timestamp: float
@@ -832,6 +877,16 @@ async def startup():
threading.Thread(target=_preload_easyocr, daemon=True, name="preload_easyocr").start()
from .replay_watchdog import get_or_create_watchdog
app.state.replay_watchdog = get_or_create_watchdog(
retry_pending=_retry_pending,
replay_queues=_replay_queues,
async_lock_factory=_async_replay_lock,
sse_notifier=None,
)
await app.state.replay_watchdog.start()
logger.info(
"API Streaming démarrée — StreamProcessor, Worker et Cleanup prêts. "
"VLM Worker dans un process séparé (run_worker.py)."
@@ -886,6 +941,9 @@ def _load_existing_workflows():
async def shutdown():
global _cleanup_running
_cleanup_running = False
watchdog = getattr(app.state, "replay_watchdog", None)
if watchdog is not None:
await watchdog.stop(timeout_s=3.0)
worker.stop()
# Nettoyer le replay lock au shutdown (sinon le worker VLM resterait bloqué)
_clear_replay_lock()
@@ -1477,17 +1535,24 @@ def _process_screenshot_thread(session_id: str, shot_id: str, path: str):
# =========================================================================
@app.post("/api/v1/traces/stream/finalize")
async def finalize(session_id: str, machine_id: str = "default"):
async def finalize(
session_id: str,
machine_id: str = "default",
launch_replay: bool = False,
):
"""Clôture la session et place le traitement en file d'attente.
Ne bloque plus : marque la session comme finalisée et l'ajoute à la queue
du worker VLM (process séparé) pour analyse + construction workflow.
Le client peut suivre la progression via GET /api/v1/traces/stream/processing/status.
Optionnellement, il peut aussi déclencher immédiatement un replay direct
depuis la session finalisée (chemin Lea-first, sans attendre le workflow VLM).
Args:
session_id: Identifiant de la session à finaliser
machine_id: Identifiant machine (informatif, le machine_id est déjà dans la session)
launch_replay: Si vrai, tente de lancer immédiatement /replay-session
"""
# Vérifier que la session existe
session = processor.session_manager.get_session(session_id)
@@ -1501,6 +1566,10 @@ async def finalize(session_id: str, machine_id: str = "default"):
processor.session_manager.finalize(session_id)
logger.info(f"Session {session_id} finalisée, ajout à la queue du worker VLM")
resolved_machine_id = machine_id
if resolved_machine_id == "default" and getattr(session, "machine_id", ""):
resolved_machine_id = session.machine_id
# Nettoyer les structures d'enrichissement temps réel pour cette session
with _enrichment_lock:
keys_to_remove = [k for k in _pending_click_enrichments if k[0] == session_id]
@@ -1521,17 +1590,70 @@ async def finalize(session_id: str, machine_id: str = "default"):
if shots_dir.exists():
full_shots_count = len(list(shots_dir.glob("shot_*_full.png")))
return {
# Patch 2026-05-23 (brief 0902 deferred-workflow) : par défaut, on
# ne propose plus le replay direct immédiat post-finalize — le chemin
# produit cible est le workflow compilé par le worker VLM. Le client
# attend la disponibilité du workflow nommé pour proposer un test.
# Le replay direct reste accessible (smoke/debug) en activant
# RPA_AUTO_LAUNCH_REPLAY_AFTER_FINALIZE=true côté serveur, OU
# en appelant explicitement POST /api/v1/traces/stream/replay-session
# depuis un outil de test.
_direct_replay_enabled = _auto_launch_replay_after_finalize()
response = {
"status": "queued_for_processing",
"session_id": session_id,
"machine_id": session.machine_id,
"screenshots_to_analyze": full_shots_count,
"replay_ready": _direct_replay_enabled,
"message": (
f"Session finalisée. {full_shots_count} screenshots seront analysés "
"en arrière-plan. Suivez la progression via "
"GET /api/v1/traces/stream/processing/status"
"GET /api/v1/traces/stream/processing/status."
),
}
if _direct_replay_enabled:
response["replay_request"] = {
"endpoint": "/api/v1/traces/stream/replay-session",
"session_id": session_id,
"machine_id": resolved_machine_id,
}
response["message"] += (
" Le replay direct est disponible via "
"POST /api/v1/traces/stream/replay-session"
)
if not launch_replay:
return response
try:
replay_result = await replay_from_session(
session_id=session_id,
machine_id=resolved_machine_id,
)
except HTTPException as exc:
logger.warning(
"Finalize %s : replay direct non lancé (%s)",
session_id,
exc.detail,
)
response["replay_launch"] = {
"status": "failed",
"status_code": exc.status_code,
"detail": exc.detail,
}
response["message"] += (
" Le lancement automatique du replay direct a échoué ; "
"la session reste finalisée et re-jouable manuellement."
)
return response
response["replay_launch"] = {
"status": "started",
"replay": replay_result,
}
response["message"] += " Le replay direct a été lancé immédiatement."
return response
# =========================================================================
@@ -2262,18 +2384,39 @@ async def replay_from_session(
if session_mem and session_mem.events:
_merge_enrichments_into_raw_events(raw_events, session_mem.events)
# ── 3. Construire le replay propre depuis les events bruts ──
# Passer le répertoire de session pour activer le visual replay (crops de référence)
# Répertoire de session utilisé par le visual replay et les anchors setup
session_dir = str(events_file.parent)
# ── 3. Préparer le setup environnement et couper le préambule source ──
setup_actions = []
app_info = _extract_required_apps_from_events(
raw_events,
session_dir=session_dir,
)
replay_raw_events = raw_events
if app_info:
setup_actions = _generate_setup_actions(app_info, setup_id_prefix="setup_sess")
if setup_actions:
replay_raw_events = _trim_redundant_setup_events(raw_events, app_info)
logger.info(
"replay-session %s : %d actions de setup préparées avant le replay "
"(app=%s, cmd=%s, raw_trim=%d%d)",
session_id, len(setup_actions),
app_info.get("primary_app"), app_info.get("primary_launch_cmd"),
len(raw_events), len(replay_raw_events),
)
# ── 4. Construire le replay propre depuis les events bruts ──
# Passer le répertoire de session pour activer le visual replay (crops de référence)
actions = build_replay_from_raw_events(
raw_events, session_id=session_id, session_dir=session_dir,
replay_raw_events, session_id=session_id, session_dir=session_dir,
)
if not actions:
raise HTTPException(
status_code=400,
detail=f"Session '{session_id}' : aucune action exploitable après nettoyage "
f"({len(raw_events)} événements bruts)"
f"({len(replay_raw_events)} événements bruts)"
)
# Limite de sécurité
@@ -2305,23 +2448,10 @@ async def replay_from_session(
if _gesture_catalog and actions:
actions = _gesture_catalog.optimize_replay_actions(actions)
# ── 3b. Setup environnement — ouvrir les applications nécessaires ──
# Analyser les événements bruts pour détecter quelles applications sont requises
# et injecter des actions de setup en tête de la queue de replay.
setup_actions = []
app_info = _extract_required_apps_from_events(raw_events)
if app_info:
setup_actions = _generate_setup_actions(app_info, setup_id_prefix="setup_sess")
if setup_actions:
actions = setup_actions + actions
logger.info(
"replay-session %s : %d actions de setup injectées avant le replay "
"(app=%s, cmd=%s)",
session_id, len(setup_actions),
app_info.get("primary_app"), app_info.get("primary_launch_cmd"),
)
if setup_actions:
actions = setup_actions + actions
# ── 4. Trouver la session de replay cible (Agent V1 actif) ──
# ── 5. Trouver la session de replay cible (Agent V1 actif) ──
# L'agent actif peut avoir une session différente de la session source
target_session_id = _find_active_agent_session(machine_id=machine_id)
if not target_session_id:
@@ -2335,7 +2465,7 @@ async def replay_from_session(
"Lancez l'Agent V1 sur le PC cible."
)
# ── 5. Injecter dans la queue de replay ──
# ── 6. Injecter dans la queue de replay ──
replay_id = f"replay_sess_{uuid.uuid4().hex[:8]}"
async with _async_replay_lock():
@@ -3265,11 +3395,35 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
# NE PAS écraser si _schedule_retry a déjà mis le bon retry_count
action_id_sent = action.get("action_id", "")
if action_id_sent and action_id_sent not in _retry_pending:
now = time.time()
_retry_pending[action_id_sent] = {
"action": dict(action),
"dispatched_action": dict(action),
"retry_count": 0,
"replay_id": "",
"replay_id": owning_replay.get("replay_id", "") if owning_replay else "",
"session_id": session_id,
"machine_id": machine_id,
"dispatched_at": now,
"first_dispatched_at": now,
"resent_count": 0,
"last_resent_at": 0.0,
}
elif action_id_sent:
existing = _retry_pending.get(action_id_sent)
if existing is not None:
now = time.time()
existing.setdefault("action", dict(action))
existing["dispatched_action"] = dict(action)
existing["replay_id"] = existing.get("replay_id") or (
owning_replay.get("replay_id", "") if owning_replay else ""
)
existing["session_id"] = session_id
existing["machine_id"] = machine_id
existing["dispatched_at"] = now
if not existing.get("first_dispatched_at"):
existing["first_dispatched_at"] = now
existing.setdefault("resent_count", 0)
existing.setdefault("last_resent_at", 0.0)
# [REPLAY] log structuré pour suivre une action à travers toute la chaîne
# Grep facile : journalctl --user -u rpa-streaming -f | grep REPLAY
@@ -3400,6 +3554,15 @@ async def report_action_result(report: ReplayResultReport):
)
return {"status": "no_active_replay", "session_id": session_id}
removed_dupes = _remove_queued_action_duplicates(session_id, action_id)
if removed_dupes:
logger.warning(
"[REPLAY] REPORT cleanup session=%s action_id=%s removed_queue_duplicates=%d",
session_id,
action_id,
removed_dupes,
)
# Récupérer l'info de retry pour cette action (si c'est un retry)
retry_info = _retry_pending.pop(action_id, None)
retry_count = retry_info["retry_count"] if retry_info else 0
@@ -3631,10 +3794,7 @@ async def report_action_result(report: ReplayResultReport):
_current = _actions_meta[_idx] or {}
if _current.get("type") == "click":
_mem_target_spec = _current.get("target_spec") or {}
_mem_window_title = (
_mem_target_spec.get("window_title", "")
or _mem_target_spec.get("expected_window_before", "")
)
_mem_window_title = _memory_window_title_for_action(_current)
if _mem_window_title:
_mem_success = (
@@ -3749,6 +3909,7 @@ async def report_action_result(report: ReplayResultReport):
"target_description": f"Dialogue système : {_sys_category}",
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": _tspec_sys,
"original_action": dict(original_action or {}),
"reason": "system_dialog",
"system_dialog": _sys_info,
"error_detail": _sys_reason or (report.error or ""),
@@ -3814,6 +3975,7 @@ async def report_action_result(report: ReplayResultReport):
"target_description": _target_desc_ww,
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": _tspec_ww,
"original_action": dict(original_action or {}),
"reason": "wrong_window",
"error_detail": report.error or "",
}
@@ -3888,6 +4050,7 @@ async def report_action_result(report: ReplayResultReport):
"target_description": _target_desc,
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": _tspec,
"original_action": dict(original_action or {}),
"reason": "no_screen_change_strict",
"resolution_method": report.resolution_method or "",
"resolution_score": report.resolution_score or 0,
@@ -3947,6 +4110,7 @@ async def report_action_result(report: ReplayResultReport):
"target_description": target_desc,
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": report.target_spec,
"original_action": dict(original_action or {}),
}
replay_state["pause_message"] = f"Je ne vois pas '{target_desc}' à l'écran"
error_entry = {
@@ -3989,6 +4153,7 @@ async def report_action_result(report: ReplayResultReport):
"target_description": target_desc,
"screenshot_b64": screenshot_after or report.screenshot,
"target_spec": report.target_spec,
"original_action": dict(original_action or {}),
}
replay_state["pause_message"] = f"Je ne vois pas '{target_desc}' à l'écran"
error_entry = {
@@ -4341,8 +4506,14 @@ async def resume_replay(
and failed_action.get("reason") != "user_request"):
# Reconstruire l'action a partir du retry_pending ou de l'original
original_action_id = failed_action["action_id"]
original = failed_action.get("original_action")
if isinstance(original, dict) and original:
original = dict(original)
else:
original = None
# Chercher l'action originale dans les retry_pending
original = _retry_pending.pop(original_action_id, {}).get("action")
if not original:
original = _retry_pending.pop(original_action_id, {}).get("action")
if not original:
# Reconstruire un minimum depuis le failed_action context
original = {
@@ -4358,8 +4529,15 @@ async def resume_replay(
# Stocker dans retry_pending pour le suivi
_retry_pending[resume_id] = {
"action": original,
"dispatched_action": dict(resume_action),
"retry_count": 0,
"replay_id": replay_id,
"session_id": session_id,
"machine_id": state.get("machine_id", "default"),
"dispatched_at": 0.0,
"first_dispatched_at": 0.0,
"resent_count": 0,
"last_resent_at": 0.0,
"reason": "resume_after_pause",
}
queue = _replay_queues.get(session_id, [])
@@ -4399,6 +4577,13 @@ async def cancel_replay(replay_id: str):
return {"status": "cancelled", "replay_id": replay_id, "session_id": session_id}
@app.get("/api/v1/traces/stream/replay/watchdog/metrics")
async def watchdog_metrics():
from .replay_watchdog import get_metrics_snapshot
return {"watchdog": get_metrics_snapshot()}
# =========================================================================
# Visual Replay — Résolution visuelle des cibles (module resolve_engine)
# =========================================================================
@@ -4545,10 +4730,13 @@ async def resolve_target(request: ResolveTargetRequest):
# Validation qualité en sortie de cascade : seuil de score + garde
# de proximité contre les coords enregistrées. Single point of
# insertion, n'altère pas la cascade existante.
# target_spec propagé pour relaxation contextuelle (switch_tab +
# som_element calibré, cf. resolve_engine.py 2026-05-22).
result = _validate_resolution_quality(
result,
request.fallback_x_pct,
request.fallback_y_pct,
target_spec=request.target_spec,
)
# Pré-check sémantique post-cascade : OCR sur une zone autour de la
@@ -4581,6 +4769,15 @@ async def resolve_target(request: ResolveTargetRequest):
_by_text = (request.target_spec.get("by_text") or "").strip()
if _by_text:
from agent_v0.server_v1.resolve_engine import _validate_text_at_position
# Propager la bbox SoM enregistrée (si présente) au
# pré-check OCR : pour les éléments étroits (onglets
# Notepad moderne, ~30-40px haut), le radius générique
# capture du texte voisin et rejette à tort.
# Patch 2026-05-23 — cf. inbox_codex/…_notepad-tab-ocr-precheck.
_som_bbox = (
(request.target_spec.get("som_element") or {})
.get("bbox_norm")
)
_is_valid, _observed, _ocr_ms = _validate_text_at_position(
tmp_path,
float(result.get("x_pct", 0) or 0),
@@ -4588,6 +4785,7 @@ async def resolve_target(request: ResolveTargetRequest):
_by_text,
effective_w,
effective_h,
som_bbox_norm=_som_bbox,
)
logger.info(
"[REPLAY] Pre-check OCR ACTIF : '%s' attendu @ (%.4f, %.4f) "
@@ -4600,7 +4798,16 @@ async def resolve_target(request: ResolveTargetRequest):
_is_valid,
_ocr_ms,
)
if not _is_valid:
# Patch 2026-05-23 : rejet uniquement si OCR a effectivement
# lu *autre chose* que la cible. Si observed est vide, l'OCR
# n'a rien lu (crop bbox SoM trop petit / contraste faible
# sur onglet Notepad moderne) — ambigu, on garde la
# résolution serveur. La garde drift ANCHOR-TM côté agent
# bloque les vrais faux positifs.
from agent_v0.server_v1.resolve_engine import (
_should_reject_on_text_mismatch,
)
if _should_reject_on_text_mismatch(_is_valid, _observed):
logger.warning(
"[REPLAY] Pre-check OCR REJET : '%s' attendu @ (%.4f, %.4f) "
"via %s mais OCR voit '%s' (%.0fms)",
@@ -4620,6 +4827,15 @@ async def resolve_target(request: ResolveTargetRequest):
"x_pct": None,
"y_pct": None,
}
elif not _is_valid:
# observed vide → on log mais on accepte
logger.info(
"[REPLAY] Pre-check OCR observed='' (crop trop "
"petit/contraste faible) — on garde la résolution "
"via %s (score=%s), garde drift agent protège en aval",
result.get("method", "?"),
result.get("score"),
)
# [REPLAY] log structuré de sortie résolution (après validation)
# Note: x_pct/y_pct peuvent être None quand le pré-check OCR rejette

View File

@@ -17,6 +17,20 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _infer_machine_id_from_session_id(session_id: str, fallback: str = "default") -> str:
"""Déduire le machine_id depuis un session_id spécial si possible.
Les heartbeats de fond de Léa utilisent `bg_<machine_id>` comme
identifiant de session. Lors d'un redémarrage serveur, ces sessions
peuvent être restaurées depuis la persistance JSON avec `machine_id`
resté à `default`. On rétablit ici l'information machine pour que les
replays ciblés retrouvent bien la session de fond active.
"""
if session_id.startswith("bg_") and len(session_id) > 3:
return session_id[3:]
return fallback
@dataclass
class LiveSessionState:
"""État d'une session active en mémoire."""
@@ -86,11 +100,18 @@ class LiveSessionManager:
def _load_persisted_sessions(self):
"""Charger les sessions sauvegardées au démarrage (JSON state files)."""
count = 0
for session_file in sorted(self._persist_dir.glob("sess_*.json")):
session_files = sorted(self._persist_dir.glob("sess_*.json"))
session_files += sorted(self._persist_dir.glob("bg_*.json"))
for session_file in session_files:
try:
with open(session_file, 'r', encoding='utf-8') as f:
data = json.load(f)
session = LiveSessionState.from_dict(data)
if session.machine_id == "default":
session.machine_id = _infer_machine_id_from_session_id(
session.session_id,
fallback=session.machine_id,
)
self._sessions[session.session_id] = session
count += 1
except Exception as e:
@@ -117,7 +138,7 @@ class LiveSessionManager:
for jsonl_file in sorted(live_dir.glob("**/live_events.jsonl")):
session_dir = jsonl_file.parent
session_id = session_dir.name
if not session_id.startswith("sess_"):
if not (session_id.startswith("sess_") or session_id.startswith("bg_")):
continue
if session_id in self._sessions:
continue
@@ -125,7 +146,7 @@ class LiveSessionManager:
# Déduire le machine_id depuis le chemin parent
parent_name = session_dir.parent.name
if parent_name == live_dir.name:
machine_id = "default"
machine_id = _infer_machine_id_from_session_id(session_id)
else:
machine_id = parent_name

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,12 @@ class ReplayLearner:
"""
target_spec = action.get("target_spec", {})
by_text = target_spec.get("by_text", "")
window_title = target_spec.get("window_title", "")
window_title = (
target_spec.get("window_title", "")
or action.get("window_title", "")
or target_spec.get("expected_window_before", "")
or (target_spec.get("context_hints") or {}).get("window_title", "")
)
x_pct = correction.get("x_pct", 0.0)
y_pct = correction.get("y_pct", 0.0)
@@ -207,20 +212,36 @@ class ReplayLearner:
# Stocker dans target_memory.db pour le lookup futur
try:
from .replay_memory import get_target_memory_store
store = get_target_memory_store()
if store:
store.record_success(
screen_signature="human_correction",
from .replay_memory import memory_record_success
stored = False
if window_title:
stored = memory_record_success(
window_title=window_title,
target_spec=target_spec,
resolved_position={"x_pct": x_pct, "y_pct": y_pct},
x_pct=float(x_pct),
y_pct=float(y_pct),
method="human_supervised",
score=1.0,
confidence=1.0,
)
else:
logger.warning(
"[APPRENTISSAGE] Correction humaine non persistée : "
"window_title absent pour '%s'",
by_text,
)
if stored:
logger.info(
f"[APPRENTISSAGE] Correction stockée dans target_memory : "
f"'{by_text}' → ({x_pct:.4f}, {y_pct:.4f})"
)
elif window_title:
logger.warning(
"[APPRENTISSAGE] Correction humaine non persistée : "
"échec memory_record_success pour '%s' dans '%s'",
by_text,
window_title,
)
except Exception as e:
logger.warning(f"Learning: échec stockage target_memory: {e}")

View File

@@ -103,15 +103,53 @@ def compute_screen_sig(window_title: str) -> str:
return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
def _round_float_list(values: Any, precision: int = 4) -> Optional[tuple[float, ...]]:
"""Normaliser une liste de coordonnées flottantes pour le hash mémoire."""
if not isinstance(values, (list, tuple)):
return None
out = []
for value in values:
try:
out.append(round(float(value), precision))
except (TypeError, ValueError):
return None
return tuple(out)
def _int_pair(values: Any) -> Optional[tuple[int, int]]:
"""Extraire une paire entière stable pour les hints spatiaux."""
if not isinstance(values, (list, tuple)) or len(values) < 2:
return None
try:
return int(values[0]), int(values[1])
except (TypeError, ValueError):
return None
def _should_reuse_recorded_window_relative_coords(fp: Any) -> bool:
"""Décider si on doit remplacer la mémoire apprise par la position source.
Cette réécriture n'est légitime que pour les entrées faibles de type
`position_fallback`/`v4_unknown`, où la mémoire ne contient pas une vraie
localisation visuelle robuste mais seulement un clic écran dépendant de la
résolution. Pour les méthodes visuelles apprises (template, SoM, OCR...),
réinjecter un vieux `click_relative` source crée des collisions et des
dérives sur des boutons homonymes (`Enregistrer`, `OK`, etc.).
"""
method = str(getattr(fp, "etype", "") or "").strip().lower()
return method in {"position_fallback", "v4_unknown"}
class _TargetSpecLike:
"""Adaptateur dict → objet pour `TargetMemoryStore._hash_target_spec()`.
Le hash interne de TargetMemoryStore utilise `getattr(spec, "by_role", ...)`
qui ne fonctionne pas avec un dict brut. On expose les attributs nécessaires.
On intègre aussi `resolve_order` et `vlm_description` dans `context_hints`
pour qu'ils entrent dans le hash — deux actions avec le même `by_text`
mais un `resolve_order` différent doivent avoir des hashes distincts.
On intègre aussi `resolve_order`, `vlm_description` et des indices
spatiaux (SoM, click_relative) dans `context_hints` pour qu'ils entrent
dans le hash. Sinon, deux actions `Enregistrer` dans la même fenêtre
mais à des emplacements différents collisionnent.
"""
__slots__ = ("by_role", "by_text", "by_position", "context_hints")
@@ -131,6 +169,21 @@ class _TargetSpecLike:
hints["_vlm_desc"] = str(d["vlm_description"])
if d.get("anchor_hint"):
hints["_anchor_hint"] = str(d["anchor_hint"])
som_element = d.get("som_element") or {}
som_bbox = _round_float_list(som_element.get("bbox_norm"))
if som_bbox:
hints["_som_bbox"] = som_bbox
som_center = _round_float_list(som_element.get("center_norm"), precision=5)
if som_center:
hints["_som_center"] = som_center
window_capture = d.get("window_capture") or {}
click_relative = _int_pair(window_capture.get("click_relative"))
window_size = _int_pair(window_capture.get("window_size"))
if click_relative and window_size:
hints["_window_rel"] = f"{click_relative[0]},{click_relative[1]}@{window_size[0]}x{window_size[1]}"
self.context_hints = hints
@@ -176,6 +229,46 @@ def memory_lookup(
logger.debug("memory_lookup: fingerprint bbox invalide")
return None
# Quand l'entrée mémoire provient d'un simple `position_fallback`, les
# coordonnées stockées reflètent surtout la géométrie écran source. Dans
# ce cas précis, réutiliser la position relative enregistrée dans la
# fenêtre source reste préférable si elle existe.
#
# En revanche, pour une méthode visuelle réellement apprise
# (`anchor_template`, `som_*`, `hybrid_text_direct`, ...), remplacer les
# coords mémorisées par un vieux `click_relative` crée des dérives sur
# des cibles textuelles homonymes. On garde donc les coords apprises.
window_capture = target_spec.get("window_capture") or {}
click_relative = window_capture.get("click_relative")
window_size = window_capture.get("window_size")
if (
_should_reuse_recorded_window_relative_coords(fp)
and (
isinstance(click_relative, (list, tuple))
and len(click_relative) >= 2
and isinstance(window_size, (list, tuple))
and len(window_size) >= 2
)
):
try:
rel_x = float(click_relative[0])
rel_y = float(click_relative[1])
win_w = float(window_size[0])
win_h = float(window_size[1])
if win_w > 1 and win_h > 1:
x_pct = rel_x / win_w
y_pct = rel_y / win_h
logger.info(
"memory_lookup: coords fenêtre source réutilisées "
"(click_relative=%s, window_size=%s) -> (%.4f, %.4f)",
click_relative,
window_size,
x_pct,
y_pct,
)
except (TypeError, ValueError, ZeroDivisionError):
logger.debug("memory_lookup: window_capture invalide, fallback bbox")
# Sanity check : les pourcentages doivent être dans [0, 1]
if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0):
logger.warning(

View File

@@ -328,10 +328,11 @@ class ReplayVerifier:
),
)
# Cas 4 : Pas de changement (key_combo, wait)
# Pour les raccourcis clavier et attentes, l'absence de changement
# n'est pas forcément un problème (ex: Ctrl+C ne change pas l'écran)
if action_type in ("key_combo", "wait"):
# Cas 4 : Pas de changement (key_combo, wait, verify_screen)
# `verify_screen` côté agent n'est qu'une temporisation de stabilisation.
# Il ne doit pas exiger un NOUVEAU changement visuel sinon le setup
# boucle inutilement une fois l'application déjà ouverte.
if action_type in ("key_combo", "wait", "verify_screen"):
return VerificationResult(
verified=True,
confidence=0.4,

View File

@@ -0,0 +1,329 @@
"""Replay orphan watchdog for in-flight replay actions.
This module watches `_retry_pending` and re-pushes actions that were
dispatched by the server but never acknowledged by the Windows agent.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
def _env_bool(name: str, default: str) -> bool:
return os.environ.get(name, default).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except (TypeError, ValueError):
logger.warning("Watchdog: invalid env %s, fallback=%s", name, default)
return default
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except (TypeError, ValueError):
logger.warning("Watchdog: invalid env %s, fallback=%s", name, default)
return default
def _env_max_resends(default: int) -> int:
raw = os.environ.get("RPA_WATCHDOG_MAX_RESENDS")
if raw is None or not str(raw).strip():
raw = os.environ.get("RPA_WATCHDOG_MAX_RETRIES")
try:
return int(raw) if raw is not None else default
except (TypeError, ValueError):
logger.warning("Watchdog: invalid max resend env, fallback=%s", default)
return default
WATCHDOG_ENABLED = _env_bool("RPA_WATCHDOG_ENABLED", "1")
WATCHDOG_SCAN_INTERVAL_S = _env_float("RPA_WATCHDOG_SCAN_INTERVAL_S", 10.0)
WATCHDOG_ORPHAN_TIMEOUT_S = _env_float("RPA_WATCHDOG_ORPHAN_TIMEOUT_S", 45.0)
WATCHDOG_MAX_RESENDS = _env_max_resends(2)
WATCHDOG_REPUSH_POSITION = (
os.environ.get("RPA_WATCHDOG_REPUSH_POSITION", "head").strip().lower()
)
_metrics_lock = asyncio.Lock()
_metrics: Dict[str, Any] = {
"orphans_detected_total": 0,
"orphans_resent_total": 0,
"orphans_giveup_total": 0,
"scans_total": 0,
"scans_failed_total": 0,
"last_scan_ts": 0.0,
"last_scan_duration_ms": 0.0,
"current_in_flight_count": 0,
"current_orphan_count": 0,
}
async def _bump(key: str, delta: int = 1) -> None:
async with _metrics_lock:
_metrics[key] = _metrics.get(key, 0) + delta
def get_metrics_snapshot() -> Dict[str, Any]:
return dict(_metrics)
SseNotifier = Callable[[str, str], None]
class ReplayWatchdog:
"""Background coroutine that re-pushes orphaned replay actions."""
def __init__(
self,
retry_pending: Dict[str, Dict[str, Any]],
replay_queues: Dict[str, List[Dict[str, Any]]],
async_lock_factory: Callable[[], Any],
sse_notifier: Optional[SseNotifier] = None,
) -> None:
self._retry_pending = retry_pending
self._replay_queues = replay_queues
self._async_lock = async_lock_factory
self._sse_notifier = sse_notifier
self._task: Optional[asyncio.Task] = None
self._stopped = asyncio.Event()
async def start(self) -> None:
if not WATCHDOG_ENABLED:
logger.info("[WATCHDOG] disabled via RPA_WATCHDOG_ENABLED=0")
return
if self._task is not None and not self._task.done():
logger.warning("[WATCHDOG] already started")
return
self._stopped.clear()
self._task = asyncio.create_task(self._run(), name="replay_watchdog")
logger.info(
"[WATCHDOG] started scan=%.1fs orphan_timeout=%.1fs max_resends=%d repush=%s",
WATCHDOG_SCAN_INTERVAL_S,
WATCHDOG_ORPHAN_TIMEOUT_S,
WATCHDOG_MAX_RESENDS,
WATCHDOG_REPUSH_POSITION,
)
async def stop(self, timeout_s: float = 5.0) -> None:
if self._task is None:
return
self._stopped.set()
self._task.cancel()
try:
await asyncio.wait_for(self._task, timeout=timeout_s)
except asyncio.CancelledError:
pass
except asyncio.TimeoutError:
logger.warning("[WATCHDOG] stop timeout after %.1fs", timeout_s)
except Exception:
logger.exception("[WATCHDOG] unexpected stop error")
self._task = None
logger.info("[WATCHDOG] stopped")
async def _run(self) -> None:
try:
while not self._stopped.is_set():
try:
await asyncio.wait_for(
self._stopped.wait(),
timeout=WATCHDOG_SCAN_INTERVAL_S,
)
break
except asyncio.TimeoutError:
pass
try:
await self._scan_once()
except Exception:
await _bump("scans_failed_total")
logger.exception("[WATCHDOG] scan failed")
except asyncio.CancelledError:
logger.info("[WATCHDOG] cancelled")
raise
finally:
logger.info("[WATCHDOG] loop terminated")
async def _scan_once(self) -> Dict[str, int]:
t0 = time.time()
await _bump("scans_total")
resent = 0
gaveup = 0
skipped = 0
in_flight = 0
orphans = 0
orphan_targets: List[Tuple[str, Dict[str, Any]]] = []
async with self._async_lock():
for action_id, info in list(self._retry_pending.items()):
dispatched_at = info.get("dispatched_at", 0.0) or 0.0
if dispatched_at <= 0:
skipped += 1
continue
age = t0 - dispatched_at
in_flight += 1
if age < WATCHDOG_ORPHAN_TIMEOUT_S:
continue
orphans += 1
orphan_targets.append((action_id, dict(info)))
for action_id, info in orphan_targets:
await _bump("orphans_detected_total")
resent_count = int(info.get("resent_count", 0) or 0)
if resent_count >= WATCHDOG_MAX_RESENDS:
async with self._async_lock():
self._retry_pending.pop(action_id, None)
age_total = t0 - float(info.get("first_dispatched_at", t0) or t0)
logger.error(
"[BUS] lea:dispatch_orphan_giveup action_id=%s resent=%d age_total=%.1fs "
"session=%s machine=%s replay=%s",
action_id,
resent_count,
age_total,
info.get("session_id", "?"),
info.get("machine_id", "?"),
info.get("replay_id", "?"),
)
gaveup += 1
await _bump("orphans_giveup_total")
continue
session_id = info.get("session_id")
machine_id = info.get("machine_id", "default")
action = info.get("dispatched_action") or info.get("action")
if not session_id or not isinstance(action, dict):
logger.warning(
"[WATCHDOG] invalid schema for %s session_id=%r action_type=%s",
action_id,
session_id,
type(action).__name__,
)
async with self._async_lock():
self._retry_pending.pop(action_id, None)
continue
async with self._async_lock():
existing = self._retry_pending.get(action_id)
if existing is None:
logger.debug(
"[WATCHDOG] %s acked between snapshot and resend; skip",
action_id,
)
continue
queue = self._replay_queues.setdefault(session_id, [])
if WATCHDOG_REPUSH_POSITION == "tail":
queue.append(dict(action))
else:
queue.insert(0, dict(action))
existing["resent_count"] = resent_count + 1
existing["last_resent_at"] = time.time()
existing["dispatched_at"] = 0.0
age_total = t0 - float(info.get("first_dispatched_at", t0) or t0)
logger.warning(
"[BUS] lea:dispatch_orphan_resent action_id=%s resent=%d/%d age=%.1fs "
"session=%s machine=%s replay=%s",
action_id,
resent_count + 1,
WATCHDOG_MAX_RESENDS,
age_total,
session_id,
machine_id,
info.get("replay_id", "?"),
)
resent += 1
await _bump("orphans_resent_total")
if self._sse_notifier is not None:
try:
self._sse_notifier(session_id, machine_id)
except Exception as exc:
logger.debug("[WATCHDOG] sse notifier failed: %s", exc)
elapsed_ms = (time.time() - t0) * 1000.0
async with _metrics_lock:
_metrics["last_scan_ts"] = t0
_metrics["last_scan_duration_ms"] = elapsed_ms
_metrics["current_in_flight_count"] = in_flight
_metrics["current_orphan_count"] = orphans
scans_total = _metrics["scans_total"]
if orphans or gaveup:
logger.info(
"[METRIC] watchdog scan=%d orphans=%d resent=%d gaveup=%d "
"in_flight=%d skipped=%d elapsed_ms=%.1f",
scans_total,
orphans,
resent,
gaveup,
in_flight,
skipped,
elapsed_ms,
)
return {
"orphans": orphans,
"resent": resent,
"gaveup": gaveup,
"skipped": skipped,
"in_flight": in_flight,
}
_singleton: Optional[ReplayWatchdog] = None
def get_or_create_watchdog(
retry_pending: Dict[str, Dict[str, Any]],
replay_queues: Dict[str, List[Dict[str, Any]]],
async_lock_factory: Callable[[], Any],
sse_notifier: Optional[SseNotifier] = None,
) -> ReplayWatchdog:
global _singleton
if _singleton is None:
_singleton = ReplayWatchdog(
retry_pending=retry_pending,
replay_queues=replay_queues,
async_lock_factory=async_lock_factory,
sse_notifier=sse_notifier,
)
return _singleton
@contextlib.asynccontextmanager
async def watchdog_lifespan(
retry_pending: Dict[str, Dict[str, Any]],
replay_queues: Dict[str, List[Dict[str, Any]]],
async_lock_factory: Callable[[], Any],
sse_notifier: Optional[SseNotifier] = None,
):
watchdog = get_or_create_watchdog(
retry_pending=retry_pending,
replay_queues=replay_queues,
async_lock_factory=async_lock_factory,
sse_notifier=sse_notifier,
)
await watchdog.start()
try:
yield watchdog
finally:
await watchdog.stop()

View File

@@ -243,6 +243,168 @@ def _validate_match_context(
return True
def _has_meaningful_recorded_coords(
fallback_x_pct: float,
fallback_y_pct: float,
) -> bool:
"""Indiquer si les coordonnées fallback représentent une vraie position source."""
return (
fallback_x_pct > 0.001
and fallback_y_pct > 0.001
and not (
abs(fallback_x_pct - 0.5) < 0.001
and abs(fallback_y_pct - 0.5) < 0.001
)
)
def _is_close_tab_target(target_spec: Optional[Dict[str, Any]]) -> bool:
"""Détecter une action close_tab issue du compilateur replay."""
if not isinstance(target_spec, dict):
return False
context_hints = target_spec.get("context_hints") or {}
return str((context_hints.get("interaction") or "")).strip().lower() == "close_tab"
def _get_expected_close_tab_coords(
target_spec: Optional[Dict[str, Any]],
screen_width: int,
screen_height: int,
fallback_x_pct: float = 0.0,
fallback_y_pct: float = 0.0,
) -> Optional[tuple[float, float]]:
"""Retrouver la position attendue la plus fiable pour un close_tab.
Ordre de préférence :
1. Coordonnées fallback explicites de l'action replay
2. centre SoM calibré à l'enregistrement
3. click_relative + rect fenêtre source
"""
if _has_meaningful_recorded_coords(fallback_x_pct, fallback_y_pct):
return float(fallback_x_pct), float(fallback_y_pct)
if not isinstance(target_spec, dict):
return None
som_center = (target_spec.get("som_element") or {}).get("center_norm")
if isinstance(som_center, (list, tuple)) and len(som_center) >= 2:
try:
exp_x = float(som_center[0])
exp_y = float(som_center[1])
if 0.0 <= exp_x <= 1.0 and 0.0 <= exp_y <= 1.0:
return exp_x, exp_y
except (TypeError, ValueError):
pass
window_capture = target_spec.get("window_capture") or {}
rect = window_capture.get("rect")
click_relative = window_capture.get("click_relative")
if (
isinstance(rect, (list, tuple))
and len(rect) >= 4
and isinstance(click_relative, (list, tuple))
and len(click_relative) >= 2
and screen_width > 0
and screen_height > 0
):
try:
abs_x = float(rect[0]) + float(click_relative[0])
abs_y = float(rect[1]) + float(click_relative[1])
exp_x = abs_x / float(screen_width)
exp_y = abs_y / float(screen_height)
if 0.0 <= exp_x <= 1.0 and 0.0 <= exp_y <= 1.0:
return exp_x, exp_y
except (TypeError, ValueError, ZeroDivisionError):
pass
return None
def _is_close_tab_result_plausible(
resolved_x: float,
resolved_y: float,
target_spec: Optional[Dict[str, Any]],
screen_width: int,
screen_height: int,
fallback_x_pct: float = 0.0,
fallback_y_pct: float = 0.0,
) -> bool:
"""Filtrer les faux positifs close_tab qui dérivent vers le bouton fermer."""
if not _is_close_tab_target(target_spec):
return True
expected = _get_expected_close_tab_coords(
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
)
if expected is None:
return True
exp_x, exp_y = expected
dx = abs(float(resolved_x) - exp_x)
dy = abs(float(resolved_y) - exp_y)
distance = (dx ** 2 + dy ** 2) ** 0.5
is_plausible = dx <= 0.18 and distance <= 0.20
if not is_plausible:
logger.warning(
"close_tab guard : résultat rejeté car trop éloigné de la zone "
"source (resolved=(%.4f, %.4f), expected=(%.4f, %.4f), "
"drift=(%.4f, %.4f), dist=%.4f)",
float(resolved_x),
float(resolved_y),
exp_x,
exp_y,
dx,
dy,
distance,
)
return is_plausible
def _is_start_button_vlm_result_plausible(
result: Dict[str, Any],
fallback_x_pct: float,
fallback_y_pct: float,
target_spec: Dict[str, Any],
max_distance: float = 0.20,
) -> bool:
"""Filtrer les faux positifs VLM sur le bouton Démarrer.
Le bouton Démarrer est un singleton système. Quand on dispose d'un vrai clic
enregistré (`fallback_*`), une localisation VLM très éloignée de cette zone
est plus probablement un faux positif qu'un vrai déplacement UI.
"""
by_role = str(target_spec.get("by_role", "") or "").strip().lower()
if by_role != "start_button":
return True
if not _has_meaningful_recorded_coords(fallback_x_pct, fallback_y_pct):
return True
if _validate_match_context(
result,
fallback_x_pct,
fallback_y_pct,
target_spec,
max_distance=max_distance,
):
return True
logger.warning(
"Start button guard : résultat VLM rejeté car trop éloigné de la "
"position enregistrée (resolved=(%.4f, %.4f), expected=(%.4f, %.4f), max=%.2f)",
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
fallback_x_pct,
fallback_y_pct,
max_distance,
)
return False
# =========================================================================
# YOLO/OmniParser — Résolution par détection d'éléments UI
# =========================================================================
@@ -1109,16 +1271,66 @@ def _resolve_by_som(
# Centre du match
match_cx = max_loc[0] + anc_w // 2
match_cy = max_loc[1] + anc_h // 2
interaction = str(
(target_spec.get("context_hints") or {}).get("interaction", "") or ""
).strip().lower()
if interaction == "close_tab":
elapsed = time.time() - t0
cx_norm = match_cx / screen_width if screen_width > 0 else 0.0
cy_norm = match_cy / screen_height if screen_height > 0 else 0.0
if _is_close_tab_result_plausible(
cx_norm,
cy_norm,
target_spec,
screen_width,
screen_height,
):
logger.info(
"SoM resolve ANCHOR exact close_tab : score=%.3f "
"centre=(%d, %d) → (%.4f, %.4f) en %.1fs",
max_score, match_cx, match_cy, cx_norm, cy_norm, elapsed,
)
return {
"resolved": True,
"method": "som_anchor_match",
"x_pct": round(cx_norm, 6),
"y_pct": round(cy_norm, 6),
"matched_element": {
"label": "close_tab_button",
"type": "visual_anchor",
"role": "som_anchor_exact",
"confidence": max_score,
},
"score": max_score,
"match_box": {
"x": int(max_loc[0]),
"y": int(max_loc[1]),
"width": int(anc_w),
"height": int(anc_h),
},
}
logger.warning(
"SoM resolve ANCHOR exact close_tab rejeté : score=%.3f "
"centre=(%d, %d) → (%.4f, %.4f), passage VLM/fallback",
max_score, match_cx, match_cy, cx_norm, cy_norm,
)
# Ne pas recycler ce faux match vers l'élément SoM le plus
# proche : pour close_tab, cela retombe facilement sur le
# bouton de fermeture de la fenêtre.
best_elem = None
else:
best_elem = None
# Trouver l'élément SomEngine le plus proche du centre du match
best_elem = None
best_dist = float("inf")
for elem in som_result.elements:
cx, cy = elem.center
dist = ((match_cx - cx) ** 2 + (match_cy - cy) ** 2) ** 0.5
if dist < best_dist:
best_dist = dist
best_elem = elem
if best_elem is None and interaction != "close_tab":
for elem in som_result.elements:
cx, cy = elem.center
dist = ((match_cx - cx) ** 2 + (match_cy - cy) ** 2) ** 0.5
if dist < best_dist:
best_dist = dist
best_elem = elem
if best_elem and best_dist < 100: # Max 100px de distance
elapsed = time.time() - t0
@@ -1584,6 +1796,49 @@ def _resolve_target_sync(
"fallback cascade legacy"
)
# ===================================================================
# Cas spécial : boutons de dialogue runtime ("Oui", "Non", "OK", ...)
# ===================================================================
# Ces boutons sont textuels, sans ancre stable, et apparaissent souvent
# au milieu d'une action déjà en cours. Si on les laisse partir dans la
# cascade générique (VLM -> SoM -> ScreenAnalyzer), on peut bloquer
# l'action principale assez longtemps pour déclencher le watchdog.
# Contrat voulu : OCR direct rapide, sinon abandon immédiat pour que le
# client essaie son fallback local par template texte.
dialog_role = str(target_spec.get("by_role", "") or "").strip().lower()
dialog_text = str(target_spec.get("by_text", "") or "").strip()
if dialog_role == "dialog_button" and dialog_text and not anchor_image_b64:
ocr_result = _resolve_by_ocr_text(
screenshot_path=screenshot_path,
target_text=dialog_text,
screen_width=screen_width,
screen_height=screen_height,
)
if ocr_result and ocr_result.get("score", 0) >= 0.80:
ocr_result["method"] = "hybrid_text_direct"
logger.info(
"Resolve dialog_button OCR-DIRECT : OK '%s' → (%.4f, %.4f) score=%.2f",
dialog_text[:40],
ocr_result.get("x_pct", 0),
ocr_result.get("y_pct", 0),
ocr_result.get("score", 0),
)
return ocr_result
logger.info(
"Resolve dialog_button OCR-only : '%s' non trouvé "
"(fenêtre='%s') — skip VLM/SoM/ScreenAnalyzer",
dialog_text[:40],
str(target_spec.get("window_title", "") or "")[:80],
)
return {
"resolved": False,
"method": "dialog_button_ocr_only",
"reason": "ocr_direct_failed_dialog_button_no_vlm",
"x_pct": fallback_x_pct,
"y_pct": fallback_y_pct,
}
# ===================================================================
# MODE STRICT (replay sessions) — Stratégie VLM-FIRST
# ===================================================================
@@ -1656,13 +1911,25 @@ def _resolve_target_sync(
screen_height=screen_height,
)
if grounding_result and grounding_result.get("resolved"):
logger.info(
"Strict resolve GROUNDING : OK (%.4f, %.4f) pour '%s'",
grounding_result.get("x_pct", 0),
grounding_result.get("y_pct", 0),
by_text_strict[:50],
if _is_close_tab_result_plausible(
float(grounding_result.get("x_pct", 0) or 0),
float(grounding_result.get("y_pct", 0) or 0),
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.info(
"Strict resolve GROUNDING : OK (%.4f, %.4f) pour '%s'",
grounding_result.get("x_pct", 0),
grounding_result.get("y_pct", 0),
by_text_strict[:50],
)
return grounding_result
logger.warning(
"Strict resolve GROUNDING : résultat close_tab rejeté, passage template/VLM"
)
return grounding_result
if not by_text_strict or by_text_source not in ("ocr", "vlm"):
# Template matching pour les éléments sans texte (icônes pures)
@@ -1690,11 +1957,23 @@ def _resolve_target_sync(
abs_y = window_rect[1] + y_tm * tm_screen_h
result["x_pct"] = round(abs_x / screen_width, 6)
result["y_pct"] = round(abs_y / screen_height, 6)
logger.info(
"Strict resolve TEMPLATE : icon match (score=%.3f)",
result.get("score", 0),
if _is_close_tab_result_plausible(
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.info(
"Strict resolve TEMPLATE : icon match (score=%.3f)",
result.get("score", 0),
)
return result
logger.warning(
"Strict resolve TEMPLATE : résultat close_tab rejeté, passage cascade suivante"
)
return result
# ---------------------------------------------------------------
# Étape 0.5 : OCR direct (hybrid_text_direct) — chemin rapide
@@ -1739,6 +2018,27 @@ def _resolve_target_sync(
by_text_strict[:40],
)
# Les boutons de dialogues runtime connus ("Oui", "Non", "OK", etc.)
# ne doivent pas partir dans la cascade lente VLM -> SoM. Si l'OCR
# direct ne les trouve pas immédiatement, on rend la main au client
# pour son fallback local par template texte, sinon on bloque l'action
# principale assez longtemps pour déclencher le watchdog.
dialog_role = str(target_spec.get("by_role", "") or "").strip().lower()
if dialog_role == "dialog_button" and by_text_strict and not anchor_image_b64:
logger.info(
"Strict resolve dialog_button : OCR-direct only pour '%s' "
"(fenêtre='%s') — skip VLM/SoM/template",
by_text_strict[:40],
str(target_spec.get("window_title", "") or "")[:80],
)
return {
"resolved": False,
"method": "dialog_button_ocr_only",
"reason": "ocr_direct_failed_dialog_button_no_vlm",
"x_pct": fallback_x_pct,
"y_pct": fallback_y_pct,
}
# ---------------------------------------------------------------
# Étape 1 : VLM Quick Find (fallback, multi-image)
# ---------------------------------------------------------------
@@ -1750,12 +2050,29 @@ def _resolve_target_sync(
)
if vlm_result and vlm_result.get("resolved"):
if vlm_result.get("score", 0) >= 0.3:
logger.info(
"Strict resolve VLM-first : VLM OK (score=%.2f) pour '%s'",
vlm_result.get("score", 0),
vlm_description[:60] if vlm_description else "(anchor)",
if _is_start_button_vlm_result_plausible(
vlm_result,
fallback_x_pct,
fallback_y_pct,
target_spec,
) and _is_close_tab_result_plausible(
float(vlm_result.get("x_pct", 0) or 0),
float(vlm_result.get("y_pct", 0) or 0),
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.info(
"Strict resolve VLM-first : VLM OK (score=%.2f) pour '%s'",
vlm_result.get("score", 0),
vlm_description[:60] if vlm_description else "(anchor)",
)
return vlm_result
logger.warning(
"Strict resolve VLM-first : résultat VLM rejeté par un garde-fou, passage SoM/template"
)
return vlm_result
else:
logger.info(
"Strict resolve VLM-first : VLM score=%.2f trop bas, passage template",
@@ -1782,12 +2099,24 @@ def _resolve_target_sync(
screen_height=screen_height,
)
if som_result and som_result.get("resolved"):
logger.info(
"Strict resolve SoM+VLM : OK (score=%.2f, mark=#%s)",
som_result.get("score", 0),
som_result.get("matched_element", {}).get("som_id", "?"),
if _is_close_tab_result_plausible(
float(som_result.get("x_pct", 0) or 0),
float(som_result.get("y_pct", 0) or 0),
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.info(
"Strict resolve SoM+VLM : OK (score=%.2f, mark=#%s)",
som_result.get("score", 0),
som_result.get("matched_element", {}).get("som_id", "?"),
)
return som_result
logger.warning(
"Strict resolve SoM+VLM : résultat close_tab rejeté, passage template matching"
)
return som_result
else:
logger.info("Strict resolve SoM+VLM : échoué, passage template matching")
@@ -1805,12 +2134,24 @@ def _resolve_target_sync(
score = result.get("score", 0)
# Score >= 0.95 : match quasi-parfait, pas besoin de valider le contexte
if score >= 0.95:
logger.info(
"Strict resolve VLM-first : template matching fallback OK "
"(score=%.3f >= 0.95, contexte skip — match quasi-parfait)",
score,
if _is_close_tab_result_plausible(
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
target_spec,
screen_width,
screen_height,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.info(
"Strict resolve VLM-first : template matching fallback OK "
"(score=%.3f >= 0.95, contexte skip — match quasi-parfait)",
score,
)
return result
logger.warning(
"Strict resolve TEMPLATE : match close_tab très fort mais hors zone source, rejeté"
)
return result
elif _validate_match_context(result, fallback_x_pct, fallback_y_pct, target_spec):
logger.info(
"Strict resolve VLM-first : template matching fallback OK "
@@ -2189,6 +2530,37 @@ def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.6
return matched / len(tokens) >= min_token_ratio
_SOM_BBOX_OCR_PADDING_PX: int = 8
_SOM_BBOX_MIN_DIM_PX: int = 12
def _should_reject_on_text_mismatch(
is_valid: bool,
observed: Optional[str],
) -> bool:
"""Décide si le pré-check OCR doit rejeter la résolution.
Patch 2026-05-23 : on distingue deux cas d'échec du fuzzy match :
- ``observed`` contient du texte (ex: ``'9 ?'``, ``'OBS Studio…'``)
→ mismatch confirmé, la cascade a probablement cliqué ailleurs
→ on rejette.
- ``observed`` est vide ou whitespace
→ l'OCR n'a rien lu (zone trop petite, texte peu contrasté,
modèle EasyOCR sous le seuil de détection). C'est ambigu :
ce n'est PAS la preuve d'un faux positif, on accepte la
résolution serveur. La garde drift ANCHOR-TM côté agent
protège en aval contre les vrais faux positifs.
Si ``is_valid=True`` → jamais de rejet (cas nominal).
"""
if is_valid:
return False
if observed is None:
return False
return bool(str(observed).strip())
def _validate_text_at_position(
screenshot_path: str,
x_pct: float,
@@ -2197,9 +2569,20 @@ def _validate_text_at_position(
screen_width: int,
screen_height: int,
radius_px: int = 280,
som_bbox_norm: Optional[List[float]] = None,
) -> tuple:
"""Pré-check sémantique : OCR sur une zone autour de (x_pct, y_pct) et
vérifie que `expected_text` y est présent (substring ou fuzzy 50%).
"""Pré-check sémantique : OCR sur une zone et vérifie que
`expected_text` y est présent (substring ou fuzzy 50%).
Zone OCR (par priorité) :
1. Si ``som_bbox_norm = [x1, y1, x2, y2]`` (normalisé 0..1) est
fourni et a une largeur/hauteur > _SOM_BBOX_MIN_DIM_PX en
pixels écran : OCR sur cette bbox élargie d'un padding court.
Plus précis pour les éléments étroits (onglets Notepad
moderne, ~30-40px haut) que le radius générique qui capture
le texte voisin (status bar, etc.).
2. Sinon : fallback historique → carré de ``radius_px`` autour
de (x_pct, y_pct).
Retourne (is_valid: bool, observed_text: str, elapsed_ms: float).
@@ -2219,16 +2602,52 @@ def _validate_text_at_position(
t0 = time.time()
img = Image.open(screenshot_path).convert("RGB")
img_w, img_h = img.size
cx = int(x_pct * screen_width)
cy = int(y_pct * screen_height)
# Saturer dans les bornes de l'image (le screenshot peut être plus
# large que la fenêtre logique — utiliser min(img_*, screen_*) en sécurité).
max_x = min(img_w, screen_width)
max_y = min(img_h, screen_height)
x1 = max(0, cx - radius_px)
y1 = max(0, cy - radius_px)
x2 = min(max_x, cx + radius_px)
y2 = min(max_y, cy + radius_px)
# --- Tentative 1 : zone OCR depuis la bbox SoM (préférée) ---
x1 = y1 = x2 = y2 = None
if (
isinstance(som_bbox_norm, (list, tuple))
and len(som_bbox_norm) == 4
):
try:
bx1, by1, bx2, by2 = (float(v) for v in som_bbox_norm)
# Tolérer ordre inversé.
bx1, bx2 = sorted((bx1, bx2))
by1, by2 = sorted((by1, by2))
# Refuser les bboxes dégénérées AVANT padding : si
# l'élément cible fait < _SOM_BBOX_MIN_DIM_PX en
# natif, c'est probablement une bbox d'apparence
# (curseur, séparateur 1px) — pas un label OCRable.
raw_w = (bx2 - bx1) * screen_width
raw_h = (by2 - by1) * screen_height
if (
raw_w >= _SOM_BBOX_MIN_DIM_PX
and raw_h >= _SOM_BBOX_MIN_DIM_PX
):
# Conversion en pixels écran + clipping et padding.
px1 = int(bx1 * screen_width) - _SOM_BBOX_OCR_PADDING_PX
py1 = int(by1 * screen_height) - _SOM_BBOX_OCR_PADDING_PX
px2 = int(bx2 * screen_width) + _SOM_BBOX_OCR_PADDING_PX
py2 = int(by2 * screen_height) + _SOM_BBOX_OCR_PADDING_PX
x1 = max(0, px1)
y1 = max(0, py1)
x2 = min(max_x, px2)
y2 = min(max_y, py2)
except (TypeError, ValueError):
# Bbox malformée : fallback silencieux sur le radius.
x1 = y1 = x2 = y2 = None
# --- Fallback : carré radius_px autour de (x_pct, y_pct) ---
if x1 is None:
cx = int(x_pct * screen_width)
cy = int(y_pct * screen_height)
x1 = max(0, cx - radius_px)
y1 = max(0, cy - radius_px)
x2 = min(max_x, cx + radius_px)
y2 = min(max_y, cy + radius_px)
if x2 - x1 < 10 or y2 - y1 < 10:
return True, "", 0.0
crop = img.crop((x1, y1, x2, y2))
@@ -2246,6 +2665,7 @@ def _validate_resolution_quality(
result: Optional[Dict[str, Any]],
fallback_x_pct: float,
fallback_y_pct: float,
target_spec: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Valide un résultat de résolution et le rejette s'il est peu fiable.
@@ -2263,6 +2683,16 @@ def _validate_resolution_quality(
elle n'est PAS appelée par les méthodes internes de la cascade, mais
uniquement depuis le handler HTTP `/resolve_target` après que la
cascade a produit son meilleur candidat.
Argument optionnel `target_spec` : permet d'appliquer des relaxations
contextuelles. Cas couvert (2026-05-22) : pour une cible
`context_hints.interaction == "switch_tab"` qui dispose d'un
`som_element.bbox_norm`, on abaisse le seuil des méthodes ``som_*``
de 0.75 → 0.60. Justification : (1) le focus_change pré-clic
prouve qu'on est dans la bonne fenêtre, (2) la bbox SoM a été
calibrée à l'enregistrement et reste valide, (3) les onglets
Notepad moderne sont visuellement quasi-identiques → score VLM
inévitablement lower.
"""
if not result or not isinstance(result, dict):
return result
@@ -2291,6 +2721,52 @@ def _validate_resolution_quality(
min_score = threshold
break
# Relaxation contextuelle pour switch_tab + SoM calibré (2026-05-22).
# Les onglets Notepad moderne (et apps similaires) sont visuellement
# quasi-identiques : le grounding VLM/SoM produit fréquemment un
# score 0.65-0.75, juste sous le seuil strict. Comme le contexte
# `interaction=switch_tab` + bbox SoM enregistrée + focus_change
# pré-clic confirment déjà la fenêtre et la zone, on relâche le
# seuil des méthodes som_* à 0.60 dans CE cas précis uniquement.
if (
min_score is not None
and target_spec
and method.startswith("som_")
):
context_hints = target_spec.get("context_hints") or {}
is_tab_switch = (
context_hints.get("interaction") == "switch_tab"
and target_spec.get("by_role") == "tab"
)
som_element = target_spec.get("som_element") or {}
has_calibrated_som = bool(som_element.get("bbox_norm"))
if is_tab_switch and has_calibrated_som:
relaxed = 0.60
if relaxed < min_score:
logger.info(
"[REPLAY] switch_tab + som_element calibré → seuil "
"som_* relâché %.2f%.2f (cible='%s')",
min_score, relaxed,
target_spec.get("by_text", ""),
)
min_score = relaxed
is_close_tab = (
method == "som_anchor_match"
and str((context_hints.get("interaction") or "")).strip().lower() == "close_tab"
and not str(target_spec.get("by_text", "") or "").strip()
and bool(target_spec.get("anchor_image_base64"))
)
if is_close_tab:
relaxed = 0.70
if relaxed < min_score:
logger.info(
"[REPLAY] close_tab + anchor-only → seuil som_anchor_match "
"relâché %.2f%.2f",
min_score, relaxed,
)
min_score = relaxed
if min_score is not None and score < min_score:
logger.warning(
"[REPLAY] Resolution REJETÉE (score trop bas) : method=%s score=%.3f < %.2f",
@@ -2306,13 +2782,40 @@ def _validate_resolution_quality(
"y_pct": fallback_y_pct,
}
if _is_close_tab_target(target_spec) and not _is_close_tab_result_plausible(
resolved_x,
resolved_y,
target_spec,
0,
0,
fallback_x_pct=fallback_x_pct,
fallback_y_pct=fallback_y_pct,
):
logger.warning(
"[REPLAY] Resolution REJETÉE (close_tab hors zone source) : "
"method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f)",
method,
resolved_x,
resolved_y,
fallback_x_pct,
fallback_y_pct,
)
return {
"resolved": False,
"method": f"rejected_close_tab_zone_{method}",
"reason": "close_tab_out_of_recorded_zone",
"original_method": method,
"original_score": score,
"x_pct": fallback_x_pct,
"y_pct": fallback_y_pct,
}
# --- Check 2 : garde de proximité ---
# On n'applique la garde que si les coordonnées enregistrées ont un
# sens (pas des placeholders 0.5/0.5 des plans V4 ni des 0.0/0.0).
_has_recorded_coords = (
fallback_x_pct > 0.001
and fallback_y_pct > 0.001
and not (abs(fallback_x_pct - 0.5) < 0.001 and abs(fallback_y_pct - 0.5) < 0.001)
_has_recorded_coords = _has_meaningful_recorded_coords(
fallback_x_pct,
fallback_y_pct,
)
if _has_recorded_coords:
dx = abs(resolved_x - fallback_x_pct)

View File

@@ -1025,6 +1025,345 @@ def enrich_click_from_screenshot(
return result
def _title_to_tab_label(window_title: str) -> str:
"""Réduire un titre de fenêtre en libellé d'onglet probable.
Exemples:
- "Sans titre Bloc-notes" -> "Sans titre"
- "*test Bloc-notes" -> "test"
"""
title = str(window_title or "").strip()
if not title:
return ""
for sep in (" ", " - "):
if sep in title:
head = title.split(sep, 1)[0].strip()
if head:
title = head
break
return title.lstrip("*").strip()
def _split_window_title_head_suffix(window_title: str) -> tuple[str, str]:
"""Découper un titre de fenêtre en ``(head, suffix)`` si possible.
Exemples:
- ``Sans titre Bloc-notes`` -> (``Sans titre``, ``Bloc-notes``)
- ``Page 1 - Google Chrome`` -> (``Page 1``, ``Google Chrome``)
- ``Enregistrer sous`` -> ("", "")
"""
title = str(window_title or "").strip()
if not title:
return "", ""
for sep in (" ", " - "):
if sep in title:
head, suffix = title.split(sep, 1)
head = head.strip()
suffix = suffix.strip()
if head and suffix:
return head, suffix
return "", ""
def _looks_like_same_app_tab_switch(from_title: str, to_title: str) -> bool:
"""Vrai si la transition de focus ressemble à un vrai changement d'onglet.
On exige que les deux titres partagent un suffixe applicatif stable
(ex: ``Bloc-notes``, ``Google Chrome``). Cela exclut les dialogs
modaux same-app comme ``Enregistrer sous`` qui ne sont pas des
onglets et ne doivent pas être compilés en ``switch_tab``.
"""
from_head, from_suffix = _split_window_title_head_suffix(from_title)
to_head, to_suffix = _split_window_title_head_suffix(to_title)
if not (from_head and from_suffix and to_head and to_suffix):
return False
return from_suffix.casefold() == to_suffix.casefold()
def _infer_tab_switch_target(
raw_events: list,
click_event: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Détecter un clic d'onglet à partir d'une bascule de focus dans la même app.
Cas réel observé:
- fenêtre active `http...txt Bloc-notes`
- clic dans la barre d'onglets (y relatif ~40 px)
- focus immédiat vers `Sans titre Bloc-notes`
Dans ce cas, l'ancre image seule est trop fragile. On enrichit donc le
target_spec avec un libellé d'onglet explicite (`by_text='Sans titre'`,
`by_role='tab'`).
"""
event_type = click_event.get("type", "")
if event_type != "mouse_click":
return None
window = click_event.get("window", {})
if not isinstance(window, dict):
return None
from_title = str(window.get("title", "")).strip()
app_name = str(window.get("app_name", "")).strip().lower()
if not from_title or not app_name:
return None
# Heuristique: on ne traite que les clics très hauts dans la fenêtre,
# typiques d'une barre d'onglets / bouton de fermeture d'onglet.
window_capture = click_event.get("window_capture", {})
if not isinstance(window_capture, dict):
return None
click_relative = window_capture.get("click_relative")
if not (isinstance(click_relative, list) and len(click_relative) == 2):
return None
try:
rel_y = int(click_relative[1])
except (TypeError, ValueError):
return None
if rel_y > 90:
return None
click_ts = click_event.get("timestamp")
click_pos = click_event.get("pos") or []
match_idx = None
for idx, raw_evt in enumerate(raw_events):
event_data = raw_evt.get("event", raw_evt)
if event_data.get("type") != "mouse_click":
continue
if event_data.get("timestamp") != click_ts:
continue
if (event_data.get("pos") or []) != click_pos:
continue
match_idx = idx
break
if match_idx is None:
return None
for follow_evt in raw_events[match_idx + 1: match_idx + 7]:
follow_data = follow_evt.get("event", follow_evt)
follow_type = follow_data.get("type", "")
if follow_type in {"mouse_click", "text_input", "key_press", "key_combo"}:
# Un autre geste utilisateur est intervenu avant le focus_change :
# le focus observé n'est plus attribuable avec confiance à CE clic.
return None
if follow_type != "window_focus_change":
continue
to_info = follow_data.get("to", {})
if not isinstance(to_info, dict):
continue
if str(to_info.get("app_name", "")).strip().lower() != app_name:
continue
to_title = str(to_info.get("title", "")).strip()
if not to_title or to_title == from_title:
continue
if not _looks_like_same_app_tab_switch(from_title, to_title):
return None
follow_ts = follow_data.get("timestamp")
if (
isinstance(click_ts, (int, float))
and isinstance(follow_ts, (int, float))
and follow_ts - click_ts > 3.0
):
break
tab_label = _title_to_tab_label(to_title)
if not tab_label:
return None
return {
"by_text": tab_label,
"by_role": "tab",
"window_title": from_title,
"context_hints": {
"window_title": from_title,
"switch_to_window_title": to_title,
"interaction": "switch_tab",
},
"vlm_description": (
f"Dans la fenêtre '{from_title}', l'onglet '{tab_label}' "
"dans la barre d'onglets en haut"
),
}
return None
def _infer_close_tab_target(
raw_events: list,
click_event: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Détecter un clic sur le bouton fermer de l'onglet actif.
Pattern ciblé observé sur Bloc-notes moderne :
- clic très haut dans la barre d'onglets sur un titre ``*... Bloc-notes``
- un clic suivant dans la même fenêtre
- puis focus vers ``Enregistrer sous``
Cela correspond à la fermeture d'un onglet modifié qui déclenche ensuite
le flow de sauvegarde. On enrichit le clic avec un hint sémantique pour
viser le vrai bouton ``x`` de l'onglet actif plutôt qu'un simple `yolo`.
"""
event_type = click_event.get("type", "")
if event_type != "mouse_click":
return None
window = click_event.get("window", {})
if not isinstance(window, dict):
return None
from_title = str(window.get("title", "")).strip()
app_name = str(window.get("app_name", "")).strip().lower()
if not from_title or not app_name or not from_title.startswith("*"):
return None
window_capture = click_event.get("window_capture", {})
if not isinstance(window_capture, dict):
return None
click_relative = window_capture.get("click_relative")
if not (isinstance(click_relative, list) and len(click_relative) == 2):
return None
try:
rel_y = int(click_relative[1])
except (TypeError, ValueError):
return None
if rel_y > 90:
return None
click_ts = click_event.get("timestamp")
click_pos = click_event.get("pos") or []
match_idx = None
for idx, raw_evt in enumerate(raw_events):
event_data = raw_evt.get("event", raw_evt)
if event_data.get("type") != "mouse_click":
continue
if event_data.get("timestamp") != click_ts:
continue
if (event_data.get("pos") or []) != click_pos:
continue
match_idx = idx
break
if match_idx is None:
return None
saw_follow_click_same_window = False
for follow_evt in raw_events[match_idx + 1: match_idx + 8]:
follow_data = follow_evt.get("event", follow_evt)
follow_type = follow_data.get("type", "")
if follow_type in {"text_input", "key_press", "key_combo"}:
return None
if follow_type == "mouse_click":
follow_window = follow_data.get("window", {})
if not isinstance(follow_window, dict):
return None
follow_app = str(follow_window.get("app_name", "")).strip().lower()
follow_title = str(follow_window.get("title", "")).strip()
if follow_app != app_name:
return None
if follow_title == from_title:
saw_follow_click_same_window = True
continue
return None
if follow_type != "window_focus_change" or not saw_follow_click_same_window:
continue
to_info = follow_data.get("to", {})
if not isinstance(to_info, dict):
continue
if str(to_info.get("app_name", "")).strip().lower() != app_name:
continue
to_title = str(to_info.get("title", "")).strip()
if to_title != "Enregistrer sous":
continue
follow_ts = follow_data.get("timestamp")
if (
isinstance(click_ts, (int, float))
and isinstance(follow_ts, (int, float))
and follow_ts - click_ts > 5.0
):
break
tab_label = _title_to_tab_label(from_title)
if not tab_label:
return None
return {
"by_text": "",
"by_role": "tab_close_button",
"window_title": from_title,
"context_hints": {
"window_title": from_title,
"active_tab_label": tab_label,
"interaction": "close_tab",
},
"vlm_description": (
f"Dans la fenêtre '{from_title}', le bouton x pour fermer "
f"l'onglet actif '{tab_label}' dans la barre d'onglets en haut"
),
}
return None
def _attach_expected_window_before(actions: list, raw_events: list) -> None:
"""Attacher la fenêtre attendue AVANT chaque clic en rejouant les
raw events et en conservant le dernier ``window_focus_change.to.title``.
Pourquoi : ``mouse_click.window.title`` capturé pendant
l'enregistrement peut être obsolète si une transition de fenêtre
se produit juste avant la capture (ex: dialog Windows qui s'ouvre
milliseconde avant le clic suivant). Le serveur dispose pourtant
des ``window_focus_change`` consécutifs — on s'en sert pour poser
explicitement ``expected_window_before`` sur le clic, lu en priorité
absolue par la pré-vérif côté agent.
Idempotent : si une action a déjà ``expected_window_before``, on
ne touche pas.
"""
if not actions or not raw_events:
return
last_focus_title = ""
action_idx = 0
def _next_click_idx(start: int) -> int:
i = start
while i < len(actions) and actions[i].get("type") != "click":
i += 1
return i
for raw_evt in raw_events:
ev = raw_evt.get("event", raw_evt) if isinstance(raw_evt, dict) else {}
etype = ev.get("type", "")
if etype == "window_focus_change":
to_info = ev.get("to") or {}
title = str(to_info.get("title", "") or "").strip()
if title and title != "unknown_window":
last_focus_title = title
continue
if etype != "mouse_click":
continue
action_idx = _next_click_idx(action_idx)
if action_idx >= len(actions):
return
a = actions[action_idx]
if last_focus_title and not a.get("expected_window_before"):
a["expected_window_before"] = last_focus_title
action_idx += 1
def _attach_expected_screenshots(
actions: list, raw_events: list, session_dir: Path,
) -> None:
@@ -1591,6 +1930,8 @@ def build_replay_from_raw_events(
k: v for k, v in enrichment.items()
if k != "by_position" # by_position est déjà dans x_pct/y_pct
}
if action.get("window_title") and not action["target_spec"].get("window_title"):
action["target_spec"]["window_title"] = action["window_title"]
# Ajouter les métadonnées fenêtre pour le grounding ciblé
wc = evt.get("window_capture", {})
if wc.get("rect"):
@@ -1600,6 +1941,33 @@ def build_replay_from_raw_events(
"click_relative": wc.get("click_relative"),
}
tab_switch_target = _infer_tab_switch_target(events, evt)
if tab_switch_target:
target_spec = action.setdefault("target_spec", {})
# Préférer une sémantique explicite d'onglet à un rôle brut
# `yolo`/anchor-only quand le flux brut montre une vraie
# bascule de focus dans la même application.
if not target_spec.get("by_text"):
target_spec["by_text"] = tab_switch_target["by_text"]
target_spec["by_role"] = tab_switch_target["by_role"]
target_spec["window_title"] = tab_switch_target["window_title"]
target_spec["vlm_description"] = tab_switch_target["vlm_description"]
context_hints = dict(target_spec.get("context_hints") or {})
context_hints.update(tab_switch_target["context_hints"])
target_spec["context_hints"] = context_hints
action["visual_mode"] = True
close_tab_target = _infer_close_tab_target(events, evt)
if close_tab_target:
target_spec = action.setdefault("target_spec", {})
target_spec["by_role"] = close_tab_target["by_role"]
target_spec["window_title"] = close_tab_target["window_title"]
target_spec["vlm_description"] = close_tab_target["vlm_description"]
context_hints = dict(target_spec.get("context_hints") or {})
context_hints.update(close_tab_target["context_hints"])
target_spec["context_hints"] = context_hints
action["visual_mode"] = True
elif evt_type == "text_input":
text = evt.get("text", "")
if not text:
@@ -1695,6 +2063,21 @@ def build_replay_from_raw_events(
if next_title:
result[ci]["expected_window_title"] = next_title
# ── 9b. Pré-condition fiable : expected_window_before ──
# Bug live 2026-05-22 (act_raw_c70976c8) : window.title d'un
# mouse_click peut être obsolète quand une transition de fenêtre
# (ex: ouverture dialog "Enregistrer sous") se produit juste avant
# la capture du click. Sans correction, target_spec.window_title
# reste sur l'ancien titre et la pré-vérif côté agent
# (executor.py:653) déclenche une pause supervisée à tort.
#
# On rejoue les raw events en maintenant le dernier titre vu via
# window_focus_change.to.title et on le pose comme
# expected_window_before sur chaque clic qui n'en a pas déjà un.
# Le champ est lu en priorité absolue par la pré-vérif agent, donc
# il prime sur target_spec.window_title obsolète.
_attach_expected_window_before(result, events)
# ── 10. Enrichir avec intention + expected_result via gemma4 (Critic) ──
# gemma4 analyse chaque action dans son contexte pour produire :
# - intention : ce que l'utilisateur veut accomplir