Compare commits
11 Commits
0a02a6ec9c
...
7847a0e829
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7847a0e829 | ||
|
|
40440f1ca0 | ||
|
|
7233df2bb9 | ||
|
|
f62fda575f | ||
|
|
22c0a2ba61 | ||
|
|
6fdedbfe9d | ||
|
|
c969f93a23 | ||
|
|
1cbec2806e | ||
|
|
864530c851 | ||
|
|
d1ebf62217 | ||
|
|
87dbe8c5ff |
@@ -49,7 +49,10 @@ try:
|
||||
from PIL import Image as PILImage
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception:
|
||||
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
|
||||
# quand X n'est pas accessible — typique d'un service systemd headless côté
|
||||
# serveur. Le serveur n'a pas besoin de pyautogui (utilisé côté client agent).
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
PILImage = None
|
||||
pyautogui = None
|
||||
|
||||
@@ -94,6 +94,11 @@ class ActionExecutorV1:
|
||||
# pause supervisée au serveur (`paused_need_help`).
|
||||
# Cf. core/system_dialog_guard.py
|
||||
self._system_dialog_pause: Optional[Dict[str, Any]] = None
|
||||
# Référence à la ChatWindow Léa V1 (Tkinter) pour afficher les bulles
|
||||
# paused interactives quand le serveur signale une pause supervisée.
|
||||
# Câblée depuis main.py après instanciation des deux objets.
|
||||
# Si None (mode headless / tests), fallback sur self.notifier.
|
||||
self._chat_window_ref = None
|
||||
# Log de la resolution physique pour le diagnostic DPI
|
||||
self._log_screen_info()
|
||||
|
||||
@@ -1796,6 +1801,65 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
self._last_conn_error_logged = False
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# Plan B (8 mai 2026 — démo GHT) : si le serveur signale une pause
|
||||
# supervisée, afficher le pause_message dans la ChatWindow Léa V1
|
||||
# (Tkinter, déjà ouverte sur Windows) sous forme de bulle interactive
|
||||
# avec boutons Continuer / Annuler. Permet à l'utilisateur Windows de
|
||||
# voir physiquement ce que Léa attend (pause_for_human ou échec
|
||||
# résolution). Fallback notifier.notify si la ChatWindow n'est pas
|
||||
# câblée (mode headless / tests).
|
||||
if data.get("replay_paused"):
|
||||
pause_msg = data.get("pause_message") or "Léa a besoin de votre aide"
|
||||
replay_id = data.get("replay_id") or ""
|
||||
pause_key = (replay_id, pause_msg)
|
||||
if getattr(self, "_last_pause_msg_shown", None) != pause_key:
|
||||
self._last_pause_msg_shown = pause_key
|
||||
completed = data.get("current_action_index", 0)
|
||||
total = data.get("total_actions", "?")
|
||||
payload = {
|
||||
"replay_id": replay_id,
|
||||
"workflow": "Replay en cours",
|
||||
"reason": pause_msg,
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
}
|
||||
# Toast Tkinter custom topmost — visible même si la
|
||||
# ChatWindow est withdraw()-cachée par défaut. Sans dépendance
|
||||
# plyer (Focus Assist Windows 11 filtre les balloons système).
|
||||
try:
|
||||
from ..ui.paused_toast import show_paused_toast
|
||||
show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=pause_msg[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast launch silenced", exc_info=True)
|
||||
|
||||
chat_window = getattr(self, "_chat_window_ref", None)
|
||||
if chat_window is not None:
|
||||
try:
|
||||
# _add_paused_bubble est thread-safe (utilise root.after)
|
||||
# et force l'affichage de la fenêtre + toast topmost
|
||||
chat_window._add_paused_bubble(payload)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"chat_window._add_paused_bubble pause silenced",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
# Fallback notifier (tests headless / chat fermé)
|
||||
try:
|
||||
self.notifier.notify(
|
||||
title="Léa — j'ai besoin de vous",
|
||||
message=pause_msg[:300],
|
||||
timeout=15,
|
||||
bypass_rate_limit=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("notifier.notify pause silenced", exc_info=True)
|
||||
return False
|
||||
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
return False
|
||||
@@ -2297,7 +2361,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
|
||||
best_match = None
|
||||
best_val = 0.0
|
||||
threshold = 0.50 # Seuil équilibré
|
||||
threshold = 0.75 # Démo GHT 8 mai — éviter faux positifs (placeholders italiques, tabs voisins). En dessous, mieux vaut tomber en mode apprentissage humain qu'un clic au pif.
|
||||
|
||||
# Essayer plusieurs tailles de police pour couvrir différentes résolutions
|
||||
for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]:
|
||||
|
||||
@@ -116,6 +116,14 @@ class AgentV1:
|
||||
# Executeur pour le replay (doit exister avant le poll)
|
||||
self._executor = ActionExecutorV1()
|
||||
|
||||
# Wiring ChatWindow → Executor pour Plan B (pause_message → bulle interactive)
|
||||
# Permet à l'executor d'afficher une bulle paused dans la fenêtre Léa V1
|
||||
# quand le serveur signale replay_paused=True via /replay/next.
|
||||
try:
|
||||
self._executor._chat_window_ref = self._chat_window
|
||||
except Exception:
|
||||
logger.debug("Wiring chat_window→executor échoué (non bloquant)", exc_info=True)
|
||||
|
||||
# Boucles permanentes (pas besoin de session active)
|
||||
self.running = True
|
||||
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
||||
|
||||
0
agent_v0/agent_v1/tools/__init__.py
Normal file
0
agent_v0/agent_v1/tools/__init__.py
Normal file
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# agent_v1/tools/test_lea_toast.py
|
||||
"""
|
||||
Test visuel rapide du toast Léa (démo GHT 8 mai 2026).
|
||||
|
||||
Lance trois scénarios de toast successifs pour valider l'affichage Windows :
|
||||
1. Toast simple « pause supervisée »
|
||||
2. Toast avec message long (vérifier wraplength)
|
||||
3. Toast type BLOCAGE (= ce que voit l'utilisateur quand Léa est perdue)
|
||||
|
||||
Usage Windows :
|
||||
C:\\rpa_vision\\.venv\\Scripts\\python.exe C:\\rpa_vision\\agent_v1\\tools\\test_lea_toast.py
|
||||
|
||||
Le script s'attend à voir trois toasts successifs en haut-droite de l'écran
|
||||
principal, espacés de ~6 s, fond bleu Léa, autodismiss après 15 s ou clic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _bootstrap_path() -> None:
|
||||
"""Autoriser l'exécution directe sans -m : ajouter C:\\rpa_vision au sys.path."""
|
||||
here = Path(__file__).resolve()
|
||||
# On remonte : tools -> agent_v1 -> rpa_vision (parent du package agent_v1)
|
||||
rpa_root = here.parent.parent.parent
|
||||
if str(rpa_root) not in sys.path:
|
||||
sys.path.insert(0, str(rpa_root))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_bootstrap_path()
|
||||
|
||||
# Import après ajout du path (les deux variantes fonctionnent)
|
||||
try:
|
||||
from agent_v1.ui.paused_toast import show_paused_toast
|
||||
except Exception as e: # pragma: no cover (debug only)
|
||||
print(f"[TEST] ERREUR import agent_v1.ui.paused_toast : {e}")
|
||||
return 1
|
||||
|
||||
scenarios = [
|
||||
(
|
||||
"Toast 1/3 : pause simple",
|
||||
"Léa a besoin de votre aide",
|
||||
"Test 1/3 — Pause supervisée. Cliquez sur 'Continuer' dans la chat.",
|
||||
),
|
||||
(
|
||||
"Toast 2/3 : message long",
|
||||
"Léa — j'attends votre validation",
|
||||
(
|
||||
"Test 2/3 — J'ai trouvé 11 dossiers correspondant à vos critères "
|
||||
"(UHCD, Forfait 1, PE2). Je vais traiter le dossier de M. DUPONT "
|
||||
"Jean en premier. Pouvez-vous valider que c'est le bon ordre "
|
||||
"avant que je continue ?"
|
||||
),
|
||||
),
|
||||
(
|
||||
"Toast 3/3 : blocage cible non trouvée",
|
||||
"Léa — je ne vois pas l'élément",
|
||||
(
|
||||
"Test 3/3 — Je n'arrive pas à trouver « Examens cliniques » à "
|
||||
"l'écran. Pouvez-vous me montrer où cliquer ?"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for label, title, message in scenarios:
|
||||
print(f"[TEST] {label}")
|
||||
ok = show_paused_toast(title=title, message=message)
|
||||
print(f" show_paused_toast() = {ok}")
|
||||
if not ok:
|
||||
print(f" ECHEC : {label}")
|
||||
# Espacer pour que Dom voit chaque toast distinctement
|
||||
# (rate limit interne = 3s pour message identique, mais ici les
|
||||
# messages diffèrent, le rate limit ne s'applique pas)
|
||||
time.sleep(6)
|
||||
|
||||
print("[TEST] Attente 12s supplémentaires pour laisser le dernier toast vivre...")
|
||||
time.sleep(12)
|
||||
print("[TEST] OK — fin du test. Si vous avez vu 3 toasts bleus en haut-droite,")
|
||||
print(" le mécanisme Léa pause est validé.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# agent_v1/ui/_test_paused_toast.py
|
||||
"""
|
||||
Test isolé du toast paused — à exécuter directement sur Windows.
|
||||
|
||||
Usage (sur Windows, depuis C:\\rpa_vision\\agent_v1) :
|
||||
python -m agent_v1.ui._test_paused_toast
|
||||
|
||||
OU plus simple :
|
||||
python C:\\rpa_vision\\agent_v1\\ui\\_test_paused_toast.py
|
||||
|
||||
Le toast doit s'afficher en haut à droite de l'écran principal pendant ~15s.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[TEST] Lancement du toast paused...")
|
||||
|
||||
try:
|
||||
# Import flexible : essai relatif puis absolu
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
except ImportError:
|
||||
from paused_toast import show_paused_toast
|
||||
except Exception as e:
|
||||
print(f"[TEST] ERREUR import : {e}")
|
||||
return 1
|
||||
|
||||
ok = show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=(
|
||||
"Test isolé — démo GHT 8 mai 2026.\n"
|
||||
"Si vous voyez ce toast, le mécanisme de pause supervisée "
|
||||
"fonctionne correctement."
|
||||
),
|
||||
)
|
||||
print(f"[TEST] show_paused_toast() retour = {ok}")
|
||||
|
||||
if not ok:
|
||||
print("[TEST] ÉCHEC : toast non déclenché.")
|
||||
return 2
|
||||
|
||||
print("[TEST] Toast déclenché. Attente de 18s pour le voir s'afficher puis se fermer...")
|
||||
time.sleep(18)
|
||||
print("[TEST] OK — fin du test.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -838,10 +838,38 @@ class ChatWindow:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide)."""
|
||||
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide).
|
||||
|
||||
IMPORTANT (8 mai 2026, démo GHT) : par défaut la fenêtre démarre cachée
|
||||
(`root.withdraw()`). Il FAUT la rendre visible et la forcer au premier
|
||||
plan, sinon Dom ne voit jamais la bulle. On exécute dans le thread
|
||||
tkinter via `root.after(0, ...)`.
|
||||
"""
|
||||
if self._root is None:
|
||||
return
|
||||
self._root.after(0, lambda: self._render_paused_bubble(payload))
|
||||
|
||||
def _show_and_render():
|
||||
try:
|
||||
self._do_show()
|
||||
# Re-pin topmost pour passer devant les apps actives
|
||||
self._root.attributes("-topmost", True)
|
||||
self._root.lift()
|
||||
# Toast topmost en complément (visible même si la chat est
|
||||
# masquée par une fenêtre d'app)
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
reason = payload.get("reason") or "Action en attente."
|
||||
show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=str(reason)[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast launch silenced", exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("force-show chat_window silenced", exc_info=True)
|
||||
self._render_paused_bubble(payload)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
|
||||
@@ -139,10 +139,28 @@ class NotificationManager:
|
||||
|
||||
Les messages BLOCAGE bypass le rate limit pour garantir que
|
||||
l'utilisateur voit qu'on a besoin de lui.
|
||||
|
||||
Démo GHT 8 mai 2026 : pour les BLOCAGE, on déclenche en complément
|
||||
un toast Tkinter custom topmost (paused_toast). Plyer est silencieux
|
||||
sur Windows 11 quand Focus Assist / Quiet Hours / app-id manquante
|
||||
bloquent les balloons. Le toast custom est 100 % autonome et garantit
|
||||
que Dom voit le message en démo.
|
||||
"""
|
||||
bypass = msg.niveau == NiveauMessage.BLOCAGE
|
||||
# Log aussi pour tracer dans les logs fichiers
|
||||
self._log_message(msg)
|
||||
|
||||
# Toast Tkinter custom — uniquement BLOCAGE pour ne pas spammer
|
||||
if msg.niveau == NiveauMessage.BLOCAGE:
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
show_paused_toast(
|
||||
title=str(msg.titre)[:80] or "Léa a besoin de votre aide",
|
||||
message=str(msg.corps)[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast (BLOCAGE) silenced", exc_info=True)
|
||||
|
||||
return self.notify(
|
||||
title=msg.titre,
|
||||
message=msg.corps,
|
||||
|
||||
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# agent_v1/ui/paused_toast.py
|
||||
"""
|
||||
Toast Tkinter custom pour la pause supervisée (« Léa a besoin de votre aide »).
|
||||
|
||||
Démo GHT 8 mai 2026 — Fallback robuste 100 % autonome quand :
|
||||
- plyer.notification est silencieux sous Windows 11 (Focus Assist, balloon tips
|
||||
bloqués par la stratégie système),
|
||||
- la ChatWindow Léa V1 est `withdraw()`-cachée par défaut (Dom ne la voit pas),
|
||||
- aucune autre UI ne peut garantir que Dom verra physiquement le message.
|
||||
|
||||
Stratégie :
|
||||
- Toplevel topmost overrideredirect en haut à droite de l'écran principal,
|
||||
- fond bleu Léa, titre + message, auto-close après TOAST_DURATION_S,
|
||||
- thread-safe : peut être appelé depuis n'importe quel thread (le polling
|
||||
replay tourne dans un daemon thread, pas le thread principal),
|
||||
- aucune dépendance externe (juste tkinter stdlib),
|
||||
- rate limit interne pour éviter le flood (1 toast / 3s minimum).
|
||||
|
||||
Si un Tk root existe déjà dans le process (ChatWindow), on attache le Toplevel
|
||||
à ce root via `root.after(0, ...)` — c'est l'idiome thread-safe officiel de
|
||||
tkinter. Sinon on crée un Tk() dédié dans un daemon thread.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Couleurs cohérentes avec le thème Léa (cf. chat_window.py)
|
||||
TOAST_BG = "#2563EB" # Bleu Léa (HEADER_BG)
|
||||
TOAST_FG = "#FFFFFF"
|
||||
TOAST_TITLE_BG = "#1E40AF" # Bleu plus foncé pour le bandeau titre
|
||||
TOAST_BORDER = "#1E3A8A"
|
||||
|
||||
TOAST_WIDTH = 380
|
||||
TOAST_PAD_X = 18
|
||||
TOAST_PAD_Y = 14
|
||||
TOAST_DURATION_MS = 15000
|
||||
TOAST_RATE_LIMIT_S = 3.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_shown_at: float = 0.0
|
||||
_last_message: str = ""
|
||||
|
||||
|
||||
def _resolve_existing_root() -> Optional[Any]:
|
||||
"""Tente de récupérer le Tk root déjà créé par la ChatWindow.
|
||||
|
||||
On évite tk._default_root (deprecated) et on remonte plutôt via les
|
||||
threads existants : la ChatWindow garde une référence dans son instance
|
||||
mais n'expose rien de global. On se rabat donc sur la création d'un Tk
|
||||
indépendant si on n'a rien — c'est sûr, tkinter supporte plusieurs Tk()
|
||||
concurrents tant qu'ils sont chacun dans leur propre thread.
|
||||
"""
|
||||
try:
|
||||
import tkinter as tk
|
||||
# tk._default_root est interne mais c'est le moyen le plus simple
|
||||
# de partager un mainloop existant. Si ChatWindow tourne, ce sera
|
||||
# son root.
|
||||
root = getattr(tk, "_default_root", None)
|
||||
if root is not None:
|
||||
# Vérifier qu'il est encore vivant
|
||||
try:
|
||||
root.winfo_exists()
|
||||
return root
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_toast(parent: Any, title: str, message: str) -> Any:
|
||||
"""Construit le Toplevel toast (appelé dans le thread tkinter)."""
|
||||
import tkinter as tk
|
||||
|
||||
top = tk.Toplevel(parent)
|
||||
top.withdraw() # éviter le flash pendant la construction
|
||||
top.overrideredirect(True) # pas de barre de titre
|
||||
top.attributes("-topmost", True)
|
||||
try:
|
||||
# Petit boost de visibilité Windows : alpha légèrement transparent
|
||||
top.attributes("-alpha", 0.97)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bordure visuelle (cadre extérieur foncé)
|
||||
outer = tk.Frame(top, bg=TOAST_BORDER, padx=2, pady=2)
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
# Bandeau titre
|
||||
title_frame = tk.Frame(outer, bg=TOAST_TITLE_BG)
|
||||
title_frame.pack(fill="x")
|
||||
tk.Label(
|
||||
title_frame,
|
||||
text=f" ⏸ {title}",
|
||||
bg=TOAST_TITLE_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
anchor="w",
|
||||
padx=10,
|
||||
pady=8,
|
||||
).pack(fill="x")
|
||||
|
||||
# Corps du message
|
||||
body_frame = tk.Frame(outer, bg=TOAST_BG)
|
||||
body_frame.pack(fill="both", expand=True)
|
||||
tk.Label(
|
||||
body_frame,
|
||||
text=message,
|
||||
bg=TOAST_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 11),
|
||||
wraplength=TOAST_WIDTH - 40,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
padx=TOAST_PAD_X,
|
||||
pady=TOAST_PAD_Y,
|
||||
).pack(fill="both", expand=True)
|
||||
|
||||
# Pied de page : "Cliquez pour fermer"
|
||||
footer = tk.Label(
|
||||
outer,
|
||||
text="Cliquez pour fermer",
|
||||
bg=TOAST_BG,
|
||||
fg="#BFDBFE",
|
||||
font=("Segoe UI", 9, "italic"),
|
||||
anchor="e",
|
||||
padx=10,
|
||||
pady=4,
|
||||
)
|
||||
footer.pack(fill="x", side="bottom")
|
||||
|
||||
# Position : haut-droite de l'écran principal
|
||||
top.update_idletasks()
|
||||
height = top.winfo_reqheight()
|
||||
screen_w = top.winfo_screenwidth()
|
||||
x = screen_w - TOAST_WIDTH - 16
|
||||
y = 16
|
||||
top.geometry(f"{TOAST_WIDTH}x{height}+{x}+{y}")
|
||||
|
||||
# Click anywhere to close
|
||||
def _close(_=None):
|
||||
try:
|
||||
top.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top.bind("<Button-1>", _close)
|
||||
for child in (outer, title_frame, body_frame, footer):
|
||||
try:
|
||||
child.bind("<Button-1>", _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Afficher + boost focus brut pour passer devant Focus Assist
|
||||
top.deiconify()
|
||||
top.lift()
|
||||
try:
|
||||
top.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-pin topmost après 100 ms (Windows désactive parfois -topmost
|
||||
# quand le focus est pris par une autre app)
|
||||
def _repin():
|
||||
try:
|
||||
top.attributes("-topmost", True)
|
||||
top.lift()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
top.after(100, _repin)
|
||||
top.after(500, _repin)
|
||||
top.after(2000, _repin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-close
|
||||
try:
|
||||
top.after(TOAST_DURATION_MS, _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return top
|
||||
|
||||
|
||||
def _show_in_dedicated_thread(title: str, message: str) -> None:
|
||||
"""Crée un Tk() indépendant dans un daemon thread.
|
||||
|
||||
Utilisé en fallback quand aucun Tk root n'existe. Le thread vit le
|
||||
temps du toast (~15s) puis se termine proprement.
|
||||
"""
|
||||
def _run():
|
||||
try:
|
||||
# DPI awareness (Windows haute résolution)
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
dpi = root.winfo_fpixels("1i")
|
||||
root.tk.call("tk", "scaling", dpi / 72.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top = _build_toast(root, title, message)
|
||||
|
||||
# Quitter mainloop quand le toast est détruit
|
||||
def _watch():
|
||||
try:
|
||||
if not top.winfo_exists():
|
||||
root.quit()
|
||||
return
|
||||
except Exception:
|
||||
root.quit()
|
||||
return
|
||||
root.after(200, _watch)
|
||||
|
||||
root.after(200, _watch)
|
||||
root.mainloop()
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("paused_toast dedicated thread failed", exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="paused-toast-tk")
|
||||
t.start()
|
||||
|
||||
|
||||
def show_paused_toast(
|
||||
title: str = "Léa a besoin de votre aide",
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
"""Affiche un toast paused topmost.
|
||||
|
||||
Thread-safe, rate-limité, sans dépendance externe. Retourne True si le
|
||||
toast a été déclenché, False s'il a été ignoré (rate limit ou erreur).
|
||||
"""
|
||||
global _last_shown_at, _last_message
|
||||
|
||||
if not message:
|
||||
message = "Action en attente de votre validation."
|
||||
|
||||
# Rate limit basique : éviter qu'un poll en boucle ouvre 50 toasts
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
same_message = (message == _last_message)
|
||||
elapsed = now - _last_shown_at
|
||||
if same_message and elapsed < TOAST_RATE_LIMIT_S:
|
||||
logger.debug(
|
||||
"paused_toast rate-limited (%.1fs since last identical)", elapsed
|
||||
)
|
||||
return False
|
||||
_last_shown_at = now
|
||||
_last_message = message
|
||||
|
||||
# Tentative 1 : utiliser le Tk root existant (ChatWindow) via after()
|
||||
root = _resolve_existing_root()
|
||||
if root is not None:
|
||||
try:
|
||||
root.after(0, lambda: _build_toast(root, title, message))
|
||||
logger.info("paused_toast scheduled on existing Tk root")
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("paused_toast existing-root path failed", exc_info=True)
|
||||
|
||||
# Tentative 2 : créer un Tk() dans un daemon thread
|
||||
try:
|
||||
_show_in_dedicated_thread(title, message)
|
||||
logger.info("paused_toast scheduled in dedicated thread")
|
||||
return True
|
||||
except Exception:
|
||||
logger.error("paused_toast dedicated-thread path failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["show_paused_toast"]
|
||||
@@ -9,6 +9,7 @@ Inclut les endpoints de replay pour renvoyer des ordres d'exécution à l'Agent
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -505,6 +506,33 @@ _pending_lock = threading.Lock()
|
||||
# Chaque session a une queue d'actions à exécuter et un état de replay
|
||||
# =========================================================================
|
||||
_replay_lock = threading.Lock()
|
||||
|
||||
|
||||
# Context manager async pour acquérir _replay_lock sans bloquer l'event loop
|
||||
# FastAPI. Pattern complémentaire au commit 35b27ae49 (lock async sur
|
||||
# /replay/next) et 87dbe8c5f (get_replay_status non-bloquant) : tous les
|
||||
# endpoints `async def` qui faisaient `with _replay_lock:` synchrone gelaient
|
||||
# l'event loop dès qu'une opération longue tenait le lock dans un autre
|
||||
# thread. Avec ce helper, l'acquire passe par run_in_executor (l'event loop
|
||||
# reste libre pour servir les autres requêtes pendant l'attente). Si le lock
|
||||
# est tenu plus de `timeout` secondes, on retourne 503 plutôt que de geler le
|
||||
# serveur.
|
||||
@contextlib.asynccontextmanager
|
||||
async def _async_replay_lock(timeout: float = 4.5):
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, timeout)
|
||||
if not acquired:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Serveur occupé (lock _replay tenu > {timeout}s) — réessayer",
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_replay_lock.release()
|
||||
|
||||
|
||||
# session_id -> liste d'actions en attente (FIFO)
|
||||
_replay_queues: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
# machine_id -> session_id (mapping pour le replay ciblé par machine)
|
||||
@@ -535,6 +563,11 @@ class RawReplayRequest(BaseModel):
|
||||
session_id: str = ""
|
||||
machine_id: Optional[str] = None # Machine cible (multi-machine)
|
||||
task_description: str = ""
|
||||
# Paramètres runtime du replay (lus dans replay_state.params côté pipeline).
|
||||
# Notamment execution_mode : "autonomous" (défaut, pause_for_human skippée)
|
||||
# ou "supervised" (pause_for_human bloque jusqu'à validation humaine via
|
||||
# PauseDialog VWB). Cf. replay_engine.py / api_stream.py:2964.
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SingleActionRequest(BaseModel):
|
||||
@@ -1982,7 +2015,7 @@ async def start_replay(request: ReplayRequest):
|
||||
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
|
||||
|
||||
# Injecter les actions dans la queue de la session
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
_replay_queues[session_id] = list(actions) # Remplacer la queue existante
|
||||
_replay_states[replay_id] = _create_replay_state(
|
||||
replay_id=replay_id,
|
||||
@@ -2082,7 +2115,7 @@ async def start_raw_replay(request: RawReplayRequest):
|
||||
session_obj = processor.session_manager.get_session(session_id)
|
||||
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
# ── Nettoyage : annuler les replays bloqués pour cette machine ──
|
||||
# Un replay en paused_need_help bloque tous les suivants.
|
||||
# Quand on lance un nouveau replay, les anciens sont obsolètes.
|
||||
@@ -2109,7 +2142,7 @@ async def start_raw_replay(request: RawReplayRequest):
|
||||
workflow_id=f"free_task:{task[:50]}",
|
||||
session_id=session_id,
|
||||
total_actions=len(actions),
|
||||
params={},
|
||||
params=dict(request.params or {}),
|
||||
machine_id=resolved_machine_id,
|
||||
actions=actions,
|
||||
)
|
||||
@@ -2302,7 +2335,7 @@ async def replay_from_session(
|
||||
# ── 5. Injecter dans la queue de replay ──
|
||||
replay_id = f"replay_sess_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
_replay_queues[target_session_id] = list(actions)
|
||||
_replay_states[replay_id] = _create_replay_state(
|
||||
replay_id=replay_id,
|
||||
@@ -2393,7 +2426,7 @@ async def enqueue_single_action(request: SingleActionRequest):
|
||||
|
||||
action_id = action["action_id"]
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
_replay_queues[session_id].append(action)
|
||||
|
||||
logger.info(
|
||||
@@ -2559,7 +2592,7 @@ async def launch_replay_from_plan(request: PlanReplayRequest):
|
||||
or (session_obj.machine_id if session_obj else "default")
|
||||
)
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
_replay_queues[target_session_id] = list(validated)
|
||||
_replay_states[replay_id] = _create_replay_state(
|
||||
replay_id=replay_id,
|
||||
@@ -2988,25 +3021,40 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
# les autres polls (extract_text OCR ~5s, t2a_decision LLM ~8-13s).
|
||||
# Le lock reste tenu (queue cohérente) mais l'event loop est libre,
|
||||
# donc les polls concurrents peuvent recevoir {server_busy: True}.
|
||||
#
|
||||
# Borne dure 180s par action : un hang d'EasyOCR / Ollama / I/O
|
||||
# ne doit JAMAIS pouvoir tenir _replay_lock indéfiniment, sinon
|
||||
# tous les endpoints sous lock (get_replay_status, /replay/next…)
|
||||
# gèlent le serveur. TimeoutError est rattrapée par l'except
|
||||
# Exception ci-dessous → queue.pop(0) → on passe à la suite.
|
||||
if type_ in _SERVER_SIDE_ACTION_TYPES and owning_replay is not None:
|
||||
try:
|
||||
if type_ == "extract_text":
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_text_action,
|
||||
action, owning_replay, session_id, _last_heartbeat,
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_text_action,
|
||||
action, owning_replay, session_id, _last_heartbeat,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "extract_table":
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_table_action,
|
||||
action, owning_replay, session_id, _last_heartbeat,
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_extract_table_action,
|
||||
action, owning_replay, session_id, _last_heartbeat,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "t2a_decision":
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_handle_t2a_decision_action,
|
||||
action, owning_replay,
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_t2a_decision_action,
|
||||
action, owning_replay,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Action serveur {type_} a levé : {e}")
|
||||
@@ -3102,7 +3150,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
auth_actions = _auth_handler.get_auth_actions(auth_request)
|
||||
if auth_actions:
|
||||
# Injecter les actions d'auth en tête de queue (avant l'action bloquée)
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
current_q = _replay_queues.get(session_id, [])
|
||||
_replay_queues[session_id] = auth_actions + current_q
|
||||
logger.info(
|
||||
@@ -3111,7 +3159,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
f"type={auth_request.auth_type} (confiance={auth_request.confidence:.2f})"
|
||||
)
|
||||
# Retourner la première action d'auth immédiatement
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
first_auth = _replay_queues[session_id].pop(0)
|
||||
return {
|
||||
"action": first_auth,
|
||||
@@ -3159,7 +3207,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
}
|
||||
|
||||
# Pre-check OK (ou skip) : retirer l'action de la queue et l'envoyer
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
current_queue = _replay_queues.get(session_id, [])
|
||||
if current_queue and current_queue[0].get("action_id") == action.get("action_id"):
|
||||
current_queue.pop(0)
|
||||
@@ -3291,7 +3339,7 @@ async def report_action_result(report: ReplayResultReport):
|
||||
)
|
||||
|
||||
# Trouver le replay correspondant à cette session
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
replay_state = None
|
||||
for state in _replay_states.values():
|
||||
if state["session_id"] == session_id and state["status"] == "running":
|
||||
@@ -3324,7 +3372,7 @@ async def report_action_result(report: ReplayResultReport):
|
||||
# Mettre à jour le dernier screenshot reçu
|
||||
screenshot_after = report.screenshot_after or report.screenshot
|
||||
if screenshot_after:
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
replay_state["last_screenshot"] = screenshot_after
|
||||
|
||||
# === Vérification post-action ===
|
||||
@@ -3395,7 +3443,7 @@ async def report_action_result(report: ReplayResultReport):
|
||||
|
||||
# Stocker le screenshot actuel comme "before" pour la prochaine action
|
||||
if screenshot_after:
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
replay_state["_last_screenshot_before"] = screenshot_after
|
||||
|
||||
# [REPLAY] log structuré de la décision de vérification
|
||||
@@ -3417,7 +3465,7 @@ async def report_action_result(report: ReplayResultReport):
|
||||
)
|
||||
|
||||
# === Enregistrer le résultat ===
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
result_entry = {
|
||||
"action_id": action_id,
|
||||
"success": report.success,
|
||||
@@ -3577,7 +3625,7 @@ async def report_action_result(report: ReplayResultReport):
|
||||
except Exception as _mem_exc:
|
||||
logger.debug("Memory record skipped : %s", _mem_exc)
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
# === Logique de retry / success / failure ===
|
||||
if report.success and (verification is None or verification.verified):
|
||||
# Action réussie (vérification OK ou pas de vérification)
|
||||
@@ -4089,7 +4137,7 @@ async def register_error_callback(config: ErrorCallbackConfig):
|
||||
replay_id = config.replay_id
|
||||
callback_url = config.callback_url
|
||||
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
if replay_id not in _replay_states:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -4113,34 +4161,52 @@ async def get_replay_status(replay_id: str):
|
||||
Quand le replay est en pause supervisee (paused_need_help), la reponse
|
||||
inclut le contexte complet de l'echec : action echouee, screenshot,
|
||||
target_spec, et message utilisateur.
|
||||
|
||||
Endpoint poll-friendly : l'acquisition du lock est timeboxée à 0.5 s.
|
||||
Si une action serveur lente (extract_text/extract_table/t2a_decision)
|
||||
tient le lock, le poll repart immédiatement avec status="busy" plutôt
|
||||
que de bloquer l'event loop FastAPI (qui gèlerait l'ensemble des
|
||||
endpoints jusqu'à libération). Suite logique du commit 35b27ae49 qui
|
||||
avait déjà appliqué ce pattern à /replay/next ; QW4 a recâblé le
|
||||
polling frontend ici → même classe de bug, même remède.
|
||||
"""
|
||||
with _replay_lock:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 0.5)
|
||||
if not acquired:
|
||||
return {
|
||||
"replay_id": replay_id,
|
||||
"status": "busy",
|
||||
"message": "Serveur occupé (action en cours), réessaie dans 1s",
|
||||
}
|
||||
try:
|
||||
state = _replay_states.get(replay_id)
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
|
||||
)
|
||||
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
|
||||
)
|
||||
# Filtrer les champs internes (prefixes par _)
|
||||
result = {k: v for k, v in state.items() if not k.startswith("_")}
|
||||
|
||||
# Filtrer les champs internes (prefixes par _)
|
||||
result = {k: v for k, v in state.items() if not k.startswith("_")}
|
||||
# Enrichir avec le contexte de pause si applicable
|
||||
if state["status"] == "paused_need_help":
|
||||
session_id = state["session_id"]
|
||||
remaining = len(_replay_queues.get(session_id, []))
|
||||
result["actions_completed"] = state["completed_actions"]
|
||||
result["actions_remaining"] = remaining
|
||||
result["message"] = state.get("pause_message", "Replay en pause")
|
||||
# Le failed_action contient deja screenshot_b64 et target_spec
|
||||
|
||||
# Enrichir avec le contexte de pause si applicable
|
||||
if state["status"] == "paused_need_help":
|
||||
session_id = state["session_id"]
|
||||
remaining = len(_replay_queues.get(session_id, []))
|
||||
result["actions_completed"] = state["completed_actions"]
|
||||
result["actions_remaining"] = remaining
|
||||
result["message"] = state.get("pause_message", "Replay en pause")
|
||||
# Le failed_action contient deja screenshot_b64 et target_spec
|
||||
|
||||
return result
|
||||
return result
|
||||
finally:
|
||||
_replay_lock.release()
|
||||
|
||||
|
||||
@app.get("/api/v1/traces/stream/replays")
|
||||
async def list_replays():
|
||||
"""Lister tous les replays (actifs, terminés, en erreur)."""
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
# Filtrer les champs internes (préfixés par _)
|
||||
return {
|
||||
"replays": [
|
||||
@@ -4172,7 +4238,7 @@ async def resume_replay(
|
||||
`required` doivent figurer dans `acknowledged_check_ids`. Sinon → 400
|
||||
avec `{"error": "required_checks_missing", "missing": [...]}`.
|
||||
"""
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
state = _replay_states.get(replay_id)
|
||||
|
||||
if not state:
|
||||
@@ -4268,7 +4334,7 @@ async def resume_replay(
|
||||
@app.post("/api/v1/traces/stream/replay/{replay_id}/cancel")
|
||||
async def cancel_replay(replay_id: str):
|
||||
"""Annuler un replay (quel que soit son statut) et vider sa queue."""
|
||||
with _replay_lock:
|
||||
async with _async_replay_lock():
|
||||
state = _replay_states.get(replay_id)
|
||||
if not state:
|
||||
raise HTTPException(status_code=404, detail=f"Replay '{replay_id}' non trouvé")
|
||||
@@ -4339,6 +4405,72 @@ async def resolve_target(request: ResolveTargetRequest):
|
||||
logger.error(f"Décodage screenshot échoué: {e}")
|
||||
return _fallback_response(request, "decode_error", str(e))
|
||||
|
||||
# Détection image tronquée + fallback heartbeat full screen.
|
||||
# Bug client constaté ce 2026-05-07 (PC Windows 192.168.1.11, agent V1) :
|
||||
# mss.monitors[1] retourne parfois une bande étroite type 2560x60, 2560x108,
|
||||
# 600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
|
||||
# ou un état mss corrompu. Reproductible même PC en mono physique. Cause
|
||||
# exacte non isolée côté client (cf. session_20260506_handoff_v2.md).
|
||||
# Les heartbeats (capturer.py, chemin différent de executor.py) restent en
|
||||
# full screen 2560x1600. On compense ici en remplaçant l'image tronquée
|
||||
# par le dernier heartbeat avant la cascade _resolve_target_sync.
|
||||
effective_w = request.screen_width
|
||||
effective_h = request.screen_height
|
||||
# Seuil large : un écran moderne fait 2560x1600 ou plus. Tout en dessous
|
||||
# de 1200x800 est suspect — bug client mss.monitors[1] qui crop sur
|
||||
# barre des tâches (2560x60), Edge fenêtré (622x856), etc.
|
||||
if img.height < 800 or img.width < 1200:
|
||||
logger.warning(
|
||||
"[RESOLVE_TARGET] Image client tronquée %dx%d (declared %dx%d) — "
|
||||
"fallback heartbeat full screen",
|
||||
img.width, img.height, effective_w, effective_h,
|
||||
)
|
||||
# Source 1 : _last_heartbeat (mémoire, peuplé par /stream/image)
|
||||
candidate_path = None
|
||||
candidate_age_s = None
|
||||
latest_hb = max(
|
||||
(h for h in _last_heartbeat.values() if h.get("path")),
|
||||
key=lambda h: h.get("timestamp", 0),
|
||||
default=None,
|
||||
)
|
||||
if latest_hb and os.path.isfile(latest_hb["path"]):
|
||||
candidate_path = latest_hb["path"]
|
||||
candidate_age_s = time.time() - latest_hb.get("timestamp", time.time())
|
||||
else:
|
||||
# Source 2 : scan disque (utile après restart serveur, avant que
|
||||
# _last_heartbeat ne se repeuple — ou si l'agent V1 ne polle pas)
|
||||
try:
|
||||
import glob as _glob
|
||||
pattern = "/home/dom/ai/rpa_vision_v3/data/training/live_sessions/*/bg_*/shots/heartbeat_*.png"
|
||||
all_files = _glob.glob(pattern)
|
||||
files = [
|
||||
f for f in all_files
|
||||
if "_blurred" not in f and os.path.isfile(f)
|
||||
]
|
||||
logger.info(
|
||||
"[RESOLVE_TARGET] Scan disque : %d match glob, %d non-blurred existants",
|
||||
len(all_files), len(files),
|
||||
)
|
||||
if files:
|
||||
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
||||
candidate_path = files[0]
|
||||
candidate_age_s = time.time() - os.path.getmtime(candidate_path)
|
||||
except Exception as e:
|
||||
logger.warning("[RESOLVE_TARGET] Scan disque heartbeat échoué : %s", e)
|
||||
|
||||
if candidate_path:
|
||||
try:
|
||||
img = Image.open(candidate_path)
|
||||
effective_w, effective_h = img.size
|
||||
logger.info(
|
||||
"[RESOLVE_TARGET] Heartbeat fallback OK : %s (%dx%d, age=%.1fs)",
|
||||
candidate_path, effective_w, effective_h, candidate_age_s or -1,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[RESOLVE_TARGET] Ouverture heartbeat échouée : %s", e)
|
||||
else:
|
||||
logger.warning("[RESOLVE_TARGET] Aucun heartbeat disponible pour fallback")
|
||||
|
||||
# Sauver temporairement pour les analyseurs (ils attendent un chemin fichier)
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
img.save(tmp, format="JPEG", quality=90)
|
||||
@@ -4354,8 +4486,8 @@ async def resolve_target(request: ResolveTargetRequest):
|
||||
_resolve_target_sync,
|
||||
tmp_path,
|
||||
request.target_spec,
|
||||
request.screen_width,
|
||||
request.screen_height,
|
||||
effective_w,
|
||||
effective_h,
|
||||
request.fallback_x_pct,
|
||||
request.fallback_y_pct,
|
||||
request.strict_mode,
|
||||
@@ -4371,6 +4503,44 @@ async def resolve_target(request: ResolveTargetRequest):
|
||||
request.fallback_y_pct,
|
||||
)
|
||||
|
||||
# Pré-check sémantique post-cascade : OCR sur une zone autour de la
|
||||
# coordonnée résolue pour vérifier que le by_text attendu y est bien
|
||||
# présent. Attrape les cas où la cascade rend des coords plausibles
|
||||
# mais pointant sur un autre élément (ex : clic sur "Dossier en cours"
|
||||
# du menu au lieu de "Synthèse Urgences" du tab plus bas).
|
||||
if result and result.get("resolved"):
|
||||
_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
|
||||
_is_valid, _observed, _ocr_ms = _validate_text_at_position(
|
||||
tmp_path,
|
||||
float(result.get("x_pct", 0) or 0),
|
||||
float(result.get("y_pct", 0) or 0),
|
||||
_by_text,
|
||||
effective_w,
|
||||
effective_h,
|
||||
)
|
||||
if not _is_valid:
|
||||
logger.warning(
|
||||
"[REPLAY] Pre-check OCR REJET : '%s' attendu @ (%.4f, %.4f) "
|
||||
"via %s mais OCR voit '%s' (%.0fms)",
|
||||
_by_text[:40],
|
||||
float(result.get("x_pct", 0) or 0),
|
||||
float(result.get("y_pct", 0) or 0),
|
||||
result.get("method", "?"),
|
||||
_observed[:80],
|
||||
_ocr_ms,
|
||||
)
|
||||
result = {
|
||||
"resolved": False,
|
||||
"method": "rejected_text_mismatch",
|
||||
"reason": f"expected='{_by_text[:40]}' observed='{_observed[:60]}'",
|
||||
"original_method": result.get("method"),
|
||||
"original_score": result.get("score"),
|
||||
"x_pct": None,
|
||||
"y_pct": None,
|
||||
}
|
||||
|
||||
# [REPLAY] log structuré de sortie résolution (après validation)
|
||||
logger.info(
|
||||
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "
|
||||
@@ -4386,7 +4556,8 @@ async def resolve_target(request: ResolveTargetRequest):
|
||||
logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}")
|
||||
return _fallback_response(request, "analysis_error", str(e))
|
||||
finally:
|
||||
import os
|
||||
# `os` est déjà importé en haut du fichier — pas de re-import local
|
||||
# (sinon UnboundLocalError plus haut dans la fonction).
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
|
||||
@@ -1746,6 +1746,49 @@ def _resolve_target_sync(
|
||||
)
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Étape 0.5 : OCR direct (hybrid_text_direct) — chemin rapide
|
||||
# ---------------------------------------------------------------
|
||||
# Si on a un texte cible non vide, le localiser par OCR direct
|
||||
# avant de tomber sur le VLM (~100-300ms vs 2-23s par appel VLM).
|
||||
# Reconnecté le 2026-05-06 : la fonction _resolve_by_ocr_text
|
||||
# existait déjà mais n'était appelée QUE depuis le runtime V4
|
||||
# (resolve_order pré-compilé), qui n'est pas branché côté frontend
|
||||
# (cf. audit project-quality-guardian Cas #5). La cascade legacy
|
||||
# tombait directement sur VLM Quick Find d'où des replays à 23s
|
||||
# par action visuelle au lieu de <500ms attendus.
|
||||
# Le method est rebadgé "hybrid_text_direct" (seuil 0.80 dans
|
||||
# _RESOLUTION_MIN_SCORES, identifiant historique côté client
|
||||
# Agent V1 et logs Learning).
|
||||
if by_text_strict:
|
||||
ocr_result = _resolve_by_ocr_text(
|
||||
screenshot_path=screenshot_path,
|
||||
target_text=by_text_strict,
|
||||
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(
|
||||
"Strict resolve OCR-DIRECT : OK '%s' → (%.4f, %.4f) score=%.2f",
|
||||
by_text_strict[:40],
|
||||
ocr_result.get("x_pct", 0),
|
||||
ocr_result.get("y_pct", 0),
|
||||
ocr_result.get("score", 0),
|
||||
)
|
||||
return ocr_result
|
||||
elif ocr_result:
|
||||
logger.info(
|
||||
"Strict resolve OCR-DIRECT : '%s' trouvé score=%.2f < 0.80, passage VLM",
|
||||
by_text_strict[:40],
|
||||
ocr_result.get("score", 0),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Strict resolve OCR-DIRECT : '%s' non trouvé, passage VLM",
|
||||
by_text_strict[:40],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Étape 1 : VLM Quick Find (fallback, multi-image)
|
||||
# ---------------------------------------------------------------
|
||||
@@ -2117,6 +2160,135 @@ _RESOLUTION_MIN_SCORES: Dict[str, float] = {
|
||||
_RESOLUTION_MAX_DRIFT: float = 0.20
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Pré-check sémantique : OCR de validation de position
|
||||
# ===========================================================================
|
||||
# Avant de dispatcher un clic, on vérifie que le texte attendu (by_text) est
|
||||
# bien présent dans une fenêtre OCR autour de la coordonnée résolue. Cela
|
||||
# attrape les cas où la cascade renvoie une coordonnée plausible mais qui
|
||||
# pointe en réalité sur un autre élément (ex: clic sur "Dossier en cours" du
|
||||
# menu au lieu de "Synthèse Urgences" du tab plus bas).
|
||||
# ===========================================================================
|
||||
|
||||
_VALIDATION_OCR_READER = None
|
||||
_VALIDATION_OCR_LOCK = threading.Lock()
|
||||
_VALIDATION_OCR_FAILED = False
|
||||
|
||||
|
||||
def _get_validation_ocr_reader():
|
||||
"""Singleton EasyOCR partagé pour la validation post-cascade.
|
||||
|
||||
Chargement paresseux à la première requête. En cas d'échec, on cache
|
||||
le statut FAILED pour ne pas retenter à chaque appel et bloquer le flux.
|
||||
"""
|
||||
global _VALIDATION_OCR_READER, _VALIDATION_OCR_FAILED
|
||||
if _VALIDATION_OCR_FAILED:
|
||||
return None
|
||||
with _VALIDATION_OCR_LOCK:
|
||||
if _VALIDATION_OCR_READER is None and not _VALIDATION_OCR_FAILED:
|
||||
try:
|
||||
import easyocr # type: ignore
|
||||
_VALIDATION_OCR_READER = easyocr.Reader(
|
||||
['fr', 'en'], gpu=True, verbose=False
|
||||
)
|
||||
logger.info("[REPLAY] EasyOCR validator chargé (fr+en, GPU)")
|
||||
except Exception as e:
|
||||
logger.warning("[REPLAY] EasyOCR validator indisponible (%s) — pré-check désactivé", e)
|
||||
_VALIDATION_OCR_FAILED = True
|
||||
return None
|
||||
return _VALIDATION_OCR_READER
|
||||
|
||||
|
||||
def _normalize_for_match(s: str) -> str:
|
||||
"""Normalisation pour comparaison textuelle robuste : lowercase, sans
|
||||
accents, ponctuation → espace, espaces multiples écrasés.
|
||||
"""
|
||||
import unicodedata
|
||||
decomposed = unicodedata.normalize('NFD', s.lower())
|
||||
no_accents = ''.join(c for c in decomposed if unicodedata.category(c) != 'Mn')
|
||||
cleaned = ''.join(c if c.isalnum() or c.isspace() else ' ' for c in no_accents)
|
||||
return ' '.join(cleaned.split())
|
||||
|
||||
|
||||
def _text_match_fuzzy(expected: str, observed: str, min_token_ratio: float = 0.60) -> bool:
|
||||
"""Match tolérant aux imperfections OCR.
|
||||
|
||||
1. Substring exacte → match.
|
||||
2. Sinon : split en tokens ≥3 caractères, retourne True si au moins
|
||||
`min_token_ratio` des tokens attendus apparaissent dans observed.
|
||||
Ex : "Coller ou saisir le dossier patient" → tokens
|
||||
['coller', 'saisir', 'dossier', 'patient'] ; si OCR voit "u saisir
|
||||
le dossier patient" → 3/4 = 75% présents → match accepté.
|
||||
|
||||
Cible le compromis entre strict (faux négatifs sur erreurs OCR) et
|
||||
permissif (faux positifs sur textes voisins).
|
||||
"""
|
||||
nexp = _normalize_for_match(expected)
|
||||
nobs = _normalize_for_match(observed)
|
||||
if not nexp:
|
||||
return True
|
||||
if nexp in nobs:
|
||||
return True
|
||||
tokens = [t for t in nexp.split() if len(t) >= 3]
|
||||
if not tokens:
|
||||
return False
|
||||
matched = sum(1 for t in tokens if t in nobs)
|
||||
return matched / len(tokens) >= min_token_ratio
|
||||
|
||||
|
||||
def _validate_text_at_position(
|
||||
screenshot_path: str,
|
||||
x_pct: float,
|
||||
y_pct: float,
|
||||
expected_text: str,
|
||||
screen_width: int,
|
||||
screen_height: int,
|
||||
radius_px: int = 200,
|
||||
) -> 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 60%).
|
||||
|
||||
Retourne (is_valid: bool, observed_text: str, elapsed_ms: float).
|
||||
|
||||
Politique en cas d'échec OCR (lib absente, exception) : retourne
|
||||
(True, "", 0.0) pour ne pas bloquer le flux. Mieux vaut un faux positif
|
||||
rare qu'une régression bloquante introduite par la validation elle-même.
|
||||
"""
|
||||
reader = _get_validation_ocr_reader()
|
||||
if reader is None:
|
||||
return True, "", 0.0
|
||||
if not expected_text or not expected_text.strip():
|
||||
return True, "", 0.0
|
||||
try:
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
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)
|
||||
if x2 - x1 < 10 or y2 - y1 < 10:
|
||||
return True, "", 0.0
|
||||
crop = img.crop((x1, y1, x2, y2))
|
||||
results = reader.readtext(np.array(crop))
|
||||
observed = " ".join(r[1] for r in results if r and len(r) >= 2)
|
||||
elapsed_ms = (time.time() - t0) * 1000
|
||||
is_valid = _text_match_fuzzy(expected_text, observed, min_token_ratio=0.60)
|
||||
return is_valid, observed, elapsed_ms
|
||||
except Exception as e:
|
||||
logger.warning("[REPLAY] _validate_text_at_position erreur (%s) — pas de blocage", e)
|
||||
return True, "", 0.0
|
||||
|
||||
|
||||
def _validate_resolution_quality(
|
||||
result: Optional[Dict[str, Any]],
|
||||
fallback_x_pct: float,
|
||||
@@ -2193,33 +2365,50 @@ def _validate_resolution_quality(
|
||||
dx = abs(resolved_x - fallback_x_pct)
|
||||
dy = abs(resolved_y - fallback_y_pct)
|
||||
if dx > _RESOLUTION_MAX_DRIFT or dy > _RESOLUTION_MAX_DRIFT:
|
||||
# Exception : si le template matching trouve l'image avec une
|
||||
# similarité quasi parfaite, on fait confiance à la position
|
||||
# visuelle peu importe le drift. Une image retrouvée à >= 0.95
|
||||
# de score est SUR l'écran à l'endroit indiqué — le drift par
|
||||
# rapport à l'enregistrement ne reflète qu'un changement de
|
||||
# layout (scroll, redimensionnement, F11, devtools), pas une
|
||||
# erreur de résolution.
|
||||
_HIGH_CONFIDENCE = 0.95
|
||||
if score >= _HIGH_CONFIDENCE and method.startswith("template_matching"):
|
||||
# Exception : pour les méthodes "haute confiance" qui ont
|
||||
# identifié sémantiquement la cible (texte exact via OCR ou
|
||||
# image quasi parfaite via template), on fait confiance à la
|
||||
# position visuelle peu importe le drift. Le drift par rapport
|
||||
# à l'enregistrement ne reflète qu'un changement de layout
|
||||
# (scroll, redimensionnement, F11, refonte UI, résolution
|
||||
# différente), pas une erreur de résolution.
|
||||
#
|
||||
# - template_matching ≥ 0.95 : image retrouvée pixel-perfect
|
||||
# - hybrid_text_direct ≥ 0.80 : texte exact reconnu par OCR
|
||||
# (0.80 est déjà le seuil d'acceptation côté _RESOLUTION_MIN_SCORES,
|
||||
# au-dessus on a un signal sémantique fiable).
|
||||
_high_confidence_method = (
|
||||
(method.startswith("template_matching") and score >= 0.95)
|
||||
or (method == "hybrid_text_direct" and score >= 0.80)
|
||||
)
|
||||
if _high_confidence_method:
|
||||
logger.info(
|
||||
"[REPLAY] Drift (%.3f, %.3f) > %.2f IGNORÉ : score=%.3f >= %.2f "
|
||||
"[REPLAY] Drift (%.3f, %.3f) > %.2f IGNORÉ : score=%.3f "
|
||||
"sur %s — résultat visuel fiable, on l'utilise",
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT, score, _HIGH_CONFIDENCE, method,
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT, score, method,
|
||||
)
|
||||
return result
|
||||
|
||||
logger.warning(
|
||||
"[REPLAY] Drift trop grand (%.3f, %.3f) > %.2f — fallback coords enregistrées (%.3f, %.3f)",
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT, fallback_x_pct, fallback_y_pct,
|
||||
"[REPLAY] Resolution REJETÉE (drift trop grand) : "
|
||||
"method=%s resolved=(%.3f, %.3f) expected=(%.3f, %.3f) "
|
||||
"drift=(%.3f, %.3f) max=%.2f",
|
||||
method, resolved_x, resolved_y,
|
||||
fallback_x_pct, fallback_y_pct,
|
||||
dx, dy, _RESOLUTION_MAX_DRIFT,
|
||||
)
|
||||
# Fallback : coordonnées enregistrées lors de la capture (écran identique = safe)
|
||||
# 100% visuel : on ne clique JAMAIS aux coords enregistrées en aveugle.
|
||||
# resolved=False → la couche supérieure tente la méthode suivante
|
||||
# (VLM Quick Find, SoM, grounding) ; si toutes échouent, l'agent
|
||||
# passe par "visual_resolve_failed" → Policy → pause supervisée.
|
||||
return {
|
||||
"resolved": True,
|
||||
"method": "fallback_recorded_coords",
|
||||
"reason": f"drift_dx{dx:.3f}_dy{dy:.3f}_using_recorded",
|
||||
"resolved": False,
|
||||
"method": f"rejected_drift_{method}",
|
||||
"reason": f"drift_dx{dx:.3f}_dy{dy:.3f}_max{_RESOLUTION_MAX_DRIFT:.2f}",
|
||||
"original_method": method,
|
||||
"original_score": score,
|
||||
"drift_dx": round(dx, 3),
|
||||
"drift_dy": round(dy, 3),
|
||||
"x_pct": fallback_x_pct,
|
||||
"y_pct": fallback_y_pct,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ logger = logging.getLogger(__name__)
|
||||
try:
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception:
|
||||
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
|
||||
# quand X n'est pas accessible — typique d'un service systemd côté serveur.
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
|
||||
try:
|
||||
|
||||
@@ -58,7 +58,9 @@ except ImportError:
|
||||
try:
|
||||
import pyautogui
|
||||
PYAUTOGUI_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception:
|
||||
# pyautogui peut lever Xlib.error.DisplayConnectionError ou KeyError('DISPLAY')
|
||||
# quand X n'est pas accessible — typique d'un service systemd côté serveur.
|
||||
pyautogui = None
|
||||
PYAUTOGUI_AVAILABLE = False
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ WorkingDirectory=/home/dom/ai/rpa_vision_v3
|
||||
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
Environment="RPA_SERVICE_NAME=rpa-streaming"
|
||||
# Service grounding persistant — socket + répertoire d'images partagés via /run/rpa/.
|
||||
Environment="RPA_GROUNDING_SOCKET=/run/rpa/grounding.sock"
|
||||
Environment="RPA_GROUNDING_IMG_DIR=/run/rpa"
|
||||
|
||||
# Lancement via le module Python (même commande que svc.sh)
|
||||
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 -m agent_v0.server_v1.api_stream
|
||||
@@ -29,6 +32,10 @@ KillSignal=SIGTERM
|
||||
# ---- Hardening (raisonnable pour un poste de dev/prod) ----
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
# /run/rpa/ partagé avec rpa-grounding (socket + images)
|
||||
RuntimeDirectory=rpa
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
|
||||
# Logs -> journald
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -14,6 +14,11 @@ EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
Environment="ENVIRONMENT=production"
|
||||
Environment="RPA_SERVICE_NAME=rpa-vision-v3-api"
|
||||
# Service grounding persistant — socket + répertoire d'images partagés via /run/rpa/.
|
||||
# Si le service rpa-grounding n'est pas démarré, le client retombe automatiquement
|
||||
# sur le subprocess one-shot (cf. ui_tars_grounder.py).
|
||||
Environment="RPA_GROUNDING_SOCKET=/run/rpa/grounding.sock"
|
||||
Environment="RPA_GROUNDING_IMG_DIR=/run/rpa"
|
||||
|
||||
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 server/api_upload.py
|
||||
|
||||
@@ -25,6 +30,11 @@ TimeoutStopSec=30
|
||||
# ---- Hardening ----
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
# /run/rpa/ partagé avec rpa-grounding pour le socket et les images grounding.
|
||||
# Le service rpa-grounding crée le répertoire ; ici on l'expose au /run du service.
|
||||
RuntimeDirectory=rpa
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
|
||||
# Logs -> journald
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -12,6 +12,9 @@ EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
Environment="ENVIRONMENT=production"
|
||||
Environment="RPA_SERVICE_NAME=rpa-vision-v3-dashboard"
|
||||
# Service grounding persistant
|
||||
Environment="RPA_GROUNDING_SOCKET=/run/rpa/grounding.sock"
|
||||
Environment="RPA_GROUNDING_IMG_DIR=/run/rpa"
|
||||
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 web_dashboard/app.py
|
||||
|
||||
Restart=on-failure
|
||||
|
||||
@@ -10,6 +10,9 @@ Group=dom
|
||||
WorkingDirectory=/home/dom/ai/rpa_vision_v3
|
||||
EnvironmentFile=/home/dom/ai/rpa_vision_v3/.env.local
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
# Service grounding persistant — socket + répertoire d'images partagés via /run/rpa/.
|
||||
Environment="RPA_GROUNDING_SOCKET=/run/rpa/grounding.sock"
|
||||
Environment="RPA_GROUNDING_IMG_DIR=/run/rpa"
|
||||
ExecStart=/home/dom/ai/rpa_vision_v3/.venv/bin/python3 server/worker_daemon.py
|
||||
|
||||
Restart=on-failure
|
||||
@@ -18,6 +21,10 @@ TimeoutStopSec=60
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
# /run/rpa/ partagé avec rpa-grounding (socket + images)
|
||||
RuntimeDirectory=rpa
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
@@ -1082,8 +1082,22 @@ def execute_windows():
|
||||
if not data.get('session_id'):
|
||||
data['session_id'] = 'agent_demo_user'
|
||||
|
||||
# Injecter le machine_id pour le ciblage multi-machine
|
||||
# Chercher la première machine Windows connectée si pas spécifié
|
||||
# Forcer le mode supervisé : pause_for_human DÉCLENCHE au lieu d'être
|
||||
# skippée. Le médecin valide la décision Léa avant que les saisies
|
||||
# type_text ne s'exécutent dans l'onglet Codage. Crucial pour la démo
|
||||
# GHT : Léa propose, humain valide, Léa finalise (cf. workflow Urgence).
|
||||
# Sans ça, mode "autonomous" par défaut → pause skippée → saisies
|
||||
# tentées sans validation → désordre visuel.
|
||||
data.setdefault('params', {})
|
||||
data['params'].setdefault('execution_mode', 'supervised')
|
||||
|
||||
# Injecter le machine_id pour le ciblage multi-machine.
|
||||
# Cibler la machine Windows la plus récemment active (heartbeat last_activity)
|
||||
# plutôt que la première dans l'ordre arbitraire renvoyé par /machines :
|
||||
# un workflow enregistré sur PC A doit pouvoir être rejoué sur PC B (vision
|
||||
# 100 % visuelle, recalcul anchors+coords selon la résolution courante).
|
||||
# Le workflow.machine_id signale l'origine d'enregistrement, pas la cible
|
||||
# d'exécution — la cible doit être l'agent qui POLLE actuellement.
|
||||
if 'machine_id' not in data or not data.get('machine_id'):
|
||||
try:
|
||||
machines_resp = req.get(
|
||||
@@ -1093,11 +1107,19 @@ def execute_windows():
|
||||
)
|
||||
if machines_resp.ok:
|
||||
machines = machines_resp.json().get('machines', [])
|
||||
for m in machines:
|
||||
mid = m.get('machine_id', '')
|
||||
if mid and mid != 'default' and 'windows' in mid.lower():
|
||||
data['machine_id'] = mid
|
||||
break
|
||||
# Filtrer Windows + non default, trier par last_activity desc
|
||||
windows_machines = [
|
||||
m for m in machines
|
||||
if m.get('machine_id')
|
||||
and m['machine_id'] != 'default'
|
||||
and 'windows' in m['machine_id'].lower()
|
||||
]
|
||||
windows_machines.sort(
|
||||
key=lambda m: m.get('last_activity', ''),
|
||||
reverse=True,
|
||||
)
|
||||
if windows_machines:
|
||||
data['machine_id'] = windows_machines[0]['machine_id']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
Reference in New Issue
Block a user