From 92833060e21fffda22e09aa285a24e67dfcf7c70 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 17:07:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(M9):=20synth=C3=A8se=20IA=20=E2=80=94?= =?UTF-8?q?=20protocole,=20providers=20OpenAI/litellm,=20factory,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: opencode/test-engineer --- .pre-commit-config.yaml | 2 +- pronote_sync/synthesis/__init__.py | 45 +++ pronote_sync/synthesis/litellm.py | 102 ++++++ pronote_sync/synthesis/openai.py | 143 ++++++++ pronote_sync/synthesis/provider.py | 27 ++ pyproject.toml | 4 + tests/unit/test_synthesis.py | 523 +++++++++++++++++++++++++++++ 7 files changed, 845 insertions(+), 1 deletion(-) create mode 100644 pronote_sync/synthesis/litellm.py create mode 100644 pronote_sync/synthesis/openai.py create mode 100644 pronote_sync/synthesis/provider.py create mode 100644 tests/unit/test_synthesis.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c6bbb8..7d6ce9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/pronote_sync/synthesis/__init__.py b/pronote_sync/synthesis/__init__.py index e69de29..042f39f 100644 --- a/pronote_sync/synthesis/__init__.py +++ b/pronote_sync/synthesis/__init__.py @@ -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) diff --git a/pronote_sync/synthesis/litellm.py b/pronote_sync/synthesis/litellm.py new file mode 100644 index 0000000..8071d66 --- /dev/null +++ b/pronote_sync/synthesis/litellm.py @@ -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 diff --git a/pronote_sync/synthesis/openai.py b/pronote_sync/synthesis/openai.py new file mode 100644 index 0000000..e2895c3 --- /dev/null +++ b/pronote_sync/synthesis/openai.py @@ -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 diff --git a/pronote_sync/synthesis/provider.py b/pronote_sync/synthesis/provider.py new file mode 100644 index 0000000..d49ba25 --- /dev/null +++ b/pronote_sync/synthesis/provider.py @@ -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 + """ + ... diff --git a/pyproject.toml b/pyproject.toml index cc94a22..9440163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/unit/test_synthesis.py b/tests/unit/test_synthesis.py new file mode 100644 index 0000000..9f8d161 --- /dev/null +++ b/tests/unit/test_synthesis.py @@ -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) From 775b5ae9cccc89eff8052164f356884e74f85f79 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 17:07:47 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20marquer=20le=20jalon=20M9=20(synth?= =?UTF-8?q?=C3=A8se=20IA)=20comme=20termin=C3=A9=20dans=20TODO.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: opencode/coder --- TODO.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 490c194..79df3c3 100644 --- a/TODO.md +++ b/TODO.md @@ -173,12 +173,12 @@ Comparer l'agenda réel et l'agenda théorique pour générer les ajouts/suppres Générer une synthèse optionnelle via un fournisseur IA, avec mode dégradé strict. -- [ ] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception). -- [ ] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3). -- [ ] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`). -- [ ] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`). -- [ ] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse). -- [ ] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA). +- [x] Créer `synthesis/provider.py` : protocole `SynthesisProvider.generate → Optional[SynthesisResult]` (ne lève jamais d'exception). +- [x] Créer `synthesis/openai.py` : `OpenAISynthesisProvider` (httpx, prompt système FR, max 800 car., timeout 30 s, temp 0.3). +- [x] Créer `synthesis/litellm.py` : `LiteLLMSynthesisProvider` (optionnel, extra `ai-litellm`). +- [x] Créer `synthesis/__init__.py` : factory `get_synthesis_provider(settings)` (OpenAI par défaut, litellm si `AI_PROVIDER=litellm`). +- [x] Mode dégradé : clé absente / timeout / exception → retour `None` (le pipeline continue sans synthèse). +- [x] Respecter les contraintes (3-5 phrases, ton sobre, pas d'emoji dans le texte IA). ### Critères d'acceptation - `generate` retourne une synthèse ≤ 800 car. conforme au prompt système.