diff --git a/core/detection/__init__.py b/core/detection/__init__.py new file mode 100644 index 000000000..4f9e69e58 --- /dev/null +++ b/core/detection/__init__.py @@ -0,0 +1,44 @@ +""" +Detection Module - Détection Sémantique d'Éléments UI + +Ce module gère la détection et classification d'éléments UI +avec Vision-Language Models (VLM). +""" + +from .ui_detector import ( + UIDetector, + DetectionConfig, + create_detector +) + +from .ollama_client import ( + OllamaClient, + create_ollama_client, + check_ollama_available +) + +# SeeClick pour visual grounding (fallback) +try: + from .seeclick_adapter import ( + SeeClickAdapter, + get_seeclick, + ground_element, + GroundingResult + ) + _seeclick_available = True +except ImportError: + _seeclick_available = False + +__all__ = [ + 'UIDetector', + 'DetectionConfig', + 'create_detector', + 'OllamaClient', + 'create_ollama_client', + 'check_ollama_available', + # SeeClick (si disponible) + 'SeeClickAdapter', + 'get_seeclick', + 'ground_element', + 'GroundingResult' +] diff --git a/core/detection/seeclick_adapter.py b/core/detection/seeclick_adapter.py new file mode 100644 index 000000000..54c5f4a55 --- /dev/null +++ b/core/detection/seeclick_adapter.py @@ -0,0 +1,333 @@ +""" +SeeClick Adapter pour RPA Vision V3 + +Intègre le modèle SeeClick pour le visual grounding sur GUI. +SeeClick peut localiser un élément GUI à partir d'une description textuelle +et retourner des coordonnées (x, y) de clic. + +Usage: + adapter = SeeClickAdapter() + x, y = adapter.ground(screenshot_pil, "bouton Valider") + # Retourne les coordonnées de clic normalisées ou en pixels +""" + +import os +import sys +from typing import Optional, Tuple, Dict, Any +from dataclasses import dataclass +from PIL import Image +import re + +# Configuration +SEECLICK_MODEL_ID = "cckevinn/SeeClick" +SEECLICK_LOCAL_PATH = "/home/dom/ai/models/seeclick" # Cache local si téléchargé + + +@dataclass +class GroundingResult: + """Résultat de grounding SeeClick""" + found: bool + x: Optional[float] # Coordonnée X normalisée (0-1) ou pixels + y: Optional[float] # Coordonnée Y normalisée (0-1) ou pixels + x_pixel: Optional[int] # Coordonnée X en pixels + y_pixel: Optional[int] # Coordonnée Y en pixels + confidence: float + raw_output: str # Sortie brute du modèle + + +class SeeClickAdapter: + """ + Adapter pour utiliser SeeClick comme grounding fallback. + + SeeClick est un modèle vision-langage basé sur Qwen-VL qui peut + localiser des éléments GUI à partir de descriptions textuelles. + + Il retourne des coordonnées (x, y) normalisées entre 0 et 1. + """ + + _instance = None + _initialized = False + + def __new__(cls): + """Singleton pour éviter de charger le modèle plusieurs fois""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """Initialise SeeClick (lazy loading)""" + if SeeClickAdapter._initialized: + return + + self.model = None + self.tokenizer = None + self.available = False + self._device = None + self._check_availability() + + def _check_availability(self): + """Vérifie si SeeClick peut être chargé""" + try: + # Vérifier que transformers est disponible + from transformers import AutoModelForCausalLM, AutoTokenizer + + # Vérifier si le modèle est en cache local ou accessible + if os.path.exists(SEECLICK_LOCAL_PATH): + print(f"✅ [SeeClick] Modèle local trouvé: {SEECLICK_LOCAL_PATH}") + self.available = True + else: + # On suppose qu'on peut télécharger depuis HuggingFace + print(f"ℹ️ [SeeClick] Modèle sera téléchargé depuis HuggingFace: {SEECLICK_MODEL_ID}") + self.available = True + + print("✅ [SeeClick] Adapter disponible (chargement différé)") + + except ImportError as e: + print(f"❌ [SeeClick] Dépendances manquantes: {e}") + self.available = False + except Exception as e: + print(f"❌ [SeeClick] Erreur vérification: {e}") + self.available = False + + def _load_model(self): + """Charge le modèle SeeClick (lazy loading)""" + if self.model is not None: + return True + + if not self.available: + return False + + try: + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + # Déterminer le device + self._device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f"🔄 [SeeClick] Chargement du modèle sur {self._device}...") + + # Charger depuis le cache local ou HuggingFace + model_path = SEECLICK_LOCAL_PATH if os.path.exists(SEECLICK_LOCAL_PATH) else SEECLICK_MODEL_ID + + self.tokenizer = AutoTokenizer.from_pretrained( + model_path, + trust_remote_code=True + ) + + self.model = AutoModelForCausalLM.from_pretrained( + model_path, + device_map=self._device, + trust_remote_code=True, + torch_dtype=torch.float16 if self._device == 'cuda' else torch.float32 + ) + + SeeClickAdapter._initialized = True + print(f"✅ [SeeClick] Modèle chargé avec succès sur {self._device}") + return True + + except Exception as e: + print(f"❌ [SeeClick] Erreur chargement modèle: {e}") + import traceback + traceback.print_exc() + self.available = False + return False + + def ground( + self, + image: Image.Image, + description: str, + return_pixels: bool = True + ) -> GroundingResult: + """ + Localise un élément GUI basé sur une description textuelle. + + Args: + image: Image PIL du screenshot + description: Description de l'élément à trouver + Ex: "bouton Valider", "icône OnlyOffice", "champ de recherche" + return_pixels: Si True, retourne les coordonnées en pixels + + Returns: + GroundingResult avec les coordonnées trouvées + """ + if not self._load_model(): + return GroundingResult( + found=False, + x=None, y=None, + x_pixel=None, y_pixel=None, + confidence=0.0, + raw_output="Model not available" + ) + + try: + W, H = image.size + + # Formatter le prompt pour SeeClick + # Format: "In this UI screenshot, what is the position of the element..." + prompt = f"In this UI screenshot, what is the position of the element corresponding to \"{description}\"?" + + # Préparer l'input (dépend de l'implémentation Qwen-VL) + # SeeClick utilise le format chat de Qwen-VL + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": prompt} + ] + } + ] + + # Tokeniser et générer + text = self.tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True + ) + + inputs = self.tokenizer(text, return_tensors="pt") + if self._device == 'cuda': + inputs = {k: v.cuda() for k, v in inputs.items()} + + outputs = self.model.generate( + **inputs, + max_new_tokens=100, + do_sample=False + ) + + # Décoder la sortie + response = self.tokenizer.decode(outputs[0], skip_special_tokens=True) + raw_output = response.split("assistant")[-1].strip() if "assistant" in response else response + + print(f"🔍 [SeeClick] Sortie brute: {raw_output}") + + # Parser les coordonnées (format: (0.xxx, 0.yyy) ou [0.xxx, 0.yyy]) + coords = self._parse_coordinates(raw_output) + + if coords: + x_norm, y_norm = coords + x_pixel = int(x_norm * W) + y_pixel = int(y_norm * H) + + print(f"✅ [SeeClick] Trouvé: ({x_pixel}, {y_pixel}) = ({x_norm:.3f}, {y_norm:.3f}) normalisé") + + return GroundingResult( + found=True, + x=x_pixel if return_pixels else x_norm, + y=y_pixel if return_pixels else y_norm, + x_pixel=x_pixel, + y_pixel=y_pixel, + confidence=0.8, # SeeClick ne donne pas de score explicite + raw_output=raw_output + ) + else: + print(f"⚠️ [SeeClick] Pas de coordonnées trouvées dans: {raw_output}") + return GroundingResult( + found=False, + x=None, y=None, + x_pixel=None, y_pixel=None, + confidence=0.0, + raw_output=raw_output + ) + + except Exception as e: + print(f"❌ [SeeClick] Erreur grounding: {e}") + import traceback + traceback.print_exc() + return GroundingResult( + found=False, + x=None, y=None, + x_pixel=None, y_pixel=None, + confidence=0.0, + raw_output=str(e) + ) + + def _parse_coordinates(self, text: str) -> Optional[Tuple[float, float]]: + """ + Parse les coordonnées depuis la sortie du modèle. + + Formats supportés: + - (0.123, 0.456) + - [0.123, 0.456] + - click(0.123, 0.456) + - x: 0.123, y: 0.456 + """ + # Pattern pour (x, y) ou [x, y] + pattern1 = r'[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)\s*[\)\]]?' + + # Pattern pour click(x, y) + pattern2 = r'click\s*[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)' + + # Pattern pour x: 0.xxx, y: 0.yyy + pattern3 = r'x\s*[:=]\s*(\d+\.?\d*).*?y\s*[:=]\s*(\d+\.?\d*)' + + for pattern in [pattern2, pattern1, pattern3]: + matches = re.findall(pattern, text, re.IGNORECASE) + if matches: + x_str, y_str = matches[0] + try: + x = float(x_str) + y = float(y_str) + + # Si les valeurs sont > 1, elles sont probablement en pixels + # Dans ce cas, on ne peut pas les normaliser sans connaître la taille + if x > 1 or y > 1: + # Essayer de normaliser si > 1 (supposer pixels) + # Mais on ne sait pas la taille de l'image ici... + # On retourne tel quel et on laisse l'appelant gérer + pass + + return (x, y) + except ValueError: + continue + + return None + + def ground_with_anchor( + self, + image: Image.Image, + anchor_image: Image.Image, + anchor_description: Optional[str] = None + ) -> GroundingResult: + """ + Localise un élément similaire à une ancre. + + Si une description est fournie, l'utilise. + Sinon, essaie de décrire l'ancre automatiquement. + + Args: + image: Screenshot actuel + anchor_image: Image de l'ancre à trouver + anchor_description: Description optionnelle de l'ancre + + Returns: + GroundingResult avec les coordonnées trouvées + """ + if anchor_description: + description = anchor_description + else: + # Générer une description générique + # On pourrait utiliser un captioning model ici + description = "the element that looks like the given target" + + return self.ground(image, description) + + +# Instance globale (singleton) +_seeclick_instance: Optional[SeeClickAdapter] = None + + +def get_seeclick() -> SeeClickAdapter: + """Retourne l'instance singleton de SeeClick""" + global _seeclick_instance + if _seeclick_instance is None: + _seeclick_instance = SeeClickAdapter() + return _seeclick_instance + + +def ground_element( + image: Image.Image, + description: str, + return_pixels: bool = True +) -> GroundingResult: + """Fonction utilitaire pour localiser un élément""" + return get_seeclick().ground(image, description, return_pixels) diff --git a/docs/VISION_RPA_INTELLIGENT.md b/docs/VISION_RPA_INTELLIGENT.md index c4e5c192a..5358cb793 100644 --- a/docs/VISION_RPA_INTELLIGENT.md +++ b/docs/VISION_RPA_INTELLIGENT.md @@ -229,14 +229,23 @@ VWB génère des données pour entraîner le moteur principal : ## Prochaines Étapes +### Fait ✅ 1. [x] Frontend VWB v4 avec React Flow -2. [ ] Toggle Mode Basique/Intelligent/Debug -3. [ ] Intégration UI-DETR-1 pour détection -4. [ ] Intégration SeeClick en fallback -5. [ ] Overlay Debug (affichage bboxes) -6. [ ] Export données d'apprentissage -7. [ ] Connexion au moteur principal +2. [x] Toggle Mode Basique/Intelligent/Debug +3. [x] Intégration UI-DETR-1 pour détection +4. [x] Overlay Debug (affichage bboxes en temps réel) +5. [x] Exécution intelligente (template matching) +6. [x] Sélection de zone de détection sur capture fixe +7. [x] Intégration SeeClick en fallback (grounding) - 24 janvier 2026 +8. [x] Self-healing interactif (proposer alternatives quand échec) - 24 janvier 2026 +9. [x] Dashboard confiance (scores en temps réel) - 24 janvier 2026 + +### À faire +10. [ ] Export données d'apprentissage (format JSON) +11. [ ] Apprentissage des corrections (feedback loop) +12. [ ] Connexion au moteur principal (agents autonomes) --- *Document créé le 23 janvier 2026* +*Mis à jour le 24 janvier 2026* diff --git a/visual_workflow_builder/backend/api_v3/execute.py b/visual_workflow_builder/backend/api_v3/execute.py index 1656d8487..9db8cfff6 100644 --- a/visual_workflow_builder/backend/api_v3/execute.py +++ b/visual_workflow_builder/backend/api_v3/execute.py @@ -52,7 +52,13 @@ _execution_state = { 'should_stop': False, 'current_execution_id': None, 'thread': None, - 'execution_mode': 'basic' # 'basic', 'intelligent', 'debug' + 'execution_mode': 'basic', # 'basic', 'intelligent', 'debug' + # Self-healing interactif + 'waiting_for_choice': False, + 'pending_action': None, # Action en attente de choix utilisateur + 'candidates': [], # Candidats proposés + 'user_choice': None, # Choix de l'utilisateur (coordonnées ou 'skip' ou 'static') + 'current_step_info': None # Info sur l'étape en cours pour affichage } @@ -164,6 +170,85 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app): # === EXÉCUTION DE L'ACTION === result = execute_action(step.action_type, params) + # === SELF-HEALING INTERACTIF === + # Si l'action nécessite un choix utilisateur, attendre + if result.get('needs_user_choice'): + print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}") + + # Stocker les informations pour le frontend + _execution_state['waiting_for_choice'] = True + _execution_state['pending_action'] = { + 'step_id': step.id, + 'step_index': index, + 'action_type': step.action_type, + 'params': params + } + _execution_state['candidates'] = result.get('candidates', []) + _execution_state['current_step_info'] = { + 'index': index, + 'total': len(steps), + 'original_bbox': result.get('original_bbox'), + 'error': result.get('error') + } + _execution_state['user_choice'] = None + + # Mettre à jour le status de l'exécution + execution.status = 'waiting_user_choice' + db.session.commit() + + # Attendre le choix de l'utilisateur + timeout_seconds = 120 # 2 minutes max + waited = 0 + while _execution_state['waiting_for_choice'] and waited < timeout_seconds: + if _execution_state['should_stop']: + break + time.sleep(0.5) + waited += 0.5 + + # Vérifier si on doit arrêter + if _execution_state['should_stop']: + execution.status = 'cancelled' + break + + # Traiter le choix de l'utilisateur + user_choice = _execution_state['user_choice'] + _execution_state['waiting_for_choice'] = False + _execution_state['pending_action'] = None + _execution_state['candidates'] = [] + + if user_choice is None: + # Timeout - aucun choix + step_result.status = 'error' + step_result.error_message = "Timeout: aucun choix utilisateur" + execution.failed_steps += 1 + execution.status = 'error' + execution.error_message = f"Timeout à l'étape {index + 1}: aucun choix utilisateur" + db.session.commit() + break + elif user_choice == 'skip': + # Utilisateur a choisi de sauter l'étape + step_result.status = 'skipped' + step_result.output = {'skipped_by_user': True} + execution.status = 'running' + db.session.commit() + print(f"⏭️ [Self-Healing] Étape {index + 1} sautée par l'utilisateur") + continue + elif user_choice == 'static': + # Utiliser les coordonnées statiques + result = execute_action_with_static_coords(step.action_type, params) + execution.status = 'running' + elif isinstance(user_choice, dict) and 'x' in user_choice: + # Coordonnées choisies par l'utilisateur + result = execute_action_with_coords(step.action_type, params, user_choice) + execution.status = 'running' + else: + step_result.status = 'error' + step_result.error_message = f"Choix invalide: {user_choice}" + execution.failed_steps += 1 + execution.status = 'error' + db.session.commit() + break + step_result.ended_at = datetime.utcnow() step_result.duration_ms = int((step_result.ended_at - step_result.started_at).total_seconds() * 1000) @@ -323,6 +408,75 @@ def execute_ai_analyze(params: dict) -> dict: return {'success': False, 'error': str(e)} +def execute_action_with_coords(action_type: str, params: dict, coords: dict) -> dict: + """ + Exécute une action avec des coordonnées spécifiées par l'utilisateur (self-healing). + """ + import pyautogui + import time + + try: + x, y = coords['x'], coords['y'] + print(f"🖱️ [Self-Healing] Clic aux coordonnées choisies: ({x}, {y})") + + if action_type in ['double_click_anchor']: + pyautogui.doubleClick(x, y) + elif action_type in ['right_click_anchor']: + pyautogui.rightClick(x, y) + else: + pyautogui.click(x, y) + + time.sleep(2.0) # Délai après le clic + + return { + 'success': True, + 'output': { + 'clicked_at': {'x': x, 'y': y}, + 'method': 'user_choice', + 'self_healed': True + } + } + except Exception as e: + return {'success': False, 'error': str(e)} + + +def execute_action_with_static_coords(action_type: str, params: dict) -> dict: + """ + Exécute une action avec les coordonnées statiques originales (fallback self-healing). + """ + import pyautogui + import time + + try: + anchor = params.get('visual_anchor', {}) + bbox = anchor.get('bounding_box', {}) + + x = bbox.get('x', 0) + bbox.get('width', 0) / 2 + y = bbox.get('y', 0) + bbox.get('height', 0) / 2 + + print(f"🖱️ [Self-Healing] Clic aux coordonnées statiques: ({x}, {y})") + + if action_type in ['double_click_anchor']: + pyautogui.doubleClick(x, y) + elif action_type in ['right_click_anchor']: + pyautogui.rightClick(x, y) + else: + pyautogui.click(x, y) + + time.sleep(2.0) + + return { + 'success': True, + 'output': { + 'clicked_at': {'x': x, 'y': y}, + 'method': 'static_fallback', + 'self_healed': True + } + } + except Exception as e: + return {'success': False, 'error': str(e)} + + def execute_action(action_type: str, params: dict) -> dict: """ Exécute une action RPA. @@ -403,11 +557,25 @@ def execute_action(action_type: str, params: dict) -> dict: } else: # En mode intelligent/debug, on refuse d'utiliser les coordonnées statiques - # si l'ancre n'est pas trouvée - cela évite les clics au mauvais endroit + # si l'ancre n'est pas trouvée - MAIS on peut proposer des alternatives reason = result.get('reason', 'Ancre non trouvée à l\'écran') confidence = result.get('confidence', 0) + candidates = result.get('candidates', []) print(f"❌ [Vision] Ancre NON trouvée (confiance: {confidence:.2f})") print(f" Raison: {reason}") + + # Si self-healing interactif activé, proposer des alternatives + if _execution_state.get('execution_mode') == 'intelligent' and candidates: + print(f"🔄 [Self-Healing] {len(candidates)} candidats disponibles - attente choix utilisateur") + return { + 'success': False, + 'needs_user_choice': True, # Flag pour self-healing + 'error': f"Ancre non trouvée - veuillez choisir parmi les alternatives", + 'candidates': candidates, + 'original_bbox': bbox, + 'confidence': confidence + } + return { 'success': False, 'error': f"Ancre non trouvée à l'écran (confiance: {confidence:.2f}). {reason}" @@ -707,7 +875,11 @@ def get_execution_status(): 'is_paused': _execution_state['is_paused'], 'execution_mode': _execution_state.get('execution_mode', 'basic'), 'execution': execution.to_dict() if execution else None, - 'session': session.to_dict() + 'session': session.to_dict(), + # Self-healing interactif + 'waiting_for_choice': _execution_state.get('waiting_for_choice', False), + 'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [], + 'current_step_info': _execution_state.get('current_step_info') if _execution_state.get('waiting_for_choice') else None }) @@ -734,3 +906,121 @@ def get_execution_history(): 'success': False, 'error': str(e) }), 500 + + +# ============================================================================== +# SELF-HEALING INTERACTIF - Endpoints +# ============================================================================== + +@api_v3_bp.route('/execute/healing/status', methods=['GET']) +def get_healing_status(): + """ + Retourne l'état du self-healing interactif. + Si waiting_for_choice est True, retourne les candidats à afficher. + """ + global _execution_state + + return jsonify({ + 'success': True, + 'waiting_for_choice': _execution_state.get('waiting_for_choice', False), + 'candidates': _execution_state.get('candidates', []), + 'current_step_info': _execution_state.get('current_step_info'), + 'pending_action': _execution_state.get('pending_action') + }) + + +@api_v3_bp.route('/execute/healing/choose', methods=['POST']) +def submit_healing_choice(): + """ + Soumet le choix de l'utilisateur pour le self-healing. + + Request: + { + "choice": "skip" | "static" | {"x": 123, "y": 456} + } + + - "skip": Sauter cette étape et continuer + - "static": Utiliser les coordonnées statiques originales + - {"x": N, "y": N}: Utiliser ces coordonnées précises + """ + global _execution_state + + if not _execution_state.get('waiting_for_choice'): + return jsonify({ + 'success': False, + 'error': "Aucune décision en attente" + }), 400 + + data = request.get_json() or {} + choice = data.get('choice') + + if choice is None: + return jsonify({ + 'success': False, + 'error': "Choix non spécifié" + }), 400 + + # Valider le choix + valid_choices = ['skip', 'static'] + if isinstance(choice, str) and choice not in valid_choices: + return jsonify({ + 'success': False, + 'error': f"Choix invalide. Valeurs acceptées: {valid_choices} ou {{x, y}}" + }), 400 + + if isinstance(choice, dict): + if 'x' not in choice or 'y' not in choice: + return jsonify({ + 'success': False, + 'error': "Coordonnées invalides. Format attendu: {x: number, y: number}" + }), 400 + + # Stocker le choix + _execution_state['user_choice'] = choice + _execution_state['waiting_for_choice'] = False + + print(f"✅ [Self-Healing] Choix reçu: {choice}") + + return jsonify({ + 'success': True, + 'choice': choice + }) + + +@api_v3_bp.route('/execute/healing/candidates', methods=['GET']) +def get_healing_candidates(): + """ + Retourne les candidats avec leurs screenshots pour affichage visuel. + Permet à l'utilisateur de voir les alternatives possibles. + """ + global _execution_state + + candidates = _execution_state.get('candidates', []) + step_info = _execution_state.get('current_step_info', {}) + + # Enrichir les candidats avec plus d'informations si disponible + enriched_candidates = [] + for i, candidate in enumerate(candidates): + enriched = { + 'id': i, + 'element_id': candidate.get('element_id'), + 'score': candidate.get('score', candidate.get('combined_score', 0)), + 'bbox': candidate.get('bbox', {}), + 'distance': candidate.get('distance'), + 'method': candidate.get('method', 'unknown') + } + # Calculer le centre si bbox disponible + bbox = candidate.get('bbox', {}) + if bbox: + enriched['center'] = { + 'x': (bbox.get('x1', 0) + bbox.get('x2', 0)) // 2, + 'y': (bbox.get('y1', 0) + bbox.get('y2', 0)) // 2 + } + enriched_candidates.append(enriched) + + return jsonify({ + 'success': True, + 'candidates': enriched_candidates, + 'step_info': step_info, + 'original_bbox': step_info.get('original_bbox') + }) diff --git a/visual_workflow_builder/backend/services/intelligent_executor.py b/visual_workflow_builder/backend/services/intelligent_executor.py index 4d0965461..2b617b1b8 100644 --- a/visual_workflow_builder/backend/services/intelligent_executor.py +++ b/visual_workflow_builder/backend/services/intelligent_executor.py @@ -6,11 +6,18 @@ Utilise UI-DETR-1 pour la détection et le matching d'ancres visuelles import time import base64 import io +import sys +import os from typing import Dict, Any, Optional, List, Tuple from dataclasses import dataclass from PIL import Image import numpy as np +# Ajouter le chemin racine pour les imports de core +RPA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +if RPA_ROOT not in sys.path: + sys.path.insert(0, RPA_ROOT) + # Import du service de détection UI from .ui_detection_service import detect_ui_elements, DetectionResult, UIElement @@ -764,7 +771,53 @@ def find_and_click( global_result['search_time_ms'] = (_time.time() - start_time) * 1000 return global_result - # === STRATÉGIE 4: Coordonnées statiques (dernier recours) === + # === STRATÉGIE 4: SeeClick (visual grounding) === + # Essayer SeeClick si les autres méthodes ont échoué + try: + print("🎯 [Vision] Essai SeeClick (visual grounding)...") + from core.detection.seeclick_adapter import get_seeclick + + seeclick = get_seeclick() + if seeclick.available: + # Utiliser une description générique basée sur l'ancre + # TODO: Améliorer avec une description plus précise + description = "the clickable element or button" + + grounding_result = seeclick.ground(screen_image, description, return_pixels=True) + + if grounding_result.found: + found_x = grounding_result.x_pixel + found_y = grounding_result.y_pixel + + # Vérifier la distance à la position originale si anchor_bbox existe + accept_seeclick = True + if anchor_bbox: + orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) // 2 + orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) // 2 + distance = np.sqrt((found_x - orig_x)**2 + (found_y - orig_y)**2) + + MAX_SEECLICK_DISTANCE = 200 # Plus permissif car c'est un fallback + if distance > MAX_SEECLICK_DISTANCE: + print(f"⛔ [Vision] SeeClick rejeté: distance {distance:.0f}px > {MAX_SEECLICK_DISTANCE}px max") + accept_seeclick = False + + if accept_seeclick: + print(f"✅ [Vision] SeeClick réussi! Coordonnées: ({found_x}, {found_y})") + return { + 'found': True, + 'confidence': grounding_result.confidence, + 'coordinates': {'x': found_x, 'y': found_y}, + 'bbox': anchor_bbox, + 'method': 'seeclick_grounding', + 'search_time_ms': (_time.time() - start_time) * 1000, + 'raw_output': grounding_result.raw_output + } + except ImportError: + print("ℹ️ [Vision] SeeClick non disponible (module non trouvé)") + except Exception as seeclick_err: + print(f"⚠️ [Vision] Erreur SeeClick: {seeclick_err}") + + # === STRATÉGIE 5: Coordonnées statiques (dernier recours) === if anchor_bbox: best_conf = max(global_result.get('confidence', 0), 0) diff --git a/visual_workflow_builder/frontend_v4/src/App.tsx b/visual_workflow_builder/frontend_v4/src/App.tsx index feab71d75..c39e92d32 100644 --- a/visual_workflow_builder/frontend_v4/src/App.tsx +++ b/visual_workflow_builder/frontend_v4/src/App.tsx @@ -25,6 +25,8 @@ import ExecutionOverlay from './components/ExecutionOverlay'; import VariableManager from './components/VariableManager'; import type { Variable } from './components/VariableManager'; import CaptureLibrary from './components/CaptureLibrary'; +import SelfHealingDialog from './components/SelfHealingDialog'; +import ConfidenceDashboard from './components/ConfidenceDashboard'; const nodeTypes: NodeTypes = { step: StepNode, @@ -44,6 +46,11 @@ function App() { const [showWorkflowManager, setShowWorkflowManager] = useState(false); const [currentCapture, setCurrentCapture] = useState(null); + // Self-healing interactif + const [showSelfHealing, setShowSelfHealing] = useState(false); + const [healingCandidates, setHealingCandidates] = useState([]); + const [healingStepInfo, setHealingStepInfo] = useState(null); + // Charger l'état initial const loadState = useCallback(async () => { try { @@ -68,11 +75,19 @@ function App() { const status = await api.getExecutionStatus(); setIsExecutionRunning(status.is_running); + // Self-healing interactif: detecter si on attend un choix utilisateur + if (status.waiting_for_choice && status.candidates) { + setHealingCandidates(status.candidates); + setHealingStepInfo(status.current_step_info); + setShowSelfHealing(true); + } + // Mettre à jour l'état si l'exécution est terminée // Note: Ne PAS fermer l'overlay automatiquement pour permettre // à l'utilisateur de voir les résultats de détection if (!status.is_running) { await loadState(); + setShowSelfHealing(false); // L'overlay reste visible, l'utilisateur peut le fermer manuellement } } catch (err) { @@ -289,6 +304,18 @@ function App() { setVariables(prev => prev.filter(v => v.id !== id)); }; + // Self-healing: soumettre le choix de l'utilisateur + const handleSelfHealingChoice = async (choice: 'skip' | 'static' | { x: number; y: number }) => { + try { + await api.submitHealingChoice(choice); + setShowSelfHealing(false); + setHealingCandidates([]); + setHealingStepInfo(null); + } catch (err) { + setError((err as Error).message); + } + }; + // Drop d'un outil sur le canvas const onDrop = useCallback( (event: React.DragEvent) => { @@ -433,6 +460,24 @@ function App() { onClose={() => setShowWorkflowManager(false)} /> )} + + {/* Self-Healing Dialog */} + { + setShowSelfHealing(false); + handleStopExecution(); + }} + /> + + {/* Confidence Dashboard - scores en temps reel */} + ); } diff --git a/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx b/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx new file mode 100644 index 000000000..c2bc95056 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx @@ -0,0 +1,406 @@ +/** + * Confidence Dashboard Component + * + * Affiche les scores de confiance en temps réel pendant l'exécution. + * Montre CLIP score, template score, distance et méthode utilisée. + */ + +import { useState, useEffect } from 'react'; + +interface StepScore { + stepIndex: number; + stepType: string; + method: string; + confidence: number; + distance?: number; + clipScore?: number; + templateScore?: number; + timestamp: number; + success: boolean; +} + +interface Props { + isExecutionRunning: boolean; + executionMode: 'basic' | 'intelligent' | 'debug'; +} + +export default function ConfidenceDashboard({ isExecutionRunning, executionMode }: Props) { + const [scores, setScores] = useState([]); + const [currentStep, setCurrentStep] = useState(0); + const [isExpanded, setIsExpanded] = useState(true); + + // Polling pour les scores en temps réel + useEffect(() => { + if (!isExecutionRunning) return; + + const pollScores = async () => { + try { + const response = await fetch('/api/v3/execute/status'); + const data = await response.json(); + + if (data.success && data.execution) { + setCurrentStep(data.execution.current_step_index || 0); + + // Si on a des resultats d'etapes, les ajouter + if (data.execution.step_results) { + const newScores: StepScore[] = data.execution.step_results.map((result: any, index: number) => ({ + stepIndex: index, + stepType: result.action_type || 'unknown', + method: result.output?.method || 'static', + confidence: result.output?.confidence || 1.0, + distance: result.output?.distance, + clipScore: result.output?.clip_score, + templateScore: result.output?.template_score, + timestamp: new Date(result.ended_at).getTime(), + success: result.status === 'success' + })); + setScores(newScores); + } + } + } catch (err) { + console.error('Erreur polling scores:', err); + } + }; + + const interval = setInterval(pollScores, 1000); + return () => clearInterval(interval); + }, [isExecutionRunning]); + + // Reset quand l'execution s'arrete + useEffect(() => { + if (!isExecutionRunning) { + // Garder les scores pour review + } + }, [isExecutionRunning]); + + if (executionMode === 'basic') { + return null; // Pas de dashboard en mode basic + } + + const getConfidenceColor = (confidence: number): string => { + if (confidence >= 0.8) return '#a6e3a1'; // Vert + if (confidence >= 0.5) return '#f9e2af'; // Jaune + return '#f38ba8'; // Rouge + }; + + const getMethodIcon = (method: string): string => { + switch (method) { + case 'clip': return '🧠'; + case 'clip_embedding': return '🧠'; + case 'zoned_template': return '📍'; + case 'direct_template': return '🔍'; + case 'seeclick_grounding': return '🎯'; + case 'static_fallback': return '📌'; + case 'user_choice': return '👆'; + default: return '⚡'; + } + }; + + const averageConfidence = scores.length > 0 + ? scores.reduce((acc, s) => acc + s.confidence, 0) / scores.length + : 0; + + const successRate = scores.length > 0 + ? (scores.filter(s => s.success).length / scores.length) * 100 + : 0; + + return ( +
+
setIsExpanded(!isExpanded)}> +
+ 📊 + Scores de confiance + {isExecutionRunning && ( + LIVE + )} +
+
+ {isExpanded ? '▼' : '▶'} +
+
+ + {isExpanded && ( +
+ {/* Metriques globales */} +
+
+ Etape actuelle + {currentStep + 1} +
+
+ Confiance moy. + + {(averageConfidence * 100).toFixed(0)}% + +
+
+ Taux succes + + {successRate.toFixed(0)}% + +
+
+ + {/* Liste des scores par etape */} +
+ {scores.length === 0 ? ( +
+ {isExecutionRunning + ? "En attente de resultats..." + : "Aucune execution en cours"} +
+ ) : ( + scores.map((score) => ( +
+
+ #{score.stepIndex + 1} + {getMethodIcon(score.method)} +
+
+ {score.method} + {score.distance !== undefined && ( + {score.distance.toFixed(0)}px + )} +
+
+ + {(score.confidence * 100).toFixed(0)}% + +
+
+ )) + )} +
+ + {/* Legende */} +
+ 🧠 CLIP + 📍 Template zone + 🎯 SeeClick + 📌 Static +
+
+ )} + + +
+ ); +} diff --git a/visual_workflow_builder/frontend_v4/src/components/SelfHealingDialog.tsx b/visual_workflow_builder/frontend_v4/src/components/SelfHealingDialog.tsx new file mode 100644 index 000000000..9a04e8d39 --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/SelfHealingDialog.tsx @@ -0,0 +1,422 @@ +/** + * Self-Healing Dialog Component + * + * Affiche les candidats alternatifs quand l'ancre n'est pas trouvée + * et permet à l'utilisateur de choisir une action. + */ + +import { useState, useEffect } from 'react'; + +interface Candidate { + id: number; + element_id: number; + score: number; + bbox: { + x1: number; + y1: number; + x2: number; + y2: number; + }; + center?: { + x: number; + y: number; + }; + distance?: number; + method?: string; +} + +interface StepInfo { + index: number; + total: number; + original_bbox?: { + x: number; + y: number; + width: number; + height: number; + }; + error?: string; +} + +interface Props { + isOpen: boolean; + candidates: Candidate[]; + stepInfo: StepInfo | null; + onChoose: (choice: 'skip' | 'static' | { x: number; y: number }) => void; + onClose: () => void; +} + +export default function SelfHealingDialog({ isOpen, candidates, stepInfo, onChoose, onClose }: Props) { + const [selectedCandidate, setSelectedCandidate] = useState(null); + const [customCoords, setCustomCoords] = useState({ x: '', y: '' }); + + // Reset quand le dialog s'ouvre + useEffect(() => { + if (isOpen) { + setSelectedCandidate(null); + setCustomCoords({ x: '', y: '' }); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleCandidateClick = (candidate: Candidate) => { + setSelectedCandidate(candidate.id); + if (candidate.center) { + setCustomCoords({ + x: candidate.center.x.toString(), + y: candidate.center.y.toString() + }); + } + }; + + const handleSubmit = () => { + if (customCoords.x && customCoords.y) { + onChoose({ + x: parseInt(customCoords.x), + y: parseInt(customCoords.y) + }); + } + }; + + return ( +
+
+
+

Self-Healing Required

+ +
+ +
+ {stepInfo && ( +
+ + Etape {stepInfo.index + 1}/{stepInfo.total} + +

{stepInfo.error || "L'ancre visuelle n'a pas ete trouvee"}

+
+ )} + +
+

Alternatives possibles

+ + {candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
handleCandidateClick(candidate)} + > +
+ #{candidate.element_id} + + Confiance: {(candidate.score * 100).toFixed(0)}% + + {candidate.distance !== undefined && ( + + Distance: {candidate.distance.toFixed(0)}px + + )} +
+ {candidate.center && ( +
+ ({candidate.center.x}, {candidate.center.y}) +
+ )} +
+ ))} +
+ ) : ( +

Aucun candidat similaire trouve

+ )} +
+ +
+

Coordonnees manuelles

+
+ + +
+
+
+ +
+ + + +
+
+ + +
+ ); +} diff --git a/visual_workflow_builder/frontend_v4/src/services/api.ts b/visual_workflow_builder/frontend_v4/src/services/api.ts index a3e39fadd..1b8c6d825 100644 --- a/visual_workflow_builder/frontend_v4/src/services/api.ts +++ b/visual_workflow_builder/frontend_v4/src/services/api.ts @@ -2,7 +2,7 @@ * API Client - Toutes les interactions avec le backend */ -import type { AppState, Workflow, Step, Execution, Capture, ActionType } from '../types'; +import type { AppState, Workflow, Step, Execution, Capture, ActionType, ExecutionMode } from '../types'; const API_BASE = '/api/v3'; @@ -61,6 +61,18 @@ export async function deleteWorkflow(workflowId: string): Promise<{ deleted_id: return request('DELETE', `/workflow/${workflowId}`); } +export async function updateWorkflow( + workflowId: string, + updates: { + name?: string; + description?: string; + tags?: string[]; + triggerExamples?: string[]; + } +): Promise<{ workflow: Workflow }> { + return request('PUT', `/workflow/${workflowId}`, updates); +} + // Steps export async function addStep( workflowId: string, @@ -126,8 +138,14 @@ export function getAnchorThumbnailUrl(anchorId: string): string { } // Execution -export async function startExecution(workflowId?: string): Promise<{ execution: Execution; session: AppState['session'] }> { - return request('POST', '/execute/start', workflowId ? { workflow_id: workflowId } : {}); +export async function startExecution( + workflowId?: string, + mode?: ExecutionMode +): Promise<{ execution: Execution; session: AppState['session'] }> { + return request('POST', '/execute/start', { + workflow_id: workflowId, + execution_mode: mode || 'basic' + }); } export async function pauseExecution(): Promise<{ execution: Execution }> { @@ -147,6 +165,29 @@ export async function getExecutionStatus(): Promise<{ is_paused: boolean; execution: Execution | null; session: AppState['session']; + // Self-healing interactif + waiting_for_choice?: boolean; + candidates?: Array<{ + id: number; + element_id: number; + score: number; + bbox: { x1: number; y1: number; x2: number; y2: number }; + center?: { x: number; y: number }; + distance?: number; + }>; + current_step_info?: { + index: number; + total: number; + original_bbox?: { x: number; y: number; width: number; height: number }; + error?: string; + }; }> { return request('GET', '/execute/status'); } + +// Self-Healing Interactif +export async function submitHealingChoice( + choice: 'skip' | 'static' | { x: number; y: number } +): Promise<{ success: boolean; choice: unknown }> { + return request('POST', '/execute/healing/choose', { choice }); +}