Files
Geniusia_v2/geniusia2/core/llm_manager.py
2026-03-05 00:20:25 +01:00

461 lines
15 KiB
Python

"""
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": <index de l'élément (0-{len(detections)-1})>,
"confidence": <score de confiance 0.0-1.0>,
"reasoning": "<explication brève>"
}}"""
# 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('<think>', '').replace('</think>', '').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
}