File size: 7,257 Bytes
0171c0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de46be0
 
 
 
 
 
 
0171c0a
 
 
 
 
 
 
 
 
 
 
 
de46be0
 
 
0171c0a
 
 
 
 
de46be0
0171c0a
 
 
 
 
 
 
 
 
 
 
 
 
de46be0
 
 
 
 
 
 
 
0171c0a
 
 
 
 
 
 
de46be0
0171c0a
de46be0
 
0171c0a
 
 
de46be0
 
 
 
 
 
 
0171c0a
 
 
 
de46be0
 
0171c0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""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",
]