feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped

Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-17 17:46:40 +02:00
parent 2fa864b5c7
commit 4f61741420
27 changed files with 5088 additions and 1543 deletions

View File

@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
# Endpoint du serveur Streaming (port 5005) # 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") 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" UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" 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) # Token d'authentification API (doit correspondre au token du serveur)
# Configurable via variable d'environnement RPA_API_TOKEN # Configurable via variable d'environnement RPA_API_TOKEN
API_TOKEN = os.environ.get("RPA_API_TOKEN", "") API_TOKEN = os.environ.get("RPA_API_TOKEN", "")

View File

@@ -477,9 +477,15 @@ class ActionExecutorV1:
}, },
headers=headers, headers=headers,
timeout=10, 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() data = resp.json()
state = data.get("screen_state", "ok") state = data.get("screen_state", "ok")
if state != "ok": if state != "ok":
@@ -703,7 +709,11 @@ class ActionExecutorV1:
f"attendu '{expected_title}' → mode apprentissage" f"attendu '{expected_title}' → mode apprentissage"
) )
try: 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: except Exception:
pass pass
@@ -935,9 +945,10 @@ class ActionExecutorV1:
# et ne trouve toujours pas. L'humain doit montrer. # et ne trouve toujours pas. L'humain doit montrer.
print(f" [POLICY] Retry échoué → mode apprentissage") print(f" [POLICY] Retry échoué → mode apprentissage")
try: try:
self.notifier.replay_target_not_found( self.notifier.replay_learning_mode(
target_desc, raison="retry_failed",
target_spec.get("window_title", ""), target_description=target_desc,
window_title=target_spec.get("window_title", ""),
) )
except Exception: except Exception:
pass pass
@@ -993,9 +1004,10 @@ class ActionExecutorV1:
# passe en mode capture et enregistre ce que # passe en mode capture et enregistre ce que
# l'humain fait (mini-workflow de correction). # l'humain fait (mini-workflow de correction).
try: try:
self.notifier.replay_target_not_found( self.notifier.replay_learning_mode(
target_desc, raison="supervise",
target_spec.get("window_title", ""), target_description=target_desc,
window_title=target_spec.get("window_title", ""),
) )
except Exception: except Exception:
pass pass
@@ -1221,7 +1233,9 @@ class ActionExecutorV1:
f"je demande de l'aide" f"je demande de l'aide"
) )
try: try:
self.notifier.replay_no_screen_change(action_type) self.notifier.replay_learning_mode(
raison="no_screen_change",
)
except Exception: except Exception:
pass pass
@@ -1377,7 +1391,13 @@ class ActionExecutorV1:
try: try:
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...") 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: if not resp.ok:
logger.warning(f"Server resolve HTTP {resp.status_code}") logger.warning(f"Server resolve HTTP {resp.status_code}")
return None return None
@@ -1521,7 +1541,7 @@ class ActionExecutorV1:
if not vlm_description: if not vlm_description:
return None 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" ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = ( prompt = (
@@ -1657,7 +1677,7 @@ Example: x_pct=0.50, y_pct=0.30"""
if anchor_b64: if anchor_b64:
images.append(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" ollama_url = f"http://{ollama_host}:11434/api/chat"
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s # 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, json=report,
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=10, 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() server_resp = resp2.json()
msg = ( msg = (
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, " 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 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" ollama_url = f"http://{ollama_host}:11434/api/chat"
prompt = ( prompt = (
@@ -2575,8 +2601,8 @@ Example: x_pct=0.50, y_pct=0.30"""
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)" f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
) )
print( print(
f" [APPRENTISSAGE] Montre-moi comment faire.\n" f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
f" Quand tu as fini → Ctrl+Shift+L\n" f" Quand vous avez fini → Ctrl+Shift+L\n"
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)" f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
) )

View File

@@ -17,6 +17,7 @@ import threading
from .config import ( from .config import (
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S, SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
STREAMING_ENDPOINT,
) )
from .core.captor import EventCaptorV1 from .core.captor import EventCaptorV1
from .core.executor import ActionExecutorV1 from .core.executor import ActionExecutorV1
@@ -86,22 +87,23 @@ class AgentV1:
self._state.set_on_stop(self.stop_session) self._state.set_on_stop(self.stop_session)
# Client serveur pour le chat et les workflows # Client serveur pour le chat et les workflows
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
self._server_client = None self._server_client = None
if LeaServerClient is not None: if LeaServerClient is not None:
# Forcer le token API pour éviter les 401 # Forcer le token API pour éviter les 401
# (le token est set par start.bat dans l'environnement) # (le token est set par start.bat dans l'environnement)
from .config import API_TOKEN as _token from .config import API_TOKEN as _token
server_host = os.getenv("RPA_SERVER_HOST", "localhost") self._server_client = LeaServerClient()
self._server_client = LeaServerClient(server_host=server_host)
if _token and not self._server_client._api_token: if _token and not self._server_client._api_token:
self._server_client._api_token = _token self._server_client._api_token = _token
logger.info("Token API forcé dans LeaServerClient") logger.info("Token API forcé dans LeaServerClient")
# Fenetre de chat Lea (tkinter natif) # Fenetre de chat Lea (tkinter natif)
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
server_host = ( server_host = (
self._server_client.server_host self._server_client.server_host
if self._server_client is not None if self._server_client is not None
else os.getenv("RPA_SERVER_HOST", "localhost") else "localhost"
) )
self._chat_window = ChatWindow( self._chat_window = ChatWindow(
server_client=self._server_client, server_client=self._server_client,
@@ -363,11 +365,11 @@ class AgentV1:
continue continue
self._last_bg_hash = img_hash 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 {} headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
with open(full_path, 'rb') as f: with open(full_path, 'rb') as f:
req.post( req.post(
f"{SERVER_URL}/traces/stream/image", f"{STREAMING_ENDPOINT}/image",
params={ params={
"session_id": bg_session, "session_id": bg_session,
"shot_id": f"heartbeat_{int(time.time())}", "shot_id": f"heartbeat_{int(time.time())}",
@@ -376,6 +378,7 @@ class AgentV1:
headers=headers, headers=headers,
files={"file": ("screenshot.png", f, "image/png")}, files={"file": ("screenshot.png", f, "image/png")},
timeout=10, timeout=10,
allow_redirects=False,
) )
except Exception as e: except Exception as e:
logger.debug(f"[HEARTBEAT] Erreur: {e}") logger.debug(f"[HEARTBEAT] Erreur: {e}")

View File

@@ -544,6 +544,28 @@ class TraceStreamer:
except OSError as e: except OSError as e:
logger.debug(f"Purge échouée : {path}{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 # Envois HTTP
# ========================================================================= # =========================================================================
@@ -551,15 +573,20 @@ class TraceStreamer:
def _register_session(self): def _register_session(self):
"""Enregistrer la session auprès du serveur (avec identifiant machine).""" """Enregistrer la session auprès du serveur (avec identifiant machine)."""
try: try:
url = f"{STREAMING_ENDPOINT}/register"
resp = requests.post( resp = requests.post(
f"{STREAMING_ENDPOINT}/register", url,
params={ params={
"session_id": self.session_id, "session_id": self.session_id,
"machine_id": self.machine_id, "machine_id": self.machine_id,
}, },
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=3, timeout=3,
allow_redirects=False,
) )
if self._check_redirect(resp, url):
logger.warning("Enregistrement session échoué (redirect)")
return
if resp.ok: if resp.ok:
logger.info( logger.info(
f"Session {self.session_id} enregistrée sur le serveur " 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. C'est la dernière chance de sauver les données de la session.
""" """
try: try:
url = f"{STREAMING_ENDPOINT}/finalize"
resp = requests.post( resp = requests.post(
f"{STREAMING_ENDPOINT}/finalize", url,
params={ params={
"session_id": self.session_id, "session_id": self.session_id,
"machine_id": self.machine_id, "machine_id": self.machine_id,
}, },
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=30, # Le build workflow peut prendre du temps timeout=30, # Le build workflow peut prendre du temps
allow_redirects=False,
) )
self._check_redirect(resp, url)
if resp.ok: if resp.ok:
result = resp.json() result = resp.json()
logger.info(f"Session finalisée: {result}") logger.info(f"Session finalisée: {result}")
@@ -601,6 +631,7 @@ class TraceStreamer:
if not self._server_available: if not self._server_available:
return False return False
try: try:
url = f"{STREAMING_ENDPOINT}/event"
payload = { payload = {
"session_id": self.session_id, "session_id": self.session_id,
"timestamp": time.time(), "timestamp": time.time(),
@@ -608,11 +639,14 @@ class TraceStreamer:
"machine_id": self.machine_id, "machine_id": self.machine_id,
} }
resp = requests.post( resp = requests.post(
f"{STREAMING_ENDPOINT}/event", url,
json=payload, json=payload,
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=2, timeout=2,
allow_redirects=False,
) )
if self._check_redirect(resp, url):
return False
return resp.ok return resp.ok
except Exception as e: except Exception as e:
logger.debug(f"Streaming Event échoué: {e}") logger.debug(f"Streaming Event échoué: {e}")
@@ -645,18 +679,22 @@ class TraceStreamer:
"machine_id": self.machine_id, "machine_id": self.machine_id,
} }
url = f"{STREAMING_ENDPOINT}/image"
if jpeg_buf is not None: if jpeg_buf is not None:
# Envoi du JPEG compressé (BytesIO, pas de fuite possible) # Envoi du JPEG compressé (BytesIO, pas de fuite possible)
files = { files = {
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type) "file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
} }
resp = requests.post( resp = requests.post(
f"{STREAMING_ENDPOINT}/image", url,
files=files, files=files,
params=params, params=params,
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=5, timeout=5,
allow_redirects=False,
) )
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok: if resp.ok:
self._purge_local_image(path) self._purge_local_image(path)
return ImageSendResult.OK return ImageSendResult.OK
@@ -668,12 +706,15 @@ class TraceStreamer:
"file": (f"{shot_id}.png", f, "image/png") "file": (f"{shot_id}.png", f, "image/png")
} }
resp = requests.post( resp = requests.post(
f"{STREAMING_ENDPOINT}/image", url,
files=files, files=files,
params=params, params=params,
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=5, timeout=5,
allow_redirects=False,
) )
if self._check_redirect(resp, url):
return ImageSendResult.FAILED
if resp.ok: if resp.ok:
self._purge_local_image(path) self._purge_local_image(path)
return ImageSendResult.OK return ImageSendResult.OK

View File

@@ -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: def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
"""Message quand la connexion avec le serveur est perdue. """Message quand la connexion avec le serveur est perdue.

View File

@@ -32,6 +32,7 @@ from .messages import (
formatter_etape_workflow, formatter_etape_workflow,
formatter_fenetre_incorrecte, formatter_fenetre_incorrecte,
formatter_fin_workflow, formatter_fin_workflow,
formatter_mode_apprentissage,
formatter_ralentissement, formatter_ralentissement,
formatter_retry, formatter_retry,
) )
@@ -273,6 +274,20 @@ class NotificationManager:
msg = formatter_ecran_inchange(action_type) msg = formatter_ecran_inchange(action_type)
return self.notify_message(msg) 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: def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
"""Notification quand Léa retente une action.""" """Notification quand Léa retente une action."""
msg = formatter_retry(action_type, tentative) msg = formatter_retry(action_type, tentative)

View File

@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger("lea_ui.server_client") logger = logging.getLogger("lea_ui.server_client")
def _get_server_host() -> str: def _get_server_url() -> str:
"""Recuperer l'adresse du serveur Linux. """Recuperer l'URL du serveur RPA (avec /api/v1).
Ordre de resolution : Ordre de resolution :
1. Variable d'environnement RPA_SERVER_HOST 1. Import depuis agent_v1.config (source de verite unique)
2. Fichier de config agent_config.json (cle "server_host") 2. Variable d'environnement RPA_SERVER_URL
3. Fallback localhost 3. Fallback http://localhost:5005/api/v1
""" """
# 1. Variable d'environnement # 1. Import depuis config.py (source de verite)
host = os.environ.get("RPA_SERVER_HOST", "").strip()
if host:
return host
# 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: try:
with open(config_path, "r", encoding="utf-8") as f: from agent_v1.config import SERVER_URL
cfg = json.load(f) return SERVER_URL
host = cfg.get("server_host", "").strip() except ImportError:
if host: pass
return host
except (OSError, json.JSONDecodeError): # 2. Variable d'environnement directe
continue url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
if url:
return url
# 3. Fallback # 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: class LeaServerClient:
@@ -67,19 +64,22 @@ class LeaServerClient:
chat_port: int = 5004, chat_port: int = 5004,
stream_port: int = 5005, stream_port: int = 5005,
) -> None: ) -> 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._chat_port = chat_port
self._stream_port = stream_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}" self._chat_base = f"http://{self._host}:{self._chat_port}"
# Etat de connexion # Etat de connexion
@@ -103,8 +103,8 @@ class LeaServerClient:
self._api_token = os.environ.get("RPA_API_TOKEN", "") self._api_token = os.environ.get("RPA_API_TOKEN", "")
logger.info( logger.info(
"LeaServerClient initialise : chat=%s, stream=%s", "LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
self._chat_base, self._stream_base, self._chat_base, self._stream_url, self._stream_base,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -154,7 +154,11 @@ class LeaServerClient:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def check_connection(self) -> bool: 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: try:
import requests import requests
resp = requests.get( resp = requests.get(
@@ -227,7 +231,7 @@ class LeaServerClient:
import requests import requests
headers = self._auth_headers() headers = self._auth_headers()
resp = requests.get( resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/workflows", f"{self._stream_url}/traces/stream/workflows",
headers=headers, headers=headers,
timeout=10, timeout=10,
) )
@@ -284,7 +288,7 @@ class LeaServerClient:
while self._polling: while self._polling:
try: try:
resp = req_lib.get( 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}, params={"session_id": self._poll_session_id},
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=5, timeout=5,
@@ -318,7 +322,7 @@ class LeaServerClient:
try: try:
import requests import requests
resp = requests.get( resp = requests.get(
f"{self._stream_base}/api/v1/traces/stream/replays", f"{self._stream_url}/traces/stream/replays",
headers=self._auth_headers(), headers=self._auth_headers(),
timeout=5, timeout=5,
) )
@@ -346,7 +350,7 @@ class LeaServerClient:
try: try:
import requests import requests
requests.post( requests.post(
f"{self._stream_base}/api/v1/traces/stream/replay/result", f"{self._stream_url}/traces/stream/replay/result",
json={ json={
"session_id": session_id, "session_id": session_id,
"action_id": action_id, "action_id": action_id,

View File

@@ -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") @app.middleware("http")
async def security_headers_middleware(request: Request, call_next): async def security_headers_middleware(request: Request, call_next):
"""Ajouter les headers de sécurité sur toutes les réponses.""" """Ajouter les headers de sécurité sur toutes les réponses."""

View File

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

View File

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

View File

@@ -22,6 +22,6 @@ USER_NAME=Prenom Nom
USER_EMAIL=prenom.nom@aivanov.com USER_EMAIL=prenom.nom@aivanov.com
USER_ID= USER_ID=
# Connexion serveur (valeurs par defaut deja pre-remplies) # Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 SERVER_URL=CONFIGURE_ME
API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab API_TOKEN=CONFIGURE_ME

View File

@@ -8,36 +8,33 @@
# #
# Les lignes commencant par # sont des commentaires (ignorees). # 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) # Adresse du serveur Lea (obligatoire — remplacer avant utilisation)
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 # 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) # Cle d'authentification (fournie par l'administrateur)
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab RPA_API_TOKEN=CONFIGURE_ME
# Nom du serveur (sans https://, sans /api/v1) # Host Ollama (defaut localhost, ne pas modifier sauf configuration speciale)
RPA_SERVER_HOST=lea.labs.laurinebazin.design # RPA_OLLAMA_HOST=localhost
# ============================================================ # Identifiant unique de ce poste
# Parametres avances (ne pas modifier sauf indication) RPA_MACHINE_ID=CONFIGURE_ME
# ============================================================
# Flouter les zones de texte dans les captures cote CLIENT. # Nom du collaborateur associe
# RPA_USER_LABEL=CONFIGURE_ME
# DEPUIS AVRIL 2026 : LE BLUR CLIENT EST DESACTIVE PAR DEFAUT.
# Le floutage des donnees sensibles (noms, adresses, telephones, NIR, email) # --- Parametres avances (ne pas modifier sauf indication) ---
# 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).
RPA_BLUR_SENSITIVE=false RPA_BLUR_SENSITIVE=false
# Duree de conservation des logs en jours (minimum 180 pour conformite)
RPA_LOG_RETENTION_DAYS=180 RPA_LOG_RETENTION_DAYS=180

View File

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

View File

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

View File

@@ -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=<TOKEN_FOURNI_PAR_DOM>
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 <TOKEN_ADMIN>
```
La réponse doit contenir une entrée avec :
```json
{
"agent_id": "<nom_du_poste_TIM>",
"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://<serveur>: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.**

View File

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

View File

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

View File

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

View File

@@ -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://<ip_dgx>: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=<token>
| RPA_MACHINE_ID=<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. |

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ Port : 5006
import json import json
import logging import logging
import math
import os import os
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -86,13 +87,18 @@ app = Flask(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fenetres considerees comme parasites # 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 = [ _PARASITIC_WINDOW_PATTERNS = [
"program manager",
"fenetre de depassement", "fenetre de depassement",
"fenêtre de dépassement", "fenêtre de dépassement",
"léa", "léa - rpa",
"lea", "lea - rpa",
"assistant",
"activer windows", "activer windows",
] ]
@@ -199,6 +205,50 @@ def _load_events(session_dir: Path) -> List[Dict[str, Any]]:
return events 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: def _get_window_title(event: Dict[str, Any]) -> str:
"""Extraire le titre de fenetre d'un evenement. """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: def _is_parasitic(event: Dict[str, Any], index: int, total: int) -> bool:
"""Determiner si un evenement est probablement parasite. """Determiner si un evenement est probablement parasite.
Criteres : Logique en 3 couches :
- Fenetre contenant un pattern parasite (systray, Program Manager, Lea, etc.) 1. Signaux durs (toujours parasites) : types non-exploitables, clics droit
- Clic droit 2. Signaux positifs C2/UIA (jamais parasites) : element UI identifie par
- Types non-exploitables (heartbeat, focus_change, action_result) nom dans une app metier, ou app connue non-systray
- Parmi les 3 derniers evenements (souvent = arret enregistrement) 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", {}) inner = event.get("event", {})
etype = inner.get("type", "") etype = inner.get("type", "")
# --- Couche 1 : signaux durs, toujours parasites ---
# Types toujours parasites # Types toujours parasites
if etype in ("heartbeat", "focus_change", "window_focus_change", "action_result", if etype in ("heartbeat", "focus_change", "window_focus_change", "action_result",
"screenshot", "status", "ping", "pong"): "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": if etype == "mouse_click" and inner.get("button") == "right":
return True 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() win_title = _get_window_title(event).lower()
if win_title: if win_title:
for pattern in _PARASITIC_WINDOW_PATTERNS: for pattern in _PARASITIC_WINDOW_PATTERNS:
if pattern in win_title: if pattern in win_title:
return True return True
# Derniers 3 evenements exploitables de la session return False
# (on les marque UNIQUEMENT si c'est un evenement exploitable, pas un heartbeat)
if etype in _ACTIONABLE_TYPES and index >= total - 3:
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 True
return False 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]]: def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict[str, Any]]:
"""Convertir les evenements bruts en liste d'actions affichables. """Convertir les evenements bruts en liste d'actions affichables.
Retourne une liste de dicts avec : index_global, type, position, fenetre, 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]] = [] actions: List[Dict[str, Any]] = []
click_count = 0 click_count = 0
total_events = len(events) 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 = [ actionable_indices = [
i for i, ev in enumerate(events) i for i, ev in enumerate(events)
if ev.get("event", {}).get("type", "") in _ACTIONABLE_TYPES 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): for i, event in enumerate(events):
inner = event.get("event", {}) inner = event.get("event", {})
@@ -308,6 +662,9 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict
"keys": "", "keys": "",
"shot_file": None, "shot_file": None,
"is_parasitic": False, "is_parasitic": False,
"description": _generate_description(event),
"is_duplicate": False,
"duplicate_info": "",
} }
# Position (pour les clics) # Position (pour les clics)
@@ -334,30 +691,25 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict
else: else:
action["keys"] = str(inner.get("key", keys)) action["keys"] = str(inner.get("key", keys))
# Detection parasite # Detection parasite — logique centralisee + extensions
# Utiliser les 3 derniers indices exploitables (pas les indices globaux) parasitic = _is_parasitic(event, i, total_events)
parasitic = False
inner_type = etype
# Clic droit # Extensions specifiques a _parse_actions :
if inner_type == "mouse_click" and inner.get("button") == "right": # - interaction systray (icones cachees, fenetre depassement)
if not parasitic and _is_systray_interaction(event):
parasitic = True parasitic = True
# Fenetre parasite # - arret d'enregistrement (dernier evenement exploitable uniquement)
win_lower = action["window_title"].lower() is_last = (i == last_actionable_idx)
if win_lower: if not parasitic and _is_stop_recording_event(event, is_last):
for pattern in _PARASITIC_WINDOW_PATTERNS:
if pattern in win_lower:
parasitic = True
break
# Derniers 3 evenements exploitables
if i in last_3_actionable:
parasitic = True parasitic = True
action["is_parasitic"] = parasitic action["is_parasitic"] = parasitic
actions.append(action) actions.append(action)
# Detection des doublons (marque is_duplicate sur le 2eme du pair)
_detect_duplicates(actions, events)
return actions return actions
@@ -392,6 +744,11 @@ tr:hover { background: #f0f7ff; }
padding: 15px; margin: 15px 0; } padding: 15px; margin: 15px 0; }
.parasitic { background: #ffe0e0; } .parasitic { background: #ffe0e0; }
.normal { background: #e0ffe0; } .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 { font-size: 18px; font-weight: bold; margin: 15px 0; }
.counter .remove { color: #e74c3c; } .counter .remove { color: #e74c3c; }
.counter .total { color: #2c3e50; } .counter .total { color: #2c3e50; }
@@ -478,6 +835,9 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
<div class="counter" id="counter"> <div class="counter" id="counter">
<span class="remove" id="remove-count">{{ parasitic_count }}</span> actions a supprimer / <span class="remove" id="remove-count">{{ parasitic_count }}</span> actions a supprimer /
<span class="total">{{ actions|length }}</span> total <span class="total">{{ actions|length }}</span> total
{% if duplicate_count > 0 %}
| <span style="color:#999">{{ duplicate_count }} doublon{{ 's' if duplicate_count > 1 else '' }}</span>
{% endif %}
</div> </div>
<form method="POST" action="{{ url_for('clean_and_replay') }}" id="clean-form"> <form method="POST" action="{{ url_for('clean_and_replay') }}" id="clean-form">
@@ -490,6 +850,7 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
<th>Supprimer</th> <th>Supprimer</th>
<th>#</th> <th>#</th>
<th>Type</th> <th>Type</th>
<th>Description</th>
<th>Position</th> <th>Position</th>
<th>Fenetre</th> <th>Fenetre</th>
<th>Texte / Touches</th> <th>Texte / Touches</th>
@@ -498,7 +859,7 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
</thead> </thead>
<tbody> <tbody>
{% for a in actions %} {% for a in actions %}
<tr class="{{ 'parasitic' if a.is_parasitic else 'normal' }}"> <tr class="{{ 'parasitic' if a.is_parasitic else ('duplicate' if a.is_duplicate else 'normal') }}">
<td> <td>
<label> <label>
<input type="checkbox" name="remove_indices" <input type="checkbox" name="remove_indices"
@@ -513,7 +874,11 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
{% if a.button is defined and a.button == 'right' %} {% if a.button is defined and a.button == 'right' %}
<span style="color:#e74c3c">(droit)</span> <span style="color:#e74c3c">(droit)</span>
{% endif %} {% endif %}
{% if a.is_duplicate %}
<span class="badge-dup" title="{{ a.duplicate_info }}">doublon</span>
{% endif %}
</td> </td>
<td class="desc">{{ a.description }}</td>
<td class="mono">{{ a.position }}</td> <td class="mono">{{ a.position }}</td>
<td>{{ a.window_title|truncate(40) }}</td> <td>{{ a.window_title|truncate(40) }}</td>
<td class="mono"> <td class="mono">
@@ -665,9 +1030,10 @@ def view_session(machine_id: str, session_id: str):
events = _load_events(session_dir) events = _load_events(session_dir)
actions = _parse_actions(events, session_dir) actions = _parse_actions(events, session_dir)
# Compter les parasites et collecter leurs indices globaux # Compter les parasites, doublons et collecter les indices globaux
parasitic_count = sum(1 for a in actions if a["is_parasitic"]) parasitic_count = sum(1 for a in actions if a["is_parasitic"])
parasitic_indices = [a["global_index"] for a in actions if a["is_parasitic"]] parasitic_indices = [a["global_index"] for a in actions if a["is_parasitic"]]
duplicate_count = sum(1 for a in actions if a.get("is_duplicate"))
# Date depuis le nom de session # Date depuis le nom de session
date_str = "" date_str = ""
@@ -688,6 +1054,7 @@ def view_session(machine_id: str, session_id: str):
actions=actions, actions=actions,
parasitic_count=parasitic_count, parasitic_count=parasitic_count,
parasitic_indices=parasitic_indices, parasitic_indices=parasitic_indices,
duplicate_count=duplicate_count,
css=_BASE_CSS, css=_BASE_CSS,
) )

View File

@@ -37,7 +37,8 @@ CORE_ACTION_TO_VWB = {
"mouse_click": "click_anchor", "mouse_click": "click_anchor",
"text_input": "type_text", "text_input": "type_text",
"key_press": "keyboard_shortcut", "key_press": "keyboard_shortcut",
"compound": "click_anchor", # Sera décomposé en sous-étapes "key_combo": "keyboard_shortcut",
"compound": "click_anchor", # Décomposé en N étapes séparées par le bridge
"wait": "wait_for_anchor", "wait": "wait_for_anchor",
"scroll": "scroll_to_anchor", "scroll": "scroll_to_anchor",
"unknown": "click_anchor", "unknown": "click_anchor",
@@ -133,22 +134,64 @@ def convert_learned_to_vwb_steps(
if to_node and to_node not in visited: if to_node and to_node not in visited:
queue.append(to_node) queue.append(to_node)
# Convertir chaque edge en Step VWB # Convertir chaque edge en Step(s) VWB
# Les actions compound sont décomposées en N steps séparés
steps = [] steps = []
for idx, edge in enumerate(ordered_edges): for edge in ordered_edges:
action = edge.get("action", {}) action = edge.get("action", {})
action_type = action.get("type", "unknown") action_type = action.get("type", "unknown")
action_params = action.get("parameters", {}) action_params = action.get("parameters", {})
target = action.get("target", {}) target = action.get("target", {})
# Déterminer le type VWB from_node = edge.get("from_node", "")
vwb_action_type = CORE_ACTION_TO_VWB.get(action_type, "click_anchor") to_node = edge.get("to_node") or edge.get("target_node", "")
from_name = nodes_by_id.get(from_node, {}).get("name", from_node)
to_name = nodes_by_id.get(to_node, {}).get("name", to_node)
edge_meta = {
"core_edge_id": edge.get("edge_id", ""),
"core_from_node": from_node,
"core_to_node": to_node,
}
# Construire les paramètres VWB if action_type == "compound":
# --- Décomposer les compound en N étapes VWB séparées ---
sub_steps = action_params.get("steps", [])
if not sub_steps:
warnings.append(
f"Action compound sans sous-étapes (edge {edge.get('edge_id', '?')})"
)
continue
for sub_idx, sub in enumerate(sub_steps):
sub_type = sub.get("type", "unknown")
sub_vwb_type, sub_params = _convert_compound_substep(
sub_type, sub, target
)
label = _build_step_label(sub_vwb_type, sub_params, from_name, to_name)
steps.append({
"action_type": sub_vwb_type,
"order": len(steps),
"position_x": 0, # sera recalculé par _compute_layout
"position_y": 0,
"parameters": sub_params,
"label": label,
"metadata": {
**edge_meta,
"compound_sub_index": sub_idx,
"compound_total": len(sub_steps),
},
})
warnings.append(
f"Compound décomposé en {len(sub_steps)} étapes VWB séparées "
f"(edge {edge.get('edge_id', '?')})"
)
else:
# --- Action simple (non-compound) ---
vwb_action_type = CORE_ACTION_TO_VWB.get(action_type, "click_anchor")
vwb_params = {} vwb_params = {}
if action_type == "mouse_click": if action_type == "mouse_click":
# Extraire la position en pourcentage si disponible
by_position = target.get("by_position") by_position = target.get("by_position")
if by_position: if by_position:
vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0 vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0
@@ -162,47 +205,32 @@ def convert_learned_to_vwb_steps(
elif action_type == "text_input": elif action_type == "text_input":
vwb_params["text"] = action_params.get("text", "") vwb_params["text"] = action_params.get("text", "")
elif action_type == "key_press": elif action_type in ("key_press", "key_combo"):
vwb_action_type = "keyboard_shortcut"
keys = action_params.get("keys", []) keys = action_params.get("keys", [])
if not keys and action_params.get("key"): if not keys and action_params.get("key"):
keys = [action_params["key"]] keys = [action_params["key"]]
vwb_params["keys"] = keys vwb_params["keys"] = keys
elif action_type == "compound":
# Stocker les sous-étapes dans les paramètres pour référence
vwb_params["compound_steps"] = action_params.get("steps", [])
warnings.append(
f"Étape {idx + 1} : action compound décomposée — vérifier manuellement"
)
# Ajouter des infos de ciblage pour la review humaine # Ajouter des infos de ciblage pour la review humaine
if target.get("by_role"): if target.get("by_role"):
vwb_params["target_role"] = target["by_role"] vwb_params["target_role"] = target["by_role"]
if target.get("by_text"): if target.get("by_text"):
vwb_params["target_text"] = target["by_text"] vwb_params["target_text"] = target["by_text"]
# Construire le label
from_node = edge.get("from_node", "")
to_node = edge.get("to_node") or edge.get("target_node", "")
from_name = nodes_by_id.get(from_node, {}).get("name", from_node)
to_name = nodes_by_id.get(to_node, {}).get("name", to_node)
label = _build_step_label(vwb_action_type, vwb_params, from_name, to_name) label = _build_step_label(vwb_action_type, vwb_params, from_name, to_name)
steps.append({
step = {
"action_type": vwb_action_type, "action_type": vwb_action_type,
"order": idx, "order": len(steps),
"position_x": 400, "position_x": 0,
"position_y": 80 + idx * 120, "position_y": 0,
"parameters": vwb_params, "parameters": vwb_params,
"label": label, "label": label,
# Métadonnées d'origine pour traçabilité "metadata": edge_meta,
"metadata": { })
"core_edge_id": edge.get("edge_id", ""),
"core_from_node": from_node, # Appliquer le layout serpentin à tous les steps
"core_to_node": to_node, _compute_layout(steps)
},
}
steps.append(step)
if not steps and nodes: if not steps and nodes:
# Pas d'edges mais des nodes → créer des étapes basiques depuis les nodes # Pas d'edges mais des nodes → créer des étapes basiques depuis les nodes
@@ -212,18 +240,91 @@ def convert_learned_to_vwb_steps(
steps.append({ steps.append({
"action_type": "click_anchor", "action_type": "click_anchor",
"order": idx, "order": idx,
"position_x": 400, "position_x": 0,
"position_y": 80 + idx * 120, "position_y": 0,
"parameters": { "parameters": {
"window_title": (node.get("template", {}).get("window", {}) or {}).get("title_pattern", ""), "window_title": (node.get("template", {}).get("window", {}) or {}).get("title_pattern", ""),
}, },
"label": f"Écran : {node_name}", "label": f"Écran : {node_name}",
"metadata": {"core_node_id": node.get("node_id", "")}, "metadata": {"core_node_id": node.get("node_id", "")},
}) })
_compute_layout(steps)
return workflow_meta, steps, warnings return workflow_meta, steps, warnings
def _convert_compound_substep(
sub_type: str, sub: Dict[str, Any], parent_target: Dict[str, Any]
) -> Tuple[str, Dict[str, Any]]:
"""
Convertit une sous-étape compound en (vwb_action_type, vwb_params).
Gère les types : mouse_click, text_input, key_combo, key_press.
"""
vwb_params: Dict[str, Any] = {}
if sub_type == "mouse_click":
vwb_type = "click_anchor"
pos = sub.get("pos")
if pos and isinstance(pos, (list, tuple)) and len(pos) >= 2:
vwb_params["x_pct"] = pos[0]
vwb_params["y_pct"] = pos[1]
button = sub.get("button", "left")
if button == "double":
vwb_type = "double_click_anchor"
elif button == "right":
vwb_type = "right_click_anchor"
# Hériter les infos de ciblage du parent
if parent_target.get("by_role"):
vwb_params["target_role"] = parent_target["by_role"]
if parent_target.get("by_text"):
vwb_params["target_text"] = parent_target["by_text"]
elif sub_type == "text_input":
vwb_type = "type_text"
vwb_params["text"] = sub.get("text", "")
elif sub_type in ("key_combo", "key_press"):
vwb_type = "keyboard_shortcut"
keys = sub.get("keys", [])
if not keys and sub.get("key"):
keys = [sub["key"]]
vwb_params["keys"] = keys
else:
# Type inconnu — fallback sur click_anchor
vwb_type = CORE_ACTION_TO_VWB.get(sub_type, "click_anchor")
return vwb_type, vwb_params
def _compute_layout(
steps: List[Dict[str, Any]],
cols: int = 3,
cell_w: int = 280,
cell_h: int = 140,
margin_x: int = 60,
margin_y: int = 40,
) -> List[Dict[str, Any]]:
"""
Disposition en grille serpentin (zigzag) pour lisibilité humaine.
Lignes paires : gauche → droite
Lignes impaires : droite → gauche
Modifie les steps en place et les retourne.
"""
for idx, step in enumerate(steps):
row = idx // cols
col = idx % cols
# Serpentin : lignes impaires inversées
if row % 2 == 1:
col = cols - 1 - col
step["position_x"] = margin_x + col * (cell_w + margin_x)
step["position_y"] = margin_y + row * (cell_h + margin_y)
return steps
def _build_step_label( def _build_step_label(
action_type: str, params: Dict[str, Any], from_name: str, to_name: str action_type: str, params: Dict[str, Any], from_name: str, to_name: str
) -> str: ) -> str:

View File

@@ -53,13 +53,15 @@ export default function WorkflowSelector({
} }
}, [editingId]); }, [editingId]);
// Charger les workflows appris quand le dropdown s'ouvre (filtrés par OS) // Charger les workflows appris quand le dropdown s'ouvre (tous OS)
// Note : pas de filtre OS — un admin Linux doit pouvoir voir et importer
// les workflows captés sur Windows (et inversement à terme avec CLIP OS)
const loadLearnedWorkflows = useCallback(async () => { const loadLearnedWorkflows = useCallback(async () => {
setLearnedLoading(true); setLearnedLoading(true);
try { try {
const data = await api.getLearnedWorkflows(undefined, userOS); const data = await api.getLearnedWorkflows();
// Ne garder que ceux qui ne sont pas encore importés // Ne garder que ceux qui ne sont pas encore importés
setLearnedWorkflows(data.workflows.filter(w => !w.already_imported)); setLearnedWorkflows(data.workflows.filter((w: LearnedWorkflow) => !w.already_imported));
} catch { } catch {
// Silencieux : le streaming server n'est peut-être pas lancé // Silencieux : le streaming server n'est peut-être pas lancé
setLearnedWorkflows([]); setLearnedWorkflows([]);
@@ -80,11 +82,10 @@ export default function WorkflowSelector({
(wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase())) (wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase()))
); );
// Filtrer les workflows appris (par recherche + OS) // Filtrer les workflows appris (par recherche uniquement, pas par OS)
const filteredLearned = learnedWorkflows.filter(wf => const filteredLearned = learnedWorkflows.filter(wf =>
(wf.name.toLowerCase().includes(search.toLowerCase()) || wf.name.toLowerCase().includes(search.toLowerCase()) ||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())) && wf.workflow_id.toLowerCase().includes(search.toLowerCase())
(wf.machine_id || '').toLowerCase().includes(userOS)
); );
// Workflows récents (les 8 premiers) // Workflows récents (les 8 premiers)
@@ -257,9 +258,12 @@ export default function WorkflowSelector({
<span className="learned-badge" title={`Machine: ${wf.machine_id}`}> <span className="learned-badge" title={`Machine: ${wf.machine_id}`}>
appris appris
</span> </span>
<span className="os-badge" title={`Capturé sur ${(wf.machine_id || '').includes('windows') ? 'Windows' : (wf.machine_id || '').includes('linux') ? 'Linux' : 'Inconnu'}`}>
{(wf.machine_id || '').includes('windows') ? '🪟' : (wf.machine_id || '').includes('linux') ? '🐧' : '❓'}
</span>
</span> </span>
<span className="item-meta"> <span className="item-meta">
{wf.nodes} noeuds, {wf.edges} transitions {wf.nodes} nœuds, {wf.edges} transitions
</span> </span>
<button <button
className="import-btn" className="import-btn"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,877 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RPA Vision V3 - Audit & Traçabilité</title>
<style>
/* === Reset & base — identique à index.html === */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
/* === Header === */
.header { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header h1 { font-size: 24px; display: flex; align-items: center; gap: 10px; }
.header-nav { display: flex; align-items: center; gap: 8px; }
.header-nav a {
color: rgba(255,255,255,0.8); text-decoration: none; font-size: 13px;
padding: 6px 14px; border-radius: 6px; transition: all 0.2s;
background: rgba(255,255,255,0.1);
}
.header-nav a:hover { background: rgba(255,255,255,0.2); }
.header-nav a.active { background: rgba(255,255,255,0.25); color: #fff; font-weight: 600; }
/* === Layout === */
.container { max-width: 1600px; margin: 0 auto; padding: 20px; }
/* === Cards === */
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 20px; }
.card h2 { font-size: 16px; margin-bottom: 15px; color: #94a3b8; display: flex; align-items: center; gap: 8px; }
/* === Stat cards === */
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 20px; }
@media (max-width: 900px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 500px) { .grid-4 { grid-template-columns: 1fr; } }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; color: #3b82f6; }
.stat-label { font-size: 12px; color: #64748b; margin-top: 5px; text-transform: uppercase; }
/* === Boutons === */
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-success { background: #22c55e; color: white; }
.btn-success:hover { background: #16a34a; }
.btn-warning { background: #f59e0b; color: white; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-secondary { background: #475569; color: white; }
.btn-secondary:hover { background: #64748b; }
.btn-small { padding: 8px 16px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* === Filtres === */
.filters-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { font-size: 11px; color: #64748b; text-transform: uppercase; font-weight: 600; }
.filter-input {
padding: 8px 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #e2e8f0; font-size: 13px; min-width: 140px;
}
.filter-input:focus { border-color: #3b82f6; outline: none; }
select.filter-input { cursor: pointer; }
/* === Tableau === */
.table-wrapper { overflow-x: auto; margin-top: 15px; }
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.audit-table thead th {
background: #334155; color: #94a3b8; padding: 12px 10px;
text-align: left; font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px;
position: sticky; top: 0; z-index: 10; white-space: nowrap;
}
.audit-table tbody tr { border-bottom: 1px solid #1e293b; transition: background 0.15s; }
.audit-table tbody tr:hover { background: #334155; }
.audit-table td { padding: 10px; vertical-align: middle; }
.audit-table td.ts { color: #94a3b8; font-size: 12px; white-space: nowrap; }
.audit-table td.detail { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* === Badges === */
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.badge-success { background: #064e3b; color: #6ee7b7; }
.badge-failed { background: #7f1d1d; color: #fca5a5; }
.badge-recovered { background: #78350f; color: #fcd34d; }
.badge-skipped { background: #1e293b; color: #94a3b8; }
.badge-shadow { background: #064e3b; color: #6ee7b7; }
.badge-copilot, .badge-assisted { background: #1e3a5f; color: #93c5fd; }
.badge-autonomous { background: #78350f; color: #fcd34d; }
.badge-action { background: #312e81; color: #a5b4fc; }
/* === Panneau de détails === */
.detail-overlay {
display: none; position: fixed; top: 0; right: 0; bottom: 0;
width: 500px; max-width: 90vw; background: #1e293b;
border-left: 1px solid #334155; z-index: 1000;
box-shadow: -4px 0 20px rgba(0,0,0,0.5); overflow-y: auto;
padding: 25px; animation: slideIn 0.2s ease-out;
}
.detail-overlay.open { display: block; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.detail-overlay h3 { font-size: 16px; margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center; }
.detail-overlay .close-btn {
background: none; border: none; color: #94a3b8; font-size: 24px;
cursor: pointer; padding: 5px;
}
.detail-overlay .close-btn:hover { color: #e2e8f0; }
.detail-json {
background: #0f172a; border: 1px solid #334155; border-radius: 8px;
padding: 15px; font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px; white-space: pre-wrap; word-break: break-all;
color: #a5b4fc; max-height: 70vh; overflow-y: auto; line-height: 1.6;
}
/* === Pagination === */
.pagination {
display: flex; justify-content: space-between; align-items: center;
margin-top: 15px; padding: 10px 0; color: #94a3b8; font-size: 13px;
}
.pagination .page-btns { display: flex; gap: 8px; }
/* === Auto-refresh indicator === */
.refresh-bar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 15px; color: #64748b; font-size: 12px;
}
.refresh-bar .indicator { display: flex; align-items: center; gap: 6px; }
.refresh-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; }
.refresh-dot.paused { background: #f59e0b; }
/* === Subtitle réglementaire === */
.regulatory-subtitle { color: #64748b; font-size: 13px; margin-top: 4px; }
.regulatory-subtitle span { background: #1e293b; padding: 2px 8px; border-radius: 4px; font-size: 11px; border: 1px solid #334155; }
/* === Loading === */
.loading { text-align: center; padding: 40px; color: #64748b; }
.spinner { border: 3px solid #334155; border-top: 3px solid #3b82f6; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 15px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* === Backdrop pour le panneau latéral === */
.detail-backdrop {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4); z-index: 999;
}
.detail-backdrop.open { display: block; }
/* === Erreur connexion === */
.error-banner {
background: #7f1d1d; border: 1px solid #ef4444; border-radius: 8px;
padding: 12px 20px; color: #fca5a5; font-size: 13px; margin-bottom: 15px;
display: none; align-items: center; gap: 10px;
}
.error-banner.visible { display: flex; }
/* === Résumé bottom === */
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
@media (max-width: 900px) { .grid-3 { grid-template-columns: 1fr; } }
.top-failures-item {
display: flex; justify-content: space-between; padding: 8px 0;
border-bottom: 1px solid #334155; font-size: 13px;
}
.top-failures-item:last-child { border-bottom: none; }
</style>
</head>
<body>
<!-- Header — identique à index.html -->
<div class="header">
<div>
<h1>&#x2696;&#xFE0F; Audit & Traçabilité</h1>
<div class="regulatory-subtitle">
Conformité <span>AI Act art. 12</span> <span>RGPD art. 30</span>
</div>
</div>
<nav class="header-nav">
<a href="/">&#x1F39B;&#xFE0F; Dashboard</a>
<a href="/audit" class="active">&#x2696;&#xFE0F; Audit</a>
</nav>
</div>
<div class="container">
<!-- Bannière d'erreur connexion -->
<div class="error-banner" id="errorBanner">
&#x26A0;&#xFE0F; <span id="errorText">Serveur streaming (5005) inaccessible.</span>
</div>
<!-- KPIs -->
<div class="grid-4" id="kpiGrid">
<div class="card stat-card">
<div class="stat-value" id="kpiTotal">-</div>
<div class="stat-label">Actions aujourd'hui</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="kpiSuccess">-</div>
<div class="stat-label">Taux de réussite</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="kpiAutonomous">-</div>
<div class="stat-label">Mode autonome</div>
</div>
<div class="card stat-card">
<div class="stat-value" id="kpiAvgDuration">-</div>
<div class="stat-label">Durée moyenne</div>
</div>
</div>
<!-- Filtres + Actions -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:15px;">
<h2 style="margin-bottom:0;">&#x1F50D; Filtres</h2>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-success btn-small" onclick="exportCSV()">&#x1F4E5; Exporter CSV</button>
<button class="btn btn-primary btn-small" onclick="exportJSON()">&#x1F4E5; Exporter JSON</button>
<button class="btn btn-secondary btn-small" onclick="refreshAll()">&#x1F504; Actualiser</button>
</div>
</div>
<div class="filters-bar">
<div class="filter-group">
<label>Date début</label>
<input type="date" id="filterDateFrom" class="filter-input">
</div>
<div class="filter-group">
<label>Date fin</label>
<input type="date" id="filterDateTo" class="filter-input">
</div>
<div class="filter-group">
<label>Collaborateur</label>
<select id="filterUser" class="filter-input">
<option value="">Tous</option>
</select>
</div>
<div class="filter-group">
<label>Application cible</label>
<select id="filterApp" class="filter-input">
<option value="">Toutes</option>
</select>
</div>
<div class="filter-group">
<label>Type d'action</label>
<select id="filterAction" class="filter-input">
<option value="">Tous</option>
<option value="click">click</option>
<option value="type">type</option>
<option value="key_combo">key_combo</option>
<option value="wait">wait</option>
</select>
</div>
<div class="filter-group">
<label>Mode d'exécution</label>
<select id="filterMode" class="filter-input">
<option value="">Tous</option>
<option value="autonomous">Autonome</option>
<option value="assisted">Assisté</option>
<option value="shadow">Shadow</option>
</select>
</div>
<div class="filter-group">
<label>Résultat</label>
<select id="filterResult" class="filter-input">
<option value="">Tous</option>
<option value="success">Succès</option>
<option value="failed">Échec</option>
<option value="recovered">Récupéré</option>
<option value="skipped">Ignoré</option>
</select>
</div>
<div class="filter-group">
<label>Recherche</label>
<input type="text" id="filterSearch" class="filter-input" placeholder="action_detail..." style="min-width:180px;">
</div>
<div class="filter-group" style="justify-content:flex-end;">
<label>&nbsp;</label>
<div style="display:flex;gap:6px;">
<button class="btn btn-primary btn-small" onclick="applyFilters()">Filtrer</button>
<button class="btn btn-secondary btn-small" onclick="resetFilters()">Réinitialiser</button>
</div>
</div>
</div>
</div>
<!-- Auto-refresh bar -->
<div class="refresh-bar">
<div class="indicator">
<div class="refresh-dot" id="refreshDot"></div>
<span id="refreshStatus">Auto-refresh actif (60s)</span>
<span>&mdash;</span>
<span>Dernière MAJ : <strong id="lastRefresh">-</strong></span>
</div>
<button class="btn btn-small btn-secondary" id="toggleRefreshBtn" onclick="toggleAutoRefresh()">
&#x23F8;&#xFE0F; Pause
</button>
</div>
<!-- Tableau -->
<div class="card" style="padding:0;overflow:hidden;">
<div class="table-wrapper" style="max-height:600px;overflow-y:auto;">
<table class="audit-table">
<thead>
<tr>
<th>Horodatage</th>
<th>Collaborateur</th>
<th>Poste</th>
<th>Application</th>
<th>Action</th>
<th>Détail</th>
<th>Mode</th>
<th>Résultat</th>
<th>Récupération</th>
<th>Durée</th>
<th>Anonymisation</th>
<th></th>
</tr>
</thead>
<tbody id="auditTableBody">
<tr><td colspan="12" class="loading"><div class="spinner"></div>Chargement...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="pagination">
<span id="paginationInfo">-</span>
<div class="page-btns">
<button class="btn btn-small btn-secondary" id="btnPrev" onclick="prevPage()" disabled>&#x25C0; Précédent</button>
<button class="btn btn-small btn-secondary" id="btnNext" onclick="nextPage()">Suivant &#x25B6;</button>
</div>
</div>
<!-- Résumé statistique (bottom) -->
<div class="grid-3" style="margin-top:20px;">
<!-- Répartition par application -->
<div class="card">
<h2>&#x1F4CA; Répartition par application</h2>
<div id="appDistribution">
<div class="loading">Chargement...</div>
</div>
</div>
<!-- Répartition par résultat -->
<div class="card">
<h2>&#x1F4CA; Répartition par résultat</h2>
<div id="resultDistribution">
<div class="loading">Chargement...</div>
</div>
</div>
<!-- Top échecs récents -->
<div class="card">
<h2>&#x26A0;&#xFE0F; Derniers échecs</h2>
<div id="recentFailures">
<div class="loading">Chargement...</div>
</div>
</div>
</div>
</div>
<!-- Panneau de détails (panneau latéral) -->
<div class="detail-backdrop" id="detailBackdrop" onclick="closeDetail()"></div>
<div class="detail-overlay" id="detailPanel">
<h3>
Détail de l'entrée
<button class="close-btn" onclick="closeDetail()">&times;</button>
</h3>
<pre class="detail-json" id="detailJson"></pre>
</div>
<script>
// =====================================================================
// État global
// =====================================================================
let currentOffset = 0;
const PAGE_SIZE = 100;
let autoRefreshEnabled = true;
let autoRefreshTimer = null;
let lastEntries = []; // entrées de la dernière requête
let summaryData = null; // résumé du jour courant
// =====================================================================
// Initialisation
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
// Date par défaut = aujourd'hui
const today = new Date().toISOString().split('T')[0];
document.getElementById('filterDateFrom').value = today;
document.getElementById('filterDateTo').value = today;
refreshAll();
startAutoRefresh();
});
// =====================================================================
// Requêtes API (via le proxy Flask)
// =====================================================================
async function fetchJSON(url) {
try {
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText }));
throw new Error(err.error || err.detail || resp.statusText);
}
hideError();
return await resp.json();
} catch (e) {
showError(e.message);
throw e;
}
}
function showError(msg) {
const banner = document.getElementById('errorBanner');
document.getElementById('errorText').textContent = msg;
banner.classList.add('visible');
}
function hideError() {
document.getElementById('errorBanner').classList.remove('visible');
}
// =====================================================================
// Construction des query params depuis les filtres
// =====================================================================
function buildQueryParams(extraParams = {}) {
const params = new URLSearchParams();
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const userId = document.getElementById('filterUser').value;
const app = document.getElementById('filterApp').value;
const action = document.getElementById('filterAction').value;
const mode = document.getElementById('filterMode').value;
const result = document.getElementById('filterResult').value;
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (userId) params.set('user_id', userId);
// target_app n'est pas un filtre backend direct —
// on filtre côté client (voir filterLocally)
if (action) params.set('action_type', action);
if (result) params.set('result', result);
// execution_mode n'est pas un filtre backend direct —
// on filtre côté client
// workflow_id non mappé ici (filtrage client si besoin)
for (const [k, v] of Object.entries(extraParams)) {
params.set(k, v);
}
return params.toString();
}
// =====================================================================
// Filtrage côté client pour les champs non supportés par le backend
// =====================================================================
function filterLocally(entries) {
const app = document.getElementById('filterApp').value;
const mode = document.getElementById('filterMode').value;
const search = document.getElementById('filterSearch').value.toLowerCase().trim();
return entries.filter(e => {
if (app && e.target_app !== app) return false;
if (mode && e.execution_mode !== mode) return false;
if (search && !(e.action_detail || '').toLowerCase().includes(search)) return false;
return true;
});
}
// =====================================================================
// Chargement de l'historique
// =====================================================================
async function loadHistory() {
const params = buildQueryParams({ limit: 1000, offset: 0 });
try {
const data = await fetchJSON(`/api/audit/history?${params}`);
const allEntries = data.entries || [];
// Filtrage local (target_app, execution_mode, recherche libre)
lastEntries = filterLocally(allEntries);
// Peupler les dropdowns dynamiques à partir des données
populateDynamicDropdowns(allEntries);
renderTable();
} catch (e) {
document.getElementById('auditTableBody').innerHTML =
`<tr><td colspan="12" style="text-align:center;padding:30px;color:#ef4444;">Erreur : ${e.message}</td></tr>`;
}
}
// =====================================================================
// Chargement du résumé (KPIs)
// =====================================================================
async function loadSummary() {
const dateFrom = document.getElementById('filterDateFrom').value;
try {
const data = await fetchJSON(`/api/audit/summary?date=${dateFrom || ''}`);
summaryData = data;
renderKPIs(data);
renderDistributions(data);
} catch (e) {
// KPIs en erreur
['kpiTotal', 'kpiSuccess', 'kpiAutonomous', 'kpiAvgDuration'].forEach(id => {
document.getElementById(id).textContent = '-';
});
}
}
// =====================================================================
// Rendu KPIs
// =====================================================================
function renderKPIs(data) {
document.getElementById('kpiTotal').textContent = data.total_actions || 0;
const rate = data.success_rate || 0;
const el = document.getElementById('kpiSuccess');
el.textContent = `${Math.round(rate * 100)}%`;
el.style.color = rate >= 0.9 ? '#22c55e' : rate >= 0.7 ? '#f59e0b' : '#ef4444';
// Mode autonome
const byMode = data.by_execution_mode || {};
const autoCount = byMode['autonomous'] || 0;
const total = data.total_actions || 1;
document.getElementById('kpiAutonomous').textContent = `${Math.round(autoCount / total * 100)}%`;
// Durée moyenne — pas disponible dans le summary backend,
// on la calcule depuis lastEntries si disponible
if (lastEntries.length > 0) {
const durations = lastEntries.filter(e => e.duration_ms > 0).map(e => e.duration_ms);
if (durations.length > 0) {
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
document.getElementById('kpiAvgDuration').textContent = formatDuration(avg);
} else {
document.getElementById('kpiAvgDuration').textContent = '-';
}
} else {
document.getElementById('kpiAvgDuration').textContent = '-';
}
}
// =====================================================================
// Rendu tableau
// =====================================================================
function renderTable() {
const tbody = document.getElementById('auditTableBody');
if (lastEntries.length === 0) {
tbody.innerHTML = '<tr><td colspan="12" style="text-align:center;padding:30px;color:#64748b;">Aucune entrée pour les filtres sélectionnés.</td></tr>';
document.getElementById('paginationInfo').textContent = '0 résultat';
document.getElementById('btnPrev').disabled = true;
document.getElementById('btnNext').disabled = true;
return;
}
// Pagination côté client (les données sont déjà chargées)
const page = lastEntries.slice(currentOffset, currentOffset + PAGE_SIZE);
const rows = page.map((e, i) => {
const ts = formatTimestamp(e.timestamp);
const user = e.user_name || e.user_id || '<span style="color:#64748b">-</span>';
const machine = e.machine_id || '-';
const app = e.target_app || '<span style="color:#64748b">-</span>';
const actionBadge = `<span class="badge badge-action">${escapeHtml(e.action_type || '-')}</span>`;
const detail = escapeHtml((e.action_detail || '').substring(0, 80)) || '<span style="color:#64748b">-</span>';
const modeBadge = renderModeBadge(e.execution_mode);
const resultBadge = renderResultBadge(e.result);
const recovery = e.recovery_action && e.result !== 'success'
? `<span style="color:#fcd34d;font-size:12px;">${escapeHtml(e.recovery_action)}</span>`
: '<span style="color:#64748b">-</span>';
const duration = e.duration_ms > 0 ? formatDuration(e.duration_ms) : '-';
// Anonymisation : champ non présent dans AuditEntry — on affiche "—" avec tooltip
const anonymisation = '<span style="color:#64748b;cursor:help;" title="Information non remontée par le pipeline actuel">&mdash;</span>';
const idx = currentOffset + i;
return `<tr style="cursor:pointer;" onclick="showDetail(${idx})">
<td class="ts">${ts}</td>
<td>${user}</td>
<td style="font-size:12px;color:#94a3b8;">${escapeHtml(machine)}</td>
<td>${escapeHtml(app)}</td>
<td>${actionBadge}</td>
<td class="detail" title="${escapeHtml(e.action_detail || '')}">${detail}</td>
<td>${modeBadge}</td>
<td>${resultBadge}</td>
<td>${recovery}</td>
<td style="white-space:nowrap;">${duration}</td>
<td style="text-align:center;">${anonymisation}</td>
<td><button class="btn btn-small btn-secondary" onclick="event.stopPropagation();showDetail(${idx})">&#x1F50D;</button></td>
</tr>`;
}).join('');
tbody.innerHTML = rows;
// Pagination info
const start = currentOffset + 1;
const end = Math.min(currentOffset + PAGE_SIZE, lastEntries.length);
document.getElementById('paginationInfo').textContent =
`${start}-${end} sur ${lastEntries.length} résultat${lastEntries.length > 1 ? 's' : ''}`;
document.getElementById('btnPrev').disabled = currentOffset === 0;
document.getElementById('btnNext').disabled = currentOffset + PAGE_SIZE >= lastEntries.length;
}
// =====================================================================
// Rendu distributions (résumé bottom)
// =====================================================================
function renderDistributions(data) {
// Par application (depuis les données chargées, plus riche que le summary)
const appDiv = document.getElementById('appDistribution');
if (lastEntries.length > 0) {
const appCounts = {};
lastEntries.forEach(e => {
const a = e.target_app || 'Non renseigné';
appCounts[a] = (appCounts[a] || 0) + 1;
});
const sorted = Object.entries(appCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
appDiv.innerHTML = sorted.map(([name, count]) => {
const pct = Math.round(count / lastEntries.length * 100);
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #334155;">
<span style="font-size:13px;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:80px;height:6px;background:#334155;border-radius:3px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:#3b82f6;border-radius:3px;"></div>
</div>
<span style="font-size:12px;color:#94a3b8;min-width:45px;text-align:right;">${count} (${pct}%)</span>
</div>
</div>`;
}).join('');
} else {
appDiv.innerHTML = '<span style="color:#64748b;font-size:13px;">Aucune donnée</span>';
}
// Par résultat
const resultDiv = document.getElementById('resultDistribution');
const byResult = data.by_result || {};
const resultEntries = Object.entries(byResult);
if (resultEntries.length > 0) {
const total = resultEntries.reduce((s, [, c]) => s + c, 0);
resultDiv.innerHTML = resultEntries.map(([name, count]) => {
const pct = Math.round(count / total * 100);
const color = name === 'success' ? '#22c55e' : name === 'failed' ? '#ef4444' : name === 'recovered' ? '#f59e0b' : '#94a3b8';
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #334155;">
<span style="font-size:13px;">${renderResultBadge(name)}</span>
<div style="display:flex;align-items:center;gap:8px;">
<div style="width:80px;height:6px;background:#334155;border-radius:3px;overflow:hidden;">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;"></div>
</div>
<span style="font-size:12px;color:#94a3b8;min-width:45px;text-align:right;">${count} (${pct}%)</span>
</div>
</div>`;
}).join('');
} else {
resultDiv.innerHTML = '<span style="color:#64748b;font-size:13px;">Aucune donnée</span>';
}
// Derniers échecs
const failDiv = document.getElementById('recentFailures');
const failures = lastEntries.filter(e => e.result === 'failed' || e.result === 'recovered').slice(0, 10);
if (failures.length > 0) {
failDiv.innerHTML = failures.map(e => {
const ts = formatTimestamp(e.timestamp);
const detail = escapeHtml((e.action_detail || e.action_type || '-').substring(0, 40));
return `<div class="top-failures-item">
<span style="color:#fca5a5;">${detail}</span>
<span style="color:#64748b;font-size:11px;">${ts}</span>
</div>`;
}).join('');
} else {
failDiv.innerHTML = '<span style="color:#22c55e;font-size:13px;">Aucun échec !</span>';
}
}
// =====================================================================
// Dropdowns dynamiques
// =====================================================================
function populateDynamicDropdowns(entries) {
// Applications
const apps = new Set();
const users = new Set();
entries.forEach(e => {
if (e.target_app) apps.add(e.target_app);
if (e.user_id) users.add(JSON.stringify({ id: e.user_id, name: e.user_name || e.user_id }));
});
const appSelect = document.getElementById('filterApp');
const currentApp = appSelect.value;
// Garder la première option "Toutes"
appSelect.innerHTML = '<option value="">Toutes</option>';
[...apps].sort().forEach(a => {
appSelect.innerHTML += `<option value="${escapeHtml(a)}" ${a === currentApp ? 'selected' : ''}>${escapeHtml(a)}</option>`;
});
const userSelect = document.getElementById('filterUser');
const currentUser = userSelect.value;
userSelect.innerHTML = '<option value="">Tous</option>';
[...users].map(u => JSON.parse(u)).sort((a, b) => a.name.localeCompare(b.name)).forEach(u => {
userSelect.innerHTML += `<option value="${escapeHtml(u.id)}" ${u.id === currentUser ? 'selected' : ''}>${escapeHtml(u.name)}</option>`;
});
}
// =====================================================================
// Formatters
// =====================================================================
function formatTimestamp(ts) {
if (!ts) return '-';
try {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const mo = String(d.getMonth() + 1).padStart(2, '0');
return `${hh}:${mm}:${ss} · ${dd}/${mo}`;
} catch {
return ts;
}
}
function formatDuration(ms) {
if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s`;
return `${Math.round(ms)} ms`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function renderModeBadge(mode) {
if (!mode) return '<span style="color:#64748b;">-</span>';
const map = {
'shadow': ['badge-shadow', 'Shadow'],
'assisted': ['badge-assisted', 'Assisté'],
'copilot': ['badge-copilot', 'Copilot'],
'autonomous': ['badge-autonomous', 'Autonome'],
};
const [cls, label] = map[mode] || ['badge-skipped', mode];
return `<span class="badge ${cls}">${label}</span>`;
}
function renderResultBadge(result) {
if (!result) return '<span style="color:#64748b;">-</span>';
const map = {
'success': ['badge-success', 'Succès'],
'failed': ['badge-failed', 'Échec'],
'recovered': ['badge-recovered', 'Récupéré'],
'skipped': ['badge-skipped', 'Ignoré'],
};
const [cls, label] = map[result] || ['badge-skipped', result];
return `<span class="badge ${cls}">${label}</span>`;
}
// =====================================================================
// Panneau de détails
// =====================================================================
function showDetail(idx) {
const entry = lastEntries[idx];
if (!entry) return;
document.getElementById('detailJson').textContent = JSON.stringify(entry, null, 2);
document.getElementById('detailPanel').classList.add('open');
document.getElementById('detailBackdrop').classList.add('open');
}
function closeDetail() {
document.getElementById('detailPanel').classList.remove('open');
document.getElementById('detailBackdrop').classList.remove('open');
}
// Fermer avec Escape
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
// =====================================================================
// Pagination
// =====================================================================
function prevPage() {
currentOffset = Math.max(0, currentOffset - PAGE_SIZE);
renderTable();
window.scrollTo({ top: 300, behavior: 'smooth' });
}
function nextPage() {
if (currentOffset + PAGE_SIZE < lastEntries.length) {
currentOffset += PAGE_SIZE;
renderTable();
window.scrollTo({ top: 300, behavior: 'smooth' });
}
}
// =====================================================================
// Filtres
// =====================================================================
function applyFilters() {
currentOffset = 0;
refreshAll();
}
function resetFilters() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('filterDateFrom').value = today;
document.getElementById('filterDateTo').value = today;
document.getElementById('filterUser').value = '';
document.getElementById('filterApp').value = '';
document.getElementById('filterAction').value = '';
document.getElementById('filterMode').value = '';
document.getElementById('filterResult').value = '';
document.getElementById('filterSearch').value = '';
currentOffset = 0;
refreshAll();
}
// =====================================================================
// Export
// =====================================================================
function exportCSV() {
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const userId = document.getElementById('filterUser').value;
let url = `/api/audit/export?date_from=${dateFrom || ''}&date_to=${dateTo || ''}`;
if (userId) url += `&user_id=${encodeURIComponent(userId)}`;
window.open(url, '_blank');
}
function exportJSON() {
// Exporter les données filtrées côté client en JSON
const blob = new Blob([JSON.stringify(lastEntries, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const dateFrom = document.getElementById('filterDateFrom').value || 'today';
a.download = `audit_${dateFrom}.json`;
a.click();
URL.revokeObjectURL(url);
}
// =====================================================================
// Auto-refresh
// =====================================================================
function startAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(() => {
if (autoRefreshEnabled) refreshAll();
}, 60000);
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
const btn = document.getElementById('toggleRefreshBtn');
const dot = document.getElementById('refreshDot');
const status = document.getElementById('refreshStatus');
if (autoRefreshEnabled) {
btn.innerHTML = '&#x23F8;&#xFE0F; Pause';
dot.classList.remove('paused');
status.textContent = 'Auto-refresh actif (60s)';
} else {
btn.innerHTML = '&#x25B6;&#xFE0F; Reprendre';
dot.classList.add('paused');
status.textContent = 'Auto-refresh en pause';
}
}
// =====================================================================
// Refresh principal
// =====================================================================
async function refreshAll() {
const ts = new Date();
document.getElementById('lastRefresh').textContent =
`${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}:${String(ts.getSeconds()).padStart(2,'0')}`;
// Charger en parallèle
await Promise.all([loadHistory(), loadSummary()]);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff