- Ajout venv Python (.venv) avec pip bootstrap (python3-venv absent) - Correction OCR Linux : marqueur TTC/TVA tolère la confusion T↔I (Tesseract 5.3.4 Linux lit parfois "TIc" au lieu de "TTC") - test_leclerc.py : skipif si Tesseract absent, xfail pour test de somme (précision OCR variable entre plateformes, solution LLM vision prévue) - Résultat : 77 passent, 1 xfail, 0 échec (vs 78 sur Windows) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 lines
3.0 KiB
Python
100 lines
3.0 KiB
Python
"""
|
|
Client HTTP bas niveau pour l'API LLM compatible OpenAI.
|
|
|
|
Ce module ne contient qu'une seule fonction publique : call_llm().
|
|
Il ne connaît pas la logique de normalisation — c'est le rôle de normalizer.py.
|
|
|
|
Exceptions levées :
|
|
LLMUnavailable — serveur injoignable (timeout, connexion refusée)
|
|
LLMError — réponse HTTP ≥ 400 ou format de réponse inattendu
|
|
"""
|
|
|
|
import logging
|
|
|
|
import requests
|
|
|
|
from tickettracker import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LLMUnavailable(Exception):
|
|
"""Le serveur LLM est injoignable (réseau, timeout)."""
|
|
|
|
|
|
class LLMError(Exception):
|
|
"""L'API LLM a retourné une erreur (HTTP ≥ 400 ou réponse malformée)."""
|
|
|
|
|
|
def call_llm(
|
|
messages: list[dict],
|
|
*,
|
|
model: str | None = None,
|
|
timeout: int | None = None,
|
|
) -> str:
|
|
"""Appelle l'API LLM et retourne le texte brut de la réponse.
|
|
|
|
Args:
|
|
messages: Liste de messages au format OpenAI
|
|
[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
|
|
model: Nom du modèle (défaut : config.LLM_MODEL)
|
|
timeout: Timeout en secondes (défaut : config.LLM_TIMEOUT)
|
|
|
|
Returns:
|
|
Le texte du premier choix de la réponse.
|
|
|
|
Raises:
|
|
LLMUnavailable: Si le serveur est injoignable.
|
|
LLMError: Si l'API retourne une erreur ou une réponse inattendue.
|
|
"""
|
|
_model = model or config.LLM_MODEL
|
|
_timeout = timeout if timeout is not None else config.LLM_TIMEOUT
|
|
|
|
if not config.LLM_API_KEY:
|
|
raise LLMError(
|
|
"Clé API LLM manquante. "
|
|
"Définissez la variable d'environnement TICKETTRACKER_LLM_API_KEY."
|
|
)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {config.LLM_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"model": _model,
|
|
"messages": messages,
|
|
"temperature": 0.1, # faible variabilité : on veut un format stable
|
|
}
|
|
|
|
logger.debug("Appel LLM %s (model=%s, timeout=%ds)", config.LLM_URL, _model, _timeout)
|
|
|
|
try:
|
|
response = requests.post(
|
|
config.LLM_URL,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=_timeout,
|
|
)
|
|
except requests.exceptions.Timeout:
|
|
raise LLMUnavailable(
|
|
f"Timeout après {_timeout}s lors de l'appel au LLM ({config.LLM_URL})."
|
|
)
|
|
except requests.exceptions.ConnectionError as e:
|
|
raise LLMUnavailable(
|
|
f"Impossible de joindre le serveur LLM ({config.LLM_URL}) : {e}"
|
|
)
|
|
|
|
if not response.ok:
|
|
raise LLMError(
|
|
f"Erreur API LLM : HTTP {response.status_code} — {response.text[:200]}"
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
return data["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, ValueError) as e:
|
|
raise LLMError(
|
|
f"Réponse LLM inattendue (impossible d'extraire le contenu) : {e}\n"
|
|
f"Réponse brute : {response.text[:300]}"
|
|
) from e
|