From 9bcce3fc6830e433579ed4c021648af2b756c098 Mon Sep 17 00:00:00 2001 From: Dom Date: Sun, 12 Apr 2026 10:41:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20session=5Fcleaner=20=E2=80=94=20outil?= =?UTF-8?q?=20leger=20de=20nettoyage=20de=20sessions=20avant=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Petit serveur Flask standalone (tools/session_cleaner.py) 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 via POST /api/v1/traces/stream/replay/raw Option A du rapport audit VWB : "Le besoin reel est supprimer 3 clics parasites et relancer — c'est 30 secondes d'UX, pas un Visual Workflow Builder." Port : 5006 Dependencies : Flask (deja dans le venv), aucune nouvelle Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/run_session_cleaner.sh | 13 + tools/session_cleaner.py | 994 +++++++++++++++++++++++++++++++++++ 2 files changed, 1007 insertions(+) create mode 100755 tools/run_session_cleaner.sh create mode 100644 tools/session_cleaner.py diff --git a/tools/run_session_cleaner.sh b/tools/run_session_cleaner.sh new file mode 100755 index 000000000..e9226178d --- /dev/null +++ b/tools/run_session_cleaner.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Lancement rapide du Session Cleaner +# Usage : ./tools/run_session_cleaner.sh [--port 5006] [--debug] + +cd "$(dirname "$0")/.." +source .venv/bin/activate 2>/dev/null || true + +# Charger le token API depuis .env.local si present +if [ -f .env.local ]; then + export $(grep RPA_API_TOKEN .env.local 2>/dev/null | xargs) +fi + +python tools/session_cleaner.py "$@" diff --git a/tools/session_cleaner.py b/tools/session_cleaner.py new file mode 100644 index 000000000..6ebbf8122 --- /dev/null +++ b/tools/session_cleaner.py @@ -0,0 +1,994 @@ +#!/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 %} + +
DateMachineSession IDEvenementsAction
{{ 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#TypePositionFenetreTexte / TouchesScreenshot
+ + {{ 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()