Files
rpa_vision_v3/core/detection/vlm_config.py
Dom 4dc7d840d6 feat(p1x): de-hardcode VLM models/endpoints to vlm_config (DGX-ready)
Migre les call-sites VLM serveur vers la configuration centrale pour
fonctionner sur DGX (tunnel Ollama 11434), où gemma4:* est absent et le
port Docker 11435 est mort.

- task_planner, replay_verifier, domain_context, ir_builder, resolve_engine
  (popup): modele -> vlm_config.get_vlm_model(), defaut 11435 -> 11434
  (override GEMMA4_PORT legacy conserve)
- resolve_engine (grounding bbox x2): nouvel helper
  vlm_config.get_bbox_grounding_model() (var dediee RPA_BBOX_GROUNDING_MODEL,
  fallback RPA_GROUNDING_MODEL puis qwen2.5vl:7b-rpa) -> desambiguise le
  conflit D5-v3b, bbox_2d + num_ctx 4096 preserves
- safety_checks_provider: defaut -> get_vlm_model(), override
  RPA_SAFETY_CHECKS_LLM_MODEL preserve
- ui_detector: default_factory + resolution lazy (corrige aussi un gel a
  l'import), pas d'appel reseau a l'import
- field_extractor: property lazy via vlm_config

TDD strict (RED->GREEN), 305 tests verts, tests mockes HTTP (zero dependance
DGX reel), aucun alias Ollama.

Hors perimetre (arbitrage Dom): client Lea agent_v1/executor.py (gele),
chemin V4 observe_reason_act (RPA_REASONING_MODEL), core/config.py defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:06:03 +02:00

312 lines
12 KiB
Python

"""
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)
# Bench 2026-05-16 : tentatives qwen2.5vl:7b et :3b écartées (runtime Ollama
# avec context = 10-13 GB → débordent toutes en 100% CPU sur RTX 5070 12 GB).
# qwen3-vl:8b écarté : think:false ignoré → tout en thinking field, pas de réponse.
# gemma4:latest reste le seul stable malgré son cold start ~20s (1 fois par run).
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, 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.
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
)
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