replay_state enrichi de safety_checks, checks_acknowledged, pause_reason,
pause_payload (audit trail).
Branche supervisée pause_for_human :
- appel build_pause_payload() avant bascule paused_need_help
- log [BUS] lea:safety_checks_generated (count, sources)
- fallback safe sur exception (pause sans checks plutôt que crash)
- déclenchement si safety_level/safety_checks déclarés OU execution_mode != autonomous
- sinon comportement legacy (skip silencieux)
POST /replay/resume :
- accepte body { acknowledged_check_ids: [...] }
- vérifie tous les checks required acquittés, sinon 400 required_checks_missing
- stocke checks_acknowledged comme audit trail
- nettoie safety_checks/pause_payload après reprise
Proxy VWB /api/v3/replay/resume → streaming /replay/{id}/resume (forward bearer
token + acknowledged_check_ids).
Backward 100% : workflows sans safety_checks → resume sans acquittement requis.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1504 lines
55 KiB
Python
1504 lines
55 KiB
Python
# agent_v0/server_v1/replay_engine.py
|
|
"""
|
|
Replay Engine — Gestion des replays de workflows.
|
|
|
|
Contient :
|
|
- Setup environnement (préparation apps avant replay)
|
|
- Validation des actions de replay (sécurité)
|
|
- Conversion workflow → actions normalisées
|
|
- Fonctions utilitaires de replay (session detection, state management, retry)
|
|
- Pre-check écran par embedding CLIP
|
|
- Détection popup
|
|
|
|
Extrait de api_stream.py pour clarifier l'architecture.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger("api_stream")
|
|
|
|
|
|
# =========================================================================
|
|
# Validation des actions de replay (sécurité HIGH)
|
|
# =========================================================================
|
|
_ALLOWED_ACTION_TYPES = {
|
|
"click", "type", "key_combo", "scroll", "wait",
|
|
"file_open", "file_save", "file_close", "file_new", "file_dialog",
|
|
"double_click", "right_click", "drag",
|
|
"verify_screen", # Replay hybride : vérification visuelle entre groupes
|
|
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
|
|
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
|
|
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
|
|
}
|
|
|
|
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
|
|
# Le pipeline /replay/next les traite en boucle interne et passe à l'action
|
|
# suivante jusqu'à trouver une action visuelle (à transmettre au client).
|
|
_SERVER_SIDE_ACTION_TYPES = {"extract_text", "t2a_decision"}
|
|
_MAX_ACTION_TEXT_LENGTH = 10000
|
|
_MAX_KEYS_PER_COMBO = 10
|
|
# Touches autorisées dans les key_combo (modificateurs + touches spéciales + caractères simples)
|
|
_KNOWN_KEY_NAMES = {
|
|
"enter", "return", "tab", "escape", "esc", "backspace", "delete", "space",
|
|
"up", "down", "left", "right", "home", "end", "page_up", "page_down",
|
|
"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12",
|
|
"ctrl", "ctrl_l", "ctrl_r", "alt", "alt_l", "alt_r",
|
|
"shift", "shift_l", "shift_r",
|
|
"cmd", "win", "super", "super_l", "super_r", "windows", "meta",
|
|
"insert", "print_screen", "caps_lock", "num_lock",
|
|
}
|
|
|
|
|
|
def _validate_replay_action(action: dict) -> Optional[str]:
|
|
"""Valide une action de replay. Retourne un message d'erreur ou None si valide."""
|
|
action_type = action.get("type", "")
|
|
|
|
# Vérifier le type d'action
|
|
if action_type not in _ALLOWED_ACTION_TYPES:
|
|
return f"Type d'action non autorisé : '{action_type}'. Autorisés : {sorted(_ALLOWED_ACTION_TYPES)}"
|
|
|
|
# Vérifier la longueur du texte
|
|
text = action.get("text", "")
|
|
if isinstance(text, str) and len(text) > _MAX_ACTION_TEXT_LENGTH:
|
|
return f"Texte trop long ({len(text)} > {_MAX_ACTION_TEXT_LENGTH} caractères)"
|
|
|
|
# Vérifier les touches
|
|
keys = action.get("keys", [])
|
|
if isinstance(keys, list):
|
|
if len(keys) > _MAX_KEYS_PER_COMBO:
|
|
return f"Trop de touches ({len(keys)} > {_MAX_KEYS_PER_COMBO})"
|
|
for key in keys:
|
|
key_lower = str(key).lower()
|
|
# Accepter les caractères simples (a-z, 0-9, ponctuation) et les noms connus
|
|
if len(str(key)) == 1 or key_lower in _KNOWN_KEY_NAMES:
|
|
continue
|
|
return f"Touche inconnue : '{key}'"
|
|
|
|
# Vérifier les coordonnées normalisées
|
|
for coord_name in ("x_pct", "y_pct"):
|
|
val = action.get(coord_name)
|
|
if val is not None:
|
|
try:
|
|
val_f = float(val)
|
|
if not (0.0 <= val_f <= 1.0):
|
|
return f"Coordonnée {coord_name}={val_f} hors limites [0.0, 1.0]"
|
|
except (TypeError, ValueError):
|
|
return f"Coordonnée {coord_name} invalide : {val}"
|
|
|
|
return None # Valide
|
|
|
|
|
|
# =========================================================================
|
|
# Setup environnement — Préparation automatique avant le replay
|
|
# =========================================================================
|
|
# Mapping des noms d'exécutables Windows courants vers la commande de lancement.
|
|
# Utilisé comme fallback pour le texte de recherche dans le menu Démarrer.
|
|
# Le format est : "processname.exe" (minuscule) -> commande shell
|
|
_APP_LAUNCH_COMMANDS: Dict[str, str] = {
|
|
"notepad.exe": "notepad",
|
|
"explorer.exe": "explorer",
|
|
"calc.exe": "calc",
|
|
"mspaint.exe": "mspaint",
|
|
"cmd.exe": "cmd",
|
|
"powershell.exe": "powershell",
|
|
"wordpad.exe": "wordpad",
|
|
"charmap.exe": "charmap",
|
|
"snippingtool.exe": "snippingtool",
|
|
"taskmgr.exe": "taskmgr",
|
|
"regedit.exe": "regedit",
|
|
"mstsc.exe": "mstsc",
|
|
"winword.exe": "winword",
|
|
"excel.exe": "excel",
|
|
"powerpnt.exe": "powerpnt",
|
|
"outlook.exe": "outlook",
|
|
"msedge.exe": "msedge",
|
|
"chrome.exe": "chrome",
|
|
"firefox.exe": "firefox",
|
|
"code.exe": "code",
|
|
}
|
|
|
|
# Mapping des exécutables vers le nom visuel à chercher dans le menu Démarrer.
|
|
# Contient le texte de recherche (souvent le nom français) et une description
|
|
# pour le VLM afin d'identifier l'icône dans les résultats de recherche.
|
|
# Format : "processname.exe" -> {"search_text": ..., "display_name": ..., "vlm_description": ...}
|
|
_APP_VISUAL_SEARCH: Dict[str, Dict[str, str]] = {
|
|
"notepad.exe": {
|
|
"search_text": "Bloc-notes",
|
|
"display_name": "Bloc-notes",
|
|
"vlm_description": "L'application Bloc-notes (Notepad) dans les résultats de recherche",
|
|
},
|
|
"calc.exe": {
|
|
"search_text": "Calculatrice",
|
|
"display_name": "Calculatrice",
|
|
"vlm_description": "L'application Calculatrice dans les résultats de recherche",
|
|
},
|
|
"mspaint.exe": {
|
|
"search_text": "Paint",
|
|
"display_name": "Paint",
|
|
"vlm_description": "L'application Paint dans les résultats de recherche",
|
|
},
|
|
"cmd.exe": {
|
|
"search_text": "Invite de commandes",
|
|
"display_name": "Invite de commandes",
|
|
"vlm_description": "L'Invite de commandes (Command Prompt) dans les résultats",
|
|
},
|
|
"powershell.exe": {
|
|
"search_text": "PowerShell",
|
|
"display_name": "PowerShell",
|
|
"vlm_description": "Windows PowerShell dans les résultats de recherche",
|
|
},
|
|
"wordpad.exe": {
|
|
"search_text": "WordPad",
|
|
"display_name": "WordPad",
|
|
"vlm_description": "L'application WordPad dans les résultats de recherche",
|
|
},
|
|
"winword.exe": {
|
|
"search_text": "Word",
|
|
"display_name": "Microsoft Word",
|
|
"vlm_description": "Microsoft Word dans les résultats de recherche",
|
|
},
|
|
"excel.exe": {
|
|
"search_text": "Excel",
|
|
"display_name": "Microsoft Excel",
|
|
"vlm_description": "Microsoft Excel dans les résultats de recherche",
|
|
},
|
|
"powerpnt.exe": {
|
|
"search_text": "PowerPoint",
|
|
"display_name": "Microsoft PowerPoint",
|
|
"vlm_description": "Microsoft PowerPoint dans les résultats de recherche",
|
|
},
|
|
"outlook.exe": {
|
|
"search_text": "Outlook",
|
|
"display_name": "Microsoft Outlook",
|
|
"vlm_description": "Microsoft Outlook dans les résultats de recherche",
|
|
},
|
|
"msedge.exe": {
|
|
"search_text": "Edge",
|
|
"display_name": "Microsoft Edge",
|
|
"vlm_description": "Microsoft Edge dans les résultats de recherche",
|
|
},
|
|
"chrome.exe": {
|
|
"search_text": "Chrome",
|
|
"display_name": "Google Chrome",
|
|
"vlm_description": "Google Chrome dans les résultats de recherche",
|
|
},
|
|
"firefox.exe": {
|
|
"search_text": "Firefox",
|
|
"display_name": "Mozilla Firefox",
|
|
"vlm_description": "Mozilla Firefox dans les résultats de recherche",
|
|
},
|
|
"code.exe": {
|
|
"search_text": "Visual Studio Code",
|
|
"display_name": "Visual Studio Code",
|
|
"vlm_description": "Visual Studio Code dans les résultats de recherche",
|
|
},
|
|
"taskmgr.exe": {
|
|
"search_text": "Gestionnaire des tâches",
|
|
"display_name": "Gestionnaire des tâches",
|
|
"vlm_description": "Le Gestionnaire des tâches dans les résultats de recherche",
|
|
},
|
|
"snippingtool.exe": {
|
|
"search_text": "Outil Capture",
|
|
"display_name": "Outil Capture d'écran",
|
|
"vlm_description": "L'Outil Capture d'écran dans les résultats de recherche",
|
|
},
|
|
"mstsc.exe": {
|
|
"search_text": "Connexion Bureau à distance",
|
|
"display_name": "Bureau à distance",
|
|
"vlm_description": "La Connexion Bureau à distance dans les résultats",
|
|
},
|
|
}
|
|
|
|
# Applications Windows à ignorer pour le setup (processus système, agents, etc.)
|
|
_SETUP_IGNORE_APPS = {
|
|
"searchhost.exe", # Barre de recherche Windows
|
|
"explorer.exe", # Explorer est toujours lancé (shell Windows)
|
|
"pythonw.exe", # Agent Python (notre propre agent)
|
|
"python.exe", # Idem
|
|
"shellexperiencehost.exe",
|
|
"startmenuexperiencehost.exe",
|
|
"applicationframehost.exe",
|
|
"systemsettings.exe",
|
|
"textinputhost.exe",
|
|
"runtimebroker.exe",
|
|
}
|
|
|
|
|
|
def _extract_required_apps_from_events(raw_events: list) -> Dict[str, Any]:
|
|
"""Extraire les applications requises depuis les événements bruts d'une session.
|
|
|
|
Analyse les window_focus_change pour identifier :
|
|
- L'application principale (la plus utilisée hors apps système)
|
|
- La première fenêtre ciblée (pour le setup initial)
|
|
|
|
Args:
|
|
raw_events: Événements bruts depuis live_events.jsonl.
|
|
|
|
Returns:
|
|
Dict avec les clés :
|
|
- primary_app: str (nom de l'exécutable principal, ex: "Notepad.exe")
|
|
- primary_launch_cmd: str (commande Win+R, ex: "notepad")
|
|
- first_window_title: str (titre de la première fenêtre applicative)
|
|
- apps: dict[str, int] (app_name -> nombre d'occurrences)
|
|
"""
|
|
app_counts: Dict[str, int] = defaultdict(int)
|
|
first_app = None
|
|
first_window_title = None
|
|
|
|
for raw_evt in raw_events:
|
|
event_data = raw_evt.get("event", raw_evt)
|
|
evt_type = event_data.get("type", "")
|
|
|
|
if evt_type == "window_focus_change":
|
|
to_info = event_data.get("to", {})
|
|
if not to_info:
|
|
continue
|
|
app_name = to_info.get("app_name", "")
|
|
title = to_info.get("title", "")
|
|
if app_name:
|
|
app_counts[app_name] += 1
|
|
if first_app is None and app_name.lower() not in _SETUP_IGNORE_APPS:
|
|
first_app = app_name
|
|
first_window_title = title
|
|
|
|
# Aussi extraire depuis les mouse_click qui ont un champ window
|
|
elif evt_type == "mouse_click":
|
|
window = event_data.get("window", {})
|
|
if isinstance(window, dict):
|
|
app_name = window.get("app_name", "")
|
|
if app_name:
|
|
app_counts[app_name] += 1
|
|
|
|
if not app_counts:
|
|
return {}
|
|
|
|
# Déterminer l'application principale (la plus fréquente hors apps ignorées)
|
|
filtered_apps = {
|
|
k: v for k, v in app_counts.items()
|
|
if k.lower() not in _SETUP_IGNORE_APPS
|
|
}
|
|
if not filtered_apps:
|
|
return {}
|
|
|
|
primary_app = max(filtered_apps, key=filtered_apps.get)
|
|
|
|
# Résoudre la commande de lancement
|
|
primary_launch_cmd = _resolve_launch_command(primary_app)
|
|
|
|
return {
|
|
"primary_app": primary_app,
|
|
"primary_launch_cmd": primary_launch_cmd,
|
|
"first_window_title": first_window_title or "",
|
|
"apps": dict(app_counts),
|
|
}
|
|
|
|
|
|
def _extract_required_apps_from_workflow(workflow) -> Dict[str, Any]:
|
|
"""Extraire les applications requises depuis un workflow structuré.
|
|
|
|
Analyse les nodes du workflow pour identifier les titres de fenêtres
|
|
requis, puis infère l'application principale.
|
|
|
|
Args:
|
|
workflow: Objet Workflow ou dict brut.
|
|
|
|
Returns:
|
|
Même format que _extract_required_apps_from_events.
|
|
"""
|
|
# Accéder aux données (objet ou dict)
|
|
if hasattr(workflow, 'nodes'):
|
|
nodes = workflow.nodes
|
|
metadata = workflow.metadata if hasattr(workflow, 'metadata') else {}
|
|
elif isinstance(workflow, dict):
|
|
nodes = workflow.get('nodes', [])
|
|
metadata = workflow.get('metadata', {})
|
|
else:
|
|
return {}
|
|
|
|
if not nodes:
|
|
return {}
|
|
|
|
# Collecter les titres de fenêtres depuis les nodes
|
|
window_titles = []
|
|
for node in nodes:
|
|
template = node.template if hasattr(node, 'template') else node.get('template', {})
|
|
if isinstance(template, dict):
|
|
window = template.get('window', {})
|
|
elif hasattr(template, 'window'):
|
|
window = template.window if hasattr(template.window, '__dict__') else {}
|
|
else:
|
|
window = {}
|
|
|
|
if isinstance(window, dict):
|
|
title = window.get('title_pattern', '') or window.get('title_contains', '')
|
|
elif hasattr(window, 'title_pattern'):
|
|
title = getattr(window, 'title_pattern', '') or ''
|
|
else:
|
|
title = ''
|
|
|
|
if title:
|
|
window_titles.append(title)
|
|
|
|
# Inférer l'app principale depuis les titres de fenêtres
|
|
primary_app, primary_launch_cmd, matched_title = _infer_app_from_window_titles(window_titles)
|
|
# Utiliser le titre qui a matché l'app (pas le premier node qui peut être "Rechercher")
|
|
first_title = matched_title or (window_titles[0] if window_titles else "")
|
|
|
|
if not primary_app:
|
|
return {}
|
|
|
|
source_session_id = metadata.get("source_session_id", "") if isinstance(metadata, dict) else ""
|
|
machine_id = metadata.get("machine_id", "") if isinstance(metadata, dict) else ""
|
|
|
|
return {
|
|
"primary_app": primary_app,
|
|
"primary_launch_cmd": primary_launch_cmd,
|
|
"first_window_title": first_title,
|
|
"apps": {},
|
|
"source_session_id": source_session_id,
|
|
"machine_id": machine_id,
|
|
}
|
|
|
|
|
|
def _resolve_launch_command(app_name: str) -> str:
|
|
"""Résoudre la commande Win+R pour lancer une application.
|
|
|
|
Si l'app n'est pas dans le mapping, utilise le nom de l'exécutable
|
|
directement sans l'extension .exe (fonctionne pour la plupart des apps).
|
|
"""
|
|
app_lower = app_name.lower()
|
|
if app_lower in _APP_LAUNCH_COMMANDS:
|
|
return _APP_LAUNCH_COMMANDS[app_lower]
|
|
# Fallback : utiliser le nom sans l'extension .exe
|
|
if app_lower.endswith(".exe"):
|
|
return app_name[:-4]
|
|
return app_name
|
|
|
|
|
|
def _infer_app_from_window_titles(titles: list) -> tuple:
|
|
"""Inférer le nom de l'application et la commande de lancement depuis des titres de fenêtres.
|
|
|
|
Utilise des heuristiques basées sur les patterns de titres Windows courants.
|
|
|
|
Returns:
|
|
Tuple (app_name, launch_command, matched_title).
|
|
("", "", "") si non identifié.
|
|
"""
|
|
_TITLE_APP_PATTERNS = [
|
|
("bloc-notes", "Notepad.exe", "notepad"),
|
|
("notepad", "Notepad.exe", "notepad"),
|
|
("word", "winword.exe", "winword"),
|
|
("excel", "excel.exe", "excel"),
|
|
("powerpoint", "powerpnt.exe", "powerpnt"),
|
|
("outlook", "outlook.exe", "outlook"),
|
|
("paint", "mspaint.exe", "mspaint"),
|
|
("calculatrice", "calc.exe", "calc"),
|
|
("calculator", "calc.exe", "calc"),
|
|
("explorateur de fichiers", "explorer.exe", "explorer"),
|
|
("file explorer", "explorer.exe", "explorer"),
|
|
("invite de commandes", "cmd.exe", "cmd"),
|
|
("command prompt", "cmd.exe", "cmd"),
|
|
("powershell", "powershell.exe", "powershell"),
|
|
("visual studio code", "code.exe", "code"),
|
|
("edge", "msedge.exe", "msedge"),
|
|
("chrome", "chrome.exe", "chrome"),
|
|
("firefox", "firefox.exe", "firefox"),
|
|
]
|
|
|
|
for title in titles:
|
|
title_lower = title.lower()
|
|
for pattern, app_name, launch_cmd in _TITLE_APP_PATTERNS:
|
|
if pattern in title_lower:
|
|
# Ignorer les apps système (explorer, etc.)
|
|
if app_name.lower() in _SETUP_IGNORE_APPS:
|
|
continue
|
|
return (app_name, launch_cmd, title)
|
|
|
|
return ("", "", "")
|
|
|
|
|
|
def _get_visual_search_info(app_name: str) -> Dict[str, str]:
|
|
"""Obtenir les informations de recherche visuelle pour une application.
|
|
|
|
Consulte _APP_VISUAL_SEARCH, sinon construit un fallback à partir du nom
|
|
de l'exécutable (ex: "MonApp.exe" -> search_text="MonApp").
|
|
|
|
Args:
|
|
app_name: Nom de l'exécutable (ex: "Notepad.exe").
|
|
|
|
Returns:
|
|
Dict avec search_text, display_name, vlm_description.
|
|
"""
|
|
app_lower = app_name.lower()
|
|
if app_lower in _APP_VISUAL_SEARCH:
|
|
return dict(_APP_VISUAL_SEARCH[app_lower])
|
|
|
|
# Fallback : utiliser le nom sans .exe
|
|
base_name = app_name[:-4] if app_lower.endswith(".exe") else app_name
|
|
return {
|
|
"search_text": base_name,
|
|
"display_name": base_name,
|
|
"vlm_description": f"L'application {base_name} dans les résultats de recherche",
|
|
}
|
|
|
|
|
|
def _generate_setup_actions(
|
|
app_info: Dict[str, Any],
|
|
setup_id_prefix: str = "setup",
|
|
) -> List[Dict[str, Any]]:
|
|
"""Générer les actions 100% visuelles pour ouvrir l'application avant le replay.
|
|
|
|
Approche entièrement visuelle -- JAMAIS de raccourcis clavier (Win, Win+R,
|
|
Ctrl+X, etc.) qui n'ont pas été enregistrés par l'utilisateur. Tout passe
|
|
par des clics visuels résolus par le VLM (Qwen2.5-VL).
|
|
|
|
La séquence est :
|
|
1. Clic visuel sur le bouton Démarrer (coin bas-gauche de l'écran)
|
|
2. Attendre que le menu Démarrer s'ouvre (1s)
|
|
3. Clic visuel sur la barre de recherche du menu Démarrer
|
|
4. Attendre que la barre de recherche soit active (500ms)
|
|
5. Taper le nom de l'application (texte français, ex: "Bloc-notes")
|
|
6. Attendre les résultats de recherche (1.2s)
|
|
7. Clic visuel sur le résultat de l'application trouvée
|
|
8. Attendre que l'application s'ouvre (2-3s selon le poids)
|
|
9. verify_screen : vérifier que la fenêtre attendue est apparue
|
|
|
|
Args:
|
|
app_info: Dict retourné par _extract_required_apps_from_events ou
|
|
_extract_required_apps_from_workflow.
|
|
setup_id_prefix: Préfixe pour les action_id générés.
|
|
|
|
Returns:
|
|
Liste d'actions normalisées, prêtes à injecter dans la queue.
|
|
Liste vide si aucune préparation n'est nécessaire.
|
|
"""
|
|
if not app_info:
|
|
return []
|
|
|
|
launch_cmd = app_info.get("primary_launch_cmd", "")
|
|
primary_app = app_info.get("primary_app", "")
|
|
first_title = app_info.get("first_window_title", "")
|
|
|
|
if not launch_cmd:
|
|
logger.debug(
|
|
"setup_actions : pas de commande de lancement pour '%s', skip",
|
|
primary_app,
|
|
)
|
|
return []
|
|
|
|
# Ne pas lancer les apps système (toujours présentes)
|
|
if primary_app.lower() in _SETUP_IGNORE_APPS:
|
|
logger.debug("setup_actions : app '%s' ignorée (système)", primary_app)
|
|
return []
|
|
|
|
# Obtenir les informations de recherche visuelle pour cette app
|
|
visual_info = _get_visual_search_info(primary_app)
|
|
search_text = visual_info["search_text"]
|
|
display_name = visual_info["display_name"]
|
|
vlm_description = visual_info["vlm_description"]
|
|
|
|
actions = []
|
|
|
|
logger.info(
|
|
"Génération setup env 100%% visuel : lancement de '%s' via clic "
|
|
"Démarrer → recherche visuelle '%s' (fenêtre attendue : '%s')",
|
|
primary_app, search_text, first_title,
|
|
)
|
|
|
|
# 1. Clic visuel sur le bouton Démarrer (toujours visible, bas-gauche)
|
|
# Le VLM résout la position exacte ; x_pct/y_pct sont des fallbacks.
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_click_start",
|
|
"type": "click",
|
|
"x_pct": 0.02,
|
|
"y_pct": 0.98,
|
|
"button": "left",
|
|
"visual_mode": True,
|
|
"target_spec": {
|
|
"by_text": "Démarrer",
|
|
"by_role": "start_button",
|
|
"vlm_description": (
|
|
"Le bouton Démarrer de Windows (icône Windows), "
|
|
"en bas à gauche de la barre des tâches"
|
|
),
|
|
},
|
|
"_setup_phase": True,
|
|
"_setup_step": "click_start_menu",
|
|
})
|
|
|
|
# 2. Attendre que le menu Démarrer s'ouvre
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_wait_start",
|
|
"type": "wait",
|
|
"duration_ms": 1000,
|
|
"_setup_phase": True,
|
|
"_setup_step": "wait_start_menu",
|
|
})
|
|
|
|
# 3. Clic visuel sur la barre de recherche du menu Démarrer
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_click_search",
|
|
"type": "click",
|
|
"x_pct": 0.20,
|
|
"y_pct": 0.92,
|
|
"button": "left",
|
|
"visual_mode": True,
|
|
"target_spec": {
|
|
"by_text": "Rechercher",
|
|
"by_role": "search_box",
|
|
"vlm_description": (
|
|
"La barre ou le champ de recherche dans le menu Démarrer "
|
|
"de Windows, souvent intitulé 'Tapez ici pour rechercher' "
|
|
"ou 'Rechercher'"
|
|
),
|
|
},
|
|
"_setup_phase": True,
|
|
"_setup_step": "click_search_box",
|
|
})
|
|
|
|
# 4. Attendre que la barre de recherche soit active et prête
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_wait_search_ready",
|
|
"type": "wait",
|
|
"duration_ms": 500,
|
|
"_setup_phase": True,
|
|
"_setup_step": "wait_search_ready",
|
|
})
|
|
|
|
# 5. Taper le nom visuel de l'application (texte français)
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_type_search",
|
|
"type": "type",
|
|
"text": search_text,
|
|
"_setup_phase": True,
|
|
"_setup_step": "type_app_name",
|
|
})
|
|
|
|
# 6. Attendre que la recherche Windows trouve l'application
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_wait_results",
|
|
"type": "wait",
|
|
"duration_ms": 1200,
|
|
"_setup_phase": True,
|
|
"_setup_step": "wait_search_results",
|
|
})
|
|
|
|
# 7. Clic visuel sur le résultat de l'application dans la liste
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_click_result",
|
|
"type": "click",
|
|
"x_pct": 0.20,
|
|
"y_pct": 0.50,
|
|
"button": "left",
|
|
"visual_mode": True,
|
|
"target_spec": {
|
|
"by_text": display_name,
|
|
"by_role": "app_icon",
|
|
"vlm_description": vlm_description,
|
|
},
|
|
"_setup_phase": True,
|
|
"_setup_step": "click_app_result",
|
|
})
|
|
|
|
# 8. Attendre que l'application s'ouvre
|
|
heavy_apps = {"winword.exe", "excel.exe", "powerpnt.exe", "outlook.exe", "code.exe"}
|
|
wait_ms = 3000 if primary_app.lower() in heavy_apps else 2000
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_wait_launch",
|
|
"type": "wait",
|
|
"duration_ms": wait_ms,
|
|
"_setup_phase": True,
|
|
"_setup_step": "wait_app_launch",
|
|
})
|
|
|
|
# 9. Vérification visuelle que la fenêtre attendue est apparue
|
|
if first_title:
|
|
actions.append({
|
|
"action_id": f"act_{setup_id_prefix}_verify",
|
|
"type": "verify_screen",
|
|
"expected_node": "setup_initial",
|
|
"timeout_ms": 5000,
|
|
"_setup_phase": True,
|
|
"_setup_step": "verify_app_ready",
|
|
"_expected_title": first_title,
|
|
})
|
|
|
|
logger.info(
|
|
"Setup env visuel généré : %d actions pour lancer '%s' "
|
|
"(recherche visuelle : '%s')",
|
|
len(actions), primary_app, search_text,
|
|
)
|
|
|
|
return actions
|
|
|
|
|
|
# =========================================================================
|
|
# Replay — Fonctions de conversion workflow → actions
|
|
# =========================================================================
|
|
|
|
def _find_active_agent_session(session_manager, machine_id: Optional[str] = None) -> Optional[str]:
|
|
"""Trouver la dernière session Agent V1 pour le replay.
|
|
|
|
Stratégie en 2 passes :
|
|
1. D'abord chercher une session non-finalisée (Agent V1 actif)
|
|
2. Sinon, prendre la plus récente même finalisée
|
|
|
|
Args:
|
|
session_manager: Instance LiveSessionManager.
|
|
machine_id: Si fourni, ne chercher que les sessions de cette machine.
|
|
"""
|
|
with session_manager._lock:
|
|
all_agent_sessions = [
|
|
s for s in session_manager._sessions.values()
|
|
if s.session_id.startswith("sess_")
|
|
and (machine_id is None or s.machine_id == machine_id)
|
|
]
|
|
|
|
if not all_agent_sessions:
|
|
return None
|
|
|
|
# Trier par session_id (contient un timestamp) — plus récent d'abord
|
|
all_agent_sessions.sort(key=lambda s: s.session_id, reverse=True)
|
|
|
|
# Passe 1 : préférer une session non-finalisée
|
|
for s in all_agent_sessions:
|
|
if not s.finalized:
|
|
return s.session_id
|
|
|
|
# Passe 2 : fallback sur la plus récente (même finalisée)
|
|
return all_agent_sessions[0].session_id
|
|
|
|
|
|
def _workflow_to_actions(
|
|
workflow,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
processor=None,
|
|
gesture_catalog=None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Convertir un workflow (nodes + edges ordonnés) en liste d'actions normalisées.
|
|
|
|
Parcourt le graphe depuis les entry_nodes en suivant les edges.
|
|
Chaque edge produit une action normalisée avec coordonnées en pourcentage.
|
|
|
|
Mode intelligent (workflows appris par Léa) :
|
|
Si le workflow a des nodes avec des prototype_vectors, utilise le
|
|
StreamProcessor.extract_enriched_actions() qui enrichit les actions
|
|
avec les données de la session originale, le ciblage visuel et le
|
|
pre-check/post-check par embedding CLIP.
|
|
|
|
Mode classique (workflows VWB/manuels) :
|
|
Parcours BFS classique avec _edge_to_normalized_actions().
|
|
"""
|
|
params = params or {}
|
|
|
|
# Détection d'un workflow appris (a des nodes avec prototype_vectors)
|
|
# et qui a des edges structurés
|
|
if _is_learned_workflow(workflow) and processor is not None:
|
|
# Priorité 1 : replay hybride (événements bruts + structure workflow)
|
|
hybrid = processor.build_hybrid_replay(workflow)
|
|
if hybrid:
|
|
logger.info(
|
|
"Replay hybride : %d actions depuis events bruts + structure workflow",
|
|
len(hybrid),
|
|
)
|
|
# Optimisation par gestes clavier si disponible
|
|
if gesture_catalog and hybrid:
|
|
hybrid = gesture_catalog.optimize_replay_actions(hybrid)
|
|
return hybrid
|
|
|
|
# Priorité 2 : enrichissement classique (fallback si hybride échoue)
|
|
enriched = processor.extract_enriched_actions(workflow, params)
|
|
if enriched:
|
|
logger.info(
|
|
"Replay intelligent : %d actions enrichies depuis le workflow appris",
|
|
len(enriched),
|
|
)
|
|
if gesture_catalog and enriched:
|
|
enriched = gesture_catalog.optimize_replay_actions(enriched)
|
|
return enriched
|
|
# Si l'enrichissement échoue aussi, fallback sur le mode classique
|
|
logger.warning(
|
|
"Enrichissement échoué pour le workflow appris, fallback mode classique"
|
|
)
|
|
|
|
# Mode classique (VWB/manuels ou fallback)
|
|
actions = []
|
|
|
|
# Construire un index des edges sortants par node
|
|
outgoing: Dict[str, list] = defaultdict(list)
|
|
for edge in workflow.edges:
|
|
outgoing[edge.from_node].append(edge)
|
|
|
|
# Parcours linéaire depuis le premier entry_node
|
|
visited = set()
|
|
current_nodes = list(workflow.entry_nodes) if workflow.entry_nodes else []
|
|
|
|
# Fallback : si pas d'entry_nodes, prendre le premier node
|
|
if not current_nodes and workflow.nodes:
|
|
current_nodes = [workflow.nodes[0].node_id]
|
|
|
|
while current_nodes:
|
|
node_id = current_nodes.pop(0)
|
|
if node_id in visited:
|
|
continue
|
|
visited.add(node_id)
|
|
|
|
edges = outgoing.get(node_id, [])
|
|
for edge in edges:
|
|
edge_actions = _edge_to_normalized_actions(edge, params)
|
|
actions.extend(edge_actions)
|
|
# Suivre le graphe vers le prochain node
|
|
if edge.to_node not in visited:
|
|
current_nodes.append(edge.to_node)
|
|
|
|
# Optimisation : substituer les actions visuelles par des gestes clavier si possible
|
|
if gesture_catalog and actions:
|
|
actions = gesture_catalog.optimize_replay_actions(actions)
|
|
|
|
return actions
|
|
|
|
|
|
def _is_learned_workflow(workflow) -> bool:
|
|
"""Détecter si un workflow est un workflow appris (vs VWB/manuel).
|
|
|
|
Un workflow appris a :
|
|
- Des nodes avec _prototype_vector dans metadata
|
|
- Des edges avec from_node/to_node
|
|
- Un learning_state indicatif (OBSERVATION, COACHING, AUTO_CANDIDATE, etc.)
|
|
|
|
Un workflow VWB/manuel a généralement :
|
|
- Des edges avec des target_spec complets (by_text, by_role remplis)
|
|
- Pas de prototype_vectors
|
|
"""
|
|
# Accéder aux données (objet ou dict)
|
|
if hasattr(workflow, 'nodes'):
|
|
nodes = workflow.nodes
|
|
edges = workflow.edges
|
|
elif isinstance(workflow, dict):
|
|
nodes = workflow.get('nodes', [])
|
|
edges = workflow.get('edges', [])
|
|
else:
|
|
return False
|
|
|
|
if not nodes or not edges:
|
|
return False
|
|
|
|
# Vérifier si au moins un node a un prototype_vector
|
|
has_prototype = False
|
|
for node in nodes:
|
|
metadata = node.metadata if hasattr(node, 'metadata') else node.get('metadata', {})
|
|
if isinstance(metadata, dict) and '_prototype_vector' in metadata:
|
|
has_prototype = True
|
|
break
|
|
|
|
return has_prototype
|
|
|
|
|
|
def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""
|
|
Convertir un WorkflowEdge en liste d'actions normalisées pour l'Agent V1.
|
|
|
|
Un edge simple produit 1 action, un edge compound produit N actions (une par step).
|
|
"""
|
|
action = edge.action
|
|
if action is None:
|
|
logger.warning(f"Edge {edge.edge_id} sans action, skip")
|
|
return []
|
|
action_type = action.type
|
|
target = action.target
|
|
action_params = action.parameters or {}
|
|
|
|
# Extraire les coordonnées normalisées depuis TargetSpec.by_position
|
|
x_pct = 0.0
|
|
y_pct = 0.0
|
|
if target and target.by_position:
|
|
px, py = target.by_position
|
|
if px <= 1.0 and py <= 1.0:
|
|
x_pct = px
|
|
y_pct = py
|
|
else:
|
|
ref_w = action_params.get("ref_width", 1920) or 1920
|
|
ref_h = action_params.get("ref_height", 1080) or 1080
|
|
x_pct = round(px / ref_w, 6)
|
|
y_pct = round(py / ref_h, 6)
|
|
|
|
base = {"edge_id": edge.edge_id, "from_node": edge.from_node, "to_node": edge.to_node}
|
|
|
|
# Compound : décomposer en actions individuelles
|
|
if action_type == "compound":
|
|
return _expand_compound_steps(action_params.get("steps", []), base, params)
|
|
|
|
# Actions simples
|
|
normalized = {**base, "action_id": f"act_{uuid.uuid4().hex[:8]}"}
|
|
|
|
if action_type == "mouse_click":
|
|
normalized["type"] = "click"
|
|
normalized["x_pct"] = x_pct
|
|
normalized["y_pct"] = y_pct
|
|
normalized["button"] = action_params.get("button", "left")
|
|
|
|
elif action_type == "text_input":
|
|
normalized["type"] = "type"
|
|
text = action_params.get("text", "")
|
|
text = _substitute_variables(text, params, action_params.get("defaults", {}))
|
|
normalized["text"] = text
|
|
normalized["x_pct"] = x_pct
|
|
normalized["y_pct"] = y_pct
|
|
|
|
elif action_type == "key_press":
|
|
normalized["type"] = "key_combo"
|
|
keys = action_params.get("keys", [])
|
|
if not keys and action_params.get("key"):
|
|
keys = [action_params["key"]]
|
|
normalized["keys"] = keys
|
|
|
|
elif action_type == "pause_for_human":
|
|
normalized["type"] = "pause_for_human"
|
|
normalized["parameters"] = {
|
|
"message": action_params.get("message", "Validation requise"),
|
|
}
|
|
return [normalized] # pas de target/coords pour cette action logique
|
|
|
|
elif action_type == "extract_text":
|
|
normalized["type"] = "extract_text"
|
|
normalized["parameters"] = {
|
|
"output_var": action_params.get("output_var", "extracted_text"),
|
|
"paragraph": bool(action_params.get("paragraph", True)),
|
|
}
|
|
return [normalized]
|
|
|
|
elif action_type == "t2a_decision":
|
|
normalized["type"] = "t2a_decision"
|
|
normalized["parameters"] = {
|
|
"input_template": action_params.get("input_template", ""),
|
|
"output_var": action_params.get("output_var", "t2a_result"),
|
|
"model": action_params.get("model"),
|
|
}
|
|
return [normalized]
|
|
|
|
else:
|
|
logger.warning(f"Type d'action inconnu : {action_type}")
|
|
return []
|
|
|
|
# Ajouter le target_spec complet pour la résolution visuelle
|
|
target_spec = {}
|
|
if target and target.by_role:
|
|
target_spec["by_role"] = target.by_role
|
|
normalized["target_role"] = target.by_role # Compat debug
|
|
if target and target.by_text:
|
|
target_spec["by_text"] = target.by_text
|
|
normalized["target_text"] = target.by_text # Compat debug
|
|
if target and hasattr(target, 'context_hints') and target.context_hints:
|
|
target_spec["context_hints"] = target.context_hints
|
|
if target_spec:
|
|
normalized["target_spec"] = target_spec
|
|
normalized["visual_mode"] = True # Signal à l'agent d'utiliser la résolution visuelle
|
|
|
|
return [normalized]
|
|
|
|
|
|
def _substitute_variables(text: str, params: Dict[str, Any], defaults: Dict[str, Any]) -> str:
|
|
"""Substituer les variables ${var} dans un texte.
|
|
|
|
Priorité : params utilisateur > defaults du workflow > texte brut inchangé.
|
|
Supporte ${var} dans un texte plus long (ex: "${expression}=").
|
|
"""
|
|
def replacer(match):
|
|
var_name = match.group(1)
|
|
return str(params.get(var_name, defaults.get(var_name, match.group(0))))
|
|
|
|
return re.sub(r'\$\{(\w+)\}', replacer, text)
|
|
|
|
|
|
# Regex pour le templating runtime : {{var}} ou {{var.champ}} ou {{var.champ.sous}}
|
|
_RUNTIME_VAR_PATTERN = re.compile(r'\{\{\s*(\w+)(?:\.([\w.]+))?\s*\}\}')
|
|
|
|
|
|
def _resolve_runtime_vars_in_str(text: str, variables: Dict[str, Any]) -> str:
|
|
"""Remplace {{var}} et {{var.field}} par leur valeur depuis le dict variables.
|
|
|
|
Variables/champs absents : laissés tels quels (ne casse pas le pipeline).
|
|
Pour les valeurs non-str (dict, list), str() est appelé.
|
|
"""
|
|
def replacer(match):
|
|
var_name = match.group(1)
|
|
path = match.group(2)
|
|
if var_name not in variables:
|
|
return match.group(0)
|
|
value = variables[var_name]
|
|
if path:
|
|
for field in path.split('.'):
|
|
if isinstance(value, dict) and field in value:
|
|
value = value[field]
|
|
else:
|
|
return match.group(0)
|
|
return str(value)
|
|
|
|
return _RUNTIME_VAR_PATTERN.sub(replacer, text)
|
|
|
|
|
|
def _resolve_runtime_vars(value: Any, variables: Dict[str, Any]) -> Any:
|
|
"""Résout récursivement les {{var}} et {{var.field}} dans une valeur.
|
|
|
|
Supporte str, dict, list. Les autres types sont retournés tels quels.
|
|
Si variables est vide ou None, value est retournée inchangée.
|
|
"""
|
|
if not variables:
|
|
return value
|
|
if isinstance(value, str):
|
|
return _resolve_runtime_vars_in_str(value, variables)
|
|
if isinstance(value, dict):
|
|
return {k: _resolve_runtime_vars(v, variables) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [_resolve_runtime_vars(item, variables) for item in value]
|
|
return value
|
|
|
|
|
|
# =========================================================================
|
|
# Handlers pour les actions exécutées côté serveur (extract_text, t2a_decision)
|
|
# =========================================================================
|
|
|
|
def _handle_extract_text_action(
|
|
action: Dict[str, Any],
|
|
replay_state: Dict[str, Any],
|
|
session_id: str,
|
|
last_heartbeat: Dict[str, Dict[str, Any]],
|
|
) -> bool:
|
|
"""Traite une action extract_text côté serveur. Stocke le texte OCRisé dans
|
|
replay_state["variables"][output_var]. Retourne True si succès.
|
|
|
|
Robuste aux échecs : si pas de heartbeat ou OCR raté, stocke "" et retourne
|
|
False (le pipeline continue, pas de blocage).
|
|
"""
|
|
params = action.get("parameters") or {}
|
|
output_var = (params.get("output_var") or "extracted_text").strip()
|
|
paragraph = bool(params.get("paragraph", True))
|
|
|
|
heartbeat = last_heartbeat.get(session_id) or {}
|
|
path = heartbeat.get("path")
|
|
text = ""
|
|
|
|
if path:
|
|
try:
|
|
from core.llm import extract_text_from_image
|
|
text = extract_text_from_image(path, paragraph=paragraph)
|
|
except Exception as e:
|
|
logger.warning("extract_text OCR échoué (%s) — variable '%s' = ''", e, output_var)
|
|
else:
|
|
logger.warning(
|
|
"extract_text : pas de heartbeat pour session %s — variable '%s' = ''",
|
|
session_id, output_var,
|
|
)
|
|
|
|
replay_state.setdefault("variables", {})[output_var] = text
|
|
logger.info(
|
|
"extract_text → variable '%s' (%d chars) replay %s",
|
|
output_var, len(text), replay_state.get("replay_id", "?"),
|
|
)
|
|
return bool(text)
|
|
|
|
|
|
def _handle_t2a_decision_action(
|
|
action: Dict[str, Any],
|
|
replay_state: Dict[str, Any],
|
|
) -> bool:
|
|
"""Traite une action t2a_decision côté serveur. Stocke le résultat JSON
|
|
dans replay_state["variables"][output_var]. Retourne True si succès.
|
|
|
|
Le DPI à analyser vient de action.parameters.input_template (déjà résolu
|
|
par _resolve_runtime_vars donc les {{var}} sont remplis).
|
|
"""
|
|
params = action.get("parameters") or {}
|
|
output_var = (params.get("output_var") or "t2a_result").strip()
|
|
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
|
|
model = params.get("model") or None # None → DEFAULT_MODEL
|
|
|
|
if not dpi_text:
|
|
logger.warning(
|
|
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
|
|
)
|
|
replay_state.setdefault("variables", {})[output_var] = {
|
|
"decision": "INDETERMINE",
|
|
"justification": "DPI vide ou non extrait",
|
|
"confiance": "faible",
|
|
"_error": "empty_input",
|
|
}
|
|
return False
|
|
|
|
try:
|
|
from core.llm import analyze_dpi, DEFAULT_MODEL
|
|
result = analyze_dpi(dpi_text, model=model or DEFAULT_MODEL)
|
|
except Exception as e:
|
|
logger.warning("t2a_decision : analyze_dpi exception %s", e)
|
|
result = {
|
|
"decision": "INDETERMINE",
|
|
"justification": f"Erreur analyse : {e}",
|
|
"confiance": "faible",
|
|
"_error": str(e),
|
|
}
|
|
|
|
replay_state.setdefault("variables", {})[output_var] = result
|
|
decision = result.get("decision", "?")
|
|
elapsed = result.get("_elapsed_s", "?")
|
|
logger.info(
|
|
"t2a_decision → variable '%s' decision=%s (%ss) replay %s",
|
|
output_var, decision, elapsed, replay_state.get("replay_id", "?"),
|
|
)
|
|
return "_error" not in result
|
|
|
|
|
|
def _expand_compound_steps(
|
|
steps: List[Dict[str, Any]], base: Dict[str, Any], params: Dict[str, Any]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Décomposer les steps d'un compound en actions individuelles."""
|
|
actions = []
|
|
for step in steps:
|
|
step_type = step.get("type", "unknown")
|
|
action = {
|
|
**base,
|
|
"action_id": f"act_{uuid.uuid4().hex[:8]}",
|
|
}
|
|
|
|
if step_type == "key_press":
|
|
action["type"] = "key_combo"
|
|
keys = step.get("keys", [])
|
|
if not keys and step.get("key"):
|
|
keys = [step["key"]]
|
|
action["keys"] = keys
|
|
|
|
elif step_type == "text_input":
|
|
action["type"] = "type"
|
|
text = step.get("text", "")
|
|
text = _substitute_variables(text, params, {})
|
|
action["text"] = text
|
|
|
|
elif step_type == "wait":
|
|
action["type"] = "wait"
|
|
action["duration_ms"] = step.get("duration_ms", 500)
|
|
|
|
elif step_type == "mouse_click":
|
|
action["type"] = "click"
|
|
action["x_pct"] = step.get("x_pct", 0.0)
|
|
action["y_pct"] = step.get("y_pct", 0.0)
|
|
action["button"] = step.get("button", "left")
|
|
|
|
else:
|
|
logger.debug(f"Step compound inconnu : {step_type}")
|
|
continue
|
|
|
|
actions.append(action)
|
|
|
|
return actions
|
|
|
|
|
|
# =========================================================================
|
|
# Pre-check écran — Vérification pré-action par embedding CLIP
|
|
# =========================================================================
|
|
|
|
|
|
def _pre_check_screen_state(
|
|
session_id: str,
|
|
expected_node_id: str,
|
|
current_screenshot_path: str,
|
|
active_processor,
|
|
replay_states: Dict[str, Dict[str, Any]],
|
|
replay_lock: threading.Lock,
|
|
precheck_threshold: float = 0.85,
|
|
) -> Dict[str, Any]:
|
|
"""Vérifier que l'écran actuel correspond à l'état attendu du node.
|
|
|
|
Compare le screenshot actuel avec le prototype du node attendu
|
|
via similarité d'embedding CLIP (rapide, ~200ms).
|
|
|
|
Args:
|
|
session_id: ID de la session de replay
|
|
expected_node_id: ID du node source de l'action (from_node)
|
|
current_screenshot_path: Chemin du screenshot heartbeat récent
|
|
active_processor: Instance StreamProcessor avec le CLIPEmbedder chargé
|
|
replay_states: Dict partagé des états de replay
|
|
replay_lock: Lock pour l'accès concurrent aux replay_states
|
|
precheck_threshold: Seuil de similarité cosine
|
|
|
|
Returns:
|
|
{"match": True/False, "similarity": float, "expected_node": str,
|
|
"reason": str (si mismatch), "popup_detected": bool}
|
|
"""
|
|
result: Dict[str, Any] = {
|
|
"match": True,
|
|
"similarity": 1.0,
|
|
"expected_node": expected_node_id,
|
|
"popup_detected": False,
|
|
}
|
|
|
|
try:
|
|
# 1. Trouver le workflow actif pour cette session
|
|
replay_state = None
|
|
workflow = None
|
|
with replay_lock:
|
|
for state in replay_states.values():
|
|
if state["session_id"] == session_id and state["status"] == "running":
|
|
replay_state = state
|
|
break
|
|
|
|
if not replay_state:
|
|
result["reason"] = "no_active_replay"
|
|
return result
|
|
|
|
workflow_id = replay_state.get("workflow_id", "")
|
|
with active_processor._data_lock:
|
|
workflow = active_processor._workflows.get(workflow_id)
|
|
|
|
if workflow is None:
|
|
result["reason"] = "workflow_not_found"
|
|
return result
|
|
|
|
# 2. Récupérer le prototype du node attendu
|
|
# Supporter à la fois les objets Workflow et les dicts bruts
|
|
node = None
|
|
if hasattr(workflow, "get_node"):
|
|
node = workflow.get_node(expected_node_id)
|
|
elif isinstance(workflow, dict):
|
|
# Format dict brut (workflows VWB/manuels)
|
|
for n in workflow.get("nodes", []):
|
|
if n.get("node_id") == expected_node_id:
|
|
node = n
|
|
break
|
|
|
|
if node is None:
|
|
result["reason"] = "node_not_found"
|
|
return result
|
|
|
|
# Extraire le prototype vector
|
|
metadata = node.metadata if hasattr(node, "metadata") else node.get("metadata", {})
|
|
proto_list = metadata.get("_prototype_vector")
|
|
if not proto_list or not isinstance(proto_list, (list, tuple)):
|
|
result["reason"] = "no_prototype_vector"
|
|
return result
|
|
|
|
import numpy as np
|
|
prototype_vector = np.array(proto_list, dtype=np.float32)
|
|
|
|
# 3. Calculer l'embedding CLIP du screenshot actuel
|
|
active_processor._ensure_initialized()
|
|
if active_processor._clip_embedder is None:
|
|
result["reason"] = "clip_embedder_unavailable"
|
|
return result
|
|
|
|
from PIL import Image
|
|
pil_image = Image.open(current_screenshot_path)
|
|
current_vector = active_processor._clip_embedder.embed_image(pil_image)
|
|
|
|
if current_vector is None or len(current_vector) == 0:
|
|
result["reason"] = "embedding_failed"
|
|
return result
|
|
|
|
# 4. Similarité cosine
|
|
current_vector = current_vector.flatten().astype(np.float32)
|
|
prototype_vector = prototype_vector.flatten().astype(np.float32)
|
|
|
|
norm_current = np.linalg.norm(current_vector)
|
|
norm_proto = np.linalg.norm(prototype_vector)
|
|
if norm_current < 1e-8 or norm_proto < 1e-8:
|
|
result["reason"] = "zero_norm_vector"
|
|
result["match"] = False
|
|
result["similarity"] = 0.0
|
|
return result
|
|
|
|
similarity = float(
|
|
np.dot(current_vector, prototype_vector) / (norm_current * norm_proto)
|
|
)
|
|
result["similarity"] = round(similarity, 4)
|
|
result["match"] = similarity >= precheck_threshold
|
|
|
|
if not result["match"]:
|
|
result["reason"] = "screen_mismatch"
|
|
logger.warning(
|
|
f"Pre-check MISMATCH pour session={session_id} "
|
|
f"node={expected_node_id}: similarity={similarity:.4f} "
|
|
f"< seuil={precheck_threshold}"
|
|
)
|
|
|
|
# 5. Détection de popup par changement de titre de fenêtre
|
|
result["popup_detected"] = _detect_popup_hint(
|
|
session_id, workflow, expected_node_id, active_processor,
|
|
)
|
|
|
|
except Exception as e:
|
|
# Ne jamais bloquer le replay en cas d'erreur du pre-check
|
|
logger.error(f"Pre-check échoué (non bloquant): {e}")
|
|
result["match"] = True # Fallback permissif
|
|
result["reason"] = f"precheck_error: {e}"
|
|
|
|
return result
|
|
|
|
|
|
def _detect_popup_hint(
|
|
session_id: str,
|
|
workflow: Any,
|
|
expected_node_id: str,
|
|
processor_instance=None,
|
|
) -> bool:
|
|
"""Détecter si une popup ou un dialogue modal est probable.
|
|
|
|
Compare le titre de fenêtre actuel (via last_window_info de la session)
|
|
avec le titre attendu du node dans le workflow. Un changement de titre
|
|
suggère une popup/dialogue inattendu.
|
|
|
|
Args:
|
|
session_id: ID de la session
|
|
workflow: Workflow object ou dict
|
|
expected_node_id: ID du node attendu
|
|
processor_instance: StreamProcessor pour accéder aux sessions
|
|
|
|
Returns:
|
|
True si un changement de titre suggère une popup
|
|
"""
|
|
try:
|
|
if processor_instance is None:
|
|
return False
|
|
# Titre actuel depuis la session
|
|
session = processor_instance.session_manager.get_session(session_id)
|
|
if not session:
|
|
return False
|
|
current_title = session.last_window_info.get("title", "").strip().lower()
|
|
if not current_title or current_title == "unknown":
|
|
return False
|
|
|
|
# Titre attendu depuis le node du workflow
|
|
expected_title = ""
|
|
if hasattr(workflow, "get_node"):
|
|
node = workflow.get_node(expected_node_id)
|
|
if node and hasattr(node, "template") and hasattr(node.template, "window"):
|
|
window_spec = node.template.window
|
|
if hasattr(window_spec, "title_contains") and window_spec.title_contains:
|
|
expected_title = window_spec.title_contains.strip().lower()
|
|
elif isinstance(workflow, dict):
|
|
for n in workflow.get("nodes", []):
|
|
if n.get("node_id") == expected_node_id:
|
|
template = n.get("template", {})
|
|
window = template.get("window", {})
|
|
expected_title = (window.get("title_contains") or "").strip().lower()
|
|
break
|
|
|
|
if not expected_title:
|
|
return False
|
|
|
|
# Si le titre actuel ne contient plus le titre attendu, popup probable
|
|
if expected_title not in current_title:
|
|
logger.info(
|
|
f"Popup détectée: titre actuel='{current_title}' "
|
|
f"ne contient pas '{expected_title}'"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Détection popup échouée: {e}")
|
|
|
|
return False
|
|
|
|
|
|
# =========================================================================
|
|
# Replay — État et retry
|
|
# =========================================================================
|
|
|
|
def _create_replay_state(
|
|
replay_id: str,
|
|
workflow_id: str,
|
|
session_id: str,
|
|
total_actions: int,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
machine_id: Optional[str] = None,
|
|
actions: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Créer un état de replay enrichi avec les champs de suivi d'erreur.
|
|
|
|
Args:
|
|
actions: Liste des actions du replay. Une copie slim (sans anchors
|
|
base64) est stockée pour permettre à `/replay/result` de
|
|
retrouver le `target_spec` de l'action courante — nécessaire
|
|
pour l'apprentissage mémoire (Phase 1 plan Léa).
|
|
"""
|
|
# Copie slim des actions : on strip les anchor_image_base64 pour ne
|
|
# pas gonfler la mémoire (anchors peuvent faire 50-200 KB chacun).
|
|
# On conserve les champs utilisés par :
|
|
# - la Phase 1 apprentissage (target_spec pour memory_record_success)
|
|
# - le contrôle strict (success_strict, expected_window_*)
|
|
# - les logs/audit (intention, action_id, type, coords)
|
|
actions_slim: List[Dict[str, Any]] = []
|
|
if actions:
|
|
for a in actions:
|
|
a_copy = {
|
|
"action_id": a.get("action_id"),
|
|
"type": a.get("type"),
|
|
"x_pct": a.get("x_pct"),
|
|
"y_pct": a.get("y_pct"),
|
|
# Contrôle strict des étapes (Dom, matin 10 avril 2026)
|
|
"success_strict": a.get("success_strict", False),
|
|
"expected_window_before": a.get("expected_window_before", ""),
|
|
"expected_window_title": a.get("expected_window_title", ""),
|
|
# Contexte métier utile pour logs et apprentissage
|
|
"intention": a.get("intention", ""),
|
|
}
|
|
ts = a.get("target_spec")
|
|
if isinstance(ts, dict):
|
|
a_copy["target_spec"] = {
|
|
k: v for k, v in ts.items()
|
|
if k not in ("anchor_image_base64",)
|
|
}
|
|
actions_slim.append(a_copy)
|
|
|
|
return {
|
|
"replay_id": replay_id,
|
|
"workflow_id": workflow_id,
|
|
"session_id": session_id,
|
|
"machine_id": machine_id or "default", # Machine cible du replay
|
|
"status": "running",
|
|
"total_actions": total_actions,
|
|
"completed_actions": 0,
|
|
"failed_actions": 0,
|
|
"current_action_index": 0,
|
|
"params": params or {},
|
|
"results": [], # Historique des résultats action par action
|
|
"actions": actions_slim, # Copie slim pour lookup par index (Phase 1 mémoire)
|
|
# Champs enrichis pour le suivi d'erreur (#7)
|
|
"retried_actions": 0,
|
|
"unverified_actions": 0,
|
|
"error_log": [], # Liste des erreurs rencontrées
|
|
"last_screenshot": None, # Path du dernier screenshot reçu
|
|
"_last_screenshot_before": None, # Interne: screenshot avant la dernière action
|
|
# Champs pour pause supervisée (target_not_found)
|
|
"failed_action": None, # Contexte de l'action en echec (quand paused_need_help)
|
|
"pause_message": None, # Message a afficher a l'utilisateur
|
|
# Variables d'exécution produites en cours de workflow (extract_text,
|
|
# t2a_decision, etc.). Résolues via templating {{var}} ou {{var.field}}
|
|
# dans les paramètres des actions suivantes.
|
|
"variables": {},
|
|
# QW2 — Anneaux d'historique pour LoopDetector (5 derniers max)
|
|
"_screenshot_history": [], # images PIL des N derniers heartbeats (LoopDetector embed à chaque tick)
|
|
"_action_history": [], # N dernières actions exécutées (signature)
|
|
# QW4 — Safety checks (hybride déclaratif + LLM contextuel) et audit acquittements
|
|
"safety_checks": [], # liste produite par SafetyChecksProvider
|
|
"checks_acknowledged": [], # ids acquittés via /replay/resume (audit trail)
|
|
"pause_reason": "", # "loop_detected" | "" pour V1
|
|
"pause_payload": None, # payload complet pour debug/audit
|
|
}
|
|
|
|
|
|
def _schedule_retry(
|
|
session_id: str,
|
|
replay_state: Dict[str, Any],
|
|
action: Dict[str, Any],
|
|
current_retry: int,
|
|
reason: str,
|
|
replay_queues: Dict[str, List[Dict[str, Any]]],
|
|
retry_pending: Dict[str, Dict[str, Any]],
|
|
max_retries: int = 3,
|
|
):
|
|
"""
|
|
Programmer un retry pour une action échouée.
|
|
|
|
Stratégie :
|
|
- Retry 1 : réinjecter l'action directement (re-résolution visuelle par l'agent)
|
|
- Retry 2 : injecter un wait de 2s avant l'action (possible loading en cours)
|
|
- Retry 3 : dernier essai direct
|
|
|
|
L'action est réinsérée en tête de la queue pour être la prochaine exécutée.
|
|
Le lock de replay doit être acquis par l'appelant.
|
|
"""
|
|
next_retry = current_retry + 1
|
|
replay_state["retried_actions"] += 1
|
|
|
|
# Créer une copie de l'action avec un nouveau action_id pour le tracking
|
|
retry_action = dict(action)
|
|
retry_action_id = f"{action.get('action_id', 'unknown')}_retry{next_retry}"
|
|
retry_action["action_id"] = retry_action_id
|
|
|
|
# Stocker l'info de retry pour le prochain report_action_result
|
|
retry_pending[retry_action_id] = {
|
|
"action": action,
|
|
"retry_count": next_retry,
|
|
"replay_id": replay_state["replay_id"],
|
|
"reason": reason,
|
|
}
|
|
|
|
# Stratégie de retry selon le numéro
|
|
actions_to_insert = []
|
|
|
|
if next_retry == 2:
|
|
# Retry 2 : injecter un wait de 2s avant l'action
|
|
wait_action = {
|
|
"action_id": f"wait_retry_{uuid.uuid4().hex[:6]}",
|
|
"type": "wait",
|
|
"duration_ms": 2000,
|
|
}
|
|
actions_to_insert.append(wait_action)
|
|
|
|
actions_to_insert.append(retry_action)
|
|
|
|
# Insérer en tête de la queue (prochaine action à exécuter)
|
|
queue = replay_queues.get(session_id, [])
|
|
replay_queues[session_id] = actions_to_insert + queue
|
|
|
|
logger.info(
|
|
f"Retry {next_retry}/{max_retries} programmé pour {action.get('action_id')} "
|
|
f"(raison: {reason}) | nouveau id: {retry_action_id}"
|
|
)
|
|
|
|
|
|
def _notify_error_callback(
|
|
replay_state: Dict[str, Any],
|
|
action_id: str,
|
|
error: Optional[str],
|
|
error_callbacks: Dict[str, str],
|
|
):
|
|
"""
|
|
Notifier le callback d'erreur si configuré pour ce replay.
|
|
|
|
Appel HTTP POST non-bloquant vers l'URL de callback.
|
|
En cas d'échec de notification, on log mais on ne bloque pas.
|
|
"""
|
|
replay_id = replay_state["replay_id"]
|
|
callback_url = error_callbacks.get(replay_id)
|
|
if not callback_url:
|
|
return
|
|
|
|
def _send_callback():
|
|
try:
|
|
import urllib.request
|
|
payload = json.dumps({
|
|
"replay_id": replay_id,
|
|
"workflow_id": replay_state.get("workflow_id"),
|
|
"session_id": replay_state.get("session_id"),
|
|
"action_id": action_id,
|
|
"error": error or "Erreur inconnue",
|
|
"retried_actions": replay_state.get("retried_actions", 0),
|
|
"error_log": replay_state.get("error_log", []),
|
|
"status": replay_state.get("status"),
|
|
}).encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
callback_url,
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
logger.info(
|
|
f"Error callback envoyé à {callback_url}: {resp.status}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Échec envoi error callback à {callback_url}: {e}"
|
|
)
|
|
|
|
# Envoyer en arrière-plan pour ne pas bloquer
|
|
threading.Thread(target=_send_callback, daemon=True).start()
|