461 lines
15 KiB
Python
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
|
|
}
|