Files
rpa_vision_v3/core/detection/model_health.py
Dom d00fe7b00b feat(health): gate vision + détection des modèles aveugles
Détecte les modèles VLM/grounding « aveugles » (capabilities sans vision, ex.
UI-TARS réimporté sans mmproj) pour éviter le HTTP 500 silencieux masqué par
la cascade de grounding.

- core/detection/model_health.py : has_vision_capability() (cache, fail-open)
  + smoke_check_models()
- core/execution/input_handler.py : gate vision dans _grounding_ui_tars
  (skip propre vers niveau 3 si modèle aveugle, plus de 500 silencieux)
- tests/unit/test_model_health.py : 6 tests (vision/aveugle/fail-open/cache/smoke)

Incident 2026-06-08 : UI-TARS sans mmproj -> niveau 2 cascade en 500 silencieux,
non détecté (hors chemin runtime démo + échec avalé par fallback + zéro test).
NB : le smoke non bloquant au démarrage (api_stream.py startup) reste dans le WIP
de la branche, mélangé au préflight non committé.

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

98 lines
3.7 KiB
Python

"""Santé des modèles VLM/grounding — détection des modèles « aveugles ».
Motivation (incident 2026-06-08) : un modèle de grounding réimporté sans son projecteur
vision (`mmproj`) déclare des `capabilities` sans `vision` et renvoie HTTP 500 sur toute
requête image. Dans la cascade `find_element_on_screen`, l'échec était avalé (`return None`)
et masqué par le fallback VLM → panne invisible malgré les tests.
Ce module permet de :
- **gater** un appel image : vérifier que le modèle a `vision` avant de lui envoyer une image
(évite le 500, skip propre vers le niveau suivant) ;
- **smoke-tester** les modèles de grounding/VLM au démarrage : rendre une panne visible
immédiatement plutôt que noyée dans un `warning` runtime.
Volontairement sans dépendance lourde : un simple appel `/api/show` Ollama.
"""
from __future__ import annotations
import logging
import os
from typing import Dict, List
import requests
logger = logging.getLogger(__name__)
DEFAULT_ENDPOINT = os.environ.get("OLLAMA_URL", "http://localhost:11434")
# Cache (endpoint::model) -> bool. Un modèle ne change pas de capacité en cours de session.
_VISION_CACHE: Dict[str, bool] = {}
def has_vision_capability(
model: str,
endpoint: str = DEFAULT_ENDPOINT,
*,
use_cache: bool = True,
timeout: float = 5.0,
) -> bool:
"""Retourne True si le modèle Ollama déclare la capacité ``vision``.
Interroge ``/api/show`` et lit ``capabilities``. Résultat mis en cache par
``(endpoint, model)``.
**Fail-open** : en cas d'erreur réseau/HTTP sur ``/api/show`` (indisponibilité
transitoire), retourne ``True`` — on ne bloque pas le grounding sur un doute ;
l'appel image en aval gérera l'échec. Seule une réponse explicite **sans** ``vision``
retourne ``False`` (modèle réellement aveugle).
"""
key = f"{endpoint}::{model}"
if use_cache and key in _VISION_CACHE:
return _VISION_CACHE[key]
try:
resp = requests.post(f"{endpoint}/api/show", json={"name": model}, timeout=timeout)
if resp.status_code != 200:
logger.debug("model_health: /api/show %s → HTTP %s (fail-open)", model, resp.status_code)
return True
caps = resp.json().get("capabilities", []) or []
has_vision = "vision" in caps
_VISION_CACHE[key] = has_vision
if not has_vision:
logger.warning(
"model_health: modèle '%s' SANS capacité 'vision' (capabilities=%s) — "
"modèle aveugle, les requêtes image échoueront",
model,
caps,
)
return has_vision
except Exception as e: # réseau, JSON, timeout
logger.debug("model_health: échec vérification vision %s: %s (fail-open)", model, e)
return True
def smoke_check_models(models: List[str], endpoint: str = DEFAULT_ENDPOINT) -> Dict[str, bool]:
"""Vérifie la capacité ``vision`` d'une liste de modèles (au démarrage/healthcheck).
Non bloquant : logue ``info`` par modèle sain, ``error`` par modèle aveugle.
Retourne ``{model: has_vision}``.
"""
results: Dict[str, bool] = {}
for m in models:
if not m:
continue
ok = has_vision_capability(m, endpoint, use_cache=False)
results[m] = ok
if ok:
logger.info("model_health[smoke]: %s → vision OK", m)
else:
logger.error(
"model_health[smoke]: %s → AVEUGLE (pas de vision) — grounding image KO sur ce modèle",
m,
)
return results
def reset_cache() -> None:
"""Vide le cache de capacités (tests, ou après réimport d'un modèle)."""
_VISION_CACHE.clear()