"""Facade LLM pour l'analyse de texte (anciennement `Gemma`). Charge un backend pluggable (mlx par defaut, ou LM Studio) selon les reglages et expose `generate` / `generate_json` consommes par tout le pipeline. Toute la logique agnostique du moteur vit ici : calcul des parametres depuis les Settings, retrait de la chaine de pensee (modeles a raisonnement) et `generate_json` tolerant qui extrait le premier objet/array JSON valide de la sortie du modele. """ from __future__ import annotations from typing import Any, Optional from ...settings import Settings, get_settings from ._text import _extract_json, _strip_reasoning from .factory import get_llm_backend # Hook de streaming optionnel. Si defini, `generate()` diffuse chaque morceau de # texte AU FIL de la generation (pensee comprise, avant tout nettoyage) en # appelant ce callback. Utilise par `inkflow benchmark --stream` pour voir les # tokens en temps reel. None -> generation par lot classique (plus rapide). _TOKEN_SINK: Optional[Any] = None def set_token_sink(callback) -> None: """Definit (ou retire avec None) le callback de streaming des tokens.""" global _TOKEN_SINK _TOKEN_SINK = callback def _model_ref_for(backend: str, settings: Settings) -> str: """Reference de modele par defaut pour un backend donne.""" if backend == "lmstudio": return settings.lmstudio_model return settings.gemma_model class LLM: """Petite facade multi-backend pour piloter le LLM d'analyse.""" def __init__(self, model_id: Optional[str] = None, backend: Optional[str] = None): settings = get_settings() self.backend_name = backend or settings.gemma_backend self.model_ref = model_id or _model_ref_for(self.backend_name, settings) self._backend = None def _ensure_loaded(self) -> None: if self._backend is None: self._backend = get_llm_backend(self.backend_name, self.model_ref) def generate( self, prompt: str, *, system: Optional[str] = None, max_tokens: Optional[int] = None, temperature: Optional[float] = None, ) -> str: """Genere une reponse texte a partir d'un prompt (template de chat). `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. """ self._ensure_loaded() settings = get_settings() if max_tokens is None: max_tokens = settings.gemma_max_tokens # En mode raisonnement, plafond dedie (garde-fou anti-boucle) ; la # generation s'arrete de toute facon des que le JSON post-pensee est # complet (cf. arret anticipe des backends). if settings.gemma_reasoning: max_tokens = max(max_tokens, settings.gemma_reasoning_max_tokens) if temperature is None: temperature = settings.gemma_temperature # Decodage glouton (temp 0) + raisonnement = boucles de pensee sans fin. # On force un echantillonnage minimal en mode raisonnement. if settings.gemma_reasoning and temperature == 0.0: temperature = settings.gemma_reasoning_temperature messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) raw = self._backend.complete( messages, max_tokens=max_tokens, temperature=temperature, reasoning=settings.gemma_reasoning, token_sink=_TOKEN_SINK, ) # Retire la chaine de pensee des modeles a raisonnement (sinon des # fragments de la "pensee" parasitent l'extraction JSON en aval). if settings.gemma_reasoning: return _strip_reasoning(raw) return raw def generate_json( self, prompt: str, *, system: Optional[str] = None, max_tokens: Optional[int] = None, temperature: Optional[float] = None, retries: int = 1, ) -> Any: """Genere puis parse un JSON. Reessaie en cas d'echec de parsing. `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. """ last_err: Optional[Exception] = None for attempt in range(retries + 1): raw = self.generate( prompt, system=system, max_tokens=max_tokens, temperature=temperature if attempt == 0 else 0.0, ) try: return _extract_json(raw) except Exception as exc: # noqa: BLE001 last_err = exc raise ValueError(f"Reponse JSON invalide apres {retries + 1} essais: {last_err}")