11 Commits

Author SHA1 Message Date
Dom
7847a0e829 feat(agent_v1): toast paused supervisée Tkinter + Plan B + threshold FIND-TEXT 0.75
Some checks failed
tests / Lint (ruff + black) (push) Successful in 16s
tests / Tests unitaires (sans GPU) (push) Failing after 13s
tests / Tests sécurité (critique) (push) Has been skipped
Démo GHT 8 mai 2026 — Dom utilise UNIQUEMENT Léa V1 sur Windows pendant
la démo (pas le frontend VWB Linux), donc les pause_message du serveur
doivent être visuellement évidents sur l'écran Windows. Modifications
client validées par Dom + redéployées via SCP (procédure 2026-04-28).

1. ui/paused_toast.py (NEW) — Toast Tkinter custom autonome :
   Toplevel topmost overrideredirect, fond bleu Léa (#2563EB), 380px,
   haut-droite, auto-close 15s, click-to-close. Re-pin -topmost à
   100/500/2000 ms (Windows démet le flag quand le focus part). Rate
   limit 3s sur message identique. Aucune dépendance externe (tkinter
   stdlib uniquement). Thread-safe : root.after si Tk root existe,
   sinon Tk dédié dans un daemon thread. Remplace plyer qui s'avère
   silencieux sur Windows 11 (Focus Assist + manque app-id COM).

2. ui/chat_window.py — _add_paused_bubble force la visibilité :
   La fenêtre Léa démarrait avec root.withdraw() — la bulle paused
   était bien rendue mais invisible. Ajout deiconify+lift+focus_force
   avant render, plus appel à show_paused_toast en complément.

3. ui/notifications.py — niveau BLOCAGE déclenche aussi le toast :
   Quand notify_message reçoit un MessageUtilisateur.BLOCAGE (cible
   non trouvée, mode apprentissage, fenêtre incorrecte), appelle
   show_paused_toast en plus de plyer. Couvre la branche supervision
   client (executor.py:1012) qui ne passe pas par Plan B serveur.

4. core/executor.py — Plan B replay_paused (lignes 1812-1850) :
   Intercepte data["replay_paused"]=True dans la réponse /replay/next,
   appelle chat_window._add_paused_bubble si _chat_window_ref défini,
   sinon fallback notifier.notify. Idempotence via _last_pause_msg_shown
   pour ne pas spammer (1 toast par (replay_id, message) unique).
   Threshold FIND-TEXT _find_text_on_screen : 0.50 → 0.75 pour rejeter
   les faux positifs (placeholders italiques, tabs voisins) et tomber
   en mode apprentissage humain plutôt qu'un clic au pif.

5. main.py — Wiring ChatWindow → Executor pour Plan B.

6. tools/test_lea_toast.py + ui/_test_paused_toast.py (NEW) — Scripts
   de test isolé pour validation visuelle rapide sans relancer un
   replay complet (commande dans les docstrings).

Validé visuellement sur DESKTOP-58D5CAC. Toasts apparaissent en haut-
droite, fond bleu, auto-close 15s. Test isolé Dom : 3 toasts successifs
visibles sans accroc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:03:51 +02:00
Dom
40440f1ca0 fix(replay): cure régression b584bbabc — fallback recorded_coords aveugle
Trois changements complémentaires dans la cascade de résolution serveur,
finis ce soir 7 mai pour la démo GHT 8 mai. Restaure le comportement strict
d'avril 2026 (workflow qui passait 20 fois d'affilée sans incident).

1. resolve_engine.py — _validate_resolution_quality (lignes 2255-2289) :
   Le commit b584bbabc du 1er mai 2026 ("fix(stream): démo UHCD") avait
   transformé le rejet strict (resolved=False, method="rejected_drift_*")
   en fallback aveugle (resolved=True, method="fallback_recorded_coords",
   coords du record). Symptôme observé : Léa cliquait sur "Dossier en
   cours" du menu au lieu de "Synthèse Urgences" du tab — le VLM Quick
   Find Ollama hallucinait à (0.526, 0.918), drift dépassé, fallback
   ratait. Restauré : resolved=False explicite, le client passe en
   pause supervisée comme prévu (philosophie échec = apprentissage).

2. resolve_engine.py — exemption high-confidence élargie :
   L'exemption drift>0.20 IGNORÉ ne couvrait que template_matching ≥ 0.95
   (commit 35b27ae49 du 2 mai). Étendue à hybrid_text_direct ≥ 0.80 :
   un OCR direct qui trouve le texte cible exact à score 0.80+ est aussi
   sûr qu'un template à 0.95 — la position est sémantiquement vraie,
   le drift reflète juste un changement de layout (résolution écran,
   refonte UI, scroll), pas une erreur de résolution.

3. resolve_engine.py + api_stream.py — pré-check OCR sémantique :
   Nouvelle fonction _validate_text_at_position (singleton EasyOCR fr+en,
   crop 200px autour de la coord résolue, fuzzy match 60% des tokens
   ≥3 caractères de l'expected_text). Câblée dans api_stream.py juste
   après _validate_resolution_quality. Si le by_text attendu n'est PAS
   présent dans la zone autour de la coord résolue → resolved=False
   method="rejected_text_mismatch" → pause supervisée.

Pattern Verification-Aware Planning (state of the art 2026 — voir
recommandations agent archéologue + agent SOTA review) : le serveur
ne renvoie une coord que s'il est sémantiquement sûr du résultat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:03:18 +02:00
Dom
7233df2bb9 fix(replay): câblage execution_mode supervised + seuil large fallback heartbeat
Deux corrections liées au scenario démo Urgence GHT (workflow lecture
multi-onglets + t2a_decision + pause_for_human + saisies dans Codage) :

1. Mode supervised propagé jusqu'au pipeline replay
---------------------------------------------------

Symptôme constaté ce 7 mai : Léa lit les onglets, t2a_decision tourne
(variable `dec` présente avec decision="FORFAIT_URGENCE"), mais la
pause_for_human est SKIPPÉE silencieusement et les saisies type_text
s'enchaînent dans le mauvais écran.

Cause : api_stream.py:2140 passait `params={}` codé en dur lors de la
création du replay_state. Conséquence : le code en aval qui lit
`replay_state.params.execution_mode` (api_stream.py:2964) avait toujours
le défaut "autonomous" → branche QW4 :

    # Mode autonome sans safety_checks → skip (comportement legacy)
    logger.info("pause_for_human ignorée (mode autonome)")

Modifications :
- RawReplayRequest gagne un champ `params: Optional[Dict[str, Any]]`
- start_raw_replay propage `request.params or {}` à _create_replay_state
- dag_execute.execute_windows force par défaut
  `data['params']['execution_mode'] = 'supervised'` quand le frontend
  ne précise rien (cas démo VWB → Windows). Override possible.

Conséquence : la pause_for_human du workflow Urgence déclenche bien la
PauseDialog VWB ("Décision : {{dec.decision_court}}"). Le médecin valide
ou annule avant que les saisies type_text ne s'exécutent dans Codage.

Note pour la démo réelle (post-aujourd'hui) : le scénario crédible
veut que Léa soit déclenchée depuis SON chat (port 5004), pas depuis
VWB. C'est un autre commit à venir — pour l'instant VWB suffit pour
le développement (cf. handoff session).

2. Seuil détection image tronquée élargi
----------------------------------------

Le seuil initial (height < 200 OR width < 400) ne capturait que les
cas extrêmes 2560x60 / 600x72. Mais le client envoie aussi 622x856
(Edge en fenêtre réduite ?) qui passait sous le radar. Élargi à
height < 800 OR width < 1200 — un écran moderne fait toujours ≥
1920x1080, donc le seuil est sain.

Sans ce fallback élargi, _resolve_target_sync recevait une image
trop petite pour matcher l'anchor → cascade VLM hallucinante.
2026-05-07 10:34:29 +02:00
Dom
f62fda575f fix(stream): /resolve_target — fallback heartbeat full si image client tronquée
Bug client constaté ce 2026-05-07 sur PC Windows 192.168.1.11 (agent V1) :
mss.monitors[1] retourne parfois une image tronquée type 2560x60, 2560x108,
600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
ou un état mss corrompu. Reproduit même PC en mono physique. Cause exacte
non isolée côté client.

Sans cette image, _resolve_target_sync ne peut rien résoudre :
- Template matching échoue (anchor 104x31 vs image 600x72)
- OCR direct ne trouve pas la cible (texte hors de l'image tronquée)
- VLM Quick Find hallucine systématiquement la même position
- Fallback recorded_coords clique au mauvais endroit

Conséquence reproduite hier soir : "Léa clique partout au pif"
(cf. session_20260506_handoff_v2.md).

Filet de sécurité côté serveur : si l'image reçue est anormalement
tronquée (height < 200 ou width < 400), le serveur la remplace par le
dernier heartbeat full screen avant la cascade _resolve_target_sync.

Sources de fallback dans l'ordre :
1. _last_heartbeat (mémoire, peuplé par /stream/image en runtime)
2. Scan disque data/training/live_sessions/*/bg_*/shots/heartbeat_*.png
   (utile après restart serveur ou si l'agent V1 ne polle pas)

Validé en isolation : image tronquée 600x60 → fallback heartbeat 2560x1600
→ template matching score 0.999 → coords (0.0312, 0.3500) = exactement
la position de l'IPP cible '25003284' en première ligne d'Easily Assure.

Bug client à traiter post-démo. Le fallback heartbeat reste utile en
roadmap autonome (résilience aux états mss transitoires).

Note : également retiré un import os local redondant dans le finally
(masquait la variable globale et provoquait UnboundLocalError dans
le scope du bloc fallback).
2026-05-07 09:31:07 +02:00
Dom
22c0a2ba61 revert: désactiver self-healing Win+D auto (cercle vicieux)
Revert effectif du commit c969f93a2.

Le Win+D auto au retry 1 produit un cercle vicieux quand combiné avec
le VLM-first qui hallucine systématiquement (positions répétitives
type 0.529/0.874 avec confidence 0.93 sans justification) :

  click rate (cible mal localisée par VLM) → no_screen_change
  → Win+D auto → minimise Easily Assure
  → retry click → cible plus visible (Easily masquée par Win+D)
  → no_screen_change → Win+D encore → boucle infernale

Reproduit ce 2026-05-06 sur le workflow Urgence : 10 Win+D dispatchés
en moins de 2 minutes. Régression majeure ressentie par Dom :
"clic partout au pif, aucune action contrôlée".

L'idée du self-healing par gesture reste valide mais demande :
1. un déclenchement plus sélectif (genre overlay/popup détecté
   visuellement, pas no_screen_change générique)
2. ou un Alt+Tab plutôt que Win+D (fait passer la fenêtre arrière
   sans minimiser l'app cible)
3. ou une vraie analyse "y a-t-il une fenêtre qui obstrue ma cible"
   avant de décider du gesture

À retravailler post-démo avec un vrai détecteur d'obstruction.
2026-05-06 20:31:31 +02:00
Dom
6fdedbfe9d fix(vwb): execute-windows route vers la machine la plus active (pas alphabétique)
Quand le frontend ne passe pas de machine_id explicite, le backend VWB
auto-sélectionne une machine Windows en interrogeant /api/v1/traces/
stream/machines. Le code prenait la première de la liste sans tri, donc
l'ordre dépendait de l'ordre arbitraire renvoyé par le streaming server.

Conséquence reproduite ce 2026-05-06 : un replay du workflow Urgence a
été dispatché vers DESKTOP-ST3VBSD_windows alors que l'agent V1 actif
polait depuis DESKTOP-58D5CAC_windows. /replay/next ne dispatchait
aucune action puisque state.machine_id != polling_machine_id.
Symptôme côté Dom : "rien ne se passe sur Windows".

Correction : tri explicite par last_activity desc avant sélection.
La machine retenue est désormais celle qui a heartbeaté le plus
récemment (= celle qui POLLE actuellement le serveur).

Le workflow.machine_id (machine d'origine d'enregistrement) reste
distinct de la cible d'exécution : un workflow enregistré sur PC A
peut être rejoué sur PC B grâce au pipeline 100% visuel qui recalcule
anchors et coordonnées selon la résolution courante. C'était la
vraie intention architecturale, masquée par le bug de tri.
2026-05-06 20:23:44 +02:00
Dom
c969f93a23 fix(replay): self-healing Win+D auto au retry 1 (verification_failed)
Audit project-quality-guardian (2026-05-06) Cas #2 : le mécanisme
qui invoquait gesture_catalog.win_minimize_all (Win+D) en cas
d'échec de grounding a été archivé le 24/04 dans
_archive/dead_code_20260424/core/visual/rpa_integration_manager.py
(_attempt_self_healing_resolution). Le catalogue
agent_chat/gesture_catalog.py:84 reste intact mais orphelin —
aucun caller actif.

Conséquence : quand une fenêtre/popup obstrue la cible, Léa
retente N fois la même action ratée puis pose une pause supervisée,
alors qu'un Win+D ("Afficher le bureau") règle souvent le problème
en 200 ms.

L'audit proposait observe_reason_act.py mais ce module est utilisé
uniquement par /execute/instruction (lui aussi sans client actif,
Cas #10). Le bon point d'insertion dans le pipeline replay actif
est _schedule_retry (replay_engine.py) — la fonction qui construit
la liste d'actions à réinjecter en tête de queue avant chaque retry.

Modification :

Au next_retry == 1 ET reason in ("verification_failed",
"no_screen_change"), insertion en tête de queue de :

  1. Action key_combo {keys: ["super", "d"]} (format reconnu par
     agent_v1/core/executor.py:1151), tagué
     _recovery_gesture: "win_minimize_all" pour audit.
  2. Wait 500 ms pour laisser l'OS terminer l'animation Win+D.
  3. Le retry de l'action originale.

Au retry 2 et au-delà, comportement inchangé (wait 2s + retry).

Tests : 27/27 baseline sprint QW verts.
2026-05-06 19:27:16 +02:00
Dom
1cbec2806e fix(resolve): rebrancher hybrid_text_direct dans _resolve_target_sync
Audit project-quality-guardian (2026-05-06) : la fonction
_resolve_by_ocr_text (resolve_engine.py:1447) existait déjà mais
n'était appelée QUE depuis _resolve_with_precompiled_order (V4),
endpoint sans client côté frontend (Cas #5 du même audit). La
cascade legacy _resolve_target_sync sautait directement d'étape 0
(grounding-window) → étape 0' (template icônes) → étape 1 (VLM
Quick Find) sans tenter l'OCR direct.

Conséquence reproduite ce 2026-05-06 sur le workflow Urgence :
chaque action visuelle avec by_text payait 2-23 s de VLM Quick
Find (ui-tars-1.5-7b-q8_0 sur Ollama) au lieu de <500 ms d'OCR
direct, total replay > 10 min vs quelques secondes attendues.
Constat utilisateur : "habituellement on est plutôt à quelques
secondes". Régression silencieuse.

Modification :

Étape 0.5 ajoutée entre l'étape 0' (template icônes) et l'étape 1
(VLM Quick Find). Si by_text_strict est non vide, appel à
_resolve_by_ocr_text — fonction docTR existante, cache singleton
_V4_OCR_PREDICTOR, score 1.0 si match exact, 0.9 si mot exact,
0.8 si contenu. Seuil de retour : 0.80 (cohérent avec
_RESOLUTION_MIN_SCORES["hybrid_text_direct"]).

Le method retourné est rebadgé "hybrid_text_direct" pour cohérence
avec :
- _RESOLUTION_MIN_SCORES (seuil 0.80, ligne 2092)
- agent_v0/agent_v1/core/executor.py:1534 (client Windows)
- logs Learning historiques ([hybrid_text_direct])

Tests : 39/39 sprint QW + grounding/resolver verts.
2026-05-06 19:24:53 +02:00
Dom
864530c851 fix(stream): _async_replay_lock helper + 17 endpoints async non-bloquants
Suite directe des commits 35b27ae49 (lock async sur /replay/next) et
87dbe8c5f (get_replay_status non-bloquant) qui n'avaient traité que
2 endpoints sur les 19 utilisant _replay_lock dans api_stream.py.

Reproduit aujourd'hui en pré-démo : un replay urgences a réussi
extract_text + t2a_decision (50s, OK), puis a hang sur l'action
suivante. start_raw_replay (POST /replay) du nouveau replay a tenté
`with _replay_lock:` synchrone à la ligne 2085 → MainThread asyncio
gelé → tous les endpoints derrière. Stack via py-spy confirmée.

Le pattern systémique : 17 sites `with _replay_lock:` synchrones
dans des handlers `async def` (start_replay, start_raw_replay,
replay_from_session, enqueue_single_action, launch_replay_from_plan,
get_next_action [×3], report_action_result [×5], register_error_callback,
list_replays, resume_replay, cancel_replay). Chacun gèle l'event
loop FastAPI dès qu'un autre thread tient le lock.

Modifications :

1. Helper _async_replay_lock(timeout=4.5) (api_stream.py:516).
   Acquire via run_in_executor (event loop libre pendant l'attente),
   timeout 4.5s puis HTTPException 503 plutôt que gel infini.
   Sémantique acquire+release identique au `with` synchrone.

2. Remplacement automatisé des 17 sites async :
   `with _replay_lock:` → `async with _async_replay_lock():`
   2 sites sync intentionnellement préservés (cleanup loop ligne 689,
   chat_status_provider ligne 5048 — pas dans des handlers async).

3. Import contextlib ajouté en haut du fichier.

Tests : 27/27 baseline sprint QW verts, /health 200 (3ms),
/replays 200 (2ms — endpoint qui utilise le nouveau helper).
2026-05-06 18:06:42 +02:00
Dom
d1ebf62217 fix(infra): durcissement headless — pyautogui robuste + cleanup .service
Suite à la mise à jour système qui a basculé Dom de Xorg vers Wayland,
les 4 services systemd côté serveur partaient en boucle restart :
pyautogui levait DisplayConnectionError / KeyError(DISPLAY) à l'import
dans 3 modules, mais l'except n'attrapait qu'ImportError → crash fatal.

Le contournement « ajouter DISPLAY=:1 + XAUTHORITY=/run/user/1000/gdm/
Xauthority dans .service » introduit fin avril était fragile : chemin
invalide en Wayland (Mutter utilise un Xauthority à suffixe aléatoire
qui change à chaque login). Le bon fix est de rendre les imports
pyautogui robustes — le serveur n'a aucun usage légitime de pyautogui,
c'est le client Agent V1 Windows qui pilote souris/clavier.

Modifications :

1. Élargi `except ImportError` → `except Exception` pour pyautogui :
   - agent_chat/autonomous_planner.py
   - core/execution/input_handler.py
   - core/execution/observe_reason_act.py
   (action_executor.py était déjà robuste avec except Exception.)

2. Retiré DISPLAY/XAUTHORITY des 4 .service (rustines) :
   - rpa-streaming.service
   - rpa-vision-v3-{api,worker,dashboard}.service
   Block grounding (RPA_GROUNDING_SOCKET) préservé (initiative
   séparée de partage VRAM, in-flight).

PYAUTOGUI_AVAILABLE=False est désormais attendu côté serveur Linux ;
les chemins aval (action_executor, autonomous_planner) gèrent déjà
ce cas via des branches "actions simulées" / "pyautogui non disponible".

Prépare la roadmap autonome (Léa daemon Linux + VM Windows) qui
tournera headless via systemd au boot, sans dépendre d'aucune
session graphique active.

Tests : 27/27 baseline sprint QW verts.
2026-05-06 17:19:18 +02:00
Dom
87dbe8c5ff fix(stream): get_replay_status non-bloquant + bornage actions serveur
Suite du commit 35b27ae49 (lock async sur /replay/next) qui n'avait
traité que la moitié du problème. Le sprint QW4 (commit f5c33477f)
a recâblé le polling frontend PauseDialog vers /replay/{replay_id} →
get_replay_status, qui gardait un `with _replay_lock:` synchrone.
Conséquence : dès qu'une action serveur (extract_text/extract_table/
t2a_decision) tient le lock, l'event loop FastAPI gèle entièrement
(heartbeats Windows, polls replay/next, get_replay_status, tout).

Reproduit aujourd'hui en pré-démo : un replay urgences a fait
extract_text → la queue suivante a tenu le lock → polling VWB sur
get_replay_status a bloqué le MainThread asyncio → 23 minutes de
gel total (py-spy a confirmé MainThread sur api_stream.py:4117).

Modifications :

1. get_replay_status : acquire timeboxé 0.5s via run_in_executor
   (même pattern que /replay/next ligne 2815). Si le lock est tenu,
   retour immédiat {status: "busy"} → le frontend retentera dans 1s.
   Aucun cas où ce poll bloque l'event loop.

2. Actions serveur lignes 2994/3000/3006 : enveloppées dans
   asyncio.wait_for(timeout=180). Borne dure pour qu'un hang
   d'EasyOCR / Ollama / I/O ne tienne plus jamais le lock
   indéfiniment. TimeoutError est rattrapée par l'except Exception
   existant → queue.pop(0) → on continue.

Tests : 27/27 baseline sprint QW verts.
2026-05-06 17:19:05 +02:00
18 changed files with 1044 additions and 80 deletions

View File

@@ -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

View File

@@ -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]:

View File

@@ -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"))

View File

View 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())

View 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())

View File

@@ -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

View File

@@ -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,

View 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"]

View File

@@ -9,6 +9,7 @@ Inclut les endpoints de replay pour renvoyer des ordres d'exécution à l'Agent
"""
import atexit
import contextlib
import json
import logging
import os
@@ -505,6 +506,33 @@ _pending_lock = threading.Lock()
# Chaque session a une queue d'actions à exécuter et un état de replay
# =========================================================================
_replay_lock = threading.Lock()
# Context manager async pour acquérir _replay_lock sans bloquer l'event loop
# FastAPI. Pattern complémentaire au commit 35b27ae49 (lock async sur
# /replay/next) et 87dbe8c5f (get_replay_status non-bloquant) : tous les
# endpoints `async def` qui faisaient `with _replay_lock:` synchrone gelaient
# l'event loop dès qu'une opération longue tenait le lock dans un autre
# thread. Avec ce helper, l'acquire passe par run_in_executor (l'event loop
# reste libre pour servir les autres requêtes pendant l'attente). Si le lock
# est tenu plus de `timeout` secondes, on retourne 503 plutôt que de geler le
# serveur.
@contextlib.asynccontextmanager
async def _async_replay_lock(timeout: float = 4.5):
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, timeout)
if not acquired:
raise HTTPException(
status_code=503,
detail=f"Serveur occupé (lock _replay tenu > {timeout}s) — réessayer",
)
try:
yield
finally:
_replay_lock.release()
# session_id -> liste d'actions en attente (FIFO)
_replay_queues: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
# machine_id -> session_id (mapping pour le replay ciblé par machine)
@@ -535,6 +563,11 @@ class RawReplayRequest(BaseModel):
session_id: str = ""
machine_id: Optional[str] = None # Machine cible (multi-machine)
task_description: str = ""
# Paramètres runtime du replay (lus dans replay_state.params côté pipeline).
# Notamment execution_mode : "autonomous" (défaut, pause_for_human skippée)
# ou "supervised" (pause_for_human bloque jusqu'à validation humaine via
# PauseDialog VWB). Cf. replay_engine.py / api_stream.py:2964.
params: Optional[Dict[str, Any]] = None
class SingleActionRequest(BaseModel):
@@ -1982,7 +2015,7 @@ async def start_replay(request: ReplayRequest):
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
# Injecter les actions dans la queue de la session
with _replay_lock:
async with _async_replay_lock():
_replay_queues[session_id] = list(actions) # Remplacer la queue existante
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -2082,7 +2115,7 @@ async def start_raw_replay(request: RawReplayRequest):
session_obj = processor.session_manager.get_session(session_id)
resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default")
with _replay_lock:
async with _async_replay_lock():
# ── Nettoyage : annuler les replays bloqués pour cette machine ──
# Un replay en paused_need_help bloque tous les suivants.
# Quand on lance un nouveau replay, les anciens sont obsolètes.
@@ -2109,7 +2142,7 @@ async def start_raw_replay(request: RawReplayRequest):
workflow_id=f"free_task:{task[:50]}",
session_id=session_id,
total_actions=len(actions),
params={},
params=dict(request.params or {}),
machine_id=resolved_machine_id,
actions=actions,
)
@@ -2302,7 +2335,7 @@ async def replay_from_session(
# ── 5. Injecter dans la queue de replay ──
replay_id = f"replay_sess_{uuid.uuid4().hex[:8]}"
with _replay_lock:
async with _async_replay_lock():
_replay_queues[target_session_id] = list(actions)
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -2393,7 +2426,7 @@ async def enqueue_single_action(request: SingleActionRequest):
action_id = action["action_id"]
with _replay_lock:
async with _async_replay_lock():
_replay_queues[session_id].append(action)
logger.info(
@@ -2559,7 +2592,7 @@ async def launch_replay_from_plan(request: PlanReplayRequest):
or (session_obj.machine_id if session_obj else "default")
)
with _replay_lock:
async with _async_replay_lock():
_replay_queues[target_session_id] = list(validated)
_replay_states[replay_id] = _create_replay_state(
replay_id=replay_id,
@@ -2988,25 +3021,40 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
# les autres polls (extract_text OCR ~5s, t2a_decision LLM ~8-13s).
# Le lock reste tenu (queue cohérente) mais l'event loop est libre,
# donc les polls concurrents peuvent recevoir {server_busy: True}.
#
# Borne dure 180s par action : un hang d'EasyOCR / Ollama / I/O
# ne doit JAMAIS pouvoir tenir _replay_lock indéfiniment, sinon
# tous les endpoints sous lock (get_replay_status, /replay/next…)
# gèlent le serveur. TimeoutError est rattrapée par l'except
# Exception ci-dessous → queue.pop(0) → on passe à la suite.
if type_ in _SERVER_SIDE_ACTION_TYPES and owning_replay is not None:
try:
if type_ == "extract_text":
await loop.run_in_executor(
None,
_handle_extract_text_action,
action, owning_replay, session_id, _last_heartbeat,
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_extract_text_action,
action, owning_replay, session_id, _last_heartbeat,
),
timeout=180,
)
elif type_ == "extract_table":
await loop.run_in_executor(
None,
_handle_extract_table_action,
action, owning_replay, session_id, _last_heartbeat,
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_extract_table_action,
action, owning_replay, session_id, _last_heartbeat,
),
timeout=180,
)
elif type_ == "t2a_decision":
await loop.run_in_executor(
None,
_handle_t2a_decision_action,
action, owning_replay,
await asyncio.wait_for(
loop.run_in_executor(
None,
_handle_t2a_decision_action,
action, owning_replay,
),
timeout=180,
)
except Exception as e:
logger.warning(f"Action serveur {type_} a levé : {e}")
@@ -3102,7 +3150,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
auth_actions = _auth_handler.get_auth_actions(auth_request)
if auth_actions:
# Injecter les actions d'auth en tête de queue (avant l'action bloquée)
with _replay_lock:
async with _async_replay_lock():
current_q = _replay_queues.get(session_id, [])
_replay_queues[session_id] = auth_actions + current_q
logger.info(
@@ -3111,7 +3159,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
f"type={auth_request.auth_type} (confiance={auth_request.confidence:.2f})"
)
# Retourner la première action d'auth immédiatement
with _replay_lock:
async with _async_replay_lock():
first_auth = _replay_queues[session_id].pop(0)
return {
"action": first_auth,
@@ -3159,7 +3207,7 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
}
# Pre-check OK (ou skip) : retirer l'action de la queue et l'envoyer
with _replay_lock:
async with _async_replay_lock():
current_queue = _replay_queues.get(session_id, [])
if current_queue and current_queue[0].get("action_id") == action.get("action_id"):
current_queue.pop(0)
@@ -3291,7 +3339,7 @@ async def report_action_result(report: ReplayResultReport):
)
# Trouver le replay correspondant à cette session
with _replay_lock:
async with _async_replay_lock():
replay_state = None
for state in _replay_states.values():
if state["session_id"] == session_id and state["status"] == "running":
@@ -3324,7 +3372,7 @@ async def report_action_result(report: ReplayResultReport):
# Mettre à jour le dernier screenshot reçu
screenshot_after = report.screenshot_after or report.screenshot
if screenshot_after:
with _replay_lock:
async with _async_replay_lock():
replay_state["last_screenshot"] = screenshot_after
# === Vérification post-action ===
@@ -3395,7 +3443,7 @@ async def report_action_result(report: ReplayResultReport):
# Stocker le screenshot actuel comme "before" pour la prochaine action
if screenshot_after:
with _replay_lock:
async with _async_replay_lock():
replay_state["_last_screenshot_before"] = screenshot_after
# [REPLAY] log structuré de la décision de vérification
@@ -3417,7 +3465,7 @@ async def report_action_result(report: ReplayResultReport):
)
# === Enregistrer le résultat ===
with _replay_lock:
async with _async_replay_lock():
result_entry = {
"action_id": action_id,
"success": report.success,
@@ -3577,7 +3625,7 @@ async def report_action_result(report: ReplayResultReport):
except Exception as _mem_exc:
logger.debug("Memory record skipped : %s", _mem_exc)
with _replay_lock:
async with _async_replay_lock():
# === Logique de retry / success / failure ===
if report.success and (verification is None or verification.verified):
# Action réussie (vérification OK ou pas de vérification)
@@ -4089,7 +4137,7 @@ async def register_error_callback(config: ErrorCallbackConfig):
replay_id = config.replay_id
callback_url = config.callback_url
with _replay_lock:
async with _async_replay_lock():
if replay_id not in _replay_states:
raise HTTPException(
status_code=404,
@@ -4113,34 +4161,52 @@ async def get_replay_status(replay_id: str):
Quand le replay est en pause supervisee (paused_need_help), la reponse
inclut le contexte complet de l'echec : action echouee, screenshot,
target_spec, et message utilisateur.
Endpoint poll-friendly : l'acquisition du lock est timeboxée à 0.5 s.
Si une action serveur lente (extract_text/extract_table/t2a_decision)
tient le lock, le poll repart immédiatement avec status="busy" plutôt
que de bloquer l'event loop FastAPI (qui gèlerait l'ensemble des
endpoints jusqu'à libération). Suite logique du commit 35b27ae49 qui
avait déjà appliqué ce pattern à /replay/next ; QW4 a recâblé le
polling frontend ici → même classe de bug, même remède.
"""
with _replay_lock:
import asyncio
loop = asyncio.get_event_loop()
acquired = await loop.run_in_executor(None, _replay_lock.acquire, True, 0.5)
if not acquired:
return {
"replay_id": replay_id,
"status": "busy",
"message": "Serveur occupé (action en cours), réessaie dans 1s",
}
try:
state = _replay_states.get(replay_id)
if not state:
raise HTTPException(
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
)
if not state:
raise HTTPException(
status_code=404, detail=f"Replay '{replay_id}' non trouvé"
)
# Filtrer les champs internes (prefixes par _)
result = {k: v for k, v in state.items() if not k.startswith("_")}
# Filtrer les champs internes (prefixes par _)
result = {k: v for k, v in state.items() if not k.startswith("_")}
# Enrichir avec le contexte de pause si applicable
if state["status"] == "paused_need_help":
session_id = state["session_id"]
remaining = len(_replay_queues.get(session_id, []))
result["actions_completed"] = state["completed_actions"]
result["actions_remaining"] = remaining
result["message"] = state.get("pause_message", "Replay en pause")
# Le failed_action contient deja screenshot_b64 et target_spec
# Enrichir avec le contexte de pause si applicable
if state["status"] == "paused_need_help":
session_id = state["session_id"]
remaining = len(_replay_queues.get(session_id, []))
result["actions_completed"] = state["completed_actions"]
result["actions_remaining"] = remaining
result["message"] = state.get("pause_message", "Replay en pause")
# Le failed_action contient deja screenshot_b64 et target_spec
return result
return result
finally:
_replay_lock.release()
@app.get("/api/v1/traces/stream/replays")
async def list_replays():
"""Lister tous les replays (actifs, terminés, en erreur)."""
with _replay_lock:
async with _async_replay_lock():
# Filtrer les champs internes (préfixés par _)
return {
"replays": [
@@ -4172,7 +4238,7 @@ async def resume_replay(
`required` doivent figurer dans `acknowledged_check_ids`. Sinon → 400
avec `{"error": "required_checks_missing", "missing": [...]}`.
"""
with _replay_lock:
async with _async_replay_lock():
state = _replay_states.get(replay_id)
if not state:
@@ -4268,7 +4334,7 @@ async def resume_replay(
@app.post("/api/v1/traces/stream/replay/{replay_id}/cancel")
async def cancel_replay(replay_id: str):
"""Annuler un replay (quel que soit son statut) et vider sa queue."""
with _replay_lock:
async with _async_replay_lock():
state = _replay_states.get(replay_id)
if not state:
raise HTTPException(status_code=404, detail=f"Replay '{replay_id}' non trouvé")
@@ -4339,6 +4405,72 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"Décodage screenshot échoué: {e}")
return _fallback_response(request, "decode_error", str(e))
# Détection image tronquée + fallback heartbeat full screen.
# Bug client constaté ce 2026-05-07 (PC Windows 192.168.1.11, agent V1) :
# mss.monitors[1] retourne parfois une bande étroite type 2560x60, 2560x108,
# 600x72 — possiblement la barre des tâches Windows confondue avec un monitor,
# ou un état mss corrompu. Reproductible même PC en mono physique. Cause
# exacte non isolée côté client (cf. session_20260506_handoff_v2.md).
# Les heartbeats (capturer.py, chemin différent de executor.py) restent en
# full screen 2560x1600. On compense ici en remplaçant l'image tronquée
# par le dernier heartbeat avant la cascade _resolve_target_sync.
effective_w = request.screen_width
effective_h = request.screen_height
# Seuil large : un écran moderne fait 2560x1600 ou plus. Tout en dessous
# de 1200x800 est suspect — bug client mss.monitors[1] qui crop sur
# barre des tâches (2560x60), Edge fenêtré (622x856), etc.
if img.height < 800 or img.width < 1200:
logger.warning(
"[RESOLVE_TARGET] Image client tronquée %dx%d (declared %dx%d) — "
"fallback heartbeat full screen",
img.width, img.height, effective_w, effective_h,
)
# Source 1 : _last_heartbeat (mémoire, peuplé par /stream/image)
candidate_path = None
candidate_age_s = None
latest_hb = max(
(h for h in _last_heartbeat.values() if h.get("path")),
key=lambda h: h.get("timestamp", 0),
default=None,
)
if latest_hb and os.path.isfile(latest_hb["path"]):
candidate_path = latest_hb["path"]
candidate_age_s = time.time() - latest_hb.get("timestamp", time.time())
else:
# Source 2 : scan disque (utile après restart serveur, avant que
# _last_heartbeat ne se repeuple — ou si l'agent V1 ne polle pas)
try:
import glob as _glob
pattern = "/home/dom/ai/rpa_vision_v3/data/training/live_sessions/*/bg_*/shots/heartbeat_*.png"
all_files = _glob.glob(pattern)
files = [
f for f in all_files
if "_blurred" not in f and os.path.isfile(f)
]
logger.info(
"[RESOLVE_TARGET] Scan disque : %d match glob, %d non-blurred existants",
len(all_files), len(files),
)
if files:
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
candidate_path = files[0]
candidate_age_s = time.time() - os.path.getmtime(candidate_path)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Scan disque heartbeat échoué : %s", e)
if candidate_path:
try:
img = Image.open(candidate_path)
effective_w, effective_h = img.size
logger.info(
"[RESOLVE_TARGET] Heartbeat fallback OK : %s (%dx%d, age=%.1fs)",
candidate_path, effective_w, effective_h, candidate_age_s or -1,
)
except Exception as e:
logger.warning("[RESOLVE_TARGET] Ouverture heartbeat échouée : %s", e)
else:
logger.warning("[RESOLVE_TARGET] Aucun heartbeat disponible pour fallback")
# Sauver temporairement pour les analyseurs (ils attendent un chemin fichier)
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
img.save(tmp, format="JPEG", quality=90)
@@ -4354,8 +4486,8 @@ async def resolve_target(request: ResolveTargetRequest):
_resolve_target_sync,
tmp_path,
request.target_spec,
request.screen_width,
request.screen_height,
effective_w,
effective_h,
request.fallback_x_pct,
request.fallback_y_pct,
request.strict_mode,
@@ -4371,6 +4503,44 @@ async def resolve_target(request: ResolveTargetRequest):
request.fallback_y_pct,
)
# Pré-check sémantique post-cascade : OCR sur une zone autour de la
# coordonnée résolue pour vérifier que le by_text attendu y est bien
# présent. Attrape les cas où la cascade rend des coords plausibles
# mais pointant sur un autre élément (ex : clic sur "Dossier en cours"
# du menu au lieu de "Synthèse Urgences" du tab plus bas).
if result and result.get("resolved"):
_by_text = (request.target_spec.get("by_text") or "").strip()
if _by_text:
from agent_v0.server_v1.resolve_engine import _validate_text_at_position
_is_valid, _observed, _ocr_ms = _validate_text_at_position(
tmp_path,
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
_by_text,
effective_w,
effective_h,
)
if not _is_valid:
logger.warning(
"[REPLAY] Pre-check OCR REJET : '%s' attendu @ (%.4f, %.4f) "
"via %s mais OCR voit '%s' (%.0fms)",
_by_text[:40],
float(result.get("x_pct", 0) or 0),
float(result.get("y_pct", 0) or 0),
result.get("method", "?"),
_observed[:80],
_ocr_ms,
)
result = {
"resolved": False,
"method": "rejected_text_mismatch",
"reason": f"expected='{_by_text[:40]}' observed='{_observed[:60]}'",
"original_method": result.get("method"),
"original_score": result.get("score"),
"x_pct": None,
"y_pct": None,
}
# [REPLAY] log structuré de sortie résolution (après validation)
logger.info(
f"[REPLAY] RESOLVE_EXIT session={request.session_id} "
@@ -4386,7 +4556,8 @@ async def resolve_target(request: ResolveTargetRequest):
logger.error(f"[REPLAY] RESOLVE_EXCEPTION session={request.session_id} error={e}")
return _fallback_response(request, "analysis_error", str(e))
finally:
import os
# `os` est déjà importé en haut du fichier — pas de re-import local
# (sinon UnboundLocalError plus haut dans la fonction).
try:
os.unlink(tmp_path)
except OSError:

View File

@@ -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,
}

View File

@@ -19,7 +19,9 @@ logger = logging.getLogger(__name__)
try:
import pyautogui
PYAUTOGUI_AVAILABLE = True
except ImportError:
except Exception:
# pyautogui peut lever Xlib.error.DisplayConnectionError (pas un ImportError)
# quand X n'est pas accessible — typique d'un service systemd côté serveur.
PYAUTOGUI_AVAILABLE = False
try:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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