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

@@ -26,7 +26,7 @@ repos:
name: mypy
entry: mypy
language: python
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0"]
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0", "types-requests>=2.31.0", "icalendar>=5.0.0", "pronotepy>=2.15.0", "responses>=0.25.0", "pytest-mock>=3.10.0", "feedparser>=6.0.0", "caldav>=1.3.0", "openai>=1.0.0"]
types: [python]
pass_filenames: true

View File

@@ -0,0 +1,45 @@
"""Factory de sélection du fournisseur de synthèse IA."""
from __future__ import annotations
import logging
from pronote_sync.config.settings import AISettings
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
from pronote_sync.synthesis.provider import SynthesisProvider
logger = logging.getLogger(__name__)
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
"""Sélectionne le fournisseur de synthèse IA selon la configuration.
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
journalisé et ``None`` est retourné.
:param settings: Paramètres IA.
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
:rtype: SynthesisProvider | None
"""
if not settings.enabled:
return None
if not settings.api_key:
return None
api_key = settings.api_key.get_secret_value()
base_url = settings.base_url
model = settings.model or "gpt-4o-mini"
if settings.provider == "litellm":
try:
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
except ImportError:
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
return None
return LiteLLMSynthesisProvider(api_key=api_key, base_url=base_url, model=model)
return OpenAISynthesisProvider(api_key=api_key, base_url=base_url, model=model)

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

View File

@@ -0,0 +1,143 @@
"""Fournisseur de synthèse IA via le SDK ``openai``.
Ce module définit :class:`OpenAISynthesisProvider`, un fournisseur de
synthèse IA qui construit un prompt utilisateur en français à partir des
données de synchronisation et appelle l'API OpenAI via le SDK ``openai``.
La méthode :meth:`OpenAISynthesisProvider.generate` ne lève jamais
d'exception : tout échec est journalisé (message rédigé) et dégradé en
retour ``None``.
"""
from __future__ import annotations
import logging
from openai import OpenAI
from pronote_sync.models.diff import AgendaChangeType
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
from pronote_sync.utils.redaction import redact_secrets
logger = logging.getLogger(__name__)
__all__ = ["OpenAISynthesisProvider"]
class OpenAISynthesisProvider:
"""Fournisseur de synthèse IA utilisant le SDK ``openai``.
Ne lève jamais d'exception : en cas d'échec, :meth:`generate` retourne
``None``.
"""
SYSTEM_PROMPT = (
"Tu es un assistant qui rédige des synthèses quotidiennes pour les parents d'élèves.\n"
"Rédige une synthèse en 3 à 5 phrases maximum, dans un ton chaleureux et sobre.\n"
"N'utilise aucun emoji, aucun titre, aucune liste.\n"
"Ne mentionne aucun horaire sauf si l'heure est explicitement dans les données.\n"
"N'invente rien. Base-toi uniquement sur les informations fournies.\n"
"Si aucune information importante n'est disponible, retourne une chaîne vide."
)
MAX_LENGTH = 800
TIMEOUT = 30
TEMPERATURE = 0.3
def __init__(
self,
api_key: str,
base_url: str | None = None,
model: str = "gpt-4o-mini",
client: OpenAI | None = None,
) -> None:
"""Initialise le fournisseur OpenAI.
:param api_key: Clé API OpenAI.
:param base_url: URL de base de l'API (``None`` pour l'URL par défaut).
:param model: Identifiant du modèle.
:param client: Client ``OpenAI`` pré-configuré (utilisé par les
tests). Si ``None``, un client est créé à partir des autres
paramètres.
"""
if client is not None:
self._client = client
elif base_url is not None:
self._client = OpenAI(api_key=api_key, base_url=base_url, timeout=self.TIMEOUT)
else:
self._client = OpenAI(api_key=api_key, timeout=self.TIMEOUT)
self._model = model
@staticmethod
def _build_prompt(input_data: SynthesisInput) -> str:
"""Construit le prompt utilisateur français à partir des données d'entrée.
Les informations sont structurées par sections (date cible, changements
d'agenda, messages non lus, événements scolaires), séparées par des
sauts de ligne. Si aucune information importante n'est disponible
(pas de changement, de message non lu ni d'événement), un message par
défaut est retourné.
:param input_data: Données de synthèse (diff agenda, messages, événements).
:return: Prompt utilisateur formaté.
:rtype: str
"""
lines: list[str] = [f"Date cible : {input_data.target_date.strftime('%d/%m/%Y')}"]
if input_data.agenda_diff is not None:
for change in input_data.agenda_diff.changes:
if change.type == AgendaChangeType.ADDED and change.lesson is not None:
lines.append(f"Cours ajouté : {change.lesson.subject}")
elif (
change.type == AgendaChangeType.REMOVED
and change.theoretical_lesson is not None
):
lines.append(f"Cours supprimé : {change.theoretical_lesson.subject}")
elif change.type == AgendaChangeType.MODIFIED and change.lesson is not None:
lines.append(f"Cours modifié : {change.lesson.subject} ({change.details})")
for msg in input_data.messages:
if not msg.read:
lines.append(f"Message de {msg.author}: {msg.title}")
for event in input_data.school_events:
lines.append(f"{event.label} du {event.from_date.strftime('%d/%m')}")
if len(lines) == 1:
return "Aucune information importante à signaler."
return "\n".join(lines)
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 :meth:`_build_prompt`, appelle le modèle et
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:
prompt = self._build_prompt(input_data)
response = self._client.chat.completions.create(
model=self._model,
messages=[
{"role": "system", "content": self.SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
max_tokens=self.MAX_LENGTH,
temperature=self.TEMPERATURE,
)
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 : %s", redact_secrets(str(e)))
return None

View File

@@ -0,0 +1,27 @@
"""Protocole de fournisseur de synthèse IA."""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from pronote_sync.models.synthesis import SynthesisInput, SynthesisResult
__all__ = ["SynthesisProvider"]
@runtime_checkable
class SynthesisProvider(Protocol):
"""Protocole pour un fournisseur de synthèse IA.
L'implémentation ne doit jamais lever d'exception : en cas
d'échec, retourner ``None``.
"""
def generate(self, input_data: SynthesisInput) -> SynthesisResult | None:
"""Génère une synthèse IA à partir des données d'entrée.
: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.
:rtype: SynthesisResult | None
"""
...

View File

@@ -116,3 +116,7 @@ warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
strict = true
[[tool.mypy.overrides]]
module = "litellm"
ignore_missing_imports = true

View File

@@ -0,0 +1,523 @@
"""Tests unitaires pour le module de synthèse IA (M9).
Ce module teste les fournisseurs de synthèse IA (OpenAI, LiteLLM) et la
factory de sélection, en vérifiant :
- La construction du prompt à partir des données d'entrée.
- Le comportement dégradé (retour ``None``) en cas d'erreur.
- L'absence de fuite de secrets dans les logs.
- La troncature et le nettoyage des réponses.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import AISettings
from pronote_sync.models.agenda import (
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.synthesis import SynthesisInput
from pronote_sync.synthesis import get_synthesis_provider
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
from pronote_sync.synthesis.provider import SynthesisProvider
if TYPE_CHECKING:
from pytest_mock import MockerFixture
# --- Fixtures ---
@pytest.fixture
def target_date() -> date:
"""Date cible pour les tests."""
return date(2025, 9, 15)
@pytest.fixture
def empty_input(target_date: date) -> SynthesisInput:
"""Entrée de synthèse vide (sans agenda_diff, messages ou événements)."""
return SynthesisInput(target_date=target_date, agenda_diff=None)
@pytest.fixture
def lesson() -> Lesson:
"""Cours pour les tests."""
return Lesson(
id="lesson-1",
start=datetime(2025, 9, 15, 8, 0),
end=datetime(2025, 9, 15, 9, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
rooms=("Salle 101",),
group=None,
status=LessonStatus.NORMAL,
content=None,
homework_blocks=(),
)
@pytest.fixture
def theoretical_lesson() -> TheoreticalLesson:
"""Cours théorique pour les tests."""
return TheoreticalLesson(
id="theoretical-1",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
rooms=("Salle 101",),
)
@pytest.fixture
def agenda_diff_added(lesson: Lesson, target_date: date) -> AgendaDiff:
"""AgendaDiff avec un cours ajouté."""
return AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(type=AgendaChangeType.ADDED, lesson=lesson, theoretical_lesson=None),
),
)
@pytest.fixture
def agenda_diff_removed(theoretical_lesson: TheoreticalLesson, target_date: date) -> AgendaDiff:
"""AgendaDiff avec un cours supprimé."""
return AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical_lesson,
),
),
)
@pytest.fixture
def agenda_diff_modified(
lesson: Lesson, theoretical_lesson: TheoreticalLesson, target_date: date
) -> AgendaDiff:
"""AgendaDiff avec un cours modifié."""
return AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=theoretical_lesson,
details="Changement de salle",
),
),
)
@pytest.fixture
def unread_message() -> Message:
"""Message non lu pour les tests."""
return Message(
id="msg-1",
type=MessageType.INFORMATION,
title="Réunion",
content="Réunion à 14h",
author="M. Martin",
date=datetime(2025, 9, 14, 10, 0),
read=False,
)
@pytest.fixture
def read_message() -> Message:
"""Message lu pour les tests."""
return Message(
id="msg-2",
type=MessageType.INFORMATION,
title="Ancien message",
content="Contenu ancien",
author="M. Martin",
date=datetime(2025, 9, 10, 10, 0),
read=True,
)
@pytest.fixture
def school_event() -> SchoolEvent:
"""Événement scolaire pour les tests."""
return SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Vacances de Noël",
from_date=date(2025, 12, 20),
to_date=date(2026, 1, 5),
)
# --- OpenAISynthesisProvider._build_prompt tests ---
def test_build_prompt_empty_input(empty_input: SynthesisInput) -> None:
"""Vérifie que _build_prompt retourne le message par défaut pour une entrée vide."""
result = OpenAISynthesisProvider._build_prompt(empty_input)
assert result == "Aucune information importante à signaler."
def test_build_prompt_with_added_lesson(lesson: Lesson, target_date: date) -> None:
"""Vérifie que _build_prompt inclut les cours ajoutés."""
input_data = SynthesisInput(
target_date=target_date,
agenda_diff=AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(type=AgendaChangeType.ADDED, lesson=lesson, theoretical_lesson=None),
),
),
)
result = OpenAISynthesisProvider._build_prompt(input_data)
assert "Cours ajouté : Mathématiques" in result
assert f"Date cible : {target_date.strftime('%d/%m/%Y')}" in result
def test_build_prompt_with_removed_lesson(
theoretical_lesson: TheoreticalLesson, target_date: date
) -> None:
"""Vérifie que _build_prompt inclut les cours supprimés."""
input_data = SynthesisInput(
target_date=target_date,
agenda_diff=AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical_lesson,
),
),
),
)
result = OpenAISynthesisProvider._build_prompt(input_data)
assert "Cours supprimé : Mathématiques" in result
def test_build_prompt_with_modified_lesson(
lesson: Lesson, theoretical_lesson: TheoreticalLesson, target_date: date
) -> None:
"""Vérifie que _build_prompt inclut les cours modifiés avec détails."""
input_data = SynthesisInput(
target_date=target_date,
agenda_diff=AgendaDiff(
target_date=target_date,
changes=(
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=theoretical_lesson,
details="Changement de salle",
),
),
),
)
result = OpenAISynthesisProvider._build_prompt(input_data)
assert "Cours modifié : Mathématiques (Changement de salle)" in result
def test_build_prompt_with_unread_messages(
unread_message: Message, read_message: Message, target_date: date
) -> None:
"""Vérifie que _build_prompt inclut uniquement les messages non lus."""
input_data = SynthesisInput(
target_date=target_date,
agenda_diff=None,
messages=[unread_message, read_message],
)
result = OpenAISynthesisProvider._build_prompt(input_data)
assert f"Message de {unread_message.author}: {unread_message.title}" in result
assert f"Message de {read_message.author}: {read_message.title}" not in result
def test_build_prompt_with_school_events(school_event: SchoolEvent, target_date: date) -> None:
"""Vérifie que _build_prompt formate correctement les événements scolaires."""
input_data = SynthesisInput(
target_date=target_date,
agenda_diff=None,
school_events=[school_event],
)
result = OpenAISynthesisProvider._build_prompt(input_data)
assert f"{school_event.label} du {school_event.from_date.strftime('%d/%m')}" in result
# --- OpenAISynthesisProvider.generate tests ---
def test_generate_success(mocker: MockerFixture, target_date: date) -> None:
"""Vérifie que generate retourne SynthesisResult en cas de succès."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Synthèse OK."
mock_client.chat.completions.create.return_value = mock_response
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is not None
assert result.text == "Synthèse OK."
def test_generate_returns_none_on_empty_response(mocker: MockerFixture, target_date: date) -> None:
"""Vérifie que generate retourne None si la réponse est vide."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = None
mock_client.chat.completions.create.return_value = mock_response
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
def test_generate_returns_none_on_empty_string_response(
mocker: MockerFixture, target_date: date
) -> None:
"""Vérifie que generate retourne None si la réponse est une chaîne vide."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = ""
mock_client.chat.completions.create.return_value = mock_response
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
def test_generate_truncates_to_max_length(mocker: MockerFixture, target_date: date) -> None:
"""Vérifie que generate tronque la réponse à MAX_LENGTH."""
mock_client = MagicMock()
mock_response = MagicMock()
long_content = "A" * 1000
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = long_content
mock_client.chat.completions.create.return_value = mock_response
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is not None
assert result.text is not None
assert result.text == "A" * 800
assert len(result.text) == OpenAISynthesisProvider.MAX_LENGTH
def test_generate_strips_whitespace(mocker: MockerFixture, target_date: date) -> None:
"""Vérifie que generate supprime les espaces en début et fin."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "\n Synthèse \n"
mock_client.chat.completions.create.return_value = mock_response
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is not None
assert result.text == "Synthèse"
def test_generate_returns_none_on_exception(
mocker: MockerFixture, target_date: date, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie que generate retourne None en cas d'exception et journalise l'erreur."""
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = Exception("timeout")
provider = OpenAISynthesisProvider(api_key="test-key", client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
assert "Échec de la génération de la synthèse IA" in caplog.text
def test_generate_does_not_leak_api_key(
mocker: MockerFixture, target_date: date, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie que generate ne fuite pas l'api_key dans les logs."""
sentinel = "sk-secret-12345"
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = Exception(f"key={sentinel}")
provider = OpenAISynthesisProvider(api_key=sentinel, client=mock_client)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
assert sentinel not in caplog.text
assert "REDACTED" in caplog.text
# --- LiteLLMSynthesisProvider.generate tests ---
def test_litellm_generate_success(mocker: MockerFixture, target_date: date) -> None:
"""Vérifie que LiteLLMSynthesisProvider.generate retourne SynthesisResult en cas de succès."""
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
mock_completion = mocker.patch("litellm.completion")
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Synthèse litellm."
mock_completion.return_value = mock_response
provider = LiteLLMSynthesisProvider(api_key="test-key", model="gpt-4o-mini")
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is not None
assert result.text == "Synthèse litellm."
def test_litellm_generate_passes_api_key_and_timeout(
mocker: MockerFixture, target_date: date
) -> None:
"""Vérifie que LiteLLMSynthesisProvider.generate passe api_key et timeout."""
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
mock_completion = mocker.patch("litellm.completion")
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Synthèse litellm."
mock_completion.return_value = mock_response
provider = LiteLLMSynthesisProvider(
api_key="test-key", # pragma: allowlist secret
base_url="https://api.example.com",
model="gpt-4o-mini",
)
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
provider.generate(input_data)
mock_completion.assert_called_once()
call_kwargs: dict[str, Any] = mock_completion.call_args[1]
assert call_kwargs["api_key"] == "test-key" # pragma: allowlist secret
assert call_kwargs["base_url"] == "https://api.example.com"
assert call_kwargs["timeout"] == LiteLLMSynthesisProvider.TIMEOUT
def test_litellm_generate_returns_none_on_exception(
mocker: MockerFixture, target_date: date
) -> None:
"""Vérifie que LiteLLMSynthesisProvider.generate retourne None en cas d'exception."""
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
mock_completion = mocker.patch("litellm.completion")
mock_completion.side_effect = Exception("error")
provider = LiteLLMSynthesisProvider(api_key="test-key", model="gpt-4o-mini")
input_data = SynthesisInput(target_date=target_date, agenda_diff=None)
result = provider.generate(input_data)
assert result is None
# --- get_synthesis_provider factory tests ---
def test_factory_returns_none_if_disabled() -> None:
"""Vérifie que la factory retourne None si la synthèse IA est désactivée."""
settings = AISettings(enabled=False, api_key=SecretStr("test-key"))
result = get_synthesis_provider(settings)
assert result is None
def test_factory_returns_none_if_no_api_key() -> None:
"""Vérifie que la factory retourne None si aucune clé API n'est configurée."""
settings = AISettings(enabled=True, api_key=None)
result = get_synthesis_provider(settings)
assert result is None
def test_factory_returns_openai_provider_by_default() -> None:
"""Vérifie que la factory retourne OpenAISynthesisProvider par défaut."""
settings = AISettings(
enabled=True,
api_key=SecretStr("test-key"),
provider="openai",
)
result = get_synthesis_provider(settings)
assert isinstance(result, OpenAISynthesisProvider)
def test_factory_returns_litellm_provider_when_requested() -> None:
"""Vérifie que la factory retourne LiteLLMSynthesisProvider si demandé."""
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
settings = AISettings(
enabled=True,
api_key=SecretStr("test-key"),
provider="litellm",
)
result = get_synthesis_provider(settings)
assert isinstance(result, LiteLLMSynthesisProvider)
def test_factory_returns_none_with_warning_if_litellm_not_available(
mocker: MockerFixture, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie que la factory retourne None avec un avertissement si litellm n'est pas disponible."""
# Forcer une ImportError lors de l'import
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == "pronote_sync.synthesis.litellm":
raise ImportError("No module named 'litellm'")
return original_import(name, *args, **kwargs)
mocker.patch.object(builtins, "__import__", mock_import)
settings = AISettings(
enabled=True,
api_key=SecretStr("test-key"),
provider="litellm",
)
result = get_synthesis_provider(settings)
assert result is None
assert "Extra 'ai-litellm' requis pour le provider litellm" in caplog.text
# --- Provider protocol compliance ---
def test_openai_provider_is_synthesis_provider() -> None:
"""Vérifie que OpenAISynthesisProvider implémente SynthesisProvider."""
provider = OpenAISynthesisProvider(api_key="test-key")
assert isinstance(provider, SynthesisProvider)
def test_litellm_provider_is_synthesis_provider() -> None:
"""Vérifie que LiteLLMSynthesisProvider implémente SynthesisProvider."""
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
provider = LiteLLMSynthesisProvider(api_key="test-key")
assert isinstance(provider, SynthesisProvider)