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:
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
39
agent_v0/agent_v1/finalize_contract.py
Normal file
39
agent_v0/agent_v1/finalize_contract.py
Normal 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",
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
329
agent_v0/server_v1/replay_watchdog.py
Normal file
329
agent_v0/server_v1/replay_watchdog.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user