Files
rpa_vision_v3/tests/unit/test_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

61 lines
2.2 KiB
Python

"""Tests unitaires de core.detection.model_health (détection modèles aveugles)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from core.detection import model_health
@pytest.fixture(autouse=True)
def _clear_cache():
model_health.reset_cache()
yield
model_health.reset_cache()
def _resp(status=200, capabilities=None):
r = MagicMock()
r.status_code = status
r.json.return_value = {"capabilities": capabilities if capabilities is not None else []}
return r
def test_vision_model_returns_true():
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["completion", "vision"])):
assert model_health.has_vision_capability("gemma4:26b", "http://x:11434") is True
def test_blind_model_returns_false():
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["tools", "completion"])):
assert model_health.has_vision_capability("0000/ui-tars-1.5-7b-q8_0:7b", "http://x:11434") is False
def test_http_error_is_fail_open():
with patch("core.detection.model_health.requests.post", return_value=_resp(404)):
assert model_health.has_vision_capability("absent:latest", "http://x:11434") is True
def test_exception_is_fail_open():
with patch("core.detection.model_health.requests.post", side_effect=ConnectionError("boom")):
assert model_health.has_vision_capability("any:latest", "http://x:11434") is True
def test_cache_avoids_second_call():
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["vision"])) as p:
model_health.has_vision_capability("m", "http://x:11434")
model_health.has_vision_capability("m", "http://x:11434")
assert p.call_count == 1 # 2e appel servi par le cache
def test_smoke_check_reports_each_model():
def _fake_post(url, json=None, timeout=None):
name = (json or {}).get("name", "")
caps = ["vision"] if name == "good" else ["completion"]
return _resp(200, caps)
with patch("core.detection.model_health.requests.post", side_effect=_fake_post):
res = model_health.smoke_check_models(["good", "blind"], "http://x:11434")
assert res == {"good": True, "blind": False}