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
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:
@@ -40,10 +40,18 @@ MACHINE_ID = os.environ.get(
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Endpoint du serveur Streaming (port 5005)
|
||||
# SERVER_URL contient TOUJOURS /api/v1 à la fin (convention unifiée).
|
||||
SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
||||
# Base sans /api/v1 — pour les routes à la racine (/health)
|
||||
SERVER_BASE = SERVER_URL.rsplit("/api/v1", 1)[0]
|
||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
||||
|
||||
# Host Ollama — SÉPARÉ du serveur RPA.
|
||||
# Ollama tourne en local sur la machine serveur, jamais exposé via le reverse proxy.
|
||||
# Défaut : localhost (exécution locale ou accès LAN direct).
|
||||
OLLAMA_HOST = os.getenv("RPA_OLLAMA_HOST", "localhost")
|
||||
|
||||
# Token d'authentification API (doit correspondre au token du serveur)
|
||||
# Configurable via variable d'environnement RPA_API_TOKEN
|
||||
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
@@ -477,9 +477,15 @@ class ActionExecutorV1:
|
||||
},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} sur POST {url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
elif resp.ok:
|
||||
data = resp.json()
|
||||
state = data.get("screen_state", "ok")
|
||||
if state != "ok":
|
||||
@@ -703,7 +709,11 @@ class ActionExecutorV1:
|
||||
f"attendu '{expected_title}' → mode apprentissage"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_wrong_window(current_title, expected_title)
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="wrong_window",
|
||||
target_description=expected_title,
|
||||
window_title=current_title,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -935,9 +945,10 @@ class ActionExecutorV1:
|
||||
# et ne trouve toujours pas. L'humain doit montrer.
|
||||
print(f" [POLICY] Retry échoué → mode apprentissage")
|
||||
try:
|
||||
self.notifier.replay_target_not_found(
|
||||
target_desc,
|
||||
target_spec.get("window_title", ""),
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="retry_failed",
|
||||
target_description=target_desc,
|
||||
window_title=target_spec.get("window_title", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -993,9 +1004,10 @@ class ActionExecutorV1:
|
||||
# passe en mode capture et enregistre ce que
|
||||
# l'humain fait (mini-workflow de correction).
|
||||
try:
|
||||
self.notifier.replay_target_not_found(
|
||||
target_desc,
|
||||
target_spec.get("window_title", ""),
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="supervise",
|
||||
target_description=target_desc,
|
||||
window_title=target_spec.get("window_title", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1221,7 +1233,9 @@ class ActionExecutorV1:
|
||||
f"je demande de l'aide"
|
||||
)
|
||||
try:
|
||||
self.notifier.replay_no_screen_change(action_type)
|
||||
self.notifier.replay_learning_mode(
|
||||
raison="no_screen_change",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1377,7 +1391,13 @@ class ActionExecutorV1:
|
||||
|
||||
try:
|
||||
print(f" [SERVER-RESOLVE] Appel serveur {server_url}...")
|
||||
resp = _requests.post(url, json=payload, headers=headers, timeout=30)
|
||||
resp = _requests.post(url, json=payload, headers=headers, timeout=30, allow_redirects=False)
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} sur POST {url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.warning(f"Server resolve HTTP {resp.status_code}")
|
||||
return None
|
||||
@@ -1521,7 +1541,7 @@ class ActionExecutorV1:
|
||||
if not vlm_description:
|
||||
return None
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
prompt = (
|
||||
@@ -1657,7 +1677,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
if anchor_b64:
|
||||
images.append(anchor_b64)
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
# Prefill pour les modèles thinking (qwen3) — évite le mode réflexion >180s
|
||||
@@ -1861,8 +1881,14 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
json=report,
|
||||
headers=self._auth_headers(),
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if resp2.ok:
|
||||
if resp2.status_code in (301, 302, 307, 308):
|
||||
logger.warning(
|
||||
f"Redirection {resp2.status_code} sur POST {replay_result_url} — "
|
||||
f"verifiez RPA_SERVER_URL (https:// si redirect)"
|
||||
)
|
||||
elif resp2.ok:
|
||||
server_resp = resp2.json()
|
||||
msg = (
|
||||
f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, "
|
||||
@@ -2128,7 +2154,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
"""
|
||||
import requests as _requests
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_host = os.environ.get("RPA_OLLAMA_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
prompt = (
|
||||
@@ -2575,8 +2601,8 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
f"inactivité={INACTIVITY_TIMEOUT}s, hotkey=Ctrl+Shift+L)"
|
||||
)
|
||||
print(
|
||||
f" [APPRENTISSAGE] Montre-moi comment faire.\n"
|
||||
f" Quand tu as fini → Ctrl+Shift+L\n"
|
||||
f" [APPRENTISSAGE] Je n'y arrive pas, montrez-moi comment faire.\n"
|
||||
f" Quand vous avez fini → Ctrl+Shift+L\n"
|
||||
f" (ou j'attends {INACTIVITY_TIMEOUT}s sans action)"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import threading
|
||||
from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
|
||||
STREAMING_ENDPOINT,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
@@ -86,22 +87,23 @@ class AgentV1:
|
||||
self._state.set_on_stop(self.stop_session)
|
||||
|
||||
# Client serveur pour le chat et les workflows
|
||||
# Plus de RPA_SERVER_HOST : le LeaServerClient derive tout de SERVER_URL
|
||||
self._server_client = None
|
||||
if LeaServerClient is not None:
|
||||
# Forcer le token API pour éviter les 401
|
||||
# (le token est set par start.bat dans l'environnement)
|
||||
from .config import API_TOKEN as _token
|
||||
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
|
||||
self._server_client = LeaServerClient(server_host=server_host)
|
||||
self._server_client = LeaServerClient()
|
||||
if _token and not self._server_client._api_token:
|
||||
self._server_client._api_token = _token
|
||||
logger.info("Token API forcé dans LeaServerClient")
|
||||
|
||||
# Fenetre de chat Lea (tkinter natif)
|
||||
# Le host est derive de SERVER_URL (plus de RPA_SERVER_HOST)
|
||||
server_host = (
|
||||
self._server_client.server_host
|
||||
if self._server_client is not None
|
||||
else os.getenv("RPA_SERVER_HOST", "localhost")
|
||||
else "localhost"
|
||||
)
|
||||
self._chat_window = ChatWindow(
|
||||
server_client=self._server_client,
|
||||
@@ -363,11 +365,11 @@ class AgentV1:
|
||||
continue
|
||||
self._last_bg_hash = img_hash
|
||||
|
||||
# Envoyer au streaming server (avec token auth)
|
||||
# Envoyer au streaming server (via STREAMING_ENDPOINT unifié)
|
||||
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
|
||||
with open(full_path, 'rb') as f:
|
||||
req.post(
|
||||
f"{SERVER_URL}/traces/stream/image",
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
params={
|
||||
"session_id": bg_session,
|
||||
"shot_id": f"heartbeat_{int(time.time())}",
|
||||
@@ -376,6 +378,7 @@ class AgentV1:
|
||||
headers=headers,
|
||||
files={"file": ("screenshot.png", f, "image/png")},
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
|
||||
@@ -544,6 +544,28 @@ class TraceStreamer:
|
||||
except OSError as e:
|
||||
logger.debug(f"Purge échouée : {path} — {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Protection redirect POST→GET (INC-7)
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _check_redirect(resp, url: str):
|
||||
"""Detecter et logger une redirection sur un POST.
|
||||
|
||||
La lib requests transforme un POST en GET sur 301/302 (RFC 7231).
|
||||
Avec allow_redirects=False, on recoit le 301/302 directement.
|
||||
On log un WARNING explicite pour que l'admin corrige l'URL.
|
||||
"""
|
||||
if resp.status_code in (301, 302, 307, 308):
|
||||
location = resp.headers.get("Location", "?")
|
||||
logger.warning(
|
||||
f"Redirection {resp.status_code} detectee sur POST {url} "
|
||||
f"→ {location}. Verifiez que RPA_SERVER_URL utilise "
|
||||
f"https:// si le serveur redirige."
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Envois HTTP
|
||||
# =========================================================================
|
||||
@@ -551,15 +573,20 @@ class TraceStreamer:
|
||||
def _register_session(self):
|
||||
"""Enregistrer la session auprès du serveur (avec identifiant machine)."""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/register"
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/register",
|
||||
url,
|
||||
params={
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=3,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
logger.warning("Enregistrement session échoué (redirect)")
|
||||
return
|
||||
if resp.ok:
|
||||
logger.info(
|
||||
f"Session {self.session_id} enregistrée sur le serveur "
|
||||
@@ -579,15 +606,18 @@ class TraceStreamer:
|
||||
C'est la dernière chance de sauver les données de la session.
|
||||
"""
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/finalize"
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/finalize",
|
||||
url,
|
||||
params={
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=30, # Le build workflow peut prendre du temps
|
||||
allow_redirects=False,
|
||||
)
|
||||
self._check_redirect(resp, url)
|
||||
if resp.ok:
|
||||
result = resp.json()
|
||||
logger.info(f"Session finalisée: {result}")
|
||||
@@ -601,6 +631,7 @@ class TraceStreamer:
|
||||
if not self._server_available:
|
||||
return False
|
||||
try:
|
||||
url = f"{STREAMING_ENDPOINT}/event"
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"timestamp": time.time(),
|
||||
@@ -608,11 +639,14 @@ class TraceStreamer:
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/event",
|
||||
url,
|
||||
json=payload,
|
||||
headers=self._auth_headers(),
|
||||
timeout=2,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
return False
|
||||
return resp.ok
|
||||
except Exception as e:
|
||||
logger.debug(f"Streaming Event échoué: {e}")
|
||||
@@ -645,18 +679,22 @@ class TraceStreamer:
|
||||
"machine_id": self.machine_id,
|
||||
}
|
||||
|
||||
url = f"{STREAMING_ENDPOINT}/image"
|
||||
if jpeg_buf is not None:
|
||||
# Envoi du JPEG compressé (BytesIO, pas de fuite possible)
|
||||
files = {
|
||||
"file": (f"{shot_id}{suffix}", jpeg_buf, content_type)
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
return ImageSendResult.FAILED
|
||||
if resp.ok:
|
||||
self._purge_local_image(path)
|
||||
return ImageSendResult.OK
|
||||
@@ -668,12 +706,15 @@ class TraceStreamer:
|
||||
"file": (f"{shot_id}.png", f, "image/png")
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
url,
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
allow_redirects=False,
|
||||
)
|
||||
if self._check_redirect(resp, url):
|
||||
return ImageSendResult.FAILED
|
||||
if resp.ok:
|
||||
self._purge_local_image(path)
|
||||
return ImageSendResult.OK
|
||||
|
||||
@@ -293,6 +293,49 @@ def formatter_ecran_inchange(action_type: str = "") -> MessageUtilisateur:
|
||||
)
|
||||
|
||||
|
||||
def formatter_mode_apprentissage(
|
||||
raison: str = "",
|
||||
description_cible: str = "",
|
||||
titre_fenetre: Optional[str] = None,
|
||||
) -> MessageUtilisateur:
|
||||
"""Message quand Léa passe en mode apprentissage (pause supervisée).
|
||||
|
||||
L'utilisateur doit comprendre :
|
||||
1. Léa est bloquée et a besoin d'aide
|
||||
2. L'utilisateur doit prendre la main et montrer comment faire
|
||||
3. Ctrl+Shift+L pour signaler qu'il a fini
|
||||
|
||||
Le ton est humble, clair, actionnable. Pas technique.
|
||||
|
||||
Exemple :
|
||||
Léa a besoin d'aide
|
||||
Je n'y arrive pas, montrez-moi comment faire.
|
||||
Quand vous avez fini, appuyez sur Ctrl+Shift+L.
|
||||
"""
|
||||
cible = _nettoyer_description_cible(description_cible) if description_cible else ""
|
||||
app = _extraire_nom_application(titre_fenetre or "") if titre_fenetre else ""
|
||||
|
||||
# Construire un contexte court si disponible
|
||||
contexte = ""
|
||||
if cible and app:
|
||||
contexte = f" (« {cible} » dans {app})"
|
||||
elif cible:
|
||||
contexte = f" (« {cible} »)"
|
||||
|
||||
corps = (
|
||||
f"Je n'y arrive pas{contexte}, montrez-moi comment faire. "
|
||||
f"Quand vous avez fini, appuyez sur Ctrl+Shift+L."
|
||||
)
|
||||
|
||||
return MessageUtilisateur(
|
||||
niveau=NiveauMessage.BLOCAGE,
|
||||
titre="Léa a besoin d'aide",
|
||||
corps=corps,
|
||||
duree_s=DUREE_PAR_NIVEAU[NiveauMessage.BLOCAGE],
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
def formatter_connexion_perdue(hote_serveur: str = "") -> MessageUtilisateur:
|
||||
"""Message quand la connexion avec le serveur est perdue.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from .messages import (
|
||||
formatter_etape_workflow,
|
||||
formatter_fenetre_incorrecte,
|
||||
formatter_fin_workflow,
|
||||
formatter_mode_apprentissage,
|
||||
formatter_ralentissement,
|
||||
formatter_retry,
|
||||
)
|
||||
@@ -273,6 +274,20 @@ class NotificationManager:
|
||||
msg = formatter_ecran_inchange(action_type)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_learning_mode(
|
||||
self,
|
||||
raison: str = "",
|
||||
target_description: str = "",
|
||||
window_title: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Notification quand Léa passe en mode apprentissage.
|
||||
|
||||
Léa est bloquée et demande à l'utilisateur de montrer comment faire.
|
||||
Message humble et actionnable pour un utilisateur non technique.
|
||||
"""
|
||||
msg = formatter_mode_apprentissage(raison, target_description, window_title)
|
||||
return self.notify_message(msg)
|
||||
|
||||
def replay_retry(self, action_type: str = "", tentative: int = 2) -> bool:
|
||||
"""Notification quand Léa retente une action."""
|
||||
msg = formatter_retry(action_type, tentative)
|
||||
|
||||
@@ -21,36 +21,33 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
logger = logging.getLogger("lea_ui.server_client")
|
||||
|
||||
|
||||
def _get_server_host() -> str:
|
||||
"""Recuperer l'adresse du serveur Linux.
|
||||
def _get_server_url() -> str:
|
||||
"""Recuperer l'URL du serveur RPA (avec /api/v1).
|
||||
|
||||
Ordre de resolution :
|
||||
1. Variable d'environnement RPA_SERVER_HOST
|
||||
2. Fichier de config agent_config.json (cle "server_host")
|
||||
3. Fallback localhost
|
||||
1. Import depuis agent_v1.config (source de verite unique)
|
||||
2. Variable d'environnement RPA_SERVER_URL
|
||||
3. Fallback http://localhost:5005/api/v1
|
||||
"""
|
||||
# 1. Variable d'environnement
|
||||
host = os.environ.get("RPA_SERVER_HOST", "").strip()
|
||||
if host:
|
||||
return host
|
||||
# 1. Import depuis config.py (source de verite)
|
||||
try:
|
||||
from agent_v1.config import SERVER_URL
|
||||
return SERVER_URL
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 2. Fichier de config
|
||||
config_paths = [
|
||||
os.path.join(os.path.dirname(__file__), "..", "agent_config.json"),
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"),
|
||||
]
|
||||
for config_path in config_paths:
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
host = cfg.get("server_host", "").strip()
|
||||
if host:
|
||||
return host
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
# 2. Variable d'environnement directe
|
||||
url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
|
||||
if url:
|
||||
return url
|
||||
|
||||
# 3. Fallback
|
||||
return "localhost"
|
||||
return "http://localhost:5005/api/v1"
|
||||
|
||||
|
||||
def _get_server_base(server_url: str) -> str:
|
||||
"""Extraire la base URL (sans /api/v1) pour les routes racine (/health)."""
|
||||
return server_url.rsplit("/api/v1", 1)[0]
|
||||
|
||||
|
||||
class LeaServerClient:
|
||||
@@ -67,19 +64,22 @@ class LeaServerClient:
|
||||
chat_port: int = 5004,
|
||||
stream_port: int = 5005,
|
||||
) -> None:
|
||||
self._host = server_host or _get_server_host()
|
||||
# URL unifiée : SERVER_URL contient TOUJOURS /api/v1 (convention INC-1).
|
||||
# _stream_url = URL avec /api/v1 (pour les routes API)
|
||||
# _stream_base = URL sans /api/v1 (pour /health uniquement)
|
||||
self._stream_url = _get_server_url()
|
||||
self._stream_base = _get_server_base(self._stream_url)
|
||||
|
||||
# Extraire le host depuis l'URL pour le chat et pour l'affichage
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(self._stream_base)
|
||||
self._host = parsed.hostname or "localhost"
|
||||
except Exception:
|
||||
self._host = server_host or "localhost"
|
||||
|
||||
self._chat_port = chat_port
|
||||
self._stream_port = stream_port
|
||||
|
||||
# En prod, la base URL passe par le reverse proxy HTTPS
|
||||
# (ex. https://lea.labs.laurinebazin.design). Si RPA_SERVER_URL est
|
||||
# definie on l'utilise telle quelle, sinon on reconstruit http://host:port.
|
||||
server_url = os.environ.get("RPA_SERVER_URL", "").strip().rstrip("/")
|
||||
if server_url:
|
||||
self._stream_base = server_url
|
||||
else:
|
||||
self._stream_base = f"http://{self._host}:{self._stream_port}"
|
||||
|
||||
self._chat_base = f"http://{self._host}:{self._chat_port}"
|
||||
|
||||
# Etat de connexion
|
||||
@@ -103,8 +103,8 @@ class LeaServerClient:
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
logger.info(
|
||||
"LeaServerClient initialise : chat=%s, stream=%s",
|
||||
self._chat_base, self._stream_base,
|
||||
"LeaServerClient initialise : chat=%s, stream_url=%s, stream_base=%s",
|
||||
self._chat_base, self._stream_url, self._stream_base,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -154,7 +154,11 @@ class LeaServerClient:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Tester la connexion au serveur streaming (port 5005)."""
|
||||
"""Tester la connexion au serveur streaming (port 5005).
|
||||
|
||||
Le health check utilise _stream_base (sans /api/v1) car la route
|
||||
/health est a la racine du serveur FastAPI, pas sous /api/v1.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
@@ -227,7 +231,7 @@ class LeaServerClient:
|
||||
import requests
|
||||
headers = self._auth_headers()
|
||||
resp = requests.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/workflows",
|
||||
f"{self._stream_url}/traces/stream/workflows",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
@@ -284,7 +288,7 @@ class LeaServerClient:
|
||||
while self._polling:
|
||||
try:
|
||||
resp = req_lib.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
||||
f"{self._stream_url}/traces/stream/replay/next",
|
||||
params={"session_id": self._poll_session_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
@@ -318,7 +322,7 @@ class LeaServerClient:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replays",
|
||||
f"{self._stream_url}/traces/stream/replays",
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
@@ -346,7 +350,7 @@ class LeaServerClient:
|
||||
try:
|
||||
import requests
|
||||
requests.post(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replay/result",
|
||||
f"{self._stream_url}/traces/stream/replay/result",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"action_id": action_id,
|
||||
|
||||
@@ -292,6 +292,20 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def url_compat_rewrite(request: Request, call_next):
|
||||
"""Rétrocompatibilité : réécriture des anciennes URLs sans préfixe /api/v1.
|
||||
|
||||
Certains agents clients (Léa V1 gelée) envoient sur /traces/stream/...
|
||||
au lieu de /api/v1/traces/stream/... Ce middleware redirige silencieusement.
|
||||
"""
|
||||
path = request.url.path
|
||||
if path.startswith("/traces/stream/") and not path.startswith("/api/v1/"):
|
||||
new_path = "/api/v1" + path
|
||||
request.scope["path"] = new_path
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""Ajouter les headers de sécurité sur toutes les réponses."""
|
||||
|
||||
18
deploy/configs/config_pc_fixe_lan.txt
Normal file
18
deploy/configs/config_pc_fixe_lan.txt
Normal 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
|
||||
18
deploy/configs/config_vm_lan.txt
Normal file
18
deploy/configs/config_vm_lan.txt
Normal 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
|
||||
@@ -22,6 +22,6 @@ USER_NAME=Prenom Nom
|
||||
USER_EMAIL=prenom.nom@aivanov.com
|
||||
USER_ID=
|
||||
|
||||
# Connexion serveur (valeurs par defaut deja pre-remplies)
|
||||
SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
|
||||
API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
|
||||
# Connexion serveur (remplacer les valeurs CONFIGURE_ME avant utilisation)
|
||||
SERVER_URL=CONFIGURE_ME
|
||||
API_TOKEN=CONFIGURE_ME
|
||||
|
||||
@@ -8,36 +8,33 @@
|
||||
#
|
||||
# Les lignes commencant par # sont des commentaires (ignorees).
|
||||
#
|
||||
# IMPORTANT : remplacez toutes les valeurs CONFIGURE_ME
|
||||
# avant de lancer Lea. L'agent refusera de demarrer sinon.
|
||||
#
|
||||
# Pour obtenir un config.txt pre-rempli, utilisez le dashboard
|
||||
# Fleet (Menu → Fleet → Telecharger le ZIP d'un agent).
|
||||
#
|
||||
# ============================================================
|
||||
|
||||
# Adresse du serveur Lea (URL complete avec /api/v1)
|
||||
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
|
||||
# Adresse du serveur Lea (obligatoire — remplacer avant utilisation)
|
||||
# Exemples :
|
||||
# LAN interne : http://192.168.1.40:5005/api/v1
|
||||
# Internet : https://lea.labs.laurinebazin.design/api/v1
|
||||
# Dev local : http://localhost:5005/api/v1
|
||||
RPA_SERVER_URL=CONFIGURE_ME
|
||||
|
||||
# Cle d'authentification (fournie par l'administrateur)
|
||||
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
|
||||
RPA_API_TOKEN=CONFIGURE_ME
|
||||
|
||||
# Nom du serveur (sans https://, sans /api/v1)
|
||||
RPA_SERVER_HOST=lea.labs.laurinebazin.design
|
||||
# Host Ollama (defaut localhost, ne pas modifier sauf configuration speciale)
|
||||
# RPA_OLLAMA_HOST=localhost
|
||||
|
||||
# ============================================================
|
||||
# Parametres avances (ne pas modifier sauf indication)
|
||||
# ============================================================
|
||||
# Identifiant unique de ce poste
|
||||
RPA_MACHINE_ID=CONFIGURE_ME
|
||||
|
||||
# Flouter les zones de texte dans les captures cote CLIENT.
|
||||
#
|
||||
# DEPUIS AVRIL 2026 : LE BLUR CLIENT EST DESACTIVE PAR DEFAUT.
|
||||
# Le floutage des donnees sensibles (noms, adresses, telephones, NIR, email)
|
||||
# est desormais effectue cote SERVEUR via EDS-NLP + OCR dans le module
|
||||
# core/anonymisation/pii_blur.py.
|
||||
#
|
||||
# Avantages du blur server-side :
|
||||
# - Cible precisement les PII (PERSON/LOCATION/PHONE/NIR/EMAIL)
|
||||
# - Ne casse plus les codes CIM, montants PMSI, identifiants techniques
|
||||
# - Deux versions stockees : _raw (entrainement) + _blurred (affichage)
|
||||
#
|
||||
# Ne remettre a 'true' que si un deploiement specifique l'exige explicitement
|
||||
# (ex : reseau non chiffre entre agent et serveur).
|
||||
# Nom du collaborateur associe
|
||||
RPA_USER_LABEL=CONFIGURE_ME
|
||||
|
||||
# --- Parametres avances (ne pas modifier sauf indication) ---
|
||||
RPA_BLUR_SENSITIVE=false
|
||||
|
||||
# Duree de conservation des logs en jours (minimum 180 pour conformite)
|
||||
RPA_LOG_RETENTION_DAYS=180
|
||||
|
||||
289
docs/demo/FAQ_EXPERTS_RPA.md
Normal file
289
docs/demo/FAQ_EXPERTS_RPA.md
Normal 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").
|
||||
200
docs/demo/GRILLE_INTERVIEW_TIM.md
Normal file
200
docs/demo/GRILLE_INTERVIEW_TIM.md
Normal 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 ?"
|
||||
259
docs/demo/GUIDE_INSTALL_AGENT_TIM.md
Normal file
259
docs/demo/GUIDE_INSTALL_AGENT_TIM.md
Normal 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.**
|
||||
284
docs/demo/SCENARIOS_DEMO_URGENCES.md
Normal file
284
docs/demo/SCENARIOS_DEMO_URGENCES.md
Normal 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.
|
||||

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

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

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

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

|
||||
|
||||
**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.
|
||||
326
docs/demo/TEMPLATE_PITCH_DUO.md
Normal file
326
docs/demo/TEMPLATE_PITCH_DUO.md
Normal 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.
|
||||

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

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

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

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

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

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

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

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

|
||||
**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)
|
||||
269
docs/plans/EVALUATION_BLOCS_VWB.md
Normal file
269
docs/plans/EVALUATION_BLOCS_VWB.md
Normal 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".
|
||||
306
docs/technique/ARCHITECTURE_CONFIG_AGENT.md
Normal file
306
docs/technique/ARCHITECTURE_CONFIG_AGENT.md
Normal 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. |
|
||||
195
tests/unit/test_agent_config.py
Normal file
195
tests/unit/test_agent_config.py
Normal 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
|
||||
655
tests/unit/test_session_cleaner.py
Normal file
655
tests/unit/test_session_cleaner.py
Normal 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)}"
|
||||
)
|
||||
@@ -15,6 +15,7 @@ Port : 5006
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -86,13 +87,18 @@ app = Flask(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Fenetres considerees comme parasites
|
||||
# ATTENTION : ces patterns sont compares en lowercase via `in` — ils doivent
|
||||
# etre suffisamment specifiques pour ne pas attraper de faux positifs dans
|
||||
# les logiciels metier (DPI, codage, facturation, etc.).
|
||||
# "program manager" retire volontairement : le bureau Windows est souvent
|
||||
# le point de depart d'un workflow (clic taskbar, icone, etc.).
|
||||
# "assistant" retire : trop large (ex: "Assistant de codage PMSI").
|
||||
# "lea"/"léa" remplace par des patterns specifiques a l'outil Lea RPA.
|
||||
_PARASITIC_WINDOW_PATTERNS = [
|
||||
"program manager",
|
||||
"fenetre de depassement",
|
||||
"fenêtre de dépassement",
|
||||
"léa",
|
||||
"lea",
|
||||
"assistant",
|
||||
"léa - rpa",
|
||||
"lea - rpa",
|
||||
"activer windows",
|
||||
]
|
||||
|
||||
@@ -199,6 +205,50 @@ def _load_events(session_dir: Path) -> List[Dict[str, Any]]:
|
||||
return events
|
||||
|
||||
|
||||
def _get_app_name(event: Dict[str, Any]) -> str:
|
||||
"""Extraire le nom de l'application depuis l'evenement.
|
||||
|
||||
Cherche dans event.event.window.app_name (format actuel).
|
||||
"""
|
||||
inner = event.get("event", {})
|
||||
window = inner.get("window") or {}
|
||||
if isinstance(window, dict):
|
||||
return window.get("app_name", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _has_identified_ui_element(event: Dict[str, Any]) -> bool:
|
||||
"""Verifier si l'evenement cible un element UI identifie par C2/UIA.
|
||||
|
||||
Un clic sur un element UI nomme (bouton, champ, onglet) est tres
|
||||
probablement un acte metier reel. Les donnees proviennent de :
|
||||
- uia_snapshot.name : nom de l'element via UI Automation (lea_uia.exe)
|
||||
- ui_elements : liste d'elements detectes par le pipeline C2 (vision)
|
||||
- vision_info.ui_elements : idem, format alternatif
|
||||
|
||||
ATTENTION : cette fonction ne garantit pas que le clic n'est pas
|
||||
parasite — un clic systray a aussi un uia_name. C'est un indice
|
||||
positif, pas une preuve absolue. Utiliser en conjonction avec les
|
||||
filtres negatifs (systray, clic droit, etc.).
|
||||
"""
|
||||
inner = event.get("event", {})
|
||||
|
||||
# UIA snapshot — l'element a un nom identifie
|
||||
uia = inner.get("uia_snapshot") or {}
|
||||
if uia.get("name"):
|
||||
return True
|
||||
|
||||
# Pipeline C2 — elements visuels detectes
|
||||
if inner.get("ui_elements"):
|
||||
return True
|
||||
|
||||
vision_info = inner.get("vision_info") or {}
|
||||
if vision_info.get("ui_elements"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_window_title(event: Dict[str, Any]) -> str:
|
||||
"""Extraire le titre de fenetre d'un evenement.
|
||||
|
||||
@@ -241,15 +291,23 @@ def _get_shot_filename(click_index: int, session_dir: Path) -> Optional[str]:
|
||||
def _is_parasitic(event: Dict[str, Any], index: int, total: int) -> bool:
|
||||
"""Determiner si un evenement est probablement parasite.
|
||||
|
||||
Criteres :
|
||||
- Fenetre contenant un pattern parasite (systray, Program Manager, Lea, etc.)
|
||||
- Clic droit
|
||||
- Types non-exploitables (heartbeat, focus_change, action_result)
|
||||
- Parmi les 3 derniers evenements (souvent = arret enregistrement)
|
||||
Logique en 3 couches :
|
||||
1. Signaux durs (toujours parasites) : types non-exploitables, clics droit
|
||||
2. Signaux positifs C2/UIA (jamais parasites) : element UI identifie par
|
||||
nom dans une app metier, ou app connue non-systray
|
||||
3. Patterns de fenetre (parasites si aucun signal positif)
|
||||
|
||||
L'ancienne regle « 3 derniers = parasites » a ete supprimee car elle
|
||||
generait trop de faux positifs sur les logiciels metier (le dernier
|
||||
clic est souvent Valider/Sauvegarder/Confirmer).
|
||||
La detection de l'arret d'enregistrement est maintenant faite par
|
||||
_is_stop_recording_event() dans _parse_actions().
|
||||
"""
|
||||
inner = event.get("event", {})
|
||||
etype = inner.get("type", "")
|
||||
|
||||
# --- Couche 1 : signaux durs, toujours parasites ---
|
||||
|
||||
# Types toujours parasites
|
||||
if etype in ("heartbeat", "focus_change", "window_focus_change", "action_result",
|
||||
"screenshot", "status", "ping", "pong"):
|
||||
@@ -259,37 +317,333 @@ def _is_parasitic(event: Dict[str, Any], index: int, total: int) -> bool:
|
||||
if etype == "mouse_click" and inner.get("button") == "right":
|
||||
return True
|
||||
|
||||
# Fenetre parasite
|
||||
# --- Couche 2 : signaux positifs C2/UIA ---
|
||||
# Si on a un element UI identifie ET que ce n'est pas la systray,
|
||||
# c'est un vrai clic metier — on le preserve quoi qu'il arrive.
|
||||
|
||||
is_systray = _is_systray_interaction(event)
|
||||
|
||||
if not is_systray and _has_identified_ui_element(event):
|
||||
# Un clic sur un bouton/champ/onglet identifie par UIA ou C2
|
||||
# dans une fenetre qui n'est pas la systray = acte metier reel
|
||||
return False
|
||||
|
||||
# Si l'app n'est pas le bureau/systray, c'est une vraie application
|
||||
# metier — les clics dedans sont legitimes meme sans info UIA/C2
|
||||
app_name = _get_app_name(event).lower()
|
||||
_NON_BUSINESS_APPS = frozenset({
|
||||
"", "explorer.exe", "pythonw.exe",
|
||||
})
|
||||
if app_name and app_name not in _NON_BUSINESS_APPS:
|
||||
return False
|
||||
|
||||
# --- Couche 3 : patterns de fenetre ---
|
||||
|
||||
win_title = _get_window_title(event).lower()
|
||||
if win_title:
|
||||
for pattern in _PARASITIC_WINDOW_PATTERNS:
|
||||
if pattern in win_title:
|
||||
return True
|
||||
|
||||
# Derniers 3 evenements exploitables de la session
|
||||
# (on les marque UNIQUEMENT si c'est un evenement exploitable, pas un heartbeat)
|
||||
if etype in _ACTIONABLE_TYPES and index >= total - 3:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_stop_recording_event(event: Dict[str, Any], is_last_actionable: bool) -> bool:
|
||||
"""Detecter si un evenement est un arret d'enregistrement Lea.
|
||||
|
||||
Plutot que de marquer aveuglement les 3 derniers evenements comme
|
||||
parasites (ce qui supprime des clics metier importants comme
|
||||
Valider/Sauvegarder), on detecte finement les patterns d'arret :
|
||||
|
||||
- Le dernier evenement exploitable est un key_combo Ctrl+Shift+L
|
||||
(raccourci explicite d'arret)
|
||||
- Le dernier evenement est un clic sur une fenetre Lea/systray
|
||||
(l'utilisateur clique sur l'icone systray pour arreter)
|
||||
- Clic sur la zone systray (barre des taches, icones cachees, etc.)
|
||||
identifie par le uia_snapshot
|
||||
"""
|
||||
if not is_last_actionable:
|
||||
return False
|
||||
|
||||
inner = event.get("event", {})
|
||||
etype = inner.get("type", "")
|
||||
|
||||
# Raccourci Ctrl+Shift+L → arret explicite
|
||||
if etype == "key_combo":
|
||||
keys = inner.get("keys", [])
|
||||
if isinstance(keys, list):
|
||||
keys_lower = [str(k).lower() for k in keys]
|
||||
# Ctrl+Shift+L (le \x0c ou 'l' selon l'encoding)
|
||||
if "ctrl" in keys_lower and "shift" in keys_lower:
|
||||
return True
|
||||
|
||||
# Clic sur fenetre Lea (pythonw.exe = agent Lea)
|
||||
if etype == "mouse_click":
|
||||
window = inner.get("window", {})
|
||||
if isinstance(window, dict):
|
||||
app_name = (window.get("app_name", "") or "").lower()
|
||||
if app_name == "pythonw.exe":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_systray_interaction(event: Dict[str, Any]) -> bool:
|
||||
"""Detecter si un evenement est une interaction avec la systray.
|
||||
|
||||
La systray (zone de notification) est identifiee par :
|
||||
- Le uia_snapshot contenant 'Afficher les icônes cachées',
|
||||
'Barre des tâches', etc.
|
||||
- Le parent_path contenant 'Fenêtre de dépassement'
|
||||
"""
|
||||
inner = event.get("event", {})
|
||||
uia = inner.get("uia_snapshot", {})
|
||||
if not uia:
|
||||
return False
|
||||
|
||||
uia_name = (uia.get("name", "") or "").lower()
|
||||
# "afficher les icônes cachées" = bouton systray Windows
|
||||
if "icônes cachées" in uia_name or "icones cachees" in uia_name:
|
||||
return True
|
||||
|
||||
# Verifier le parent_path pour la systray
|
||||
parent_path = uia.get("parent_path", [])
|
||||
if isinstance(parent_path, list):
|
||||
for parent in parent_path:
|
||||
if isinstance(parent, dict):
|
||||
parent_name = (parent.get("name", "") or "").lower()
|
||||
if "dépassement" in parent_name or "depassement" in parent_name:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alias de raccourcis clavier courants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KEY_COMBO_ALIASES: Dict[str, str] = {
|
||||
"ctrl+s": "Sauvegarde",
|
||||
"ctrl+z": "Annuler",
|
||||
"ctrl+y": "Rétablir",
|
||||
"ctrl+c": "Copier",
|
||||
"ctrl+v": "Coller",
|
||||
"ctrl+x": "Couper",
|
||||
"ctrl+a": "Tout sélectionner",
|
||||
"ctrl+n": "Nouveau",
|
||||
"ctrl+o": "Ouvrir",
|
||||
"ctrl+p": "Imprimer",
|
||||
"ctrl+f": "Rechercher",
|
||||
"ctrl+w": "Fermer l'onglet",
|
||||
"ctrl+shift+l": "Arrêt enregistrement Léa",
|
||||
"alt+f4": "Fermer la fenêtre",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_keys_for_alias(keys_raw) -> str:
|
||||
"""Normaliser les touches pour la recherche d'alias.
|
||||
|
||||
Gère les caractères de contrôle (ex: \\x13 = Ctrl+S) et uniformise
|
||||
en lowercase avec '+' comme séparateur.
|
||||
Tri : modifiers (ctrl, shift, alt) en premier, puis la touche finale.
|
||||
"""
|
||||
if isinstance(keys_raw, str):
|
||||
keys_list = [keys_raw]
|
||||
elif isinstance(keys_raw, (list, tuple)):
|
||||
keys_list = [str(k) for k in keys_raw]
|
||||
else:
|
||||
return ""
|
||||
|
||||
_MODIFIERS = {"ctrl", "shift", "alt", "meta", "super", "win"}
|
||||
modifiers = []
|
||||
others = []
|
||||
|
||||
for k in keys_list:
|
||||
k_clean = k.strip().lower()
|
||||
# Caractères de contrôle : \x01=a, \x03=c, \x04=d, ..., \x13=s, \x16=v, \x1a=z
|
||||
if len(k_clean) == 1 and ord(k_clean) < 32:
|
||||
k_clean = chr(ord(k_clean) + ord('a') - 1)
|
||||
if k_clean in _MODIFIERS:
|
||||
modifiers.append(k_clean)
|
||||
else:
|
||||
others.append(k_clean)
|
||||
|
||||
return "+".join(sorted(modifiers) + sorted(others))
|
||||
|
||||
|
||||
def _generate_description(event: Dict[str, Any]) -> str:
|
||||
"""Generer une description lisible en français pour un evenement.
|
||||
|
||||
Utilise les donnees UIA/C2 quand disponibles, sinon position + fenetre.
|
||||
"""
|
||||
inner = event.get("event", {})
|
||||
etype = inner.get("type", "")
|
||||
|
||||
if etype == "mouse_click":
|
||||
uia = inner.get("uia_snapshot") or {}
|
||||
uia_name = uia.get("name", "") if uia else ""
|
||||
uia_ct = uia.get("control_type", "") if uia else ""
|
||||
|
||||
if uia_name:
|
||||
ct_label = f" ({uia_ct})" if uia_ct else ""
|
||||
return f'Clic sur « {uia_name} »{ct_label}'
|
||||
else:
|
||||
pos = inner.get("pos", [])
|
||||
win_title = _get_window_title(event)
|
||||
pos_str = f"({pos[0]}, {pos[1]})" if pos and len(pos) >= 2 else "(?)"
|
||||
if win_title and win_title != "unknown_window":
|
||||
return f'Clic à {pos_str} dans « {win_title} »'
|
||||
return f'Clic à {pos_str}'
|
||||
|
||||
elif etype in ("text_input", "type"):
|
||||
text = inner.get("text", "")
|
||||
if text:
|
||||
# Tronquer si trop long
|
||||
display = text if len(text) <= 40 else text[:37] + "..."
|
||||
return f'Saisie : « {display} »'
|
||||
return "Saisie (vide)"
|
||||
|
||||
elif etype == "key_combo":
|
||||
keys_raw = inner.get("keys", [])
|
||||
keys_display = " + ".join(str(k) for k in keys_raw) if isinstance(keys_raw, list) else str(keys_raw)
|
||||
|
||||
# Normaliser pour chercher l'alias
|
||||
norm = _normalize_keys_for_alias(keys_raw)
|
||||
alias = _KEY_COMBO_ALIASES.get(norm, "")
|
||||
|
||||
# Affichage propre des noms de touches
|
||||
keys_pretty = _pretty_keys(keys_raw)
|
||||
|
||||
if alias:
|
||||
return f"Raccourci : {keys_pretty} ({alias})"
|
||||
return f"Raccourci : {keys_pretty}"
|
||||
|
||||
elif etype == "key_press":
|
||||
key = inner.get("key", "")
|
||||
return f"Touche : {_pretty_key(str(key))}"
|
||||
|
||||
return etype
|
||||
|
||||
|
||||
def _pretty_keys(keys_raw) -> str:
|
||||
"""Formater une liste de touches pour l'affichage."""
|
||||
if isinstance(keys_raw, (list, tuple)):
|
||||
return "+".join(_pretty_key(str(k)) for k in keys_raw)
|
||||
return _pretty_key(str(keys_raw))
|
||||
|
||||
|
||||
def _pretty_key(key: str) -> str:
|
||||
"""Formater une touche individuelle pour l'affichage."""
|
||||
k = key.strip().lower()
|
||||
# Caractères de contrôle
|
||||
if len(k) == 1 and ord(k) < 32:
|
||||
return chr(ord(k) + ord('A') - 1)
|
||||
mapping = {
|
||||
"ctrl": "Ctrl",
|
||||
"shift": "Shift",
|
||||
"alt": "Alt",
|
||||
"enter": "Entrée",
|
||||
"return": "Entrée",
|
||||
"tab": "Tab",
|
||||
"escape": "Échap",
|
||||
"esc": "Échap",
|
||||
"space": "Espace",
|
||||
"backspace": "Retour arrière",
|
||||
"delete": "Suppr",
|
||||
"up": "↑",
|
||||
"down": "↓",
|
||||
"left": "←",
|
||||
"right": "→",
|
||||
}
|
||||
if k in mapping:
|
||||
return mapping[k]
|
||||
# Touches de fonction : f1-f12
|
||||
if k.startswith("f") and k[1:].isdigit():
|
||||
return k.upper()
|
||||
return key.capitalize() if len(key) == 1 else key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection des doublons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_duplicates(actions: List[Dict[str, Any]], events: List[Dict[str, Any]]) -> None:
|
||||
"""Detecter les actions dupliquees et marquer is_duplicate=True.
|
||||
|
||||
Criteres (tous requis) :
|
||||
- Meme type (mouse_click)
|
||||
- Meme position (distance euclidienne < 10px)
|
||||
- Meme fenetre
|
||||
- Ecart temporel < 1s
|
||||
|
||||
Le SECOND evenement du doublon est marque (le premier est preserve).
|
||||
Ne supprime rien — signale seulement.
|
||||
"""
|
||||
for j in range(1, len(actions)):
|
||||
a_prev = actions[j - 1]
|
||||
a_curr = actions[j]
|
||||
|
||||
# Seulement les mouse_click
|
||||
if a_prev["type"] != "mouse_click" or a_curr["type"] != "mouse_click":
|
||||
continue
|
||||
|
||||
# Verifier la fenetre
|
||||
if a_prev["window_title"] != a_curr["window_title"]:
|
||||
# Autoriser unknown_window comme equivalent
|
||||
win_a = a_prev["window_title"]
|
||||
win_b = a_curr["window_title"]
|
||||
if win_a and win_b and win_a != "unknown_window" and win_b != "unknown_window":
|
||||
continue
|
||||
|
||||
# Verifier la position (distance euclidienne < 10px)
|
||||
ev_prev = events[a_prev["global_index"]].get("event", {})
|
||||
ev_curr = events[a_curr["global_index"]].get("event", {})
|
||||
pos_prev = ev_prev.get("pos", [])
|
||||
pos_curr = ev_curr.get("pos", [])
|
||||
|
||||
if not pos_prev or not pos_curr or len(pos_prev) < 2 or len(pos_curr) < 2:
|
||||
continue
|
||||
|
||||
dx = pos_prev[0] - pos_curr[0]
|
||||
dy = pos_prev[1] - pos_curr[1]
|
||||
dist = math.sqrt(dx * dx + dy * dy)
|
||||
if dist >= 10:
|
||||
continue
|
||||
|
||||
# Verifier l'ecart temporel < 1s
|
||||
ts_prev = ev_prev.get("timestamp", 0)
|
||||
ts_curr = ev_curr.get("timestamp", 0)
|
||||
dt = abs(ts_curr - ts_prev)
|
||||
if dt >= 1.0:
|
||||
continue
|
||||
|
||||
# C'est un doublon
|
||||
a_curr["is_duplicate"] = True
|
||||
a_curr["duplicate_info"] = (
|
||||
f"Doublon (< {dt:.0f}s, {dist:.0f}px de distance)"
|
||||
if dist > 0
|
||||
else f"Doublon (< {dt:.1f}s, même position)"
|
||||
)
|
||||
|
||||
|
||||
def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict[str, Any]]:
|
||||
"""Convertir les evenements bruts en liste d'actions affichables.
|
||||
|
||||
Retourne une liste de dicts avec : index_global, type, position, fenetre,
|
||||
texte, touches, shot_file, is_parasitic, etc.
|
||||
texte, touches, shot_file, is_parasitic, description, is_duplicate, etc.
|
||||
"""
|
||||
actions: List[Dict[str, Any]] = []
|
||||
click_count = 0
|
||||
total_events = len(events)
|
||||
|
||||
# Pre-calculer les 3 derniers indices d'evenements exploitables
|
||||
# Pre-calculer le dernier indice d'evenement exploitable
|
||||
# pour la detection fine de l'arret d'enregistrement
|
||||
actionable_indices = [
|
||||
i for i, ev in enumerate(events)
|
||||
if ev.get("event", {}).get("type", "") in _ACTIONABLE_TYPES
|
||||
]
|
||||
last_3_actionable = set(actionable_indices[-3:]) if len(actionable_indices) >= 3 else set(actionable_indices)
|
||||
last_actionable_idx = actionable_indices[-1] if actionable_indices else -1
|
||||
|
||||
for i, event in enumerate(events):
|
||||
inner = event.get("event", {})
|
||||
@@ -308,6 +662,9 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict
|
||||
"keys": "",
|
||||
"shot_file": None,
|
||||
"is_parasitic": False,
|
||||
"description": _generate_description(event),
|
||||
"is_duplicate": False,
|
||||
"duplicate_info": "",
|
||||
}
|
||||
|
||||
# Position (pour les clics)
|
||||
@@ -334,30 +691,25 @@ def _parse_actions(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict
|
||||
else:
|
||||
action["keys"] = str(inner.get("key", keys))
|
||||
|
||||
# Detection parasite
|
||||
# Utiliser les 3 derniers indices exploitables (pas les indices globaux)
|
||||
parasitic = False
|
||||
inner_type = etype
|
||||
# Detection parasite — logique centralisee + extensions
|
||||
parasitic = _is_parasitic(event, i, total_events)
|
||||
|
||||
# Clic droit
|
||||
if inner_type == "mouse_click" and inner.get("button") == "right":
|
||||
# Extensions specifiques a _parse_actions :
|
||||
# - interaction systray (icones cachees, fenetre depassement)
|
||||
if not parasitic and _is_systray_interaction(event):
|
||||
parasitic = True
|
||||
|
||||
# Fenetre parasite
|
||||
win_lower = action["window_title"].lower()
|
||||
if win_lower:
|
||||
for pattern in _PARASITIC_WINDOW_PATTERNS:
|
||||
if pattern in win_lower:
|
||||
parasitic = True
|
||||
break
|
||||
|
||||
# Derniers 3 evenements exploitables
|
||||
if i in last_3_actionable:
|
||||
# - arret d'enregistrement (dernier evenement exploitable uniquement)
|
||||
is_last = (i == last_actionable_idx)
|
||||
if not parasitic and _is_stop_recording_event(event, is_last):
|
||||
parasitic = True
|
||||
|
||||
action["is_parasitic"] = parasitic
|
||||
actions.append(action)
|
||||
|
||||
# Detection des doublons (marque is_duplicate sur le 2eme du pair)
|
||||
_detect_duplicates(actions, events)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
@@ -392,6 +744,11 @@ tr:hover { background: #f0f7ff; }
|
||||
padding: 15px; margin: 15px 0; }
|
||||
.parasitic { background: #ffe0e0; }
|
||||
.normal { background: #e0ffe0; }
|
||||
.duplicate { background: #f0f0f0; color: #999; }
|
||||
.duplicate td { color: #999; }
|
||||
.desc { font-size: 13px; color: #555; max-width: 300px; }
|
||||
.badge-dup { display: inline-block; background: #ddd; color: #888; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: 3px; margin-left: 4px; cursor: help; }
|
||||
.counter { font-size: 18px; font-weight: bold; margin: 15px 0; }
|
||||
.counter .remove { color: #e74c3c; }
|
||||
.counter .total { color: #2c3e50; }
|
||||
@@ -478,6 +835,9 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
|
||||
<div class="counter" id="counter">
|
||||
<span class="remove" id="remove-count">{{ parasitic_count }}</span> actions a supprimer /
|
||||
<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>
|
||||
|
||||
<form method="POST" action="{{ url_for('clean_and_replay') }}" id="clean-form">
|
||||
@@ -490,6 +850,7 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
|
||||
<th>Supprimer</th>
|
||||
<th>#</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Position</th>
|
||||
<th>Fenetre</th>
|
||||
<th>Texte / Touches</th>
|
||||
@@ -498,7 +859,7 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>
|
||||
<label>
|
||||
<input type="checkbox" name="remove_indices"
|
||||
@@ -513,7 +874,11 @@ _SESSION_TEMPLATE = """<!DOCTYPE html>
|
||||
{% if a.button is defined and a.button == 'right' %}
|
||||
<span style="color:#e74c3c">(droit)</span>
|
||||
{% endif %}
|
||||
{% if a.is_duplicate %}
|
||||
<span class="badge-dup" title="{{ a.duplicate_info }}">doublon</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="desc">{{ a.description }}</td>
|
||||
<td class="mono">{{ a.position }}</td>
|
||||
<td>{{ a.window_title|truncate(40) }}</td>
|
||||
<td class="mono">
|
||||
@@ -665,9 +1030,10 @@ def view_session(machine_id: str, session_id: str):
|
||||
events = _load_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_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_str = ""
|
||||
@@ -688,6 +1054,7 @@ def view_session(machine_id: str, session_id: str):
|
||||
actions=actions,
|
||||
parasitic_count=parasitic_count,
|
||||
parasitic_indices=parasitic_indices,
|
||||
duplicate_count=duplicate_count,
|
||||
css=_BASE_CSS,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ CORE_ACTION_TO_VWB = {
|
||||
"mouse_click": "click_anchor",
|
||||
"text_input": "type_text",
|
||||
"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",
|
||||
"scroll": "scroll_to_anchor",
|
||||
"unknown": "click_anchor",
|
||||
@@ -133,76 +134,103 @@ def convert_learned_to_vwb_steps(
|
||||
if to_node and to_node not in visited:
|
||||
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 = []
|
||||
for idx, edge in enumerate(ordered_edges):
|
||||
for edge in ordered_edges:
|
||||
action = edge.get("action", {})
|
||||
action_type = action.get("type", "unknown")
|
||||
action_params = action.get("parameters", {})
|
||||
target = action.get("target", {})
|
||||
|
||||
# Déterminer le type VWB
|
||||
vwb_action_type = CORE_ACTION_TO_VWB.get(action_type, "click_anchor")
|
||||
|
||||
# Construire les paramètres VWB
|
||||
vwb_params = {}
|
||||
|
||||
if action_type == "mouse_click":
|
||||
# Extraire la position en pourcentage si disponible
|
||||
by_position = target.get("by_position")
|
||||
if by_position:
|
||||
vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0
|
||||
vwb_params["y_pct"] = by_position[1] if isinstance(by_position, list) else 0
|
||||
button = action_params.get("button", "left")
|
||||
if button == "double":
|
||||
vwb_action_type = "double_click_anchor"
|
||||
elif button == "right":
|
||||
vwb_action_type = "right_click_anchor"
|
||||
|
||||
elif action_type == "text_input":
|
||||
vwb_params["text"] = action_params.get("text", "")
|
||||
|
||||
elif action_type == "key_press":
|
||||
keys = action_params.get("keys", [])
|
||||
if not keys and action_params.get("key"):
|
||||
keys = [action_params["key"]]
|
||||
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
|
||||
if target.get("by_role"):
|
||||
vwb_params["target_role"] = target["by_role"]
|
||||
if target.get("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)
|
||||
|
||||
step = {
|
||||
"action_type": vwb_action_type,
|
||||
"order": idx,
|
||||
"position_x": 400,
|
||||
"position_y": 80 + idx * 120,
|
||||
"parameters": vwb_params,
|
||||
"label": label,
|
||||
# Métadonnées d'origine pour traçabilité
|
||||
"metadata": {
|
||||
"core_edge_id": edge.get("edge_id", ""),
|
||||
"core_from_node": from_node,
|
||||
"core_to_node": to_node,
|
||||
},
|
||||
edge_meta = {
|
||||
"core_edge_id": edge.get("edge_id", ""),
|
||||
"core_from_node": from_node,
|
||||
"core_to_node": to_node,
|
||||
}
|
||||
steps.append(step)
|
||||
|
||||
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 = {}
|
||||
|
||||
if action_type == "mouse_click":
|
||||
by_position = target.get("by_position")
|
||||
if by_position:
|
||||
vwb_params["x_pct"] = by_position[0] if isinstance(by_position, list) else 0
|
||||
vwb_params["y_pct"] = by_position[1] if isinstance(by_position, list) else 0
|
||||
button = action_params.get("button", "left")
|
||||
if button == "double":
|
||||
vwb_action_type = "double_click_anchor"
|
||||
elif button == "right":
|
||||
vwb_action_type = "right_click_anchor"
|
||||
|
||||
elif action_type == "text_input":
|
||||
vwb_params["text"] = action_params.get("text", "")
|
||||
|
||||
elif action_type in ("key_press", "key_combo"):
|
||||
vwb_action_type = "keyboard_shortcut"
|
||||
keys = action_params.get("keys", [])
|
||||
if not keys and action_params.get("key"):
|
||||
keys = [action_params["key"]]
|
||||
vwb_params["keys"] = keys
|
||||
|
||||
# Ajouter des infos de ciblage pour la review humaine
|
||||
if target.get("by_role"):
|
||||
vwb_params["target_role"] = target["by_role"]
|
||||
if target.get("by_text"):
|
||||
vwb_params["target_text"] = target["by_text"]
|
||||
|
||||
label = _build_step_label(vwb_action_type, vwb_params, from_name, to_name)
|
||||
steps.append({
|
||||
"action_type": vwb_action_type,
|
||||
"order": len(steps),
|
||||
"position_x": 0,
|
||||
"position_y": 0,
|
||||
"parameters": vwb_params,
|
||||
"label": label,
|
||||
"metadata": edge_meta,
|
||||
})
|
||||
|
||||
# Appliquer le layout serpentin à tous les steps
|
||||
_compute_layout(steps)
|
||||
|
||||
if not steps and 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({
|
||||
"action_type": "click_anchor",
|
||||
"order": idx,
|
||||
"position_x": 400,
|
||||
"position_y": 80 + idx * 120,
|
||||
"position_x": 0,
|
||||
"position_y": 0,
|
||||
"parameters": {
|
||||
"window_title": (node.get("template", {}).get("window", {}) or {}).get("title_pattern", ""),
|
||||
},
|
||||
"label": f"Écran : {node_name}",
|
||||
"metadata": {"core_node_id": node.get("node_id", "")},
|
||||
})
|
||||
_compute_layout(steps)
|
||||
|
||||
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(
|
||||
action_type: str, params: Dict[str, Any], from_name: str, to_name: str
|
||||
) -> str:
|
||||
|
||||
@@ -53,13 +53,15 @@ export default function WorkflowSelector({
|
||||
}
|
||||
}, [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 () => {
|
||||
setLearnedLoading(true);
|
||||
try {
|
||||
const data = await api.getLearnedWorkflows(undefined, userOS);
|
||||
const data = await api.getLearnedWorkflows();
|
||||
// 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 {
|
||||
// Silencieux : le streaming server n'est peut-être pas lancé
|
||||
setLearnedWorkflows([]);
|
||||
@@ -80,11 +82,10 @@ export default function WorkflowSelector({
|
||||
(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 =>
|
||||
(wf.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())) &&
|
||||
(wf.machine_id || '').toLowerCase().includes(userOS)
|
||||
wf.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
// Workflows récents (les 8 premiers)
|
||||
@@ -257,9 +258,12 @@ export default function WorkflowSelector({
|
||||
<span className="learned-badge" title={`Machine: ${wf.machine_id}`}>
|
||||
appris
|
||||
</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 className="item-meta">
|
||||
{wf.nodes} noeuds, {wf.edges} transitions
|
||||
{wf.nodes} nœuds, {wf.edges} transitions
|
||||
</span>
|
||||
<button
|
||||
className="import-btn"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
877
web_dashboard/templates/audit.html
Normal file
877
web_dashboard/templates/audit.html
Normal 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>⚖️ 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="/">🎛️ Dashboard</a>
|
||||
<a href="/audit" class="active">⚖️ Audit</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Bannière d'erreur connexion -->
|
||||
<div class="error-banner" id="errorBanner">
|
||||
⚠️ <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;">🔍 Filtres</h2>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn btn-success btn-small" onclick="exportCSV()">📥 Exporter CSV</button>
|
||||
<button class="btn btn-primary btn-small" onclick="exportJSON()">📥 Exporter JSON</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="refreshAll()">🔄 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> </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>—</span>
|
||||
<span>Dernière MAJ : <strong id="lastRefresh">-</strong></span>
|
||||
</div>
|
||||
<button class="btn btn-small btn-secondary" id="toggleRefreshBtn" onclick="toggleAutoRefresh()">
|
||||
⏸️ 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>◀ Précédent</button>
|
||||
<button class="btn btn-small btn-secondary" id="btnNext" onclick="nextPage()">Suivant ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Résumé statistique (bottom) -->
|
||||
<div class="grid-3" style="margin-top:20px;">
|
||||
<!-- Répartition par application -->
|
||||
<div class="card">
|
||||
<h2>📊 Répartition par application</h2>
|
||||
<div id="appDistribution">
|
||||
<div class="loading">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Répartition par résultat -->
|
||||
<div class="card">
|
||||
<h2>📊 Répartition par résultat</h2>
|
||||
<div id="resultDistribution">
|
||||
<div class="loading">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top échecs récents -->
|
||||
<div class="card">
|
||||
<h2>⚠️ 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()">×</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">—</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})">🔍</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 = '⏸️ Pause';
|
||||
dot.classList.remove('paused');
|
||||
status.textContent = 'Auto-refresh actif (60s)';
|
||||
} else {
|
||||
btn.innerHTML = '▶️ 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
Reference in New Issue
Block a user