feat: Léa UX — messages français naturels + feedback temps réel

Aspect 1/4 de Léa (agent Windows) : rendre Léa humaine.

Nouveaux modules :
- agent_v1/ui/messages.py : 11 formatters (cible non trouvée, mauvaise fenêtre,
  écran inchangé, connexion, workflow, retry, ralentissement, erreur générique)
- agent_v1/ui/activity_panel.py : panneau tkinter lazy avec état courant,
  action, progression X/Y, temps écoulé, 7 états (OBSERVE/CHERCHE/AGIT/VERIFIE...)

Hiérarchie de notifications :
- INFO (4s, vert) — début workflow, étape en cours
- ATTENTION (7s, orange) — retry, ralentissement
- BLOCAGE (15s, rouge, persistent, bypass rate-limit) — cible introuvable, mauvaise fenêtre

Transformations de messages :
  AVANT : "target_not_found: dans *bonjour, – Bloc-notes"
  APRÈS : "Léa a besoin d'aide"
          "Je ne trouve pas « bonjour » dans Bloc-notes.
           Peux-tu cliquer dessus toi-même ? Je reprends ensuite."

Robustesse :
- Détection fenêtre Léa via regex word-boundaries (évite cléa.txt, leapfrog.exe)
- Centralisée dans messages.est_fenetre_lea() — source unique de vérité
- Noop stub universel via __getattr__ (plus besoin de lister les méthodes)
- Thread-safe (RLock + snapshots immutables)
- Fallback silencieux si tkinter/plyer absent

101 nouveaux tests, aucune régression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 08:42:01 +02:00
parent f6ad5ff2b2
commit a6eb4c168f
6 changed files with 1864 additions and 72 deletions

View File

@@ -38,8 +38,19 @@ except (ImportError, ValueError):
except ImportError:
LeaServerClient = None
# Configuration du logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# Configuration du logging — format structuré et lisible pour un TIM
# Niveau de détail : INFO par défaut, DEBUG si RPA_AGENT_DEBUG=1
_log_level = logging.DEBUG if os.environ.get("RPA_AGENT_DEBUG") == "1" else logging.INFO
logging.basicConfig(
level=_log_level,
format="%(asctime)s %(levelname)-7s %(name)-25s %(message)s",
datefmt="%H:%M:%S",
)
# Réduire le bruit de certaines libs
for _noisy in ("urllib3", "requests.packages.urllib3", "PIL", "mss"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# Intervalle de polling replay (secondes)
@@ -371,12 +382,22 @@ class AgentV1:
time.sleep(5)
def stop_session(self):
# Arrêter la capture et le streaming de la session d'enregistrement
if self.captor: self.captor.stop()
if self.streamer: self.streamer.stop()
logger.info(f"Session {self.session_id} terminée.")
# Sauvegarder le session_id avant de l'annuler (pour les logs)
ended_session_id = self.session_id
# Reset le session_id pour que le poll replay utilise l'ID stable
# Arrêter la capture d'abord (plus d'events entrants)
if self.captor: self.captor.stop()
# Attendre que les events en cours de traitement dans _on_event_bridge
# aient le temps d'être envoyés au streamer (capture duale + push)
import time
time.sleep(1.5)
# Maintenant arrêter le streamer (drain queue + finalize)
if self.streamer: self.streamer.stop()
logger.info(f"Session {ended_session_id} terminée.")
# Reset le session_id APRÈS le stop complet du streamer
self.session_id = None
# Reset le backoff de l'executor pour reprendre le polling immédiatement
@@ -403,6 +424,7 @@ class AgentV1:
"""Capture périodique pour donner du contexte au stagiaire.
Déduplication : n'envoie que si l'écran a changé.
Tourne tant que session_id est défini (= enregistrement actif).
Enrichi avec le titre de la fenêtre active pour contextualisation.
"""
while self.running and self.session_id:
try:
@@ -413,7 +435,17 @@ class AgentV1:
if img_hash != self._last_heartbeat_hash:
self._last_heartbeat_hash = img_hash
self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}")
self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id})
heartbeat_event = {
"type": "heartbeat",
"image": full_path,
"timestamp": time.time(),
"machine_id": self.machine_id,
}
# Ajouter le titre de la fenêtre active (léger, pas de crop)
window_title = self.vision.get_active_window_title()
if window_title:
heartbeat_event["active_window_title"] = window_title
self.streamer.push_event(heartbeat_event)
except Exception as e:
logger.error(f"Heartbeat error: {e}")
time.sleep(5)
@@ -448,20 +480,33 @@ class AgentV1:
event["screenshot_context"] = full_path
self.streamer.push_image(full_path, f"focus_{int(time.time())}")
# 🔴 Capture Interactive (Dual)
# Capture Interactive (Dual + Fenêtre active)
if event["type"] in ["mouse_click", "key_combo"]:
self.shot_counter += 1
shot_id = f"shot_{self.shot_counter:04d}"
pos = event.get("pos", (0, 0))
capture_info = self.vision.capture_dual(pos[0], pos[1], shot_id)
event["screenshot_id"] = shot_id
event["vision_info"] = capture_info
# Enrichir l'event avec les métadonnées de la fenêtre active
# (titre, rect, coordonnées clic relatives, taille fenêtre)
window_capture = capture_info.get("window_capture")
if window_capture:
event["window_capture"] = {
"title": window_capture.get("window_title", ""),
"app_name": window_capture.get("app_name", ""),
"rect": window_capture.get("window_rect"),
"click_relative": window_capture.get("click_in_window"),
"window_size": window_capture.get("window_size"),
"click_inside_window": window_capture.get("click_inside_window", True),
}
self._stream_capture_info(capture_info, shot_id)
# 🕒 POST-ACTION : Capture du résultat après 1s (pour voir le résultat du clic)
# POST-ACTION : Capture du résultat après 1s (pour voir le résultat du clic)
threading.Timer(1.0, self._capture_result, args=(shot_id,)).start()
self.ui.update_stats(self.shot_counter)
@@ -481,6 +526,12 @@ class AgentV1:
self.streamer.push_image(capture_info["full"], f"{shot_id}_full")
if "crop" in capture_info:
self.streamer.push_image(capture_info["crop"], f"{shot_id}_crop")
# Streamer l'image de la fenêtre active si disponible
window_capture = capture_info.get("window_capture")
if window_capture and "window_image" in window_capture:
self.streamer.push_image(
window_capture["window_image"], f"{shot_id}_window"
)
def run(self):
self.ui.run()