Regroupe le WIP non committé requis pour le clone/runtime DGX (Option A) : - api_stream.py : préflight replay + smoke santé modèles + handler 403 WP-B - de-hardcode VLM : vlm_config, gpu/*, vram_orchestrator, ollama_manager - stream_processor, semantic_matcher, agent_chat (app/planner/intent) - workflows.db (acquis ; le transfert artifacts le mettra à jour + rewrite chemins) - docs : plans DGX, benchmarks VLM/grounders, recherche SOTA, coordination 8 juin Snapshot destiné à la branche poc-dgx poussée sur Gitea pour cloner le DGX. Scan anti-secret : clean. graphify (repo embarqué) exclu. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
358 lines
14 KiB
Python
358 lines
14 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 — 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
|