Initial commit
This commit is contained in:
460
geniusia2/core/llm_manager.py
Normal file
460
geniusia2/core/llm_manager.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user