feat: migration Windows → Ubuntu, stabilisation suite de tests
- 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>
This commit is contained in:
99
tickettracker/llm/client.py
Normal file
99
tickettracker/llm/client.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user