180 lines
7.9 KiB
Python
180 lines
7.9 KiB
Python
"""Adapter Ollama — implémentation concrète des ports LLMProvider et LLMChatProvider.
|
||
|
||
Isole le reste de l'application des spécificités du protocole Ollama
|
||
(URL /api/generate, /api/chat, payload, parsing). Pour swap vers OpenAI
|
||
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
||
"""
|
||
import json
|
||
import logging
|
||
from typing import AsyncIterator
|
||
|
||
import httpx
|
||
|
||
from app.core.config import Settings
|
||
from app.domain.models import ChatMessage
|
||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OllamaLLMProvider:
|
||
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
|
||
|
||
Satisfait implicitement (duck typing) à la fois `LLMProvider` (endpoint
|
||
/api/generate, appel unique) et `LLMChatProvider` (endpoint /api/chat,
|
||
streaming token par token).
|
||
"""
|
||
|
||
def __init__(self, settings: Settings) -> None:
|
||
self._base_url = settings.ollama_base_url
|
||
self._model = settings.llm_model
|
||
self._timeout = settings.llm_timeout_seconds
|
||
self._num_ctx = settings.llm_num_ctx
|
||
|
||
def _build_options(self, temperature: float | None) -> dict[str, object]:
|
||
"""Construit le dict `options` attendu par Ollama (hyperparamètres).
|
||
|
||
`num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut
|
||
2048 et tronque silencieusement les gros prompts (Structural Context
|
||
du Lore enrichi depuis b9). `temperature` n'est ajoutée que si
|
||
fournie par le use case (sinon Ollama utilise son défaut).
|
||
"""
|
||
options: dict[str, object] = {"num_ctx": self._num_ctx}
|
||
if temperature is not None:
|
||
options["temperature"] = temperature
|
||
return options
|
||
|
||
async def generate(
|
||
self,
|
||
prompt: str,
|
||
*,
|
||
output_format: str | None = None,
|
||
temperature: float | None = None,
|
||
) -> str:
|
||
url = f"{self._base_url}/api/generate"
|
||
payload: dict[str, object] = {
|
||
"model": self._model,
|
||
"prompt": prompt,
|
||
"stream": False,
|
||
"options": self._build_options(temperature),
|
||
}
|
||
if output_format is not None:
|
||
payload["format"] = output_format
|
||
|
||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||
try:
|
||
response = await client.post(url, json=payload)
|
||
if response.status_code >= 400:
|
||
body = response.text
|
||
try:
|
||
err_obj = json.loads(body)
|
||
err_msg = err_obj.get("error") or body
|
||
except json.JSONDecodeError:
|
||
err_msg = body
|
||
raise LLMProviderError(
|
||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||
)
|
||
except httpx.ConnectTimeout as exc:
|
||
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
|
||
raise LLMProviderError(
|
||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||
) from exc
|
||
except httpx.TimeoutException as exc:
|
||
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
|
||
# donc le dépasser = génération trop lente pour la sortie demandée
|
||
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
|
||
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
|
||
# morceau en deux moitiés (sortie 2× plus courte) à la place.
|
||
raise LLMGenerationTimeout(
|
||
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
|
||
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
|
||
"modèle plus rapide."
|
||
) from exc
|
||
except httpx.HTTPError as exc:
|
||
raise LLMProviderError(
|
||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||
) from exc
|
||
|
||
data = response.json()
|
||
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
|
||
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
|
||
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
|
||
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
|
||
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
|
||
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
|
||
done_reason = data.get("done_reason")
|
||
if done_reason and done_reason != "stop":
|
||
logger.warning(
|
||
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
|
||
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
|
||
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
|
||
"d'import ou augmentez num_ctx (Paramètres).",
|
||
done_reason, data.get("prompt_eval_count"),
|
||
data.get("eval_count"), self._num_ctx,
|
||
)
|
||
return data["response"]
|
||
|
||
async def stream_chat(
|
||
self,
|
||
messages: list[ChatMessage],
|
||
*,
|
||
system_prompt: str | None = None,
|
||
temperature: float | None = None,
|
||
) -> AsyncIterator[str]:
|
||
"""Streame depuis Ollama /api/chat. Parse le NDJSON ligne par ligne.
|
||
|
||
Ollama renvoie un JSON par ligne au fil de la génération :
|
||
- étapes intermédiaires : `{"message": {"content": "token"}, "done": false}`
|
||
- étape finale : `{"done": true, ...}`
|
||
|
||
On yield chaque token non-vide au consommateur, qui se charge du
|
||
formatage SSE (c'est la responsabilité du controller HTTP, pas
|
||
de l'adapter LLM).
|
||
"""
|
||
url = f"{self._base_url}/api/chat"
|
||
|
||
payload_messages: list[dict[str, str]] = []
|
||
if system_prompt:
|
||
payload_messages.append({"role": "system", "content": system_prompt})
|
||
payload_messages.extend(
|
||
{"role": m.role, "content": m.content} for m in messages
|
||
)
|
||
|
||
payload: dict[str, object] = {
|
||
"model": self._model,
|
||
"messages": payload_messages,
|
||
"stream": True,
|
||
"options": self._build_options(temperature),
|
||
}
|
||
|
||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||
try:
|
||
async with client.stream("POST", url, json=payload) as response:
|
||
if response.status_code >= 400:
|
||
# On lit le body d'erreur pour le remonter a l'utilisateur,
|
||
# sinon on ne voit que "500 Internal Server Error" sans
|
||
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
|
||
# num_ctx trop grand pour la VRAM, etc.).
|
||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||
try:
|
||
err_obj = json.loads(body)
|
||
err_msg = err_obj.get("error") or body
|
||
except json.JSONDecodeError:
|
||
err_msg = body
|
||
raise LLMProviderError(
|
||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||
)
|
||
async for line in response.aiter_lines():
|
||
if not line.strip():
|
||
continue
|
||
chunk = json.loads(line)
|
||
if chunk.get("done"):
|
||
break
|
||
token = chunk.get("message", {}).get("content", "")
|
||
if token:
|
||
yield token
|
||
except httpx.HTTPError as exc:
|
||
raise LLMProviderError(
|
||
f"Erreur lors du streaming Ollama : {exc}"
|
||
) from exc
|