diff --git a/agent_v0/agent_v1/config.py b/agent_v0/agent_v1/config.py index 86a391e78..d54880034 100644 --- a/agent_v0/agent_v1/config.py +++ b/agent_v0/agent_v1/config.py @@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get( BASE_DIR = Path(__file__).resolve().parent # Endpoint du serveur Streaming (port 5005) +# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée). SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") +# Base sans /api/v1 — pour les routes à la racine (/health) +SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0] UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload" STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" +# Host Ollama — SÉPARÉ du serveur RPA. +# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy. +# Défaut : localhost (exécution locale ou accès LAN direct). +OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost") + # Token d'authentification API (doit correspondre au token du serveur) # Configurable via variable d'environnement RPA_API_TOKEN API_TOKEN = os.environ.get("RPA_API_TOKEN", "") diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py index 7f329dc54..6105b85c9 100644 --- a/agent_v0/agent_v1/core/executor.py +++ b/agent_v0/agent_v1/core/executor.py @@ -477,9 +477,15 @@ class ActionExecutorV1: }, headers=headers, timeout=10, + allow_redirects=False, ) - if resp.ok: + if resp.status_code in (301, 302, 307, 308): + logger.warning( + f"Redirection {resp.status_code} sur POST {url} — " + f"verifiez RPA_SERVER_URL (https:// si redirect)" + ) + elif resp.ok: data = resp.json() state = data.get("screen_state", "ok") if state != "ok": @@ -703,7 +709,11 @@ class ActionExecutorV1: f"attendu '{expected_title}' → mode apprentissage" ) try: - self.notifier.replay_wrong_window(current_title, expected_title) + self.notifier.replay_learning_mode( + raison="wrong_window", + target_description=expected_title, + window_title=current_title, + ) except Exception: pass @@ -935,9 +945,10 @@ class ActionExecutorV1: # et ne trouve toujours pas. L'humain doit montrer. print(f" [POLICY] Retry échoué → mode apprentissage") try: - self.notifier.replay_target_not_found( - target_desc, - target_spec.get("window_title", ""), + self.notifier.replay_learning_mode( + raison="retry_failed", + target_description=target_desc, + window_title=target_spec.get("window_title", ""), ) except Exception: pass @@ -993,9 +1004,10 @@ class ActionExecutorV1: # passe en mode capture et enregistre ce que # l'humain fait (mini-workflow de correction). try: - self.notifier.replay_target_not_found( - target_desc, - target_spec.get("window_title", ""), + self.notifier.replay_learning_mode( + raison="supervise", + target_description=target_desc, + window_title=target_spec.get("window_title", ""), ) except Exception: pass @@ -1221,7 +1233,9 @@ class ActionExecutorV1: f"je demande de l'aide" ) try: - self.notifier.replay_no_screen_change(action_type) + self.notifier.replay_learning_mode( + raison="no_screen_change", + ) except Exception: pass @@ -1377,7 +1391,13 @@ class ActionExecutorV1: try: print(f" [SERVER-RESOLVE] Appel serveur {server_url}...") - resp = _requests.post(url, json=payload, headers=headers, timeout=30) + resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False) + if resp.status_code in (301, 302, 307, 308): + logger.warning( + f"Redirection {resp.status_code} sur POST {url} — " + f"verifiez RPA_SERVER_URL (https:// si redirect)" + ) + return None if not resp.ok: logger.warning(f"Server resolve HTTP {resp.status_code}") return None @@ -1521,7 +1541,7 @@ class ActionExecutorV1: if not vlm_description: return None - ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost") + ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost") ollama_url = f"http://{ollama_host}:11434/api/chat" prompt = ( @@ -1657,7 +1677,7 @@ Example: x_pct=0.50, y_pct=0.30""" if anchor_b64: images.append(anchor_b64) - ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost") + ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost") ollama_url = f"http://{ollama_host}:11434/api/chat" # Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s @@ -1861,8 +1881,14 @@ Example: x_pct=0.50, y_pct=0.30""" json=report, headers=self._auth_headers(), timeout=10, + allow_redirects=False, ) - if resp2.ok: + if resp2.status_code in (301, 302, 307, 308): + logger.warning( + f"Redirection {resp2.status_code} sur POST {replay_result_url} — " + f"verifiez RPA_SERVER_URL (https:// si redirect)" + ) + elif resp2.ok: server_resp = resp2.json() msg = ( f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, " @@ -2128,7 +2154,7 @@ Example: x_pct=0.50, y_pct=0.30""" """ import requests as _requests - ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost") + ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost") ollama_url = f"http://{ollama_host}:11434/api/chat" prompt = ( @@ -2575,8 +2601,8 @@ Example: x_pct=0.50, y_pct=0.30""" f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)" ) print( - f" [APPRENTISSAGE] Montre-moi comment faire.\n" - f" Quand tu as fini → Ctrl+Shift+L\n" + f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n" + f" Quand vous avez fini → Ctrl+Shift+L\n" f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)" ) diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 89e70006c..ecd8afae0 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -17,6 +17,7 @@ import threading from .config import ( SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S, + STREAMING_ENDPOINT, ) from .core.captor import EventCaptorV1 from .core.executor import ActionExecutorV1 @@ -86,22 +87,23 @@ class AgentV1: self._state.set_on_stop(self.stop_session) # Client serveur pour le chat et les workflows + # Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL self._server_client = None if LeaServerClient is not None: # Forcer le token API pour éviter les 401 # (le token est set par start.bat dans l'environnement) from .config import API_TOKEN as _token - server_host = os.getenv("RPA_SERVER_HOST", "localhost") - self._server_client = LeaServerClient(server_host=server_host) + self._server_client = LeaServerClient() if _token and not self._server_client._api_token: self._server_client._api_token = _token logger.info("Token API forcé dans LeaServerClient") # Fenetre de chat Lea (tkinter natif) + # Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST) server_host = ( self._server_client.server_host if self._server_client is not None - else os.getenv("RPA_SERVER_HOST", "localhost") + else "localhost" ) self._chat_window = ChatWindow( server_client=self._server_client, @@ -363,11 +365,11 @@ class AgentV1: continue self._last_bg_hash = img_hash - # Envoyer au streaming server (avec token auth) + # Envoyer au streaming server (via STREAMING_ENDPOINT unifié) headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {} with open(full_path, 'rb') as f: req.post( - f"{SERVER_URL}/traces/stream/image", + f"{STREAMING_ENDPOINT}/image", params={ "session_id": bg_session, "shot_id": f"heartbeat_{int(time.time())}", @@ -376,6 +378,7 @@ class AgentV1: headers=headers, files={"file": ("screenshot.png", f, "image/png")}, timeout=10, + allow_redirects=False, ) except Exception as e: logger.debug(f"[HEARTBEAT] Erreur: {e}") diff --git a/agent_v0/agent_v1/network/streamer.py b/agent_v0/agent_v1/network/streamer.py index 30336971c..ffe2fad8e 100644 --- a/agent_v0/agent_v1/network/streamer.py +++ b/agent_v0/agent_v1/network/streamer.py @@ -544,6 +544,28 @@ class TraceStreamer: except OSError as e: logger.debug(f"Purge échouée : {path} — {e}") + # ========================================================================= + # Protection redirect POST→GET (INC-7) + # ========================================================================= + + @staticmethod + def _check_redirect(resp, url: str): + """Detecter et logger une redirection sur un POST. + + La lib requests transforme un POST en GET sur 301/302 (RFC 7231). + Avec allow_redirects=False, on recoit le 301/302 directement. + On log un WARNING explicite pour que l'admin corrige l'URL. + """ + if resp.status_code in (301, 302, 307, 308): + location = resp.headers.get("Location", "?") + logger.warning( + f"Redirection {resp.status_code} detectee sur POST {url} " + f"→ {location}. Verifiez que RPA_SERVER_URL utilise " + f"https:// si le serveur redirige." + ) + return True + return False + # ========================================================================= # Envois HTTP # ========================================================================= @@ -551,15 +573,20 @@ class TraceStreamer: def _register_session(self): """Enregistrer la session auprès du serveur (avec identifiant machine).""" try: + url = f"{STREAMING_ENDPOINT}/register" resp = requests.post( - f"{STREAMING_ENDPOINT}/register", + url, params={ "session_id": self.session_id, "machine_id": self.machine_id, }, headers=self._auth_headers(), timeout=3, + allow_redirects=False, ) + if self._check_redirect(resp, url): + logger.warning("Enregistrement session échoué (redirect)") + return if resp.ok: logger.info( f"Session {self.session_id} enregistrée sur le serveur " @@ -579,15 +606,18 @@ class TraceStreamer: C'est la dernière chance de sauver les données de la session. """ try: + url = f"{STREAMING_ENDPOINT}/finalize" resp = requests.post( - f"{STREAMING_ENDPOINT}/finalize", + url, params={ "session_id": self.session_id, "machine_id": self.machine_id, }, headers=self._auth_headers(), timeout=30, # Le build workflow peut prendre du temps + allow_redirects=False, ) + self._check_redirect(resp, url) if resp.ok: result = resp.json() logger.info(f"Session finalisée: {result}") @@ -601,6 +631,7 @@ class TraceStreamer: if not self._server_available: return False try: + url = f"{STREAMING_ENDPOINT}/event" payload = { "session_id": self.session_id, "timestamp": time.time(), @@ -608,11 +639,14 @@ class TraceStreamer: "machine_id": self.machine_id, } resp = requests.post( - f"{STREAMING_ENDPOINT}/event", + url, json=payload, headers=self._auth_headers(), timeout=2, + allow_redirects=False, ) + if self._check_redirect(resp, url): + return False return resp.ok except Exception as e: logger.debug(f"Streaming Event échoué: {e}") @@ -645,18 +679,22 @@ class TraceStreamer: "machine_id": self.machine_id, } + url = f"{STREAMING_ENDPOINT}/image" if jpeg_buf is not None: # Envoi du JPEG compressé (BytesIO, pas de fuite possible) files = { "file": (f"{shot_id}{suffix}", jpeg_buf, content_type) } resp = requests.post( - f"{STREAMING_ENDPOINT}/image", + url, files=files, params=params, headers=self._auth_headers(), timeout=5, + allow_redirects=False, ) + if self._check_redirect(resp, url): + return ImageSendResult.FAILED if resp.ok: self._purge_local_image(path) return ImageSendResult.OK @@ -668,12 +706,15 @@ class TraceStreamer: "file": (f"{shot_id}.png", f, "image/png") } resp = requests.post( - f"{STREAMING_ENDPOINT}/image", + url, files=files, params=params, headers=self._auth_headers(), timeout=5, + allow_redirects=False, ) + if self._check_redirect(resp, url): + return ImageSendResult.FAILED if resp.ok: self._purge_local_image(path) return ImageSendResult.OK diff --git a/agent_v0/agent_v1/ui/messages.py b/agent_v0/agent_v1/ui/messages.py index bc485f901..bf38e2be5 100644 --- a/agent_v0/agent_v1/ui/messages.py +++ b/agent_v0/agent_v1/ui/messages.py @@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur: ) +def formatter_mode_apprentissage( + raison: str = "", + description_cible: str = "", + titre_fenetre: Optional[str] = None, +) -> MessageUtilisateur: + """Message quand Léa passe en mode apprentissage (pause supervisée). + + L'utilisateur doit comprendre : + 1. Léa est bloquée et a besoin d'aide + 2. L'utilisateur doit prendre la main et montrer comment faire + 3. Ctrl+Shift+L pour signaler qu'il a fini + + Le ton est humble, clair, actionnable. Pas technique. + + Exemple : + Léa a besoin d'aide + Je n'y arrive pas, montrez-moi comment faire. + Quand vous avez fini, appuyez sur Ctrl+Shift+L. + """ + cible = _nettoyer_description_cible(description_cible) if description_cible else "" + app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else "" + + # Construire un contexte court si disponible + contexte = "" + if cible and app: + contexte = f" (« {cible} » dans {app})" + elif cible: + contexte = f" (« {cible} »)" + + corps = ( + f"Je n'y arrive pas{contexte}, montrez-moi comment faire. " + f"Quand vous avez fini, appuyez sur Ctrl+Shift+L." + ) + + return MessageUtilisateur( + niveau=NiveauMessage.BLOCAGE, + titre="Léa a besoin d'aide", + corps=corps, + duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE], + persistent=True, + ) + + def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur: """Message quand la connexion avec le serveur est perdue. diff --git a/agent_v0/agent_v1/ui/notifications.py b/agent_v0/agent_v1/ui/notifications.py index 111e12875..c6014e126 100644 --- a/agent_v0/agent_v1/ui/notifications.py +++ b/agent_v0/agent_v1/ui/notifications.py @@ -32,6 +32,7 @@ from .messages import ( formatter_etape_workflow, formatter_fenetre_incorrecte, formatter_fin_workflow, + formatter_mode_apprentissage, formatter_ralentissement, formatter_retry, ) @@ -273,6 +274,20 @@ class NotificationManager: msg = formatter_ecran_inchange(action_type) return self.notify_message(msg) + def replay_learning_mode( + self, + raison: str = "", + target_description: str = "", + window_title: Optional[str] = None, + ) -> bool: + """Notification quand Léa passe en mode apprentissage. + + Léa est bloquée et demande à l'utilisateur de montrer comment faire. + Message humble et actionnable pour un utilisateur non technique. + """ + msg = formatter_mode_apprentissage(raison, target_description, window_title) + return self.notify_message(msg) + def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool: """Notification quand Léa retente une action.""" msg = formatter_retry(action_type, tentative) diff --git a/agent_v0/lea_ui/server_client.py b/agent_v0/lea_ui/server_client.py index 9767c53cf..9274fd4d4 100644 --- a/agent_v0/lea_ui/server_client.py +++ b/agent_v0/lea_ui/server_client.py @@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger("lea_ui.server_client") -def _get_server_host() -> str: - """Recuperer l'adresse du serveur Linux. +def _get_server_url() -> str: + """Recuperer l'URL du serveur RPA (avec /api/v1). Ordre de resolution : - 1. Variable d'environnement RPA_SERVER_HOST - 2. Fichier de config agent_config.json (cle "server_host") - 3. Fallback localhost + 1. Import depuis agent_v1.config (source de verite unique) + 2. Variable d'environnement RPA_SERVER_URL + 3. Fallback http://localhost:5005/api/v1 """ - # 1. Variable d'environnement - host = os.environ.get("RPA_SERVER_HOST", "").strip() - if host: - return host + # 1. Import depuis config.py (source de verite) + try: + from agent_v1.config import SERVER_URL + return SERVER_URL + except ImportError: + pass - # 2. Fichier de config - config_paths = [ - os.path.join(os.path.dirname(__file__), "..", "agent_config.json"), - os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"), - ] - for config_path in config_paths: - try: - with open(config_path, "r", encoding="utf-8") as f: - cfg = json.load(f) - host = cfg.get("server_host", "").strip() - if host: - return host - except (OSError, json.JSONDecodeError): - continue + # 2. Variable d'environnement directe + url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/") + if url: + return url # 3. Fallback - return "localhost" + return "http://localhost:5005/api/v1" + + +def _get_server_base(server_url: str) -> str: + """Extraire la base URL (sans /api/v1) pour les routes racine (/health).""" + return server_url.rsplit("/api/v1", 1)[0] class LeaServerClient: @@ -67,19 +64,22 @@ class LeaServerClient: chat_port: int = 5004, stream_port: int = 5005, ) -> None: - self._host = server_host or _get_server_host() + # URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1). + # _stream_url = URL avec /api/v1 (pour les routes API) + # _stream_base = URL sans /api/v1 (pour /health uniquement) + self._stream_url = _get_server_url() + self._stream_base = _get_server_base(self._stream_url) + + # Extraire le host depuis l'URL pour le chat et pour l'affichage + try: + from urllib.parse import urlparse + parsed = urlparse(self._stream_base) + self._host = parsed.hostname or "localhost" + except Exception: + self._host = server_host or "localhost" + self._chat_port = chat_port self._stream_port = stream_port - - # En prod, la base URL passe par le reverse proxy HTTPS - # (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est - # definie on l'utilise telle quelle, sinon on reconstruit http://host:port. - server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/") - if server_url: - self._stream_base = server_url - else: - self._stream_base = f"http://{self._host}:{self._stream_port}" - self._chat_base = f"http://{self._host}:{self._chat_port}" # Etat de connexion @@ -103,8 +103,8 @@ class LeaServerClient: self._api_token = os.environ.get("RPA_API_TOKEN", "") logger.info( - "LeaServerClient initialise : chat=%s, stream=%s", - self._chat_base, self._stream_base, + "LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s", + self._chat_base, self._stream_url, self._stream_base, ) # --------------------------------------------------------------------------- @@ -154,7 +154,11 @@ class LeaServerClient: # --------------------------------------------------------------------------- def check_connection(self) -> bool: - """Tester la connexion au serveur streaming (port 5005).""" + """Tester la connexion au serveur streaming (port 5005). + + Le health check utilise _stream_base (sans /api/v1) car la route + /health est a la racine du serveur FastAPI, pas sous /api/v1. + """ try: import requests resp = requests.get( @@ -227,7 +231,7 @@ class LeaServerClient: import requests headers = self._auth_headers() resp = requests.get( - f"{self._stream_base}/api/v1/traces/stream/workflows", + f"{self._stream_url}/traces/stream/workflows", headers=headers, timeout=10, ) @@ -284,7 +288,7 @@ class LeaServerClient: while self._polling: try: resp = req_lib.get( - f"{self._stream_base}/api/v1/traces/stream/replay/next", + f"{self._stream_url}/traces/stream/replay/next", params={"session_id": self._poll_session_id}, headers=self._auth_headers(), timeout=5, @@ -318,7 +322,7 @@ class LeaServerClient: try: import requests resp = requests.get( - f"{self._stream_base}/api/v1/traces/stream/replays", + f"{self._stream_url}/traces/stream/replays", headers=self._auth_headers(), timeout=5, ) @@ -346,7 +350,7 @@ class LeaServerClient: try: import requests requests.post( - f"{self._stream_base}/api/v1/traces/stream/replay/result", + f"{self._stream_url}/traces/stream/replay/result", json={ "session_id": session_id, "action_id": action_id, diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index b285c17f1..5dbada0f0 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -292,6 +292,20 @@ app.add_middleware( ) +@app.middleware("http") +async def url_compat_rewrite(request: Request, call_next): + """Rétrocompatibilité : réécriture des anciennes URLs sans préfixe /api/v1. + + Certains agents clients (Léa V1 gelée) envoient sur /traces/stream/... + au lieu de /api/v1/traces/stream/... Ce middleware redirige silencieusement. + """ + path = request.url.path + if path.startswith("/traces/stream/") and not path.startswith("/api/v1/"): + new_path = "/api/v1" + path + request.scope["path"] = new_path + return await call_next(request) + + @app.middleware("http") async def security_headers_middleware(request: Request, call_next): """Ajouter les headers de sécurité sur toutes les réponses.""" diff --git a/deploy/configs/config_pc_fixe_lan.txt b/deploy/configs/config_pc_fixe_lan.txt new file mode 100644 index 000000000..57525d015 --- /dev/null +++ b/deploy/configs/config_pc_fixe_lan.txt @@ -0,0 +1,18 @@ +# ============================================================ +# Configuration Lea — PC fixe Windows (LAN) +# ============================================================ +# +# Poste : PC fixe Windows de Dom +# Serveur : 192.168.1.40:5005 (RTX 5070) +# +# ============================================================ + +RPA_SERVER_URL=http://192.168.1.40:5005/api/v1 +RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab +RPA_MACHINE_ID=PC_WINDOWS_dOM +RPA_USER_LABEL=Dom + +# --- Parametres avances (ne pas modifier sauf indication) --- +# RPA_OLLAMA_HOST=localhost +RPA_BLUR_SENSITIVE=false +RPA_LOG_RETENTION_DAYS=180 diff --git a/deploy/configs/config_vm_lan.txt b/deploy/configs/config_vm_lan.txt new file mode 100644 index 000000000..feb14f8a3 --- /dev/null +++ b/deploy/configs/config_vm_lan.txt @@ -0,0 +1,18 @@ +# ============================================================ +# Configuration Lea — VM Windows (LAN) +# ============================================================ +# +# Poste : VM Windows 11 en reseau local +# Serveur : 192.168.1.40:5005 (RTX 5070) +# +# ============================================================ + +RPA_SERVER_URL=http://192.168.1.40:5005/api/v1 +RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab +RPA_MACHINE_ID=windows_vm +RPA_USER_LABEL=Dom2 + +# --- Parametres avances (ne pas modifier sauf indication) --- +# RPA_OLLAMA_HOST=localhost +RPA_BLUR_SENSITIVE=false +RPA_LOG_RETENTION_DAYS=180 diff --git a/deploy/installer/config_template.txt b/deploy/installer/config_template.txt index f2081f249..f6adecd64 100644 --- a/deploy/installer/config_template.txt +++ b/deploy/installer/config_template.txt @@ -22,6 +22,6 @@ USER_NAME=Prenom Nom USER_EMAIL=prenom.nom@aivanov.com USER_ID= -# Connexion serveur (valeurs par defaut deja pre-remplies) -SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 -API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab +# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation) +SERVER_URL=CONFIGURE_ME +API_TOKEN=CONFIGURE_ME diff --git a/deploy/lea_package/config.txt b/deploy/lea_package/config.txt index 3dd97c298..8073f07aa 100644 --- a/deploy/lea_package/config.txt +++ b/deploy/lea_package/config.txt @@ -8,36 +8,33 @@ # # Les lignes commencant par # sont des commentaires (ignorees). # +# IMPORTANT : remplacez toutes les valeurs CONFIGURE_ME +# avant de lancer Lea. L'agent refusera de demarrer sinon. +# +# Pour obtenir un config.txt pre-rempli, utilisez le dashboard +# Fleet (Menu → Fleet → Telecharger le ZIP d'un agent). +# # ============================================================ -# Adresse du serveur Lea (URL complete avec /api/v1) -RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 +# Adresse du serveur Lea (obligatoire — remplacer avant utilisation) +# Exemples : +# LAN interne : http://192.168.1.40:5005/api/v1 +# Internet : https://lea.labs.laurinebazin.design/api/v1 +# Dev local : http://localhost:5005/api/v1 +RPA_SERVER_URL=CONFIGURE_ME # Cle d'authentification (fournie par l'administrateur) -RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab +RPA_API_TOKEN=CONFIGURE_ME -# Nom du serveur (sans https://, sans /api/v1) -RPA_SERVER_HOST=lea.labs.laurinebazin.design +# Host Ollama (defaut localhost, ne pas modifier sauf configuration speciale) +# RPA_OLLAMA_HOST=localhost -# ============================================================ -# Parametres avances (ne pas modifier sauf indication) -# ============================================================ +# Identifiant unique de ce poste +RPA_MACHINE_ID=CONFIGURE_ME -# Flouter les zones de texte dans les captures cote CLIENT. -# -# DEPUIS AVRIL 2026 : LE BLUR CLIENT EST DESACTIVE PAR DEFAUT. -# Le floutage des donnees sensibles (noms, adresses, telephones, NIR, email) -# est desormais effectue cote SERVEUR via EDS-NLP + OCR dans le module -# core/anonymisation/pii_blur.py. -# -# Avantages du blur server-side : -# - Cible precisement les PII (PERSON/LOCATION/PHONE/NIR/EMAIL) -# - Ne casse plus les codes CIM, montants PMSI, identifiants techniques -# - Deux versions stockees : _raw (entrainement) + _blurred (affichage) -# -# Ne remettre a 'true' que si un deploiement specifique l'exige explicitement -# (ex : reseau non chiffre entre agent et serveur). +# Nom du collaborateur associe +RPA_USER_LABEL=CONFIGURE_ME + +# --- Parametres avances (ne pas modifier sauf indication) --- RPA_BLUR_SENSITIVE=false - -# Duree de conservation des logs en jours (minimum 180 pour conformite) RPA_LOG_RETENTION_DAYS=180 diff --git a/docs/demo/FAQ_EXPERTS_RPA.md b/docs/demo/FAQ_EXPERTS_RPA.md new file mode 100644 index 000000000..03a829f93 --- /dev/null +++ b/docs/demo/FAQ_EXPERTS_RPA.md @@ -0,0 +1,289 @@ +# FAQ — Questions des experts RPA (démo 26 avril 2026) + +**Audience** : DG/DSI de groupements de cliniques, dont **plusieurs ont déjà +déployé UiPath, Automation Anywhere ou Power Automate**. Ils connaissent les +limites du RPA classique. Ils vont challenger. + +**Posture de réponse** : factuelle, posée, ni défensive ni bravache. Quand +on n'a pas, on le dit. On finit toujours par ramener la conversation sur +**le cas métier urgences et le ROI chiffré**. + +--- + +## Bloc TECHNO + +### Q1. Pourquoi pas UiPath / Automation Anywhere / Power Automate ? + +Les RPA classiques se cassent dès que l'UI change d'un pixel ou qu'on passe +par Citrix. Ils fonctionnent bien sur des processus répétitifs ultra-stables +(compta, RH), mal sur des métiers visuels variables comme les urgences. +Léa ne lit pas le DOM ni l'accessibility tree : elle **voit l'écran comme +un humain** et s'adapte quand l'UI bouge. On n'est pas un concurrent +généraliste d'UiPath : on est le bon outil pour **un métier précis où +UiPath échoue**. + +### Q2. Comment Léa gère Citrix / RDP / VDI ? + +C'est notre terrain principal, pas un cas dégradé. Léa capture l'écran +(image), comprend la structure via un modèle de vision, et interagit +via clavier/souris standard. Pas d'API, pas de tree, pas de crochet. +Citrix ou natif, c'est transparent. En contrepartie, la latence est +légèrement plus élevée (100-300 ms par action vs 50 ms en natif) — +acceptable pour du codage PMSI, pas pour du trading. + +### Q3. Vous utilisez quel VLM ? Vos modèles sont open source ? + +100 % modèles open source, tournant en local. Le stack actuel combine : +Qwen2.5-VL (grounding visuel), UI-TARS-1.5-7B (action sur UI), et des +prompts métier spécifiques par domaine (PMSI urgences, facturation, etc.). +Rien chez OpenAI, rien chez Anthropic au runtime client. Infrastructure +minimum pour un pilote : un serveur GPU (RTX 5070 ou équivalent) sur site +ou dans le cloud souverain. + +### Q4. Que se passe-t-il quand l'UI change (mise à jour du DPI) ? + +C'est la vraie question. Trois niveaux de réponse : +1. **Petit changement** (position d'un bouton, couleur) : Léa s'adapte + toute seule, parce qu'elle reconnaît sémantiquement "bouton Valider", + pas "bouton en x=420 y=180". +2. **Gros changement** (refonte UI) : Léa **détecte qu'elle ne comprend + plus**, se met en pause, et demande à l'humain. Pas de casse silencieuse. +3. **Réapprentissage** : la TIM refait le workflow une fois en mode + "apprends-moi", Léa se remet à jour. Temps typique : 15-30 minutes. + +### Q5. Vous supportez quels OS ? + +Agent client : **Windows 10/11** (cible principale, c'est ce que les +cliniques ont). Serveur : **Linux** (Ubuntu 22.04+). macOS et Linux +côté client ne sont pas prioritaires — la demande est marginale en milieu +hospitalier. + +### Q6. Que faites-vous contre le drift d'écran entre deux sessions (taille de fenêtre, thème Windows) ? + +Trois couches : (1) capture normalisée en résolution logique, (2) +invariance aux thèmes sombres/clairs via apprentissage multi-contextes, +(3) mémoire des cibles par signature sémantique plutôt que par coordonnées. +En pratique, sur nos tests chez Resurgences, le drift normal (thème, +multi-écran) ne casse pas l'exécution. + +### Q7. Qu'est-ce qui se passe si le médecin ou la TIM tape au clavier pendant que Léa exécute ? + +Détection d'interférence humaine : Léa met en pause dès qu'elle détecte une +activité clavier/souris non-Léa sur son fil d'exécution. L'humain reprend +la main, Léa reprend quand il a fini. Pas de conflit, pas de frappe +mélangée. + +--- + +## Bloc SCALING / ROBUSTESSE + +### Q8. Combien de postes Léa peut-on déployer en parallèle ? + +Sur un serveur GPU mid-range (RTX 5070), on vise **10-20 postes en +parallèle** pour des workflows typiques (pas de vidéo temps réel 4K). Au- +delà, on scale horizontalement (plusieurs serveurs) ou on passe sur un GPU +plus costaud (A100, DGX). L'architecture est modulaire, le goulet +d'étranglement est le GPU, pas le code. + +### Q9. Latence serveur ? + +Action simple (clic, frappe) : 200-400 ms côté Léa (capture → inférence → +commande). Action complexe (compréhension d'écran + décision) : 1-3 s. +Pour du codage PMSI, ça passe largement. Pour du trading haute fréquence, +ce n'est pas le bon produit. + +### Q10. Que fait Léa si le serveur est down ? + +L'agent client bascule en **buffer local** : il continue à capturer ce que +l'utilisateur fait, stocke les sessions, et les envoie quand le serveur +revient. Pas de perte de données. L'exécution autonome, elle, se met en +pause — pas de fallback aveugle. + +### Q11. Reprise après erreur ? Replay automatique ? + +Trois niveaux : +1. **Reprise automatique** si l'erreur est transiente (popup, dialogue + inattendu reconnu par Léa). +2. **Pause + demande à l'humain** si Léa ne comprend pas ce qu'elle voit + ("je vois un écran que je ne connais pas, merci de m'aider"). C'est la + règle : un échec devient un apprentissage, pas un crash. +3. **Replay à froid** du workflow complet depuis l'observation humaine + initiale, si on veut tout rejouer. + +### Q12. Et si Léa fait une erreur qui a un impact réel (un mauvais code envoyé au PMSI) ? + +Trois garde-fous : (1) **mode strict** où Léa ne valide jamais un envoi +final — toujours confirmation humaine, (2) **seuil de confidence** configurable +par workflow, (3) **log d'audit complet** (toutes les décisions Léa sont +tracées, replay vidéo possible). En 100 % autonomous, Léa agit seulement +sur des workflows **validés plusieurs fois** et avec un seuil de confidence +haut. Pour les urgences pilote, on démarre en mode "assistante" (copilote), +pas en 100 % autonome. + +--- + +## Bloc SÉCURITÉ / CONFORMITÉ + +### Q13. RGPD, AI Act, HDS — comment vous vous positionnez ? + +- **RGPD** : 100 % local, aucune donnée patient ne sort du SI. L'agent + capture l'écran, traite sur un serveur **sur site ou en cloud souverain** + (3DS Outscale, OVH HDS). Pas de routing cloud US. +- **AI Act** : Léa est système IA au sens de l'article 50 (cf. LISEZMOI), + flag d'information utilisateur intégré. Classification "risque limité" en + copilote, "risque élevé" si autonome — documentation dans + `docs/RAPPORT_CONFORMITE_AI_ACT.md`. +- **HDS** : l'hébergement serveur doit être HDS-certifié. On accompagne le + pilote sur le choix de l'hébergeur si besoin. + +### Q14. "100 % local", ça veut dire quoi concrètement ? + +Deux niveaux d'installation possibles : +1. **Tout sur site** : serveur GPU dans le SI de la clinique, zéro sortie + réseau. Cas le plus sûr, le plus cher (hardware). +2. **Serveur en cloud souverain HDS** : données chiffrées en transit et au + repos, clés maîtrisées par le client. Aucun transit hors UE. +Dans les deux cas : **aucun appel cloud US**, aucun LLM propriétaire +externe (pas de ChatGPT, pas de Claude, pas de Gemini). + +### Q15. Logs d'audit, traçabilité des décisions IA ? + +Chaque décision de Léa (ce qu'elle a vu, ce qu'elle a décidé, sur quel +élément elle a cliqué) est loggée : screenshot avant/après, prompt soumis +au VLM, réponse, action exécutée. Rétention paramétrable (**minimum 180 +jours** en config par défaut pour conformité). Replay vidéo d'un workflow +possible pour audit DIM ou ARS. + +### Q16. Où est stocké le modèle ? Il apprend sur nos données ? Qui les possède ? + +Modèles **open source** stockés sur le serveur client (poids Qwen, UI-TARS, +etc.). **Aucun apprentissage en ligne sur les données patient** par défaut — +on ne renvoie rien aux éditeurs des modèles. Un mode "fine-tuning local" +existe pour spécialiser Léa sur le vocabulaire d'une clinique, **exécuté +sur le serveur du client, poids gardés par le client**. Le client est +propriétaire de ses données ET de ses fine-tunings. + +### Q17. Qui accède aux données chez vous ? Vos équipes ? + +En prod chez un client : personne chez AIVANOV n'accède aux données sans +autorisation écrite. En pilote : un accès de debug peut être demandé avec +procédure documentée (lecture seule, logs anonymisés). **Les credentials +métier (compte DPI de la TIM) sont stockés dans un vault chiffré +Fernet/AES local** — jamais en clair côté serveur, jamais chez nous. + +--- + +## Bloc BUSINESS + +### Q18. Licensing ? C'est combien ? + +**Modèle économique aligné sur la valeur**. Les postes utilisateurs ne +sont qu'une étape (Shadow puis Copilot) : une fois Léa entraînée, elle +tourne **en autonome** sur infrastructure dédiée — facturer "par poste" +reviendrait à facturer la phase d'apprentissage, pas la valeur créée. + +**Notre proposition de valeur** : vous ne payez significativement **que +sur la valeur démontrée** (gains PMSI récupérés, mesurés via audit trail). +Plusieurs modèles possibles selon votre contexte (forfait établissement, +% de la valeur récupérée, volume traité, hybride) — **on cale ensemble +le modèle qui vous convient en one-to-one**. + +Pour les **premiers pilotes** : accompagnement gratuit 2 mois +(infrastructure + setup + apprentissage), puis bascule sur le modèle +pérenne choisi ensemble. + +*→ Rediriger vers one-to-one à la pause si question poussée — ne pas +annoncer un chiffre précis en plénière.* + +### Q19. Support, maintenance, SLA ? + +Pilote : accompagnement direct Dom + Amina, réponse < 4 h ouvrées. +Production : SLA à contractualiser (8 h ouvrées standard, 2 h premium). +Maintenance : mises à jour modèles trimestrielles, correctifs sur demande. + +### Q20. Qui êtes-vous, votre équipe, vos références ? + +AIVANOV, SAS française, fondée par Amina ETTORCHI (présidente, +ex-TIM/DIM, 15 ans d'expérience PMSI). Équipe technique lead Dom +(architecture vision + RPA). Projet Léa en développement depuis 2025, +version 1.0 déployable depuis avril 2026. **Premier pilote client à +démarrer** — pas de référence client public aujourd'hui, on est +transparent sur ce point et c'est une **opportunité pour les premiers +cliniques** (conditions pilote spécifiques). + +### Q21. Combien de temps pour un pilote ? Pour une prod ? + +- **Pilote** : 6-8 semaines (semaine 1-2 cadrage + capture workflow, + semaine 3-4 test en double avec la TIM, semaine 5-8 mesure ROI). +- **Mise en prod** : 2-3 mois après pilote validé, selon intégration SI + et HDS. +- **Rampe multi-clinique** : 1 clinique par mois en rythme raisonnable. + +### Q22. Qu'est-ce qui n'est pas encore prêt, honnêtement ? + +- Mode **autonomous 100 % non-supervisé** : en développement, pas + production-ready. En pilote, on démarre en **copilote** (Léa propose, + la TIM valide). +- **Fiche d'identité par DPI** : Resurgences et quelques autres sont + bien testés, les outils plus rares demandent une phase d'apprentissage + dédiée. +- **Support macOS / Linux côté client** : pas prioritaire, à la demande. +- **Multilingue** : français uniquement aujourd'hui. Anglais prévu S2 2026. + +--- + +## Bloc URGENCES (spécifique) + +### Q23. Pourquoi les urgences d'abord ? Pourquoi pas la facturation ou la pharmacie ? + +Deux raisons : (1) **c'est là qu'Amina a prouvé 150 k€/mois de récupération +manuelle** — on bâtit sur une preuve terrain chiffrée. (2) C'est un +domaine à **forte douleur** (TIM sous pression, codage fait vite, DPI +variés). Le ROI est évident, la reproductibilité est haute. Facturation +et pharmacie suivront, après un premier carton aux urgences. + +### Q24. Quels DPI urgences supportés aujourd'hui ? + +**Testés et opérationnels** : Resurgences (Softway). **En cours de +validation** : Urqual, DxCare, CristalNet Urgences, Hôpital Manager. +**Non testés aujourd'hui** : Osiris, outils métier propriétaires +d'établissement. Le coût d'ajout d'un nouveau DPI est faible (5-10 jours +de capture/apprentissage TIM) grâce à l'approche 100 % vision. + +### Q25. Quel ROI moyen sur les urgences ? + +Borne basse prouvée (sans IA, Amina en manuel) : **150 k€/mois/clinique**. +Avec Léa, on **scale cette méthode sur des volumes que la TIM ne pouvait +pas traiter manuellement** — on couvre 100 % des dossiers au lieu de 10-20 %. +Projection prudente : **+30 à +70 % vs manuel**, soit **200-250 k€/mois/ +clinique potentiel** pour des groupements avec volumes urgences élevés. +**À confirmer sur pilote.** + +### Q26. Est-ce que Léa remplace la TIM ? + +Non. Léa fait le travail répétitif à haute valeur/faible complexité (les +80 % des dossiers "évidents") et **libère la TIM pour les 20 % de cas +complexes** (dossiers longs, arbitrages médicaux, contrôle qualité). Le +discours pour la TIM : "on t'enlève les corvées, tu gardes le cœur du +métier". En pratique, on gagne du temps qualifié et on réduit le turn-over +TIM (douleur RH forte dans la plupart des cliniques). + +### Q27. Comment vous convainquez un médecin urgentiste que Léa sait coder mieux que lui le soir à 23 h fatigué ? + +On ne le convainc pas, **on lui montre sur son dossier d'hier soir** ce +qu'il a oublié, avec la justification ligne par ligne. L'adhésion médecin +se joue en démo, pas en réunion. Et ce n'est pas "Léa contre le médecin", +c'est "Léa filet de sécurité pour le médecin fatigué à 23 h". + +--- + +## Ce qu'on ne dit JAMAIS devant cette audience + +- "On n'a pas encore..." (à transformer en "c'est sur la roadmap S2 2026"). +- "Notre produit est un prototype" (il est en v1.0, livrable). +- "Ça ne marche pas toujours" (on dit "il y a un mode supervisé pour les + cas où Léa doute"). +- "UiPath c'est nul" (on dit "UiPath est excellent sur d'autres terrains, + nous on est spécialisés urgences"). +- "On est une startup de 2 personnes" (on dit "une équipe ramassée, agile, + avec 15 ans d'expertise métier en lead"). diff --git a/docs/demo/GRILLE_INTERVIEW_TIM.md b/docs/demo/GRILLE_INTERVIEW_TIM.md new file mode 100644 index 000000000..e2bc44fa7 --- /dev/null +++ b/docs/demo/GRILLE_INTERVIEW_TIM.md @@ -0,0 +1,200 @@ +# GRILLE D'INTERVIEW — TIM urgences (préparation démo du 26 avril) + +**Objectif** : identifier **LE** workflow urgences à transformer en démo live +percutante devant 10-20 DG/DSI de groupements de cliniques. + +**Durée cible de l'entretien** : **45-60 min** max. + +**Posture** : Amina pose les questions métier, Dom écoute et prend des notes +techniques. On ne contredit **jamais** la TIM sur sa description du terrain — +même si ça ne correspond pas à ce qu'on avait imaginé. C'est elle l'experte. + +**À la sortie de l'entretien, on doit repartir avec :** +1. Un workflow précis (5-15 étapes) à reproduire en démo. +2. Un chiffre de ROI estimé (euros/dossier ou euros/mois). +3. La liste des logiciels concernés (DPI + outils annexes). +4. Un verdict "on demande à la TIM d'être présente à la démo ? Oui/Non". + +--- + +## Bloc A — Son quotidien (5 questions) + +### A1. Combien de dossiers urgences tu codes par jour ? + +*Pourquoi on pose cette question : pour calibrer le volume annuel et projeter +le ROI — 50/jour vs 200/jour change complètement l'échelle du chiffre qu'on +montre aux DG.* + +### A2. Quel est ton temps moyen par dossier (du moment où tu l'ouvres au moment où tu valides) ? + +*Pourquoi on pose cette question : pour chiffrer le gain potentiel en minutes +× nombre de dossiers × coût chargé TIM. C'est la promesse "temps gagné", qui +est secondaire à la promesse "récupération de valorisation", mais utile.* + +### A3. Quel DPI urgences tu utilises ? (Resurgences, Urqual, DxCare, CristalNet, Hôpital Manager, autre ?) + +*Pourquoi on pose cette question : on veut savoir si elle utilise **un DPI +qu'on a déjà testé** (Resurgences côté Softway est le plus probable en +clinique). Si c'est un outil exotique, on ajuste la démo ou on bascule sur +un de ses collègues.* + +### A4. Où tu saisis le RUM ? Où tu saisis le RPU ? (même logiciel, onglets séparés, deux logiciels ?) + +*Pourquoi on pose cette question : la structure PMSI urgences est double (RUM +PMSI + RPU ARS). On veut savoir combien d'écrans/logiciels sont impliqués +pour dimensionner la complexité du scénario démo.* + +### A5. À quelle heure/dans quel contexte tu codes ? (temps réel pendant que le médecin voit le patient, J+1 en batch, fin de semaine ?) + +*Pourquoi on pose cette question : si elle code en différé (J+1 ou J+3), le +scénario "audit rétrospectif" colle parfaitement — c'est exactement ce que +Léa fait de mieux. Si c'est en temps réel, scénario B "assistant temps réel" +est plus adapté mais plus risqué.* + +--- + +## Bloc B — Les pertes et les douleurs (5 questions) + +### B1. Quels sont les codes les plus souvent oubliés ou sous-valorisés ? Tu peux me citer le top 5 ? + +*Pourquoi on pose cette question : **question clé**. On cherche LE pattern qui +revient (ex : "la suture complexe qui devient simple", "le monitoring oublié", +"l'anesthésie locale pas codée"). C'est ce qu'on fera détecter à Léa en démo, +et c'est crédible parce que ça vient du terrain.* + +### B2. Tu as un exemple récent (cette semaine ou la semaine dernière) d'un dossier où tu as récupéré un acte ou un code que le médecin avait oublié ? Raconte-moi. + +*Pourquoi on pose cette question : on cherche une **histoire vraie, concrète, +chiffrable** qu'Amina pourra raconter en ouverture de démo. "Lundi dernier, +dossier M. X, ECG non codé = 42 €." C'est 100× plus fort que des stats.* + +### B3. Dans ta journée, combien de temps tu passes à **chercher** dans le DPI (scroll, changement d'onglets, copier-coller d'un écran à l'autre) versus à réellement **coder** ? + +*Pourquoi on pose cette question : on veut quantifier la friction UI. Léa +élimine justement la partie "navigation/recherche". Si elle répond "70 % du +temps à chercher", on a un slide killer.* + +### B4. Quelles sont les étapes répétitives qui t'énervent le plus ? (celles qui te donnent l'impression de perdre ton temps) + +*Pourquoi on pose cette question : on cherche la **douleur émotionnelle**, pas +juste le gain chiffré. Les DG aiment les vraies histoires ("Pendant qu'elle +fait ça 80 fois par jour, elle pourrait faire autre chose"). Ça humanise.* + +### B5. À côté de ton DPI, tu utilises des outils Excel / Word / listes papier / mail pour compléter le codage ? (suivi, relance des médecins, consolidation) + +*Pourquoi on pose cette question : c'est là que **Léa est imbattable** — faire +le pont entre DPI et outils bureautiques. Si elle tient un Excel des "dossiers +à relancer", on a un scénario en or : Léa lit le DPI, remplit l'Excel, +envoie le mail au médecin.* + +--- + +## Bloc C — Un scénario démo idéal (5 questions) + +### C1. Si tu devais impressionner ton directeur général avec un outil qui fait ton travail pour toi, **qu'est-ce que tu lui montrerais en premier** ? + +*Pourquoi on pose cette question : meilleure question du blocage. Elle nous +dit **exactement** ce qui, pour elle, est le plus impressionnant. Si elle +hésite, reformuler : "Qu'est-ce qui, pour toi, serait magique ?"* + +### C2. Ton workflow de codage typique, il tient en combien d'étapes ? (5 clics ? 20 ? 50 ?) + +*Pourquoi on pose cette question : pour dimensionner la durée de la démo live. +Au-dessus de 15 étapes, on découpe en segments ou on enregistre une vidéo +backup pour la partie répétitive.* + +### C3. Est-ce que tu valides parfois plusieurs dossiers à la suite en **batch** (par exemple le lundi matin pour les passages du week-end) ? + +*Pourquoi on pose cette question : le batch est le cas d'usage où Léa brille +(2 min × 30 dossiers = 1 h gagnée d'un coup). Si elle dit oui, c'est notre +scénario A idéal.* + +### C4. Y a-t-il un cas où tu **croises plusieurs logiciels** (DPI + courrier SAMU + Excel de suivi + mail au médecin) pour clôturer un dossier ? + +*Pourquoi on pose cette question : **démo multi-app = effet "waouh" +maximum** devant les RPA-experts qui savent que ça casse leurs bots UiPath. +Si elle dit oui et que le workflow est reproductible, c'est notre scénario +roi.* + +### C5. Si je te disais "Léa lit un dossier urgence et te dit : il manque un acte (ECG non codé = 42 €)", est-ce que ton DG comprendrait tout de suite la valeur ? + +*Pourquoi on pose cette question : test de pitch. Si elle dit "oui, il +comprend direct", on tient notre angle. Si elle dit "non, il s'en fout, c'est +la TIM qui décide", on doit repivoter vers un autre interlocuteur (DAF ? DIM ?).* + +--- + +## Bloc D — Contraintes pratiques (4 questions) + +### D1. Ton poste c'est quoi exactement ? Windows 10 ou 11 ? Portable ou fixe ? Un ou deux écrans ? + +*Pourquoi on pose cette question : Léa tourne sur Windows 10/11. Si c'est un +vieux Windows 7 ou un thin client sans OS local, on a un problème. Le nombre +d'écrans change la captation (multi-screen = plus complexe).* + +### D2. Ton DPI urgences, il tourne **en local sur le poste** ou via Citrix / VDI / bureau à distance ? + +*Pourquoi on pose cette question : **question cruciale**. Si c'est Citrix, on +est en zone 100 % vision sans accessibility tree. C'est notre cas nominal mais +il faut s'y préparer (latence serveur +50-100 ms). Si c'est natif, démo plus +fluide.* + +### D3. Est-ce qu'il y a un antivirus corporate agressif sur ton poste (Kaspersky, Sophos, Cortex XDR, Defender ATP) ? + +*Pourquoi on pose cette question : certains AV bloquent les outils qui +capturent l'écran ou simulent clavier/souris. Il faut prévoir une exception +IT **avant** l'installation, pas le jour de la démo.* + +### D4. Pour la démo, on veut travailler sur des **données fictives anonymisées**, jamais de vrais patients. Tu peux nous préparer 5-10 dossiers urgences fictifs représentatifs, ou on doit les créer ensemble ? + +*Pourquoi on pose cette question : **RGPD non négociable**. Même si la TIM dit +"y a pas de souci, on prend des vrais", on refuse. Montrer en démo un vrai +dossier = fin de carrière commerciale. Il faut des dossiers fictifs, mais +réalistes (pathos plausibles, codes cohérents, chiffres crédibles).* + +--- + +## Questions bonus si on a le temps (à piocher) + +- **Depuis combien de temps tu fais ce métier ?** (crédibilise la démo si + elle est expérimentée) +- **Vous êtes combien de TIM dans l'établissement ?** (volume de déploiement) +- **Ton DIM (Département Information Médicale) connaît combien tu récupères + par an sur les urgences ?** (pour calibrer le pitch ROI) +- **Y a-t-il déjà eu une tentative d'automatisation sur ton poste (UiPath, AA, + BluePrism, macro Excel) ?** (pour anticiper le "on a déjà essayé, ça a pas + marché" du DG) + +--- + +## Après l'entretien — synthèse en 10 minutes + +À remplir **tout de suite après**, pendant que c'est frais : + +- [ ] Workflow démo choisi : __________________________________________ +- [ ] Nombre d'étapes : _______ +- [ ] DPI principal : _______ +- [ ] Outils annexes : _______ +- [ ] Chiffre ROI par dossier : ___ € +- [ ] Volume annuel extrapolable : ___ dossiers/an/clinique → ___ €/an +- [ ] Citrix/VDI : Oui / Non +- [ ] Antivirus à faire débloquer : _______ +- [ ] Histoire vraie utilisable en ouverture : _______________________ +- [ ] TIM invitée à la démo du 26 avril : Oui / Non / Peut-être +- [ ] Date de tournage de la **vidéo de backup** (au cas où démo live plante) : + _______ + +--- + +## Règles d'or pendant l'interview + +1. **Ne pas lui vendre Léa.** On écoute, on ne pitch pas. Elle nous parlera + plus librement si elle ne se sent pas en position de cliente. +2. **Ne pas promettre qu'on va faire X.** On dit "on regarde si c'est faisable", + jamais "Léa le fera en démo" — tant qu'on ne l'a pas testé. +3. **Prendre des notes écrites.** Pas d'enregistrement audio sans accord + écrit (RGPD). +4. **Demander si elle est partante pour la démo en présentiel.** Sa présence + à Paris/Lyon le 26 avril vaut 10 slides. +5. **Lui demander son retour sincère.** À la fin : "Tu nous trouves + crédibles ? Qu'est-ce qui te rendrait sceptique à la place des DG ?" diff --git a/docs/demo/GUIDE_INSTALL_AGENT_TIM.md b/docs/demo/GUIDE_INSTALL_AGENT_TIM.md new file mode 100644 index 000000000..7a5352a5a --- /dev/null +++ b/docs/demo/GUIDE_INSTALL_AGENT_TIM.md @@ -0,0 +1,259 @@ +# GUIDE D'INSTALLATION — Agent Léa sur poste TIM + +**Public** : TIM (Technicienne Information Médicale) ou son service informatique. +**Durée cible** : **10 minutes** (hors téléchargement). +**Prérequis** : Windows 10/11, compte avec droits utilisateur standard (pas besoin d'admin sauf étape Python), accès Internet, **DPI urgences fonctionnel** sur le poste. + +> **Avant de commencer** : vérifier que la TIM peut ouvrir son DPI urgences +> habituel (Resurgences, Urqual, DxCare, CristalNet, Hôpital Manager…) et y +> naviguer normalement. Si le DPI passe par Citrix/VDI, le vérifier avant +> d'installer Léa. **Si le DPI ne marche pas, l'agent ne servira à rien.** + +--- + +## Pourquoi pas un installeur `.exe` ? + +On livre un **ZIP + scripts**, pas un installeur Inno Setup. Raison : un `.exe` +non signé (code-signing EV à 500 €/an) déclenche le SmartScreen rouge Windows +("Windows a protégé votre PC") + l'antivirus corporate. Sur le poste d'une TIM +en clinique, **c'est la pire première impression possible**. + +L'approche ZIP + `.bat` passe sous le radar du SmartScreen et s'installe dans +le dossier utilisateur (pas besoin d'admin pour le copier). + +--- + +## Étape 1 — Récupérer le package (1 min) + +1. Télécharger `Lea_v1.0.0.zip` depuis l'URL fournie par Dom + (lien Owncloud interne ou clé USB si réseau isolé). +2. Fichier attendu : environ **5 Mo**. +3. **Vérifier l'intégrité** : clic droit sur le ZIP → Propriétés → si un bouton + "Débloquer" est visible en bas, le cocher puis OK (sinon Windows peut + bloquer l'exécution des `.bat`). + +--- + +## Étape 2 — Extraire dans `C:\rpa_vision\Lea\` (1 min) + +1. Créer le dossier **`C:\rpa_vision\Lea\`** (ou `C:\Lea\` si l'admin préfère). +2. Clic droit sur `Lea_v1.0.0.zip` → **Extraire tout…** → choisir + `C:\rpa_vision\Lea\` → Extraire. +3. Vérifier que le dossier contient : + + ``` + C:\rpa_vision\Lea\ + ├── install.bat + ├── Lea.bat + ├── config.txt + ├── LISEZMOI.txt + ├── requirements_agent.txt + └── run_agent_v1.py + ``` + +> **Piège** : si Windows extrait un dossier intermédiaire +> (`Lea_v1.0.0\Lea_v1.0.0\...`), **déplacer le contenu d'un cran** pour que +> `install.bat` soit à la racine `C:\rpa_vision\Lea\`. + +--- + +## Étape 3 — Vérifier Python (1 min) + +Ouvrir une invite de commandes (`Démarrer` → taper `cmd` → Entrée) et taper : + +``` +python --version +``` + +- **Si ça affiche** `Python 3.10.x` à `3.12.x` → OK, passer à l'étape 4. +- **Si erreur** `'python' n'est pas reconnu` → installer Python : + 1. Aller sur https://www.python.org/downloads/ + 2. Télécharger Python 3.12.x + 3. **Important** : pendant l'installation, **cocher + "Add Python to PATH"** (case du bas, souvent décochée). + 4. Terminer, fermer/rouvrir l'invite de commandes, re-tester + `python --version`. + +> Python installé en "Microsoft Store" fonctionne aussi mais peut poser des +> soucis de PATH. Si ça bloque, désinstaller la version Store et installer +> celle de python.org. + +--- + +## Étape 4 — Configurer `config.txt` (2 min) + +Ouvrir `C:\rpa_vision\Lea\config.txt` **avec le Bloc-notes** +(clic droit → "Ouvrir avec" → Bloc-notes). + +Vérifier/modifier ces 3 lignes : + +``` +RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 +RPA_API_TOKEN= +RPA_SERVER_HOST=lea.labs.laurinebazin.design +``` + +- **URL du serveur** : celle de prod publique OU une URL interne si on bascule + sur un serveur clinique pendant la démo. +- **Token** : Dom fournit un token **dédié à la TIM** (révocable). Ne pas + réutiliser le token de dev. +- **Sauvegarder** (Ctrl+S), fermer. + +> Ne **pas** toucher aux autres lignes (`RPA_BLUR_SENSITIVE`, etc.). + +--- + +## Étape 5 — Lancer `install.bat` (3-4 min) + +1. Double-cliquer sur `install.bat`. +2. Une fenêtre noire s'ouvre avec le titre "Lea - Installation". +3. Écrans attendus dans l'ordre : + + ``` + [1/5] Verification de Python... + Python 3.12.2 detecte - OK + [2/5] Creation de l'environnement isole... + Environnement cree - OK + [3/5] Activation de l'environnement... + Active - OK + [4/5] Installation des composants (cela peut prendre 1-2 min)... + Composants installes - OK + [5/5] Configuration Windows... + Configuration terminee - OK + Tous les composants sont OK ! + Installation terminee ! + ``` + +4. Appuyer sur une touche pour fermer. + +> **Durée typique** : 2-3 minutes (dépend de la bande passante — `pip install` +> télécharge ~50 Mo). + +--- + +## Étape 6 — Lancer Léa (30 s) + +1. Double-cliquer sur `Lea.bat`. +2. Une fenêtre noire affiche : + + ``` + Demarrage de Lea... + (Lea apparait dans la barre des taches, en bas a droite) + ``` + +3. **Au bout de 3-5 secondes**, une **icône ronde** apparaît dans la barre + des tâches (en bas à droite de l'écran, à côté de l'horloge, parfois + cachée sous la flèche `^`). +4. **Clic droit sur l'icône** → menu : + - Apprenez-moi une tâche + - C'est terminé + - Discuter avec Léa + - ARRÊT D'URGENCE + - Quitter Léa + +Si l'icône apparaît et que le menu s'ouvre → **installation réussie**. + +> La fenêtre noire peut être fermée. L'agent tourne en arrière-plan via +> `pythonw.exe`. + +--- + +## Étape 7 — Vérifier côté dashboard (1 min) + +Côté Dom, depuis un navigateur : + +``` +GET https://lea.labs.laurinebazin.design/api/v1/agents/fleet +Header: Authorization: Bearer +``` + +La réponse doit contenir une entrée avec : + +```json +{ + "agent_id": "", + "status": "online", + "last_seen": "2026-04-...", + "version": "1.0.0" +} +``` + +Si le poste n'apparaît pas après 30 s, voir la section "Si ça plante" ci-dessous. + +Alternative : ouvrir le dashboard web (`http://:5001`) → onglet +"Flotte" → le poste de la TIM doit s'afficher en vert. + +--- + +## Si ça plante (5 cas les plus probables) + +### 1. "Token invalide" / erreur 401 dans les logs + +- Vérifier que `RPA_API_TOKEN` dans `config.txt` **ne contient pas d'espace** + en début/fin, **pas de guillemets**, pas de retour à la ligne. +- Le format attendu : `RPA_API_TOKEN=abc123def...` (sans espace autour du `=`). +- Si le token a été copié-collé depuis un mail, il peut contenir un caractère + invisible. **Retaper à la main** ou recopier depuis un éditeur brut. + +### 2. "Python n'est pas installé" alors qu'il l'est + +- `python --version` marche dans un cmd "normal" mais `install.bat` le trouve + pas → Python est installé **pour l'utilisateur** mais la TIM lance le `.bat` + en mode admin (session différente). +- **Solution** : double-cliquer sur `install.bat` en user normal, PAS en + "Exécuter en tant qu'administrateur". + +### 3. Firewall bloque la connexion au serveur + +- Au premier lancement, Windows Defender peut demander + "Autoriser pythonw.exe à communiquer sur le réseau ?" → **Autoriser**. +- Si firewall corporate plus strict : demander au service IT d'autoriser + les connexions sortantes vers `lea.labs.laurinebazin.design:443` (HTTPS). +- Test rapide depuis le poste : ouvrir un navigateur sur + `https://lea.labs.laurinebazin.design/health` → doit répondre `{"status":"ok"}`. + +### 4. Antivirus bloque `pythonw.exe` + +- Certains AV corporate (Kaspersky, Sophos, Cortex XDR) mettent `pythonw.exe` + en quarantaine dès qu'il capture l'écran. +- Symptôme : `Lea.bat` affiche "Lea n'a pas demarre correctement" et le mode + verbeux montre un `pythonw.exe n'a pas pu se lancer` ou rien du tout. +- **Solution** : demander au service IT d'ajouter **une exception pour le + dossier** `C:\rpa_vision\Lea\.venv\Scripts\` (et pas juste pour `pythonw.exe` + globalement — ce serait une faille de sécurité). + +### 5. Double-clic sur `Lea.bat` ouvre le Notepad + +- Cause classique : la TIM a fait clic droit → "Ouvrir avec…" → "Toujours + utiliser Notepad" une fois par erreur. Windows a associé `.bat` à Notepad. +- **Solution** : + 1. Clic droit sur `Lea.bat` → "Ouvrir avec" → "Choisir une autre application" + → "Plus d'applications" → "Rechercher une autre app sur ce PC" + → `C:\Windows\System32\cmd.exe` → **ne PAS cocher** "Toujours utiliser". + 2. Ou alternative rapide : ouvrir un cmd, taper + `cd C:\rpa_vision\Lea` puis `Lea.bat`. + +--- + +## Désinstaller Léa (si besoin) + +1. Clic droit sur l'icône Léa → "Quitter Léa". +2. Supprimer le dossier `C:\rpa_vision\Lea\`. +3. (Optionnel) Supprimer les logs dans `%LOCALAPPDATA%\Lea\` si existant. + +Pas de désinstalleur, pas de clé registre, pas de service : Léa est un **binaire +portable**. C'est voulu : aucune trace système, facile à auditer. + +--- + +## Check final avant la démo (à faire ensemble avec la TIM) + +- [ ] Icône Léa visible dans la tray. +- [ ] Clic droit → menu s'ouvre. +- [ ] Dashboard côté serveur affiche le poste en "online". +- [ ] Le DPI urgences de la TIM s'ouvre et répond normalement (pas de lenteur). +- [ ] Démo d'enregistrement de 30 s : clic droit → "Apprenez-moi une tâche" + → faire 2-3 clics → clic droit → "C'est terminé". Vérifier côté serveur + que la session arrive. + +**Si tout est vert → on est prêts pour le 26 avril.** diff --git a/docs/demo/SCENARIOS_DEMO_URGENCES.md b/docs/demo/SCENARIOS_DEMO_URGENCES.md new file mode 100644 index 000000000..0a47f117d --- /dev/null +++ b/docs/demo/SCENARIOS_DEMO_URGENCES.md @@ -0,0 +1,284 @@ +**SCÉNARIOS DE DÉMO — Urgences (26 avril 2026)** +**Contexte** : 10-20 DG/DSI de groupements de cliniques, dont plusieurs +  + RPA-experts (UiPath, Automation Anywhere). Pitch duo Amina + Dom. +**Cadre narratif** : Amina a prouvé manuellement 150 k€/mois de +  + récupération PMSI urgences par clinique. **Léa est le scaler de cette** + ** + méthode prouvée** — pas une techno RPA de plus. +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OMQ2AABAAsSNBCkLfE07YGfHAiAU2QtIq6DIzW7UHAMBfnGt1V8fXEwAAXrse4eQF6VhvmPsAAAAASUVORK5CYII=) +**Critères d'évaluation (grille commune aux 3 scénarios)** +| | | | +|-|-|-| +| **Critère** | **Description** | **Note** | +| Impact émotionnel DG | Est-ce que ça fait lever les sourcils ? | /5 | +| Faisabilité technique | On sait le faire aujourd'hui sans rustine ? | /5 | +| Risque démo live | Probabilité que ça plante devant 20 personnes | /5 (5 = très risqué) | +| Reproductibilité | On peut le refaire dans 3 pilotes différents | /5 | +| Crédibilité ROI | Le chiffre annoncé est défendable si un DAF challenge | /5 | +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAALUlEQVR4nO3OQQ0AIAwEsAMlSJ0UrOFkGngRklZBR1WtJDsAAPzizNcDAADuNcKwAyU+nb+5AAAAAElFTkSuQmCC) +**Scénario A — "L'audit rétrospectif des actes oubliés"** +**Titre pitchable** +**"Récupération de 15 000 € d'actes oubliés en 5 minutes — sur un lot de** + ** + 30 dossiers urgences de la semaine."** +**Durée démo live** +**5 minutes** (+ 2 min de commentaire par Amina sur le chiffrage). +**Pitch en une phrase** +Léa relit les dossiers urgences de la semaine écoulée, détecte les actes +  + cliniques non codés, propose les corrections, chiffre le gain PMSI. +  +  +**Étapes de la démo** +1. **[Dom, 10 s]** Ouvre l'interface Léa (dashboard). Montre un dossier +  + d'entrée : "30 passages urgences du 15 au 19 avril, à auditer." +2. **[Amina, 15 s]** "Normalement, c'est ce que je fais moi-même, à la main, +  + en une après-midi. Je vais vous montrer ce que Léa fait en 3 minutes." +3. **[Dom, 3 min]** Lance Léa en mode "audit rétrospectif". À l'écran : +  + Léa ouvre le DPI (Resurgences en natif ou en vidéo captée), parcourt +  + chaque dossier, lit le compte-rendu médical, compare aux actes cotés. +  + Dans une side-pane dashboard, on voit apparaître en temps réel : +- Dossier 1 : ✅ OK +- Dossier 2 : ⚠️ ECG mentionné dans CR, non codé → +42 € +- Dossier 3 : ⚠️ Suture complexe codée comme simple → +78 € +- Dossier 4 : ✅ OK +- ... (avec un compteur ROI qui monte) +4. **[Amina, 30 s]** Commentaire pendant que ça défile : "Regardez, ça, c'est +  + exactement ce que j'aurais vu. Et là, on est à 14 800 € de récupération +  + sur **une semaine**." +5. **[Dom, 30 s]** Fin de l'audit. Léa affiche un rapport final : 12 actes +  + oubliés, 3 erreurs de cotation, total **14 850 €**. Export CSV + mail +  + automatique au médecin urgentiste pour validation. +6. **[Amina, 60 s, closing du scénario]** "Sur un volume annuel de 50 000 +  + passages urgences par clinique — un groupement de 10 cliniques c'est + **plus de 8 M€/an** de valorisation récupérable. Et on ne parle que des +  + urgences." +**Chiffre clé à afficher** +- **14 850 €** récupérés sur 30 dossiers = **495 €/dossier en moyenne** +- Projection : **150 000 €/mois/clinique** (la preuve Amina, sans IA, donc + borne inférieure) +- Groupement 10 cliniques = **18 M€/an de potentiel** +  +  +**Prérequis** +- **Corpus de 30 dossiers fictifs** réalistes — à préparer avec la TIM +- Léa connaît les patterns de codage PMSI urgences (prompts métier déjà +  + chargés) +- Dashboard avec side-pane "audit en cours" prête +- Vidéo de backup enregistrée **avant la démo** (au cas où) +**Risques live & mitigation** +| | | | +|-|-|-| +| **Risque** | **Proba** | **Mitigation** | +| Léa rate un code évident | Moyenne | Préalable : **tourner le scénario 5× à l'avance**, fixer le corpus | +| Latence serveur sur Citrix | Moyenne | Basculer sur DPI natif local pour la démo | +| Amina coupe Dom pour commenter trop tôt | Élevée | **Répétition en binôme la veille** | +| Un DG dit "vous avez scripté ça" | Haute | Proposer un **pilote 2 semaines chez lui** tout de suite | +  +**Notes** +- **Scénario préféré** : très visuel, chiffrage direct, colle au narratif +  + Amina, risque maîtrisable. +- Avantage : la partie "Léa lit/compare" peut être accélérée en post-prod +  + si on passe sur une vidéo backup. +- Limite : ne montre pas la capacité **autonomous** de Léa (elle ne clique +  + pas pour valider, elle propose). À compléter éventuellement par B ou C. +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANElEQVR4nO3OQQmAUBBAwSfIb+HdmNvAkgaxgjcRZhLMNjNHdQUAwF/ce7Wq8+sJAACvrQctewNKtdojwQAAAABJRU5ErkJggg==) +**Scénario B — "L'assistant temps réel"** +**Titre pitchable** +**"Pendant que la TIM code un dossier qui vient de sortir, Léa regarde** + ** + par-dessus son épaule et signale ce qu'elle oublie."** +  +  +**Durée démo live** +**3 minutes**. +**Pitch en une phrase** +La TIM code normalement. Léa observe l'écran, compare au CR médical, et +  + pop-up une alerte quand elle détecte un acte manquant. +**Étapes de la démo** +1. **[Dom, 15 s]** "La TIM (ou un intervenant qui joue la TIM — idéalement +  + la vraie TIM si elle est venue) ouvre un dossier. Léa tourne en arrière- +  + plan." +2. **[TIM, 90 s]** Code un dossier comme d'habitude. Amina commente ce +  + qu'elle fait ("elle ouvre le CR, elle regarde les actes, elle saisit…"). +3. **[Pop-up Léa, 5 s]** Dans le coin, une bulle apparaît : +  + "Acte probablement manquant : monitoring cardiaque (mentionné ligne 3 +  + du CR) — +28 €. Confirmer ?" +4. **[TIM, 15 s]** Clique "Confirmer". Léa ajoute le code dans le DPI +  + (ou propose le code et la TIM le saisit — à choisir selon niveau de +  + risque). +5. **[Amina, 45 s, closing]** "En temps réel. Pas de batch. Pas de +  + vérification rétrospective. La TIM garde la main, Léa est le filet." +**Chiffre clé à afficher** +- **80 % des actes oubliés** détectés en temps réel +- **+8 à 12 min par dossier économisées** (pas besoin de revenir dessus + J+1) +- "Un filet de sécurité sur chaque dossier" +  +  +  +  +**Prérequis** +- Un dossier fictif avec **au moins un acte clairement détectable** (ECG ou +  + monitoring) +- La pop-up Léa visuellement propre (pas de dialog Windows moche) +- Couplage visuel OCR du CR ↔ interface de saisie (à tester spécifiquement) +- Latence < 5 secondes entre le moment où la TIM est sur le bon écran et la +  + pop-up Léa +**Risques live & mitigation** +| | | | +|-|-|-| +| **Risque** | **Proba** | **Mitigation** | +| Pop-up n'apparaît pas / trop tard | **Haute** | Fixer un déclencheur manuel de secours (Amina dit "et regardez, Léa a détecté…") | +| Faux positif en direct | Moyenne | Trigger garde-fou : seuil de confidence > 0.8 | +| TIM stressée, perd ses moyens | Moyenne | **Répétition 3× la veille** si la TIM joue live | +| Écran capturé mal rendu au projecteur | Haute | Test projecteur 1h avant, résolution fixe | +  +**Notes** +- Effet "magie" maximum si ça marche. +- **Risque de plantage > scénario A.** À faire en **deuxième position**, pas +  + en ouverture. +- Peut être joué en "complément" du A (audit rétrospectif, puis "et en +  + temps réel, voici ce que Léa fait aussi"), en 2 min flat. +- Si la TIM est venue à la démo : énorme plus-value émotionnelle (elle +  + raconte elle-même). Si elle n'est pas là, Dom ou Amina joue son rôle, +  + ce qui est moins crédible. +  +  +  +  +  +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBACPiUML0NpGACyywEZJWQZeZ2aszAAD+4l6rrTq+ngAA8Nr1AL/SBEZwuCSwAAAAAElFTkSuQmCC) +**Scénario C — "Le codeur autonome"** +**Titre pitchable** +**"Léa code seule un dossier urgence complet — admission, examens, actes,** + ** + sortie — en 90 secondes. La TIM valide, c'est parti au PMSI."** +**Durée démo live** +**7 minutes** (2 min setup + 90 s exécution Léa + 3 min commentaires + +  + 30 s validation). +**Pitch en une phrase** +Léa prend un CR brut d'urgence, ouvre le DPI, navigue dans les écrans, +  + remplit les champs RUM + RPU, valide le codage PMSI. Humain en superviseur. +**Étapes de la démo** +1. **[Dom, 30 s]** Montre le CR en entrée : texte brut (1 page). +2. **[Amina, 30 s]** "Normalement, coder ce dossier me prend 4-5 minutes. +  + Regardez Léa." +3. **[Dom, 90 s]** Lance Léa en mode autonomous. À l'écran : +- Léa ouvre le DPI (clic sur l'icône du bureau) +- Navigue dans le menu "nouveau passage" +- Saisit nom, prénom, date de naissance (issus du CR) +- Remplit motif d'entrée (CIM-10 auto depuis CR) +- Navigue vers "actes réalisés", cote chaque acte +- Remplit diagnostic principal + associés +- Clique "Enregistrer" → dialogue de validation +- **S'arrête** sur la validation finale +4. **[Amina, 30 s]** "Léa s'arrête ici **volontairement**. C'est la TIM qui +  + valide. On garde l'humain dans la boucle." +5. **[TIM ou Dom, 15 s]** Valide → message "RUM/RPU envoyés au PMSI". +6. **[Amina, 2 min, closing]** "4 minutes économisées par dossier. +  + 50 dossiers/jour. 10 cliniques. Faites le calcul." (et elle projette.) +  +  +**Chiffre clé à afficher** +- **90 s vs 4 minutes** (division par 2.5 à 3 du temps) +- **Sur un volume de 50 k passages/an** : 3 300 heures TIM économisées/an +- Projection ROI : dépend du chargé TIM (60 k€/an chargé = **~160 k€ de** + **temps dégagé/clinique/an**, hors récupération PMSI) +**Prérequis** +- Workflow **très** répété, testé 20 fois au moins avant la démo +- DPI cible **fixé et gelé** (pas de mise à jour 48h avant) +- Mode autonomous Léa stable (voir Phase 3 roadmap : probablement **pas** + ** + encore prêt le 26 avril**) +- Vidéo de backup non négociable +- Plan B : passer en "Léa remplit les champs un à un, la TIM valide +  + étape par étape" (demi-autonomous, moins risqué) +**Risques live & mitigation** +| | | | +|-|-|-| +| **Risque** | **Proba** | **Mitigation** | +| Léa rate un clic au milieu | **Très haute** | Vidéo de backup + plan B demi-autonomous | +| DPI a changé d'UI depuis la capture | Haute | Freeze DPI version 48h avant | +| Timing perçu comme "lent" par le public | Moyenne | Accélérer en post-prod (si vidéo) | +| Question acerbe d'un RPA-expert sur l'UI drift | Haute | Réponse cadrée (cf. FAQ, question "UI qui change") | +| Dom stressé et Léa refuse de démarrer | Moyenne | 15 min de setup tranquille avant + test final 5 min avant | +  +**Notes** +- **Le plus spectaculaire**, mais aussi **le plus risqué**. +- **À GARDER POUR PLUS TARD** — début juin, voire fin mai. Le 26 avril, +  + Léa en full autonomous devant des RPA-experts = roulette russe. +- Option : montrer ce scénario **en vidéo enregistrée** en bonus (2 min), + pas en live. On garde l'impact sans le risque. +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OQQmAABRAsSd49m4tA8nPaQJjWMGbCFuCLTOzV2cAAPzFvVZbdXw9AQDgtesBorcEPwOKyvQAAAAASUVORK5CYII=) +  +**Recommandation** +**Plan proposé pour la démo du 26 avril** +1. **Ouverture Amina** (2 min, storytelling 150 k€/mois urgences). +2. **Démo principale = scénario A** (audit rétrospectif, 5 min). +3. **Bonus = scénario B** (assistant temps réel, 3 min), **uniquement si** + ** + la TIM est présente et à l'aise**. Sinon on saute. +4. **Teaser = scénario C en vidéo** (2 min, "voilà ce qu'on déploiera en +  + pilote"), pas en live. +5. **Closing Amina** (3 min, ROI projetté, appel à pilote). +**Pourquoi ce plan** +- **A en premier** : visuel, chiffré, quasi zéro risque live, parle + directement aux DG. +- **B en bonus** : effet "waouh" si on a les billes, skipable sinon. +- **C en vidéo** : montre l'ambition/roadmap sans se prendre un plantage + en pleine figure. +- **Amina bookends** : c'est elle qui ouvre et ferme. Elle est la crédibilité + métier. Dom est l'exécution. +**Question ouverte à trancher** +**Est-ce qu'on invite la TIM à la démo du 26 avril ?** +- Oui = scénario B devient solide, mais +1 logistique (transport, hôtel, +  + briefing, déblocage de sa journée avec la clinique). +- Non = on joue tous les scénarios en simulation, narratif un peu moins fort. +- **À décider avec Amina demain matin** en fonction de son feeling +  + sur la TIM pendant l'interview. diff --git a/docs/demo/TEMPLATE_PITCH_DUO.md b/docs/demo/TEMPLATE_PITCH_DUO.md new file mode 100644 index 000000000..bd6832401 --- /dev/null +++ b/docs/demo/TEMPLATE_PITCH_DUO.md @@ -0,0 +1,326 @@ +**SCRIPT DE PITCH DUO — Démo 26 avril 2026** +**Durée totale** : **15 minutes** (strict, on coupe tout ce qui dépasse). +**Duo** : **Amina ETTORCHI** (métier, ROI, closing) + **Dom** (technique, +  + démo, réponses techniques). +**Principe** : Amina ouvre, Amina ferme. Dom exécute au milieu. C'est +  + **Amina la figure de crédibilité** pour cette audience (TIM/DIM/DG savent +  + qu'une présidente de société qui vient du terrain PMSI vaut 10 ingénieurs). +**Objectif closing** : **3-5 rendez-vous pilote** pris avant que la salle +  + se vide. +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANUlEQVR4nO3OQQmAABRAsSeYxKS/kJkED6bwYAVvImwJtszMVu0BAPAXx1rd1fn1BACA164HHDwF+DpPyKwAAAAASUVORK5CYII=) +**BEAT 1 — Opening Amina (2 min)** +**Qui parle** +**Amina seule**, debout, sans slide au début (slide 1 : logo AIVANOV + titre). +**Message clé** +"J'ai prouvé 150 000 €/mois de récupération aux urgences **sans aucune** + ** + technologie**. Ce que je vais vous montrer aujourd'hui, c'est comment on +  + scale ça." +**Exemples de phrases à dire (à peu près mot à mot)** +*"Bonjour. Je m'appelle Amina ETTORCHI, je suis présidente d'AIVANOV. Avant* * * *ça, j'ai été TIM, j'ai été responsable département d'information médicale.* + * + Pendant 15 ans, je suis allée dans vos cliniques. J'ai lu vos dossiers* + * + * +  +*urgences."* +*"Et j'ai trouvé * ***systématiquement*** * la même chose : entre 100 000 et* + * + 180 000 € par mois et par clinique, de valorisation PMSI qui partait* + * + à la poubelle. Des actes pas codés. Des sutures complexes marquées* + * + simples. * +*Des ECG oubliés."* * * *"J'ai fait ça * ***à la main*** *, en lisant les dossiers un par un. J'ai récupéré* * * *cet argent pour mes clients. Sans IA. Sans automatisation. Juste avec de* * * *l'expertise et du temps."* +*"Aujourd'hui, je vais vous présenter Léa. Léa, c'est moi. En plus rapide,* * * *24 h/24, sur tous vos dossiers, pas juste ceux que j'ai eu le temps de* * * *lire. C'est Dom qui va vous la montrer."* +**À l'écran pendant ce beat** +- **Slide 1** : logo AIVANOV + Amina ETTORCHI + "150 000 €/mois/clinique +  + prouvés sans IA" +- **Slide 2** (en fond) : un chiffre massif — "+150 k€ / mois / clinique" +**Durée** +**2 min strict**. Si Amina dépasse à 2 min 30 s, Dom glisse un signal visuel +  + discret (pointer l'horloge, tousser). **La règle : ne pas entrer dans la** + ** + technique à ce beat.** +**Piège à éviter** +- Ne pas dire "on est une startup qui démarre" → on dit "on industrialise ce + que j'ai fait pendant 15 ans à la main". +- Ne pas lister les DPI supportés → Amina reste sur le chiffre. +- Ne pas montrer de graph → storytelling pur, pas de slide data. +  +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBACPiUML0NpGACyywEZJWQZeZ2aszAAD+4l6rrTq+ngAA8Nr1AL/SBEZwuCSwAAAAAElFTkSuQmCC) +**BEAT 2 — Transition Dom (1 min)** +**Qui parle** +**Dom**, sur scène, assis ou debout à côté d'Amina. +**Message clé** +"Voilà l'idée technique : on a construit une IA qui regarde l'écran comme Amina, pas comme un bot UiPath." +**Exemples de phrases à dire** +*"Merci Amina. Concrètement, Léa est une assistante qui * ***regarde votre*** *** *** ***écran comme un humain*** *. Pas de connexion API, pas de DOM, pas de* * * *configuration par workflow. Elle * ***voit*** * le DPI. Elle * ***comprend*** * ce* * * *qu'elle voit. Elle * ***agit*** *."* +*"C'est important de le préciser tout de suite parce que plusieurs d'entre* * * *vous ont déjà déployé de l'UiPath ou de l'Automation Anywhere. * ***On ne*** *** *** ***remplace pas UiPath.*** * UiPath est très bon sur la compta, sur les RH. Mais* * * *sur un dossier urgence, où l'UI change selon le patient, où le DPI passe* * * *par Citrix — UiPath a beaucoup de mal. Pas Léa."* +*"Je vais vous montrer. Je ne vais pas faire de slides, je vais lancer* + * + Léa."* +**À l'écran pendant ce beat** +- **Slide 3** : schéma simple — Léa = "Observe → Comprend → Agit" (3 pictos, +  + pas de jargon) +- **Transition visible** vers le desktop de démo à la fin du beat +  +  +  +  +**Durée** +**1 min strict**. +**Piège à éviter** +- Ne pas entrer dans les détails du VLM ou du grounding visuel ici → c'est + pour la FAQ en fin de session. +- Ne pas dire "c'est 100 % local" ici → on le dit au beat 5 (vision/roadmap), pour garder de la munition. +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBCkLfFR7wwIgHRiywEZJWQZeZ2ao9AAD+4lyruzq+ngAA8Nr1AOIEBeX8aGZPAAAAAElFTkSuQmCC) +**BEAT 3 — Démo live (5-7 min selon scénario)** +**Qui parle** +**Dom pilote**, **Amina commente en temps réel** (métier/impact). +**Message clé** +"Regardez ce que Léa fait. Regardez ce qu'elle récupère." +**Structure (scénario A recommandé — audit rétrospectif)** +1. **[Dom, 15 s]** Lance le dashboard, montre l'entrée : "30 passages +  + urgences du 15 au 19 avril, à auditer." +2. **[Amina, 15 s]** "Ces 30 passages, je les aurais faits moi-même en 4 h. +  + Regardez Léa en 3 minutes." +3. **[Dom, 2-3 min]** Lance Léa. Les détections défilent à l'écran : +- Dossier 5 : ECG non codé → +42 € +- Dossier 12 : suture complexe → +78 € +- etc. +4. **[Amina, 20 s, à deux moments]** Commentaires courts pendant le défilé : +  + "Regardez, ça c'est typique." / "Sur celui-là, le médecin aurait +  + pris 10 min pour rechercher. Léa 3 secondes." +5. **[Dom, 30 s]** Rapport final affiché : **14 850 €** sur 30 dossiers. +6. **[Dom → Amina, 10 s]** "Et maintenant, Amina te projette le chiffre." +  +  +**Exemples de phrases à dire pendant la démo** +**Dom (technique, posé)** : +*"Là, Léa vient de lire le CR médical, et elle compare avec les actes* + *cotés dans le DPI. Elle voit qu'il manque un ECG mentionné en page 2. Elle* * * *propose le code. Elle ne valide pas elle-même, c'est la TIM qui valide.* * * *C'est un filet de sécurité, pas un remplacement."* +**Amina (métier, chaleureuse)** : +*"C'est exactement ce que je fais moi, sauf que Léa le fait en temps réel* * * *sur * ***tous*** * vos dossiers, pas juste ceux que j'ai le temps de lire."* +**À l'écran pendant ce beat** +- **Dashboard Léa** plein écran +- Side-pane "détections" avec compteur ROI qui monte (important : le +  + compteur en gros chiffre vert est **le** visuel qui accroche les DG) +**Durée** +**5 min strict scénario A**, **+2 min si on enchaîne sur scénario B bonus**. +**Piège à éviter** +- Ne **JAMAIS** dire "attendez, ça plante" ou "normalement ça marche" → +  + si ça plante, **Dom bascule en vidéo backup sans commentaire**, +  + continue comme si de rien n'était. +- Ne pas faire defiler trop vite les détections — laisser **les DG voir** + ** + chaque ligne** avec le chiffre. +- Amina ne coupe **pas** Dom en pleine exécution technique. Elle attend les +  + respirations. +  +  +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBACPiUML0NpGACyywEZJWQZeZ2aszAAD+4l6rrTq+ngAA8Nr1AL/SBEZwuCSwAAAAAElFTkSuQmCC) +**BEAT 4 — Chiffrage (2 min)** +**Qui parle** +**Amina seule**, retour sur une slide projection. +**Message clé** +"Multipliez ça par votre volume. Voilà ce que vous laissez sur la table +  + aujourd'hui." +**Exemples de phrases à dire** +*"On vient de voir 14 850 € récupérés sur * ***une semaine*** * de 30 dossiers.* + * + Vous, dans vos cliniques, vous traitez combien de passages urgences par an ?* + * + 30 000 ? 50 000 ? 100 000 pour un gros établissement ?"* +*"Sur 50 000 passages par an, en projetant le même taux de récupération,* + * + vous êtes à * ***2,5 millions d'euros de valorisation PMSI récupérée par an*** *** *** ***et par clinique*** *. Pour un groupement de 10 cliniques, on est à * ***25*** *** *** ***millions*** *. Ça, c'est avec les données que j'ai prouvées à la main.* + * + Léa ne fait que scaler."* +*"Je vous rappelle : ce ne sont * ***pas*** * des économies. C'est de l'argent* + * + qui vous revient de droit, que vous ne facturez pas aujourd'hui, parce que* * * *vos TIM n'ont pas le temps de tout relire."* +  +  +  +**À l'écran pendant ce beat** +- **Slide 4** : tableau projection +- 30 000 passages/an → 1,5 M€/an +- 50 000 passages/an → 2,5 M€/an +- 100 000 passages/an → 5 M€/an +- **Astérisque** : "Base Amina 2024-2026, borne basse. Pilote à chiffrer +  + chez vous." +**Durée** +**2 min strict**. +**Piège à éviter** +- Ne pas sous-estimer dans le chiffrage (prudent = perd en impact), ne pas surestimer (perd en crédibilité). **Borne basse + "à chiffrer chez vous"**. +- Ne pas dire "licensing", "coût", "abonnement" ici → c'est dans la FAQ ou + le closing. **Ici c'est la projection, rien d'autre.** +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAM0lEQVR4nO3OMQ0AIAwAwZIgBKm1gjSMNCwYYCIkd9OP3zJzRMQMAAB+sfqJeroBAMCN2pTWBSSZVtjzAAAAAElFTkSuQmCC) +**BEAT 5 — Vision + roadmap (2 min)** +**Qui parle** +**Dom ou Amina** (au choix, Amina préférable si elle est à l'aise avec la +  + technique), **court, honnête sur la maturité**. +**Message clé** +"On démarre sur les urgences. On n'est pas une techno générique. Et on est +  + 100 % local." +  +  +  +  +**Exemples de phrases à dire** +*"Deux choses importantes avant de conclure. Un : * ***Léa est 100 % locale*** *.* * * *Les données de vos patients * ***ne sortent jamais de votre SI*** *. Serveur sur* * * *site ou cloud souverain HDS, au choix. Pas de ChatGPT, pas de Claude, pas* * * *de Gemini. Tout est open source, tout tourne chez vous."* +*"Deux : on * ***ne promet pas*** * de faire tout tout de suite. On * ***démarre sur*** + ***les urgences*** *, parce que c'est là qu'Amina a l'expertise prouvée, et que* * * *l* *e ROI est évident. Après les urgences, dans l'ordre, on fera la* + *facturation, puis la pharmacie, puis le codage hospitalisation. On* + *n'essaie pas de tout faire en même temps."* +*"Pour le pilote : * ***6 à 8 semaines, 2 mois gratuits*** *, accompagnement* + *direct par Amina et mon équipe. Après le pilote, on contractualise."* +**À l'écran pendant ce beat** +- **Slide 5** : "100 % local, 100 % souverain" (en gros) + petit schéma +  + infrastructure simple (agent + serveur en dessous d'un cadenas) +- **Slide 6** : roadmap en 4 blocs — Urgences (2026) → Facturation (2027) → +  + Pharmacie (2027) → Hospit. (2028) +**Durée** +**2 min strict**. +**Piège à éviter** +- Ne pas lister la roadmap interne détaillée (phases 0/1/2/3, apprentissage, +  + etc.) → c'est du jargon interne, ils s'en fichent. +- Ne pas s'excuser de "on ne fait pas encore X" → transformer en +  + "la prochaine étape c'est X". +  +  +  +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBACPiUML0NpGACyywEZJWQZeZ2aszAAD+4l6rrTq+ngAA8Nr1AL/SBEZwuCSwAAAAAElFTkSuQmCC) +**BEAT 6 — Closing Amina + appel à pilote (1 min)** +**Qui parle** +**Amina seule**, debout, direct caméra/salle. +**Message clé** +"Qui veut qu'on mesure ensemble ce qu'on laisse sur la table chez vous ?" +**Exemples de phrases à dire** +*"On cherche * ***3 à 5 cliniques*** * pour démarrer un pilote, entre mai et* + *juin. Vous nous donnez un mois de dossiers urgences, nous, on vous* + *chiffre exactement ce que vous pourriez récupérer. * ***Sans engagement de*** *** *** ***contrat*** *. Si après un mois on n'a rien trouvé, on repart. Si on trouve,* * * *vous savez combien ça vaut pour vous."* +*"Je suis là pendant toute la pause. Venez me voir. On regarde ensemble* * * *sur votre cas précis."* +*"Merci."* +  +**À l'écran pendant ce beat** +- **Slide 7** (final, restera à l'écran pendant la pause) : +- "Pilote 6-8 semaines, 2 mois gratuits" +- Coordonnées Amina + Dom (mail, téléphone) +- QR code vers une landing page pour prendre RDV +**Durée** +**1 min strict**. Amina **ne** prend **pas** de questions depuis la scène, +  + les questions se font à la pause en one-to-one. **Pourquoi** : les questions +  + publiques attirent toujours le RPA-expert sceptique qui peut plomber +  + l'ambiance. Off-stage, on gère en tête-à-tête. +  +**Piège à éviter** +- Ne pas ouvrir un Q&R ouvert plénière → trop de risques, durée non maîtrisée. +- Ne pas dire "merci pour votre attention" mièvre → c'est sec, c'est franc. +  + "Venez me voir. » +  +- Ne pas oublier de dire **"sans engagement"** — c'est ce qui débloque le +  + DG hésitant. +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBCkLfFR7wwIgHRiywEZJWQZeZ2ao9AAD+4lyruzq+ngAA8Nr1AOIEBeX8aGZPAAAAAElFTkSuQmCC) +**ANNEXE A — Phrases toxiques à bannir** +- "On est encore en bêta." / "C'est un prototype." +- "On a rencontré quelques difficultés techniques." / "Ça ne marche pas + toujours." +- "On est une petite équipe." / "On est une startup qui débute." +- "Nos concurrents font mieux sur X." +- "UiPath c'est du passé." (trop arrogant, les RPA-experts dans la salle +  + l'ont payé cher, ne pas insulter leur choix) +- "Il faudra qu'on teste chez vous." (à remplacer par "le pilote est fait +  + pour ça, on chiffre ensemble") +- "Ça dépend." (tuer ce réflexe — toujours répondre par une borne concrète +  + même approximative) +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAM0lEQVR4nO3OUQmAQBBAwSdcjsu6HYxoDsEK/okwk2COmdnVGQAAf3GtalX76wkAAK/dDxFWBDkFf6+SAAAAAElFTkSuQmCC) +**ANNEXE B — Phrases magiques à caser** +- **"Vos données ne sortent pas de votre SI. 100 % local."** (beat 5) +- **"150 000 €/mois prouvés sans IA. Imaginez avec."** (beat 1) +- **"On ne remplace pas la TIM, on lui enlève les corvées."** (si question +  + RH) +- **"Filet de sécurité, pas remplacement."** (si question médecin/humain) +- **"Sans engagement, 2 mois gratuits."** (beat 6) +- **"On chiffre chez vous, pas en PowerPoint."** (si DG sceptique) +- **"Léa, c'est Amina. En plus rapide, 24 h/24."** (accroche beat 1) +- **"Ce n'est pas une économie, c'est de l'argent qui vous revient de** + ** + droit."** (beat 4) +![](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnEAAAACCAYAAAA3pIp+AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAANklEQVR4nO3OMQ2AABAAsSNBACPykMH4NpGACyywEZJWQZeZ2aszAAD+4l6rrTo+jgAA8N71AL/CBEiG5xPoAAAAAElFTkSuQmCC) +**ANNEXE C — Check-list matériel (48 h avant)** +**Technique** +- PC démo testé (autonomie 2 h, adaptateur écran HDMI + mini-DP) +- Connexion au serveur Léa testée **depuis le lieu de démo** (wifi +  + local si possible, 4G backup) +- Agent Léa installé sur le PC démo, test complet de 15 min la veille +- **Vidéo de backup** du scénario A enregistrée, dans un dossier +  + accessible depuis le bureau (raccourci visible) +- Deuxième PC de backup (au cas où le principal plante) +- Câble Ethernet + switch portable (si wifi instable) +- Mode avion sur tous les téléphones du binôme pendant la démo +**Slides** +- Slides exportées en PDF (backup si PowerPoint plante) +- Slides sur clé USB + cloud (double backup) +- Slides testées sur le projecteur du lieu (résolution, couleurs) +**Logistique** +- Arrivée 1 h avant minimum (pas 30 min — trop juste) +- Café/eau pour Amina avant la prise de parole +- Téléphone Dom + Amina muets +- QR code de la slide 7 testé (scanner avec un vrai téléphone, pas + juste en preview) +**Contenu** +- Corpus de 30 dossiers fictifs urgences validé avec la TIM +- Chiffres de la slide 4 recalculés et validés à 2 par Amina + Dom +- FAQ experts RPA relue, les 5 questions probables identifiées +- Script de pitch répété **au moins 2 fois** en binôme la veille +- Qui fait quoi à chaque beat écrit sur une fiche cartonnée (Amina + aime avoir ça en poche) +  +  +**Après la démo** +- Feuille d'émargement des DG intéressés (pré-imprimée, pas de Google +  + Form) +- Agenda de RDV pilote partagé Amina + Dom, à remplir à chaud pendant + la pause +- Mail de suivi prêt, à envoyer dans les 24 h (template à préparer + à l'avance) diff --git a/docs/plans/EVALUATION_BLOCS_VWB.md b/docs/plans/EVALUATION_BLOCS_VWB.md new file mode 100644 index 000000000..b4e99784a --- /dev/null +++ b/docs/plans/EVALUATION_BLOCS_VWB.md @@ -0,0 +1,269 @@ +# Evaluation exhaustive des blocs VWB -- Demo 26 avril 2026 + +**Date** : 13 avril 2026 +**Objectif** : Savoir exactement ce qui marche, ce qui est stub, ce qui manque. + +--- + +## Section A -- Inventaire complet des blocs (37 blocs) + +### SOURIS (7 blocs) + +| Bloc | action_type | Backend execute.py | Backend BaseVWBAction | Fonctionnel ? | +|------|-------------|--------------------|-----------------------|---------------| +| Clic | click_anchor | OUI (basic + vision) | OUI (VWBClickAnchorAction) | OUI | +| Double-clic | double_click_anchor | OUI | OUI (VWBDoubleClickAnchorAction) | OUI | +| Clic droit | right_click_anchor | OUI | OUI (VWBRightClickAnchorAction) | OUI | +| Survol | hover_anchor | NON dans execute.py | OUI (VWBSurvolElementAction) | PARTIEL -- pas wire dans l'executeur lineaire | +| Glisser-deposer | drag_drop_anchor | NON dans execute.py | OUI (VWBGlisserDeposerAction) | PARTIEL -- idem | +| Defiler vers | scroll_to_anchor | NON dans execute.py | OUI (VWBScrollToAnchorAction) | PARTIEL -- idem | +| Focus | focus_anchor | NON dans execute.py | OUI (VWBFocusAnchorAction) | PARTIEL -- idem | + +**Diagnostic** : Seuls click, double-click, right-click sont cables dans `execute_action()`. Les 4 autres ont des classes backend mais ne sont pas dispatches a l'execution. + +### CLAVIER (3 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Saisir texte | type_text | OUI (safe_type_text AZERTY) | OUI | +| Saisir secret | type_secret | OUI (VWBTypeSecretAction) | OUI (via credential_vault) | +| Raccourci clavier | keyboard_shortcut | OUI (pyautogui.hotkey) | OUI | + +**Diagnostic** : Les 3 fonctionnent. `type_text` supporte la substitution `{{variable}}`. + +### ATTENTE (1 bloc) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Attendre element | wait_for_anchor | OUI (time.sleep) | PARTIEL -- fait un sleep, pas de detection visuelle d'apparition | + +**Diagnostic** : Fonctionne comme un timer, mais ne fait PAS de polling visuel "attendre que l'element apparaisse". Le DAGExecutor le traite comme un StepType.WAIT. + +### DONNEES (7 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Extraire texte | extract_text | VWBExtractTextAction | **STUB** -- `_find_visual_element` retourne random, `_perform_ocr_extraction` retourne du texte hardcode | +| Extraire tableau | extract_table | VWBExtraireTableauAction | **OPERATIONNEL** -- appel Ollama reel avec prompt structurel, fallback OCR | +| Capture preuve | screenshot_evidence | VWBScreenshotEvidenceAction | OUI | +| Telecharger | download_to_folder | VWBTelechargerVersDossierAction | A verifier -- fichier existe | +| Sauvegarder BDD | db_save_data | VWBSauvegarderDonneesAction | **OPERATIONNEL** -- SQLite via GestionnaireDB | +| Lire BDD | db_read_data | VWBChargerDonneesAction | **OPERATIONNEL** -- SQLite via GestionnaireDB | +| Importer Excel | import_excel | ExcelImporter (core/data/) | **OPERATIONNEL** -- import .xlsx dans SQLite, detection de types | + +### BOUCLE DONNEES (1 bloc) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Pour chaque ligne | db_foreach | DBIterator + DAG sub-execution | **OPERATIONNEL** -- itere sur table SQLite, injecte `${current_row.colonne}`, execute sous-DAG par ligne | + +**Diagnostic** : Le pipeline `import_excel` -> `db_foreach` est completement implemente dans `dag_execute.py`. C'est le mecanisme de boucle de donnees le plus mature du projet. + +### LOGIQUE (2 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Condition visuelle | visual_condition | DAGExecutor (StepType.CONDITION) | **PARTIEL** -- le DAGExecutor evalue la condition via `safe_eval_condition`, mais le frontend n'a pas de vrai branchement visuel (edges on_found/on_not_found) | +| Boucle visuelle | loop_visual | **AUCUN** | **NON IMPLEMENTE** -- present dans la palette, aucun handler backend | + +**Diagnostic** : `visual_condition` a un squelette dans le DAGExecutor mais n'est pas connecte a la detection visuelle "est-ce que l'ancre est visible ?". `loop_visual` est un **placeholder UI pur** -- aucun code backend. + +### IA (6 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| OCR Intelligent | ai_ocr | Non cable | **NON** -- pas de handler | +| Resume IA | ai_summarize | Non cable | **NON** -- pas de handler | +| Extraction IA | ai_extract | Non cable | **NON** -- pas de handler | +| Classification IA | ai_classify | Non cable | **NON** -- pas de handler | +| Analyse complete | ai_analyze_text | **OUI** (execute_ai_analyze) | **OPERATIONNEL** -- appel Ollama reel, mode texte + mode image, variables {{}} | +| IA Personnalisee | ai_custom | Non cable | **NON** -- pas de handler | + +**Diagnostic** : Seul `ai_analyze_text` est cable et fonctionnel. Les 5 autres sont des placeholders. Ils pourraient tous passer par `execute_ai_analyze` avec des prompts differents -- c'est 1-2 jours de travail. + +### IA / LLM (4 blocs -- DAGExecutor) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Analyser texte | llm_analyze | LLMActionHandler.analyze_text | **OPERATIONNEL** -- Ollama /api/chat | +| Traduire | llm_translate | LLMActionHandler.translate | **OPERATIONNEL** -- Ollama /api/chat | +| Extraire donnees | llm_extract_data | LLMActionHandler.extract_data | **OPERATIONNEL** -- JSON schema extraction | +| Generer texte | llm_generate | LLMActionHandler.generate_text | **OPERATIONNEL** -- Ollama /api/chat | + +**Diagnostic** : Les 4 blocs LLM/DAG sont completement operationnels. Execution parallele via ThreadPool. Injection de resultats `${step_id.result}`. + +### FICHIERS (5 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Lister dossier | file_list_dir | FileActionHandler._list_dir | **OPERATIONNEL** -- avec securite path traversal | +| Creer dossier | file_create_dir | FileActionHandler._create_dir | **OPERATIONNEL** | +| Deplacer fichier | file_move | FileActionHandler._move_file | **OPERATIONNEL** | +| Copier fichier | file_copy | FileActionHandler._copy_file | **OPERATIONNEL** | +| Classer par ext | file_sort_by_ext | FileActionHandler._sort_by_extension | **OPERATIONNEL** | + +**Diagnostic** : Les 5 blocs fichiers sont implementes dans `file_actions.py` avec validation de securite. Mais ils ne sont pas dispatches dans `execute_action()` (executeur lineaire) -- seulement accessibles via le DAGExecutor (les types sont declares dans `_FILE_ACTION_TYPES` de `dag_execute.py`, mais le dispatch dans `_execute_ui_step` du DAG passe par le `ui_handler` qui n'a pas de logique pour les fichiers). **Il faut cabler le dispatch.** + +### VALIDATION (2 blocs) + +| Bloc | action_type | Backend | Fonctionnel ? | +|------|-------------|---------|---------------| +| Verifier presence | verify_element_exists | VWBVerifyElementExistsAction | PARTIEL -- classe existe, pas cable dans execute_action | +| Verifier texte | verify_text_content | VWBVerifyTextContentAction | **OPERATIONNEL** -- OCR via Ollama + docTR, matching multi-mode | + +--- + +## Section B -- Blocs operationnels (prets pour la demo) + +### Pret a l'emploi (17 blocs) + +1. **click_anchor** -- Clic gauche (basic + vision intelligente + self-healing) +2. **double_click_anchor** -- Double-clic +3. **right_click_anchor** -- Clic droit +4. **type_text** -- Saisie texte (AZERTY, variables {{var}}) +5. **type_secret** -- Saisie mot de passe (credential vault) +6. **keyboard_shortcut** -- Raccourci clavier +7. **ai_analyze_text** -- Analyse IA (Ollama, mode texte + image) +8. **llm_analyze** -- Analyse LLM parallele +9. **llm_translate** -- Traduction LLM +10. **llm_extract_data** -- Extraction structuree JSON +11. **llm_generate** -- Generation texte +12. **import_excel** -- Import Excel dans SQLite +13. **db_foreach** -- Boucle sur table (injection ${current_row.col}) +14. **db_save_data** -- Sauvegarde BDD (cle-valeur + collections) +15. **db_read_data** -- Lecture BDD +16. **extract_table** -- Extraction tableau (Ollama VLM) +17. **verify_text_content** -- Verification texte (OCR Ollama + docTR) + +### Pipeline data-loop fonctionnel +Le chemin **import_excel -> db_foreach -> (sous-workflow par ligne)** est completement implemente et teste dans `dag_execute.py` : +- Upload Excel via `/api/v3/upload-excel` +- Import automatique dans SQLite +- Iteration avec injection de colonnes +- Sous-DAG execute par ligne (LLM parallele + UI sequentiel) + +--- + +## Section C -- Blocs a completer (effort estime) + +| Bloc | Ce qui manque | Effort | +|------|---------------|--------| +| hover_anchor | Ajouter `elif action_type == 'hover_anchor'` dans execute_action() avec pyautogui.moveTo() | 0.5h | +| drag_drop_anchor | Ajouter dispatch + pyautogui.moveTo + drag | 1h | +| scroll_to_anchor | Ajouter dispatch + pyautogui.scroll() | 0.5h | +| focus_anchor | Ajouter dispatch + pyautogui.click() | 0.5h | +| wait_for_anchor | Remplacer sleep par boucle de detection visuelle (screenshot + match) | 2-4h | +| extract_text | Remplacer le stub par un vrai appel OCR (Ollama VLM ou docTR) -- le pattern existe deja dans verify_text_content | 2-4h | +| visual_condition | Connecter la detection visuelle (ancre trouvee ?) au branchement du DAG + gerer les edges on_found/on_not_found dans le frontend | 1-2j | +| verify_element_exists | Cabler dans execute_action() -- la classe backend est prete | 1h | +| file_* (5 blocs) | Cabler le dispatch dans execute_action() ou dans le ui_handler du DAGExecutor | 2-4h | +| ai_ocr, ai_summarize, ai_extract, ai_classify, ai_custom | Creer des wrappers autour de execute_ai_analyze avec des prompts systeme specifiques | 1-2j | + +--- + +## Section D -- Blocs manquants (a creer) + +| Fonctionnalite | Description | Effort | +|----------------|-------------|--------| +| **loop_visual** (boucle visuelle) | Repeter tant qu'une ancre est visible. Necessite : boucle de detection + condition de sortie + limite iterations + gestion dans le DAG | 2-3j | +| **set_variable / get_variable** | Blocs explicites pour definir/lire des variables. Actuellement fait implicitement via output_variable dans les params -- pas de bloc dedie | 0.5j (optionnel) | +| **Export CSV/Excel** | Exporter les resultats dans un fichier. Pas de bloc dedie. | 1j | +| **Envoyer email** | Notification des resultats. Absent. | 1-2j | +| **Attente conditionnelle** | wait_until_text("Chargement termine", timeout=30s). Combine wait + OCR. | 1-2j | + +--- + +## Section E -- Faisabilite des 3 cas d'usage + +### Cas A : "Traite toutes les factures du mois" + +**Besoins** : variable mois, boucle sur factures, extraction montant + +| Composant | Bloc VWB | Statut | +|-----------|----------|--------| +| Importer la liste de factures | import_excel | VERT | +| Boucler sur chaque facture | db_foreach | VERT | +| Ouvrir chaque facture (clic) | click_anchor | VERT | +| Extraire le montant (OCR) | extract_text | **ORANGE** -- stub, mais extract_table + ai_analyze_text fonctionnent | +| Saisir dans le SI | type_text + {{current_row.montant}} | VERT | +| Sauvegarder le resultat | db_save_data | VERT | + +**Verdict : ORANGE -- faisable avec 2-4h de travail** pour remplacer le stub extract_text par un appel Ollama VLM (le pattern existe dans extract_table et verify_text_content). + +**Alternative immediate** : utiliser `ai_analyze_text` avec un prompt "Extrait le montant de cette facture" au lieu de extract_text. Fonctionne aujourd'hui. + +### Cas B : "Recupere tous les CR de tous les dossiers de la journee" + +**Besoins** : variable date_jour, iterateur sur dossiers, extraction CR, stockage + +| Composant | Bloc VWB | Statut | +|-----------|----------|--------| +| Importer la liste de dossiers | import_excel ou db_read_data | VERT | +| Boucler sur chaque dossier | db_foreach | VERT | +| Naviguer vers le dossier (clics) | click_anchor + type_text | VERT | +| Extraire le CR (texte) | ai_analyze_text (mode image) | VERT | +| Sauvegarder le CR | db_save_data | VERT | +| Variable date du jour | Pas de bloc dedie, mais {{date_jour}} dans type_text marche si variable initialisee | VERT (si pre-setee) | + +**Verdict : VERT -- faisable avec l'existant.** Le chemin complet import_excel -> db_foreach -> (navigation + extraction IA + sauvegarde) est operationnel. La variable date_jour doit etre initialisee au debut du workflow (via le VariableManager du frontend). + +### Cas C : "Code les diagnostics de ce dossier patient" + +**Besoins** : extraction texte medical, appel LLM pour CIM-10, saisie dans le DPI + +| Composant | Bloc VWB | Statut | +|-----------|----------|--------| +| Ouvrir le dossier patient | click_anchor | VERT | +| Extraire le texte medical | ai_analyze_text (mode image, prompt: "Extrait le texte medical") | VERT | +| Suggerer codes CIM-10 | llm_analyze (prompt: "Propose les codes CIM-10 pour ce texte") | VERT | +| Afficher/valider la suggestion | Pas de bloc "dialogue humain" -- necessite le mode supervised | **ORANGE** -- le mode `paused_need_help` existe dans l'executeur | +| Saisir les codes dans le DPI | type_text + clic | VERT | + +**Verdict : ORANGE -- faisable pour la demo avec 1j de preparation.** La chaine extraction -> LLM CIM-10 -> saisie fonctionne. Le maillon faible est la validation humaine intermediaire -- mais pour une demo, on peut le montrer en mode step-by-step (pause entre etapes). + +--- + +## Section F -- Recommandation pour la demo du 26 avril + +### A montrer en live (confiance haute) + +1. **Workflow record-and-replay basique** : clic -> saisie texte -> raccourci clavier. Fonctionne deja. + +2. **Pipeline data-loop Excel** : importer un fichier Excel de 5-10 lignes, boucler avec db_foreach, remplir un formulaire web par ligne. C'est le cas d'usage le plus impressionnant et le plus solide techniquement. **Preparer un formulaire web simple ou utiliser un outil metier reel.** + +3. **Extraction + IA** : capturer un ecran d'application, lancer ai_analyze_text pour extraire des informations, montrer le resultat en temps reel. Parfait pour le cas "codage diagnostique". + +4. **Execution DAG parallele** : montrer que pendant qu'un LLM analyse un texte (10-30s), le workflow continue a executer d'autres taches. Visuellement impressionnant quand on voit les etapes s'allumer en parallele. + +### A preparer avant la demo (effort minimal) + +| Tache | Effort | Impact demo | +|-------|--------|-------------| +| Cabler hover/scroll/focus dans execute_action | 2h | Proprete (eviter un plantage si utilise par erreur) | +| Cabler les file_* dans le dispatch | 2h | Permet de montrer la gestion de fichiers | +| Remplacer le stub extract_text par un vrai OCR | 4h | Permet d'utiliser ce bloc au lieu du workaround ai_analyze_text | +| Preparer 2-3 workflows de demo pre-configures | 4h | **Indispensable** | +| Tester E2E sur un outil metier reel (DPI, Excel, Webapp) | 8h | **Indispensable** | + +### A ne PAS montrer (risque de plantage) + +- **loop_visual** : pas implemente, aucun backend +- **ai_ocr, ai_summarize, ai_extract, ai_classify, ai_custom** : pas cables -- utiliser ai_analyze_text ou les blocs llm_* a la place +- **visual_condition** : le branchement conditionnel n'est pas fiable -- le frontend ne gere pas les edges multiples proprement + +### A promettre comme roadmap + +- Boucles visuelles intelligentes (loop_visual) : Q3 2026 +- Conditions visuelles avec branchement dans le canvas : Q3 2026 +- Export automatique CSV/Excel des resultats : Q2 2026 (facile) +- Notifications email en fin de workflow : Q2 2026 +- Les 5 blocs IA manquants sont des variations de prompt -- livraison rapide apres la demo + +### Resumme + +Sur 37 blocs dans la palette : +- **17 sont operationnels** (46%) +- **10 sont partiellement implementes** (27%) -- necessitent du cablage (heures) +- **10 sont des placeholders** (27%) -- necessitent du developpement (jours) + +Le pipeline le plus impressionnant pour la demo est le **data-loop** (import_excel + db_foreach + LLM parallele) -- c'est le seul qui est 100% fonctionnel de bout en bout pour un cas d'usage concret de type "traiter N dossiers". diff --git a/docs/technique/ARCHITECTURE_CONFIG_AGENT.md b/docs/technique/ARCHITECTURE_CONFIG_AGENT.md new file mode 100644 index 000000000..bd242e283 --- /dev/null +++ b/docs/technique/ARCHITECTURE_CONFIG_AGENT.md @@ -0,0 +1,306 @@ +# Architecture de Configuration Agent -- Serveur + +**Date** : 2026-04-13 +**Auteur** : Analyse automatique (Claude) +**Statut** : Diagnostic + recommandation -- PAS de code modifie + +--- + +## 1. Schema de la chaine de configuration + +``` + config.txt (fichier plat sur le poste Windows) + | + | Lea.bat (for /f "eol=# tokens=1,* delims==" => set) + v + Variables d'environnement du process Python + | + +---> agent_v1/config.py + | SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") + | STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" + | API_TOKEN = os.environ.get("RPA_API_TOKEN", "") + | + +---> lea_ui/server_client.py + | _stream_base = RPA_SERVER_URL (si defini) OU http://{RPA_SERVER_HOST}:{5005} + | Utilise _stream_base + "/api/v1/traces/stream/..." (chemin COMPLET en dur) + | + +---> agent_v1/main.py + | _background_heartbeat_loop : utilise SERVER_URL DIRECTEMENT (pas STREAMING_ENDPOINT) + | _replay_poll_loop : passe SERVER_URL a executor.poll_and_execute() + | + +---> agent_v1/core/executor.py + poll_and_execute : construit f"{server_url}/traces/stream/replay/next" + _server_resolve_target : construit f"{server_url}/traces/stream/replay/resolve_target" + _observe_screen : construit f"{server_url}/traces/stream/replay/pre_analyze" +``` + +### Cote serveur (generation du config.txt) + +``` + web_dashboard/app.py + | + +---> _RPA_PUBLIC_URL = RPA_PUBLIC_URL ou RPA_SERVER_URL ou "https://lea.labs.laurinebazin.design" + +---> _build_custom_config(machine_id, user_name, token) + | server_url = _RPA_PUBLIC_URL.rstrip("/") + | if not server_url.endswith("/api/v1"): + | server_url += "/api/v1" <=== CORRECTIF RECENT (fonctionne) + | -> RPA_SERVER_URL={server_url} + | -> RPA_SERVER_HOST={host sans schema ni path} + | + +---> deploy/lea_package/config.txt (template statique dans le repo) + +---> deploy/installer/config_template.txt (template pour installeur Inno Setup) +``` + +### Cote serveur (routes FastAPI, port 5005) + +``` + agent_v0/server_v1/api_stream.py + | + +---> TOUTES les routes sous /api/v1/traces/stream/... + | /register, /event, /image, /finalize, /replay/next, etc. + | + +---> /health (public, a la racine) + | + +---> Middleware url_compat_rewrite : + /traces/stream/... => /api/v1/traces/stream/... +``` + +--- + +## 2. Inventaire des incoherences + +### INC-1 : Deux systemes paralleles de resolution d'URL (CRITIQUE) + +**Fichiers concernes** : +- `agent_v0/agent_v1/config.py` (ligne 43-45) : `SERVER_URL` = URL complete avec `/api/v1` ; `STREAMING_ENDPOINT = SERVER_URL + "/traces/stream"` +- `agent_v0/lea_ui/server_client.py` (ligne 77-81) : `_stream_base` = `RPA_SERVER_URL` BRUTE (sans garantie de `/api/v1`) ; utilise ensuite `_stream_base + "/api/v1/traces/stream/..."` (chemin complet en dur) + +**Probleme** : Si `RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1` : +- `config.py` produit `STREAMING_ENDPOINT = .../api/v1/traces/stream` (CORRECT) +- `server_client.py` produit `_stream_base/api/v1/traces/stream/...` = `.../api/v1/api/v1/traces/stream/...` (DOUBLE `/api/v1` !) + +Si `RPA_SERVER_URL=https://lea.labs.laurinebazin.design` : +- `config.py` produit `STREAMING_ENDPOINT = .../traces/stream` (MANQUE `/api/v1`) +- `server_client.py` produit `_stream_base/api/v1/traces/stream/...` (CORRECT) + +**Il n'existe aucune valeur de `RPA_SERVER_URL` qui fasse fonctionner les deux modules simultanement.** + +### INC-2 : `_background_heartbeat_loop` utilise `SERVER_URL` au lieu de `STREAMING_ENDPOINT` + +**Fichier** : `agent_v0/agent_v1/main.py` (ligne 370) +```python +req.post(f"{SERVER_URL}/traces/stream/image", ...) +``` + +`SERVER_URL` = `http://localhost:5005/api/v1` => URL finale = `/api/v1/traces/stream/image` (CORRECT). + +Mais c'est un accident : le heartbeat bypasse `STREAMING_ENDPOINT` (`SERVER_URL + "/traces/stream"`) et reconstruit son propre chemin. Le meme pattern se retrouve dans `executor.py` qui recoit `server_url` (= `SERVER_URL`) et construit `f"{server_url}/traces/stream/replay/next"`. + +**Consequence** : Deux conventions coexistent : +1. `STREAMING_ENDPOINT + "/register"` (streamer.py) -- attend que `SERVER_URL` contienne `/api/v1` +2. `SERVER_URL + "/traces/stream/image"` (main.py, executor.py) -- attend aussi que `SERVER_URL` contienne `/api/v1` + +Aujourd'hui les deux marchent parce que `SERVER_URL` inclut `/api/v1`. Mais `server_client.py` utilise une troisieme convention (chemin complet en dur), d'ou l'INC-1. + +### INC-3 : `LeaServerClient.check_connection()` appelle `/health` sur `_stream_base` + +**Fichier** : `agent_v0/lea_ui/server_client.py` (ligne 161) +```python +resp = requests.get(f"{self._stream_base}/health", ...) +``` + +Si `_stream_base = "https://lea.labs.laurinebazin.design/api/v1"`, l'URL finale est `/api/v1/health` -- **cette route n'existe pas**. La route sante est `GET /health` (racine). + +Si `_stream_base = "https://lea.labs.laurinebazin.design"`, l'URL finale est `/health` -- OK. + +Encore un conflit : `server_client.py` attend une URL **sans** `/api/v1`, `config.py` la fournit **avec**. + +### INC-4 : Template `deploy/lea_package/config.txt` contient de vrais secrets + +**Fichier** : `deploy/lea_package/config.txt` (ligne 19) +``` +RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab +``` + +Ce fichier est versionne dans Git. Le token reel est en clair dans le repo. + +### INC-5 : Copie ancienne non maintenue (`agent_v0/deploy/windows_client/`) + +**Fichier** : `agent_v0/deploy/windows_client/config.py` (ligne 41) +```python +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:8000/api/traces/upload") +``` + +Cette copie pointe encore sur l'ancien serveur API (port 8000, endpoint `/api/traces/upload`). Completement obsolete. Idem pour `agent_v0/config.py` (ligne 41). + +### INC-6 : `RPA_SERVER_HOST` sert a deux choses incompatibles + +- **`server_client.py`** : utilise `RPA_SERVER_HOST` comme **hostname nu** (ex: `192.168.1.40` ou `lea.labs.laurinebazin.design`) pour construire `http://{host}:5005` +- **`executor.py`** : utilise `RPA_SERVER_HOST` pour construire des URLs **Ollama** (`http://{host}:11434/api/chat`) +- **`main.py`** (ligne 94-95) : passe `RPA_SERVER_HOST` comme `server_host` au `LeaServerClient` et au `ChatWindow` + +Le `config.txt` genere par Fleet met `RPA_SERVER_HOST=lea.labs.laurinebazin.design`. Cela provoque : +- `executor.py` tente `http://lea.labs.laurinebazin.design:11434/api/chat` -- **Ollama n'est pas expose sur Internet** (echec silencieux) + +### INC-7 : Redirect POST -> GET (Bug 3, non resolu cote client) + +La lib Python `requests` suit les redirections 301/302 en transformant les POST en GET (RFC 7231). Quand NPM redirige `http://` vers `https://`, tous les POST streaming (register, event, image, finalize) deviennent des GET et recevront un 405. + +**Aucune protection cote client**. Le middleware serveur `url_compat_rewrite` ne resout que le probleme de path (pas le probleme de schema HTTP/HTTPS). + +--- + +## 3. Recommandation architecturale + +### Principe : une seule variable, deux composants + +``` +RPA_SERVER_URL = URL complete incluant le prefixe API + Exemples : + http://localhost:5005/api/v1 (dev local) + http://192.168.1.40:5005/api/v1 (LAN) + https://lea.labs.laurinebazin.design/api/v1 (Internet) +``` + +**Toutes les URLs de l'agent sont construites par concatenation de `SERVER_URL` + suffixe de route** : +```python +# config.py +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") +# Pour le health-check (route a la racine, pas sous /api/v1) : +SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0] # ex: "https://lea.labs.laurinebazin.design" + +# streamer.py, executor.py, main.py : tous utilisent +STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" # inchange +HEALTH_ENDPOINT = f"{SERVER_BASE}/health" # NOUVEAU + +# server_client.py : supprimer le systeme parallele +# _stream_base = SERVER_URL (plus de re-concatenation de "/api/v1" en dur) +``` + +### Supprimer `RPA_SERVER_HOST` + +Cette variable est source de confusion (INC-6). Elle ne doit pas exister. Le hostname est derive de `RPA_SERVER_URL` si besoin (pour l'affichage dans la chat window, etc.). + +L'acces Ollama doit avoir sa propre variable `RPA_OLLAMA_HOST` (defaut : `localhost`), car Ollama n'est JAMAIS accessible via le reverse proxy Internet. + +### Protection POST -> GET + +Ajouter dans `streamer.py` et `executor.py` : +```python +# Dans chaque session requests : +session = requests.Session() +session.max_redirects = 0 # Refuser les redirections (echouer bruyamment) +``` +Ou forcer `https://` cote client si le host est un domaine public (pas localhost/IP privee). + +### Template config.txt + +``` +RPA_SERVER_URL=CONFIGURE_ME +RPA_API_TOKEN=CONFIGURE_ME +RPA_MACHINE_ID=CONFIGURE_ME +``` + +Pas de token reel, pas de valeur par defaut fonctionnelle. L'agent doit refuser de demarrer si `RPA_SERVER_URL` contient "CONFIGURE_ME". + +--- + +## 4. Matrice scenarios x configuration + +| Scenario | RPA_SERVER_URL | RPA_API_TOKEN | Notes | +|---|---|---|---| +| Dev local (meme machine) | `http://localhost:5005/api/v1` | (vide ou token dev) | RPA_AUTH_DISABLED=true cote serveur | +| LAN interne (Dom <-> VM) | `http://192.168.1.40:5005/api/v1` | token prod | HTTP OK en LAN ferme | +| Internet via NPM (TIM) | `https://lea.labs.laurinebazin.design/api/v1` | token prod | HTTPS obligatoire, pas de redirect | +| Futur DGX on-premise | `http://:5005/api/v1` ou `https://...` | token prod | Selon reseau client | + +--- + +## 5. Liste des fichiers a corriger + +### Priorite HAUTE (bloquant pour le deploiement TIM) + +| Fichier | Ligne(s) | Action | +|---|---|---| +| `agent_v0/lea_ui/server_client.py` | 77-81, 161, 230, 287, 321, 349 | Supprimer la double logique `_stream_base`. Utiliser `SERVER_URL` de config.py comme base, ne plus concatener `/api/v1` en dur dans les appels. Pour `/health`, utiliser `SERVER_BASE` (sans `/api/v1`). | +| `agent_v0/agent_v1/main.py` | 94-95 | Supprimer l'utilisation de `RPA_SERVER_HOST` pour construire le `LeaServerClient`. Passer `SERVER_URL` directement. | +| `agent_v0/agent_v1/main.py` | 370 | Utiliser `STREAMING_ENDPOINT` au lieu de reconstruire le chemin manuellement. | +| `agent_v0/agent_v1/network/streamer.py` | 34 | Aucun changement (utilise deja `STREAMING_ENDPOINT` correctement). | +| `deploy/lea_package/config.txt` | 14, 19, 20 | Remplacer les valeurs par des placeholders `CONFIGURE_ME`. Supprimer le token reel. | +| `deploy/installer/config_template.txt` | 26-27 | Idem, remplacer le token reel par un placeholder. | + +### Priorite MOYENNE (coherence du codebase) + +| Fichier | Ligne(s) | Action | +|---|---|---| +| `agent_v0/agent_v1/config.py` | 43-45 | Ajouter `SERVER_BASE` (URL sans `/api/v1`) pour le health-check. | +| `agent_v0/agent_v1/core/executor.py` | 1144, 1280, 1595 | Remplacer `RPA_SERVER_HOST` par une nouvelle var `RPA_OLLAMA_HOST` (defaut `localhost`). | +| `web_dashboard/app.py` | 2055-2060 | Renommer `_RPA_PUBLIC_URL` en `_RPA_PUBLIC_SERVER_URL`. S'assurer que le `/api/v1` est toujours present dans le config.txt genere (deja fait, ligne 2097-2098). | +| `web_dashboard/app.py` | 2119 | Supprimer la ligne `RPA_SERVER_HOST=` du config.txt genere. | + +### Priorite BASSE (nettoyage) + +| Fichier | Action | +|---|---| +| `agent_v0/config.py` | Supprimer ou marquer deprecated (ancien agent V0, port 8000). | +| `agent_v0/deploy/windows_client/` | Supprimer l'arborescence entiere (copie obsolete, remplacee par le ZIP Fleet). | +| `agent_v0/deploy/windows_client/config.py` | Port 8000, endpoint `/api/traces/upload` -- completement mort. | + +### Protection anti-redirect (Bug 3) + +| Fichier | Action | +|---|---| +| `agent_v0/agent_v1/network/streamer.py` | Utiliser `requests.Session()` avec `max_redirects=0` ou forcer HTTPS si domaine public. | +| `agent_v0/agent_v1/core/executor.py` | Idem pour les appels HTTP du replay (resolve_target, pre_analyze, replay/next, replay/result). | + +--- + +## 6. Diagramme de flux (etat cible) + +``` + config.txt (genere par Fleet ou rempli a la main) + | + | RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 + | RPA_API_TOKEN= + | RPA_MACHINE_ID= + | (plus de RPA_SERVER_HOST) + | + v + Lea.bat -> set variables d'environnement + | + v + agent_v1/config.py + | SERVER_URL = "https://lea.labs.laurinebazin.design/api/v1" + | SERVER_BASE = "https://lea.labs.laurinebazin.design" + | STREAMING_ENDPOINT = "https://lea.labs.laurinebazin.design/api/v1/traces/stream" + | HEALTH_ENDPOINT = "https://lea.labs.laurinebazin.design/health" + | + +-------> streamer.py : STREAMING_ENDPOINT + "/register", "/event", "/image", "/finalize" + +-------> main.py : STREAMING_ENDPOINT + "/image" (heartbeat) + | SERVER_URL (passe a executor.poll_and_execute) + +-------> executor.py : SERVER_URL + "/traces/stream/replay/next", etc. + +-------> server_client.py : SERVER_URL + "/traces/stream/workflows", etc. + | HEALTH_ENDPOINT (pour check_connection) + | + v (HTTPS, port 443) + NPM Reverse Proxy + | + v (HTTP, port 5005) + FastAPI api_stream.py + | /health + | /api/v1/traces/stream/register + | /api/v1/traces/stream/event + | /api/v1/traces/stream/image + | /api/v1/traces/stream/replay/next + | ... +``` + +--- + +## 7. Resume des bugs originaux et leur resolution + +| Bug | Cause racine | Correction | +|---|---|---| +| Bug 1 (URL obsolete dans config.txt) | Template `deploy/lea_package/config.txt` jamais mis a jour | DEJA CORRIGE (le fichier actuel contient `/api/v1`). Mais le token reel est toujours en clair. | +| Bug 2 (mismatch /api/v1) | Deux modules (`config.py` vs `server_client.py`) avec des conventions incompatibles pour construire les URLs | Unifier sur une seule convention : `RPA_SERVER_URL` inclut TOUJOURS `/api/v1`. `server_client.py` doit utiliser `SERVER_URL` de `config.py` au lieu de reimplementer sa propre logique. | +| Bug 3 (POST -> GET sur redirect) | `requests` suit les 301 en changeant la methode HTTP | Forcer HTTPS cote client quand le domaine est public, OU desactiver les redirections (`max_redirects=0`) pour echouer explicitement. Le middleware serveur `url_compat_rewrite` est un filet de securite pour le path, pas pour le schema. | diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py new file mode 100644 index 000000000..083ec476b --- /dev/null +++ b/tests/unit/test_agent_config.py @@ -0,0 +1,195 @@ +# tests/unit/test_agent_config.py +""" +Tests unitaires pour la convention de configuration agent (INC-1 a INC-7). + +Verifie que : + - STREAMING_ENDPOINT contient /api/v1/traces/stream + - SERVER_BASE est l'URL sans /api/v1 (pour /health) + - Le health check utilise la racine, pas /api/v1 + - OLLAMA_HOST est separe de SERVER_URL +""" + +import os +import pytest + + +class TestAgentConfig: + """Tests de la resolution d'URL dans agent_v1.config.""" + + def test_streaming_endpoint_includes_api_v1(self, monkeypatch): + """STREAMING_ENDPOINT doit contenir /api/v1/traces/stream.""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + # Recharger le module config pour prendre en compte la variable + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert "/api/v1/traces/stream" in config.STREAMING_ENDPOINT + assert config.STREAMING_ENDPOINT == "http://192.168.1.40:5005/api/v1/traces/stream" + + def test_streaming_endpoint_default(self, monkeypatch): + """Endpoint par defaut (localhost:5005).""" + monkeypatch.delenv("RPA_SERVER_URL", raising=False) + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert config.STREAMING_ENDPOINT == "http://localhost:5005/api/v1/traces/stream" + + def test_server_base_strips_api_v1(self, monkeypatch): + """SERVER_BASE doit etre l'URL sans /api/v1.""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert config.SERVER_BASE == "http://192.168.1.40:5005" + assert "/api/v1" not in config.SERVER_BASE + + def test_server_base_https_domain(self, monkeypatch): + """SERVER_BASE avec un domaine HTTPS (reverse proxy).""" + monkeypatch.setenv( + "RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1" + ) + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert config.SERVER_BASE == "https://lea.labs.laurinebazin.design" + + def test_health_url_is_root_not_api_v1(self, monkeypatch): + """Le health check doit etre sur SERVER_BASE/health (racine).""" + monkeypatch.setenv( + "RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1" + ) + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + health_url = f"{config.SERVER_BASE}/health" + assert health_url == "https://lea.labs.laurinebazin.design/health" + assert "/api/v1/health" not in health_url + + def test_ollama_host_separate_from_server(self, monkeypatch): + """OLLAMA_HOST est independant de SERVER_URL.""" + monkeypatch.setenv( + "RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1" + ) + monkeypatch.delenv("RPA_OLLAMA_HOST", raising=False) + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + # Par defaut, Ollama est en local + assert config.OLLAMA_HOST == "localhost" + + def test_ollama_host_custom(self, monkeypatch): + """OLLAMA_HOST peut etre configure separement.""" + monkeypatch.setenv("RPA_OLLAMA_HOST", "192.168.1.40") + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert config.OLLAMA_HOST == "192.168.1.40" + + def test_no_double_api_v1(self, monkeypatch): + """Aucune URL ne doit contenir /api/v1/api/v1 (double prefixe).""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + all_urls = [ + config.SERVER_URL, + config.SERVER_BASE, + config.STREAMING_ENDPOINT, + config.UPLOAD_ENDPOINT, + ] + for url in all_urls: + assert "/api/v1/api/v1" not in url, f"Double /api/v1 dans : {url}" + + +class TestServerClientUrls: + """Tests de la resolution d'URL dans lea_ui.server_client.""" + + def test_stream_url_includes_api_v1(self, monkeypatch): + """_stream_url doit contenir /api/v1.""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + from agent_v0.lea_ui.server_client import LeaServerClient + + client = LeaServerClient() + assert "/api/v1" in client._stream_url + assert client._stream_url.endswith("/api/v1") + + def test_stream_base_no_api_v1(self, monkeypatch): + """_stream_base ne doit PAS contenir /api/v1.""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + from agent_v0.lea_ui.server_client import LeaServerClient + + client = LeaServerClient() + assert "/api/v1" not in client._stream_base + + def test_health_on_root(self, monkeypatch): + """Le health check doit pointer sur la racine.""" + monkeypatch.setenv( + "RPA_SERVER_URL", "https://lea.labs.laurinebazin.design/api/v1" + ) + from agent_v0.lea_ui.server_client import LeaServerClient + + client = LeaServerClient() + health_url = f"{client._stream_base}/health" + assert health_url == "https://lea.labs.laurinebazin.design/health" + + def test_workflows_url_no_double_api_v1(self, monkeypatch): + """L'URL workflows ne doit pas avoir /api/v1/api/v1.""" + monkeypatch.setenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + from agent_v0.lea_ui.server_client import LeaServerClient + + client = LeaServerClient() + workflows_url = f"{client._stream_url}/traces/stream/workflows" + assert "/api/v1/api/v1" not in workflows_url + assert workflows_url == "http://192.168.1.40:5005/api/v1/traces/stream/workflows" + + +class TestScenarios: + """Validation des 3 scenarios de deploiement.""" + + @pytest.mark.parametrize( + "server_url,expected_stream,expected_base,expected_health", + [ + # Scenario 1 : LAN interne + ( + "http://192.168.1.40:5005/api/v1", + "http://192.168.1.40:5005/api/v1/traces/stream", + "http://192.168.1.40:5005", + "http://192.168.1.40:5005/health", + ), + # Scenario 2 : Internet via NPM + ( + "https://lea.labs.laurinebazin.design/api/v1", + "https://lea.labs.laurinebazin.design/api/v1/traces/stream", + "https://lea.labs.laurinebazin.design", + "https://lea.labs.laurinebazin.design/health", + ), + # Scenario 3 : Dev local (defaut) + ( + "http://localhost:5005/api/v1", + "http://localhost:5005/api/v1/traces/stream", + "http://localhost:5005", + "http://localhost:5005/health", + ), + ], + ids=["lan", "internet", "localhost"], + ) + def test_scenario_urls( + self, monkeypatch, server_url, expected_stream, expected_base, expected_health + ): + """Valider la matrice URL pour chaque scenario de deploiement.""" + monkeypatch.setenv("RPA_SERVER_URL", server_url) + import importlib + from agent_v0.agent_v1 import config + importlib.reload(config) + + assert config.STREAMING_ENDPOINT == expected_stream + assert config.SERVER_BASE == expected_base + assert f"{config.SERVER_BASE}/health" == expected_health diff --git a/tests/unit/test_session_cleaner.py b/tests/unit/test_session_cleaner.py new file mode 100644 index 000000000..19d0386cb --- /dev/null +++ b/tests/unit/test_session_cleaner.py @@ -0,0 +1,655 @@ +"""Tests pour tools/session_cleaner.py — precision de la detection parasite. + +Ces tests verifient que la detection parasite est assez fine pour les +logiciels metier hospitaliers (DPI, codage PMSI, facturation) ou les +interfaces sont complexes (onglets, dialogues, assistants). + +Regle d'or : mieux vaut garder un parasite que supprimer un vrai clic. +""" + +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List + +import pytest + +# Ajouter le repertoire racine au path pour importer tools.session_cleaner +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tools.session_cleaner import ( + _ACTIONABLE_TYPES, + _PARASITIC_WINDOW_PATTERNS, + _get_app_name, + _has_identified_ui_element, + _is_parasitic, + _is_stop_recording_event, + _is_systray_interaction, + _parse_actions, +) + + +# --------------------------------------------------------------------------- +# Helpers pour construire des evenements de test +# --------------------------------------------------------------------------- + + +def _make_event( + etype: str = "mouse_click", + window_title: str = "", + app_name: str = "", + button: str = "left", + pos: list = None, + keys: list = None, + text: str = "", + uia_name: str = "", + uia_parent_path: list = None, + ui_elements: list = None, + vision_ui_elements: list = None, +) -> Dict[str, Any]: + """Construire un evenement minimal pour les tests. + + Parametres supplementaires pour la logique C2/UIA : + - uia_name : nom de l'element UIA (bouton, champ, etc.) + - ui_elements : liste d'elements C2 detectes par le pipeline vision + - vision_ui_elements : idem, place dans vision_info.ui_elements + """ + inner: Dict[str, Any] = {"type": etype} + + if window_title or app_name: + inner["window"] = {"title": window_title, "app_name": app_name} + + if etype == "mouse_click": + inner["button"] = button + inner["pos"] = pos or [640, 400] + + if etype in ("key_combo", "key_press"): + inner["keys"] = keys or [] + + if etype in ("text_input", "type"): + inner["text"] = text + + if uia_name or uia_parent_path: + inner["uia_snapshot"] = { + "name": uia_name, + "control_type": "", + "parent_path": uia_parent_path or [], + } + + if ui_elements is not None: + inner["ui_elements"] = ui_elements + + if vision_ui_elements is not None: + inner["vision_info"] = {"ui_elements": vision_ui_elements} + + return {"event": inner, "session_id": "test_sess", "machine_id": "test"} + + +def _wrap_session(actionable_events: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Entourer d'evenements non-exploitables pour simuler une vraie session.""" + hb = {"event": {"type": "heartbeat"}, "session_id": "test_sess", "machine_id": "test"} + return [hb] + actionable_events + [hb] + + +# --------------------------------------------------------------------------- +# Bug 1 — Premier clic sur le bureau (Program Manager) pas parasite +# --------------------------------------------------------------------------- + + +class TestBug1ProgramManager: + """Program Manager ne doit plus jamais etre marque parasite.""" + + def test_first_click_on_desktop_not_parasitic(self): + """Premier clic avec window='Program Manager' → NOT parasite. + + Cas reel : l'utilisateur demarre un workflow depuis le bureau + Windows en cliquant sur une icone ou la barre des taches. + """ + event = _make_event( + window_title="Program Manager", + app_name="explorer.exe", + pos=[640, 400], + ) + assert not _is_parasitic(event, index=0, total=10) + + def test_middle_click_on_program_manager_not_parasitic(self): + """Clic milieu de workflow sur bureau → NOT parasite. + + Choix pragmatique : meme si l'utilisateur clique dans le vide, + c'est a lui de decider via l'interface du cleaner. Pas de + suppression automatique. + """ + event = _make_event( + window_title="Program Manager", + app_name="explorer.exe", + pos=[640, 400], + ) + # Index 5 sur 10 = milieu de session + assert not _is_parasitic(event, index=5, total=10) + + def test_program_manager_not_in_patterns(self): + """'program manager' ne doit pas figurer dans les patterns parasites.""" + for pattern in _PARASITIC_WINDOW_PATTERNS: + assert "program manager" not in pattern.lower(), ( + f"'program manager' ne doit pas etre dans _PARASITIC_WINDOW_PATTERNS " + f"(trouve dans '{pattern}')" + ) + + +# --------------------------------------------------------------------------- +# Bug 2 — Derniers clics : pas de regle blanket "3 derniers" +# --------------------------------------------------------------------------- + + +class TestBug2LastEventsNotBlanketParasitic: + """Les derniers clics ne doivent plus etre automatiquement parasites.""" + + def test_last_click_on_notepad_not_parasitic(self): + """Dernier clic avec window='Bloc-notes' → NOT parasite. + + Cas reel : l'utilisateur valide/sauvegarde dans son dernier clic. + L'ancienne regle supprimait systematiquement les 3 derniers clics. + """ + event = _make_event( + window_title="Bloc-notes", + app_name="Notepad.exe", + pos=[500, 300], + ) + # Dernier evenement sur 10 + assert not _is_parasitic(event, index=9, total=10) + + def test_last_click_on_dpi_not_parasitic(self): + """Dernier clic sur DPI urgences → NOT parasite. + + Cas critique : le dernier clic est souvent 'Valider le codage' + ou 'Sauvegarder la fiche patient'. + """ + event = _make_event( + window_title="DPI Urgences - Validation du codage", + app_name="dpi.exe", + pos=[800, 600], + ) + assert not _is_parasitic(event, index=9, total=10) + + def test_second_to_last_not_parasitic(self): + """Avant-dernier clic metier → NOT parasite.""" + event = _make_event( + window_title="Facturation MCO - Saisie actes", + app_name="facturation.exe", + pos=[500, 400], + ) + assert not _is_parasitic(event, index=8, total=10) + + def test_last_click_on_lea_systray_is_parasitic_via_stop(self): + """Dernier clic sur Lea RPA (pythonw.exe) → parasite via stop_recording. + + L'utilisateur clique sur l'icone Lea dans la systray pour arreter. + """ + event = _make_event( + window_title="unknown_window", + app_name="pythonw.exe", + pos=[917, 522], + uia_name="Contexte", + ) + assert _is_stop_recording_event(event, is_last_actionable=True) + + def test_not_last_click_on_pythonw_not_stop(self): + """Clic sur pythonw.exe qui n'est PAS le dernier → pas stop_recording.""" + event = _make_event( + window_title="unknown_window", + app_name="pythonw.exe", + pos=[917, 522], + ) + assert not _is_stop_recording_event(event, is_last_actionable=False) + + def test_ctrl_shift_l_stop_is_parasitic(self): + """key_combo Ctrl+Shift+L en dernier → parasite (arret explicite).""" + event = _make_event( + etype="key_combo", + window_title="Bloc-notes", + app_name="Notepad.exe", + keys=["ctrl", "shift", "l"], + ) + assert _is_stop_recording_event(event, is_last_actionable=True) + + def test_ctrl_shift_l_not_last_not_stop(self): + """Ctrl+Shift+L au milieu de session → pas un arret.""" + event = _make_event( + etype="key_combo", + window_title="Bloc-notes", + app_name="Notepad.exe", + keys=["ctrl", "shift", "l"], + ) + assert not _is_stop_recording_event(event, is_last_actionable=False) + + def test_ctrl_s_last_not_stop(self): + """Ctrl+S en dernier → NOT stop_recording (c'est un Ctrl+S, pas Ctrl+Shift). + + Sauvegarder est un geste metier, pas un arret. + """ + event = _make_event( + etype="key_combo", + window_title="Bloc-notes", + app_name="Notepad.exe", + keys=["ctrl", "s"], + ) + # Ctrl+S n'a pas 'shift', donc pas un stop + assert not _is_stop_recording_event(event, is_last_actionable=True) + + +# --------------------------------------------------------------------------- +# Bug 3 — Pattern "assistant" trop large +# --------------------------------------------------------------------------- + + +class TestBug3AssistantPattern: + """'assistant' ne doit plus etre un pattern parasite.""" + + def test_assistant_in_dpi_not_parasitic(self): + """Clic avec window='Assistant de codage PMSI' → NOT parasite. + + Les logiciels metier hospitaliers utilisent souvent 'Assistant' + dans leurs titres de fenetre. + """ + event = _make_event( + window_title="Assistant de codage PMSI", + app_name="dpi.exe", + pos=[600, 400], + ) + assert not _is_parasitic(event, index=5, total=10) + + def test_assistant_facturation_not_parasitic(self): + """'Assistant facturation' → NOT parasite.""" + event = _make_event( + window_title="Assistant facturation - Etape 2/4", + app_name="facturation.exe", + pos=[500, 300], + ) + assert not _is_parasitic(event, index=3, total=10) + + def test_assistant_saisie_not_parasitic(self): + """'Assistant de saisie' → NOT parasite.""" + event = _make_event( + window_title="Assistant de saisie des actes CCAM", + app_name="saisie.exe", + pos=[700, 500], + ) + assert not _is_parasitic(event, index=4, total=10) + + def test_assistant_not_in_patterns(self): + """'assistant' ne doit pas figurer tel quel dans les patterns.""" + for pattern in _PARASITIC_WINDOW_PATTERNS: + # "assistant" seul ne doit pas y etre ; + # "lea - rpa assistant" ou similaire est OK car specifique + if pattern == "assistant": + pytest.fail( + "'assistant' seul est trop large pour les logiciels metier. " + "Utiliser un pattern plus specifique si necessaire." + ) + + def test_lea_rpa_pattern_matches(self): + """'Léa - RPA' est bien detecte comme parasite.""" + event = _make_event( + window_title="Léa - RPA Vision", + app_name="pythonw.exe", + pos=[400, 300], + ) + assert _is_parasitic(event, index=5, total=10) + + +# --------------------------------------------------------------------------- +# Systray detection +# --------------------------------------------------------------------------- + + +class TestSystrayDetection: + """La detection d'interaction systray doit etre precise.""" + + def test_icones_cachees_is_systray(self): + """Clic sur 'Afficher les icônes cachées' → systray.""" + event = _make_event( + window_title="unknown_window", + app_name="explorer.exe", + pos=[1080, 773], + uia_name="Afficher les icônes cachées", + ) + assert _is_systray_interaction(event) + + def test_depassement_parent_is_systray(self): + """Element dont le parent est 'Fenêtre de dépassement' → systray.""" + event = _make_event( + window_title="Fenêtre de dépassement", + app_name="explorer.exe", + pos=[1026, 710], + uia_parent_path=[ + {"name": "Bureau 1", "control_type": "volet"}, + {"name": "Fenêtre de dépassement de capacité", "control_type": "volet"}, + ], + ) + assert _is_systray_interaction(event) + + def test_normal_button_not_systray(self): + """Bouton normal dans une application → pas systray.""" + event = _make_event( + window_title="Bloc-notes", + app_name="Notepad.exe", + pos=[500, 300], + uia_name="Enregistrer", + uia_parent_path=[ + {"name": "Barre de menu", "control_type": "barre de menu"}, + ], + ) + assert not _is_systray_interaction(event) + + def test_no_uia_not_systray(self): + """Pas de uia_snapshot → pas systray.""" + event = _make_event( + window_title="Bloc-notes", + app_name="Notepad.exe", + pos=[500, 300], + ) + assert not _is_systray_interaction(event) + + +# --------------------------------------------------------------------------- +# C2/UIA — signaux positifs : element identifie = jamais parasite +# --------------------------------------------------------------------------- + + +class TestC2UIAPositiveSignals: + """Si C2 ou UIA identifie un element UI reel, le clic est preserve. + + Principe : un clic sur un bouton/champ/onglet identifie par nom dans + UIA ou par le pipeline C2 est un acte metier reel, meme si la fenetre + a un titre bizarre ou inconnu. + """ + + def test_click_with_uia_element_never_parasitic(self): + """Clic avec uia_snapshot.name='Enregistrer' → NOT parasite. + + Meme si la fenetre a un titre etrange, un element UI nomme + est la preuve que c'est un vrai clic metier. + """ + event = _make_event( + window_title="Fenetre bizarre", + app_name="app_metier.exe", + pos=[600, 400], + uia_name="Enregistrer", + ) + assert not _is_parasitic(event, index=5, total=10) + + def test_click_with_uia_element_overrides_unknown_window(self): + """Clic avec UIA identifie dans 'unknown_window' → NOT parasite. + + Cas reel : certaines fenetres ont des titres non resolus par + l'agent mais l'UIA snapshot identifie quand meme l'element. + """ + event = _make_event( + window_title="unknown_window", + app_name="dpi.exe", + pos=[400, 300], + uia_name="Valider le codage", + ) + assert not _is_parasitic(event, index=8, total=10) + + def test_click_with_c2_ui_elements_never_parasitic(self): + """Clic avec ui_elements (pipeline C2) → NOT parasite. + + Quand le pipeline C2 enrichit l'evenement avec des elements + visuels detectes, c'est un signal fort de clic metier. + """ + event = _make_event( + window_title="unknown_window", + app_name="app_metier.exe", + pos=[600, 400], + ui_elements=[{"label": "Sauvegarder", "bbox": [580, 380, 620, 420]}], + ) + assert not _is_parasitic(event, index=5, total=10) + + def test_click_with_vision_info_ui_elements_never_parasitic(self): + """Clic avec vision_info.ui_elements (format alternatif C2) → NOT parasite.""" + event = _make_event( + window_title="unknown_window", + app_name="facturation.exe", + pos=[500, 350], + vision_ui_elements=[{"type": "button", "text": "Confirmer"}], + ) + assert not _is_parasitic(event, index=6, total=10) + + def test_uia_systray_still_parasitic(self): + """Clic avec UIA identifie MAIS sur la systray → reste parasite. + + Le shield C2/UIA ne s'applique pas a la systray. Un clic sur + 'Afficher les icônes cachées' est toujours parasite meme si + l'UIA a identifie l'element. + """ + event = _make_event( + window_title="unknown_window", + app_name="explorer.exe", + pos=[1080, 773], + uia_name="Afficher les icônes cachées", + ) + # _is_parasitic retourne False (UIA identifie + pas pattern fenetre) + # mais _is_systray_interaction le rattrape + assert _is_systray_interaction(event) + + def test_right_click_with_uia_still_parasitic(self): + """Clic droit avec UIA identifie → reste parasite. + + Les clics droit sont un signal dur — meme si l'UIA a identifie + un element, un clic droit n'est jamais un clic metier exploitable. + """ + event = _make_event( + window_title="Bloc-notes", + app_name="Notepad.exe", + pos=[500, 300], + button="right", + uia_name="Zone de texte", + ) + assert _is_parasitic(event, index=5, total=10) + + +class TestC2AppNameShield: + """Si l'app est une application metier connue, les clics sont preserves.""" + + def test_click_in_known_app_not_parasitic(self): + """Clic dans app_name='Notepad.exe' sans UIA → NOT parasite. + + Une vraie application (pas explorer, pas pythonw) est une app + metier — ses clics sont legitimes meme sans info UIA/C2. + """ + event = _make_event( + window_title="Sans titre", + app_name="Notepad.exe", + pos=[400, 300], + ) + assert not _is_parasitic(event, index=5, total=10) + + def test_click_in_dpi_not_parasitic(self): + """Clic dans dpi.exe sans UIA → NOT parasite.""" + event = _make_event( + window_title="DPI Urgences", + app_name="dpi.exe", + pos=[600, 400], + ) + assert not _is_parasitic(event, index=3, total=10) + + def test_click_in_searchhost_not_parasitic(self): + """Clic dans SearchHost.exe → NOT parasite. + + La recherche Windows (SearchHost.exe) est une application + normale, pas un process systeme a filtrer. + """ + event = _make_event( + window_title="Rechercher", + app_name="SearchHost.exe", + pos=[500, 300], + ) + assert not _is_parasitic(event, index=1, total=10) + + def test_click_in_explorer_no_uia_can_be_parasitic(self): + """Clic dans explorer.exe sans UIA → peut etre parasite si pattern. + + explorer.exe est un cas particulier : c'est a la fois le bureau + (Program Manager) et l'explorateur de fichiers. Sans UIA pour + distinguer, on laisse les patterns de fenetre decider. + """ + event = _make_event( + window_title="Fenêtre de dépassement de capacité", + app_name="explorer.exe", + pos=[1026, 710], + ) + # explorer.exe est dans _NON_BUSINESS_APPS, donc pas de shield + # et le pattern "fenêtre de dépassement" matche → parasite + assert _is_parasitic(event, index=5, total=10) + + def test_click_in_pythonw_no_shield(self): + """Clic dans pythonw.exe → pas de shield (c'est l'agent Lea).""" + event = _make_event( + window_title="unknown_window", + app_name="pythonw.exe", + pos=[917, 522], + ) + # pythonw.exe est dans _NON_BUSINESS_APPS, pas de shield app + # mais pas de pattern de fenetre non plus → pas parasite via _is_parasitic + # (sera attrape par _is_stop_recording_event si c'est le dernier) + assert not _is_parasitic(event, index=5, total=10) + + +class TestHasIdentifiedUIElement: + """Tests unitaires pour _has_identified_ui_element().""" + + def test_uia_name_detected(self): + """uia_snapshot avec nom → element identifie.""" + event = _make_event(uia_name="Bouton OK") + assert _has_identified_ui_element(event) + + def test_uia_empty_name_not_detected(self): + """uia_snapshot avec nom vide → pas d'element identifie.""" + event = _make_event(uia_name="") + assert not _has_identified_ui_element(event) + + def test_no_uia_not_detected(self): + """Pas de uia_snapshot → pas d'element identifie.""" + event = _make_event() + assert not _has_identified_ui_element(event) + + def test_c2_ui_elements_detected(self): + """ui_elements (C2) → element identifie.""" + event = _make_event(ui_elements=[{"label": "Sauvegarder"}]) + assert _has_identified_ui_element(event) + + def test_vision_ui_elements_detected(self): + """vision_info.ui_elements → element identifie.""" + event = _make_event(vision_ui_elements=[{"type": "button"}]) + assert _has_identified_ui_element(event) + + def test_empty_ui_elements_not_detected(self): + """ui_elements vide → pas d'element identifie.""" + event = _make_event(ui_elements=[]) + assert not _has_identified_ui_element(event) + + +class TestGetAppName: + """Tests unitaires pour _get_app_name().""" + + def test_app_name_from_window(self): + event = _make_event(app_name="Notepad.exe") + assert _get_app_name(event) == "Notepad.exe" + + def test_no_app_name(self): + event = _make_event() + assert _get_app_name(event) == "" + + def test_app_name_none_returns_empty(self): + """app_name None → chaine vide.""" + event = {"event": {"type": "mouse_click", "window": {"title": "Test", "app_name": None}}} + assert _get_app_name(event) == "" + + +# --------------------------------------------------------------------------- +# Test sur la vraie session de Dom +# --------------------------------------------------------------------------- + + +class TestRealSessionDom: + """Verification sur la session E2E reelle de Dom. + + Session: sess_20260417T133324_30c2d0 + Workflow: clic Rechercher → texte → clic resultat → Bloc-notes → texte → + Ctrl+S → systray stop. + """ + + SESSION_PATH = os.path.join( + os.path.dirname(__file__), + "..", "..", + "data", "training", "live_sessions", "windows_vm", + "sess_20260417T133324_30c2d0", "live_events.jsonl", + ) + + @pytest.fixture + def real_events(self): + """Charger les evenements reels si disponibles.""" + path = Path(self.SESSION_PATH).resolve() + if not path.is_file(): + pytest.skip(f"Session reelle non disponible : {path}") + events = [] + with open(path, encoding="utf-8") as f: + for line in f: + if line.strip(): + events.append(json.loads(line)) + return events + + def test_real_session_no_false_positives(self, real_events): + """Aucun clic metier ne doit etre marque parasite. + + Les 7 premiers evenements exploitables sont du workflow reel : + - Clic Rechercher (taskbar) + - Texte dans la recherche + - Clic sur un resultat + - Clic Agrandir (Bloc-notes) + - Clic Nouvel onglet + - Texte 'bijour' + - Ctrl+S (sauvegarder) + + Les 4 derniers sont l'arret (systray): + - Right-click systray + - Left-click icones cachees + - Right-click fenetre depassement + - Left-click menu Lea (pythonw.exe) + """ + # Utiliser _parse_actions avec un dossier bidon (pas de shots) + session_dir = Path(self.SESSION_PATH).parent + actions = _parse_actions(real_events, session_dir) + + # 11 evenements exploitables au total + assert len(actions) == 11, f"Attendu 11 actions, obtenu {len(actions)}" + + # Les 7 premiers doivent etre OK (pas parasites) + for idx, action in enumerate(actions[:7]): + assert not action["is_parasitic"], ( + f"Action {idx} (evt {action['global_index']}) faussement marquee parasite : " + f"type={action['type']}, win={action['window_title']}" + ) + + # Les 4 derniers doivent etre parasites (arret enregistrement) + for idx, action in enumerate(actions[7:], start=7): + assert action["is_parasitic"], ( + f"Action {idx} (evt {action['global_index']}) devrait etre parasite : " + f"type={action['type']}, win={action['window_title']}" + ) + + def test_real_session_parasitic_count(self, real_events): + """4 parasites sur 11 — pas plus, pas moins.""" + session_dir = Path(self.SESSION_PATH).parent + actions = _parse_actions(real_events, session_dir) + parasitic = [a for a in actions if a["is_parasitic"]] + ok = [a for a in actions if not a["is_parasitic"]] + assert len(parasitic) == 4, ( + f"Attendu 4 parasites, obtenu {len(parasitic)} : " + + ", ".join(f"evt{a['global_index']}({a['window_title'][:30]})" for a in parasitic) + ) + assert len(ok) == 7, ( + f"Attendu 7 OK, obtenu {len(ok)}" + ) diff --git a/tools/session_cleaner.py b/tools/session_cleaner.py index 43465f8dd..515598aa0 100644 --- a/tools/session_cleaner.py +++ b/tools/session_cleaner.py @@ -15,6 +15,7 @@ Port : 5006 import json import logging +import math import os import uuid from datetime import datetime @@ -86,13 +87,18 @@ app = Flask(__name__) # --------------------------------------------------------------------------- # Fenetres considerees comme parasites +# ATTENTION : ces patterns sont compares en lowercase via `in` — ils doivent +# etre suffisamment specifiques pour ne pas attraper de faux positifs dans +# les logiciels metier (DPI, codage, facturation, etc.). +# "program manager" retire volontairement : le bureau Windows est souvent +# le point de depart d'un workflow (clic taskbar, icone, etc.). +# "assistant" retire : trop large (ex: "Assistant de codage PMSI"). +# "lea"/"léa" remplace par des patterns specifiques a l'outil Lea RPA. _PARASITIC_WINDOW_PATTERNS = [ - "program manager", "fenetre de depassement", "fenêtre de dépassement", - "léa", - "lea", - "assistant", + "léa - rpa", + "lea - rpa", "activer windows", ] @@ -199,6 +205,50 @@ def _load_events(session_dir: Path) -> List[Dict[str, Any]]: return events +def _get_app_name(event: Dict[str, Any]) -> str: + """Extraire le nom de l'application depuis l'evenement. + + Cherche dans event.event.window.app_name (format actuel). + """ + inner = event.get("event", {}) + window = inner.get("window") or {} + if isinstance(window, dict): + return window.get("app_name", "") or "" + return "" + + +def _has_identified_ui_element(event: Dict[str, Any]) -> bool: + """Verifier si l'evenement cible un element UI identifie par C2/UIA. + + Un clic sur un element UI nomme (bouton, champ, onglet) est tres + probablement un acte metier reel. Les donnees proviennent de : + - uia_snapshot.name : nom de l'element via UI Automation (lea_uia.exe) + - ui_elements : liste d'elements detectes par le pipeline C2 (vision) + - vision_info.ui_elements : idem, format alternatif + + ATTENTION : cette fonction ne garantit pas que le clic n'est pas + parasite — un clic systray a aussi un uia_name. C'est un indice + positif, pas une preuve absolue. Utiliser en conjonction avec les + filtres negatifs (systray, clic droit, etc.). + """ + inner = event.get("event", {}) + + # UIA snapshot — l'element a un nom identifie + uia = inner.get("uia_snapshot") or {} + if uia.get("name"): + return True + + # Pipeline C2 — elements visuels detectes + if inner.get("ui_elements"): + return True + + vision_info = inner.get("vision_info") or {} + if vision_info.get("ui_elements"): + return True + + return False + + def _get_window_title(event: Dict[str, Any]) -> str: """Extraire le titre de fenetre d'un evenement. @@ -241,15 +291,23 @@ def _get_shot_filename(click_index: int, session_dir: Path) -> Optional[str]: def _is_parasitic(event: Dict[str, Any], index: int, total: int) -> bool: """Determiner si un evenement est probablement parasite. - Criteres : - - Fenetre contenant un pattern parasite (systray, Program Manager, Lea, etc.) - - Clic droit - - Types non-exploitables (heartbeat, focus_change, action_result) - - Parmi les 3 derniers evenements (souvent = arret enregistrement) + Logique en 3 couches : + 1. Signaux durs (toujours parasites) : types non-exploitables, clics droit + 2. Signaux positifs C2/UIA (jamais parasites) : element UI identifie par + nom dans une app metier, ou app connue non-systray + 3. Patterns de fenetre (parasites si aucun signal positif) + + L'ancienne regle « 3 derniers = parasites » a ete supprimee car elle + generait trop de faux positifs sur les logiciels metier (le dernier + clic est souvent Valider/Sauvegarder/Confirmer). + La detection de l'arret d'enregistrement est maintenant faite par + _is_stop_recording_event() dans _parse_actions(). """ inner = event.get("event", {}) etype = inner.get("type", "") + # --- Couche 1 : signaux durs, toujours parasites --- + # Types toujours parasites if etype in ("heartbeat", "focus_change", "window_focus_change", "action_result", "screenshot", "status", "ping", "pong"): @@ -259,37 +317,333 @@ def _is_parasitic(event: Dict[str, Any], index: int, total: int) -> bool: if etype == "mouse_click" and inner.get("button") == "right": return True - # Fenetre parasite + # --- Couche 2 : signaux positifs C2/UIA --- + # Si on a un element UI identifie ET que ce n'est pas la systray, + # c'est un vrai clic metier — on le preserve quoi qu'il arrive. + + is_systray = _is_systray_interaction(event) + + if not is_systray and _has_identified_ui_element(event): + # Un clic sur un bouton/champ/onglet identifie par UIA ou C2 + # dans une fenetre qui n'est pas la systray = acte metier reel + return False + + # Si l'app n'est pas le bureau/systray, c'est une vraie application + # metier — les clics dedans sont legitimes meme sans info UIA/C2 + app_name = _get_app_name(event).lower() + _NON_BUSINESS_APPS = frozenset({ + "", "explorer.exe", "pythonw.exe", + }) + if app_name and app_name not in _NON_BUSINESS_APPS: + return False + + # --- Couche 3 : patterns de fenetre --- + win_title = _get_window_title(event).lower() if win_title: for pattern in _PARASITIC_WINDOW_PATTERNS: if pattern in win_title: return True - # Derniers 3 evenements exploitables de la session - # (on les marque UNIQUEMENT si c'est un evenement exploitable, pas un heartbeat) - if etype in _ACTIONABLE_TYPES and index >= total - 3: - return True + return False + + +def _is_stop_recording_event(event: Dict[str, Any], is_last_actionable: bool) -> bool: + """Detecter si un evenement est un arret d'enregistrement Lea. + + Plutot que de marquer aveuglement les 3 derniers evenements comme + parasites (ce qui supprime des clics metier importants comme + Valider/Sauvegarder), on detecte finement les patterns d'arret : + + - Le dernier evenement exploitable est un key_combo Ctrl+Shift+L + (raccourci explicite d'arret) + - Le dernier evenement est un clic sur une fenetre Lea/systray + (l'utilisateur clique sur l'icone systray pour arreter) + - Clic sur la zone systray (barre des taches, icones cachees, etc.) + identifie par le uia_snapshot + """ + if not is_last_actionable: + return False + + inner = event.get("event", {}) + etype = inner.get("type", "") + + # Raccourci Ctrl+Shift+L → arret explicite + if etype == "key_combo": + keys = inner.get("keys", []) + if isinstance(keys, list): + keys_lower = [str(k).lower() for k in keys] + # Ctrl+Shift+L (le \x0c ou 'l' selon l'encoding) + if "ctrl" in keys_lower and "shift" in keys_lower: + return True + + # Clic sur fenetre Lea (pythonw.exe = agent Lea) + if etype == "mouse_click": + window = inner.get("window", {}) + if isinstance(window, dict): + app_name = (window.get("app_name", "") or "").lower() + if app_name == "pythonw.exe": + return True return False +def _is_systray_interaction(event: Dict[str, Any]) -> bool: + """Detecter si un evenement est une interaction avec la systray. + + La systray (zone de notification) est identifiee par : + - Le uia_snapshot contenant 'Afficher les icônes cachées', + 'Barre des tâches', etc. + - Le parent_path contenant 'Fenêtre de dépassement' + """ + inner = event.get("event", {}) + uia = inner.get("uia_snapshot", {}) + if not uia: + return False + + uia_name = (uia.get("name", "") or "").lower() + # "afficher les icônes cachées" = bouton systray Windows + if "icônes cachées" in uia_name or "icones cachees" in uia_name: + return True + + # Verifier le parent_path pour la systray + parent_path = uia.get("parent_path", []) + if isinstance(parent_path, list): + for parent in parent_path: + if isinstance(parent, dict): + parent_name = (parent.get("name", "") or "").lower() + if "dépassement" in parent_name or "depassement" in parent_name: + return True + + return False + + +# --------------------------------------------------------------------------- +# Alias de raccourcis clavier courants +# --------------------------------------------------------------------------- + +_KEY_COMBO_ALIASES: Dict[str, str] = { + "ctrl+s": "Sauvegarde", + "ctrl+z": "Annuler", + "ctrl+y": "Rétablir", + "ctrl+c": "Copier", + "ctrl+v": "Coller", + "ctrl+x": "Couper", + "ctrl+a": "Tout sélectionner", + "ctrl+n": "Nouveau", + "ctrl+o": "Ouvrir", + "ctrl+p": "Imprimer", + "ctrl+f": "Rechercher", + "ctrl+w": "Fermer l'onglet", + "ctrl+shift+l": "Arrêt enregistrement Léa", + "alt+f4": "Fermer la fenêtre", +} + + +def _normalize_keys_for_alias(keys_raw) -> str: + """Normaliser les touches pour la recherche d'alias. + + Gère les caractères de contrôle (ex: \\x13 = Ctrl+S) et uniformise + en lowercase avec '+' comme séparateur. + Tri : modifiers (ctrl, shift, alt) en premier, puis la touche finale. + """ + if isinstance(keys_raw, str): + keys_list = [keys_raw] + elif isinstance(keys_raw, (list, tuple)): + keys_list = [str(k) for k in keys_raw] + else: + return "" + + _MODIFIERS = {"ctrl", "shift", "alt", "meta", "super", "win"} + modifiers = [] + others = [] + + for k in keys_list: + k_clean = k.strip().lower() + # Caractères de contrôle : \x01=a, \x03=c, \x04=d, ..., \x13=s, \x16=v, \x1a=z + if len(k_clean) == 1 and ord(k_clean) < 32: + k_clean = chr(ord(k_clean) + ord('a') - 1) + if k_clean in _MODIFIERS: + modifiers.append(k_clean) + else: + others.append(k_clean) + + return "+".join(sorted(modifiers) + sorted(others)) + + +def _generate_description(event: Dict[str, Any]) -> str: + """Generer une description lisible en français pour un evenement. + + Utilise les donnees UIA/C2 quand disponibles, sinon position + fenetre. + """ + inner = event.get("event", {}) + etype = inner.get("type", "") + + if etype == "mouse_click": + uia = inner.get("uia_snapshot") or {} + uia_name = uia.get("name", "") if uia else "" + uia_ct = uia.get("control_type", "") if uia else "" + + if uia_name: + ct_label = f" ({uia_ct})" if uia_ct else "" + return f'Clic sur « {uia_name} »{ct_label}' + else: + pos = inner.get("pos", []) + win_title = _get_window_title(event) + pos_str = f"({pos[0]}, {pos[1]})" if pos and len(pos) >= 2 else "(?)" + if win_title and win_title != "unknown_window": + return f'Clic à {pos_str} dans « {win_title} »' + return f'Clic à {pos_str}' + + elif etype in ("text_input", "type"): + text = inner.get("text", "") + if text: + # Tronquer si trop long + display = text if len(text) <= 40 else text[:37] + "..." + return f'Saisie : « {display} »' + return "Saisie (vide)" + + elif etype == "key_combo": + keys_raw = inner.get("keys", []) + keys_display = " + ".join(str(k) for k in keys_raw) if isinstance(keys_raw, list) else str(keys_raw) + + # Normaliser pour chercher l'alias + norm = _normalize_keys_for_alias(keys_raw) + alias = _KEY_COMBO_ALIASES.get(norm, "") + + # Affichage propre des noms de touches + keys_pretty = _pretty_keys(keys_raw) + + if alias: + return f"Raccourci : {keys_pretty} ({alias})" + return f"Raccourci : {keys_pretty}" + + elif etype == "key_press": + key = inner.get("key", "") + return f"Touche : {_pretty_key(str(key))}" + + return etype + + +def _pretty_keys(keys_raw) -> str: + """Formater une liste de touches pour l'affichage.""" + if isinstance(keys_raw, (list, tuple)): + return "+".join(_pretty_key(str(k)) for k in keys_raw) + return _pretty_key(str(keys_raw)) + + +def _pretty_key(key: str) -> str: + """Formater une touche individuelle pour l'affichage.""" + k = key.strip().lower() + # Caractères de contrôle + if len(k) == 1 and ord(k) < 32: + return chr(ord(k) + ord('A') - 1) + mapping = { + "ctrl": "Ctrl", + "shift": "Shift", + "alt": "Alt", + "enter": "Entrée", + "return": "Entrée", + "tab": "Tab", + "escape": "Échap", + "esc": "Échap", + "space": "Espace", + "backspace": "Retour arrière", + "delete": "Suppr", + "up": "↑", + "down": "↓", + "left": "←", + "right": "→", + } + if k in mapping: + return mapping[k] + # Touches de fonction : f1-f12 + if k.startswith("f") and k[1:].isdigit(): + return k.upper() + return key.capitalize() if len(key) == 1 else key + + +# --------------------------------------------------------------------------- +# Detection des doublons +# --------------------------------------------------------------------------- + + +def _detect_duplicates(actions: List[Dict[str, Any]], events: List[Dict[str, Any]]) -> None: + """Detecter les actions dupliquees et marquer is_duplicate=True. + + Criteres (tous requis) : + - Meme type (mouse_click) + - Meme position (distance euclidienne < 10px) + - Meme fenetre + - Ecart temporel < 1s + + Le SECOND evenement du doublon est marque (le premier est preserve). + Ne supprime rien — signale seulement. + """ + for j in range(1, len(actions)): + a_prev = actions[j - 1] + a_curr = actions[j] + + # Seulement les mouse_click + if a_prev["type"] != "mouse_click" or a_curr["type"] != "mouse_click": + continue + + # Verifier la fenetre + if a_prev["window_title"] != a_curr["window_title"]: + # Autoriser unknown_window comme equivalent + win_a = a_prev["window_title"] + win_b = a_curr["window_title"] + if win_a and win_b and win_a != "unknown_window" and win_b != "unknown_window": + continue + + # Verifier la position (distance euclidienne < 10px) + ev_prev = events[a_prev["global_index"]].get("event", {}) + ev_curr = events[a_curr["global_index"]].get("event", {}) + pos_prev = ev_prev.get("pos", []) + pos_curr = ev_curr.get("pos", []) + + if not pos_prev or not pos_curr or len(pos_prev) < 2 or len(pos_curr) < 2: + continue + + dx = pos_prev[0] - pos_curr[0] + dy = pos_prev[1] - pos_curr[1] + dist = math.sqrt(dx * dx + dy * dy) + if dist >= 10: + continue + + # Verifier l'ecart temporel < 1s + ts_prev = ev_prev.get("timestamp", 0) + ts_curr = ev_curr.get("timestamp", 0) + dt = abs(ts_curr - ts_prev) + if dt >= 1.0: + continue + + # C'est un doublon + a_curr["is_duplicate"] = True + a_curr["duplicate_info"] = ( + f"Doublon (< {dt:.0f}s, {dist:.0f}px de distance)" + if dist > 0 + else f"Doublon (< {dt:.1f}s, même position)" + ) + + def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict[str, Any]]: """Convertir les evenements bruts en liste d'actions affichables. Retourne une liste de dicts avec : index_global, type, position, fenetre, - texte, touches, shot_file, is_parasitic, etc. + texte, touches, shot_file, is_parasitic, description, is_duplicate, etc. """ actions: List[Dict[str, Any]] = [] click_count = 0 total_events = len(events) - # Pre-calculer les 3 derniers indices d'evenements exploitables + # Pre-calculer le dernier indice d'evenement exploitable + # pour la detection fine de l'arret d'enregistrement actionable_indices = [ i for i, ev in enumerate(events) if ev.get("event", {}).get("type", "") in _ACTIONABLE_TYPES ] - last_3_actionable = set(actionable_indices[-3:]) if len(actionable_indices) >= 3 else set(actionable_indices) + last_actionable_idx = actionable_indices[-1] if actionable_indices else -1 for i, event in enumerate(events): inner = event.get("event", {}) @@ -308,6 +662,9 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict "keys": "", "shot_file": None, "is_parasitic": False, + "description": _generate_description(event), + "is_duplicate": False, + "duplicate_info": "", } # Position (pour les clics) @@ -334,30 +691,25 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict else: action["keys"] = str(inner.get("key", keys)) - # Detection parasite - # Utiliser les 3 derniers indices exploitables (pas les indices globaux) - parasitic = False - inner_type = etype + # Detection parasite — logique centralisee + extensions + parasitic = _is_parasitic(event, i, total_events) - # Clic droit - if inner_type == "mouse_click" and inner.get("button") == "right": + # Extensions specifiques a _parse_actions : + # - interaction systray (icones cachees, fenetre depassement) + if not parasitic and _is_systray_interaction(event): parasitic = True - # Fenetre parasite - win_lower = action["window_title"].lower() - if win_lower: - for pattern in _PARASITIC_WINDOW_PATTERNS: - if pattern in win_lower: - parasitic = True - break - - # Derniers 3 evenements exploitables - if i in last_3_actionable: + # - arret d'enregistrement (dernier evenement exploitable uniquement) + is_last = (i == last_actionable_idx) + if not parasitic and _is_stop_recording_event(event, is_last): parasitic = True action["is_parasitic"] = parasitic actions.append(action) + # Detection des doublons (marque is_duplicate sur le 2eme du pair) + _detect_duplicates(actions, events) + return actions @@ -392,6 +744,11 @@ tr:hover { background: #f0f7ff; } padding: 15px; margin: 15px 0; } .parasitic { background: #ffe0e0; } .normal { background: #e0ffe0; } +.duplicate { background: #f0f0f0; color: #999; } +.duplicate td { color: #999; } +.desc { font-size: 13px; color: #555; max-width: 300px; } +.badge-dup { display: inline-block; background: #ddd; color: #888; font-size: 11px; + padding: 2px 6px; border-radius: 3px; margin-left: 4px; cursor: help; } .counter { font-size: 18px; font-weight: bold; margin: 15px 0; } .counter .remove { color: #e74c3c; } .counter .total { color: #2c3e50; } @@ -478,6 +835,9 @@ _SESSION_TEMPLATE = """
{{ parasitic_count }} actions a supprimer / {{ actions|length }} total + {% if duplicate_count > 0 %} + | {{ duplicate_count }} doublon{{ 's' if duplicate_count > 1 else '' }} + {% endif %}
@@ -490,6 +850,7 @@ _SESSION_TEMPLATE = """ Supprimer # Type + Description Position Fenetre Texte / Touches @@ -498,7 +859,7 @@ _SESSION_TEMPLATE = """ {% for a in actions %} - +