feat(M9): synthèse IA — protocole, providers OpenAI/litellm, factory, tests

Synthèse optionnelle via SDK openai (client injectable, prompt système
FR, max 800 car., timeout 30 s, temp 0.3). Mode dégradé strict :
generate() ne lève jamais, retourne None si clé absente/timeout/erreur.
Provider litellm optionnel (extra ai-litellm) réutilisant le prompt
OpenAI. Factory get_synthesis_provider() selon AISettings. 23 tests
sans réseau, couverture synthesis/ 93%.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-07 17:07:05 +02:00
parent 4d11ec9b22
commit 92833060e2
7 changed files with 845 additions and 1 deletions

View File

@@ -0,0 +1,102 @@
"""Fournisseur de synthèse IA via ``litellm``.
Ce module définit :class:`LiteLLMSynthesisProvider`, un fournisseur de
synthèse IA qui délègue l'appel à ``litellm.completion`` en réutilisant le
prompt système et la construction de prompt de
:class:`~pronote_sync.synthesis.openai.OpenAISynthesisProvider`. La méthode
:meth:`LiteLLMSynthesisProvider.generate` ne lève jamais d'exception : tout
échec est journalisé (message rédigé) et dégradé en retour ``None``.
Ce module nécessite l'extra ``ai-litellm`` (le paquet ``litellm``).
"""
from __future__ import annotations
import logging
from typing import Any
import litellm
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
__all__ = ["LiteLLMSynthesisProvider"]
class LiteLLMSynthesisProvider:
"""Fournisseur de synthèse IA utilisant ``litellm``.
Réutilise le prompt système et la construction de prompt de
:class:`OpenAISynthesisProvider`. Ne lève jamais d'exception : en cas
d'échec, :meth:`generate` retourne ``None``.
"""
SYSTEM_PROMPT = OpenAISynthesisProvider.SYSTEM_PROMPT
MAX_LENGTH = OpenAISynthesisProvider.MAX_LENGTH
TIMEOUT = OpenAISynthesisProvider.TIMEOUT
TEMPERATURE = OpenAISynthesisProvider.TEMPERATURE
def __init__(
self, api_key: str, base_url: str | None = None, model: str = "gpt-4o-mini"
) -> None:
"""Initialise le fournisseur LiteLLM.
:param api_key: Clé API du fournisseur.
:param base_url: URL de base de l'API (``None`` pour l'URL par défaut).
:param model: Identifiant du modèle.
"""
self._api_key = api_key
self._base_url = base_url
self._model = model
def generate(self, input_data: SynthesisInput) -> SynthesisResult | None:
"""Génère une synthèse IA à partir des données d'entrée.
Construit le prompt via ``OpenAISynthesisProvider._build_prompt``,
appelle ``litellm.completion`` en transmettant explicitement
``api_key`` et ``base_url`` (uniquement si non ``None``) ainsi que
``timeout``, puis nettoie la réponse (troncature à
:attr:`MAX_LENGTH`, suppression des sauts de ligne en début et fin).
Ne lève jamais d'exception : toute erreur est journalisée (message
rédigé) et dégradée en retour ``None``.
:param input_data: Données de synthèse (diff agenda, messages, événements).
:return: Résultat de la synthèse, ou ``None`` en cas d'échec ou de
réponse vide.
:rtype: SynthesisResult | None
"""
try:
completion_kwargs: dict[str, Any] = {
"model": self._model,
"messages": [
{"role": "system", "content": self.SYSTEM_PROMPT},
{
"role": "user",
"content": OpenAISynthesisProvider._build_prompt(input_data),
},
],
"max_tokens": self.MAX_LENGTH,
"temperature": self.TEMPERATURE,
"timeout": self.TIMEOUT,
}
if self._api_key is not None:
completion_kwargs["api_key"] = self._api_key
if self._base_url is not None:
completion_kwargs["base_url"] = self._base_url
response = litellm.completion(**completion_kwargs)
content = response.choices[0].message.content
if not content:
return None
synthesis_text = content[: self.MAX_LENGTH].strip()
if not synthesis_text:
return None
return SynthesisResult(text=synthesis_text)
except Exception as e:
logger.error(
"Échec de la génération de la synthèse IA (litellm) : %s",
redact_secrets(str(e)),
)
return None