""" Gestionnaire LLM pour le raisonnement visuel avec Ollama. Interface vers les modèles vision-langage pour la prise de décision. """ import json import base64 from io import BytesIO from typing import Dict, List, Optional, Any, Tuple import numpy as np from PIL import Image try: import ollama except ImportError: ollama = None from .logger import Logger class LLMManager: """ Gestionnaire LLM pour le raisonnement visuel utilisant Ollama. Supporte les modèles vision-langage comme Qwen 2.5-VL et CogVLM. """ def __init__( self, model_name: str = "qwen2.5-vl:3b", ollama_host: str = "localhost:11434", logger: Optional[Logger] = None, fallback_to_vision: bool = True ): """ Initialise le gestionnaire LLM. Args: model_name: Nom du modèle Ollama ollama_host: Hôte Ollama logger: Instance du logger fallback_to_vision: Utiliser la vision pure en cas d'échec LLM """ self.model_name = model_name self.ollama_host = ollama_host self.logger = logger self.fallback_to_vision = fallback_to_vision # Initialiser le client Ollama self._init_client() def _init_client(self): """Initialise le client Ollama.""" if ollama is None: raise ImportError( "Ollama n'est pas installé. " "Installez-le avec: pip install ollama" ) try: self.client = ollama.Client(host=self.ollama_host) # Vérifier que le modèle est disponible models = self.client.list() model_names = [m.model for m in models.models] if hasattr(models, 'models') else [] if self.model_name not in model_names: if self.logger: self.logger.log_action({ "action": "model_not_found", "model": self.model_name, "available_models": model_names }) print(f"Avertissement: Le modèle {self.model_name} n'est pas trouvé.") print(f"Modèles disponibles: {model_names}") if self.logger: self.logger.log_action({ "action": "llm_client_initialized", "model": self.model_name, "host": self.ollama_host }) except Exception as e: error_msg = f"Erreur lors de l'initialisation du client Ollama: {e}" if self.logger: self.logger.log_action({ "action": "llm_init_error", "error": str(e) }) if not self.fallback_to_vision: raise RuntimeError(error_msg) print(f"Avertissement: {error_msg}") self.client = None def _image_to_base64(self, image: np.ndarray) -> str: """ Convertit une image numpy en base64. Args: image: Image numpy array (H, W, C) Returns: String base64 de l'image """ # Convertir BGR vers RGB si nécessaire if len(image.shape) == 3 and image.shape[2] == 3: image_rgb = image[:, :, ::-1] else: image_rgb = image # Convertir en PIL Image pil_image = Image.fromarray(image_rgb.astype(np.uint8)) # Convertir en base64 buffered = BytesIO() pil_image.save(buffered, format="PNG") img_str = base64.b64encode(buffered.getvalue()).decode() return img_str def reason_about_detections( self, detections: List[Dict[str, Any]], context: Dict[str, Any], intent: str ) -> Dict[str, Any]: """ Utilise le VLM pour sélectionner la meilleure action parmi les détections. Args: detections: Liste de détections avec labels, bbox, images ROI context: Contexte actuel (fenêtre, historique, etc.) intent: Intention utilisateur Returns: Dictionnaire avec l'élément sélectionné et le score de confiance """ if not detections: return { "selected_element": None, "confidence": 0.0, "reasoning": "Aucune détection disponible" } # Fallback si pas de client Ollama if self.client is None and self.fallback_to_vision: return self._fallback_to_vision_only(detections) try: # Préparer le prompt elements_desc = [ f"- Élément {i+1}: {d['label']} (confiance: {d['confidence']:.2f})" for i, d in enumerate(detections) ] prompt = f"""Tu es un assistant d'automatisation RPA. Analyse ces éléments UI détectés et détermine lequel correspond le mieux à l'intention de l'utilisateur. Intention: {intent} Contexte: Fenêtre '{context.get('window', 'Inconnue')}' Éléments détectés: {chr(10).join(elements_desc)} Réponds UNIQUEMENT avec un JSON au format suivant: {{ "element_index": , "confidence": , "reasoning": "" }}""" # Préparer les images images = [] for detection in detections: if 'roi_image' in detection and detection['roi_image'] is not None: img_b64 = self._image_to_base64(detection['roi_image']) images.append(img_b64) # Générer la réponse response = self.client.generate( model=self.model_name, prompt=prompt, images=images if images else None, stream=False ) # Parser la réponse result = self._parse_llm_response(response['response'], detections) if self.logger: self.logger.log_action({ "action": "llm_reasoning", "intent": intent, "num_detections": len(detections), "selected_index": result.get("element_index"), "confidence": result.get("confidence") }) return result except Exception as e: if self.logger: self.logger.log_action({ "action": "llm_reasoning_error", "error": str(e) }) if self.fallback_to_vision: return self._fallback_to_vision_only(detections) return { "selected_element": None, "confidence": 0.0, "reasoning": f"Erreur LLM: {str(e)}" } def _fallback_to_vision_only( self, detections: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Fallback vers la sélection basée uniquement sur la confiance vision. Args: detections: Liste de détections Returns: Dictionnaire avec l'élément le plus confiant """ if not detections: return { "selected_element": None, "confidence": 0.0, "reasoning": "Aucune détection" } # Sélectionner la détection avec la confiance la plus élevée best_detection = max(detections, key=lambda d: d.get('confidence', 0.0)) best_index = detections.index(best_detection) return { "element_index": best_index, "selected_element": best_detection, "confidence": best_detection.get('confidence', 0.0), "reasoning": "Sélection basée sur la confiance vision (fallback)", "llm_score": 0.0 } def _parse_llm_response( self, response: str, detections: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Parse la réponse du LLM. Args: response: Réponse texte du LLM detections: Liste des détections originales Returns: Dictionnaire avec l'élément sélectionné et les métadonnées """ try: # Extraire le JSON de la réponse response_clean = response.strip() # Chercher le JSON dans la réponse start_idx = response_clean.find('{') end_idx = response_clean.rfind('}') + 1 if start_idx != -1 and end_idx > start_idx: json_str = response_clean[start_idx:end_idx] parsed = json.loads(json_str) element_index = parsed.get('element_index', 0) confidence = parsed.get('confidence', 0.5) reasoning = parsed.get('reasoning', '') # Valider l'index if 0 <= element_index < len(detections): selected = detections[element_index] return { "element_index": element_index, "selected_element": selected, "confidence": confidence, "reasoning": reasoning, "llm_score": confidence } # Si le parsing échoue, fallback return self._fallback_to_vision_only(detections) except Exception as e: if self.logger: self.logger.log_action({ "action": "llm_parse_error", "error": str(e), "response": response }) return self._fallback_to_vision_only(detections) def generate_with_vision( self, prompt: str, images: Optional[List[np.ndarray]] = None ) -> str: """ Génération multi-modale avec images. Args: prompt: Prompt texte images: Liste d'images numpy arrays Returns: Réponse générée """ if self.client is None: return "Erreur: Client Ollama non disponible" try: # Convertir les images en base64 images_b64 = [] if images: print(f"[LLM] Conversion de {len(images)} images en base64...") for i, img in enumerate(images): try: img_b64 = self._image_to_base64(img) images_b64.append(img_b64) print(f"[LLM] Image {i+1}/{len(images)} convertie ({len(img_b64)} bytes)") except Exception as e: print(f"[LLM] Erreur conversion image {i+1}: {e}") raise # Générer print(f"[LLM] Appel Ollama avec modèle {self.model_name}...") print(f"[LLM] Prompt: {prompt[:100]}...") print(f"[LLM] Images: {len(images_b64) if images_b64 else 0}") response = self.client.generate( model=self.model_name, prompt=prompt, images=images_b64 if images_b64 else None, stream=False, options={ "temperature": 0.3, # Basse température pour réponses plus déterministes "num_predict": 20, # Limiter à 20 tokens (environ 3-4 mots) "top_p": 0.9, "top_k": 40 } ) print(f"[LLM] Réponse brute: {response}") # Qwen3-VL peut mettre la réponse dans 'thinking' au lieu de 'response' result = response.get('response', '') # Si response est vide, essayer thinking if not result and 'thinking' in response: thinking = response['thinking'] print(f"[LLM] Response vide, extraction depuis thinking: '{thinking}'") # Nettoyer les balises spéciales de Qwen result = thinking.replace('<|im_start|>', '').replace('<|im_end|>', '').replace('', '').replace('', '').strip() print(f"[LLM] Réponse extraite: '{result}' (longueur: {len(result)})") return result except Exception as e: print(f"[LLM] ❌ EXCEPTION: {e}") import traceback traceback.print_exc() if self.logger: self.logger.log_action({ "action": "generation_error", "error": str(e) }) return f"Erreur de génération: {str(e)}" def score_action_relevance( self, action: Dict[str, Any], intent: str ) -> float: """ Calcule un score de pertinence pour une action donnée. Args: action: Dictionnaire décrivant l'action intent: Intention utilisateur Returns: Score de confiance (0.0-1.0) """ if self.client is None: # Retourner un score neutre si pas de LLM return 0.5 try: prompt = f"""Évalue la pertinence de cette action par rapport à l'intention utilisateur. Intention: {intent} Action: {action.get('action_type', 'unknown')} sur '{action.get('target_element', 'unknown')}' Réponds UNIQUEMENT avec un score entre 0.0 et 1.0 (ex: 0.85)""" response = self.client.generate( model=self.model_name, prompt=prompt, stream=False ) # Extraire le score response_text = response['response'].strip() # Chercher un nombre décimal import re match = re.search(r'0\.\d+|1\.0|0|1', response_text) if match: score = float(match.group()) return max(0.0, min(1.0, score)) return 0.5 except Exception as e: if self.logger: self.logger.log_action({ "action": "scoring_error", "error": str(e) }) return 0.5 def is_available(self) -> bool: """ Vérifie si le service LLM est disponible. Returns: True si disponible, False sinon """ if self.client is None: return False try: self.client.list() return True except: return False def get_model_info(self) -> Dict[str, Any]: """ Retourne des informations sur le modèle. Returns: Dictionnaire d'informations """ return { "model_name": self.model_name, "host": self.ollama_host, "available": self.is_available(), "fallback_enabled": self.fallback_to_vision }