#!/usr/bin/env python3 """ Session Cleaner -- Outil leger de nettoyage de sessions avant replay. Petit serveur Flask standalone qui permet de : - Lister les sessions enregistrees recentes - Visualiser chaque session avec ses screenshots (crop + full) - Marquer les clics parasites a supprimer (auto-detection des toasts, clics droit, fenetres Lea/systray, derniers 3 evenements) - Re-construire un replay nettoye et l'injecter dans la queue Option A du rapport audit VWB. Port : 5006 """ import json import logging import os import uuid from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from flask import ( Flask, redirect, render_template_string, request, send_from_directory, url_for, ) # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- STREAMING_SERVER = os.environ.get("RPA_STREAMING_SERVER", "http://localhost:5005") LIVE_SESSIONS_DIR = os.environ.get( "RPA_LIVE_SESSIONS_DIR", os.path.join(os.path.dirname(__file__), "..", "data", "training", "live_sessions"), ) PORT = int(os.environ.get("SESSION_CLEANER_PORT", "5006")) # Charger le token API depuis l'environnement ou .env.local API_TOKEN = os.environ.get("RPA_API_TOKEN", "") if not API_TOKEN: env_local = os.path.join(os.path.dirname(__file__), "..", ".env.local") if os.path.isfile(env_local): try: with open(env_local, encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("RPA_API_TOKEN="): API_TOKEN = line.split("=", 1)[1].strip().strip('"').strip("'") break except OSError: pass # --------------------------------------------------------------------------- # Import optionnel de build_replay_from_raw_events # --------------------------------------------------------------------------- _build_replay_fn = None try: from agent_v0.server_v1.stream_processor import build_replay_from_raw_events _build_replay_fn = build_replay_from_raw_events except ImportError: pass # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("session_cleaner") # --------------------------------------------------------------------------- # Application Flask # --------------------------------------------------------------------------- app = Flask(__name__) # --------------------------------------------------------------------------- # Utilitaires # --------------------------------------------------------------------------- # Fenetres considerees comme parasites _PARASITIC_WINDOW_PATTERNS = [ "program manager", "fenetre de depassement", "fenêtre de dépassement", "léa", "lea", "assistant", "activer windows", ] # Types d'evenements exploitables (affiches a l'utilisateur) _ACTIONABLE_TYPES = frozenset({"mouse_click", "text_input", "key_combo", "key_press", "type"}) def _resolve_sessions_dir() -> Path: """Resoudre le repertoire racine des live_sessions.""" return Path(LIVE_SESSIONS_DIR).resolve() def _discover_sessions(limit: int = 50) -> List[Dict[str, Any]]: """Decouvrir les sessions recentes. Parcourt deux niveaux : - //sess_* (format actuel) - /sess_* (ancien format, sessions au niveau racine) """ base = _resolve_sessions_dir() if not base.is_dir(): logger.warning("Repertoire live_sessions introuvable : %s", base) return [] sessions: List[Dict[str, Any]] = [] for item in base.iterdir(): if not item.is_dir(): continue # Sessions directement a la racine (ancien format) if item.name.startswith("sess_"): jsonl = item / "live_events.jsonl" if jsonl.is_file(): sessions.append(_build_session_info("(racine)", item.name, item, jsonl)) continue # Ignorer les dossiers systeme if item.name.startswith(".") or item.name in ("embeddings", "streaming_sessions", "workflows", "test_gpu"): continue # Sous-dossiers machine_id for sub in item.iterdir(): if sub.is_dir() and sub.name.startswith("sess_"): jsonl = sub / "live_events.jsonl" if jsonl.is_file(): sessions.append(_build_session_info(item.name, sub.name, sub, jsonl)) # Tri par date decroissante (mtime du JSONL) sessions.sort(key=lambda s: s["mtime"], reverse=True) return sessions[:limit] def _build_session_info(machine_id: str, session_id: str, session_dir: Path, jsonl_path: Path) -> Dict[str, Any]: """Construire les metadonnees d'une session.""" mtime = jsonl_path.stat().st_mtime event_count = 0 try: with open(jsonl_path, encoding="utf-8") as f: for line in f: if line.strip(): event_count += 1 except OSError: pass # Extraire la date depuis le nom de session (sess_YYYYMMDDTHHMMSS_...) date_str = "" try: parts = session_id.split("_") if len(parts) >= 2: raw = parts[1] # 20260410T222352 dt = datetime.strptime(raw, "%Y%m%dT%H%M%S") date_str = dt.strftime("%d/%m/%Y %H:%M:%S") except (ValueError, IndexError): date_str = datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S") return { "machine_id": machine_id, "session_id": session_id, "session_dir": str(session_dir), "date_str": date_str, "event_count": event_count, "mtime": mtime, } def _load_events(session_dir: Path) -> List[Dict[str, Any]]: """Charger les evenements depuis live_events.jsonl.""" jsonl = session_dir / "live_events.jsonl" events: List[Dict[str, Any]] = [] if not jsonl.is_file(): return events try: with open(jsonl, encoding="utf-8") as f: for line in f: line = line.strip() if line: try: events.append(json.loads(line)) except json.JSONDecodeError: continue except OSError as e: logger.error("Erreur lecture %s : %s", jsonl, e) return events def _get_window_title(event: Dict[str, Any]) -> str: """Extraire le titre de fenetre d'un evenement. Les evenements plus recents stockent la fenetre dans event.window.title, les anciens dans event.active_window_title. """ inner = event.get("event", {}) # Format actuel : inner.window.title window = inner.get("window") or {} if isinstance(window, dict) and window.get("title"): return window["title"] # Ancien format return inner.get("active_window_title", "") def _get_shot_filename(click_index: int, session_dir: Path) -> Optional[str]: """Trouver le fichier screenshot pour un clic donne. Essaie dans l'ordre : 1. shot_XXXX_crop.png (ancien format) 2. shot_XXXX_full.png (ancien format) 3. res_shot_XXXX.png (format recent — resultat post-action) ``click_index`` est 1-based (premier clic = 1). """ shots_dir = session_dir / "shots" if not shots_dir.is_dir(): return None shot_id = f"shot_{click_index:04d}" # Priorite au crop (plus informatif en thumbnail) for pattern in [f"{shot_id}_crop.png", f"{shot_id}_full.png", f"res_{shot_id}.png"]: if (shots_dir / pattern).is_file(): return pattern return None 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) """ inner = event.get("event", {}) etype = inner.get("type", "") # Types toujours parasites if etype in ("heartbeat", "focus_change", "window_focus_change", "action_result", "screenshot", "status", "ping", "pong"): return True # Clics droit if etype == "mouse_click" and inner.get("button") == "right": return True # Fenetre parasite 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 _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. """ actions: List[Dict[str, Any]] = [] click_count = 0 total_events = len(events) # Pre-calculer les 3 derniers indices d'evenements exploitables 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) for i, event in enumerate(events): inner = event.get("event", {}) etype = inner.get("type", "") # Ne montrer que les evenements exploitables if etype not in _ACTIONABLE_TYPES: continue action: Dict[str, Any] = { "global_index": i, "type": etype, "position": "", "window_title": _get_window_title(event), "text": "", "keys": "", "shot_file": None, "is_parasitic": False, } # Position (pour les clics) pos = inner.get("pos") if pos and isinstance(pos, (list, tuple)) and len(pos) >= 2: action["position"] = f"({pos[0]}, {pos[1]})" # Bouton de clic if etype == "mouse_click": action["button"] = inner.get("button", "left") click_count += 1 action["shot_file"] = _get_shot_filename(click_count, session_dir) action["click_number"] = click_count # Texte tape if etype in ("text_input", "type"): action["text"] = inner.get("text", "") # Touches pour key_combo / key_press if etype in ("key_combo", "key_press"): keys = inner.get("keys", []) if isinstance(keys, list): action["keys"] = " + ".join(str(k) for k in keys) 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 # Clic droit if inner_type == "mouse_click" and inner.get("button") == "right": 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: parasitic = True action["is_parasitic"] = parasitic actions.append(action) return actions # --------------------------------------------------------------------------- # Templates HTML # --------------------------------------------------------------------------- _BASE_CSS = """ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; } h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; } h2 { color: #34495e; } a { color: #2980b9; text-decoration: none; } a:hover { text-decoration: underline; } table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.12); } th { background: #2c3e50; color: white; padding: 12px 15px; text-align: left; } td { padding: 10px 15px; border-bottom: 1px solid #eee; } tr:hover { background: #f0f7ff; } .btn { display: inline-block; padding: 10px 20px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .btn:hover { background: #c0392b; } .btn-secondary { background: #3498db; } .btn-secondary:hover { background: #2980b9; } .info-box { background: #eaf4ff; border: 1px solid #b8d4f0; border-radius: 6px; padding: 15px; margin: 15px 0; } .warning-box { background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 15px; margin: 15px 0; } .success-box { background: #d4edda; border: 1px solid #28a745; border-radius: 6px; padding: 15px; margin: 15px 0; } .error-box { background: #f8d7da; border: 1px solid #dc3545; border-radius: 6px; padding: 15px; margin: 15px 0; } .parasitic { background: #ffe0e0; } .normal { background: #e0ffe0; } .counter { font-size: 18px; font-weight: bold; margin: 15px 0; } .counter .remove { color: #e74c3c; } .counter .total { color: #2c3e50; } img.thumb { max-height: 80px; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; } img.thumb:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.3); } .nav { margin-bottom: 20px; } .mono { font-family: 'Fira Code', 'Consolas', monospace; font-size: 13px; } label { cursor: pointer; } """ _INDEX_TEMPLATE = """ Session Cleaner -- Lea

Session Cleaner

Outil de nettoyage des sessions avant replay. Selectionnez une session pour voir ses actions.

{% if sessions %} {% for s in sessions %} {% endfor %}
Date Machine Session ID Evenements Action
{{ s.date_str }} {{ s.machine_id }} {{ s.session_id }} {{ s.event_count }} Voir
{% else %}

Aucune session trouvee dans {{ sessions_dir }}.

Lancez un enregistrement depuis l'Agent V1 pour creer des sessions.

{% endif %} """ _SESSION_TEMPLATE = """ Session {{ session_id }} -- Session Cleaner

Session : {{ session_id }}

Machine : {{ machine_id }} | Date : {{ date_str }} | Evenements bruts : {{ total_events }}
{% if actions %}
{{ parasitic_count }} actions a supprimer / {{ actions|length }} total
{% for a in actions %} {% endfor %}
Supprimer # Type Position Fenetre Texte / Touches Screenshot
{{ loop.index }} {{ a.type }} {% if a.button is defined and a.button == 'right' %} (droit) {% endif %} {{ a.position }} {{ a.window_title|truncate(40) }} {% if a.text %}{{ a.text|truncate(60) }}{% endif %} {% if a.keys %}{{ a.keys }}{% endif %} {% if a.shot_file %} Screenshot action {{ loop.index }} {% else %} -- {% endif %}
{% else %}
Aucune action exploitable dans cette session.
{% endif %}
Screenshot agrandi
""" _RESULT_TEMPLATE = """ Replay lance -- Session Cleaner

Replay lance

{% if success %}

Replay demarre avec succes.

Replay ID : {{ replay_id }}

Session : {{ session_id }}

Machine cible : {{ machine_id }}

Actions injectees : {{ action_count }}

Actions supprimees : {{ removed_count }}

{% else %}

Erreur lors du lancement du replay.

{{ error_message }}

{% endif %} """ # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.route("/") def index(): """Page d'accueil : liste des sessions recentes.""" sessions = _discover_sessions(limit=50) return render_template_string( _INDEX_TEMPLATE, sessions=sessions, sessions_dir=str(_resolve_sessions_dir()), css=_BASE_CSS, ) @app.route("/session//") def view_session(machine_id: str, session_id: str): """Vue detaillee d'une session avec ses actions.""" session_dir = _find_session_dir(machine_id, session_id) if session_dir is None: return render_template_string( """ Session introuvable

Session {{ sid }} introuvable pour la machine {{ mid }}.

Retour""", sid=session_id, mid=machine_id, css=_BASE_CSS, ), 404 events = _load_events(session_dir) actions = _parse_actions(events, session_dir) # Compter les parasites et collecter leurs 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"]] # Date depuis le nom de session date_str = "" try: parts = session_id.split("_") if len(parts) >= 2: dt = datetime.strptime(parts[1], "%Y%m%dT%H%M%S") date_str = dt.strftime("%d/%m/%Y %H:%M:%S") except (ValueError, IndexError): date_str = "?" return render_template_string( _SESSION_TEMPLATE, session_id=session_id, machine_id=machine_id, date_str=date_str, total_events=len(events), actions=actions, parasitic_count=parasitic_count, parasitic_indices=parasitic_indices, css=_BASE_CSS, ) @app.route("/shots///") def serve_shot(machine_id: str, session_id: str, filename: str): """Servir un fichier screenshot.""" session_dir = _find_session_dir(machine_id, session_id) if session_dir is None: return "Session introuvable", 404 shots_dir = session_dir / "shots" if not shots_dir.is_dir(): return "Repertoire shots introuvable", 404 # Securite : empecher la traversee de repertoire safe_name = Path(filename).name if safe_name != filename: return "Nom de fichier invalide", 400 target = shots_dir / safe_name if not target.is_file(): return "Fichier introuvable", 404 return send_from_directory(str(shots_dir), safe_name, mimetype="image/png") @app.route("/clean-and-replay", methods=["POST"]) def clean_and_replay(): """Nettoyer les evenements et lancer un replay.""" session_id = request.form.get("session_id", "") machine_id = request.form.get("machine_id", "") remove_indices_raw = request.form.getlist("remove_indices") # Convertir les indices en entiers remove_indices = set() for idx_str in remove_indices_raw: try: remove_indices.add(int(idx_str)) except ValueError: continue # Trouver le repertoire de session session_dir = _find_session_dir(machine_id, session_id) if session_dir is None: return render_template_string( _RESULT_TEMPLATE, success=False, error_message=f"Session {session_id} introuvable pour la machine {machine_id}.", replay_id="", session_id=session_id, machine_id=machine_id, action_count=0, removed_count=0, css=_BASE_CSS, ) # Charger les evenements et filtrer all_events = _load_events(session_dir) cleaned_events = [ ev for i, ev in enumerate(all_events) if i not in remove_indices ] removed_count = len(all_events) - len(cleaned_events) logger.info( "Nettoyage session %s : %d evenements -> %d (suppression de %d)", session_id, len(all_events), len(cleaned_events), removed_count, ) # Construire les actions de replay replay_actions = None error_message = "" if _build_replay_fn is not None: # Chemin principal : utiliser build_replay_from_raw_events try: replay_actions = _build_replay_fn( cleaned_events, session_id=session_id, session_dir=str(session_dir), ) logger.info("build_replay_from_raw_events a produit %d actions", len(replay_actions)) except Exception as e: logger.error("Erreur build_replay_from_raw_events : %s", e) error_message = f"Erreur lors de la construction du replay : {e}" if replay_actions is None and not error_message: # Fallback : filtrage simple et conversion directe try: replay_actions = _simple_build_replay(cleaned_events, session_dir) logger.info("Fallback simple_build_replay a produit %d actions", len(replay_actions)) except Exception as e: logger.error("Erreur fallback simple_build_replay : %s", e) error_message = f"Erreur lors de la construction du replay (fallback) : {e}" if not replay_actions: if not error_message: error_message = "Aucune action exploitable apres nettoyage." return render_template_string( _RESULT_TEMPLATE, success=False, error_message=error_message, replay_id="", session_id=session_id, machine_id=machine_id, action_count=0, removed_count=removed_count, css=_BASE_CSS, ) # Envoyer au streaming server replay_id = f"replay_clean_{uuid.uuid4().hex[:8]}" try: import requests as _requests headers = {"Content-Type": "application/json"} if API_TOKEN: headers["Authorization"] = f"Bearer {API_TOKEN}" payload = { "session_id": session_id, "actions": replay_actions, "machine_id": machine_id if machine_id != "(racine)" else "", "task_description": f"Replay nettoye de {session_id} ({removed_count} actions supprimees)", } resp = _requests.post( f"{STREAMING_SERVER}/api/v1/traces/stream/replay/raw", json=payload, headers=headers, timeout=30, ) if resp.status_code == 200: data = resp.json() replay_id = data.get("replay_id", replay_id) logger.info("Replay lance : %s (%d actions)", replay_id, len(replay_actions)) return render_template_string( _RESULT_TEMPLATE, success=True, replay_id=replay_id, session_id=session_id, machine_id=machine_id, action_count=len(replay_actions), removed_count=removed_count, error_message="", css=_BASE_CSS, ) else: error_message = f"Serveur streaming a repondu {resp.status_code} : {resp.text[:300]}" logger.error("Erreur POST replay : %s", error_message) except ImportError: error_message = ( "Module 'requests' non disponible. " "Installez-le avec : pip install requests" ) except Exception as e: error_message = f"Erreur de connexion au serveur streaming ({STREAMING_SERVER}) : {e}" logger.error("Erreur connexion streaming : %s", e) return render_template_string( _RESULT_TEMPLATE, success=False, error_message=error_message, replay_id="", session_id=session_id, machine_id=machine_id, action_count=0, removed_count=removed_count, css=_BASE_CSS, ) # --------------------------------------------------------------------------- # Helpers internes # --------------------------------------------------------------------------- def _find_session_dir(machine_id: str, session_id: str) -> Optional[Path]: """Trouver le repertoire d'une session. Cherche dans : 1. /// 2. // (ancien format, racine) """ base = _resolve_sessions_dir() # Sous machine_id if machine_id and machine_id != "(racine)": candidate = base / machine_id / session_id if candidate.is_dir(): return candidate # Directement a la racine candidate = base / session_id if candidate.is_dir(): return candidate # Recherche exhaustive (au cas ou le machine_id a change) for item in base.iterdir(): if item.is_dir() and not item.name.startswith("."): candidate = item / session_id if candidate.is_dir(): return candidate return None def _simple_build_replay(events: List[Dict[str, Any]], session_dir: Path) -> List[Dict[str, Any]]: """Construire un replay simplifie sans dependre de stream_processor. Convertit les evenements bruts en actions normalisees simples : - mouse_click -> action click (coordonnees en pixels) - text_input / type -> action type - key_combo / key_press -> action key_combo C'est un fallback pour quand build_replay_from_raw_events n'est pas disponible. Les coordonnees ne sont PAS converties en pourcentages (le serveur les accepte aussi en pixels). """ actions: List[Dict[str, Any]] = [] click_count = 0 # Essayer d'extraire la resolution d'ecran screen_w, screen_h = 1920, 1080 for ev in events: inner = ev.get("event", {}) meta = inner.get("screen_metadata", {}) res = meta.get("screen_resolution") if res and isinstance(res, (list, tuple)) and len(res) >= 2: screen_w, screen_h = int(res[0]), int(res[1]) break for ev in events: inner = ev.get("event", {}) etype = inner.get("type", "") if etype not in _ACTIONABLE_TYPES: continue action_id = f"act_clean_{uuid.uuid4().hex[:6]}" if etype == "mouse_click": pos = inner.get("pos", [0, 0]) click_count += 1 action = { "action_id": action_id, "type": "click", "x_percent": round(pos[0] / screen_w * 100, 2) if screen_w else 0, "y_percent": round(pos[1] / screen_h * 100, 2) if screen_h else 0, "button": inner.get("button", "left"), "wait_before": 0.5, } actions.append(action) elif etype in ("text_input", "type"): text = inner.get("text", "") if text: action = { "action_id": action_id, "type": "type", "text": text, "wait_before": 0.3, } actions.append(action) elif etype in ("key_combo", "key_press"): keys = inner.get("keys", []) if isinstance(keys, str): keys = [keys] key_single = inner.get("key", "") if not keys and key_single: keys = [key_single] if keys: action = { "action_id": action_id, "type": "key_combo", "keys": keys, "wait_before": 0.3, } actions.append(action) return actions # --------------------------------------------------------------------------- # Point d'entree # --------------------------------------------------------------------------- def main(): """Demarrer le serveur Session Cleaner.""" import argparse parser = argparse.ArgumentParser( description="Session Cleaner -- Nettoyage de sessions avant replay", ) parser.add_argument( "--port", type=int, default=PORT, help=f"Port du serveur (defaut: {PORT})", ) parser.add_argument( "--host", default="0.0.0.0", help="Adresse d'ecoute (defaut: 0.0.0.0)", ) parser.add_argument( "--debug", action="store_true", help="Mode debug Flask", ) args = parser.parse_args() logger.info("Session Cleaner demarre sur http://%s:%d", args.host, args.port) logger.info("Repertoire sessions : %s", _resolve_sessions_dir()) logger.info("Serveur streaming : %s", STREAMING_SERVER) logger.info("Token API : %s", "configure" if API_TOKEN else "non configure") app.run(host=args.host, port=args.port, debug=args.debug) if __name__ == "__main__": main()