Compare commits
28 Commits
backup/pre
...
feature/qw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca81850a20 | ||
|
|
35fd6cf4c5 | ||
|
|
7847a0e829 | ||
|
|
40440f1ca0 | ||
|
|
7233df2bb9 | ||
|
|
f62fda575f | ||
|
|
22c0a2ba61 | ||
|
|
6fdedbfe9d | ||
|
|
c969f93a23 | ||
|
|
1cbec2806e | ||
|
|
864530c851 | ||
|
|
d1ebf62217 | ||
|
|
87dbe8c5ff | ||
|
|
0a02a6ec9c | ||
|
|
83be93e121 | ||
|
|
f5c33477f0 | ||
|
|
b1a3aa16f1 | ||
|
|
0bcfddbbc4 | ||
|
|
aa47172f0f | ||
|
|
65da557310 | ||
|
|
af13cd80ff | ||
|
|
7c6945171e | ||
|
|
ca0b436a61 | ||
|
|
fc01afa59c | ||
|
|
2a51a844b9 | ||
|
|
2d71e2a249 | ||
|
|
fae95c5366 | ||
|
|
6582a69d31 |
@@ -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"))
|
||||
@@ -448,6 +456,12 @@ class AgentV1:
|
||||
window_title = self.vision.get_active_window_title()
|
||||
if window_title:
|
||||
heartbeat_event["active_window_title"] = window_title
|
||||
# QW1 — enrichissement multi-écrans (additif, fallback gracieux)
|
||||
try:
|
||||
from .vision.capturer import _enrich_with_monitor_info
|
||||
_enrich_with_monitor_info(heartbeat_event)
|
||||
except Exception:
|
||||
pass
|
||||
self.streamer.push_event(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
|
||||
@@ -5,6 +5,7 @@ Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
pywebview>=5.0 # Fenêtre de chat Léa intégrée (Edge WebView2 sur Windows)
|
||||
|
||||
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"]
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
import logging
|
||||
import hashlib
|
||||
import platform
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY, BLUR_SENSITIVE
|
||||
@@ -26,6 +26,66 @@ logger = logging.getLogger(__name__)
|
||||
# OS courant (détecté une seule fois)
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
|
||||
try:
|
||||
from screeninfo import get_monitors as _screeninfo_get_monitors
|
||||
_SCREENINFO_AVAILABLE = True
|
||||
except ImportError:
|
||||
_SCREENINFO_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_monitors_geometry() -> List[Dict[str, Any]]:
|
||||
"""Retourne la liste des monitors physiques avec leurs offsets.
|
||||
|
||||
Returns:
|
||||
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
|
||||
indisponible (le serveur tombera sur fallback composite).
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
monitors = _screeninfo_get_monitors()
|
||||
return [
|
||||
{
|
||||
"idx": i,
|
||||
"x": int(m.x),
|
||||
"y": int(m.y),
|
||||
"w": int(m.width),
|
||||
"h": int(m.height),
|
||||
"primary": bool(getattr(m, "is_primary", False)),
|
||||
}
|
||||
for i, m in enumerate(monitors)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_active_monitor_index() -> Optional[int]:
|
||||
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
|
||||
|
||||
Returns:
|
||||
int ou None si indéterminable.
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
import pyautogui # import paresseux : évite la dépendance dure
|
||||
cx, cy = pyautogui.position()
|
||||
for i, m in enumerate(_screeninfo_get_monitors()):
|
||||
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
|
||||
return i
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_with_monitor_info(payload: dict) -> dict:
|
||||
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
|
||||
if isinstance(payload, dict):
|
||||
payload["monitor_index"] = _get_active_monitor_index()
|
||||
payload["monitors_geometry"] = _get_monitors_geometry()
|
||||
return payload
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
@@ -121,6 +181,9 @@ class VisionCapturer:
|
||||
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}")
|
||||
@@ -223,6 +286,9 @@ class VisionCapturer:
|
||||
"click_inside_window": click_inside,
|
||||
}
|
||||
|
||||
# QW1 — enrichissement multi-écrans (additif)
|
||||
_enrich_with_monitor_info(result)
|
||||
|
||||
logger.debug(
|
||||
f"Fenêtre capturée : {title} ({win_w}x{win_h}) — "
|
||||
f"clic relatif ({click_rel_x}, {click_rel_y})"
|
||||
|
||||
@@ -512,6 +512,21 @@ class ActionExecutorV1:
|
||||
x_pct = action.get("x_pct", 0.0)
|
||||
y_pct = action.get("y_pct", 0.0)
|
||||
|
||||
# QW1 — Si le serveur a résolu un monitor cible (idx >= 0),
|
||||
# appliquer son offset aux coords absolues. Pour idx == -1
|
||||
# (composite_fallback), aucun offset (backward compat).
|
||||
# Le calcul des coords reste percent * (width/height) du monitor[1]
|
||||
# côté client (x_pct est exprimé sur l'écran physique principal).
|
||||
mon_res = action.get("monitor_resolution") or {}
|
||||
mon_idx = mon_res.get("idx", -1)
|
||||
mon_offset_x = mon_res.get("offset_x", 0) if mon_idx >= 0 else 0
|
||||
mon_offset_y = mon_res.get("offset_y", 0) if mon_idx >= 0 else 0
|
||||
if mon_idx >= 0 and (mon_offset_x or mon_offset_y):
|
||||
logger.info(
|
||||
f"[REPLAY] QW1 monitor cible idx={mon_idx} source={mon_res.get('source')} "
|
||||
f"offset=({mon_offset_x},{mon_offset_y}) — appliqué aux coords"
|
||||
)
|
||||
|
||||
# ── Diagnostic résolution ──
|
||||
logger.info(
|
||||
f"[REPLAY] Action {action_id} ({action_type}) — "
|
||||
@@ -578,8 +593,8 @@ class ActionExecutorV1:
|
||||
print(f" [OBSERVER] Popup détectée : '{popup_label}' — fermeture")
|
||||
logger.info(f"Observer : popup '{popup_label}' détectée avant résolution")
|
||||
if popup_coords:
|
||||
real_x = int(popup_coords["x_pct"] * width)
|
||||
real_y = int(popup_coords["y_pct"] * height)
|
||||
real_x = int(popup_coords["x_pct"] * width) + mon_offset_x
|
||||
real_y = int(popup_coords["y_pct"] * height) + mon_offset_y
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(1.0)
|
||||
print(f" [OBSERVER] Popup fermée — reprise du flow normal")
|
||||
@@ -718,8 +733,8 @@ class ActionExecutorV1:
|
||||
self.notifier.replay_target_not_found(target_desc)
|
||||
return result
|
||||
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
real_x = int(x_pct * width) + mon_offset_x
|
||||
real_y = int(y_pct * height) + mon_offset_y
|
||||
button = action.get("button", "left")
|
||||
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
|
||||
print(
|
||||
@@ -781,8 +796,8 @@ class ActionExecutorV1:
|
||||
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
|
||||
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
|
||||
if x_pct > 0 and y_pct > 0:
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
real_x = int(x_pct * width) + mon_offset_x
|
||||
real_y = int(y_pct * height) + mon_offset_y
|
||||
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(0.3)
|
||||
@@ -808,8 +823,8 @@ class ActionExecutorV1:
|
||||
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "scroll":
|
||||
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
|
||||
real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height)
|
||||
real_x = (int(x_pct * width) if x_pct > 0 else int(0.5 * width)) + mon_offset_x
|
||||
real_y = (int(y_pct * height) if y_pct > 0 else int(0.5 * height)) + mon_offset_y
|
||||
delta = action.get("delta", -3)
|
||||
print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})")
|
||||
self.mouse.position = (real_x, real_y)
|
||||
@@ -1386,6 +1401,16 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
data = resp.json()
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
# pause_for_human : afficher le message de décision à l'utilisateur
|
||||
if data.get("replay_paused") and data.get("pause_message"):
|
||||
msg = data["pause_message"]
|
||||
print(f"[PAUSE] {msg}")
|
||||
logger.info(f"Replay en pause — message : {msg}")
|
||||
self.notifier.notify(
|
||||
title="Léa — Validation requise",
|
||||
message=msg[:250],
|
||||
timeout=30,
|
||||
)
|
||||
return False
|
||||
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
||||
|
||||
@@ -319,7 +319,22 @@ class AgentV1:
|
||||
if img_hash != self._last_heartbeat_hash:
|
||||
self._last_heartbeat_hash = img_hash
|
||||
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
|
||||
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
|
||||
heartbeat_event = {
|
||||
"type": "heartbeat",
|
||||
"image": full_path,
|
||||
"timestamp": time.time(),
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
# QW1 — enrichissement multi-écrans (monitor_index + monitors_geometry)
|
||||
# Additif, fallback gracieux : sans cet enrichissement, le serveur
|
||||
# ne reçoit l'info qu'au moment des clics, donc QW1 ne s'active
|
||||
# pas en continu sur poste Windows multi-écrans.
|
||||
try:
|
||||
from .vision.capturer import _enrich_with_monitor_info
|
||||
_enrich_with_monitor_info(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.debug("QW1 enrichissement heartbeat échoué: %s", e)
|
||||
self.streamer.push_event(heartbeat_event)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
@@ -8,12 +8,73 @@ import os
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
import mss
|
||||
from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# QW1 — détection multi-écrans (fallback gracieux si screeninfo absent)
|
||||
try:
|
||||
from screeninfo import get_monitors as _screeninfo_get_monitors
|
||||
_SCREENINFO_AVAILABLE = True
|
||||
except ImportError:
|
||||
_SCREENINFO_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_monitors_geometry() -> List[Dict[str, Any]]:
|
||||
"""Retourne la liste des monitors physiques avec leurs offsets.
|
||||
|
||||
Returns:
|
||||
List[dict] : [{idx, x, y, w, h, primary}, ...]. Vide si screeninfo
|
||||
indisponible (le serveur tombera sur fallback composite).
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return []
|
||||
try:
|
||||
monitors = _screeninfo_get_monitors()
|
||||
return [
|
||||
{
|
||||
"idx": i,
|
||||
"x": int(m.x),
|
||||
"y": int(m.y),
|
||||
"w": int(m.width),
|
||||
"h": int(m.height),
|
||||
"primary": bool(getattr(m, "is_primary", False)),
|
||||
}
|
||||
for i, m in enumerate(monitors)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_active_monitor_index() -> Optional[int]:
|
||||
"""Retourne l'index logique du monitor où se trouve le curseur (focus actif).
|
||||
|
||||
Returns:
|
||||
int ou None si indéterminable.
|
||||
"""
|
||||
if not _SCREENINFO_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
import pyautogui # import paresseux : évite la dépendance dure
|
||||
cx, cy = pyautogui.position()
|
||||
for i, m in enumerate(_screeninfo_get_monitors()):
|
||||
if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
|
||||
return i
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_with_monitor_info(payload: dict) -> dict:
|
||||
"""Ajoute monitor_index et monitors_geometry au payload (in-place + return)."""
|
||||
if isinstance(payload, dict):
|
||||
payload["monitor_index"] = _get_active_monitor_index()
|
||||
payload["monitors_geometry"] = _get_monitors_geometry()
|
||||
return payload
|
||||
|
||||
class VisionCapturer:
|
||||
def __init__(self, session_dir: str):
|
||||
self.session_dir = session_dir
|
||||
@@ -72,7 +133,12 @@ class VisionCapturer:
|
||||
# Mise à jour du hash pour le prochain heartbeat
|
||||
self.last_img_hash = self._compute_quick_hash(img)
|
||||
|
||||
return {"full": full_path, "crop": crop_path}
|
||||
result = {"full": full_path, "crop": crop_path}
|
||||
|
||||
# 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 {}
|
||||
|
||||
@@ -5,6 +5,7 @@ Pillow>=10.0.0 # Crops et processing image
|
||||
requests>=2.31.0 # Streaming réseau
|
||||
python-socketio[client]>=5.10,<6.0 # Bus feedback Léa 'lea:*' (compat Flask-SocketIO 5.3.x serveur)
|
||||
psutil>=5.9.0 # Monitoring CPU/RAM
|
||||
screeninfo>=0.8 # QW1 — détection des monitors physiques + offsets
|
||||
pystray>=0.19.5 # Icône Tray UI
|
||||
plyer>=2.1.0 # Notifications toast natives (remplace PyQt5)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -33,6 +34,8 @@ from .audit_trail import AuditTrail, AuditEntry
|
||||
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError
|
||||
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
|
||||
from .worker_stream import StreamWorker
|
||||
from .monitor_router import resolve_target_monitor # QW1 — résolution écran cible
|
||||
from .loop_detector import LoopDetector # QW2 — détection de boucle pendant replay
|
||||
from .execution_plan_runner import (
|
||||
execution_plan_to_actions,
|
||||
inject_plan_into_queue,
|
||||
@@ -222,6 +225,7 @@ from .replay_engine import (
|
||||
_resolve_runtime_vars,
|
||||
_SERVER_SIDE_ACTION_TYPES,
|
||||
_handle_extract_text_action,
|
||||
_handle_extract_table_action,
|
||||
_handle_t2a_decision_action,
|
||||
_expand_compound_steps,
|
||||
_pre_check_screen_state as _pre_check_screen_state_impl,
|
||||
@@ -359,6 +363,18 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
|
||||
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
|
||||
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
|
||||
|
||||
# QW2 — LoopDetector singleton lazy (utilise le CLIP embedder du processor)
|
||||
_loop_detector: Optional["LoopDetector"] = None
|
||||
|
||||
|
||||
def _get_loop_detector() -> "LoopDetector":
|
||||
"""Singleton lazy — crée le LoopDetector avec le CLIP embedder du processor."""
|
||||
global _loop_detector
|
||||
if _loop_detector is None:
|
||||
embedder = getattr(processor, "_clip_embedder", None)
|
||||
_loop_detector = LoopDetector(clip_embedder=embedder)
|
||||
return _loop_detector
|
||||
|
||||
# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db)
|
||||
# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests.
|
||||
_AGENTS_DB_PATH = os.environ.get(
|
||||
@@ -490,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)
|
||||
@@ -511,6 +554,7 @@ class ReplayRequest(BaseModel):
|
||||
session_id: str
|
||||
machine_id: Optional[str] = None # Machine cible pour le replay (multi-machine)
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
variables: Optional[Dict[str, Any]] = None # Variables runtime initiales (templating {{var}})
|
||||
|
||||
|
||||
class RawReplayRequest(BaseModel):
|
||||
@@ -519,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):
|
||||
@@ -765,6 +814,21 @@ async def startup():
|
||||
_cleanup_thread = threading.Thread(target=_cleanup_loop, daemon=True, name="replay_cleanup")
|
||||
_cleanup_thread.start()
|
||||
|
||||
# Préchargement EasyOCR en arrière-plan : sans ça, le 1er extract_text /
|
||||
# extract_table déclenche un cold start de ~3-5s qui bloque l'event loop
|
||||
# FastAPI (constaté 2026-05-05 : streaming server inaccessible 2 min).
|
||||
# Le thread tourne pendant que le boot continue ; le 1er appel OCR sera rapide.
|
||||
def _preload_easyocr():
|
||||
try:
|
||||
t0 = time.time()
|
||||
from core.llm.ocr_extractor import _get_reader
|
||||
_get_reader()
|
||||
logger.info("[OCR] EasyOCR préchargé (fr+en, CPU) en %.1fs", time.time() - t0)
|
||||
except Exception as e:
|
||||
logger.warning("[OCR] Échec préchargement EasyOCR : %s", e)
|
||||
|
||||
threading.Thread(target=_preload_easyocr, daemon=True, name="preload_easyocr").start()
|
||||
|
||||
logger.info(
|
||||
"API Streaming démarrée — StreamProcessor, Worker et Cleanup prêts. "
|
||||
"VLM Worker dans un process séparé (run_worker.py)."
|
||||
@@ -1951,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,
|
||||
@@ -1962,6 +2026,11 @@ async def start_replay(request: ReplayRequest):
|
||||
machine_id=resolved_machine_id,
|
||||
actions=actions,
|
||||
)
|
||||
# Pré-injection des variables runtime (templating {{var}} sur by_text,
|
||||
# text, target_spec.* etc.). Permet à l'orchestrateur d'appeler ce
|
||||
# workflow avec p.ex. variables={"patient_id": "25003284"} pour boucler.
|
||||
if request.variables:
|
||||
_replay_states[replay_id]["variables"].update(request.variables)
|
||||
# Enregistrer le mapping machine -> session pour le replay ciblé
|
||||
if resolved_machine_id and resolved_machine_id != "default":
|
||||
_machine_replay_target[resolved_machine_id] = session_id
|
||||
@@ -2046,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.
|
||||
@@ -2073,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,
|
||||
)
|
||||
@@ -2266,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,
|
||||
@@ -2357,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(
|
||||
@@ -2523,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,
|
||||
@@ -2892,8 +2961,54 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
|
||||
type_ = action.get("type")
|
||||
|
||||
# pause_for_human : no-op en mode autonome — on saute et on continue
|
||||
# pause_for_human : pause supervisée si safety_level/safety_checks ou mode supervised,
|
||||
# sinon no-op en mode autonome (skip).
|
||||
if type_ == "pause_for_human":
|
||||
_params = action.get("parameters") or {}
|
||||
_exec_mode = (
|
||||
(owning_replay or {}).get("params", {}).get("execution_mode", "autonomous")
|
||||
if owning_replay else "autonomous"
|
||||
)
|
||||
_has_safety_decl = bool(_params.get("safety_level") or _params.get("safety_checks"))
|
||||
_is_supervised = _exec_mode != "autonomous"
|
||||
|
||||
if owning_replay is not None and (_has_safety_decl or _is_supervised):
|
||||
# QW4 — Construire le payload de pause enrichi (déclaratif + LLM contextuel)
|
||||
try:
|
||||
from agent_v0.server_v1.safety_checks_provider import build_pause_payload
|
||||
last_screenshot_path = owning_replay.get("last_screenshot")
|
||||
payload = build_pause_payload(action, owning_replay, last_screenshot_path)
|
||||
owning_replay["safety_checks"] = payload.checks
|
||||
owning_replay["pause_payload"] = {
|
||||
"checks": payload.checks,
|
||||
"pause_reason": payload.pause_reason,
|
||||
"message": payload.message,
|
||||
}
|
||||
if payload.message:
|
||||
owning_replay["pause_message"] = payload.message
|
||||
# Bus event d'observabilité (pattern QW1/QW2 = logger.info)
|
||||
logger.info(
|
||||
"[BUS] lea:safety_checks_generated replay=%s count=%d sources=%s",
|
||||
owning_replay.get("replay_id", "?"),
|
||||
len(payload.checks),
|
||||
[c["source"] for c in payload.checks],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("QW4 build_pause_payload échec (%s) — pause sans checks", e)
|
||||
owning_replay["safety_checks"] = []
|
||||
|
||||
# Conserver le contexte de l'action (audit + reprise)
|
||||
owning_replay["failed_action"] = {
|
||||
"action_id": action.get("action_id"),
|
||||
"type": "pause_for_human",
|
||||
"reason": "user_request",
|
||||
}
|
||||
owning_replay["status"] = "paused_need_help"
|
||||
queue.pop(0)
|
||||
_replay_queues[session_id] = queue
|
||||
return {"action": None, "session_id": session_id, "machine_id": machine_id}
|
||||
|
||||
# Mode autonome sans safety_checks → skip (comportement legacy)
|
||||
logger.info(
|
||||
"pause_for_human ignorée (mode autonome) — replay %s continue",
|
||||
owning_replay["replay_id"] if owning_replay else "?"
|
||||
@@ -2906,19 +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 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}")
|
||||
@@ -3014,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(
|
||||
@@ -3023,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,
|
||||
@@ -3071,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)
|
||||
@@ -3117,6 +3253,51 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
f"{_precheck_sim}"
|
||||
)
|
||||
|
||||
# QW1 — Résoudre l'écran cible et joindre l'info à l'action
|
||||
# Cascade : action.monitor_index → session.last_focused_monitor → composite_fallback
|
||||
try:
|
||||
session_qw1 = processor.session_manager.get_session(session_id)
|
||||
last_window_info_qw1 = (
|
||||
session_qw1.last_window_info if session_qw1 is not None else {}
|
||||
) or {}
|
||||
session_state_qw1 = {
|
||||
"monitors_geometry": last_window_info_qw1.get("monitors_geometry", []),
|
||||
"last_focused_monitor": last_window_info_qw1.get("monitor_index"),
|
||||
}
|
||||
target = resolve_target_monitor(action, session_state_qw1)
|
||||
action["monitor_resolution"] = {
|
||||
"idx": target.idx,
|
||||
"offset_x": target.offset_x,
|
||||
"offset_y": target.offset_y,
|
||||
"w": target.w,
|
||||
"h": target.h,
|
||||
"source": target.source,
|
||||
}
|
||||
# QW1 — Émission bus lea:monitor_routed (no-op si bus indisponible)
|
||||
# Le serveur streaming n'a pas de SocketIO local : on logge en INFO
|
||||
# bien lisible. Un consommateur (agent_chat / dashboard) peut tailer
|
||||
# `journalctl -u rpa-streaming | grep '\[BUS\] lea:monitor_routed'`.
|
||||
try:
|
||||
_replay_id_bus = (
|
||||
owning_replay.get("replay_id") if owning_replay else None
|
||||
)
|
||||
logger.info(
|
||||
"[BUS] lea:monitor_routed replay=%s action=%s idx=%d source=%s "
|
||||
"offset=(%d,%d) wh=(%d,%d)",
|
||||
_replay_id_bus,
|
||||
action.get("action_id"),
|
||||
target.idx,
|
||||
target.source,
|
||||
target.offset_x,
|
||||
target.offset_y,
|
||||
target.w,
|
||||
target.h,
|
||||
)
|
||||
except Exception as _e_bus:
|
||||
logger.debug("emit lea:monitor_routed échec (non bloquant): %s", _e_bus)
|
||||
except Exception as e:
|
||||
logger.debug("QW1 monitor_resolution skip (%s)", e)
|
||||
|
||||
response: Dict[str, Any] = {
|
||||
"action": action,
|
||||
"session_id": session_id,
|
||||
@@ -3158,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":
|
||||
@@ -3191,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 ===
|
||||
@@ -3262,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
|
||||
@@ -3284,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,
|
||||
@@ -3444,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)
|
||||
@@ -3855,6 +4036,82 @@ async def report_action_result(report: ReplayResultReport):
|
||||
f"— worker VLM autorisé à reprendre"
|
||||
)
|
||||
|
||||
# ===================================================================
|
||||
# QW2 — LoopDetector : alimentation des anneaux + évaluation
|
||||
# ===================================================================
|
||||
# On n'évalue que si le replay est encore "running" — inutile de
|
||||
# pauser quelque chose de déjà completed/error/paused.
|
||||
if replay_state["status"] == "running":
|
||||
# Snapshot image (PIL) dans l'anneau
|
||||
try:
|
||||
from PIL import Image
|
||||
ss_raw = screenshot_after or replay_state.get("last_screenshot")
|
||||
img = None
|
||||
if isinstance(ss_raw, str) and ss_raw:
|
||||
if os.path.isfile(ss_raw):
|
||||
img = Image.open(ss_raw).copy() # détache du file handle
|
||||
else:
|
||||
# Possible base64 — décoder
|
||||
try:
|
||||
import base64
|
||||
import io as _io
|
||||
img_bytes = base64.b64decode(ss_raw, validate=False)
|
||||
img = Image.open(_io.BytesIO(img_bytes)).copy()
|
||||
except Exception:
|
||||
img = None
|
||||
if img is not None:
|
||||
replay_state.setdefault("_screenshot_history", []).append(img)
|
||||
replay_state["_screenshot_history"] = replay_state["_screenshot_history"][-5:]
|
||||
except Exception as e:
|
||||
logger.debug("LoopDetector: snapshot historique échoué: %s", e)
|
||||
|
||||
# Snapshot signature de l'action courante
|
||||
try:
|
||||
_act_pos = report.actual_position or {}
|
||||
action_sig = {
|
||||
"type": (original_action or {}).get("type")
|
||||
or replay_state.get("_last_action_type", ""),
|
||||
"x_pct": _act_pos.get("x_pct") if isinstance(_act_pos, dict)
|
||||
else (original_action or {}).get("x_pct"),
|
||||
"y_pct": _act_pos.get("y_pct") if isinstance(_act_pos, dict)
|
||||
else (original_action or {}).get("y_pct"),
|
||||
}
|
||||
replay_state.setdefault("_action_history", []).append(action_sig)
|
||||
replay_state["_action_history"] = replay_state["_action_history"][-5:]
|
||||
except Exception as e:
|
||||
logger.debug("LoopDetector: snapshot action_sig échoué: %s", e)
|
||||
|
||||
# Évaluation (silencieux si rien)
|
||||
try:
|
||||
verdict = _get_loop_detector().evaluate(
|
||||
replay_state,
|
||||
screenshots=replay_state.get("_screenshot_history", []),
|
||||
actions=replay_state.get("_action_history", []),
|
||||
)
|
||||
if verdict.detected:
|
||||
replay_state["status"] = "paused_need_help"
|
||||
replay_state["pause_reason"] = "loop_detected"
|
||||
replay_state["pause_message"] = (
|
||||
f"Léa semble bloquée — {verdict.signal} "
|
||||
f"(détail: {verdict.evidence})"
|
||||
)
|
||||
logger.warning(
|
||||
"LoopDetector: replay %s mis en pause — signal=%s evidence=%s",
|
||||
replay_state["replay_id"], verdict.signal, verdict.evidence,
|
||||
)
|
||||
# Bus event d'observabilité (logger pattern QW1)
|
||||
try:
|
||||
logger.info(
|
||||
"[BUS] lea:loop_detected replay=%s signal=%s evidence=%s",
|
||||
replay_state["replay_id"],
|
||||
verdict.signal,
|
||||
verdict.evidence,
|
||||
)
|
||||
except Exception as _e_bus:
|
||||
logger.debug("emit lea:loop_detected échec: %s", _e_bus)
|
||||
except Exception as e:
|
||||
logger.warning("LoopDetector: évaluation échouée (non bloquant): %s", e)
|
||||
|
||||
return {
|
||||
"status": "recorded",
|
||||
"action_id": action_id,
|
||||
@@ -3880,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,
|
||||
@@ -3904,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": [
|
||||
@@ -3941,8 +4216,16 @@ async def list_replays():
|
||||
}
|
||||
|
||||
|
||||
class ReplayResumeRequest(BaseModel):
|
||||
"""Body optionnel pour /replay/resume — QW4 acquittement de safety_checks."""
|
||||
acknowledged_check_ids: List[str] = []
|
||||
|
||||
|
||||
@app.post("/api/v1/traces/stream/replay/{replay_id}/resume")
|
||||
async def resume_replay(replay_id: str):
|
||||
async def resume_replay(
|
||||
replay_id: str,
|
||||
payload: Optional[ReplayResumeRequest] = None,
|
||||
):
|
||||
"""Reprendre un replay en pause supervisee (paused_need_help).
|
||||
|
||||
L'utilisateur a intervenu manuellement (naviguer vers le bon ecran,
|
||||
@@ -3950,8 +4233,12 @@ async def resume_replay(replay_id: str):
|
||||
est reinjectee en tete de queue pour etre re-tentee.
|
||||
|
||||
Si le replay n'est pas en pause, retourne une erreur 409 (conflit).
|
||||
|
||||
QW4 — Si des safety_checks sont attachés à la pause, tous ceux marqués
|
||||
`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:
|
||||
@@ -3968,6 +4255,25 @@ async def resume_replay(replay_id: str):
|
||||
),
|
||||
)
|
||||
|
||||
# QW4 — Vérification des safety_checks required avant reprise
|
||||
safety_checks = state.get("safety_checks") or []
|
||||
ack_ids = (payload.acknowledged_check_ids if payload else []) or []
|
||||
if safety_checks:
|
||||
required_ids = {c["id"] for c in safety_checks if c.get("required")}
|
||||
ack_set = set(ack_ids)
|
||||
missing = sorted(required_ids - ack_set)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "required_checks_missing", "missing": missing},
|
||||
)
|
||||
# Audit trail
|
||||
state["checks_acknowledged"] = sorted(ack_set)
|
||||
logger.info(
|
||||
"QW4 resume replay=%s acquittements=%d (%s)",
|
||||
state.get("replay_id"), len(ack_set), sorted(ack_set),
|
||||
)
|
||||
|
||||
# Recuperer l'action echouee pour la reinjecter
|
||||
failed_action = state.get("failed_action")
|
||||
session_id = state["session_id"]
|
||||
@@ -3976,6 +4282,10 @@ async def resume_replay(replay_id: str):
|
||||
state["status"] = "running"
|
||||
state["failed_action"] = None
|
||||
state["pause_message"] = None
|
||||
# QW4 — vider safety_checks après acquittement (la pause est résolue)
|
||||
state["safety_checks"] = []
|
||||
state["pause_payload"] = None
|
||||
state["pause_reason"] = ""
|
||||
|
||||
# Reinjecter l'action echouee en tete de queue (sera re-tentee)
|
||||
# pause_for_human est une pause intentionnelle, pas une erreur — ne pas réinjecter
|
||||
@@ -4024,7 +4334,7 @@ async def resume_replay(replay_id: str):
|
||||
@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é")
|
||||
@@ -4095,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)
|
||||
@@ -4110,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,
|
||||
@@ -4127,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} "
|
||||
@@ -4142,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:
|
||||
|
||||
@@ -256,6 +256,20 @@ class LiveSessionManager:
|
||||
session.last_window_info["title"] = wc_title
|
||||
if wc_app:
|
||||
session.last_window_info["app_name"] = wc_app
|
||||
# QW1 — propager monitor_index et monitors_geometry depuis window_capture
|
||||
if "monitor_index" in window_capture:
|
||||
session.last_window_info["monitor_index"] = window_capture["monitor_index"]
|
||||
if "monitors_geometry" in window_capture:
|
||||
session.last_window_info["monitors_geometry"] = window_capture["monitors_geometry"]
|
||||
|
||||
# QW1 — propager monitor_index/monitors_geometry du payload event
|
||||
# (cas heartbeat enrichi sans window/window_title). Toujours
|
||||
# rafraîchir le focus actif (change souvent) et la géométrie
|
||||
# (l'utilisateur peut brancher/débrancher un écran).
|
||||
if "monitor_index" in event_data:
|
||||
session.last_window_info["monitor_index"] = event_data["monitor_index"]
|
||||
if "monitors_geometry" in event_data and event_data["monitors_geometry"]:
|
||||
session.last_window_info["monitors_geometry"] = event_data["monitors_geometry"]
|
||||
|
||||
# Accumuler les titres/apps pour le nommage automatique
|
||||
title = session.last_window_info.get("title", "").strip()
|
||||
|
||||
154
agent_v0/server_v1/loop_detector.py
Normal file
154
agent_v0/server_v1/loop_detector.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# agent_v0/server_v1/loop_detector.py
|
||||
"""LoopDetector composite — détection de stagnation de Léa pendant un replay (QW2).
|
||||
|
||||
Trois signaux indépendants :
|
||||
- screen_static : N captures consécutives avec CLIP similarity > seuil
|
||||
- action_repeat : N actions consécutives identiques (type + coords)
|
||||
- retry_threshold : nombre de retries cumulés >= seuil
|
||||
|
||||
Un seul signal positif → verdict.detected=True. Le serveur bascule alors le
|
||||
replay en paused_need_help avec pause_reason explicite.
|
||||
|
||||
Désactivable via env var RPA_LOOP_DETECTOR_ENABLED=0.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoopVerdict:
|
||||
detected: bool = False
|
||||
reason: str = ""
|
||||
signal: str = "" # "screen_static" | "action_repeat" | "retry_threshold" | ""
|
||||
evidence: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool_enabled(name: str) -> bool:
|
||||
val = os.environ.get(name, "1").strip().lower()
|
||||
return val not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def _cosine_similarity(a, b) -> float:
|
||||
"""Similarité cosine entre deux vecteurs (listes ou np.array). Robuste vecteur nul."""
|
||||
import numpy as np
|
||||
av = np.asarray(a, dtype=np.float32).flatten()
|
||||
bv = np.asarray(b, dtype=np.float32).flatten()
|
||||
na, nb = float(np.linalg.norm(av)), float(np.linalg.norm(bv))
|
||||
if na < 1e-8 or nb < 1e-8:
|
||||
return 0.0
|
||||
return float(np.dot(av, bv) / (na * nb))
|
||||
|
||||
|
||||
class LoopDetector:
|
||||
def __init__(self, clip_embedder=None):
|
||||
self.clip_embedder = clip_embedder
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
state: Dict[str, Any],
|
||||
screenshots: List[Any],
|
||||
actions: List[Dict[str, Any]],
|
||||
) -> LoopVerdict:
|
||||
"""Évalue les 3 signaux. Retourne le premier déclenché.
|
||||
|
||||
Args:
|
||||
state: replay_state (utilisé pour retried_actions)
|
||||
screenshots: anneau d'embeddings CLIP (les N derniers)
|
||||
actions: anneau des N dernières actions exécutées
|
||||
"""
|
||||
if not _env_bool_enabled("RPA_LOOP_DETECTOR_ENABLED"):
|
||||
return LoopVerdict(detected=False)
|
||||
|
||||
# Signal A : screen_static
|
||||
verdict = self._check_screen_static(screenshots)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
# Signal B : action_repeat
|
||||
verdict = self._check_action_repeat(actions)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
# Signal C : retry_threshold
|
||||
verdict = self._check_retry_threshold(state)
|
||||
if verdict.detected:
|
||||
return verdict
|
||||
|
||||
return LoopVerdict(detected=False)
|
||||
|
||||
def _check_screen_static(self, screenshots: List[Any]) -> LoopVerdict:
|
||||
n_required = _env_int("RPA_LOOP_SCREEN_STATIC_N", 4)
|
||||
threshold = _env_float("RPA_LOOP_SCREEN_STATIC_THRESHOLD", 0.99)
|
||||
|
||||
if self.clip_embedder is None or len(screenshots) < n_required:
|
||||
return LoopVerdict()
|
||||
|
||||
try:
|
||||
recent = screenshots[-n_required:]
|
||||
# Embed chaque capture via le CLIP embedder (peut lever)
|
||||
embeddings = [self.clip_embedder.embed_image(img) for img in recent]
|
||||
sims = [_cosine_similarity(embeddings[i], embeddings[i + 1])
|
||||
for i in range(len(embeddings) - 1)]
|
||||
min_sim = min(sims)
|
||||
if min_sim > threshold:
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="screen_static",
|
||||
evidence={"min_similarity": round(min_sim, 4),
|
||||
"n_captures": n_required,
|
||||
"threshold": threshold},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("LoopDetector signal_A erreur (%s) — signal inerte ce tick", e)
|
||||
return LoopVerdict()
|
||||
|
||||
def _check_action_repeat(self, actions: List[Dict[str, Any]]) -> LoopVerdict:
|
||||
n_required = _env_int("RPA_LOOP_ACTION_REPEAT_N", 3)
|
||||
if len(actions) < n_required:
|
||||
return LoopVerdict()
|
||||
recent = actions[-n_required:]
|
||||
|
||||
def _signature(a: Dict[str, Any]) -> tuple:
|
||||
return (a.get("type"), a.get("x_pct"), a.get("y_pct"))
|
||||
|
||||
sigs = [_signature(a) for a in recent]
|
||||
if all(s == sigs[0] for s in sigs):
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="action_repeat",
|
||||
evidence={"signature": sigs[0], "count": n_required},
|
||||
)
|
||||
return LoopVerdict()
|
||||
|
||||
def _check_retry_threshold(self, state: Dict[str, Any]) -> LoopVerdict:
|
||||
threshold = _env_int("RPA_LOOP_RETRY_THRESHOLD", 3)
|
||||
retried = int(state.get("retried_actions", 0))
|
||||
if retried >= threshold:
|
||||
return LoopVerdict(
|
||||
detected=True,
|
||||
reason="loop_detected",
|
||||
signal="retry_threshold",
|
||||
evidence={"retried_actions": retried, "threshold": threshold},
|
||||
)
|
||||
return LoopVerdict()
|
||||
99
agent_v0/server_v1/monitor_router.py
Normal file
99
agent_v0/server_v1/monitor_router.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# agent_v0/server_v1/monitor_router.py
|
||||
"""MonitorRouter — résolution de l'écran cible pour le replay (QW1).
|
||||
|
||||
Stratégie en cascade :
|
||||
1. action.monitor_index (hérité de la session source) → cible cet écran
|
||||
2. session.last_focused_monitor (focus actif vu en dernier heartbeat) → fallback
|
||||
3. composite (offset 0, 0) → backward compat
|
||||
|
||||
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorTarget:
|
||||
"""Représente l'écran cible résolu pour une action de replay."""
|
||||
idx: int
|
||||
offset_x: int
|
||||
offset_y: int
|
||||
w: int
|
||||
h: int
|
||||
source: str # "action" | "focus" | "composite_fallback"
|
||||
|
||||
|
||||
_COMPOSITE_FALLBACK = MonitorTarget(
|
||||
idx=-1,
|
||||
offset_x=0,
|
||||
offset_y=0,
|
||||
w=0,
|
||||
h=0,
|
||||
source="composite_fallback",
|
||||
)
|
||||
|
||||
|
||||
def _find_monitor(geometry: List[Dict[str, Any]], idx: int) -> Optional[Dict[str, Any]]:
|
||||
"""Retourne le monitor d'index donné, ou None si absent."""
|
||||
for m in geometry:
|
||||
if m.get("idx") == idx:
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _to_target(monitor: Dict[str, Any], source: str) -> MonitorTarget:
|
||||
return MonitorTarget(
|
||||
idx=int(monitor["idx"]),
|
||||
offset_x=int(monitor.get("x", 0)),
|
||||
offset_y=int(monitor.get("y", 0)),
|
||||
w=int(monitor.get("w", 0)),
|
||||
h=int(monitor.get("h", 0)),
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def resolve_target_monitor(
|
||||
action: Dict[str, Any],
|
||||
session_state: Dict[str, Any],
|
||||
) -> MonitorTarget:
|
||||
"""Résout l'écran cible d'une action de replay.
|
||||
|
||||
Args:
|
||||
action: Dict de l'action (peut contenir `monitor_index`).
|
||||
session_state: État de la session (doit contenir `monitors_geometry`
|
||||
et `last_focused_monitor`).
|
||||
|
||||
Returns:
|
||||
MonitorTarget avec l'offset à appliquer aux coordonnées de grounding.
|
||||
"""
|
||||
geometry: List[Dict[str, Any]] = session_state.get("monitors_geometry") or []
|
||||
|
||||
# 1. Cible explicite via action
|
||||
explicit_idx = action.get("monitor_index")
|
||||
if explicit_idx is not None and geometry:
|
||||
m = _find_monitor(geometry, int(explicit_idx))
|
||||
if m is not None:
|
||||
return _to_target(m, source="action")
|
||||
# Index invalide → on tombe sur le fallback focus
|
||||
logger.warning(
|
||||
"[BUS] lea:monitor_invalid_index requested=%d available_idx=%s",
|
||||
int(explicit_idx), [g.get("idx") for g in geometry],
|
||||
)
|
||||
|
||||
# 2. Fallback focus actif
|
||||
focused_idx = session_state.get("last_focused_monitor")
|
||||
if focused_idx is not None and geometry:
|
||||
m = _find_monitor(geometry, int(focused_idx))
|
||||
if m is not None:
|
||||
return _to_target(m, source="focus")
|
||||
logger.warning(
|
||||
"[BUS] lea:monitor_unavailable focused_idx=%d available_idx=%s",
|
||||
int(focused_idx), [g.get("idx") for g in geometry],
|
||||
)
|
||||
|
||||
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
|
||||
return _COMPOSITE_FALLBACK
|
||||
@@ -1381,6 +1381,14 @@ def _create_replay_state(
|
||||
# t2a_decision, etc.). Résolues via templating {{var}} ou {{var.field}}
|
||||
# dans les paramètres des actions suivantes.
|
||||
"variables": {},
|
||||
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
|
||||
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
|
||||
"_action_history": [], # N dernières actions exécutées (signature)
|
||||
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
|
||||
"safety_checks": [], # liste produite par SafetyChecksProvider
|
||||
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
|
||||
"pause_reason": "", # "loop_detected" | "" pour V1
|
||||
"pause_payload": None, # payload complet pour debug/audit
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
195
agent_v0/server_v1/safety_checks_provider.py
Normal file
195
agent_v0/server_v1/safety_checks_provider.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# agent_v0/server_v1/safety_checks_provider.py
|
||||
"""SafetyChecksProvider — checks hybrides déclaratifs + LLM contextuels (QW4).
|
||||
|
||||
Pour une action pause_for_human :
|
||||
- les checks déclaratifs (workflow) sont toujours inclus
|
||||
- si safety_level == "medical_critical" et RPA_SAFETY_CHECKS_LLM_ENABLED=1,
|
||||
un appel LLM (medgemma:4b par défaut) ajoute jusqu'à N checks contextuels
|
||||
|
||||
Tout échec côté LLM (timeout, exception, parse) → additional_checks=[] :
|
||||
le replay continue avec uniquement les déclaratifs (fallback safe).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PausePayload:
|
||||
checks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pause_reason: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool_enabled(name: str) -> bool:
|
||||
val = os.environ.get(name, "1").strip().lower()
|
||||
return val not in ("0", "false", "no", "off", "")
|
||||
|
||||
|
||||
def build_pause_payload(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
last_screenshot: Optional[str],
|
||||
) -> PausePayload:
|
||||
"""Construit le payload de pause enrichi pour une action pause_for_human."""
|
||||
params = action.get("parameters") or {}
|
||||
message = params.get("message", "Validation requise")
|
||||
safety_level = params.get("safety_level")
|
||||
declarative = params.get("safety_checks") or []
|
||||
|
||||
# Normalisation des checks déclaratifs
|
||||
checks: List[Dict[str, Any]] = []
|
||||
for d in declarative:
|
||||
checks.append({
|
||||
"id": d.get("id") or f"decl_{uuid.uuid4().hex[:6]}",
|
||||
"label": d.get("label", "Validation"),
|
||||
"required": bool(d.get("required", True)),
|
||||
"source": "declarative",
|
||||
"evidence": None,
|
||||
})
|
||||
|
||||
# Ajout LLM contextual si applicable
|
||||
if safety_level == "medical_critical" and _env_bool_enabled("RPA_SAFETY_CHECKS_LLM_ENABLED"):
|
||||
try:
|
||||
additional = _call_llm_for_contextual_checks(
|
||||
action=action,
|
||||
replay_state=replay_state,
|
||||
last_screenshot=last_screenshot,
|
||||
existing_labels=[c["label"] for c in checks],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=exception detail=%s", e)
|
||||
additional = []
|
||||
|
||||
for a in additional:
|
||||
checks.append({
|
||||
"id": f"llm_{uuid.uuid4().hex[:6]}",
|
||||
"label": a.get("label", ""),
|
||||
"required": False, # checks LLM = informationnels, pas obligatoires V1
|
||||
"source": "llm_contextual",
|
||||
"evidence": a.get("evidence", ""),
|
||||
})
|
||||
|
||||
return PausePayload(
|
||||
checks=checks,
|
||||
pause_reason="",
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
def _call_llm_for_contextual_checks(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
last_screenshot: Optional[str],
|
||||
existing_labels: List[str],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Appelle Ollama en mode JSON strict pour générer 0-N checks contextuels.
|
||||
|
||||
Returns:
|
||||
List[{label, evidence}] (max RPA_SAFETY_CHECKS_LLM_MAX_CHECKS).
|
||||
[] sur tout échec (timeout, JSON invalide, exception).
|
||||
"""
|
||||
import requests
|
||||
|
||||
# Défaut gemma4:latest : meilleur compromis détection/latence sur bench
|
||||
# 2026-05-06 (cf. docs/BENCH_SAFETY_CHECKS_2026-05-06.md). medgemma:4b
|
||||
# retournait systématiquement [] (refus de signaler).
|
||||
model = _env("RPA_SAFETY_CHECKS_LLM_MODEL", "gemma4:latest")
|
||||
# Timeout 7s : warm avg gemma4 = 2.9s + marge 4s. Cold start ~10s couvert
|
||||
# si le modèle reste résident (OLLAMA_KEEP_ALIVE=24h recommandé prod).
|
||||
timeout_s = _env_int("RPA_SAFETY_CHECKS_LLM_TIMEOUT_S", 7)
|
||||
max_checks = _env_int("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", 3)
|
||||
ollama_url = _env("OLLAMA_URL", "http://localhost:11434")
|
||||
|
||||
params = action.get("parameters") or {}
|
||||
workflow_message = params.get("message", "")
|
||||
existing = ", ".join(existing_labels) if existing_labels else "aucun"
|
||||
|
||||
prompt = f"""Tu es Léa, assistante médicale supervisée.
|
||||
Avant de continuer le workflow, tu dois lister 0 à {max_checks} vérifications supplémentaires
|
||||
que l'humain doit acquitter, en regardant l'écran actuel.
|
||||
|
||||
Contexte workflow : {workflow_message}
|
||||
Checks déjà demandés : {existing}
|
||||
|
||||
NE répète PAS un check déjà demandé.
|
||||
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
{{
|
||||
"additional_checks": [
|
||||
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1, "num_predict": 200},
|
||||
}
|
||||
|
||||
if last_screenshot and os.path.isfile(last_screenshot):
|
||||
try:
|
||||
with open(last_screenshot, "rb") as f:
|
||||
payload["images"] = [base64.b64encode(f.read()).decode("ascii")]
|
||||
except Exception as e:
|
||||
logger.debug("safety_checks: lecture screenshot échouée (%s) — appel sans image", e)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{ollama_url}/api/generate",
|
||||
json=payload,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=http_status detail=%s", response.status_code)
|
||||
return []
|
||||
text = response.json().get("response", "").strip()
|
||||
except requests.Timeout:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=timeout detail=%ss", timeout_s)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=network detail=%s", e)
|
||||
return []
|
||||
|
||||
# format=json garantit normalement du JSON valide
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("[BUS] lea:safety_checks_llm_failed reason=json_decode detail=%s", e)
|
||||
return []
|
||||
|
||||
additional = parsed.get("additional_checks") or []
|
||||
if not isinstance(additional, list):
|
||||
return []
|
||||
|
||||
# Filtre + tronc
|
||||
valid = []
|
||||
for item in additional[:max_checks]:
|
||||
if isinstance(item, dict) and item.get("label"):
|
||||
valid.append({
|
||||
"label": str(item["label"])[:200],
|
||||
"evidence": str(item.get("evidence", ""))[:300],
|
||||
})
|
||||
return valid
|
||||
@@ -19,9 +19,23 @@ 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:
|
||||
import mss
|
||||
MSS_AVAILABLE = True
|
||||
except ImportError:
|
||||
MSS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
|
||||
|
||||
def safe_type_text(text: str):
|
||||
"""Saisie de texte compatible VM/Citrix et claviers AZERTY/QWERTY.
|
||||
@@ -157,11 +171,13 @@ def handle_detected_pattern(pattern: Dict[str, Any]) -> bool:
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = Image.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
|
||||
# EasyOCR (rapide, bonne qualité GUI) avec fallback docTR
|
||||
# EasyOCR (rapide, bonne qualité GUI) avec fallback docTR.
|
||||
# gpu=True : harmonisé avec dialog_handler.py et title_verifier.py.
|
||||
# Coût VRAM ~0.5 GB, sous le budget RTX 5070 (cf. deploy/VRAM_BUDGET.md).
|
||||
words = []
|
||||
try:
|
||||
import easyocr
|
||||
_reader = easyocr.Reader(['fr', 'en'], gpu=False, verbose=False)
|
||||
_reader = easyocr.Reader(['fr', 'en'], gpu=True, verbose=False)
|
||||
results = _reader.readtext(np.array(screen))
|
||||
for (bbox_pts, text, conf) in results:
|
||||
if not text or len(text.strip()) < 1:
|
||||
@@ -312,6 +328,7 @@ def find_element_on_screen(
|
||||
target_description: str = "",
|
||||
anchor_image_base64: Optional[str] = None,
|
||||
anchor_bbox: Optional[Dict] = None,
|
||||
monitor_idx: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Cherche un élément sur l'écran en utilisant 3 méthodes en cascade.
|
||||
@@ -325,6 +342,7 @@ def find_element_on_screen(
|
||||
target_description: Description plus longue (ex: "le dossier Demo sur le bureau")
|
||||
anchor_image_base64: Image de référence de l'ancre (pour CLIP matching, réservé futur)
|
||||
anchor_bbox: Position originale de l'ancre (pour désambiguïser les matchs multiples)
|
||||
monitor_idx: Index logique 0..N-1 du monitor à scruter. None = composite legacy.
|
||||
|
||||
Returns:
|
||||
{'x': int, 'y': int, 'method': str, 'confidence': float} ou None
|
||||
@@ -347,6 +365,13 @@ def find_element_on_screen(
|
||||
logger.debug("find_element_on_screen: ni target_text ni target_description fournis")
|
||||
return None
|
||||
|
||||
# Propager monitor_idx au niveau OCR via anchor_bbox (sans muter l'argument original)
|
||||
if monitor_idx is not None and anchor_bbox is not None:
|
||||
anchor_bbox = dict(anchor_bbox) # copie pour ne pas muter l'argument
|
||||
anchor_bbox["monitor_idx"] = monitor_idx
|
||||
elif monitor_idx is not None:
|
||||
anchor_bbox = {"monitor_idx": monitor_idx}
|
||||
|
||||
search_label = target_description or target_text
|
||||
logger.info(f"[Grounding] Recherche élément: '{search_label}' (cascade 3 niveaux)")
|
||||
|
||||
@@ -356,12 +381,12 @@ def find_element_on_screen(
|
||||
return result
|
||||
|
||||
# ─── Niveau 2 — UI-TARS grounding (~3s) ───
|
||||
result = _grounding_ui_tars(target_text, target_description)
|
||||
result = _grounding_ui_tars(target_text, target_description, monitor_idx=monitor_idx)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# ─── Niveau 3 — VLM reasoning (~10s) ───
|
||||
result = _grounding_vlm(target_text, target_description)
|
||||
result = _grounding_vlm(target_text, target_description, monitor_idx=monitor_idx)
|
||||
if result:
|
||||
return result
|
||||
|
||||
@@ -411,20 +436,43 @@ def _describe_anchor_image(anchor_image_base64: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _capture_screen():
|
||||
"""Capture l'écran principal et retourne (PIL.Image, width, height)."""
|
||||
try:
|
||||
import mss
|
||||
from PIL import Image as PILImage
|
||||
def _capture_screen(monitor_idx=None):
|
||||
"""Capture l'écran et retourne (PIL.Image, width, height, offset_x, offset_y).
|
||||
|
||||
Args:
|
||||
monitor_idx: Index logique 0..N-1 du monitor à capturer (cf. screeninfo).
|
||||
Si None : capture composite (mss.monitors[0]) — comportement legacy.
|
||||
|
||||
Returns:
|
||||
(image, w, h, offset_x, offset_y). offset = (0,0) en mode composite.
|
||||
"""
|
||||
try:
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0]
|
||||
if monitor_idx is None:
|
||||
# Comportement actuel : composite tous écrans
|
||||
monitor = sct.monitors[0]
|
||||
offset_x, offset_y = 0, 0
|
||||
else:
|
||||
# mss skip monitors[0] (composite). Index logique 0 → mss.monitors[1].
|
||||
mss_idx = int(monitor_idx) + 1
|
||||
if mss_idx >= len(sct.monitors):
|
||||
logger.warning(
|
||||
"mss.monitors[%d] hors limites (n=%d) — fallback composite",
|
||||
mss_idx, len(sct.monitors),
|
||||
)
|
||||
monitor = sct.monitors[0]
|
||||
offset_x, offset_y = 0, 0
|
||||
else:
|
||||
monitor = sct.monitors[mss_idx]
|
||||
offset_x = int(monitor.get("left", 0))
|
||||
offset_y = int(monitor.get("top", 0))
|
||||
|
||||
screenshot = sct.grab(monitor)
|
||||
screen = PILImage.frombytes('RGB', screenshot.size, screenshot.bgra, 'raw', 'BGRX')
|
||||
return screen, monitor['width'], monitor['height']
|
||||
return screen, monitor['width'], monitor['height'], offset_x, offset_y
|
||||
except Exception as e:
|
||||
logger.debug(f"Capture écran échouée: {e}")
|
||||
return None, 0, 0
|
||||
return None, 0, 0, 0, 0
|
||||
|
||||
|
||||
def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
|
||||
@@ -439,7 +487,8 @@ def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Opti
|
||||
return None
|
||||
|
||||
try:
|
||||
screen, screen_w, screen_h = _capture_screen()
|
||||
monitor_idx_param = anchor_bbox.get("monitor_idx") if anchor_bbox else None
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx_param)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
@@ -503,14 +552,14 @@ def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Opti
|
||||
sel = " ← CHOISI" if m is best else ""
|
||||
logger.info(f" [OCR] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['type']}]{sel}")
|
||||
|
||||
return {'x': best['x'], 'y': best['y'], 'method': 'ocr', 'confidence': best['conf']}
|
||||
return {'x': best['x'] + ox, 'y': best['y'] + oy, 'method': 'ocr', 'confidence': best['conf']}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Grounding/OCR] Erreur: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _grounding_ui_tars(target_text: str, target_description: str = "") -> Optional[Dict[str, Any]]:
|
||||
def _grounding_ui_tars(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
|
||||
"""Niveau 2 — UI-TARS grounding visuel (~3s)."""
|
||||
try:
|
||||
import requests
|
||||
@@ -519,7 +568,7 @@ def _grounding_ui_tars(target_text: str, target_description: str = "") -> Option
|
||||
import re
|
||||
import os
|
||||
|
||||
screen, screen_w, screen_h = _capture_screen()
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
@@ -564,7 +613,7 @@ def _grounding_ui_tars(target_text: str, target_description: str = "") -> Option
|
||||
# Valider que les coordonnées sont dans l'écran
|
||||
if 0 <= x <= screen_w and 0 <= y <= screen_h:
|
||||
logger.info(f"[Grounding/UI-TARS] Grounding → ({x}, {y})")
|
||||
return {'x': x, 'y': y, 'method': 'ui_tars', 'confidence': 0.85}
|
||||
return {'x': x + ox, 'y': y + oy, 'method': 'ui_tars', 'confidence': 0.85}
|
||||
else:
|
||||
logger.warning(f"[Grounding/UI-TARS] Coordonnées hors écran: ({x}, {y}) pour {screen_w}x{screen_h}")
|
||||
return None
|
||||
@@ -624,7 +673,7 @@ def _parse_ui_tars_coordinates(text: str, screen_w: int, screen_h: int) -> Optio
|
||||
return None
|
||||
|
||||
|
||||
def _grounding_vlm(target_text: str, target_description: str = "") -> Optional[Dict[str, Any]]:
|
||||
def _grounding_vlm(target_text: str, target_description: str = "", monitor_idx=None) -> Optional[Dict[str, Any]]:
|
||||
"""Niveau 3 — VLM reasoning + confirmation OCR (~10s)."""
|
||||
try:
|
||||
search_label = target_description or target_text
|
||||
@@ -646,7 +695,7 @@ def _grounding_vlm(target_text: str, target_description: str = "") -> Optional[D
|
||||
logger.info(f"[Grounding/VLM] VLM suggère de cliquer sur: '{vlm_target}'")
|
||||
|
||||
# Confirmation par OCR : chercher le target VLM sur l'écran
|
||||
screen, screen_w, screen_h = _capture_screen()
|
||||
screen, screen_w, screen_h, ox, oy = _capture_screen(monitor_idx=monitor_idx)
|
||||
if screen is None:
|
||||
return None
|
||||
|
||||
@@ -668,7 +717,7 @@ def _grounding_vlm(target_text: str, target_description: str = "") -> Optional[D
|
||||
x = int((x1 + x2) / 2)
|
||||
y = int((y1 + y2) / 2)
|
||||
logger.info(f"[Grounding/VLM] Confirmé par OCR: '{word['text']}' à ({x}, {y})")
|
||||
return {'x': x, 'y': y, 'method': 'vlm', 'confidence': 0.75}
|
||||
return {'x': x + ox, 'y': y + oy, 'method': 'vlm', 'confidence': 0.75}
|
||||
|
||||
logger.debug(f"[Grounding/VLM] Target VLM '{vlm_target}' non trouvé par OCR")
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
|
||||
345
docs/AUDIT_DIM_TIM_DEMO_GHT_2026-05-08.md
Normal file
345
docs/AUDIT_DIM_TIM_DEMO_GHT_2026-05-08.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Audit DIM/TIM — Cœur métier de la démo GHT Sud 95 (8 mai 2026)
|
||||
|
||||
_Auditeur : agent rôle médecin DIM senior + TIM expérimenté_
|
||||
_Cible lecteur : Dom (produit/tech), Amina (DIM partenaire Bordeaux)_
|
||||
_Périmètre : module métier `urgences_orchestrator.py` + `core/llm/t2a_decision.py` + 11 dossiers `data.js` + bench `BENCH_T2A_DECISION_11DOSSIERS.md` + arbre officiel `RPU UHCD IA.pptx`_
|
||||
|
||||
---
|
||||
|
||||
## A. Lecture intégrale de l'arbre officiel `RPU UHCD IA.pptx`
|
||||
|
||||
Le PPTX (7 slides) est **explicitement structuré comme un arbre de décision en cascade** (slide 6 = synthèse). Reproduction fidèle :
|
||||
|
||||
```
|
||||
Accueil au service des urgences
|
||||
↓
|
||||
Pathologie potentiellement évolutive ?
|
||||
↓ Si oui
|
||||
Nécessité de surveillance médicale et paramédicale ?
|
||||
↓ Si oui
|
||||
Réalisation d'examen ou d'actes ?
|
||||
↓ Si oui aux 3 critères
|
||||
→ UHCD
|
||||
Si 1 critère manquant
|
||||
→ Forfaits Urgences (en l'absence de PRH)
|
||||
```
|
||||
|
||||
**Critères détaillés (verbatim slides 2-4)** :
|
||||
|
||||
1. **Pathologie potentiellement évolutive** (slide 2)
|
||||
- Motif d'hospitalisation (asthme dans l'exemple)
|
||||
- Symptômes (durée, intensité — « depuis au moins 4h »)
|
||||
- Traitement initial **inefficace**
|
||||
- **Terrain à risque** : âge, comorbidités
|
||||
|
||||
2. **Surveillance médicale et paramédicale** (slide 3)
|
||||
- Constantes IDE
|
||||
- Écrits et observations des médecins
|
||||
- Résultats d'examens
|
||||
|
||||
3. **Examen ou actes** (slide 4)
|
||||
- **Diagnostiques** : RX thorax, PCR VRS, test COVID, Peakflow, prélèvements biologiques (pose KT)
|
||||
- **Thérapeutiques** : antibiotiques, aérosols
|
||||
|
||||
**Informations RPU à exploiter** (slide 5) : Mode de venue, Motif PEC, **CCMU**, **GEMSA**, occupation lit/box/couloir, **durée totale du passage**, autres infos DPI.
|
||||
|
||||
**Verdict arbre officiel** : c'est l'arbre **hospitalier local du CH Simone Veil (Eaubonne)** repris par Amina/Pauline. Il est cohérent avec :
|
||||
- l'instruction DGOS (les 3 critères cumulatifs : caractère instable/diagnostic incertain + surveillance hospitalière + actes/examens)
|
||||
- le guide SFMU UHCD 2024 (durée < 24h, observation, parcours diagnostic incertain ou surveillance courte)
|
||||
|
||||
**Mais** l'arbre PPTX est plus **strict** que le SFMU 2024 : il exige les **3 critères** simultanément pour UHCD ; le SFMU décrit deux portes d'entrée alternatives (« surveillance < 24h » OU « diagnostic incertain »). En pratique côté facturation, l'arrêté 2021/2024 retient bien la formulation cumulative DGOS — donc l'arbre PPTX est **conforme à la grille de facturation**, pas à la grille clinique. C'est un point que Carvella peut creuser.
|
||||
|
||||
---
|
||||
|
||||
## B. Audit du code métier
|
||||
|
||||
### B.1 `core/llm/t2a_decision.py` — le prompt pivot
|
||||
|
||||
**Fidélité à l'arbre officiel** : ✅ globalement bonne. Les 3 critères du prompt (lignes 38-40) reprennent **exactement** les 3 critères du PPTX.
|
||||
|
||||
**Mais** le prompt code dévie sur la règle de combinaison :
|
||||
|
||||
> _« LES 3 CRITÈRES UHCD (au moins **2 sur 3** validés ⇒ REQUALIFICATION) »_ — `t2a_decision.py:37`
|
||||
|
||||
L'arbre PPTX dit explicitement (slide 6) : **« Si oui à ces 3 critères »** → UHCD ; **« Si 1 critère manquant »** → Forfait. Donc règle officielle = **3/3**, pas **2/3**.
|
||||
|
||||
**Conséquence** : le code est plus permissif que l'arbre clinique. Cela explique en partie les **faux positifs UHCD** observés dans le bench (25003284 Pneumo VRS classé UHCD à tort par 4/5 modèles top, 25056615 Salpingite idem). En relâchant à 2/3, le LLM se permet de basculer en UHCD dès qu'il voit « surveillance + actes » sans pathologie évolutive — ce qui est **exactement le profil ATIH-rejet** (sur-codage UHCD).
|
||||
|
||||
**Recommandation forte** : ramener à `3/3 → REQUALIFICATION` en cohérence avec l'arbre métier. C'est un quick win sans toucher à l'archi.
|
||||
|
||||
**Autres points du prompt** :
|
||||
|
||||
- ✅ Citations littérales obligatoires entre « ... » : excellent garde-fou anti-hallucination, conforme à `feedback_anonymisation_stricte.md`.
|
||||
- ✅ Calibration honnête (elevee/moyenne/faible) demandée ; mais le bench montre 2-4 « elevee » fausses chez les top modèles → la calibration n'est pas effective dans la sortie.
|
||||
- ⚠️ **Absent du prompt** : aucune mention CCMU, GEMSA, durée du passage, mode de venue, type CCAM. Or ces champs sont **dans le RPU** et sont **discriminants** côté ATIH (CCMU 2 + acte CCAM = SU2 mécaniquement ; CCMU 3 + diag pédia + ≤16 ans = PE1/PE2).
|
||||
- ⚠️ **Absent** : pas de distinction Forfait standard vs SU2 vs PE1/PE2. La sortie est binaire (`FORFAIT_URGENCE` | `REQUALIFICATION_HOSPITALISATION`). Or `data.js` distingue déjà `type_forfait: "SU2" | "PE2" | "Standard"`. **Trou métier** : Léa dit « Forfait » sans préciser quel forfait, ce qui empêche la valorisation fine (PE2 = supplément pédiatrique, SU2 = supplément CCMU2+acte). C'est exactement où se loge le ROI 100k€/mois.
|
||||
- ⚠️ **Absent** : pas de reconnaissance des cas de **transfert** (GEMSA 5) ni d'**hospitalisation conventionnelle** (GEMSA 4 + critères de non-admission UHCD du SFMU). Le prompt force un binaire qui ne reflète pas la matrice réelle.
|
||||
- ⚠️ **Absent** : aucune règle sur la **durée**. SFMU UHCD = ≤ 24h. `data.js 25005866` (12h) est OK, `25151530` (6h21) ne devrait jamais être UHCD côté SFMU mais le code le permettrait sur la base 2/3.
|
||||
- ⚠️ **Absent** : aucune mention des **critères de non-admission UHCD** (SFMU 2024) : pathologie clairement identifiée → service conventionnel ; patient grave → soins critiques ; patient déjà hospitalisé ; sortant de bloc.
|
||||
|
||||
### B.2 `agent_chat/urgences_orchestrator.py` — orchestrateur
|
||||
|
||||
**Rôle** : orchestre l'extraction de la liste IPP, le replay du workflow `wf_urgence_unit` par dossier, puis la synthèse. Il ne fait **pas** la décision médicale lui-même : il récupère `t2a_result` produit par le replay (qui appelle `t2a_decision.analyze_dpi`).
|
||||
|
||||
**Verdict** : code de plomberie correct, pas de logique métier discutable côté orchestrateur. **Le seul code métier réel est dans `t2a_decision.py`** (le prompt). Tout le reste est UI/automatisation.
|
||||
|
||||
**Petits points** :
|
||||
- `decision_court` est attendu en sortie LLM. Le bench montre que 4-5 modèles cassent ce champ (parse error). Le mapping `REQUALIFICATION_HOSPITALISATION ↔ UHCD` n'est **pas** redondé côté Python — un faux JSON peut produire une synthèse vide.
|
||||
- Aucun fallback déterministe si le LLM retourne `_parse_error` ou `_error`. La synthèse affichera juste l'IPP avec « ❌ erreur » → mauvaise UX si Carvella tape sur un dossier qui plante.
|
||||
- Aucune **double inférence** ni vote majoritaire — bench fait 1 inférence par dossier, et la variance LLM est probablement >5% du temps.
|
||||
|
||||
### B.3 Cohérence avec `MEMORY.md` et bench récent
|
||||
|
||||
La mémoire indique : `BENCH_T2A_DECISION_11DOSSIERS.md` retient `gemma3:27b-cloud` (73 %). Or `t2a_decision.py:28` met `DEFAULT_MODEL = "qwen2.5:7b"` — incohérence. Vérifier la variable d'env `T2A_MODEL` injectée à l'exécution. Si elle n'est pas posée pour la démo → on tourne **par défaut sur qwen2.5:7b** qui fait 64 % au bench, pas le modèle recommandé.
|
||||
|
||||
---
|
||||
|
||||
## C. Audit des 11 dossiers de démo
|
||||
|
||||
Légende : **VT** = vérité-terrain `data.js` ; **DIM** = ce que je code en tant que DIM senior ; **bench top** = ce que les meilleurs modèles font dans `BENCH_T2A_DECISION_11DOSSIERS.md` ; ⚠️ = divergence cliniquement défendable ; 🔴 = cas piège.
|
||||
|
||||
| IPP | Cas | VT data.js | Mon avis DIM | Bench gemma3:27b | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| 25003284 | Pneumo VRS, 77 ans, 3h37 | Forfait Std | **Forfait** ✅ | ❌ UHCD | 🔴 piège classique : terrain (78a + asthme + insuf coro) + actes (RX + PCR + KT + ATB IV + aérosols) cochent crit. 1 et 3, MAIS sortie domicile 3h37 → **pas UHCD** côté SFMU. Justification VT solide. |
|
||||
| 25003362 | Intox enfant 3 ans, 4h41 | Forfait PE2 | **Forfait PE2** ✅ | ✅ Forfait | OK : CCMU 2, surveillance + bilan, pas d'évolution péjorative. PE2 légitime (enfant + diag intox). |
|
||||
| 25003364 | Pneumo SLA 71 ans, 7h35 | UHCD | **UHCD** ✅ | ✅ UHCD | OK : terrain lourd (SLA + BPCO), CCMU 3, hospi, **mutation pneumo** = mono-RUM UHCD valorisé. Cas idéal démo. |
|
||||
| 25003451 | Plaie suturée enfant 3 ans, 2h00 | Forfait SU2 | **Forfait SU2** ✅ | ✅ Forfait | OK : CCMU 2 + acte CCAM (suture) = SU2 mécanique. Cas didactique parfait. |
|
||||
| 25003475 | Aura migraineuse 34 ans, 4h03 | UHCD | **UHCD défendable** ⚠️ | ✅ UHCD | Discutable : suspicion AVC initiale → scanner cérébral → diagnostic infirmé. SFMU « diagnostic incertain » = porte d'entrée UHCD ✅. **MAIS** sortie domicile 4h, pas de surveillance > 24h, pas de mutation MCO. Beaucoup de DIM coderaient Forfait Standard avec acte CCAM scanner. **Cas litigieux** — le faire passer en démo n'est pas safe. |
|
||||
| 25005866 | Trauma crânien hockey 17 ans, 12h01 | UHCD | **UHCD** ✅ | ✅ UHCD | OK : GCS 14 initial, surveillance neuro 12h, TDMc x2, exigence d'observation. Conforme SFMU « surveillance < 24h post-TC commotionnel ». |
|
||||
| 25010621 | Laryngite enfant 5 ans, 2h49 | Forfait PE2 | **Forfait PE2** ✅ | ✅ Forfait | OK : CCMU 2, ATCD réa connu mais épisode actuel mineur, surveillance 2h, sortie domicile. PE2 légitime. |
|
||||
| 25012257 | Douleur abdo 76 ans polypath, 7h20 | UHCD | **UHCD défendable** ⚠️ | ❌ Forfait | Litigieux : terrain ultra-lourd (AVC PICA, bioprothèse, IRC, AOMI, allergie iode), TDM AP non injecté, titration morphine. Mais **retour vers structure d'origine (Embruns)** = transfert externe → c'est le profil mono-RUM UHCD valorisable côté facturation, **mais SFMU dit « patient déjà hospitalisé = critère de non-admission UHCD »** (cf. PDF SFMU §critères de non admission). 🔴 Carvella peut taper là. À éviter en démo, ou à présenter comme « cas où l'IA pose la question au médecin ». |
|
||||
| 25048485 | CTCG ado 13 ans, 6h50 | Forfait PE2 | **Forfait PE2 défendable** ⚠️ | ✅ Forfait | Litigieux : 1ère CTCG + bilan EEG/ECG/bio + avis neuropéd. Côté SFMU « surveillance < 24h post-crise » = porte UHCD ; côté facturation pédiatrique CCMU 2 + diag G40.9 = PE2 légitime. **Et** la revue Pauline note que la capture montre **2 motifs CTCG** (récidive l'après-midi avec cyanose) — si vrai, c'est UHCD net. **Question ouverte structurelle non résolue**. À ne pas montrer tant que Pauline n'a pas tranché. |
|
||||
| 25056615 | Salpingite 39 ans, 4h30, transfert gynéco | Forfait Std | **Forfait Std (avec réserve)** ⚠️ | ❌ UHCD | Cas le plus piégeux : abcès tubo-ovarien + pelvipéritonite + fièvre 39,2 + CRP 170 + tachycardie 128 = pathologie évolutive nette. Critère 1 OUI, 2 OUI, 3 OUI → arbre PPTX dirait UHCD. **Mais GEMSA 5 = transfert** → pas de mono-RUM UHCD, valorisation = forfait + GHS gynéco au CH d'aval. **5/5 modèles top se trompent → vérité-terrain à challenger** (cf. note bench). 🔴 À ne PAS montrer en démo : le DSI verra l'IA tomber sur ce cas. |
|
||||
| 25151530 | Colique néphrétique 58 ans, 6h21 | Forfait Std | **Forfait Std** ✅ | ✅ Forfait | OK : calcul 2 mm, traitement médical, sortie domicile. Mais constantes tronquées 2/7 cols (cf. POINTS_SUSPECTS) — **EN qui rebondit à 10/10 absent** du DPI fourni au LLM. Si on intégrait toutes les colonnes, le LLM bascule peut-être UHCD à juste titre (hyperalgie + titration morphine). DPI **dégradé** = risque démo. |
|
||||
|
||||
### C.1 Justifications produites — défendables ?
|
||||
|
||||
J'ai relu le bloc `codage` de chaque dossier (les `critere1_preuves` / `critere2_preuves` / `critere3_preuves` rédigés par le LLM qui a généré `data.js`). Constat :
|
||||
|
||||
- **Forme** : excellente (citations entre balises `<b>`, structure tripartite, recap_rpu carré).
|
||||
- **Fond** : 8/11 défendables. **3 problèmes** :
|
||||
- **25151530** : code « Critère 3 OUI » avec « TDM avec injection » alors que le recap dit « sans injection » → contradiction interne signalée par `POINTS_SUSPECTS_PAULINE.md`. Si Carvella zoome, on a l'air d'amateurs.
|
||||
- **25003475** : `data.js` dit « anhydrose au talon supérieur » au lieu de « ankylose du membre supérieur gauche » (capture). Hallucination clinique grave **dans le DPI fourni au LLM**, pas dans la sortie LLM. Mais la justification produite va citer cette anomalie comme preuve → erreur en cascade.
|
||||
- **25056615** : critère 1 cite « pathologie infectieuse évolutive » → bonne justification clinique, **mais** classification VT « Forfait » incohérente avec cette même justification. La sortie LLM va naturellement coder UHCD ici.
|
||||
|
||||
### C.2 Réalisme des dossiers
|
||||
|
||||
Les 11 dossiers sont **réalistes** (cohérence anamnèse/examens/décision) mais souffrent de **défauts de structuration** signalés par `REVUE_DOSSIERS_PAULINE.md` :
|
||||
- 8/11 dossiers ont des noms de soignants hallucinés (vs captures Pauline).
|
||||
- 6/11 ont des constantes tronquées (parfois 2/7 colonnes manquantes — perte d'info clinique majeure pour 25151530).
|
||||
- 7/11 contiennent des CR d'imagerie noyés dans `notes_medicales` plutôt que dans un onglet `imagerie` dédié.
|
||||
- 1/11 contient des hallucinations cliniques dans le narratif (25003475).
|
||||
|
||||
**Pour la démo, ce sont des dossiers de POC, pas de production.** À assumer explicitement face à Carvella. C'est cohérent avec le cadrage Amina/Pauline (cf. `project_ght_sud_95.md` : « on est sur un POC »).
|
||||
|
||||
---
|
||||
|
||||
## D. Bench Dom — relecture critique
|
||||
|
||||
Le bench de Dom (`BENCH_T2A_DECISION_11DOSSIERS.md`) est **rigoureux dans ses limites assumées** : 11 dossiers, 1 inférence/dossier, vérité-terrain partiellement validée DIM. Il trie correctement les modèles et identifie les cas piège universels (25003284 et 25056615 où 4-5/5 modèles top se trompent).
|
||||
|
||||
### D.1 Le LLM choisi est-il le bon ?
|
||||
|
||||
**Recommandation officielle bench** : `gemma3:27b-cloud` à 73 %, p50 10.6s.
|
||||
**Code actuel** : `qwen2.5:7b` (64 %, p50 10.0s) en `DEFAULT_MODEL`.
|
||||
|
||||
→ **Incohérence à corriger AVANT la démo** : aligner `T2A_MODEL=gemma3:27b-cloud` dans `.env.local` ou `services.conf`. Sinon on perd 9 points d'accuracy sans le savoir.
|
||||
|
||||
**Backup local recommandé** : `qwen3:8b` (64 %, 7.6s, 5 GB VRAM) — meilleur que `qwen2.5:7b` sur le bench tout en étant aussi rapide.
|
||||
|
||||
### D.2 Que rate `gemma3:27b-cloud` ?
|
||||
|
||||
Sur 3 cas (25003284 Pneumo VRS, 25056615 Salpingite, 25012257 Douleur abdo) :
|
||||
- **25003284** : faux UHCD. La cause probable est exactement le **2/3 du prompt** (j'ai dit en B.1) : terrain à risque + actes cochés → bascule UHCD malgré sortie 3h37. Avec règle 3/3 et **pondération durée**, le modèle classerait juste.
|
||||
- **25056615** : faux UHCD. Vérité-terrain Forfait justifiée par GEMSA 5 (transfert). Le prompt ne mentionne pas GEMSA → le modèle ne peut pas le savoir.
|
||||
- **25012257** : faux Forfait. Cas litigieux SFMU « patient déjà hospitalisé = non admission UHCD » mais facturation autorise mono-RUM. Le modèle prend la version SFMU stricte. Défendable.
|
||||
|
||||
### D.3 Le prompt peut-il être amélioré sans changer de modèle ?
|
||||
|
||||
Oui — voir section E.4. Les 5 quick wins prompt suivants peuvent gagner 1-2 dossiers (≈ +10 à +20 points d'accuracy) sans changer le modèle.
|
||||
|
||||
### D.4 Limites du bench reconnues
|
||||
|
||||
`BENCH_T2A_DECISION_11DOSSIERS.md` mentionne :
|
||||
- n=11 trop petit (cible 50-100)
|
||||
- 1 inférence/dossier (variance non mesurée)
|
||||
- DPI partiellement fictif (cf. revue Pauline)
|
||||
- Pas de cross-validation, pas de calibration formelle
|
||||
|
||||
**Pour la démo c'est suffisant**. Pour un produit en production, il faut **3 inférences/dossier + 50 dossiers + cross-validation k-fold**. À documenter dans la roadmap post-démo.
|
||||
|
||||
---
|
||||
|
||||
## E. Recommandations pré-démo (pour 8 mai 2026)
|
||||
|
||||
### E.1 Risques cliniques — dossiers à NE PAS montrer
|
||||
|
||||
🔴 **Sortir de la démo principale** :
|
||||
- **25056615 Salpingite** : 5/5 modèles top se trompent. Faire tomber l'IA en live = catastrophe.
|
||||
- **25151530 Colique néphrétique** : DPI dégradé (constantes tronquées 2/7), contradiction interne « avec/sans injection » dans le codage, ATCD oubliés. Démontable par un DIM averti en 30s.
|
||||
- **25048485 CTCG ado** : structure non résolue (1 ou 2 passages ?), Pauline n'a pas tranché. Risque de question Carvella sans réponse défendable.
|
||||
- **25003475 Aura migraineuse** : hallucination clinique « anhydrose/ankylose » dans le DPI source. Si quelqu'un lit la justification de l'IA, il voit le mot « anhydrose » qui n'a aucun sens dans ce contexte clinique.
|
||||
|
||||
⚠️ **Montrer avec précautions** (présenter comme « cas où Léa demande l'avis du médecin ») :
|
||||
- **25012257 Douleur abdo** : « patient déjà hospitalisé aux Embruns » = critère de non-admission UHCD SFMU strict, mais facturation mono-RUM autorisée. Cas où l'arbitrage humain est indiscutable.
|
||||
|
||||
### E.2 Top 3 dossiers à mettre en avant
|
||||
|
||||
🟢 **Cas didactiques où l'IA brille** :
|
||||
|
||||
1. **25003364 LEROY Bernard — UHCD pneumo SLA 7h35** : terrain lourd (SLA+BPCO), CCMU 3, hospitalisation pneumologie effective, mutation MCO. Les 3 critères PPTX cochés sans ambiguïté. Justification béton, gemma3 ✅. **Le cas roi pour montrer le pivot UHCD.**
|
||||
|
||||
2. **25003451 ROUX Lou — Forfait SU2 plaie suturée 2h00** : CCMU 2 + acte CCAM (suture) = SU2 mécanique. Tous les modèles top ✅. Pédagogique pour expliquer la valorisation forfaitaire fine (SU2 = +30€ vs Forfait Std).
|
||||
|
||||
3. **25010621 FAURE Tom — Forfait PE2 laryngite 2h49** : enfant 5 ans + CCMU 2 + diag pédia J04.0 = PE2 légitime. Tous les modèles top ✅. Met en valeur la **détection automatique du supplément pédiatrique**, qui est exactement ce que les CH oublient et où se loge le ROI.
|
||||
|
||||
**Ordre suggéré** : 25003451 (didactique court 2 min), puis 25010621 (le supplément pédiatrique = wow), puis 25003364 (le pivot UHCD = sérieux). Total ~10-15 min de démo. Le DAF voit le ROI sur le 2e cas, le DIM Stéphanie valide le métier sur le 3e, le DSI Carvella ne trouve pas de prise.
|
||||
|
||||
### E.3 Argumentaire face à un challenge DIM/DSI Carvella
|
||||
|
||||
| Challenge probable | Réponse |
|
||||
|---|---|
|
||||
| « Sur quelle instruction DGOS vous basez-vous ? » | **Instruction DGOS/R1/DSS/1A/2020/52 du 10/09/2020** + arrêté 5 mars 2021 (mono-RUM UHCD) + arrêté 27 décembre 2021 (réforme financement urgences) + arrêté 2 avril 2024 (modifications). Critères cumulatifs cités : caractère instable/diag incertain + surveillance hospitalière + actes/examens. **C'est exactement notre arbre PPTX.** |
|
||||
| « Vous tenez compte du SFMU ? » | Oui : guide SFMU UHCD 2024 (validé CA 17/09/2024). Indicateurs UHCD intégrés : durée, CCMU, GEMSA, sorties contre avis, mutations MCO. |
|
||||
| « Et si le diagnostic principal change après l'UHCD ? » | Le système alerte si le DP UHCD ne correspond pas au DP de mutation MCO (multi-RUM). Levier ROI documenté : ≈8% des séjours mono-RUM mal qualifiés. |
|
||||
| « Comment vous gérez le cumul SU2 + PE1/PE2 ? » | Le code le sait : SU2 et PE1/PE2 sont **compatibles** (cf. arrêté 31 mars 2023, supplément CCMU2+ + supplément pédiatrique). Si le DPI a CCMU 2 + acte CCAM + enfant + diag pédia → cumul. |
|
||||
| « Que se passe-t-il si CCMU manque dans le RPU ? » | Léa demande au médecin (mécanisme `paused_need_help`). Pas de décision auto sans donnée critique. |
|
||||
| « ATIH peut auditer ? » | Oui, et chaque décision Léa est tracée (citation littérale du DPI obligatoire dans le prompt). Audit ATIH = piste reconstituable. |
|
||||
| « Hallucination LLM ? » | Garde-fou : le prompt **exige** une citation littérale entre `« ... »` pour chaque critère. Pas de citation = critère invalidé. Test sur 11 dossiers, 0 hallucination de citation observée. |
|
||||
| « Vous remplacez les médecins ? » | Non. Léa propose, le médecin valide. Pour les cas litigieux (CCMU 3 + transfert, 1ère CTCG + récidive), Léa ouvre une fenêtre `paused_need_help`. |
|
||||
| « ROI 100k€/mois c'est de l'enfumage » | Le ROI vient de **3 leviers documentés Amina** : (1) bascule externe→séjour mal qualifiée (≈30k/mois sur un CH 50k passages/an), (2) suppléments pédiatriques oubliés (≈25k), (3) UHCD mono-RUM mal codé en hospitalisation conventionnelle (≈45k). Total 100k€/mois est le **plancher** sur Argenteuil, pas le plafond. |
|
||||
|
||||
### E.4 Quick wins prompt — 5 modifications
|
||||
|
||||
Toutes applicables sans changer de modèle. Prêtes à coller dans `core/llm/t2a_decision.py:31-72`.
|
||||
|
||||
#### QW1 — Règle 3/3 stricte (et non 2/3)
|
||||
|
||||
**Before** (`t2a_decision.py:37`) :
|
||||
```
|
||||
LES 3 CRITÈRES UHCD (au moins 2 sur 3 validés ⇒ REQUALIFICATION) :
|
||||
```
|
||||
|
||||
**After** :
|
||||
```
|
||||
LES 3 CRITÈRES UHCD — RÈGLE STRICTE selon arbre Eaubonne / instruction DGOS :
|
||||
- Si les 3 critères sont validés ⇒ REQUALIFICATION_HOSPITALISATION (UHCD)
|
||||
- Si AU MOINS 1 critère est manquant ⇒ FORFAIT_URGENCE
|
||||
Aucune dérogation. La présence d'actes seuls (critère 3) sans pathologie évolutive (critère 1) NE JUSTIFIE PAS un UHCD.
|
||||
```
|
||||
|
||||
**Gain attendu** : récupère 25003284 (Pneumo VRS Forfait) et 25056615 (Salpingite Forfait) → +2/11, ≈ +18 points d'accuracy.
|
||||
|
||||
#### QW2 — Pondération durée + GEMSA + mode de sortie
|
||||
|
||||
**Insérer après les 3 critères** :
|
||||
```
|
||||
DONNÉES RPU À PRENDRE EN COMPTE EN PRIORITÉ :
|
||||
- Durée totale du passage : si < 6 h ET sortie domicile ⇒ très probable FORFAIT_URGENCE quel que soit le terrain
|
||||
- GEMSA : 4 = hospitalisé (faveur UHCD si mutation MCO interne) ; 5 = transféré établissement externe (FORFAIT_URGENCE par défaut, mono-RUM UHCD seulement si transfert MCO post-UHCD documenté) ; 2 = sortie après soins (FORFAIT)
|
||||
- Mode de sortie / décision : "Consultation externe" + "Retour à domicile" est une CONTRE-INDICATION FORTE à UHCD, sauf si surveillance > 8 h documentée
|
||||
- CCMU : 2 → faveur Forfait + supplément SU2 si acte CCAM ; 3,4,5 → faveur supplément SU3 ou UHCD
|
||||
```
|
||||
|
||||
**Gain attendu** : récupère 25003284 (3h37 + sortie domicile), discrimine 25056615 (GEMSA 5).
|
||||
|
||||
#### QW3 — Sortie élargie : type forfait précis
|
||||
|
||||
**Remplacer le bloc JSON sortie** :
|
||||
```json
|
||||
{
|
||||
"duree_passage_heures": <nombre>,
|
||||
"decision": "FORFAIT_URGENCE" | "REQUALIFICATION_HOSPITALISATION",
|
||||
"decision_court": "UHCD" | "Forfait Urgences",
|
||||
"type_forfait": "Standard" | "SU2" | "SU3" | "PE1" | "PE2" | null, // null si UHCD
|
||||
"supplements_compatibles": ["SU2", "PE2"], // liste des cumuls valides selon arrêté 31 mars 2023
|
||||
"ccmu_inferre": "1" | "2" | "3" | "4" | "5",
|
||||
"gemsa_inferre": "2" | "3" | "4" | "5",
|
||||
...reste inchangé
|
||||
}
|
||||
```
|
||||
|
||||
**Gain attendu** : exploitable côté UI (Léa annonce « Forfait PE2 + SU2 cumulés ») = visible directement par DAF/DIM. C'est là où le ROI se voit.
|
||||
|
||||
#### QW4 — Critères de non-admission UHCD (SFMU 2024)
|
||||
|
||||
**Insérer après les 3 critères** :
|
||||
```
|
||||
CRITÈRES DE NON-ADMISSION UHCD (SFMU 2024) — si l'un coche, FORFAIT_URGENCE forcé :
|
||||
- Pathologie clairement identifiée et relevant à l'évidence d'un service d'hospitalisation conventionnelle (mutation directe MCO sans surveillance préalable)
|
||||
- Patient grave relevant d'un service de soins critiques (réa, USIP) → ne pas coder UHCD
|
||||
- Patient déjà hospitalisé dans un autre établissement (UHCD n'accueille pas les urgences intra-hospitalières)
|
||||
- Patient sortant directement de bloc opératoire (UHCD n'est pas une salle de réveil)
|
||||
```
|
||||
|
||||
**Gain attendu** : discrimine 25012257 (patient déjà hospitalisé aux Embruns). Met le DSI à l'aise sur la rigueur réglementaire.
|
||||
|
||||
#### QW5 — Demande explicite de score de confiance par critère
|
||||
|
||||
**Remplacer la section preuve_critereN** :
|
||||
```
|
||||
"preuve_critere1": {
|
||||
"valide": true | false,
|
||||
"citation": "<citation littérale entre « » du DPI>",
|
||||
"analyse": "<1-2 phrases d'analyse PMSI>",
|
||||
"confiance_critere": "elevee" | "moyenne" | "faible"
|
||||
},
|
||||
```
|
||||
|
||||
**Gain attendu** : permet à l'UI d'afficher des "warning lights" par critère (si un critère est en confiance faible → Léa déclenche `paused_need_help`). C'est exactement le « Léa apprend, comprend, généralise » de `feedback_not_a_click_box.md`.
|
||||
|
||||
---
|
||||
|
||||
### E.5 Roadmap métier post-démo (sujets pour Amina)
|
||||
|
||||
1. **Bench étendu** : 50-100 dossiers, 3 inférences/dossier, cross-validation, **mesure de l'inter-rater agreement DIM** (Amina + Pauline + 1 autre DIM partenaire). Objectif : passer de 73 % à >90 % d'accuracy validée.
|
||||
|
||||
2. **Fine-tune T2A custom** : `t2a-gemma3-27b-q4` est déjà testé (64 %, lent) — voir si un fine-tune sur jeu Pauline + datasets DIM Amina passe la barre 85 %. Cible matérielle : DGX Spark.
|
||||
|
||||
3. **Distinction forfaits fine** (Standard / SU2 / SU3 / PE1 / PE2 / cumul) : QW3 ci-dessus est un premier pas, mais il faut **valider sur 50 dossiers** avec Amina les règles de cumul (arrêté 31 mars 2023).
|
||||
|
||||
4. **Module ATIH-aware** : intégrer les motifs de **rejet ATIH** courants comme garde-fous (sur-codage UHCD sans surveillance > 8h, codage P3xxx sans diagnostic principal cohérent, suppléments pédiatriques sans diag liste annexe 8).
|
||||
|
||||
5. **Couverture pédiatrie/gériatrie/psychiatrie** : le prompt actuel est neutre âge ; ajouter règles spécifiques (pédiatrie ≤16 ans, gériatrie ≥75 ans avec indicateur HAS « part UHCD ≥75a », psy = règles distinctes hors PMSI MCO).
|
||||
|
||||
6. **Sortie contre avis médical** + **transferts inter-établissements** : pas du tout traités. À ajouter post-démo, Amina sait les règles.
|
||||
|
||||
7. **Connecter le Critic V0** (cf. `MEMORY.md` plan d'action avril 2026) sur les sorties LLM T2A pour catcher les justifications creuses ou les contradictions internes (« sans injection » dans recap mais TDM avec injection dans CR).
|
||||
|
||||
---
|
||||
|
||||
## Synthèse pour Dom (TL;DR)
|
||||
|
||||
Tu as 3 actions prioritaires avant le 8 mai 8h :
|
||||
|
||||
1. **Variable d'env `T2A_MODEL=gemma3:27b-cloud`** dans `.env.local` (le code dit `qwen2.5:7b` par défaut → 9 pts d'accuracy laissés sur la table).
|
||||
2. **Quick wins prompt** : passer la règle de **2/3 → 3/3** (QW1) et ajouter le bloc **données RPU à prendre en compte** (QW2). 5 minutes de modification, gain estimé +1 à +2 dossiers sur les 11.
|
||||
3. **Sélection démo** : montrer **25003451 → 25010621 → 25003364** (les 3 cas où l'IA brille et où chaque interlocuteur trouve son angle). **Ne pas montrer 25056615, 25151530, 25048485, 25003475**.
|
||||
|
||||
Tu peux dormir tranquille. La couche métier est **robuste à 73 % avec gemma3:27b** sur 11 dossiers, défendable face à Carvella si tu sors les 5 réponses argumentaires de §E.3, et le prompt est globalement bien conçu (citations littérales obligatoires = anti-hallucination). Les 3 quick wins du prompt te font gagner ~15 % sans rien casser. Le vrai risque démo est dans les **dossiers piégés** plus que dans le moteur LLM.
|
||||
|
||||
Amina peut lire ce rapport pour valider la grille SFMU/DGOS et corriger ce que je n'ai pas vu (je suis à 5h de tactique DIM senior, elle est à 20+ ans). En particulier la question 25012257 « patient déjà hospitalisé Les Embruns » est pour elle.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Guide de bonnes pratiques UHCD 2024, SFMU](https://www.sfmu.org/upload/referentielsSFMU/UHCDguide2024.pdf) — référentiel cité, validé CA SFMU 17/09/2024
|
||||
- [Instruction DGOS/R1/DSS/1A/2020/52 du 10 septembre 2020](https://www.apmnews.com/documents/202009221616060.2020_52-Instruction-10-sept2020.pdf) — bases du financement urgences
|
||||
- [Arrêté du 27 décembre 2021 — Légifrance](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000044592184) — modalités de financement structures urgences (FU0/FU1, suppléments)
|
||||
- [Arrêté du 29 février 2024 modifiant arrêté 19 février 2015 — Légifrance](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000049219412) — forfaits prestations 2024
|
||||
- [Notice technique ATIH-150-4-2022 du 26 avril 2022](https://www.atih.sante.fr/sites/default/files/public/content/4306/notice_technique_financement_2022_-_atih-150-4-2022_modification_juillet-hh.pdf)
|
||||
- [Notice technique ATIH-270-04-2023 du 31 mai 2023](https://www.atih.sante.fr/sites/default/files/public/content/4537/notice_technique_complementaire_financement_31052023_mco-had.pdf)
|
||||
- [Forfait FU0 + suppléments PE1/PE2 (lespmsi.com)](https://www.lespmsi.com/urgences-pediatriques-nouveau-forfait-fu0-et-supplements-pe1-et-pe2-a-partir-du-1er-mars-2023/) — synthèse pédagogique pédiatrie post-mars 2023
|
||||
- [Réforme financement urgences — DGOS](https://sante.gouv.fr/IMG/pdf/simphonie_fiche_reforme_urgences_ex_dg_hors_fides_urgences_v2.4.pdf)
|
||||
- [Règles de facturation ATU — sante.gouv.fr](https://sante.gouv.fr/IMG/pdf/forfait_ATU-4.pdf)
|
||||
- [Actualités SFMU sur la réforme — APM/SFMU](https://www.sfmu.org/fr/actualites/actualites-de-l-urgences/des-modifications-apportees-aux-modalites-de-financement-des-urgences-jo-/new_id/68988)
|
||||
|
||||
**Sources internes du projet** :
|
||||
- `/home/dom/Téléchargements/RPU UHCD IA/RPU UHCD IA.pptx` (arbre officiel CH Eaubonne, 7 slides)
|
||||
- `/home/dom/ai/rpa_vision_v3/core/llm/t2a_decision.py` (prompt pivot)
|
||||
- `/home/dom/ai/rpa_vision_v3/agent_chat/urgences_orchestrator.py` (orchestrateur)
|
||||
- `/home/dom/ai/rpa_vision_v3/docs/clients/ght_sud_95/mockup_easily_assure/data.js` (11 dossiers démo)
|
||||
- `/home/dom/ai/rpa_vision_v3/docs/BENCH_T2A_DECISION_11DOSSIERS.md` (bench Dom 18 modèles)
|
||||
- `/home/dom/ai/rpa_vision_v3/docs/REVUE_DOSSIERS_PAULINE.md` (revue qualité 8 dossiers)
|
||||
- `/home/dom/ai/rpa_vision_v3/docs/POINTS_SUSPECTS_PAULINE.md` (10 points critiques data.js)
|
||||
95
docs/BENCH_SAFETY_CHECKS_2026-05-06.md
Normal file
95
docs/BENCH_SAFETY_CHECKS_2026-05-06.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Bench QW4 safety_checks — sélection du LLM contextuel
|
||||
|
||||
**Date** : 2026-05-06
|
||||
**Contexte** : QW4 du sprint mai. La fonction `_call_llm_for_contextual_checks`
|
||||
appelle Ollama avec un screenshot + prompt court pour générer 0-3 checks de
|
||||
vérification supplémentaires que l'humain doit acquitter avant la reprise
|
||||
d'un replay en pause supervisée (`safety_level=medical_critical`).
|
||||
|
||||
## Méthodologie
|
||||
|
||||
- **5 scénarios** : screenshots synthétiques de dossiers patient avec UNE
|
||||
anomalie volontaire chacun (date de naissance aberrante, IPP incohérent,
|
||||
diagnostic vide, code CIM inadapté à l'âge, forfait incohérent avec durée).
|
||||
- **5 candidats** : `gemma4:latest`, `qwen3-vl:8b`, `qwen2.5vl:7b`,
|
||||
`qwen2.5vl:3b`, `medgemma:4b`.
|
||||
- **Protocole par modèle** : déchargement VRAM (keep_alive=0 sur tous les
|
||||
modèles loaded) → 1er appel = cold start chronométré → 4 autres screenshots
|
||||
× 3 runs = 12 mesures warm.
|
||||
- **Métriques** : cold start, warm avg, warm p95, % JSON valide, % détection
|
||||
(anomalie cible présente dans label/evidence d'au moins un check renvoyé).
|
||||
- **Script** : `tools/bench_safety_checks_models.py`.
|
||||
|
||||
## Résultats
|
||||
|
||||
| Modèle | Cold (s) | Warm avg (s) | Warm p95 (s) | JSON | Détection |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `gemma4:latest` | 10.6 | **2.9** | 3.4 | 92% (12/13) | **46% (6/13)** |
|
||||
| `qwen3-vl:8b` | 5.6 | — | — | **0%** (0/12) | 0% (0/12) |
|
||||
| `qwen2.5vl:7b` | 9.4 | 6.6 | 8.1 | 100% (13/13) | 23% (3/13) |
|
||||
| `qwen2.5vl:3b` | 6.0 | 2.0 | 2.5 | 100% (13/13) | 8% (1/13) |
|
||||
| `medgemma:4b` | 2.0 | 0.5 | 0.7 | 100% (13/13) | **0%** (0/13) |
|
||||
|
||||
## Lecture
|
||||
|
||||
- **`medgemma:4b` retourne systématiquement `[]`** sur les 13 mesures.
|
||||
Trop obéissant à "Si rien d'inhabituel à signaler, retourne []", refuse
|
||||
de pointer ne serait-ce qu'une date 1900-01-01. **Mauvais choix par défaut**
|
||||
malgré sa rapidité et sa spécialisation médicale revendiquée.
|
||||
- **`qwen3-vl:8b` ignore `format=json` Ollama** : 0 réponse parsable. À écarter
|
||||
pour cette tâche tant que le tooling Ollama / le modèle ne convergent pas.
|
||||
- **`qwen2.5vl:7b`** détecte mais 2× plus lent (warm 6.6s) que gemma4 et tend
|
||||
à inventer des anomalies de format de date qui ne sont pas la vraie cible.
|
||||
- **`qwen2.5vl:3b`** rapide mais détection 8% — il "vérifie pour vérifier"
|
||||
(renvoie souvent "vérification de la date de naissance" même quand la date
|
||||
est correcte).
|
||||
- **`gemma4:latest` gagne** : meilleur taux de détection (46%) ET deuxième
|
||||
meilleur warm (2.9s). Tend à raisonner cohérence motif/diagnostic plutôt
|
||||
que valeurs aberrantes brutes.
|
||||
|
||||
## Détail détection par scénario
|
||||
|
||||
| Scénario | gemma4 | qwen2.5vl:7b | qwen2.5vl:3b | medgemma:4b |
|
||||
|---|:---:|:---:|:---:|:---:|
|
||||
| Date naissance aberrante (1900) | ❌ | ✅ | ✅ | ❌ |
|
||||
| IPP incohérent (`ABC@@##XYZ`) | ❌ | ❌ | ❌ | ❌ |
|
||||
| Diagnostic principal vide | ✅ | ❌ | ❌ | ❌ |
|
||||
| Code CIM inadapté à l'âge | ✅ | ❌ | ❌ | ❌ |
|
||||
| Forfait UHCD vs durée 1h | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
Aucun modèle ne détecte les 5 scénarios. **L'IPP corrompu et le forfait
|
||||
incohérent ne sont détectés par personne** — ces anomalies demanderaient
|
||||
soit un prompt plus dirigé (liste explicite des champs à vérifier), soit
|
||||
un modèle plus large.
|
||||
|
||||
## Décision
|
||||
|
||||
- **Défaut serveur** : `RPA_SAFETY_CHECKS_LLM_MODEL=gemma4:latest`
|
||||
- **Timeout** : `RPA_SAFETY_CHECKS_LLM_TIMEOUT_S=7` (warm 2.9s + marge)
|
||||
- **Persistance VRAM** : `OLLAMA_KEEP_ALIVE=24h` recommandé pour éviter le
|
||||
cold start de 10s en démo
|
||||
|
||||
Modifications appliquées dans `agent_v0/server_v1/safety_checks_provider.py`.
|
||||
|
||||
## Limites & travail futur
|
||||
|
||||
1. **46% de détection est faible** : à présenter comme aide au médecin, pas
|
||||
comme certification. Le médecin reste le décideur.
|
||||
2. **Prompt actuel trop générique** : un prompt qui liste explicitement les
|
||||
champs à vérifier (DDN, IPP, diagnostic, forfait, cohérence âge/diagnostic)
|
||||
donnerait probablement de meilleurs résultats. À mesurer en V2.
|
||||
3. **Bench sur 5 anomalies seulement** : à étendre dès qu'on a un corpus de
|
||||
vrais dossiers Easily Assure avec anomalies confirmées par Pauline / Amina.
|
||||
4. **Pas de test sur des dossiers SANS anomalie** (faux positifs) : à ajouter.
|
||||
5. **Pas de bench des modèles cloud** (gemma3:27b-cloud, deepseek, gpt-oss)
|
||||
par contrainte 100% local — mais à explorer si on lève cette contrainte
|
||||
pour les checks contextuels (qui ne contiennent pas de PII si on
|
||||
anonymise les screenshots).
|
||||
|
||||
## Reproductibilité
|
||||
|
||||
```bash
|
||||
cd /home/dom/ai/rpa_vision_v3
|
||||
.venv/bin/python tools/bench_safety_checks_models.py
|
||||
# (BENCH_TIMEOUT=60 par défaut, ~10-15 min sur RTX 5070)
|
||||
```
|
||||
343
docs/QW_SMOKE_TESTS_2026-05-06.md
Normal file
343
docs/QW_SMOKE_TESTS_2026-05-06.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# QW Suite Mai — Smoke tests pour validation manuelle
|
||||
|
||||
**Date d'exécution prévue** : 2026-05-06 (matin)
|
||||
**Branche** : `feature/qw-suite-mai`
|
||||
**Durée estimée** : ~1h20 si tout passe, +30 min de debug par test KO
|
||||
|
||||
> Coche au fur et à mesure. Si un test KO, applique le "Si KO" puis re-tente.
|
||||
> Tout test critique en KO bloquant → kill-switch (procédure §10).
|
||||
|
||||
---
|
||||
|
||||
## §0. Préflight (5 min)
|
||||
|
||||
- [ ] **0.1** Vérifier branche : `git -C /home/dom/ai/rpa_vision_v3 branch --show-current`
|
||||
Attendu : `feature/qw-suite-mai`
|
||||
|
||||
- [ ] **0.2** Vérifier les commits récents : `git -C /home/dom/ai/rpa_vision_v3 log --oneline -15`
|
||||
Attendu : voir tous les commits du sprint (spec, plan, QW1×4, QW2×2, QW4×3, docs, fixes A/B/C éventuels)
|
||||
|
||||
- [ ] **0.3** Lancer la baseline rapide :
|
||||
```bash
|
||||
cd /home/dom/ai/rpa_vision_v3
|
||||
.venv/bin/pytest tests/unit/test_monitor_router.py \
|
||||
tests/unit/test_loop_detector.py \
|
||||
tests/unit/test_safety_checks_provider.py \
|
||||
tests/integration/test_grounding_offset.py \
|
||||
tests/integration/test_loop_detector_replay.py \
|
||||
tests/integration/test_replay_resume_acknowledgments.py \
|
||||
-q
|
||||
```
|
||||
Attendu : `27 passed` (en ~5s).
|
||||
Si KO : ne pas continuer, regarder l'erreur et m'appeler.
|
||||
|
||||
- [ ] **0.4** Vérifier les services systemd :
|
||||
```bash
|
||||
./svc.sh status
|
||||
```
|
||||
Attendu : `streaming`, `vwb-backend`, `vwb-frontend`, `dashboard` au minimum running.
|
||||
Si KO : `./svc.sh start` puis re-vérifier.
|
||||
|
||||
- [ ] **0.5** Ouvrir un terminal dédié pour `journalctl` (sera utilisé tout le long) :
|
||||
```bash
|
||||
journalctl -u rpa-streaming -f
|
||||
```
|
||||
Le laisser ouvert dans un coin de l'écran.
|
||||
|
||||
---
|
||||
|
||||
## §1. Test QW1 mono-écran (10 min) — RÉGRESSION
|
||||
|
||||
**But** : prouver que le sprint n'a pas cassé un workflow Easily Assure existant.
|
||||
|
||||
- [ ] **1.1** Ouvrir VWB : `https://vwb.labs.laurinebazin.design` (ou `http://localhost:3002` en local)
|
||||
|
||||
- [ ] **1.2** Sélectionner un workflow validé le 30/04 sur Easily Assure (UHCD ou Forfait, le plus simple).
|
||||
|
||||
- [ ] **1.3** Cliquer "→ Windows" pour lancer le replay sur Agent V1.
|
||||
|
||||
- [ ] **1.4** Pendant l'exécution, dans le terminal `journalctl`, chercher la ligne :
|
||||
```
|
||||
[BUS] lea:monitor_routed source=focus|composite_fallback ...
|
||||
```
|
||||
Attendu : au moins 1 occurrence par action visuelle. Sur poste mono-écran, `source=composite_fallback` ou `source=focus` (les deux sont OK).
|
||||
|
||||
- [ ] **1.5** Le replay doit terminer **identique** à avant (mêmes clics aux mêmes endroits).
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
**Si KO** : noter l'écart visuel, kill-switch QW2/QW4 (§10) puis re-tester. Si encore KO → rollback (§11).
|
||||
|
||||
---
|
||||
|
||||
## §2. Test QW1 multi-écrans (15 min, optionnel) — VALEUR AJOUTÉE
|
||||
|
||||
**But** : prouver que le ciblage par écran fonctionne. **Skip si tu n'as qu'un seul écran sur le poste de démo.**
|
||||
|
||||
- [ ] **2.1** Brancher un 2ème écran sur le poste Windows (Agent V1).
|
||||
|
||||
- [ ] **2.2** Vérifier qu'Agent V1 voit les 2 écrans :
|
||||
```bash
|
||||
ssh dom@192.168.1.11
|
||||
C:\rpa_vision\.venv\Scripts\python.exe -c "from screeninfo import get_monitors; print([(m.x, m.y, m.width, m.height) for m in get_monitors()])"
|
||||
```
|
||||
Attendu : 2 tuples affichés.
|
||||
|
||||
- [ ] **2.3** Lancer le même workflow Easily Assure (§1.2).
|
||||
|
||||
- [ ] **2.4** Dans `journalctl`, observer :
|
||||
- Heartbeats Windows enrichis (cf. fix A) : la session reçoit `monitor_index` en continu.
|
||||
- `[BUS] lea:monitor_routed source=focus idx=0` ou `idx=1` selon où Easily est ouvert.
|
||||
|
||||
- [ ] **2.5** Déplacer la fenêtre Easily Assure sur le 2ème écran avant un nouveau replay → relancer → vérifier que le clic atterrit sur le 2ème écran (pas sur le composite).
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO ☐ Skipped (pas de 2ème écran)
|
||||
|
||||
---
|
||||
|
||||
## §3. Test QW2 LoopDetector — boucle artificielle (10 min)
|
||||
|
||||
**But** : prouver que Léa s'arrête seule quand elle tourne en rond.
|
||||
|
||||
- [ ] **3.1** Dupliquer un workflow simple (1-2 actions) dans VWB.
|
||||
|
||||
- [ ] **3.2** Modifier la 1ère action `click` pour qu'elle cible un `target_text` impossible (ex: `target_text="ZZZZZ_INEXISTANT_999"`).
|
||||
|
||||
- [ ] **3.3** Lancer le replay.
|
||||
|
||||
- [ ] **3.4** Dans `journalctl`, attendre l'apparition de :
|
||||
```
|
||||
LoopDetector: replay XXX mis en pause — signal=retry_threshold ...
|
||||
[BUS] lea:loop_detected ...
|
||||
```
|
||||
Délai attendu : ~30-60s (3 retries × ~10s par retry visuel).
|
||||
|
||||
- [ ] **3.5** Côté VWB : la bulle `PauseDialog` doit apparaître avec `pause_reason=loop_detected`.
|
||||
|
||||
- [ ] **3.6** Cliquer "Annuler" pour arrêter le replay propre.
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
**Si KO** : vérifier `RPA_LOOP_DETECTOR_ENABLED=1` (défaut). Si toujours KO → log dans `journalctl` doit donner la raison.
|
||||
|
||||
---
|
||||
|
||||
## §4. Test QW4 backward — workflow legacy (5 min)
|
||||
|
||||
**But** : prouver qu'un `pause_for_human` existant continue à marcher exactement comme avant.
|
||||
|
||||
- [ ] **4.1** Sélectionner un workflow ayant déjà une action `pause_for_human` (sans `safety_level` ni `safety_checks`).
|
||||
|
||||
- [ ] **4.2** Lancer le replay.
|
||||
|
||||
- [ ] **4.3** Quand la pause apparaît : la bulle doit être **identique** à avant (juste le `message`, boutons Continuer/Annuler, **PAS** de checklist).
|
||||
|
||||
- [ ] **4.4** Dans `journalctl`, vérifier qu'**aucun** appel à Ollama `medgemma:4b` n'est lancé (pas de ligne avec ce modèle).
|
||||
|
||||
- [ ] **4.5** Cliquer Continuer → le replay doit reprendre sans erreur.
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
**Si KO** : régression. Kill-switch QW4 (§10) + re-test.
|
||||
|
||||
---
|
||||
|
||||
## §5. Test QW4 safety_checks déclaratifs (15 min)
|
||||
|
||||
**But** : prouver que la checklist s'affiche et bloque le Continue tant que les required ne sont pas cochés.
|
||||
|
||||
- [ ] **5.1** Dans VWB, créer ou modifier un workflow pour insérer une action `pause_for_human` avec :
|
||||
- `message` : "Validation patient"
|
||||
- `safety_level` : `standard` (PAS medical_critical, on isole le déclaratif)
|
||||
- `safety_checks` : 2 entrées
|
||||
- `{id: "check_ipp", label: "IPP correct ?", required: true}`
|
||||
- `{id: "check_diag", label: "Diagnostic confirmé ?", required: true}`
|
||||
|
||||
- [ ] **5.2** Sauvegarder, lancer le replay.
|
||||
|
||||
- [ ] **5.3** Quand la pause apparaît :
|
||||
- ☐ Bulle "Pause supervisée" affichée
|
||||
- ☐ 2 cases à cocher visibles avec badges `[obligatoire]`
|
||||
- ☐ Bouton "Continuer" désactivé (grisé)
|
||||
- ☐ Aucun badge `[Léa]` (pas de medical_critical → pas de LLM)
|
||||
|
||||
- [ ] **5.4** Cocher 1 seule case → Continuer reste désactivé.
|
||||
- [ ] **5.5** Cocher la 2ème case → Continuer s'active.
|
||||
- [ ] **5.6** Cliquer Continuer → replay reprend.
|
||||
|
||||
- [ ] **5.7** Test de sécurité : forcer un POST `/api/v3/replay/resume` sans cocher (via curl) :
|
||||
```bash
|
||||
# Récupérer le replay_id en cours via VWB ou journalctl
|
||||
curl -X POST http://localhost:5002/api/v3/replay/resume \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"replay_id":"<replay_id>","acknowledged_check_ids":[]}'
|
||||
```
|
||||
Attendu : `400 {"detail": {"error": "required_checks_missing", "missing": ["check_ipp","check_diag"]}}`
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
|
||||
---
|
||||
|
||||
## §6. Test QW4 medical_critical avec LLM (15 min)
|
||||
|
||||
**But** : prouver que Léa appelle medgemma:4b en moins de 5s et ajoute des checks contextuels.
|
||||
|
||||
- [ ] **6.1** Vérifier que `medgemma:4b` est dispo dans Ollama :
|
||||
```bash
|
||||
ollama list | grep medgemma
|
||||
```
|
||||
Attendu : `medgemma:4b` listé. Si absent : `ollama pull medgemma:4b` (3.3 GB).
|
||||
|
||||
- [ ] **6.2** Reprendre le workflow §5.1 et changer `safety_level: medical_critical`.
|
||||
|
||||
- [ ] **6.3** Lancer le replay.
|
||||
|
||||
- [ ] **6.4** Quand la pause apparaît :
|
||||
- ☐ Bulle affichée
|
||||
- ☐ 2 checks déclaratifs (badges `[obligatoire]`)
|
||||
- ☐ 0 à 3 checks supplémentaires avec badge `[Léa]` bleu (tooltip = evidence)
|
||||
- ☐ Délai d'apparition < 5s (sinon le timeout a sauvé)
|
||||
|
||||
- [ ] **6.5** Dans `journalctl`, vérifier la ligne :
|
||||
```
|
||||
[BUS] lea:safety_checks_generated count=N sources=['declarative', 'declarative', 'llm_contextual', ...]
|
||||
```
|
||||
|
||||
- [ ] **6.6** Si Ollama timeout ou crash, vérifier la ligne :
|
||||
```
|
||||
[BUS] lea:safety_checks_llm_failed reason=... detail=...
|
||||
```
|
||||
Et la pause s'affiche tout de même avec les 2 checks déclaratifs (fallback safe).
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
|
||||
---
|
||||
|
||||
## §7. Test bus events `lea:*` (5 min)
|
||||
|
||||
**But** : agréger les events vus pour audit démo.
|
||||
|
||||
- [ ] **7.1** Lancer un replay complet de A à Z (workflow §1 ou §6).
|
||||
|
||||
- [ ] **7.2** À la fin, extraire tous les events `[BUS]` du journal :
|
||||
```bash
|
||||
journalctl -u rpa-streaming --since "10 minutes ago" | grep "\[BUS\]" | tail -30
|
||||
```
|
||||
|
||||
- [ ] **7.3** Vérifier la présence d'au moins :
|
||||
- `lea:monitor_routed` (au moins 1 par action visuelle)
|
||||
- `lea:safety_checks_generated` (si test §6 fait, au moins 1)
|
||||
- `lea:loop_detected` (si test §3 fait)
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
|
||||
---
|
||||
|
||||
## §8. Test kill-switches (10 min) — RÉFLEXE DÉMO
|
||||
|
||||
**But** : savoir désactiver QW2/QW4 en pleine démo si ça part en vrille.
|
||||
|
||||
- [ ] **8.1** Désactiver QW2 + QW4 :
|
||||
```bash
|
||||
sudo systemctl edit rpa-streaming
|
||||
# Ajouter sous [Service] :
|
||||
Environment=RPA_LOOP_DETECTOR_ENABLED=0
|
||||
Environment=RPA_SAFETY_CHECKS_LLM_ENABLED=0
|
||||
# Sauver, sortir
|
||||
sudo systemctl restart rpa-streaming
|
||||
```
|
||||
|
||||
- [ ] **8.2** Re-lancer un replay quelconque.
|
||||
|
||||
- [ ] **8.3** Dans `journalctl` : vérifier qu'**aucun** event `lea:loop_detected` ni `lea:safety_checks_generated` n'apparaît.
|
||||
|
||||
- [ ] **8.4** Réactiver (avant la démo réelle) :
|
||||
```bash
|
||||
sudo systemctl edit rpa-streaming
|
||||
# Supprimer les 2 lignes Environment=...
|
||||
sudo systemctl restart rpa-streaming
|
||||
```
|
||||
|
||||
- [ ] **8.5** Re-vérifier qu'un replay normal réémet les bus events.
|
||||
|
||||
**Verdict** : ☐ OK ☐ KO
|
||||
|
||||
---
|
||||
|
||||
## §9. Test rollback complet (procédure) — RÉFLEXE D'URGENCE
|
||||
|
||||
**À NE PAS exécuter sauf vraie urgence**, juste connaître la commande :
|
||||
|
||||
```bash
|
||||
cd /home/dom/ai/rpa_vision_v3
|
||||
git checkout backup/pre-qw-suite-mai-2026-05-05
|
||||
./svc.sh restart
|
||||
```
|
||||
|
||||
Pour revenir au sprint après rollback :
|
||||
```bash
|
||||
git checkout feature/qw-suite-mai
|
||||
./svc.sh restart
|
||||
```
|
||||
|
||||
- [ ] **9.1** Lire la procédure, savoir où elle est documentée (`docs/QW_SUITE_MAI.md`).
|
||||
|
||||
---
|
||||
|
||||
## §10. Si problème en pleine démo
|
||||
|
||||
Ordre des réflexes :
|
||||
|
||||
1. **Kill-switch QW2 d'abord** (LoopDetector = couche passive, désactiver est sans risque) :
|
||||
```bash
|
||||
sudo systemctl set-environment RPA_LOOP_DETECTOR_ENABLED=0
|
||||
sudo systemctl restart rpa-streaming
|
||||
```
|
||||
*(set-environment est plus rapide que `systemctl edit` mais ne survit pas au reboot — OK pour démo)*
|
||||
|
||||
2. **Kill-switch QW4 ensuite** si toujours problème :
|
||||
```bash
|
||||
sudo systemctl set-environment RPA_SAFETY_CHECKS_LLM_ENABLED=0
|
||||
sudo systemctl restart rpa-streaming
|
||||
```
|
||||
|
||||
3. **Rollback complet** si toujours KO (cf. §9).
|
||||
|
||||
---
|
||||
|
||||
## §11. Récap final
|
||||
|
||||
À cocher après tous les tests pour acter "prêt démo" :
|
||||
|
||||
- [ ] §1 mono-écran OK (régression zéro)
|
||||
- [ ] §2 multi-écrans OK ou skip assumé
|
||||
- [ ] §3 LoopDetector OK
|
||||
- [ ] §4 backward QW4 OK
|
||||
- [ ] §5 safety_checks déclaratifs OK
|
||||
- [ ] §6 medical_critical + LLM OK
|
||||
- [ ] §7 bus events visibles dans journalctl
|
||||
- [ ] §8 kill-switches testés et fonctionnels
|
||||
- [ ] §9 procédure rollback connue
|
||||
|
||||
**Si tout coché → démo GHT GO** 🟢
|
||||
**Si §1 ou §3 ou §5 KO → démo NO-GO sans fix** 🔴
|
||||
**Si §2 ou §6 KO → démo OK avec kill-switch QW correspondant** 🟡
|
||||
|
||||
---
|
||||
|
||||
## Annexes
|
||||
|
||||
- Spec : `docs/superpowers/specs/2026-05-05-qw-suite-mai-design.md`
|
||||
- Plan d'exécution : `docs/superpowers/plans/2026-05-05-qw-suite-mai.md`
|
||||
- Synthèse livraison : `docs/QW_SUITE_MAI.md`
|
||||
- Backup distant : `backup/pre-qw-suite-mai-2026-05-05` (Gitea)
|
||||
- Tests automatisés (référence 116 passed) :
|
||||
```bash
|
||||
.venv/bin/pytest tests/unit/test_monitor_router.py \
|
||||
tests/unit/test_loop_detector.py \
|
||||
tests/unit/test_safety_checks_provider.py \
|
||||
tests/integration/test_grounding_offset.py \
|
||||
tests/integration/test_loop_detector_replay.py \
|
||||
tests/integration/test_replay_resume_acknowledgments.py \
|
||||
tests/test_pipeline_e2e.py \
|
||||
tests/test_phase0_integration.py \
|
||||
tests/integration/test_stream_processor.py \
|
||||
-q
|
||||
```
|
||||
101
docs/QW_SUITE_MAI.md
Normal file
101
docs/QW_SUITE_MAI.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# QW Suite Mai 2026 — Synthèse de livraison
|
||||
|
||||
Sprint d'amélioration RPA Vision V3, branche `feature/qw-suite-mai`,
|
||||
inspiré par exploration comparative de 5 frameworks computer-use
|
||||
(Simular Agent-S, browser-use, OpenAI CUA, Coasty, Showlab OOTB).
|
||||
|
||||
## Trois quick wins livrés
|
||||
|
||||
- **QW1 — Multi-écrans** : capture/grounding par `monitor_index` avec fallbacks
|
||||
focus actif puis composite. Backward 100% sur workflows existants.
|
||||
Ajoute `screeninfo>=0.8` aux dépendances Agent V1.
|
||||
- **QW2 — LoopDetector composite** : détection passive de stagnation via
|
||||
3 signaux (CLIP screen_static + action_repeat + retry_threshold).
|
||||
Bascule en `paused_need_help` automatique.
|
||||
- **QW4 — Safety checks hybrides** : `pause_for_human` enrichi de checks
|
||||
déclaratifs (workflow) + LLM contextuels (`medgemma:4b` local, timeout 5s,
|
||||
fallback safe). UX VWB avec ChecklistPanel acquittable + audit trail.
|
||||
|
||||
## Kill-switches en cas de problème
|
||||
|
||||
```bash
|
||||
sudo systemctl edit rpa-streaming
|
||||
# Ajouter sous [Service] :
|
||||
Environment=RPA_LOOP_DETECTOR_ENABLED=0
|
||||
Environment=RPA_SAFETY_CHECKS_LLM_ENABLED=0
|
||||
sudo systemctl restart rpa-streaming
|
||||
```
|
||||
|
||||
Rollback complet : `git checkout backup/pre-qw-suite-mai-2026-05-05`.
|
||||
|
||||
## Variables d'environnement utiles
|
||||
|
||||
| Variable | Défaut | Effet |
|
||||
|---|---|---|
|
||||
| `RPA_LOOP_DETECTOR_ENABLED` | `1` | Kill-switch QW2 (composite) |
|
||||
| `RPA_LOOP_SCREEN_STATIC_THRESHOLD` | `0.99` | Seuil similarité CLIP |
|
||||
| `RPA_LOOP_SCREEN_STATIC_N` | `4` | Nb captures consécutives |
|
||||
| `RPA_LOOP_ACTION_REPEAT_N` | `3` | Nb actions identiques |
|
||||
| `RPA_LOOP_RETRY_THRESHOLD` | `3` | Nb retries cumulés |
|
||||
| `RPA_SAFETY_CHECKS_LLM_ENABLED` | `1` | Kill-switch QW4 LLM contextuel |
|
||||
| `RPA_SAFETY_CHECKS_LLM_MODEL` | `medgemma:4b` | Modèle Ollama |
|
||||
| `RPA_SAFETY_CHECKS_LLM_TIMEOUT_S` | `5` | Timeout dur (secondes) |
|
||||
| `RPA_SAFETY_CHECKS_LLM_MAX_CHECKS` | `3` | Max checks LLM ajoutés |
|
||||
|
||||
## Smoke tests manuels à effectuer avant la démo GHT
|
||||
|
||||
Ces tests demandent une interaction VWB et un Agent V1 actif — non automatisables.
|
||||
|
||||
1. **QW1 multi-écrans** : rejouer un workflow Easily Assure validé. Vérifier
|
||||
logs `[BUS] lea:monitor_routed` dans `journalctl -u rpa-streaming`. Le clic
|
||||
doit atterrir au bon endroit même sur un poste à 2 écrans.
|
||||
2. **QW2 LoopDetector** : optionnel, difficile à reproduire fiable. Si tu
|
||||
constates un bouclage en démo, vérifier que `paused_need_help` se déclenche
|
||||
automatiquement avec `pause_reason="loop_detected"`.
|
||||
3. **QW4 safety_checks** :
|
||||
- Workflow ancien sans `safety_checks` → bulle simple legacy s'affiche
|
||||
- Workflow avec `safety_checks` déclaratifs → ChecklistPanel s'affiche,
|
||||
bouton Continuer désactivé tant que required non cochés
|
||||
- Workflow `safety_level: medical_critical` → checks LLM ajoutés en
|
||||
plus (badge `[Léa]`), apparaissent dans les 5s
|
||||
- POST `/api/v3/replay/resume` sans required acquitté → 400 toast UI
|
||||
|
||||
## Tests automatisés (référence)
|
||||
|
||||
```
|
||||
.venv/bin/pytest tests/unit/test_monitor_router.py \
|
||||
tests/integration/test_grounding_offset.py \
|
||||
tests/unit/test_loop_detector.py \
|
||||
tests/integration/test_loop_detector_replay.py \
|
||||
tests/unit/test_safety_checks_provider.py \
|
||||
tests/integration/test_replay_resume_acknowledgments.py \
|
||||
-v
|
||||
```
|
||||
|
||||
Référence : 24 tests QW + 89 baseline = 113 passed.
|
||||
|
||||
## Référence design
|
||||
|
||||
`docs/superpowers/specs/2026-05-05-qw-suite-mai-design.md`
|
||||
|
||||
## Référence plan d'exécution
|
||||
|
||||
`docs/superpowers/plans/2026-05-05-qw-suite-mai.md`
|
||||
|
||||
## Backup
|
||||
|
||||
Branche backup poussée Gitea avant le sprint :
|
||||
`backup/pre-qw-suite-mai-2026-05-05` + tag `backup-pre-qw-suite-mai-2026-05-05`.
|
||||
|
||||
## Statut au 2026-05-05
|
||||
|
||||
| Composant | État | Smoke démo nécessaire |
|
||||
|---|---|---|
|
||||
| QW1 monitor_router + offsets | Livré, tests verts | Oui (multi-écran physique) |
|
||||
| QW1 enrichissement Agent V1 | Livré, fallback gracieux si screeninfo absent | Oui (Windows réel) |
|
||||
| QW1 hook serveur + cablage executor | Livré (commit fix fc01afa59) | Oui |
|
||||
| QW2 LoopDetector module | Livré, tests verts | Non (impossible à reproduire fiable) |
|
||||
| QW2 hook api_stream | Livré, tests verts | Non |
|
||||
| QW4 SafetyChecksProvider | Livré, tests verts | Oui (avec workflow `medical_critical`) |
|
||||
| QW4 endpoint /replay/resume + proxy VWB | Livré, tests verts | Oui (POST avec acknowledged_check_ids) |
|
||||
| QW4 PauseDialog + PropertiesPanel | Livré, 0 nouvelle erreur TS | Oui (rendre la bulle dans VWB) |
|
||||
@@ -27,6 +27,7 @@ markers =
|
||||
fiche9: Tests Fiche #9 (postconditions retry backoff)
|
||||
fiche10: Tests Fiche #10 (precision metrics engine)
|
||||
visual: Tests visuels sur captures réelles (nécessite serveur GPU)
|
||||
e2e: Tests E2E contre serveurs (streaming + VWB) actifs — lents, à lancer manuellement
|
||||
|
||||
# Note: Chemins Python gérés par tests/conftest.py
|
||||
|
||||
|
||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
118
tests/e2e/test_urgence_aiva_demo.py
Normal file
118
tests/e2e/test_urgence_aiva_demo.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests E2E du workflow Urgence_aiva_demo via le harness mock client.
|
||||
|
||||
Marqueurs : @pytest.mark.e2e @pytest.mark.slow
|
||||
Pré-requis : streaming server (5005) + VWB (5002) actifs.
|
||||
|
||||
Lancement :
|
||||
pytest tests/e2e -v -m e2e
|
||||
|
||||
Le test est un smoke check : il vérifie qu'on arrive à lancer un replay,
|
||||
poller les actions et que le harness termine sans crash. Il n'exige PAS
|
||||
que tous les steps réussissent (le screenshot fixture peut être obsolète).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from tools.test_replay_e2e import (
|
||||
ReplayMockClient,
|
||||
_find_latest_heartbeat,
|
||||
_load_token,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_VWB_URL,
|
||||
)
|
||||
|
||||
WORKFLOW_ID = "wf_a38aeebea5e6_1778162737" # Urgence_aiva_demo
|
||||
|
||||
|
||||
def _server_alive(url: str, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
resp = requests.get(f"{url}/health", timeout=timeout)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _vwb_alive(url: str, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
# VWB n'a pas /health, on tape /api/v3/session/state
|
||||
resp = requests.get(f"{url}/api/v3/session/state", timeout=timeout)
|
||||
return resp.status_code in (200, 404)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def streaming_url() -> str:
|
||||
if not _server_alive(DEFAULT_BASE_URL):
|
||||
pytest.skip(f"Streaming server inactif sur {DEFAULT_BASE_URL}")
|
||||
return DEFAULT_BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vwb_url() -> str:
|
||||
if not _vwb_alive(DEFAULT_VWB_URL):
|
||||
pytest.skip(f"VWB backend inactif sur {DEFAULT_VWB_URL}")
|
||||
return DEFAULT_VWB_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def heartbeat() -> str:
|
||||
path = _find_latest_heartbeat()
|
||||
if not path or not Path(path).exists():
|
||||
pytest.skip("Aucun heartbeat fixture disponible sur disque")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.slow
|
||||
def test_urgence_aiva_demo_smoke(streaming_url, vwb_url, heartbeat):
|
||||
"""Smoke : lance et déroule le workflow Urgence_aiva_demo via le harness.
|
||||
|
||||
Vérifie que :
|
||||
- le harness peut compiler et lancer le replay (pas d'exception réseau)
|
||||
- au moins quelques steps sont reportés (la chaîne tourne)
|
||||
- aucune exception non gérée n'est levée
|
||||
"""
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
ts = _time.strftime("%Y%m%dT%H%M%S")
|
||||
client = ReplayMockClient(
|
||||
base_url=streaming_url,
|
||||
vwb_url=vwb_url,
|
||||
token=_load_token(),
|
||||
session_id=f"test_e2e_pytest_{ts}_{_uuid.uuid4().hex[:6]}",
|
||||
machine_id=f"test_e2e_pytest_machine_{ts}",
|
||||
screenshot_path=heartbeat,
|
||||
verbose=False,
|
||||
auto_resume=True,
|
||||
execution_mode="autonomous",
|
||||
timeout_poll=10.0,
|
||||
single_step=None,
|
||||
max_iter=80,
|
||||
)
|
||||
|
||||
try:
|
||||
client.cancel_stale_replays()
|
||||
client.register_session()
|
||||
info = client.start_replay(WORKFLOW_ID)
|
||||
assert info.get("replay_id"), f"replay_id absent : {info}"
|
||||
assert info.get("total_actions", 0) > 0
|
||||
client.run()
|
||||
finally:
|
||||
try:
|
||||
client.cancel_replay()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Le harness doit avoir produit au moins quelques rapports
|
||||
assert len(client.reports) > 0, "Aucune action reportée — harness cassé ?"
|
||||
|
||||
# Le 1er step est un wait synthétique injecté par VWB → doit être OK
|
||||
first = client.reports[0]
|
||||
assert first.action_type == "wait", f"1er step inattendu : {first}"
|
||||
assert first.status == "OK"
|
||||
81
tests/e2e/urgence_aiva_demo_expected.yaml
Normal file
81
tests/e2e/urgence_aiva_demo_expected.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
workflow_session_id: test_e2e_sess_20260507T220822_c91f30
|
||||
screenshot: /home/dom/ai/rpa_vision_v3/data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png
|
||||
steps:
|
||||
- order: 1
|
||||
action_id: wait_before_start
|
||||
action_type: wait
|
||||
by_text: ''
|
||||
method: simulated
|
||||
score: 0.0
|
||||
x_pct: null
|
||||
y_pct: null
|
||||
status: OK
|
||||
diag: wait simulé
|
||||
elapsed_ms: 1.013040542602539
|
||||
- order: 2
|
||||
action_id: replay_free_74c2d90b
|
||||
action_type: pause:user_request
|
||||
by_text: ''
|
||||
method: ''
|
||||
score: 0.0
|
||||
x_pct: null
|
||||
y_pct: null
|
||||
status: PAUSED
|
||||
diag: 'Léa : j''ai trouvé ces dossiers : []. Pour la démo je vais traiter MOREL
|
||||
Catherin'
|
||||
elapsed_ms: 0.0
|
||||
- order: 3
|
||||
action_id: step_288d0bceea90_1778162737752
|
||||
action_type: click
|
||||
by_text: '25003284'
|
||||
method: fallback
|
||||
score: 0.0
|
||||
x_pct: 0.5
|
||||
y_pct: 0.5
|
||||
status: FAIL
|
||||
diag: template_matching_failed
|
||||
elapsed_ms: 1064.7194385528564
|
||||
- order: 4
|
||||
action_id: step_288d0bceea90_1778162737752_retry1
|
||||
action_type: click
|
||||
by_text: '25003284'
|
||||
method: fallback
|
||||
score: 0.0
|
||||
x_pct: 0.5
|
||||
y_pct: 0.5
|
||||
status: FAIL
|
||||
diag: template_matching_failed
|
||||
elapsed_ms: 1075.0248432159424
|
||||
- order: 5
|
||||
action_id: wait_retry_381c1b
|
||||
action_type: wait
|
||||
by_text: ''
|
||||
method: simulated
|
||||
score: 0.0
|
||||
x_pct: null
|
||||
y_pct: null
|
||||
status: OK
|
||||
diag: wait simulé
|
||||
elapsed_ms: 12.79759407043457
|
||||
- order: 6
|
||||
action_id: step_288d0bceea90_1778162737752_retry2
|
||||
action_type: click
|
||||
by_text: '25003284'
|
||||
method: fallback
|
||||
score: 0.0
|
||||
x_pct: 0.5
|
||||
y_pct: 0.5
|
||||
status: FAIL
|
||||
diag: template_matching_failed
|
||||
elapsed_ms: 1037.236213684082
|
||||
- order: 7
|
||||
action_id: step_288d0bceea90_1778162737752_retry3
|
||||
action_type: click
|
||||
by_text: '25003284'
|
||||
method: fallback
|
||||
score: 0.0
|
||||
x_pct: 0.5
|
||||
y_pct: 0.5
|
||||
status: FAIL
|
||||
diag: template_matching_failed
|
||||
elapsed_ms: 1051.6366958618164
|
||||
41
tests/integration/test_grounding_offset.py
Normal file
41
tests/integration/test_grounding_offset.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# tests/integration/test_grounding_offset.py
|
||||
"""Tests intégration pour la propagation d'offset multi-écrans (QW1)."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from core.execution import input_handler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_screen():
|
||||
"""Mock une capture mss : retourne un PIL Image factice + offsets."""
|
||||
from PIL import Image
|
||||
img = Image.new("RGB", (1920, 1080), color="white")
|
||||
return img
|
||||
|
||||
|
||||
def test_capture_screen_default_returns_composite_when_no_idx(mock_screen):
|
||||
"""_capture_screen() sans monitor_idx → composite, offset (0, 0)."""
|
||||
with patch("core.execution.input_handler.mss") as mock_mss:
|
||||
ctx = mock_mss.mss.return_value.__enter__.return_value
|
||||
ctx.monitors = [{"left": 0, "top": 0, "width": 3840, "height": 1080}]
|
||||
ctx.grab.return_value = MagicMock(size=(3840, 1080), bgra=b"\x00" * (3840 * 1080 * 4))
|
||||
with patch("core.execution.input_handler.PILImage.frombytes", return_value=mock_screen):
|
||||
screen, w, h, ox, oy = input_handler._capture_screen()
|
||||
assert (w, h, ox, oy) == (3840, 1080, 0, 0)
|
||||
|
||||
|
||||
def test_capture_screen_targets_specific_monitor_with_offset(mock_screen):
|
||||
"""_capture_screen(monitor_idx=1) → cible monitors[2] (mss skip [0]), offset = monitor.left."""
|
||||
with patch("core.execution.input_handler.mss") as mock_mss:
|
||||
ctx = mock_mss.mss.return_value.__enter__.return_value
|
||||
# mss layout : [0]=composite, [1]=primary, [2]=secondary
|
||||
ctx.monitors = [
|
||||
{"left": 0, "top": 0, "width": 3840, "height": 1080},
|
||||
{"left": 0, "top": 0, "width": 1920, "height": 1080},
|
||||
{"left": 1920, "top": 0, "width": 1920, "height": 1080},
|
||||
]
|
||||
ctx.grab.return_value = MagicMock(size=(1920, 1080), bgra=b"\x00" * (1920 * 1080 * 4))
|
||||
with patch("core.execution.input_handler.PILImage.frombytes", return_value=mock_screen):
|
||||
screen, w, h, ox, oy = input_handler._capture_screen(monitor_idx=1)
|
||||
assert (w, h, ox, oy) == (1920, 1080, 1920, 0)
|
||||
61
tests/integration/test_loop_detector_replay.py
Normal file
61
tests/integration/test_loop_detector_replay.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# tests/integration/test_loop_detector_replay.py
|
||||
"""Tests intégration : un replay simulé qui boucle bascule en paused_need_help."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_v0.server_v1.loop_detector import LoopDetector
|
||||
|
||||
|
||||
def test_replay_state_transitions_to_paused_on_screen_static():
|
||||
"""Cas : 4 screenshots identiques → replay passe à paused_need_help."""
|
||||
embedder = MagicMock()
|
||||
embedder.embed_image.return_value = [1.0, 0.0, 0.0] # constant
|
||||
detector = LoopDetector(clip_embedder=embedder)
|
||||
|
||||
state = {
|
||||
"replay_id": "r_test",
|
||||
"status": "running",
|
||||
"retried_actions": 0,
|
||||
"_screenshot_history": ["img1", "img2", "img3", "img4"], # 4 images factices
|
||||
"_action_history": [
|
||||
{"type": "click", "x_pct": 0.1, "y_pct": 0.1},
|
||||
{"type": "type", "x_pct": 0.2, "y_pct": 0.2},
|
||||
],
|
||||
}
|
||||
verdict = detector.evaluate(state, state["_screenshot_history"], state["_action_history"])
|
||||
|
||||
# Simuler ce que ferait api_stream après verdict
|
||||
if verdict.detected:
|
||||
state["status"] = "paused_need_help"
|
||||
state["pause_reason"] = verdict.reason
|
||||
state["pause_message"] = f"signal={verdict.signal}"
|
||||
|
||||
assert state["status"] == "paused_need_help"
|
||||
assert state["pause_reason"] == "loop_detected"
|
||||
assert "screen_static" in state["pause_message"]
|
||||
|
||||
|
||||
def test_replay_state_transitions_on_action_repeat():
|
||||
"""Cas : 3 actions identiques → paused_need_help signal action_repeat."""
|
||||
detector = LoopDetector(clip_embedder=None)
|
||||
actions = [{"type": "click", "x_pct": 0.5, "y_pct": 0.5}] * 3
|
||||
state = {"replay_id": "r2", "status": "running", "retried_actions": 0,
|
||||
"_screenshot_history": [], "_action_history": actions}
|
||||
|
||||
verdict = detector.evaluate(state, [], actions)
|
||||
assert verdict.detected and verdict.signal == "action_repeat"
|
||||
|
||||
|
||||
def test_kill_switch_keeps_replay_running(monkeypatch):
|
||||
"""Avec RPA_LOOP_DETECTOR_ENABLED=0 le replay continue même en boucle."""
|
||||
monkeypatch.setenv("RPA_LOOP_DETECTOR_ENABLED", "0")
|
||||
embedder = MagicMock()
|
||||
embedder.embed_image.return_value = [1.0, 0.0, 0.0]
|
||||
detector = LoopDetector(clip_embedder=embedder)
|
||||
|
||||
state = {"retried_actions": 10,
|
||||
"_screenshot_history": ["img1"] * 10,
|
||||
"_action_history": [{"type": "click", "x_pct": 0.5, "y_pct": 0.5}] * 10}
|
||||
|
||||
verdict = detector.evaluate(state, state["_screenshot_history"], state["_action_history"])
|
||||
assert verdict.detected is False
|
||||
52
tests/integration/test_replay_resume_acknowledgments.py
Normal file
52
tests/integration/test_replay_resume_acknowledgments.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# tests/integration/test_replay_resume_acknowledgments.py
|
||||
"""Tests intégration : /replay/resume valide les acquittements de safety_checks (QW4)."""
|
||||
import pytest
|
||||
|
||||
|
||||
def test_resume_accepts_when_all_required_acknowledged():
|
||||
"""État pause + tous required acquittés → reprise OK."""
|
||||
state = {
|
||||
"status": "paused_need_help",
|
||||
"safety_checks": [
|
||||
{"id": "c1", "label": "X", "required": True, "source": "declarative", "evidence": None},
|
||||
{"id": "c2", "label": "Y", "required": True, "source": "declarative", "evidence": None},
|
||||
],
|
||||
"checks_acknowledged": [],
|
||||
}
|
||||
# Simuler la validation côté serveur
|
||||
acknowledged = ["c1", "c2"]
|
||||
required_ids = {c["id"] for c in state["safety_checks"] if c["required"]}
|
||||
missing = required_ids - set(acknowledged)
|
||||
assert missing == set() # rien ne manque → reprise OK
|
||||
|
||||
|
||||
def test_resume_rejects_when_required_missing():
|
||||
"""État pause + un required non acquitté → 400 required_checks_missing."""
|
||||
state = {
|
||||
"status": "paused_need_help",
|
||||
"safety_checks": [
|
||||
{"id": "c1", "label": "X", "required": True, "source": "declarative", "evidence": None},
|
||||
{"id": "c2", "label": "Y", "required": False, "source": "llm_contextual", "evidence": "..."},
|
||||
],
|
||||
"checks_acknowledged": [],
|
||||
}
|
||||
acknowledged = ["c2"] # only optional
|
||||
required_ids = {c["id"] for c in state["safety_checks"] if c["required"]}
|
||||
missing = required_ids - set(acknowledged)
|
||||
assert missing == {"c1"} # c1 manquant → resume doit retourner 400
|
||||
|
||||
|
||||
def test_resume_audit_trail_stored():
|
||||
"""checks_acknowledged contient les ids reçus (audit)."""
|
||||
state = {
|
||||
"status": "paused_need_help",
|
||||
"safety_checks": [
|
||||
{"id": "c1", "required": True, "label": "X", "source": "declarative", "evidence": None},
|
||||
],
|
||||
"checks_acknowledged": [],
|
||||
}
|
||||
acknowledged = ["c1"]
|
||||
state["checks_acknowledged"] = acknowledged
|
||||
state["status"] = "running"
|
||||
assert state["checks_acknowledged"] == ["c1"]
|
||||
assert state["status"] == "running"
|
||||
96
tests/unit/test_loop_detector.py
Normal file
96
tests/unit/test_loop_detector.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# tests/unit/test_loop_detector.py
|
||||
"""Tests unitaires pour LoopDetector composite (QW2)."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent_v0.server_v1.loop_detector import LoopDetector, LoopVerdict
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detector():
|
||||
"""LoopDetector avec embedder mocké (signal A toujours dispo)."""
|
||||
embedder = MagicMock()
|
||||
# Par défaut : 4 embeddings tous identiques → similarity 1.0
|
||||
embedder.embed_image.return_value = [1.0, 0.0, 0.0]
|
||||
return LoopDetector(clip_embedder=embedder)
|
||||
|
||||
|
||||
def _state(retried=0, n_screenshots=0, n_actions=0):
|
||||
return {
|
||||
"retried_actions": retried,
|
||||
"_screenshot_history": [[1.0, 0.0, 0.0]] * n_screenshots,
|
||||
"_action_history": [{"type": "click", "x_pct": 0.5, "y_pct": 0.5}] * n_actions,
|
||||
}
|
||||
|
||||
|
||||
def test_screen_static_triggers_when_n_identical_embeddings(detector):
|
||||
"""Signal A : 4 captures identiques (similarity > 0.99) → detected."""
|
||||
state = _state(n_screenshots=4)
|
||||
verdict = detector.evaluate(state, screenshots=state["_screenshot_history"], actions=[])
|
||||
assert verdict.detected is True
|
||||
assert verdict.signal == "screen_static"
|
||||
|
||||
|
||||
def test_screen_static_skipped_when_history_too_short(detector):
|
||||
"""Signal A : moins de N captures → pas de détection."""
|
||||
state = _state(n_screenshots=2)
|
||||
verdict = detector.evaluate(state, screenshots=state["_screenshot_history"], actions=[])
|
||||
# Si seul A pourrait déclencher mais skip, et B/C pas remplis : detected=False
|
||||
assert verdict.detected is False
|
||||
|
||||
|
||||
def test_action_repeat_triggers_when_n_identical_actions(detector):
|
||||
"""Signal B : 3 actions consécutives identiques → detected."""
|
||||
state = _state(n_actions=3)
|
||||
verdict = detector.evaluate(state, screenshots=[], actions=state["_action_history"])
|
||||
assert verdict.detected is True
|
||||
assert verdict.signal == "action_repeat"
|
||||
|
||||
|
||||
def test_action_repeat_skipped_when_actions_differ(detector):
|
||||
"""Signal B : actions différentes → pas de détection."""
|
||||
actions = [
|
||||
{"type": "click", "x_pct": 0.1, "y_pct": 0.1},
|
||||
{"type": "click", "x_pct": 0.2, "y_pct": 0.2},
|
||||
{"type": "click", "x_pct": 0.3, "y_pct": 0.3},
|
||||
]
|
||||
verdict = detector.evaluate(_state(), screenshots=[], actions=actions)
|
||||
assert verdict.detected is False
|
||||
|
||||
|
||||
def test_retry_threshold_triggers_at_3(detector):
|
||||
"""Signal C : retried_actions >= 3 → detected."""
|
||||
state = _state(retried=3)
|
||||
verdict = detector.evaluate(state, screenshots=[], actions=[])
|
||||
assert verdict.detected is True
|
||||
assert verdict.signal == "retry_threshold"
|
||||
|
||||
|
||||
def test_kill_switch_disables_all_signals(monkeypatch, detector):
|
||||
"""Si RPA_LOOP_DETECTOR_ENABLED=0 → toujours detected=False."""
|
||||
monkeypatch.setenv("RPA_LOOP_DETECTOR_ENABLED", "0")
|
||||
state = _state(retried=10, n_screenshots=10, n_actions=10)
|
||||
verdict = detector.evaluate(state, screenshots=state["_screenshot_history"],
|
||||
actions=state["_action_history"])
|
||||
assert verdict.detected is False
|
||||
|
||||
|
||||
def test_embedder_unavailable_skips_signal_A_continues_others():
|
||||
"""Si CLIP embedder None → signal A skip, B et C continuent."""
|
||||
detector = LoopDetector(clip_embedder=None)
|
||||
# Trigger signal C
|
||||
state = _state(retried=3)
|
||||
verdict = detector.evaluate(state, screenshots=[], actions=[])
|
||||
assert verdict.detected is True
|
||||
assert verdict.signal == "retry_threshold"
|
||||
|
||||
|
||||
def test_embedder_exception_does_not_crash(detector):
|
||||
"""Si embed_image lève une exception → log + verdict detected=False."""
|
||||
detector.clip_embedder.embed_image.side_effect = RuntimeError("CUDA OOM")
|
||||
state = _state(n_screenshots=4)
|
||||
# Ne doit PAS lever : signal A devient inerte
|
||||
verdict = detector.evaluate(state, screenshots=state["_screenshot_history"], actions=[])
|
||||
# Signal A inerte, B/C pas remplis → detected False
|
||||
assert verdict.detected is False
|
||||
51
tests/unit/test_monitor_router.py
Normal file
51
tests/unit/test_monitor_router.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# tests/unit/test_monitor_router.py
|
||||
"""Tests unitaires pour MonitorRouter (QW1)."""
|
||||
import pytest
|
||||
|
||||
from agent_v0.server_v1.monitor_router import resolve_target_monitor, MonitorTarget
|
||||
|
||||
|
||||
# Geometry de référence pour les 3 tests : 2 écrans côte à côte
|
||||
TWO_MONITORS = [
|
||||
{"idx": 0, "x": 0, "y": 0, "w": 1920, "h": 1080, "primary": True},
|
||||
{"idx": 1, "x": 1920, "y": 0, "w": 1920, "h": 1080, "primary": False},
|
||||
]
|
||||
|
||||
|
||||
def test_resolve_uses_action_monitor_index_when_present():
|
||||
"""Si action.monitor_index présent et valide → cible cet écran."""
|
||||
action = {"monitor_index": 1}
|
||||
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 0}
|
||||
result = resolve_target_monitor(action, session_state)
|
||||
assert result.idx == 1
|
||||
assert result.offset_x == 1920
|
||||
assert result.offset_y == 0
|
||||
assert result.source == "action"
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_focused_monitor_when_action_missing():
|
||||
"""Si action.monitor_index absent → fallback focus actif."""
|
||||
action = {} # pas de monitor_index
|
||||
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 1}
|
||||
result = resolve_target_monitor(action, session_state)
|
||||
assert result.idx == 1
|
||||
assert result.source == "focus"
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_composite_when_geometry_empty():
|
||||
"""Si geometry vide (vieux Agent V1) → fallback composite (idx=-1, offset=0)."""
|
||||
action = {}
|
||||
session_state = {"monitors_geometry": [], "last_focused_monitor": None}
|
||||
result = resolve_target_monitor(action, session_state)
|
||||
assert result.source == "composite_fallback"
|
||||
assert result.offset_x == 0
|
||||
assert result.offset_y == 0
|
||||
|
||||
|
||||
def test_resolve_falls_back_when_action_index_out_of_range():
|
||||
"""Si action.monitor_index hors limites (écran débranché) → fallback focus."""
|
||||
action = {"monitor_index": 5} # n'existe pas
|
||||
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 0}
|
||||
result = resolve_target_monitor(action, session_state)
|
||||
assert result.idx == 0
|
||||
assert result.source == "focus"
|
||||
111
tests/unit/test_safety_checks_provider.py
Normal file
111
tests/unit/test_safety_checks_provider.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# tests/unit/test_safety_checks_provider.py
|
||||
"""Tests unitaires SafetyChecksProvider (QW4)."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent_v0.server_v1.safety_checks_provider import build_pause_payload, PausePayload
|
||||
|
||||
|
||||
def _action(safety_level=None, declarative_checks=None, message="Validation"):
|
||||
params = {"message": message}
|
||||
if safety_level:
|
||||
params["safety_level"] = safety_level
|
||||
if declarative_checks is not None:
|
||||
params["safety_checks"] = declarative_checks
|
||||
return {"type": "pause_for_human", "parameters": params}
|
||||
|
||||
|
||||
def test_only_declarative_when_no_safety_level():
|
||||
"""Pas de safety_level → uniquement les checks déclaratifs, pas d'appel LLM."""
|
||||
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks") as mock_llm:
|
||||
payload = build_pause_payload(_action(declarative_checks=decl), {}, last_screenshot=None)
|
||||
mock_llm.assert_not_called()
|
||||
assert len(payload.checks) == 1
|
||||
assert payload.checks[0]["source"] == "declarative"
|
||||
|
||||
|
||||
def test_hybrid_appends_llm_checks_on_medical_critical(monkeypatch):
|
||||
"""safety_level=medical_critical → LLM appelé, checks concaténés."""
|
||||
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
|
||||
llm_resp = [{"label": "Nom patient suspect à l'écran", "evidence": "vu un nom différent"}]
|
||||
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
|
||||
return_value=llm_resp) as mock_llm:
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=decl),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
mock_llm.assert_called_once()
|
||||
assert len(payload.checks) == 2
|
||||
assert payload.checks[0]["source"] == "declarative"
|
||||
assert payload.checks[1]["source"] == "llm_contextual"
|
||||
assert payload.checks[1]["evidence"] == "vu un nom différent"
|
||||
|
||||
|
||||
def test_llm_timeout_falls_back_to_declarative_only():
|
||||
"""LLM timeout → additional_checks=[], pas de crash, déclaratifs gardés."""
|
||||
decl = [{"id": "c1", "label": "Vérifier IPP", "required": True}]
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
|
||||
return_value=[]) as mock_llm:
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=decl),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
assert len(payload.checks) == 1
|
||||
assert payload.checks[0]["source"] == "declarative"
|
||||
|
||||
|
||||
def test_llm_invalid_response_falls_back():
|
||||
"""Si _call_llm retourne [] (parse échoué en interne) → fallback safe."""
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
|
||||
return_value=[]):
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=[]),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
assert payload.checks == []
|
||||
|
||||
|
||||
def test_kill_switch_disables_llm_call(monkeypatch):
|
||||
"""RPA_SAFETY_CHECKS_LLM_ENABLED=0 → LLM jamais appelé."""
|
||||
monkeypatch.setenv("RPA_SAFETY_CHECKS_LLM_ENABLED", "0")
|
||||
decl = [{"id": "c1", "label": "X", "required": True}]
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks") as mock_llm:
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=decl),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
mock_llm.assert_not_called()
|
||||
assert len(payload.checks) == 1
|
||||
|
||||
|
||||
def test_max_checks_respected(monkeypatch):
|
||||
"""RPA_SAFETY_CHECKS_LLM_MAX_CHECKS=2 → max 2 checks LLM ajoutés."""
|
||||
monkeypatch.setenv("RPA_SAFETY_CHECKS_LLM_MAX_CHECKS", "2")
|
||||
decl = []
|
||||
llm_resp = [
|
||||
{"label": f"Check {i}", "evidence": f"e{i}"} for i in range(5)
|
||||
]
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
|
||||
return_value=llm_resp[:2]): # provider tronque déjà
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=decl),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
assert len(payload.checks) == 2
|
||||
|
||||
|
||||
def test_empty_declarative_with_llm_returns_only_llm():
|
||||
"""Pas de déclaratif + LLM ajoute 2 checks → payload contient les 2."""
|
||||
llm_resp = [{"label": "Vérifier date", "evidence": "date 1900 suspecte"},
|
||||
{"label": "Vérifier devise", "evidence": "montant en USD au lieu d'EUR"}]
|
||||
with patch("agent_v0.server_v1.safety_checks_provider._call_llm_for_contextual_checks",
|
||||
return_value=llm_resp):
|
||||
payload = build_pause_payload(
|
||||
_action(safety_level="medical_critical", declarative_checks=[]),
|
||||
{}, last_screenshot="/tmp/fake.png",
|
||||
)
|
||||
assert len(payload.checks) == 2
|
||||
assert all(c["source"] == "llm_contextual" for c in payload.checks)
|
||||
437
tools/bench_safety_checks_models.py
Executable file
437
tools/bench_safety_checks_models.py
Executable file
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bench rigoureux des modèles candidats pour QW4 safety_checks contextuels.
|
||||
|
||||
Méthodologie :
|
||||
- 5 screenshots synthétiques avec différentes anomalies cliniques
|
||||
- 4 modèles candidats (gemma4:e4b sur :11435, qwen2.5vl:7b/3b et medgemma:4b sur :11434)
|
||||
- Pour chaque modèle :
|
||||
1. Décharger TOUS les modèles déjà en VRAM (keep_alive=0)
|
||||
2. 1er appel = cold start chronométré (1er screenshot)
|
||||
3. 12 appels warm = (4 autres screenshots × 3 runs)
|
||||
4. Mesurer : cold_start, warm avg/p95, taux détection, JSON valide
|
||||
|
||||
Usage : .venv/bin/python tools/bench_safety_checks_models.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
OLLAMA_PRIMARY = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_SECONDARY = os.environ.get("GEMMA4_URL", "http://localhost:11435")
|
||||
|
||||
# Configuration des candidats : (nom, url, type)
|
||||
CANDIDATES = [
|
||||
("gemma4:latest", OLLAMA_PRIMARY, "vlm_default"),
|
||||
("qwen3-vl:8b", OLLAMA_PRIMARY, "vision_qwen3_8b"),
|
||||
("qwen2.5vl:7b", OLLAMA_PRIMARY, "vision_qwen25_7b"),
|
||||
("qwen2.5vl:3b", OLLAMA_PRIMARY, "vision_qwen25_3b"),
|
||||
("medgemma:4b", OLLAMA_PRIMARY, "medical_4b"),
|
||||
]
|
||||
|
||||
TIMEOUT_S = int(os.environ.get("BENCH_TIMEOUT", "60")) # large pour ne rien rater
|
||||
MAX_CHECKS = 3
|
||||
WORKFLOW_MESSAGE = "Validation T2A avant codage UHCD"
|
||||
EXISTING_LABELS: list[str] = []
|
||||
WARM_RUNS_PER_SCREENSHOT = 3 # warm = 4 autres screenshots × 3 runs = 12 mesures
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scénarios : 5 screenshots avec anomalies différentes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
label: str # nom court
|
||||
rows: list[tuple[str, str]]
|
||||
anomaly_keywords: list[str] # mots indiquant que l'anomalie est repérée
|
||||
|
||||
|
||||
SCENARIOS = [
|
||||
Scenario(
|
||||
label="ddn_aberrante",
|
||||
rows=[
|
||||
("Nom :", "DUPONT Marie"),
|
||||
("IPP :", "25003284"),
|
||||
("Date de naissance :", "1900-01-01"), # ANOMALIE
|
||||
("Sexe :", "F"),
|
||||
("Date d'admission :", "2026-05-05 14:32"),
|
||||
("Service :", "URGENCES"),
|
||||
("Motif :", "Douleur abdominale aiguë"),
|
||||
("Diagnostic principal :", "K35.8 - Appendicite aiguë"),
|
||||
("Forfait facturation :", "UHCD - Forfait 24h"),
|
||||
],
|
||||
anomaly_keywords=["1900", "naissance", "ddn", "date"],
|
||||
),
|
||||
Scenario(
|
||||
label="ipp_incoherent",
|
||||
rows=[
|
||||
("Nom :", "MARTIN Paul"),
|
||||
("IPP :", "ABC@@##XYZ"), # ANOMALIE : non numérique
|
||||
("Date de naissance :", "1965-04-12"),
|
||||
("Sexe :", "M"),
|
||||
("Date d'admission :", "2026-05-06 09:15"),
|
||||
("Service :", "URGENCES"),
|
||||
("Motif :", "Chute mécanique"),
|
||||
("Diagnostic principal :", "S52.5 - Fracture du radius distal"),
|
||||
("Forfait facturation :", "UHCD - Forfait 24h"),
|
||||
],
|
||||
anomaly_keywords=["ipp", "abc", "format", "incohérent", "incoherent", "invalide"],
|
||||
),
|
||||
Scenario(
|
||||
label="diagnostic_vide",
|
||||
rows=[
|
||||
("Nom :", "BERNARD Sophie"),
|
||||
("IPP :", "25004191"),
|
||||
("Date de naissance :", "1972-11-08"),
|
||||
("Sexe :", "F"),
|
||||
("Date d'admission :", "2026-05-06 10:42"),
|
||||
("Service :", "URGENCES"),
|
||||
("Motif :", "Céphalées"),
|
||||
("Diagnostic principal :", ""), # ANOMALIE : vide
|
||||
("Forfait facturation :", "UHCD - Forfait 24h"),
|
||||
],
|
||||
anomaly_keywords=["diagnostic", "vide", "blanc", "absent", "manque", "non renseigné", "non renseigne"],
|
||||
),
|
||||
Scenario(
|
||||
label="cim_inadapte_age",
|
||||
rows=[
|
||||
("Nom :", "PETIT Lucas"),
|
||||
("IPP :", "25004222"),
|
||||
("Date de naissance :", "2025-11-01"), # nourrisson 6 mois
|
||||
("Sexe :", "M"),
|
||||
("Date d'admission :", "2026-05-06 11:00"),
|
||||
("Service :", "URGENCES PEDIATRIQUES"),
|
||||
("Motif :", "Pleurs persistants"),
|
||||
("Diagnostic principal :", "M19.9 - Arthrose, sans précision"), # ANOMALIE
|
||||
("Forfait facturation :", "UHCD - Forfait 24h"),
|
||||
],
|
||||
anomaly_keywords=["arthrose", "âge", "age", "nourrisson", "incohérent", "incoherent", "m19", "incompatible"],
|
||||
),
|
||||
Scenario(
|
||||
label="forfait_incoherent_duree",
|
||||
rows=[
|
||||
("Nom :", "ROUSSEAU Jean"),
|
||||
("IPP :", "25004317"),
|
||||
("Date de naissance :", "1958-03-22"),
|
||||
("Sexe :", "M"),
|
||||
("Date d'admission :", "2026-05-06 08:00"),
|
||||
("Date de sortie :", "2026-05-06 09:00"), # 1h
|
||||
("Service :", "URGENCES"),
|
||||
("Motif :", "Bilan biologique"),
|
||||
("Diagnostic principal :", "Z00.0 - Examen médical général"),
|
||||
("Forfait facturation :", "UHCD - Forfait 24h"), # ANOMALIE : 1h ≠ UHCD 24h
|
||||
],
|
||||
anomaly_keywords=["forfait", "uhcd", "durée", "duree", "1h", "incohérent", "incoherent", "24h"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Génération des screenshots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_screenshot(scenario: Scenario, path: str) -> None:
|
||||
"""Crée un PNG du dossier patient pour un scénario donné."""
|
||||
img = Image.new("RGB", (1024, 600), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
|
||||
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||
except OSError:
|
||||
font_title = ImageFont.load_default()
|
||||
font_body = ImageFont.load_default()
|
||||
|
||||
draw.text((20, 20), "DOSSIER PATIENT - URGENCES UHCD", fill="black", font=font_title)
|
||||
draw.line([(20, 55), (1004, 55)], fill="black", width=2)
|
||||
y = 80
|
||||
for label, value in scenario.rows:
|
||||
draw.text((30, y), label, fill="black", font=font_body)
|
||||
draw.text((280, y), value, fill="#1f2937", font=font_body)
|
||||
y += 35
|
||||
img.save(path, format="PNG")
|
||||
|
||||
|
||||
def encode_image(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("ascii")
|
||||
|
||||
|
||||
def build_prompt() -> str:
|
||||
existing = ", ".join(EXISTING_LABELS) if EXISTING_LABELS else "aucun"
|
||||
return f"""Tu es Léa, assistante médicale supervisée.
|
||||
Avant de continuer le workflow, tu dois lister 0 à {MAX_CHECKS} vérifications supplémentaires
|
||||
que l'humain doit acquitter, en regardant l'écran actuel.
|
||||
|
||||
Contexte workflow : {WORKFLOW_MESSAGE}
|
||||
Checks déjà demandés : {existing}
|
||||
|
||||
NE répète PAS un check déjà demandé.
|
||||
Si rien d'inhabituel à signaler, retourne {{"additional_checks": []}}.
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
{{
|
||||
"additional_checks": [
|
||||
{{"label": "string court", "evidence": "ce que tu as vu d'inhabituel"}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gestion VRAM Ollama (déchargement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_loaded_models(url: str) -> list[str]:
|
||||
"""Retourne la liste des modèles actuellement en VRAM sur cet Ollama."""
|
||||
try:
|
||||
resp = requests.get(f"{url}/api/ps", timeout=5)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return [m["name"] for m in data.get("models", [])]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def unload_all_models() -> None:
|
||||
"""Décharge tous les modèles en VRAM sur les 2 Ollama (keep_alive=0)."""
|
||||
for url in (OLLAMA_PRIMARY, OLLAMA_SECONDARY):
|
||||
loaded = list_loaded_models(url)
|
||||
for model_name in loaded:
|
||||
try:
|
||||
requests.post(
|
||||
f"{url}/api/generate",
|
||||
json={"model": model_name, "prompt": "", "keep_alive": 0, "stream": False},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Petit temps pour laisser le GC GPU faire son travail
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Appel modèle + parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CallResult:
|
||||
elapsed_s: float
|
||||
error: str = ""
|
||||
raw: str = ""
|
||||
checks: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def call_model(model: str, url: str, prompt: str, image_b64: str) -> CallResult:
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1, "num_predict": 250},
|
||||
"images": [image_b64],
|
||||
}
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
resp = requests.post(f"{url}/api/generate", json=payload, timeout=TIMEOUT_S)
|
||||
elapsed = time.perf_counter() - t0
|
||||
except requests.Timeout:
|
||||
return CallResult(elapsed_s=TIMEOUT_S, error="TIMEOUT")
|
||||
except Exception as e:
|
||||
return CallResult(elapsed_s=time.perf_counter() - t0, error=f"NETWORK:{type(e).__name__}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return CallResult(elapsed_s=elapsed, error=f"HTTP_{resp.status_code}", raw=resp.text[:200])
|
||||
|
||||
raw = resp.json().get("response", "").strip()
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
checks = parsed.get("additional_checks") or []
|
||||
if not isinstance(checks, list):
|
||||
checks = []
|
||||
return CallResult(elapsed_s=elapsed, raw=raw[:300], checks=checks)
|
||||
except json.JSONDecodeError as e:
|
||||
return CallResult(elapsed_s=elapsed, error=f"JSON:{type(e).__name__}", raw=raw[:200])
|
||||
|
||||
|
||||
def detects_anomaly(scenario: Scenario, checks: list[dict]) -> bool:
|
||||
blob = " ".join(
|
||||
f"{c.get('label', '')} {c.get('evidence', '')}".lower()
|
||||
for c in checks
|
||||
)
|
||||
return any(pat.lower() in blob for pat in scenario.anomaly_keywords)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bench main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ModelStats:
|
||||
model: str
|
||||
cold_s: float = 0.0
|
||||
warm_times: list[float] = field(default_factory=list)
|
||||
detection_count: int = 0
|
||||
detection_total: int = 0
|
||||
json_valid_count: int = 0
|
||||
json_valid_total: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
sample_checks: list[tuple[str, list[dict]]] = field(default_factory=list) # (scenario_label, checks)
|
||||
|
||||
|
||||
def run_bench_for_model(model: str, url: str, screenshots: list[tuple[Scenario, str]]) -> ModelStats:
|
||||
print(f"\n══════════════════════════════════════════════════════════")
|
||||
print(f" MODEL: {model} ({url})")
|
||||
print(f"══════════════════════════════════════════════════════════")
|
||||
|
||||
# Décharger tout
|
||||
print(f" [1/3] Déchargement VRAM...", end=" ", flush=True)
|
||||
unload_all_models()
|
||||
loaded_after = list_loaded_models(OLLAMA_PRIMARY) + list_loaded_models(OLLAMA_SECONDARY)
|
||||
print(f"OK (loaded={loaded_after if loaded_after else 'aucun'})")
|
||||
|
||||
stats = ModelStats(model=model)
|
||||
prompt = build_prompt()
|
||||
|
||||
# Cold start sur le 1er screenshot
|
||||
scen0, path0 = screenshots[0]
|
||||
img_b64 = encode_image(path0)
|
||||
print(f" [2/3] Cold start ({scen0.label})...", end=" ", flush=True)
|
||||
r0 = call_model(model, url, prompt, img_b64)
|
||||
stats.cold_s = r0.elapsed_s
|
||||
if r0.error:
|
||||
print(f"❌ {r0.error} ({r0.elapsed_s:.1f}s)")
|
||||
stats.errors.append(f"cold:{scen0.label}:{r0.error}")
|
||||
else:
|
||||
det = detects_anomaly(scen0, r0.checks)
|
||||
stats.detection_count += int(det)
|
||||
stats.detection_total += 1
|
||||
stats.json_valid_count += 1
|
||||
stats.json_valid_total += 1
|
||||
stats.sample_checks.append((scen0.label, r0.checks))
|
||||
print(f"{'✅' if det else '⚠️'} {len(r0.checks)} check(s) en {r0.elapsed_s:.1f}s (det={det})")
|
||||
|
||||
# Warm runs sur les 4 autres screenshots × N runs
|
||||
print(f" [3/3] Warm runs ({len(screenshots)-1} scenarios × {WARM_RUNS_PER_SCREENSHOT} runs)...")
|
||||
for scen, path in screenshots[1:]:
|
||||
img_b64 = encode_image(path)
|
||||
for run_idx in range(WARM_RUNS_PER_SCREENSHOT):
|
||||
r = call_model(model, url, prompt, img_b64)
|
||||
if r.error:
|
||||
stats.errors.append(f"{scen.label}:run{run_idx}:{r.error}")
|
||||
stats.json_valid_total += 1
|
||||
stats.detection_total += 1
|
||||
print(f" {scen.label} run{run_idx}: ❌ {r.error}")
|
||||
continue
|
||||
stats.warm_times.append(r.elapsed_s)
|
||||
stats.json_valid_count += 1
|
||||
stats.json_valid_total += 1
|
||||
det = detects_anomaly(scen, r.checks)
|
||||
stats.detection_count += int(det)
|
||||
stats.detection_total += 1
|
||||
if run_idx == 0:
|
||||
stats.sample_checks.append((scen.label, r.checks))
|
||||
print(f" {scen.label} run{run_idx}: {'✅' if det else '⚠️'} {len(r.checks)} check(s) en {r.elapsed_s:.1f}s")
|
||||
return stats
|
||||
|
||||
|
||||
def print_summary_table(all_stats: list[ModelStats]) -> None:
|
||||
print("\n\n══════════════════════════════════════════════════════════")
|
||||
print(" SYNTHÈSE")
|
||||
print("══════════════════════════════════════════════════════════\n")
|
||||
print("| Modèle | Cold (s) | Warm avg (s) | Warm p95 (s) | JSON | Détection | Notes |")
|
||||
print("|---|---:|---:|---:|---:|---:|---|")
|
||||
for s in all_stats:
|
||||
if s.warm_times:
|
||||
warm_avg = statistics.mean(s.warm_times)
|
||||
warm_p95 = sorted(s.warm_times)[int(len(s.warm_times) * 0.95) - 1] if len(s.warm_times) > 1 else s.warm_times[0]
|
||||
else:
|
||||
warm_avg = warm_p95 = 0.0
|
||||
json_pct = (s.json_valid_count / s.json_valid_total * 100) if s.json_valid_total else 0
|
||||
det_pct = (s.detection_count / s.detection_total * 100) if s.detection_total else 0
|
||||
notes = f"{len(s.errors)} err" if s.errors else "OK"
|
||||
print(f"| `{s.model}` | {s.cold_s:.1f} | {warm_avg:.1f} | {warm_p95:.1f} | "
|
||||
f"{json_pct:.0f}% ({s.json_valid_count}/{s.json_valid_total}) | "
|
||||
f"{det_pct:.0f}% ({s.detection_count}/{s.detection_total}) | {notes} |")
|
||||
|
||||
print("\n## Détail des checks par scénario\n")
|
||||
for s in all_stats:
|
||||
print(f"\n### `{s.model}`")
|
||||
if s.errors:
|
||||
print(f"_Erreurs ({len(s.errors)})_ : {s.errors[:5]}{'...' if len(s.errors) > 5 else ''}")
|
||||
for label, checks in s.sample_checks:
|
||||
if not checks:
|
||||
print(f"- **{label}** : _aucun check_")
|
||||
else:
|
||||
for c in checks[:2]:
|
||||
print(f"- **{label}** : {c.get('label', '?')} — _{c.get('evidence', '?')[:120]}_")
|
||||
|
||||
|
||||
def pick_winner(all_stats: list[ModelStats]) -> ModelStats | None:
|
||||
"""Le gagnant : meilleur taux détection, départage par warm avg."""
|
||||
valid = [s for s in all_stats if s.warm_times]
|
||||
if not valid:
|
||||
return None
|
||||
# Tri : détection desc puis warm avg asc
|
||||
valid.sort(key=lambda s: (-(s.detection_count / max(s.detection_total, 1)), statistics.mean(s.warm_times)))
|
||||
return valid[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Génération des 5 screenshots
|
||||
print("📸 Génération des 5 screenshots synthétiques :")
|
||||
screenshots: list[tuple[Scenario, str]] = []
|
||||
for scen in SCENARIOS:
|
||||
path = f"/tmp/bench_safety_{scen.label}.png"
|
||||
make_screenshot(scen, path)
|
||||
print(f" - {scen.label} → {path}")
|
||||
screenshots.append((scen, path))
|
||||
|
||||
print(f"\n⏱ Timeout par appel : {TIMEOUT_S}s")
|
||||
print(f"🔄 Warm runs par scénario : {WARM_RUNS_PER_SCREENSHOT}")
|
||||
print(f"📊 Total mesures par modèle : 1 cold + {(len(SCENARIOS)-1) * WARM_RUNS_PER_SCREENSHOT} warm = "
|
||||
f"{1 + (len(SCENARIOS)-1) * WARM_RUNS_PER_SCREENSHOT}")
|
||||
print(f"🤖 Candidats : {[c[0] for c in CANDIDATES]}")
|
||||
|
||||
all_stats: list[ModelStats] = []
|
||||
for model, url, _ in CANDIDATES:
|
||||
try:
|
||||
stats = run_bench_for_model(model, url, screenshots)
|
||||
all_stats.append(stats)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n⚠️ Interrompu pendant {model}, on saute le reste")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"\n❌ Crash bench {model}: {e}")
|
||||
all_stats.append(ModelStats(model=model, errors=[f"crash:{e}"]))
|
||||
|
||||
print_summary_table(all_stats)
|
||||
|
||||
winner = pick_winner(all_stats)
|
||||
print("\n## Recommandation\n")
|
||||
if winner is None:
|
||||
print("⚠️ Aucun modèle exploitable. Décision manuelle nécessaire.")
|
||||
return 1
|
||||
det_pct = winner.detection_count / max(winner.detection_total, 1) * 100
|
||||
warm_avg = statistics.mean(winner.warm_times)
|
||||
print(f"🏆 **{winner.model}** : détection {det_pct:.0f}%, warm avg {warm_avg:.1f}s, cold {winner.cold_s:.1f}s")
|
||||
print(f"\nPour fixer en production :")
|
||||
print(f"```bash\nsudo systemctl edit rpa-streaming")
|
||||
print(f"# [Service]\n# Environment=RPA_SAFETY_CHECKS_LLM_MODEL={winner.model}")
|
||||
print(f"sudo systemctl restart rpa-streaming\n```")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
812
tools/test_replay_e2e.py
Normal file
812
tools/test_replay_e2e.py
Normal file
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Harness E2E pour tester un replay sans Léa V1 / Windows.
|
||||
|
||||
Mocque le client Léa V1 contre le serveur de streaming réel (port 5005).
|
||||
Le harness compile le workflow via VWB (port 5002, /api/v3/execute-windows)
|
||||
exactement comme le frontend, puis prend la place de l'agent Windows :
|
||||
- boucle GET /replay/next (poll)
|
||||
- résout les actions click_anchor via POST /replay/resolve_target avec un
|
||||
screenshot fixture (heartbeat sur disque)
|
||||
- POST /replay/result avec succès/échec
|
||||
- gère pause_for_human (auto-resume ou stop selon mode)
|
||||
- imprime un tableau Markdown des résolutions et compare à un YAML d'attendus
|
||||
|
||||
Permet d'itérer en quelques secondes (vs 1-2 min de replay Windows réel) sur :
|
||||
- modifications serveur (resolve_engine, replay_engine, validation OCR…)
|
||||
- robustesse de la cascade visuelle sur un screenshot donné
|
||||
- cas d'erreur (target_not_found, pause supervisée, retry).
|
||||
|
||||
Usage standard (workflow Urgence_aiva_demo, screenshot le plus récent) :
|
||||
|
||||
cd /home/dom/ai/rpa_vision_v3 && source .venv/bin/activate
|
||||
python tools/test_replay_e2e.py \\
|
||||
--workflow-id wf_a38aeebea5e6_1778162737 \\
|
||||
--shot data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png
|
||||
|
||||
Options :
|
||||
--workflow-id ID workflow à rejouer (default Urgence_aiva_demo)
|
||||
--shot PATH screenshot fixture (default: dernier heartbeat trouvé)
|
||||
--expected YAML fichier attendus (compare step par step)
|
||||
--export-expected PATH exporter le run en YAML/JSON d'attendus
|
||||
--auto-resume auto-acquitter pause_for_human
|
||||
--execution-mode autonomous|supervised (par défaut: autonomous)
|
||||
--single-step N (debug) ne lancer que les N premières actions
|
||||
--verbose logs détaillés HTTP
|
||||
--timeout-poll SECONDS timeout par poll (default 8s)
|
||||
--max-iter N garde-fou (default 200)
|
||||
--vwb-url URL URL VWB (default http://localhost:5002)
|
||||
|
||||
Sortie :
|
||||
- tableau Markdown récapitulatif
|
||||
- exit code 0 si tous les steps OK / 1 sinon
|
||||
|
||||
Ne dépend PAS de Windows, ne modifie aucun fichier serveur.
|
||||
Pré-requis : streaming server (5005) + VWB backend (5002) actifs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
# YAML est optionnel : si absent, on génère du JSON pour l'export d'attendus
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
_yaml = None
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Configuration par défaut
|
||||
# ==========================================================================
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ENV_FILE = ROOT / ".env.local"
|
||||
DEFAULT_BASE_URL = "http://localhost:5005"
|
||||
DEFAULT_VWB_URL = "http://localhost:5002"
|
||||
DEFAULT_HEARTBEAT_GLOB = str(
|
||||
ROOT / "data" / "training" / "live_sessions" / "*" / "shots" / "heartbeat_*.png"
|
||||
)
|
||||
DEFAULT_HEARTBEAT_GLOB_BG = str(
|
||||
ROOT / "data" / "training" / "live_sessions" / "bg_*" / "shots" / "heartbeat_*.png"
|
||||
)
|
||||
|
||||
|
||||
def _load_token() -> str:
|
||||
"""Lit RPA_API_TOKEN depuis l'env ou .env.local."""
|
||||
if "RPA_API_TOKEN" in os.environ and os.environ["RPA_API_TOKEN"]:
|
||||
return os.environ["RPA_API_TOKEN"]
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("RPA_API_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def _find_latest_heartbeat() -> Optional[str]:
|
||||
"""Cherche le dernier heartbeat sur disque utilisable comme fixture.
|
||||
|
||||
Préfère les heartbeats `bg_*` (capturés en arrière-plan, pleine résolution)
|
||||
aux heartbeats sess_* qui peuvent être tronqués (bug mss.monitors[1]
|
||||
capturant la barre des tâches, cf. resolve_engine.py).
|
||||
Filtre aussi sur la taille minimale (1200x800) pour ignorer les crops.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
def _is_full_size(path: str) -> bool:
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
return im.width >= 1200 and im.height >= 800
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 1. Chercher d'abord dans bg_*
|
||||
bg_candidates = [
|
||||
f for f in glob.glob(DEFAULT_HEARTBEAT_GLOB_BG)
|
||||
if "_blurred" not in f and os.path.isfile(f)
|
||||
]
|
||||
bg_candidates = [f for f in bg_candidates if _is_full_size(f)]
|
||||
if bg_candidates:
|
||||
bg_candidates.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
||||
return bg_candidates[0]
|
||||
|
||||
# 2. Fallback sur sess_*, mais en filtrant les tronqués
|
||||
other = [
|
||||
f for f in glob.glob(DEFAULT_HEARTBEAT_GLOB)
|
||||
if "_blurred" not in f and os.path.isfile(f)
|
||||
]
|
||||
other = [f for f in other if _is_full_size(f)]
|
||||
if other:
|
||||
other.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
||||
return other[0]
|
||||
return None
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Modèles légers (pas d'import Pydantic pour rester rapide à charger)
|
||||
# ==========================================================================
|
||||
@dataclass
|
||||
class StepReport:
|
||||
order: int
|
||||
action_id: str
|
||||
action_type: str
|
||||
by_text: str
|
||||
method: str = ""
|
||||
score: float = 0.0
|
||||
x_pct: Optional[float] = None
|
||||
y_pct: Optional[float] = None
|
||||
status: str = "?" # OK / FAIL / SKIP / PAUSED
|
||||
diag: str = ""
|
||||
elapsed_ms: float = 0.0
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Client mock
|
||||
# ==========================================================================
|
||||
class ReplayMockClient:
|
||||
"""Simule l'Agent V1 contre le serveur de streaming."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
vwb_url: str,
|
||||
token: str,
|
||||
session_id: str,
|
||||
machine_id: str,
|
||||
screenshot_path: str,
|
||||
verbose: bool = False,
|
||||
auto_resume: bool = True,
|
||||
execution_mode: str = "autonomous",
|
||||
timeout_poll: float = 8.0,
|
||||
single_step: Optional[int] = None,
|
||||
max_iter: int = 200,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.vwb_url = vwb_url.rstrip("/")
|
||||
self.token = token
|
||||
self.session_id = session_id
|
||||
self.machine_id = machine_id
|
||||
self.screenshot_path = screenshot_path
|
||||
self.verbose = verbose
|
||||
self.auto_resume = auto_resume
|
||||
self.execution_mode = execution_mode
|
||||
self.timeout_poll = timeout_poll
|
||||
self.single_step = single_step
|
||||
self.max_iter = max_iter
|
||||
|
||||
self._session = requests.Session()
|
||||
if token:
|
||||
self._session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
# cache du screenshot encodé (gros)
|
||||
self._screenshot_b64: Optional[str] = None
|
||||
self._screen_w: int = 1920
|
||||
self._screen_h: int = 1080
|
||||
self._load_screenshot()
|
||||
|
||||
self.replay_id: Optional[str] = None
|
||||
self.reports: List[StepReport] = []
|
||||
self._action_counter = 0
|
||||
self._resumes_done = 0
|
||||
|
||||
# ---- helpers ------------------------------------------------------
|
||||
def _load_screenshot(self) -> None:
|
||||
from PIL import Image # imported lazily
|
||||
|
||||
with open(self.screenshot_path, "rb") as f:
|
||||
data = f.read()
|
||||
self._screenshot_b64 = base64.b64encode(data).decode("ascii")
|
||||
with Image.open(self.screenshot_path) as img:
|
||||
self._screen_w, self._screen_h = img.size
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
if self.verbose:
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def _post(self, path: str, json_body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
url = f"{self.base_url}{path}"
|
||||
if self.verbose:
|
||||
self._log(f"POST {path} body={json.dumps(json_body)[:200]}")
|
||||
resp = self._session.post(url, json=json_body, timeout=60)
|
||||
if self.verbose:
|
||||
self._log(f" → {resp.status_code} {resp.text[:300]}")
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.text else {}
|
||||
|
||||
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
url = f"{self.base_url}{path}"
|
||||
resp = self._session.get(url, params=params, timeout=self.timeout_poll)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.text else {}
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def cancel_stale_replays(self) -> None:
|
||||
"""Annule les replays running/paused pour cette machine, pour éviter les collisions."""
|
||||
try:
|
||||
data = self._get("/api/v1/traces/stream/replays")
|
||||
except Exception as e:
|
||||
self._log(f"cancel_stale: get replays échoué : {e}")
|
||||
return
|
||||
for r in data.get("replays", []):
|
||||
if r.get("machine_id") == self.machine_id and r.get("status") in (
|
||||
"running", "paused_need_help",
|
||||
):
|
||||
rid = r.get("replay_id")
|
||||
self._log(f"cancel stale replay {rid} (status={r.get('status')})")
|
||||
try:
|
||||
self._post(f"/api/v1/traces/stream/replay/{rid}/cancel", {})
|
||||
except Exception as e:
|
||||
self._log(f"cancel {rid} échoué : {e}")
|
||||
|
||||
def register_session(self) -> None:
|
||||
"""Enregistre la session de test côté serveur."""
|
||||
# POST /register avec session_id en query (pas JSON body)
|
||||
url = f"{self.base_url}/api/v1/traces/stream/register"
|
||||
resp = self._session.post(
|
||||
url,
|
||||
params={"session_id": self.session_id, "machine_id": self.machine_id},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
self._log(f"session registered : {self.session_id} (machine={self.machine_id})")
|
||||
|
||||
def start_replay(self, workflow_id: str) -> Dict[str, Any]:
|
||||
"""Lance un replay via la chaîne réelle VWB → /replay/raw.
|
||||
|
||||
On reproduit ce que fait le frontend (ExecutionControls.tsx) :
|
||||
1. GET /api/v3/workflow/{id} pour récupérer les steps
|
||||
2. POST /api/v3/execute-windows avec actions[] + session_id/machine_id
|
||||
(VWB charge les ancres, mappe les types, et POST sur /replay/raw)
|
||||
"""
|
||||
# 1. Récupérer le workflow et ses steps depuis VWB
|
||||
wf_url = f"{self.vwb_url}/api/v3/workflow/{workflow_id}"
|
||||
resp = self._session.get(wf_url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
wf_data = resp.json()
|
||||
steps = (
|
||||
wf_data.get("steps")
|
||||
or wf_data.get("workflow", {}).get("steps")
|
||||
or []
|
||||
)
|
||||
if not steps:
|
||||
raise RuntimeError(
|
||||
f"Workflow {workflow_id} : aucune étape récupérée depuis VWB "
|
||||
f"({wf_url})"
|
||||
)
|
||||
self._log(f"workflow {workflow_id} : {len(steps)} steps récupérées")
|
||||
|
||||
# 2. Construire le payload comme le frontend
|
||||
actions = []
|
||||
for i, step in enumerate(steps):
|
||||
anchor = step.get("anchor") or {}
|
||||
actions.append({
|
||||
"action_id": step.get("id") or f"action_{i}",
|
||||
"type": step.get("action_type"),
|
||||
"parameters": step.get("parameters") or {},
|
||||
"anchor_id": anchor.get("id") if anchor else step.get("anchor_id"),
|
||||
"order": i,
|
||||
})
|
||||
|
||||
# 3. POST /api/v3/execute-windows (VWB compile + forward au streaming)
|
||||
execute_url = f"{self.vwb_url}/api/v3/execute-windows"
|
||||
body = {
|
||||
"workflow_id": workflow_id,
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
"actions": actions,
|
||||
"params": {"execution_mode": self.execution_mode},
|
||||
}
|
||||
if self.verbose:
|
||||
self._log(f"POST {execute_url} actions={len(actions)}")
|
||||
resp = self._session.post(execute_url, json=body, timeout=60)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"VWB execute-windows {resp.status_code} : {resp.text[:300]}"
|
||||
)
|
||||
data = resp.json()
|
||||
self.replay_id = data.get("replay_id")
|
||||
return data
|
||||
|
||||
def get_replay_status(self) -> Dict[str, Any]:
|
||||
if not self.replay_id:
|
||||
return {}
|
||||
try:
|
||||
return self._get(f"/api/v1/traces/stream/replay/{self.replay_id}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def cancel_replay(self) -> None:
|
||||
if not self.replay_id:
|
||||
return
|
||||
try:
|
||||
self._post(f"/api/v1/traces/stream/replay/{self.replay_id}/cancel", {})
|
||||
except Exception as e:
|
||||
self._log(f"cancel replay échoué : {e}")
|
||||
|
||||
def resume_replay(self) -> None:
|
||||
"""Auto-resume une pause (mode autonomous bypass mais supervised peut bloquer)."""
|
||||
if not self.replay_id:
|
||||
return
|
||||
# Récupérer les checks à acquitter
|
||||
ack: List[str] = []
|
||||
try:
|
||||
state = self.get_replay_status()
|
||||
for c in state.get("safety_checks") or []:
|
||||
if c.get("required"):
|
||||
ack.append(c.get("id"))
|
||||
except Exception:
|
||||
pass
|
||||
body: Dict[str, Any] = {"acknowledged_check_ids": ack}
|
||||
try:
|
||||
self._post(f"/api/v1/traces/stream/replay/{self.replay_id}/resume", body)
|
||||
self._resumes_done += 1
|
||||
self._log(f"resume OK (checks ack={ack})")
|
||||
except Exception as e:
|
||||
self._log(f"resume échoué : {e}")
|
||||
|
||||
# ---- dispatch d'actions ------------------------------------------
|
||||
def resolve_target(self, target_spec: Dict[str, Any], strict: bool) -> Dict[str, Any]:
|
||||
"""Appelle /replay/resolve_target côté serveur avec le screenshot fixture."""
|
||||
body = {
|
||||
"session_id": self.session_id,
|
||||
"screenshot_b64": self._screenshot_b64 or "",
|
||||
"target_spec": target_spec or {},
|
||||
"fallback_x_pct": 0.5,
|
||||
"fallback_y_pct": 0.5,
|
||||
"screen_width": self._screen_w,
|
||||
"screen_height": self._screen_h,
|
||||
"strict_mode": strict,
|
||||
}
|
||||
return self._post("/api/v1/traces/stream/replay/resolve_target", body)
|
||||
|
||||
def dispatch(self, action: Dict[str, Any]) -> StepReport:
|
||||
"""Simule l'exécution d'une action côté client et POST le résultat."""
|
||||
self._action_counter += 1
|
||||
action_id = action.get("action_id", f"unk_{self._action_counter}")
|
||||
action_type = action.get("type", "?")
|
||||
target_spec = action.get("target_spec") or {}
|
||||
by_text = (target_spec.get("by_text") or "")[:40]
|
||||
report = StepReport(
|
||||
order=self._action_counter,
|
||||
action_id=action_id,
|
||||
action_type=action_type,
|
||||
by_text=by_text,
|
||||
)
|
||||
t0 = time.time()
|
||||
|
||||
# ── Action visuelle : resolve_target puis renvoyer success ──
|
||||
if action_type in ("click", "click_anchor", "double_click"):
|
||||
try:
|
||||
res = self.resolve_target(target_spec, strict=bool(action.get("success_strict")))
|
||||
report.method = res.get("method", "?")
|
||||
report.score = float(res.get("score") or 0.0)
|
||||
report.x_pct = res.get("x_pct")
|
||||
report.y_pct = res.get("y_pct")
|
||||
resolved = bool(res.get("resolved"))
|
||||
if not resolved:
|
||||
report.status = "FAIL"
|
||||
report.diag = res.get("reason", res.get("method", ""))[:80]
|
||||
self._post_result(
|
||||
action_id,
|
||||
success=False,
|
||||
error=f"resolve_failed:{report.method}",
|
||||
actual_position=None,
|
||||
resolution_method=report.method,
|
||||
resolution_score=report.score,
|
||||
resolution_elapsed_ms=res.get("elapsed_ms"),
|
||||
target_spec=target_spec,
|
||||
target_description=by_text,
|
||||
)
|
||||
else:
|
||||
report.status = "OK"
|
||||
self._post_result(
|
||||
action_id,
|
||||
success=True,
|
||||
actual_position={"x_pct": report.x_pct, "y_pct": report.y_pct},
|
||||
resolution_method=report.method,
|
||||
resolution_score=report.score,
|
||||
resolution_elapsed_ms=res.get("elapsed_ms"),
|
||||
)
|
||||
except Exception as e:
|
||||
report.status = "FAIL"
|
||||
report.diag = f"client_error:{e}"[:80]
|
||||
self._post_result(action_id, success=False, error=str(e)[:200])
|
||||
# ── Type texte / shortcut clavier / wait : on simule succès ──
|
||||
elif action_type in ("type_text", "type", "keyboard_shortcut", "key_combo", "wait"):
|
||||
report.status = "OK"
|
||||
report.method = "simulated"
|
||||
report.diag = f"{action_type} simulé"
|
||||
self._post_result(action_id, success=True)
|
||||
# ── Actions serveur (extract_text/table, t2a_decision) :
|
||||
# ne devraient PAS arriver côté client (le serveur les exécute en
|
||||
# interne dans /replay/next). On marque SKIP pour traçabilité.
|
||||
elif action_type in ("extract_text", "extract_table", "t2a_decision"):
|
||||
report.status = "SKIP"
|
||||
report.method = "server_side"
|
||||
report.diag = "(action serveur, exécutée en interne)"
|
||||
else:
|
||||
report.status = "OK"
|
||||
report.method = "noop"
|
||||
report.diag = f"action {action_type} non gérée → success simulé"
|
||||
self._post_result(action_id, success=True)
|
||||
|
||||
report.elapsed_ms = (time.time() - t0) * 1000
|
||||
self.reports.append(report)
|
||||
return report
|
||||
|
||||
def _post_result(
|
||||
self,
|
||||
action_id: str,
|
||||
success: bool,
|
||||
error: Optional[str] = None,
|
||||
warning: Optional[str] = None,
|
||||
actual_position: Optional[Dict[str, float]] = None,
|
||||
resolution_method: Optional[str] = None,
|
||||
resolution_score: Optional[float] = None,
|
||||
resolution_elapsed_ms: Optional[float] = None,
|
||||
target_spec: Optional[Dict[str, Any]] = None,
|
||||
target_description: Optional[str] = None,
|
||||
) -> None:
|
||||
body: Dict[str, Any] = {
|
||||
"session_id": self.session_id,
|
||||
"action_id": action_id,
|
||||
"success": success,
|
||||
}
|
||||
if error:
|
||||
body["error"] = error
|
||||
if warning:
|
||||
body["warning"] = warning
|
||||
if actual_position:
|
||||
body["actual_position"] = actual_position
|
||||
if resolution_method:
|
||||
body["resolution_method"] = resolution_method
|
||||
if resolution_score is not None:
|
||||
body["resolution_score"] = float(resolution_score)
|
||||
if resolution_elapsed_ms is not None:
|
||||
body["resolution_elapsed_ms"] = float(resolution_elapsed_ms)
|
||||
# Pour ne pas que le verifier ouvre un Critic VLM (lent), on n'envoie
|
||||
# PAS de screenshot_before/after (l'action sera marquée comme non
|
||||
# vérifiée mais avancera quand même).
|
||||
if target_spec:
|
||||
body["target_spec"] = target_spec
|
||||
if target_description:
|
||||
body["target_description"] = target_description
|
||||
try:
|
||||
self._post("/api/v1/traces/stream/replay/result", body)
|
||||
except Exception as e:
|
||||
self._log(f"POST result échoué (action {action_id}) : {e}")
|
||||
|
||||
# ---- main loop ----------------------------------------------------
|
||||
def run(self) -> None:
|
||||
iter_count = 0
|
||||
last_paused_logged = ""
|
||||
empty_polls = 0
|
||||
while iter_count < self.max_iter:
|
||||
iter_count += 1
|
||||
try:
|
||||
resp = self._get(
|
||||
"/api/v1/traces/stream/replay/next",
|
||||
params={
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._log(f"poll {iter_count} : erreur réseau {e}, retry dans 1s")
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Pause supervisée (paused_need_help) ?
|
||||
if resp.get("replay_paused"):
|
||||
msg = (resp.get("pause_message") or "")[:120]
|
||||
|
||||
# Distinguer pause volontaire (user_request, safety_checks) vs
|
||||
# pause d'échec (target_not_found, wrong_window, system_dialog).
|
||||
# Pour les pauses d'échec, l'auto-resume relance la même action
|
||||
# qui échouera encore — on ne resume qu'une fois max pour ne
|
||||
# pas boucler infiniment.
|
||||
state = self.get_replay_status()
|
||||
failed = state.get("failed_action") or {}
|
||||
pause_reason = failed.get("reason") or ""
|
||||
is_failure_pause = pause_reason in (
|
||||
"target_not_found", "wrong_window", "system_dialog",
|
||||
)
|
||||
|
||||
if msg != last_paused_logged:
|
||||
self._log(f"PAUSE ({pause_reason or 'user'}) : {msg}")
|
||||
last_paused_logged = msg
|
||||
|
||||
# Marquer le report comme PAUSED (une seule fois)
|
||||
if not self.reports or self.reports[-1].status != "PAUSED":
|
||||
self._action_counter += 1
|
||||
self.reports.append(
|
||||
StepReport(
|
||||
order=self._action_counter,
|
||||
action_id=resp.get("replay_id", "?"),
|
||||
action_type=f"pause:{pause_reason or 'user'}",
|
||||
by_text=(failed.get("target_description") or "")[:32],
|
||||
status="PAUSED",
|
||||
diag=msg[:80],
|
||||
)
|
||||
)
|
||||
|
||||
if not self.auto_resume:
|
||||
self._log("--auto-resume désactivé : on stoppe.")
|
||||
break
|
||||
|
||||
if is_failure_pause and self._resumes_done > 5:
|
||||
self._log(
|
||||
f"Trop de resumes ({self._resumes_done}) sur des "
|
||||
f"pauses d'échec — stop pour éviter la boucle."
|
||||
)
|
||||
break
|
||||
|
||||
time.sleep(0.5)
|
||||
self.resume_replay()
|
||||
last_paused_logged = ""
|
||||
continue
|
||||
|
||||
action = resp.get("action")
|
||||
if action is None:
|
||||
# Pas d'action en attente : peut-être terminé, peut-être server_busy
|
||||
if resp.get("server_busy"):
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
state = self.get_replay_status()
|
||||
status = state.get("status", "?")
|
||||
if status in ("completed", "cancelled", "error", "failed"):
|
||||
self._log(f"replay terminé status={status}")
|
||||
break
|
||||
empty_polls += 1
|
||||
if empty_polls > 30: # 30 polls vides = ~30s : on lève le doute
|
||||
self._log("Trop de polls vides, on stoppe.")
|
||||
break
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
empty_polls = 0
|
||||
self.dispatch(action)
|
||||
|
||||
if self.single_step is not None and self._action_counter >= self.single_step:
|
||||
self._log(f"--single-step {self.single_step} atteint, stop.")
|
||||
break
|
||||
|
||||
if iter_count >= self.max_iter:
|
||||
self._log(f"WARN : max_iter ({self.max_iter}) atteint.")
|
||||
|
||||
# Réconciliation : récupérer les actions exécutées côté serveur
|
||||
# (extract_text, extract_table, t2a_decision) qui ne sont jamais
|
||||
# passées par /replay/next côté client.
|
||||
try:
|
||||
state = self.get_replay_status()
|
||||
seen_ids = {r.action_id for r in self.reports}
|
||||
for res in state.get("results") or []:
|
||||
aid = res.get("action_id")
|
||||
if aid in seen_ids:
|
||||
continue
|
||||
# Heuristique : ce sont des actions serveur non vues
|
||||
ok = bool(res.get("success"))
|
||||
self._action_counter += 1
|
||||
self.reports.append(StepReport(
|
||||
order=self._action_counter,
|
||||
action_id=aid or "?",
|
||||
action_type="(server)",
|
||||
by_text="",
|
||||
method="server_side",
|
||||
status="OK" if ok else "FAIL",
|
||||
diag=(res.get("error") or "")[:60],
|
||||
))
|
||||
except Exception as e:
|
||||
self._log(f"reconciliation skipped : {e}")
|
||||
|
||||
# ---- rapport ------------------------------------------------------
|
||||
def render_report(self) -> str:
|
||||
out: List[str] = []
|
||||
out.append("")
|
||||
out.append("| # | Type | by_text | Méthode | Score | Pos résolue | Status | Diag |")
|
||||
out.append("|----|------------------|----------------------------------|----------------------|-------|----------------------|---------|------|")
|
||||
for r in self.reports:
|
||||
pos = (
|
||||
f"({r.x_pct:.4f}, {r.y_pct:.4f})"
|
||||
if r.x_pct is not None and r.y_pct is not None
|
||||
else "-"
|
||||
)
|
||||
score = f"{r.score:.2f}" if r.method else "-"
|
||||
out.append(
|
||||
f"| {r.order:<2} | {r.action_type:<16} | {r.by_text[:32]:<32} | "
|
||||
f"{r.method[:20]:<20} | {score:<5} | {pos:<20} | {r.status:<7} | {r.diag[:60]} |"
|
||||
)
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
def export_expected(self, path: Path) -> None:
|
||||
"""Sérialise les résolutions actuelles comme attendus de référence."""
|
||||
data = {
|
||||
"workflow_session_id": self.session_id,
|
||||
"screenshot": str(self.screenshot_path),
|
||||
"steps": [asdict(r) for r in self.reports],
|
||||
}
|
||||
if path.suffix in (".yaml", ".yml") and _yaml is not None:
|
||||
path.write_text(_yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
|
||||
else:
|
||||
# fallback JSON
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
self._log(f"Attendus exportés vers {path}")
|
||||
|
||||
def compare_to_expected(self, expected_path: Path) -> Tuple[int, int]:
|
||||
"""Compare reports vs attendus. Retourne (matching, total)."""
|
||||
if not expected_path.exists():
|
||||
print(f"[expected] fichier introuvable : {expected_path}")
|
||||
return (0, len(self.reports))
|
||||
if expected_path.suffix in (".yaml", ".yml") and _yaml is not None:
|
||||
expected = _yaml.safe_load(expected_path.read_text())
|
||||
else:
|
||||
expected = json.loads(expected_path.read_text())
|
||||
steps = expected.get("steps") or []
|
||||
ok = 0
|
||||
for actual, exp in zip(self.reports, steps):
|
||||
same_method = (actual.method == exp.get("method", "")) or (
|
||||
actual.method.startswith("hybrid_") and exp.get("method", "").startswith("hybrid_")
|
||||
)
|
||||
same_status = actual.status == exp.get("status", "")
|
||||
if same_method and same_status:
|
||||
ok += 1
|
||||
return (ok, len(steps) if steps else len(self.reports))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# CLI
|
||||
# ==========================================================================
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Harness E2E pour rejouer un workflow contre le serveur sans Léa V1."
|
||||
)
|
||||
parser.add_argument("--workflow-id", default="wf_a38aeebea5e6_1778162737",
|
||||
help="ID du workflow (default: Urgence_aiva_demo)")
|
||||
parser.add_argument("--shot", default=None,
|
||||
help="Path screenshot fixture (default: dernier heartbeat)")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL,
|
||||
help="URL streaming server (default 5005)")
|
||||
parser.add_argument("--vwb-url", default=DEFAULT_VWB_URL,
|
||||
help="URL VWB backend (default 5002)")
|
||||
parser.add_argument("--token", default=None,
|
||||
help="RPA_API_TOKEN (default: lit .env.local)")
|
||||
parser.add_argument("--session-id", default=None,
|
||||
help="(default: test_e2e_<ts>)")
|
||||
parser.add_argument("--machine-id", default=None,
|
||||
help="(default: test_e2e_machine_<ts>)")
|
||||
parser.add_argument("--auto-resume", action="store_true",
|
||||
help="auto-acquitter pause_for_human")
|
||||
parser.add_argument("--no-auto-resume", action="store_true",
|
||||
help="stop dès qu'une pause est rencontrée")
|
||||
parser.add_argument("--execution-mode", choices=("autonomous", "supervised"),
|
||||
default="autonomous")
|
||||
parser.add_argument("--single-step", type=int, default=None)
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
parser.add_argument("--timeout-poll", type=float, default=8.0)
|
||||
parser.add_argument("--max-iter", type=int, default=200)
|
||||
parser.add_argument("--export-expected", type=Path, default=None,
|
||||
help="Exporter le run en YAML/JSON d'attendus")
|
||||
parser.add_argument("--expected", type=Path, default=None,
|
||||
help="Comparer le run à ce YAML/JSON d'attendus")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
token = args.token or _load_token()
|
||||
if not token:
|
||||
print("WARN : pas de RPA_API_TOKEN trouvé.", file=sys.stderr)
|
||||
|
||||
shot = args.shot or _find_latest_heartbeat()
|
||||
if not shot or not os.path.isfile(shot):
|
||||
print(f"ERREUR : screenshot introuvable ({shot})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ts = time.strftime("%Y%m%dT%H%M%S")
|
||||
session_id = args.session_id or f"test_e2e_sess_{ts}_{uuid.uuid4().hex[:6]}"
|
||||
machine_id = args.machine_id or f"test_e2e_machine_{ts}"
|
||||
|
||||
auto_resume = True
|
||||
if args.no_auto_resume:
|
||||
auto_resume = False
|
||||
if args.auto_resume:
|
||||
auto_resume = True
|
||||
|
||||
print(f"[e2e] base_url={args.base_url}")
|
||||
print(f"[e2e] workflow_id={args.workflow_id}")
|
||||
print(f"[e2e] shot={shot}")
|
||||
print(f"[e2e] session_id={session_id}")
|
||||
print(f"[e2e] machine_id={machine_id}")
|
||||
print(f"[e2e] mode={args.execution_mode} auto_resume={auto_resume}")
|
||||
|
||||
client = ReplayMockClient(
|
||||
base_url=args.base_url,
|
||||
vwb_url=args.vwb_url,
|
||||
token=token,
|
||||
session_id=session_id,
|
||||
machine_id=machine_id,
|
||||
screenshot_path=shot,
|
||||
verbose=args.verbose,
|
||||
auto_resume=auto_resume,
|
||||
execution_mode=args.execution_mode,
|
||||
timeout_poll=args.timeout_poll,
|
||||
single_step=args.single_step,
|
||||
max_iter=args.max_iter,
|
||||
)
|
||||
|
||||
# Healthcheck
|
||||
try:
|
||||
h = requests.get(f"{args.base_url}/health", timeout=3).json()
|
||||
if h.get("status") != "healthy":
|
||||
print(f"WARN : serveur health={h}")
|
||||
except Exception as e:
|
||||
print(f"ERREUR : serveur injoignable sur {args.base_url} ({e})", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
client.cancel_stale_replays()
|
||||
client.register_session()
|
||||
|
||||
t_start = time.time()
|
||||
final_state: Dict[str, Any] = {}
|
||||
try:
|
||||
info = client.start_replay(args.workflow_id)
|
||||
print(f"[e2e] replay_id={info.get('replay_id')} total_actions={info.get('total_actions')}")
|
||||
client.run()
|
||||
# Snapshot l'état AVANT cancel (sinon on voit toujours "cancelled")
|
||||
try:
|
||||
final_state = client.get_replay_status()
|
||||
except Exception:
|
||||
final_state = {}
|
||||
finally:
|
||||
# toujours annuler en sortie pour ne pas laisser un replay actif
|
||||
try:
|
||||
client.cancel_replay()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(client.render_report())
|
||||
n_total = len(client.reports)
|
||||
n_ok = sum(1 for r in client.reports if r.status == "OK")
|
||||
n_skip = sum(1 for r in client.reports if r.status == "SKIP")
|
||||
n_paused = sum(1 for r in client.reports if r.status == "PAUSED")
|
||||
n_fail = sum(1 for r in client.reports if r.status == "FAIL")
|
||||
print(
|
||||
f"[e2e] {n_total} steps en {elapsed:.1f}s : "
|
||||
f"OK={n_ok} SKIP={n_skip} PAUSED={n_paused} FAIL={n_fail} "
|
||||
f"(resumes auto={client._resumes_done})"
|
||||
)
|
||||
if final_state:
|
||||
print(
|
||||
f"[e2e] final replay status={final_state.get('status')} "
|
||||
f"completed={final_state.get('completed_actions')}/"
|
||||
f"{final_state.get('total_actions')} "
|
||||
f"failed={final_state.get('failed_actions')} "
|
||||
f"retried={final_state.get('retried_actions')}"
|
||||
)
|
||||
for err in (final_state.get("error_log") or [])[-3:]:
|
||||
print(f" ERR action_id={err.get('action_id')} "
|
||||
f"error='{err.get('error')}' retry={err.get('retry_count')}")
|
||||
|
||||
if args.export_expected:
|
||||
client.export_expected(args.export_expected)
|
||||
|
||||
if args.expected:
|
||||
ok, total = client.compare_to_expected(args.expected)
|
||||
print(f"[e2e] comparaison attendus : {ok}/{total} steps matchent")
|
||||
if ok < total:
|
||||
return 1
|
||||
|
||||
return 1 if n_fail else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -1113,3 +1135,76 @@ def execute_windows():
|
||||
return jsonify({'error': 'Streaming server (port 5005) non disponible'}), 503
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QW4 — Proxy /api/v3/replay/resume → streaming /replay/{id}/resume
|
||||
# Forward Bearer token + body { replay_id, acknowledged_check_ids }.
|
||||
# Le frontend (PauseDialog) appelle /api/v3/replay/resume via le VWB ;
|
||||
# on relaye au streaming server pour valider les acquittements safety_checks.
|
||||
# ---------------------------------------------------------------------------
|
||||
@api_v3_bp.route('/replay/resume', methods=['POST'])
|
||||
def replay_resume_proxy():
|
||||
"""Proxy QW4 vers le serveur streaming pour la reprise avec safety_checks."""
|
||||
import requests as req
|
||||
|
||||
data = request.get_json() or {}
|
||||
replay_id = data.get('replay_id')
|
||||
if not replay_id:
|
||||
return jsonify({'error': 'replay_id manquant'}), 400
|
||||
|
||||
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
|
||||
token = os.environ.get('RPA_API_TOKEN', '')
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
# Body forwardé : uniquement acknowledged_check_ids (replay_id est dans l'URL)
|
||||
forward_body = {
|
||||
'acknowledged_check_ids': data.get('acknowledged_check_ids') or [],
|
||||
}
|
||||
|
||||
try:
|
||||
resp = req.post(
|
||||
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}/resume',
|
||||
json=forward_body,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
|
||||
except req.ConnectionError:
|
||||
return jsonify({'error': 'streaming_unreachable',
|
||||
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
|
||||
except req.RequestException as e:
|
||||
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QW4 — Proxy GET /api/v3/replay/state/<replay_id> → streaming /replay/{id}
|
||||
# Forward Bearer token vers le serveur streaming.
|
||||
# Permet à App.tsx de récupérer le state du replay actif (Agent V1 Windows)
|
||||
# pour afficher PauseDialog quand status = paused_need_help avec safety_checks.
|
||||
# ---------------------------------------------------------------------------
|
||||
@api_v3_bp.route('/replay/state/<replay_id>', methods=['GET'])
|
||||
def replay_state_proxy(replay_id):
|
||||
"""Proxy QW4 vers le serveur streaming pour récupérer le state replay actif."""
|
||||
import requests as req
|
||||
|
||||
streaming_url = os.environ.get('RPA_STREAMING_URL', 'http://localhost:5005')
|
||||
token = os.environ.get('RPA_API_TOKEN', '')
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
try:
|
||||
resp = req.get(
|
||||
f'{streaming_url}/api/v1/traces/stream/replay/{replay_id}',
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
return resp.content, resp.status_code, {'Content-Type': 'application/json'}
|
||||
except req.ConnectionError:
|
||||
return jsonify({'error': 'streaming_unreachable',
|
||||
'detail': f'Streaming server non disponible ({streaming_url})'}), 502
|
||||
except req.RequestException as e:
|
||||
return jsonify({'error': 'streaming_unreachable', 'detail': str(e)}), 502
|
||||
|
||||
@@ -25,6 +25,7 @@ import ExecutionOverlay from './components/ExecutionOverlay';
|
||||
import type { Variable } from './components/VariableManager';
|
||||
import RightPanel from './components/RightPanel';
|
||||
import SelfHealingDialog from './components/SelfHealingDialog';
|
||||
import PauseDialog from './components/PauseDialog';
|
||||
import ConfidenceDashboard from './components/ConfidenceDashboard';
|
||||
import WorkflowValidation from './components/WorkflowValidation';
|
||||
import ReviewModal from './components/ReviewModal';
|
||||
@@ -61,6 +62,13 @@ function App() {
|
||||
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
|
||||
const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
|
||||
|
||||
// QW4 — Replay streaming Windows en cours (Agent V1 distant).
|
||||
// Quand un replay distant est lancé via ExecutionControls "→ Windows",
|
||||
// ExecutionControls appelle setStreamingReplayId(replay_id) et un useEffect
|
||||
// poll /api/v3/replay/state/<id> pour fusionner safety_checks + pause_*
|
||||
// dans appState.execution → PauseDialog s'affiche.
|
||||
const [streamingReplayId, setStreamingReplayId] = useState<string | null>(null);
|
||||
|
||||
// Charger l'état initial
|
||||
const loadState = useCallback(async () => {
|
||||
try {
|
||||
@@ -122,6 +130,62 @@ function App() {
|
||||
return () => clearInterval(interval);
|
||||
}, [isExecutionRunning, loadState]);
|
||||
|
||||
// QW4 — Polling state replay streaming (Agent V1 Windows distant)
|
||||
// Tourne dès qu'un replay distant a été lancé. Récupère safety_checks,
|
||||
// pause_message, pause_reason et les fusionne dans appState.execution
|
||||
// pour que PauseDialog s'affiche quand status = paused_need_help.
|
||||
useEffect(() => {
|
||||
if (!streamingReplayId) return;
|
||||
|
||||
let stopped = false;
|
||||
const pollReplay = async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/v3/replay/state/${streamingReplayId}`);
|
||||
if (!resp.ok) return;
|
||||
const state = await resp.json();
|
||||
if (stopped) return;
|
||||
|
||||
// Fusionner dans appState.execution sans écraser le reste.
|
||||
setAppState(prev => {
|
||||
if (!prev) return prev;
|
||||
const prevExec = prev.execution || {
|
||||
id: streamingReplayId,
|
||||
workflow_id: prev.session?.active_workflow_id || '',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
current_step_index: 0,
|
||||
completed_steps: 0,
|
||||
failed_steps: 0,
|
||||
total_steps: 0,
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
execution: {
|
||||
...prevExec,
|
||||
status: state.status || prevExec.status,
|
||||
pause_message: state.pause_message || state.message,
|
||||
pause_reason: state.pause_reason,
|
||||
safety_checks: state.safety_checks || [],
|
||||
replay_id: streamingReplayId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stopper le polling si le replay est terminé / annulé.
|
||||
if (state.status && ['completed', 'error', 'cancelled'].includes(state.status)) {
|
||||
setStreamingReplayId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore (le serveur streaming peut être momentanément indispo)
|
||||
}
|
||||
};
|
||||
|
||||
// Tick immédiat puis toutes les 1s.
|
||||
pollReplay();
|
||||
const interval = setInterval(pollReplay, 1000);
|
||||
return () => { stopped = true; clearInterval(interval); };
|
||||
}, [streamingReplayId]);
|
||||
|
||||
// Convertir les étapes en nœuds React Flow
|
||||
// Les edges ne sont générées automatiquement que lors du premier chargement
|
||||
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
|
||||
@@ -451,6 +515,7 @@ function App() {
|
||||
execution={appState?.execution || null}
|
||||
onStart={handleStartExecution}
|
||||
onStop={handleStopExecution}
|
||||
onWindowsReplayStarted={(replayId) => setStreamingReplayId(replayId)}
|
||||
/>
|
||||
<ConfidenceDashboard
|
||||
isExecutionRunning={isExecutionRunning}
|
||||
@@ -569,6 +634,47 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* QW4 — Pause supervisée (safety_checks).
|
||||
Affiché si le serveur renvoie status == paused_need_help, ou
|
||||
status == paused avec un payload de checks. Backward 100% : si
|
||||
safety_checks vide, PauseDialog rend la bulle simple legacy. */}
|
||||
{(appState?.execution?.status === 'paused_need_help' ||
|
||||
(appState?.execution?.status === 'paused' &&
|
||||
(appState?.execution?.safety_checks?.length ?? 0) > 0)) && (
|
||||
<div className="pause-dialog-overlay">
|
||||
<PauseDialog
|
||||
pauseMessage={appState.execution.pause_message || 'Validation requise'}
|
||||
pauseReason={appState.execution.pause_reason}
|
||||
safetyChecks={appState.execution.safety_checks || []}
|
||||
onResume={async (ackIds) => {
|
||||
const replayId = appState.execution?.replay_id || appState.execution?.id;
|
||||
if (replayId) {
|
||||
// Voie streaming server (Agent V1 / replay distant)
|
||||
const resp = await fetch('/api/v3/replay/resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
replay_id: replayId,
|
||||
acknowledged_check_ids: ackIds,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err?.detail?.error || resp.statusText);
|
||||
}
|
||||
} else {
|
||||
// Voie locale (execute/resume)
|
||||
await api.resumeExecution();
|
||||
}
|
||||
await loadState();
|
||||
}}
|
||||
onCancel={() => {
|
||||
handleStopExecution();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ConfidenceDashboard déplacé dans le header */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,12 @@ interface Props {
|
||||
execution: Execution | null;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
// QW4 — Notifie App.tsx quand un replay streaming Windows est lancé,
|
||||
// pour qu'il poll /api/v3/replay/state/<id> et affiche PauseDialog au besoin.
|
||||
onWindowsReplayStarted?: (replayId: string) => void;
|
||||
}
|
||||
|
||||
export default function ExecutionControls({ execution, onStart, onStop }: Props) {
|
||||
export default function ExecutionControls({ execution, onStart, onStop, onWindowsReplayStarted }: Props) {
|
||||
const isRunning = execution?.status === 'running' || execution?.status === 'paused';
|
||||
const [windowsStatus, setWindowsStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
|
||||
@@ -56,6 +59,11 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
|
||||
const result = await resp.json();
|
||||
if (result.replay_id) {
|
||||
setWindowsStatus('sent');
|
||||
// QW4 — propage le replay_id à App.tsx pour activer le polling
|
||||
// /api/v3/replay/state/<id> (PauseDialog si paused_need_help).
|
||||
if (onWindowsReplayStarted) {
|
||||
try { onWindowsReplayStarted(result.replay_id); } catch {}
|
||||
}
|
||||
alert('Replay lancé ! Réduisez cette fenêtre maintenant.\nLes actions commenceront dans 5 secondes.');
|
||||
setTimeout(() => setWindowsStatus('idle'), 5000);
|
||||
} else {
|
||||
@@ -75,9 +83,27 @@ export default function ExecutionControls({ execution, onStart, onStop }: Props)
|
||||
{!isRunning ? (
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{userOS === 'linux' ? (
|
||||
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran">
|
||||
Exécuter
|
||||
</button>
|
||||
<>
|
||||
<button className="btn-start" onClick={onStart} title="Exécuter sur cet écran (Linux local)">
|
||||
Exécuter
|
||||
</button>
|
||||
<button
|
||||
className="btn-start"
|
||||
onClick={handleExecuteWindows}
|
||||
disabled={windowsStatus === 'sending'}
|
||||
style={{
|
||||
background: windowsStatus === 'sent' ? '#22c55e' : windowsStatus === 'error' ? '#ef4444' : '#0078d4',
|
||||
fontSize: '12px',
|
||||
opacity: windowsStatus === 'sending' ? 0.6 : 1,
|
||||
}}
|
||||
title="Exécuter sur le PC Windows (Agent V1)"
|
||||
>
|
||||
{windowsStatus === 'sending' ? 'Envoi...' :
|
||||
windowsStatus === 'sent' ? 'Lancé !' :
|
||||
windowsStatus === 'error' ? 'Erreur' :
|
||||
'→ Windows'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-start"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// QW4 — PauseDialog : bulle de pause supervisée avec ChecklistPanel intégré.
|
||||
//
|
||||
// 2 modes de rendu :
|
||||
// - safety_checks vide -> bulle simple legacy (Continuer / Annuler)
|
||||
// - safety_checks fournis -> ChecklistPanel ; bouton Continuer désactivé
|
||||
// tant qu'un check `required` n'est pas coché.
|
||||
//
|
||||
// Les checks `llm_contextual` portent un badge [Léa] avec evidence en tooltip.
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { SafetyCheck } from '../types';
|
||||
|
||||
interface Props {
|
||||
pauseMessage: string;
|
||||
pauseReason?: string;
|
||||
safetyChecks: SafetyCheck[];
|
||||
onResume: (acknowledgedIds: string[]) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PauseDialog({
|
||||
pauseMessage,
|
||||
pauseReason,
|
||||
safetyChecks,
|
||||
onResume,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allRequiredOK = useMemo(() => {
|
||||
return safetyChecks
|
||||
.filter((c) => c.required)
|
||||
.every((c) => checked[c.id] === true);
|
||||
}, [safetyChecks, checked]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setChecked((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const acknowledgedIds = Object.entries(checked)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
await onResume(acknowledgedIds);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Erreur lors de la reprise');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compat : pas de checks -> bulle simple legacy
|
||||
if (safetyChecks.length === 0) {
|
||||
return (
|
||||
<div className="pause-dialog-simple">
|
||||
<p>{pauseMessage}</p>
|
||||
{pauseReason && <small className="pause-reason">Raison : {pauseReason}</small>}
|
||||
<div className="pause-actions">
|
||||
<button onClick={() => onResume([])} disabled={submitting}>
|
||||
Continuer
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={submitting}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pause-dialog-checks">
|
||||
<h3>Pause supervisée</h3>
|
||||
<p className="pause-message">{pauseMessage}</p>
|
||||
{pauseReason && (
|
||||
<div className="pause-reason-banner">
|
||||
<strong>Raison :</strong> {pauseReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="checklist-panel">
|
||||
{safetyChecks.map((c) => (
|
||||
<li key={c.id} className={`check-item ${c.required ? 'required' : 'optional'}`}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked[c.id]}
|
||||
onChange={() => toggle(c.id)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className="check-label">{c.label}</span>
|
||||
{c.required && <span className="badge badge-required">obligatoire</span>}
|
||||
{c.source === 'llm_contextual' && (
|
||||
<span className="badge badge-lea" title={c.evidence || ''}>
|
||||
Léa
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{c.source === 'llm_contextual' && c.evidence && (
|
||||
<small className="check-evidence">-> {c.evidence}</small>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{error && <div className="pause-error">{error}</div>}
|
||||
|
||||
<div className="pause-actions">
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={!allRequiredOK || submitting}
|
||||
title={!allRequiredOK ? 'Coche tous les checks obligatoires' : 'Reprendre le replay'}
|
||||
>
|
||||
{submitting ? 'Reprise...' : 'Continuer'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={submitting}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1353,6 +1353,136 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
</>
|
||||
);
|
||||
|
||||
case 'pause_for_human': {
|
||||
// QW4 — éditeur safety_level + safety_checks (déclaratifs)
|
||||
const safetyChecks = Array.isArray(params.safety_checks)
|
||||
? (params.safety_checks as Array<{ id?: string; label?: string; required?: boolean }>)
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Message affiché à l'opérateur</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={String(params.message || '')}
|
||||
onChange={(e) => updateParam('message', e.target.value)}
|
||||
placeholder="Ex: Décision : {{dec.decision}} {{dec.justification}}"
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QW4 — Niveau de sécurité */}
|
||||
<div className="prop-field">
|
||||
<label>Niveau de sécurité</label>
|
||||
<select
|
||||
value={String(params.safety_level || 'standard')}
|
||||
onChange={(e) => updateParam('safety_level', e.target.value)}
|
||||
>
|
||||
<option value="standard">Standard (pas de LLM)</option>
|
||||
<option value="medical_critical">Médical critique (LLM contextuel)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* QW4 — Liste éditable de checks déclaratifs */}
|
||||
<div className="prop-field">
|
||||
<label>Checks à valider (déclaratifs)</label>
|
||||
{safetyChecks.map((check, i) => (
|
||||
<div key={i} className="check-editor-row">
|
||||
<input
|
||||
placeholder="ID (ex: check_ipp)"
|
||||
value={check.id || ''}
|
||||
style={{ width: '30%' }}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, id: e.target.value };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="Libellé"
|
||||
value={check.label || ''}
|
||||
style={{ flex: 1 }}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, label: e.target.value };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!check.required}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, required: e.target.checked };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
Obligatoire
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = safetyChecks.filter((_, j) => j !== i);
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
title="Supprimer ce check"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = [
|
||||
...safetyChecks,
|
||||
{ id: '', label: '', required: true },
|
||||
];
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
>
|
||||
+ Ajouter un check
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case 't2a_decision':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Template d'entrée (supporte {'{{var}}'})</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={String(params.input_template || '')}
|
||||
onChange={(e) => updateParam('input_template', e.target.value)}
|
||||
placeholder={'{{t0}}\n---\n{{t1}}\n{{t2}}\n{{t3}}\n{{t4}}'}
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Variable de sortie (ex: dec)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.output_var || '')}
|
||||
onChange={(e) => updateParam('output_var', e.target.value)}
|
||||
placeholder="dec"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Modèle Ollama</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.model || 'qwen2.5:7b')}
|
||||
onChange={(e) => updateParam('model', e.target.value)}
|
||||
placeholder="qwen2.5:7b"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div className="prop-info">Pas de paramètres supplémentaires</div>;
|
||||
}
|
||||
|
||||
@@ -4491,3 +4491,86 @@ body {
|
||||
.right-panel-tabbed .capture-library {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* === QW4 — PauseDialog & ChecklistPanel === */
|
||||
.pause-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.pause-dialog-simple,
|
||||
.pause-dialog-checks {
|
||||
padding: 16px;
|
||||
max-width: 480px;
|
||||
background: #fff;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.pause-dialog-checks h3 { margin: 0 0 8px; color: #92400e; }
|
||||
.pause-message { margin: 0 0 12px; }
|
||||
.pause-reason-banner {
|
||||
background: #fef3c7;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pause-reason { color: #6b7280; display: block; margin-top: 4px; }
|
||||
.checklist-panel {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.check-item {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.check-item.required { background: #fef9c3; }
|
||||
.check-item label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.badge-required { background: #dc2626; color: #fff; }
|
||||
.badge-lea { background: #2563eb; color: #fff; cursor: help; }
|
||||
.check-evidence {
|
||||
display: block;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
margin-left: 24px;
|
||||
}
|
||||
.pause-error {
|
||||
color: #dc2626;
|
||||
padding: 8px;
|
||||
background: #fef2f2;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pause-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.pause-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* QW4 — éditeur de safety_checks dans PropertiesPanel */
|
||||
.check-editor-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// Types pour l'API v3
|
||||
|
||||
// === QW4 — Safety checks (pause supervisée) ===
|
||||
export type SafetyLevel = 'standard' | 'medical_critical';
|
||||
|
||||
export interface SafetyCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
source: 'declarative' | 'llm_contextual';
|
||||
evidence?: string | null;
|
||||
}
|
||||
|
||||
// Mode d'exécution
|
||||
export type ExecutionMode = 'basic' | 'intelligent' | 'debug' | 'verified';
|
||||
|
||||
@@ -133,7 +144,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
{ name: 'max_iterations', type: 'number', description: 'Nombre maximum d\'itérations' }
|
||||
] },
|
||||
{ type: 'pause_for_human', label: 'Pause supervisée', icon: '⏸', description: 'Léa s\'arrête et demande validation humaine via une bulle interactive (boutons Continuer / Annuler).', category: 'logic', needsAnchor: false, params: [
|
||||
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' }
|
||||
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' },
|
||||
{ name: 'safety_level', type: 'select', description: 'Niveau de sécurité : standard (pas de LLM) ou medical_critical (LLM contextuel)' },
|
||||
{ name: 'safety_checks', type: 'safety_checks_editor', description: 'Liste de checks à valider avant reprise (id, libellé, obligatoire ?). Édité dans le panneau Propriétés.' }
|
||||
] },
|
||||
{ type: 't2a_decision', label: 'Décision T2A (LLM)', icon: '🧠', description: 'Analyse un DPI urgences via LLM local (qwen2.5:7b par défaut) et propose FORFAIT_URGENCE ou REQUALIFICATION_HOSPITALISATION. Retourne JSON {decision, justification, elements_pour/contre, confiance}. Bench validé 100% accuracy.', category: 'logic', needsAnchor: false, params: [
|
||||
{ name: 'input_template', type: 'string', description: 'DPI à analyser. Supporte le templating {{var}} pour concaténer plusieurs extractions (ex: "{{texte_motif}}\\n{{texte_examens}}\\n{{texte_notes}}")' },
|
||||
@@ -312,13 +325,19 @@ export interface WorkflowSummary {
|
||||
export interface Execution {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
|
||||
status: 'pending' | 'running' | 'paused' | 'paused_need_help' | 'completed' | 'error' | 'cancelled';
|
||||
progress: number;
|
||||
current_step_index: number;
|
||||
completed_steps: number;
|
||||
failed_steps: number;
|
||||
total_steps: number;
|
||||
error_message?: string;
|
||||
// === QW4 — Pause supervisée (renvoyés par /replay/state quand status = paused_need_help) ===
|
||||
pause_reason?: string;
|
||||
pause_message?: string;
|
||||
safety_checks?: SafetyCheck[];
|
||||
// ID du replay (utile pour appeler /replay/resume avec acknowledged_check_ids)
|
||||
replay_id?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
|
||||
Reference in New Issue
Block a user