""" Configuration centralisée du modèle VLM (Vision-Language Model). Point unique de configuration pour le modèle VLM utilisé dans tout le pipeline. Gère la variable d'environnement RPA_VLM_MODEL avec fallback automatique si le modèle configuré n'est pas disponible dans Ollama. Ordre de résolution du modèle : 1. Variable d'env RPA_VLM_MODEL (prioritaire) 2. Variable d'env VLM_MODEL (compatibilité) 3. Modèle par défaut : gemma4:latest Fallback automatique : Si le modèle choisi n'est pas trouvé dans Ollama, on essaie les modèles de fallback dans l'ordre (FALLBACK_VLM_MODELS). """ import logging import os from typing import List, Optional import requests logger = logging.getLogger(__name__) # Modèle VLM par défaut — Gemma 4 latest (8B dense, Q4_K_M) # Nécessite think=false dans le payload (sinon tokens vides sur Ollama >=0.20) DEFAULT_VLM_MODEL = "gemma4:latest" # Modèles de fallback, testés dans l'ordre si le modèle principal n'est pas dispo FALLBACK_VLM_MODELS = ["qwen3-vl:8b", "0000/ui-tars-1.5-7b-q8_0:7b"] # Endpoint Ollama par défaut DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434" # Cache du modèle résolu (évite de requêter Ollama à chaque appel) _resolved_model: Optional[str] = None _resolved_model_checked = False def get_vlm_model( endpoint: str = DEFAULT_OLLAMA_ENDPOINT, force_check: bool = False, ) -> str: """Retourne le nom du modèle VLM à utiliser, avec fallback automatique. Vérifie la disponibilité du modèle dans Ollama au premier appel, puis cache le résultat pour les appels suivants. Args: endpoint: URL de l'API Ollama force_check: Forcer une nouvelle vérification (ignorer le cache) Returns: Nom du modèle VLM disponible (ex: "gemma4:latest") """ global _resolved_model, _resolved_model_checked if _resolved_model_checked and not force_check: return _resolved_model # Lire le modèle configuré depuis l'environnement configured = ( os.environ.get("RPA_VLM_MODEL") or os.environ.get("VLM_MODEL") or DEFAULT_VLM_MODEL ) # Vérifier la disponibilité dans Ollama available = _list_ollama_models(endpoint) if available is None: # Ollama non joignable — utiliser le modèle configuré sans vérification logger.warning( "Ollama non joignable (%s) — utilisation de '%s' sans vérification", endpoint, configured, ) _resolved_model = configured _resolved_model_checked = True return _resolved_model # Vérifier si le modèle configuré est disponible if _model_available(configured, available): logger.info("VLM model: %s (configuré, disponible)", configured) _resolved_model = configured _resolved_model_checked = True return _resolved_model # Fallback : essayer les modèles alternatifs logger.warning( "Modèle VLM '%s' non trouvé dans Ollama. Recherche d'un fallback...", configured, ) # Construire la liste de fallback complète fallback_candidates = [DEFAULT_VLM_MODEL] + FALLBACK_VLM_MODELS for candidate in fallback_candidates: if candidate == configured: continue # Déjà testé if _model_available(candidate, available): logger.info( "VLM model: %s (fallback, '%s' non disponible)", candidate, configured, ) _resolved_model = candidate _resolved_model_checked = True return _resolved_model # Aucun fallback trouvé — utiliser le modèle configuré quand même # (Ollama le téléchargera peut-être au premier appel) logger.warning( "Aucun modèle VLM trouvé dans Ollama. " "Modèles disponibles : %s. Utilisation de '%s' par défaut.", [m for m in available if "vl" in m.lower() or "gemma" in m.lower()], configured, ) _resolved_model = configured _resolved_model_checked = True return _resolved_model def reset_vlm_model_cache(): """Réinitialiser le cache du modèle résolu. Utile après un changement de configuration ou un pull de modèle. """ global _resolved_model, _resolved_model_checked _resolved_model = None _resolved_model_checked = False def is_thinking_model(model_name: str) -> bool: """Détermine si un modèle est un modèle 'thinking' (qwen3). Les modèles thinking nécessitent un assistant prefill pour éviter le mode réflexion interne qui peut durer >180s avec des images. Args: model_name: Nom du modèle (ex: "qwen3-vl:8b", "gemma4:e4b") Returns: True si le modèle est de type thinking (nécessite prefill workaround) """ return "qwen3" in model_name.lower() def needs_think_false(model_name: str) -> bool: """Détermine si un modèle nécessite think=false dans le payload. Sur Ollama >=0.20, gemma4 produit des tokens vides si think n'est pas explicitement désactivé. Ce flag doit être envoyé dans le payload chat. Args: model_name: Nom du modèle (ex: "gemma4:latest", "gemma4:e4b") Returns: True si le modèle nécessite think=false """ return "gemma4" in model_name.lower() def _list_ollama_models(endpoint: str) -> Optional[List[str]]: """Lister les modèles disponibles dans Ollama. Returns: Liste des noms de modèles, ou None si Ollama n'est pas joignable. """ try: resp = requests.get(f"{endpoint}/api/tags", timeout=5) if resp.status_code == 200: models = resp.json().get("models", []) return [m["name"] for m in models] except Exception: pass return None def _model_available(model_name: str, available_models: List[str]) -> bool: """Vérifie si un modèle est disponible dans la liste Ollama. Supporte la correspondance exacte et le match sans tag de version (ex: "gemma4:e4b" match "gemma4:e4b" ou "gemma4:e4b-q4_0"). """ # Match exact if model_name in available_models: return True # Match par préfixe (sans tag) — "gemma4:e4b" match "gemma4:e4b" base_name = model_name.split(":")[0] if ":" in model_name else model_name for m in available_models: if m.startswith(base_name + ":"): return True return False