Picarones / picarones /web /engine_utils.py
Claude
fix(web): durcir le parsing XML (defusedxml en dépendance dure) + exceptions précises
de46be0 unverified
Raw
History Blame
7.26 kB
"""Helpers métier des routeurs ``engines`` et ``models``.
Ces utilitaires détectent la disponibilité des moteurs OCR et LLM
locaux, listent leurs modèles, et inférent leurs capacités (text /
vision) à partir du nom — quand l'API du provider ne fournit pas
cette information directement.
"""
from __future__ import annotations
import json
# ──────────────────────────────────────────────────────────────────────────
# Tables de capacités par famille
# ──────────────────────────────────────────────────────────────────────────
MISTRAL_TEXT_ONLY = frozenset({
"ministral-3b-latest", "ministral-8b-latest", "mistral-tiny",
"mistral-tiny-latest", "open-mistral-7b", "open-mixtral-8x7b",
"mistral-small-latest", "mistral-small-2409",
})
"""Modèles Mistral explicitement text-only (pas de support vision)."""
MISTRAL_TEXT_ONLY_PREFIXES = (
"ministral", "open-mistral", "open-mixtral", "codestral",
"mistral-embed", "mistral-tiny",
)
"""Préfixes de modèles Mistral à traiter comme text-only."""
OLLAMA_VISION_FAMILIES = frozenset({
"llava", "bakllava", "moondream", "minicpm-v", "llama3.2-vision",
"llava-llama3", "llava-phi3", "nanollava",
})
"""Familles Ollama multimodales connues."""
# ──────────────────────────────────────────────────────────────────────────
# Disponibilité des moteurs locaux
# ──────────────────────────────────────────────────────────────────────────
def check_engine(engine_id: str, module_name: str, label: str = "") -> dict:
"""Vérifie qu'un moteur OCR local est installé et retourne son statut.
On ne fait que ``__import__(module_name)`` + ``getattr(mod, "__version__")`` —
seules ``ImportError`` et ``AttributeError`` peuvent légitimement
survenir. Tout autre type d'exception (panne disque, OSError…) est
propagé pour ne pas masquer un vrai bug.
"""
label = label or engine_id.replace("_", " ").title()
try:
__import__(module_name)
installed = True
except ImportError:
installed = False
version = ""
if installed and engine_id == "tesseract":
try:
import pytesseract
version = str(pytesseract.get_tesseract_version())
except (ImportError, pytesseract.TesseractNotFoundError, OSError):
# ``TesseractNotFoundError`` : binaire absent ; ``OSError`` :
# ``PATH`` manquant ; ``ImportError`` : racine du sous-import.
version = "installé"
elif installed:
try:
mod = __import__(module_name)
version = getattr(mod, "__version__", "installé")
except (ImportError, AttributeError):
version = "installé"
return {
"id": engine_id,
"label": label,
"type": "ocr",
"available": installed,
"version": version,
"status": "available" if installed else "not_installed",
}
def fetch_ollama_info() -> tuple[bool, list[str]]:
"""Vérifie la disponibilité d'Ollama et liste ses modèles en un seul appel HTTP.
Capture explicitement ``URLError`` (Ollama pas démarré, port fermé,
timeout) et ``json.JSONDecodeError`` (réponse non-JSON inattendue).
Toute autre exception (par ex. ``OSError`` sur lecture réseau,
``UnicodeDecodeError``) est aussi traitée comme "Ollama
indisponible" — c'est l'intention du caller (UX dégradée gracieuse).
"""
import urllib.error
import urllib.request
try:
with urllib.request.urlopen("http://localhost:11434/api/tags", timeout=2) as r:
if r.status != 200:
return False, []
data = json.loads(r.read().decode())
except (urllib.error.URLError, OSError, json.JSONDecodeError, UnicodeDecodeError):
return False, []
models = [m.get("name", "") for m in data.get("models", [])]
return True, models
def get_tesseract_langs() -> list[str]:
"""Liste les langues Tesseract installées (avec fallback éditorial).
``TesseractNotFoundError`` quand le binaire est absent du ``PATH``,
``ImportError`` si pytesseract n'est pas installé, ``OSError`` sur
appel système échoué — tous traités comme "lister les langues
indisponible, fallback à la liste éditoriale historique".
"""
try:
import pytesseract
langs = pytesseract.get_languages(config="")
return sorted(lg for lg in langs if lg != "osd")
except (ImportError, OSError):
# ``pytesseract.TesseractNotFoundError`` hérite d'``OSError``.
return ["fra", "lat", "eng", "deu", "ita", "spa"]
# ──────────────────────────────────────────────────────────────────────────
# Inférence de capacités par nom de modèle
# ──────────────────────────────────────────────────────────────────────────
def model_entry(model_id: str, capabilities: list[str]) -> dict:
"""Crée une entrée modèle avec son ID et ses capacités."""
return {"id": model_id, "capabilities": capabilities}
def infer_mistral_capabilities(model_id: str) -> list[str]:
mid = model_id.lower()
# Modèles explicitement vision (Pixtral)
if "pixtral" in mid:
return ["text", "vision"]
# Modèles explicitement text-only
if mid in MISTRAL_TEXT_ONLY or any(mid.startswith(p) for p in MISTRAL_TEXT_ONLY_PREFIXES):
return ["text"]
# Mistral Large et modèles récents non-identifiés → vision par défaut
if "mistral-large" in mid or "mistral-medium" in mid:
return ["text", "vision"]
# Par défaut, marquer comme text-only (plus sûr que de supposer vision)
return ["text"]
def infer_openai_capabilities(model_id: str) -> list[str]:
mid = model_id.lower()
if "gpt-4o" in mid or "gpt-4-turbo" in mid or "gpt-4.1" in mid or "o1" in mid or "o3" in mid:
return ["text", "vision"]
return ["text"]
def infer_ollama_capabilities(model_name: str) -> list[str]:
base = model_name.split(":")[0].lower()
if any(base.startswith(family) for family in OLLAMA_VISION_FAMILIES):
return ["text", "vision"]
return ["text"]
__all__ = [
"MISTRAL_TEXT_ONLY",
"MISTRAL_TEXT_ONLY_PREFIXES",
"OLLAMA_VISION_FAMILIES",
"check_engine",
"fetch_ollama_info",
"get_tesseract_langs",
"model_entry",
"infer_mistral_capabilities",
"infer_openai_capabilities",
"infer_ollama_capabilities",
]