""" 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 — DGX-safe (P1.w, 2026-06-05). # Historiquement `gemma4:latest`, mais ce modèle peut être absent du tunnel DGX # (dépull) : sans env `RPA_VLM_MODEL`/`VLM_MODEL`, le fallback tombait alors en # 404 Ollama et tout le pipeline VLM échouait avant un test Lea humain. # `qwen2.5vl:7b-rpa` est confirmé présent sur DGX et déjà utilisé par les chemins # reasoning (cf. get_reasoning_model) et bbox grounding (DEFAULT_GROUNDING_FALLBACK) # → default cohérent et sûr. `gemma4:latest` reste accessible via env explicite. DEFAULT_VLM_MODEL = "qwen2.5vl:7b-rpa" # Allow-list des modèles VLM généralistes confirmés présents sur le DGX et donc # utilisables comme default sans risque de 404. `gemma4:31b-cloud` est réservé au # benchmark P1.y (≈20 Go VRAM, latence élevée), pas au default runtime. DGX_SAFE_VLM_MODELS = ("qwen2.5vl:7b-rpa", "qwen2.5vl:7b") # 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, qwen3.5). 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", "qwen3.5:9b", "gemma4:e4b") Returns: True si le modèle est de type thinking (nécessite prefill workaround) """ return "qwen3" in model_name.lower() # ──────────────────────────────────────────────────────────────────────────── # D5-v2 (2026-05-25) : profil grounding dédié, centralisé, env-overridable # ──────────────────────────────────────────────────────────────────────────── # Profil grounding par défaut — qwen3.5:9b avec ctx 4096 et prefill JSON. # Cohérent avec décision Codex après revue Gemini : empêcher rechauffe # qwen2.5vl en ctx 8192 et garantir un chemin grounding reproductible. # ⚠️ DETTE (2026-06-05) : qwen3.5:9b est ABSENT du endpoint Ollama/DGX → le # chemin grounding JSON retombe en pratique sur DEFAULT_GROUNDING_FALLBACK # (qwen2.5vl:7b-rpa). Ce chemin JSON est donc peu/pas exercé au runtime DGX. # À pull sur le DGX OU nettoyer (aligner sur le fallback) — décision Dom. DEFAULT_GROUNDING_MODEL = "qwen3.5:9b" DEFAULT_GROUNDING_CTX = 4096 DEFAULT_GROUNDING_PREFILL = '{"x_pct":' DEFAULT_GROUNDING_TEMPERATURE = 0.0 DEFAULT_GROUNDING_NUM_PREDICT = 96 # ~80 tokens suffisent pour `{x_pct,y_pct,confidence}` DEFAULT_GROUNDING_KEEP_ALIVE = "30m" # éviter cold reload entre actions # Fallback grounding : qwen2.5vl conservé pour compat existante (rpa-tag). DEFAULT_GROUNDING_FALLBACK = "qwen2.5vl:7b-rpa" def get_grounding_profile(endpoint: str = DEFAULT_OLLAMA_ENDPOINT) -> dict: """Retourne le profil VLM pour les appels de grounding **format JSON** (réponse `{"x_pct": ..., "y_pct": ..., "confidence": ...}`). ⚠️ ATTENTION SCOPE D5-v3a (2026-05-25) : Ce profil est destiné aux appels qui consomment la sortie via prefill JSON (typiquement qwen3.5:9b avec prefill `{"x_pct":`). Il n'est PAS adapté aux appels grounding **format bbox_2d natif** de qwen2.5vl (utilisés dans `agent_v0/server_v1/resolve_engine.py:959-1013, 3008-3045` avec parsing via `core.grounding.bbox_parser.parse_bbox_to_norm`). Conflit env var connu : `resolve_engine.py:959` lit aussi `RPA_GROUNDING_MODEL` mais attend un modèle bbox_2d (qwen2.5vl). Si tu setes `RPA_GROUNDING_MODEL=qwen3.5:9b`, ce profil OK mais le site bbox legacy de resolve_engine va recevoir un modèle incompatible. Reporté à D5-v3b : renommer en `RPA_BBOX_GROUNDING_MODEL` côté legacy + introduire `OllamaClient.generate_bbox_grounding()`. Centralise la politique pour empêcher les chemins VLM de retomber sur qwen2.5vl en num_ctx=8192 (Modelfile). Sortie consommée par OllamaClient.generate_grounding(). Env vars supportées : - RPA_GROUNDING_MODEL : modèle principal (défaut qwen3.5:9b) - RPA_GROUNDING_CTX : context window (défaut 4096) - RPA_GROUNDING_FALLBACK : modèle fallback (défaut qwen2.5vl:7b-rpa) - RPA_VLM_PREFILL=false : désactive le prefill JSON (rare, debug) Returns: dict avec clés : - model: str - num_ctx: int - prefill: str ou None - temperature: float - num_predict: int - think: bool (False pour qwen3 et qwen3.5) - keep_alive: str - fallback_model: str """ model = os.environ.get("RPA_GROUNDING_MODEL", DEFAULT_GROUNDING_MODEL).strip() try: num_ctx = int(os.environ.get("RPA_GROUNDING_CTX", str(DEFAULT_GROUNDING_CTX))) except (TypeError, ValueError): num_ctx = DEFAULT_GROUNDING_CTX fallback = os.environ.get( "RPA_GROUNDING_FALLBACK", DEFAULT_GROUNDING_FALLBACK ).strip() prefill_enabled = os.environ.get("RPA_VLM_PREFILL", "true").strip().lower() not in ( "0", "false", "no", "off" ) prefill = DEFAULT_GROUNDING_PREFILL if prefill_enabled else None # think=False obligatoire pour qwen3/qwen3.5 (prefill = mécanisme principal) # et gemma4 (sinon tokens vides Ollama >=0.20). think_false = is_thinking_model(model) or needs_think_false(model) return { "model": model, "num_ctx": num_ctx, "prefill": prefill, "temperature": DEFAULT_GROUNDING_TEMPERATURE, "num_predict": DEFAULT_GROUNDING_NUM_PREDICT, "think": not think_false, # API Ollama : think=False → on envoie False "keep_alive": DEFAULT_GROUNDING_KEEP_ALIVE, "fallback_model": fallback, } def get_bbox_grounding_model() -> str: """Retourne le modèle pour le grounding **format bbox_2d natif** (qwen2.5vl). Distinct de get_grounding_profile() (format JSON {x_pct,y_pct} via prefill, défaut qwen3.5:9b). Les chemins bbox_2d de resolve_engine (`parse_bbox_to_norm` / `parse_bbox_to_norm_validated`) exigent un modèle de la famille qwen2.5vl qui émet des coordonnées en pixels. D5-v3b (2026-06-03) : désambiguïse l'env var. Historiquement le site bbox lisait `RPA_GROUNDING_MODEL`, partagé avec get_grounding_profile() qui attend un modèle JSON → conflit documenté. On introduit une var dédiée. Ordre de résolution : 1. RPA_BBOX_GROUNDING_MODEL (dédié, prioritaire) 2. RPA_GROUNDING_MODEL (rétrocompat — ancien comportement) 3. DEFAULT_GROUNDING_FALLBACK (qwen2.5vl:7b-rpa, présent sur DGX) Returns: Nom du modèle bbox_2d (ex: "qwen2.5vl:7b-rpa") """ return ( os.environ.get("RPA_BBOX_GROUNDING_MODEL") or os.environ.get("RPA_GROUNDING_MODEL") or DEFAULT_GROUNDING_FALLBACK ) # ──────────────────────────────────────────────────────────────────────────── # P1.z (2026-06-04) : résolution centralisée du modèle V4/reasoning, DGX-safe # ──────────────────────────────────────────────────────────────────────────── # Modèle de raisonnement V4/ORA par défaut — DGX-safe. # Les chemins reasoning (ORALoop, détection dialogue/popup, vram_orchestrator) # font du VLM généraliste sur screenshot (JSON action/decision), pas du grounding # bbox. Le default est aligné sur le modèle présent sur le tunnel DGX # (qwen2.5vl:7b-rpa), PAS sur `qwen2.5vl:7b` brut qui est absent du DGX → 404. DEFAULT_REASONING_MODEL = "qwen2.5vl:7b-rpa" def get_reasoning_model() -> str: """Retourne le modèle pour les chemins V4/reasoning (ORALoop, détection dialogue/popup, orchestration VRAM). Distinct du grounding (get_grounding_profile / get_bbox_grounding_model) : ici on raisonne en langage naturel + JSON sur un screenshot, pas de coordonnées. Pas d'appel réseau (résolution lazy, safe à l'import). Ordre de résolution : 1. RPA_REASONING_MODEL (dédié, prioritaire) 2. RPA_VLM_MODEL / VLM_MODEL (hérite de la config VLM existante) 3. DEFAULT_REASONING_MODEL (qwen2.5vl:7b-rpa, présent sur DGX) Returns: Nom du modèle de raisonnement (ex: "qwen2.5vl:7b-rpa"). """ return ( os.environ.get("RPA_REASONING_MODEL") or os.environ.get("RPA_VLM_MODEL") or os.environ.get("VLM_MODEL") or DEFAULT_REASONING_MODEL ) 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