backup: snapshot post-démo GHT 2026-05-19
Some checks failed
tests / Lint (ruff + black) (push) Successful in 1m50s
tests / Tests unitaires (sans GPU) (push) Failing after 1m50s
tests / Tests sécurité (critique) (push) Has been skipped

Backup état complet après enregistrement vidéo démo de bout en bout.
À utiliser comme point de référence pour la consolidation post-démo.

Changements majeurs de la session 18-19 mai :
- AIVA-URGENCE : page autonome avec preset URL + auto-focus chain
- Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine
- Bypass LLM (static_result / static_text) dans replay_engine
  pour démos déterministes sans appel Ollama
- Fix api_stream:3013 — replay_paused au premier polling /next
- dag_execute : lift duration_ms vers top-level pour wait runtime
- NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git)
- scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue

Anchors visuels (468) forcés dans le commit pour garantir restorabilité.
DB workflows actuelle + ~12 .bak DB de la journée incluses.

Sujets identifiés pour consolidation post-démo (TODO) :
1. Bug VWB recapture anchor ne régénère pas le PNG
2. Léa client accumule état mémoire (restart périodique requis)
3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel)
4. Bug coord client mss tronqué 2560x60 → mapping Y cassé
5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-19 14:55:06 +02:00
parent f2212e77e3
commit 5ea4960e65
627 changed files with 211348 additions and 169 deletions

View File

@@ -1148,8 +1148,9 @@ class ActionExecutorV1:
if raw_keys:
self._replay_raw_keys(raw_keys)
else:
# Fallback copier-coller (anciens enregistrements sans raw_keys)
self._type_text(text)
# Fallback (anciens enregistrements ou templating runtime sans raw_keys)
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
self._type_text(text, paste=bool(action.get("paste", False)))
print(f" [TYPE] Termine.")
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
@@ -1287,6 +1288,13 @@ class ActionExecutorV1:
pass
else:
print(f" [OK] Changement d'ecran detecte apres {action_type}")
# Stabilisation page avant capture screenshot_after :
# _wait_for_screen_change retourne dès le 1er tick de diff
# (200ms), or sur une transition de page (fade, render API)
# le DOM n'est pas encore stable → screenshot ambigu →
# verify_action serveur déclenche retry fantôme. Cf. démo
# GHT 2026-05-11.
time.sleep(0.5)
else:
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
time.sleep(0.5)
@@ -1295,8 +1303,11 @@ class ActionExecutorV1:
result["screenshot"] = self._capture_screenshot_b64()
except Exception as e:
result["error"] = str(e)
logger.error(f"Echec replay action {action_id} ({action_type}) : {e}")
result["error"] = f"{type(e).__name__}: {e}"
logger.error(
f"Echec replay action {action_id} ({action_type}) : {e}",
exc_info=True,
)
return result
@@ -1783,7 +1794,15 @@ Example: x_pct=0.50, y_pct=0.30"""
replay_next_url,
params={"session_id": session_id, "machine_id": machine_id},
headers=self._auth_headers(),
timeout=5,
# 8 mai 2026 — démo GHT : 5s → 30s. Le serveur peut exécuter
# extract_text (5-7s) PUIS dispatcher l'action suivante dans
# la même réponse HTTP. À 5s, le client coupait avant la
# réponse, le serveur stockait l'action dans _retry_pending
# mais aucun watchdog ne la republiait → action perdue
# silencieusement. Cf. docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md.
# Le serveur a sa propre borne 180s + retour server_busy à 4.5s,
# 30s côté client est sûr.
timeout=30,
)
if not resp.ok:
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
@@ -1824,23 +1843,17 @@ Example: x_pct=0.50, y_pct=0.30"""
"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)
# UX fix 8 mai 2026 (démo GHT) : un seul affichage. La
# bulle ChatWindow EST l'affichage canonique (force show +
# topmost + bell sonore). Plus de toast paused_toast en
# double — Dom ne voulait qu'un seul popup. Si la ChatWindow
# n'est pas câblée (mode headless / tests), on retombe sur
# le toast Tkinter custom comme dernier recours visuel.
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
# et force l'affichage + topmost + bell sonore.
chat_window._add_paused_bubble(payload)
except Exception:
logger.debug(
@@ -1848,16 +1861,17 @@ Example: x_pct=0.50, y_pct=0.30"""
exc_info=True,
)
else:
# Fallback notifier (tests headless / chat fermé)
# Fallback : pas de ChatWindow câblée → toast Tkinter
# autonome (headless / tests). C'est le SEUL chemin qui
# déclenche encore show_paused_toast.
try:
self.notifier.notify(
title="Léa — j'ai besoin de vous",
from ..ui.paused_toast import show_paused_toast
show_paused_toast(
title="Léa a besoin de votre aide",
message=pause_msg[:300],
timeout=15,
bypass_rate_limit=True,
)
except Exception:
logger.debug("notifier.notify pause silenced", exc_info=True)
logger.debug("paused_toast fallback silenced", exc_info=True)
return False
action = data.get("action")
@@ -2535,20 +2549,48 @@ Example: x_pct=0.50, y_pct=0.30"""
# Helpers
# =========================================================================
def _type_text(self, text: str):
"""Saisir du texte caractère par caractère (anti-détection robot).
def _type_text(self, text: str, paste: bool = False):
"""Saisir du texte caractère par caractère (anti-détection robot)
OU via clipboard + Ctrl+V si paste=True (opt-in workflow non-Citrix).
Chaque caractère est tapé individuellement avec un délai aléatoire
pour simuler une frappe humaine. Les caractères spéciaux AZERTY
(@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
Mode char-by-char (défaut) :
Chaque caractère tapé individuellement avec délai aléatoire
(anti-détection robot, requis pour Citrix).
Caractères spéciaux AZERTY via KeyCode.from_char().
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
Mode paste (opt-in via action.paste=True dans le workflow) :
Texte poussé dans le clipboard Windows (CF_UNICODETEXT) puis Ctrl+V.
Gain massif sur textes longs (~100ms total vs ~80ms/char).
Réservé aux workflows hors-Citrix (maquette Easily web OK, Citrix bloque).
Fallback char-by-char automatique si exception clipboard.
"""
import random
if not text:
return
if paste:
try:
import win32clipboard
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
win32clipboard.CloseClipboard()
# 16 mai 2026 : 0.05s → 0.5s pour laisser NoMachine sync
# le clipboard Windows → VM Ubuntu avant le Ctrl+V (sinon
# le paste arrive avant que le clipboard guest soit à jour).
time.sleep(0.5)
self.keyboard.press(Key.ctrl_l)
self.keyboard.press(KeyCode.from_char('v'))
self.keyboard.release(KeyCode.from_char('v'))
self.keyboard.release(Key.ctrl_l)
time.sleep(0.1)
logger.debug(f"Texte saisi via clipboard paste ({len(text)} chars)")
return
except Exception as e:
logger.warning(f"Paste clipboard échoué ({e}) → fallback char-by-char")
# Chemin par défaut (ou fallback paste) : char-by-char
for char in text:
try:
# Taper le caractère via from_char (respecte le layout clavier)

View File

@@ -0,0 +1,88 @@
# agent_v1/tools/test_lea_pause_flow.py
"""Smoke test : simuler un lea:paused localement et vérifier la bulle ChatWindow.
À lancer SUR WINDOWS (PC démo) :
cd C:/rpa_vision
.venv\\Scripts\\python.exe -m agent_v1.tools.test_lea_pause_flow
Ce script ouvre une ChatWindow, simule l'arrivée d'un payload paused_need_help
avec un message LONG (350+ chars pour tester le scroll interne), puis attend
les clics utilisateur sur Continuer/Annuler. Le test vérifie qu'il y a UN SEUL
affichage (la bulle chat), pas de toast supplémentaire.
Exit code 0 si succès. Logs dans la console.
"""
import logging
import os
import sys
import time
# Configurer le logging avant tout import du package
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
)
logger = logging.getLogger("test_lea_pause_flow")
# Forcer le bus feedback (pour que les boutons puissent émettre vers
# rpa-agent-chat — port 5004). Si on ne veut PAS du bus, mettre LEA_FEEDBACK_BUS=0.
os.environ.setdefault("LEA_FEEDBACK_BUS", "1")
os.environ.setdefault("RPA_API_TOKEN", "") # à remplir si serveur exige Bearer
def main() -> int:
try:
from agent_v1.ui.chat_window import ChatWindow
except ImportError as e:
print(f"[TEST] Import ChatWindow KO : {e}")
return 2
print("[TEST] Création ChatWindow...")
cw = ChatWindow(
server_client=None,
on_start_callback=None,
server_host=os.environ.get("RPA_SERVER_HOST", "192.168.1.40"),
chat_port=5004,
)
# Attendre que le tk loop soit prêt
time.sleep(2.0)
cw.show()
time.sleep(0.5)
print("[TEST] Simulation lea:paused avec long message (350 chars)...")
long_msg = (
"Je n'arrive pas à trouver le champ « Numéro de dossier patient » "
"sur l'écran courant. J'ai essayé 3 stratégies de grounding visuel "
"(template matching, OCR, VLM) sans succès. Pouvez-vous me montrer "
"l'emplacement exact du champ, ou cliquer dessus à ma place ? "
"Quand vous avez fini, cliquez sur Continuer pour que je reprenne."
)
payload = {
"replay_id": "test_replay_pause_flow_001",
"workflow": "Démo UHCD",
"reason": long_msg,
"completed": 5,
"total": 12,
}
cw._add_paused_bubble(payload)
print(f"[TEST] Bulle envoyée. Message len={len(long_msg)} chars.")
print("[TEST] Vérifiez visuellement :")
print(" 1) UN SEUL popup (la bulle chat dans la fenêtre Léa)")
print(" 2) Le message long s'affiche en intégralité (scroll interne si besoin)")
print(" 3) Boutons Continuer / Annuler visibles")
print(" 4) Cliquez sur Annuler → bulle fermée + feedback '✗ Annulé'")
print("[TEST] La fenêtre reste ouverte 30s. Ctrl+C pour quitter avant.")
try:
for i in range(30):
time.sleep(1.0)
except KeyboardInterrupt:
print("[TEST] Interruption clavier.")
print("[TEST] Test terminé. Vérifier visuellement les 4 points ci-dessus.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -833,6 +833,9 @@ class ChatWindow:
font=FONT_TIMESTAMP, anchor="w",
).pack(fill=tk.X, anchor=tk.W, pady=(2, 0))
# UX fix 8 mai 2026 : auto-scroll après chaque bulle d'action
self._scroll_to_bottom()
# ------------------------------------------------------------------
# Bulle paused_need_help interactive (J3.5)
# ------------------------------------------------------------------
@@ -844,6 +847,9 @@ class ChatWindow:
(`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, ...)`.
UX fix 8 mai 2026 : un seul affichage (la bulle chat). Plus de toast
en double — on force juste la chat window au premier plan.
"""
if self._root is None:
return
@@ -854,23 +860,40 @@ class ChatWindow:
# 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)
# Bell sonore léger pour attirer l'attention (Tkinter natif)
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],
)
self._root.bell()
except Exception:
logger.debug("paused_toast launch silenced", exc_info=True)
pass
except Exception:
logger.debug("force-show chat_window silenced", exc_info=True)
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
# l'attention sur la question (clear visuel uniquement,
# self._messages reste intact pour la traçabilité debug).
self._clear_chat_history()
self._render_paused_bubble(payload)
self._root.after(0, _show_and_render)
def _clear_chat_history(self) -> None:
"""Vide la zone d'affichage du chat (widgets enfants de _msg_frame).
Détruit uniquement les widgets visuels — self._messages (liste mémoire)
reste intact pour la traçabilité debug. Cohérent avec _do_remove_typing
qui détruit aussi le widget sans toucher à l'historique.
"""
if getattr(self, "_msg_frame", None) is None:
return
try:
for child in list(self._msg_frame.winfo_children()):
child.destroy()
self._active_paused_bubble = None
if hasattr(self, "_typing_frame"):
self._typing_frame = None
self._scroll_to_bottom()
except Exception:
logger.debug("clear chat history silenced", exc_info=True)
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
tk = self._tk
if getattr(self, "_msg_frame", None) is None:
@@ -898,11 +921,30 @@ class ChatWindow:
font=("Segoe UI", 12, "bold"), anchor="w",
).pack(fill=tk.X, anchor=tk.W)
tk.Label(
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wraplength=MSG_WRAP_WIDTH - 30,
anchor="w", justify=tk.LEFT,
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
reason_str = str(reason)
# Estimation simple : ~70 chars/ligne avec wraplength
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
reason_text = tk.Text(
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_MSG, wrap=tk.WORD, bd=0, height=approx_lines,
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
)
reason_text.insert("1.0", reason_str)
reason_text.configure(state="disabled")
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Scrollbar interne uniquement si le contenu déborde (long messages)
if len(reason_str) > 280:
reason_scroll = tk.Scrollbar(
msg_frame, orient=tk.VERTICAL,
command=reason_text.yview, width=8,
)
reason_text.configure(yscrollcommand=reason_scroll.set)
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
tk.Label(
inner, text=f"{workflow} — étape {completed}/{total}",
@@ -930,12 +972,23 @@ class ChatWindow:
)
btn_abort.pack(side=tk.LEFT)
# Zone de feedback (mise à jour après clic, avant l'ack du bus)
feedback_label = tk.Label(
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
font=FONT_TIMESTAMP, anchor="w",
)
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
self._active_paused_bubble = {
"container": container, "inner": inner,
"btn_resume": btn_resume, "btn_abort": btn_abort,
"feedback_label": feedback_label,
"replay_id": replay_id,
}
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
self._scroll_to_bottom()
def _close_active_paused_bubble(self, reason: str) -> None:
if self._active_paused_bubble is None or self._root is None:
return
@@ -961,28 +1014,79 @@ class ChatWindow:
self._active_paused_bubble = None
def _on_paused_resume(self, replay_id: str) -> None:
if not replay_id or self._bus is None or not self._bus.connected:
self._add_lea_message("⚠ Bus indisponible — impossible de relancer")
"""Bouton Continuer : émettre lea:replay_resume + feedback immédiat UI.
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
arrière-plan ; si la connexion est tombée, on log un warning visible.
"""
if not replay_id:
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
return
self._bus.resume_replay(replay_id)
if self._active_paused_bubble:
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
pass
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.resume_replay(replay_id)
# Feedback immédiat : disable boutons + message
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("→ Reprise demandée…")
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
else:
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
logger.warning("paused_bubble: bus déconnecté, resume non émis")
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on resume silenced", exc_info=True)
def _on_paused_abort(self, replay_id: str) -> None:
if self._bus is None or not self._bus.connected:
self._add_lea_message("⚠ Bus indisponible — impossible d'annuler")
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
UX fix 8 mai 2026 : on ferme la bulle localement dès le clic (le serveur
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
locale la bulle restait coincée — c'était la cause de "Annuler ne
fonctionne pas" rapportée par Dom).
"""
emitted = False
if self._bus is not None and self._bus.connected:
emitted = self._bus.abort_replay(replay_id)
self._disable_paused_buttons()
if emitted:
self._update_paused_feedback("✗ Annulé")
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
else:
self._update_paused_feedback("✗ Annulé (bus indisponible)")
logger.warning("paused_bubble: bus déconnecté, abort non émis")
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
self._close_active_paused_bubble(reason="abort_local")
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
# avec _on_paused_resume, demandé explicitement par Dom).
try:
self._root.after(500, self._do_hide)
except Exception:
logger.debug("auto-hide on abort silenced", exc_info=True)
def _disable_paused_buttons(self) -> None:
if not self._active_paused_bubble:
return
self._bus.abort_replay(replay_id)
if self._active_paused_bubble:
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
pass
try:
self._active_paused_bubble["btn_resume"].config(state="disabled")
self._active_paused_bubble["btn_abort"].config(state="disabled")
except Exception:
logger.debug("disable paused buttons silenced", exc_info=True)
def _update_paused_feedback(self, text: str) -> None:
if not self._active_paused_bubble:
return
label = self._active_paused_bubble.get("feedback_label")
if label is None:
return
try:
label.config(text=text)
except Exception:
logger.debug("update paused feedback silenced", exc_info=True)
# ======================================================================
# Ajout de messages dans la zone de chat

View File

@@ -140,27 +140,18 @@ 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.
UX fix 8 mai 2026 (démo GHT) : la bulle ChatWindow Léa V1 (Tkinter
topmost + bell + force-show) est désormais l'affichage canonique pour
les BLOCAGE de pause supervisée. On NE déclenche PLUS show_paused_toast
depuis ici — Dom rapportait 3 popups en parallèle (toast executor,
toast bubble, toast notifications). Plyer reste actif comme
notification système discrète. Le toast Tkinter custom est conservé
pour les fallbacks sans ChatWindow (cf. executor.Plan B).
"""
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,

View File

@@ -227,6 +227,9 @@ from .replay_engine import (
_handle_extract_text_action,
_handle_extract_table_action,
_handle_t2a_decision_action,
_handle_llm_generate_action,
_handle_concat_text_vars_action,
_handle_paste_and_execute_action,
_expand_compound_steps,
_pre_check_screen_state as _pre_check_screen_state_impl,
_detect_popup_hint as _detect_popup_hint_impl,
@@ -2946,7 +2949,8 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
if not queue:
return {"action": None, "session_id": session_id, "machine_id": machine_id}
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision)
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision,
# llm_generate)
# exécutées entièrement côté serveur jusqu'à trouver une action visuelle
# à transmettre à l'Agent V1 ou un pause_for_human qui bloque le replay.
action = None
@@ -3006,7 +3010,18 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
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}
# Inclure replay_paused/pause_message/replay_id dès ce return
# pour que la bulle Léa s'affiche au PREMIER polling (sinon
# gap 1-2s où le chat est vide). Aligné sur la branche
# target_not_found ligne 2869-2876.
return {
"action": None,
"session_id": session_id,
"machine_id": machine_id,
"replay_paused": True,
"pause_message": owning_replay.get("pause_message", "Validation requise"),
"replay_id": owning_replay["replay_id"],
}
# Mode autonome sans safety_checks → skip (comportement legacy)
logger.info(
@@ -3056,6 +3071,39 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
),
timeout=180,
)
elif type_ == "llm_generate":
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_llm_generate_action,
action, owning_replay,
),
timeout=180,
)
elif type_ == "_concat_text_vars":
# Action serveur interne (générée par expansion
# extract_text_scroll). Concat 2 vars runtime → 1 var finale.
# Quasi-instantanée (pas de I/O), mais on la gère via le
# même chemin pour cohérence (timeout généreux par sécurité).
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_concat_text_vars_action,
action, owning_replay,
),
timeout=10,
)
elif type_ == "paste_and_execute":
# Invoque scripts/paste_and_execute_linuxdb.sh
# (Ctrl+V + Ctrl+Enter via ydotool dans la VM).
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_paste_and_execute_action,
action, owning_replay,
),
timeout=30,
)
except Exception as e:
logger.warning(f"Action serveur {type_} a levé : {e}")
queue.pop(0)

View File

@@ -41,6 +41,7 @@ _ALLOWED_ACTION_TYPES = {
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
"llm_generate", # Génération texte libre côté serveur → variable workflow
"paste_and_execute", # Bypass NoMachine : ydotool Ctrl+V+Ctrl+Enter dans VM via SSH
}
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
@@ -52,6 +53,7 @@ _SERVER_SIDE_ACTION_TYPES = {
"t2a_decision",
"llm_generate",
"_concat_text_vars",
"paste_and_execute",
}
# Pause par défaut entre Ctrl+End/Home et la capture suivante (ms).
@@ -890,6 +892,11 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
}
return [normalized]
elif action_type == "paste_and_execute":
normalized["type"] = "paste_and_execute"
normalized["parameters"] = {}
return [normalized]
elif action_type == "extract_table":
normalized["type"] = "extract_table"
normalized["parameters"] = {
@@ -1216,6 +1223,18 @@ def _handle_t2a_decision_action(
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
model = params.get("model") or None # None → DEFAULT_MODEL
# Bypass LLM : si static_result est fourni dans les params, on l'utilise
# tel quel comme résultat. Utile pour les démos déterministes (pas de
# hallucination, pas de latence, pas de truncation de prompt).
static_result = params.get("static_result")
if isinstance(static_result, dict) and static_result.get("decision"):
replay_state.setdefault("variables", {})[output_var] = static_result
logger.info(
"t2a_decision (STATIC) → variable '%s' decision=%s replay %s",
output_var, static_result.get("decision"), replay_state.get("replay_id", "?"),
)
return True
if not dpi_text:
logger.warning(
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
@@ -1289,6 +1308,18 @@ def _handle_llm_generate_action(
or params.get("variable_name")
or "generated_text"
).strip()
# Bypass LLM : si static_text est fourni dans les params, on l'utilise
# tel quel. Utile pour les démos déterministes.
static_text = params.get("static_text")
if isinstance(static_text, str) and static_text.strip():
replay_state.setdefault("variables", {})[output_var] = static_text
logger.info(
"llm_generate (STATIC) → variable '%s' (%d chars) replay %s",
output_var, len(static_text), replay_state.get("replay_id", "?"),
)
return True
prompt = str(params.get("prompt") or "").strip()
context = str(params.get("context") or "")
model = params.get("model") or None
@@ -1384,6 +1415,40 @@ def _handle_concat_text_vars_action(
return bool(merged)
def _handle_paste_and_execute_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
) -> bool:
"""Action serveur : invoque scripts/paste_and_execute_linuxdb.sh pour
déclencher Ctrl+V + Ctrl+Enter dans DBeaver de la VM via ydotool.
Bypasse Léa/NoMachine (Ctrl mangé par NoMachine passive grab).
Cf. handoff 2026-05-16_handoff_ydotool_clipboard.md.
"""
import subprocess
script_path = "/home/dom/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh"
try:
result = subprocess.run(
[script_path],
timeout=30,
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning(
"paste_and_execute échoué (rc=%d) stderr=%s",
result.returncode, (result.stderr or "")[:500],
)
return False
logger.info("paste_and_execute OK replay %s", replay_state.get("replay_id", "?"))
return True
except subprocess.TimeoutExpired:
logger.warning("paste_and_execute timeout (30s)")
return False
except Exception as e:
logger.warning("paste_and_execute exception : %s", e)
return False
def _expand_extract_text_scroll(
base: Dict[str, Any],
final_var: str,

View File

@@ -127,7 +127,7 @@ def _resolve_by_template_matching(
best_scale = 1.0
best_anchor_size = (aw, ah)
for scale in [1.0, 0.9, 1.1, 0.8, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 1.75, 2.0]:
for scale in [1.0, 0.95, 0.93, 0.9, 1.05, 1.1, 0.85, 0.8, 1.15, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 0.4, 0.35, 0.3, 0.25, 1.75, 2.0]:
if scale != 1.0:
new_w = int(aw * scale)
new_h = int(ah * scale)
@@ -2107,7 +2107,10 @@ _RESOLUTION_MIN_SCORES: Dict[str, float] = {
# Écart maximum toléré entre coords résolues et coords enregistrées
# (en fraction d'écran, dans chaque axe). Au-delà, on considère que la
# résolution a trouvé un faux positif ailleurs sur l'écran.
_RESOLUTION_MAX_DRIFT: float = 0.20
# 15 mai 2026 : élargi 0.20 → 0.95 pour respecter le principe "100% vision"
# (Dom). Le score template/VLM seul est désormais la source de confiance,
# la position de la capture initiale n'est plus contraignante.
_RESOLUTION_MAX_DRIFT: float = 0.95
# ===========================================================================