Compare commits

...

4 Commits

Author SHA1 Message Date
775b5ae9cc docs: marquer le jalon M9 (synthèse IA) comme terminé dans TODO.md
Co-authored-by: opencode/coder <coder@agents.invalid>
2026-09-07 17:07:47 +02:00
92833060e2 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>
2026-09-07 17:07:05 +02:00
4d11ec9b22 merge: jalon M8 — comparaison avec l'agenda théorique + corrections FIXME_M8
M8 livré : AgendaComparator dans sync/diff.py avec matching déterministe,
tolérance ±15 min, normalisation NFKC des matières, appariement un-à-un.
Correctifs FIXME_M8 : appariement consommé, filtrage par date, détails
triés déterministes, validateur AgendaChange strict, secondes à la minute
près, documentation §8.4/§8.5 alignée.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 16:00:00 +02:00
5907c9aeaf fix(M8): corrections d'audit FIXME_M8 — appariement, date, déterminisme, validateur
Quatre corrections bloquantes/majeures de l'audit FIXME_M8 :
- Appariement un-à-un déterministe (consommation du candidat sélectionné) ;
  1 réel / 2 théoriques → 1 REMOVED, 2 réels / 1 théorique → 1 ADDED.
- Filtrage strict par date : les cours réels hors target_date sont exclus
  du matching avec un warning logé (décision architecte : pas d'exception).
- Déterminisme des détails : formatage via sorted(set(...)) au lieu de
  set(...) brut, indépendant de PYTHONHASHSEED.
- Validateur AgendaChange strict : ADDED = lesson seule, REMOVED =
  theoretical_lesson seule, MODIFIED = les deux requis.
- Comparaison à la minute près dans _is_modified (cohérent avec _matches).
- Documentation §8.4/§8.5 alignée avec l'implémentation (tolérance 15 min,
  API compare(), normalize_subject référencé, appariement consommé).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 15:59:17 +02:00
15 changed files with 1413 additions and 232 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

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true,
"line_number": 4940,
"line_number": 4809,
"is_secret": false
}
],
@@ -177,5 +177,5 @@
}
]
},
"generated_at": "2026-09-07T10:24:08Z"
"generated_at": "2026-09-07T13:30:38Z"
}

View File

@@ -3376,9 +3376,11 @@ def week_parity(
**Règle déterministe** pour les collisions entre cours théoriques et réels :
1. **Tri par identifiant stable** : Les cours sont triés par ID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
2. **Comparaison exacte** : Les créneaux horaires et la matière normalisée doivent correspondre.
2. **Comparaison des créneaux** : Les créneaux horaires sont comparés avec une tolérance symétrique de ±15 minutes sur le début et la fin séparément ; la matière normalisée doit correspondre exactement.
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
La correspondance sélectionnée est **consommée** (appariement un-à-un), ce qui rend la cardinalité du diff non ambiguë : un cours théorique ne peut être apparié qu'à un seul cours réel et inversement. Les cours théoriques non appariés sont signalés comme supprimés et les cours réels non appariés comme ajoutés.
**Exemple de tri** :
```python
# Tri des cours théoriques par ID stable (pour un matching déterministe)
@@ -3414,8 +3416,8 @@ def match_theoretical_lesson(
def to_minutes(t: time) -> int:
return t.hour * 60 + t.minute
# ``normalize_subject`` sera défini dans ``sync/diff.py`` (M8) ou dans le
# module théorique ; il normalise les matières pour un matching déterministe.
# ``normalize_subject`` est définie dans ``pronote_sync.utils.text`` ;
# elle normalise les matières pour un matching déterministe.
start_minutes = real_start.hour * 60 + real_start.minute
end_minutes = real_lesson.end.hour * 60 + real_lesson.end.minute
candidates = [
@@ -3436,203 +3438,70 @@ def match_theoretical_lesson(
### 8.5 Logique de comparaison (`sync/diff.py`)
```python
List, Tuple, Optional
from datetime import date, time, timedelta
from ..models.agenda import Lesson, TheoreticalLesson
from ..models.diff import AgendaDiff, AgendaChange, AgendaChangeType
import logging
La classe `AgendaComparator` implémente la comparaison entre l'agenda réel (Pronote) et l'agenda théorique. Son API publique est la suivante :
logger = logging.getLogger(__name__)
- **`__init__(theoretical_provider: TheoreticalAgendaProvider)`** : Le fournisseur d'agenda théorique est **strictement non optionnel**. Si `THEORETICAL_AGENDA_PATH` est `None`, le provider est désactivé et la composition root du pipeline (M11) retourne un diff vide.
- **`compare(real_lessons: list[Lesson], target_date: date) -> AgendaDiff`** : Méthode publique unique pour produire le diff.
**Comportement clé** :
- **Filtrage par date** : Les cours réels dont la date de début ne correspond pas à `target_date` sont **exclus du diff** et signalés par un `logging.warning` (identifiant et date uniquement, sans secret).
- **Appariement un-à-un déterministe** :
- Les cours réels sont triés par `id`.
- Pour chaque cours réel, les candidats théoriques **disponibles** (non encore appariés) sont cherchés.
- Le premier candidat par `id` est sélectionné et **consommé** (retiré de l'ensemble disponible via `available_theoretical_ids.discard(selected.id)`).
- **Tolérance ±15 minutes** : Comparaison en valeur absolue sur `start` et `end` séparément (symétrique, secondes ignorées).
- **Normalisation des matières** : Utilisation de `pronote_sync.utils.text.normalize_subject` (NFKC + espaces + ponctuation + minuscules).
- **Détection MODIFIED** : Un cours est marqué comme modifié si :
- Les horaires diffèrent à la minute près (secondes ignorées).
- Les matières normalisées diffèrent.
- Les ensembles de professeurs (`set(teachers)`) diffèrent.
- Les ensembles de salles (`set(rooms)`) diffèrent.
- Le statut n'est pas `LessonStatus.NORMAL`.
- **ADDED** : Cours réel sans candidat → `AgendaChange(type=ADDED, lesson=real, theoretical_lesson=None)`.
- **REMOVED** : Cours théorique non apparié → `AgendaChange(type=REMOVED, lesson=None, theoretical_lesson=theoretical)`.
- **Ordre déterministe** : Les changements sont émis dans l'ordre suivant :
1. ADDED/MODIFIED (cours réels triés par `id`).
2. REMOVED (cours théoriques triés par `id`).
- **Déterminisme des détails** : `_describe_changes` formate les enseignants et salles via `sorted(set(...))` pour garantir un texte indépendant de `PYTHONHASHSEED`.
**Extrait de l'API** :
```python
from datetime import date
from pronote_sync.models.agenda import Lesson
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.sources.theoretical.provider import TheoreticalAgendaProvider
class AgendaComparator:
"""
Compare l'agenda réel (Pronote) avec l'agenda théorique.
"""Compare l'agenda réel à l'agenda théorique pour une date cible.
:class:`AgendaComparator` apparie chaque cours réel au cours théorique qui
lui correspond (tolérance temporelle ±15 minutes et matière normalisée),
détecte les cours ajoutés, supprimés et modifiés, puis produit un
:class:`AgendaDiff` ordonné de manière déterministe.
"""
# Tolérance pour le matching des heures (en minutes)
TIME_TOLERANCE = 5
def __init__(self, theoretical_provider: TheoreticalAgendaProvider) -> None:
"""Initialise le comparateur avec un fournisseur d'agenda théorique.
def __init__(self, theoretical_provider: TheoreticalAgendaProvider):
self.theoretical_provider = theoretical_provider
def _normalize_subject(self, subject: str) -> str:
"""Normalise le nom d'une matière pour le matching."""
import re
# Supprimer les accents, passer en minuscules, supprimer les espaces multiples
subject = re.sub(r"[^\w\s]", "", subject) # Supprimer la ponctuation
subject = re.sub(r"\s+", " ", subject).strip().lower()
return subject
def _normalize_time(self, t: time) -> time:
"""Normalise une heure (arrondir à 5 minutes près)."""
minute = (t.minute // 5) * 5
return time(t.hour, minute)
def _match_lesson(
self,
real_lesson: Lesson,
theoretical_lessons: List[TheoreticalLesson],
) -> Optional[TheoreticalLesson]:
:param theoretical_provider: Fournisseur des cours théoriques.
:rtype: None
"""
Trouve le cours théorique correspondant à un cours réel.
self._theoretical_provider = theoretical_provider
Args:
real_lesson: Cours réel (Pronote).
theoretical_lessons: Liste des cours théoriques pour le même jour.
def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff:
"""Compare les cours réels aux cours théoriques pour la date cible.
Returns:
Cours théorique correspondant ou None.
**Politique de départage** :
Si plusieurs cours théoriques correspondent, on trie par UID stable (pour un matching déterministe)
et on retourne le premier.
:param real_lessons: Liste des cours réels.
:param target_date: Date cible de la comparaison.
:return: Le diff entre l'agenda réel et l'agenda théorique.
:rtype: AgendaDiff
"""
real_day = real_lesson.start.weekday()
real_start = self._normalize_time(real_lesson.start.time())
real_end = self._normalize_time(real_lesson.end.time())
real_subject = self._normalize_subject(real_lesson.subject)
# Collecter tous les candidats correspondants
candidates = []
for theoretical in theoretical_lessons:
if theoretical.day_of_week != real_day:
continue
theo_start = self._normalize_time(theoretical.start_time)
theo_end = self._normalize_time(theoretical.end_time)
theo_subject = self._normalize_subject(theoretical.subject)
# Matching sur :
# 1. Créneau horaire (avec tolérance)
# 2. Matière normalisée
if (
theo_start == real_start
and theo_end == real_end
and theo_subject == real_subject
):
candidates.append(theoretical)
# Trier les candidats par UID stable pour un matching déterministe
candidates.sort(key=lambda t: t.id)
return candidates[0] if candidates else None
def compare_for_date(self, date: date, real_lessons: List[Lesson]) -> AgendaDiff:
"""
Compare l'agenda réel et théorique pour une date donnée.
Args:
date: Date à comparer.
real_lessons: Liste des cours réels pour cette date.
Returns:
Différences entre les deux agendas.
"""
theoretical_lessons = self.theoretical_provider.get_lessons(date)
changes: List[AgendaChange] = []
# Indexer les cours réels par ID pour éviter les doublons
real_by_id = {lesson.id: lesson for lesson in real_lessons}
# 1. Trouver les cours ajoutés ou modifiés
for real_lesson in real_lessons:
matched = self._match_lesson(real_lesson, theoretical_lessons)
if matched is None:
# Cours ajouté (pas dans l'agenda théorique)
changes.append(AgendaChange(
type=AgendaChangeType.ADDED,
lesson=real_lesson,
theoretical_lesson=None,
details="Cours ajouté par rapport à l'agenda théorique",
))
else:
# Vérifier si le cours a été modifié
if (
real_lesson.subject != matched.subject
or real_lesson.teachers != matched.teachers
or real_lesson.rooms != matched.rooms
or real_lesson.status != LessonStatus.NORMAL
):
changes.append(AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=real_lesson,
theoretical_lesson=matched,
details=self._describe_changes(real_lesson, matched),
))
# 2. Trouver les cours supprimés
for theoretical in theoretical_lessons:
# Vérifier si ce cours théorique a un correspondant réel
has_match = any(
self._match_lesson(real, [theoretical]) is not None
for real in real_lessons
)
if not has_match:
changes.append(AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical,
details="Cours supprimé par rapport à l'agenda théorique",
))
return AgendaDiff(target_date=date, changes=changes)
def _describe_changes(
self,
real: Lesson,
theoretical: TheoreticalLesson,
) -> str:
"""Décrit les différences entre un cours réel et un cours théorique."""
differences = []
if real.subject != theoretical.subject:
differences.append(f"matière: {theoretical.subject} → {real.subject}")
if set(real.teachers) != set(theoretical.teachers):
differences.append(
f"professeurs: {theoretical.teachers} → {real.teachers}"
)
if set(real.rooms) != set(theoretical.rooms):
differences.append(f"salles: {theoretical.rooms} → {real.rooms}")
if real.status != LessonStatus.NORMAL:
differences.append(f"statut: {real.status.value}")
return "; ".join(differences)
def compare_for_range(
self,
start_date: date,
end_date: date,
real_lessons_by_date: dict[date, List[Lesson]],
) -> List[AgendaDiff]:
"""
Compare les agendas pour une plage de dates.
Args:
start_date: Date de début.
end_date: Date de fin.
real_lessons_by_date: Dictionnaire {date: liste des cours réels}.
Returns:
Liste des différences par date.
"""
diffs = []
current_date = start_date
while current_date <= end_date:
real_lessons = real_lessons_by_date.get(current_date, [])
diff = self.compare_for_date(current_date, real_lessons)
if diff.changes:
diffs.append(diff)
current_date += timedelta(days=1)
return diffs
...
```
> **Note** : La gestion de l'absence de `THEORETICAL_AGENDA_PATH` (provider désactivé → diff vide) est reportée à la composition root du pipeline (M11).
### 8.7 Points clés
- **Format JSON** : L'agenda théorique est décrit par un **fichier JSON** (leçons `all`/`even`/`odd`) ; les vacances scolaires sont décrites par un **fichier JSON séparé**.

12
TODO.md
View File

@@ -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.

View File

@@ -34,14 +34,28 @@ class AgendaChange(BaseModel):
def _validate_payload_consistency(self) -> AgendaChange:
"""Valide la cohérence entre le type de changement et le payload.
Applique la matrice stricte de payload :
- ``ADDED`` : ``lesson`` requis et ``theoretical_lesson`` doit être ``None``.
- ``REMOVED`` : ``theoretical_lesson`` requis et ``lesson`` doit être ``None``.
- ``MODIFIED`` : ``lesson`` et ``theoretical_lesson`` tous deux requis.
:return: L'instance validée.
:rtype: AgendaChange
:raises ValueError: Si le payload ne correspond pas au type de changement.
"""
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
if self.type == AgendaChangeType.ADDED:
if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.theoretical_lesson is not None:
raise ValueError(f"theoretical_lesson doit être None pour le type {self.type!r}")
elif self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
if self.lesson is not None:
raise ValueError(f"lesson doit être None pour le type {self.type!r}")
elif self.type == AgendaChangeType.MODIFIED:
if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
return self

View File

@@ -11,6 +11,7 @@ pour le matching.
from __future__ import annotations
import logging
from datetime import date, datetime, time
from pronote_sync.models.agenda import Lesson, LessonStatus, TheoreticalLesson
@@ -21,6 +22,9 @@ from pronote_sync.utils.text import normalize_subject
#: Tolérance temporelle en minutes (valeur absolue) pour l'appariement.
_TOLERANCE_MINUTES = 15
#: Logger du module pour les avertissements de bornage.
_logger = logging.getLogger(__name__)
def _minutes_since_midnight(dt: datetime) -> int:
"""Retourne le nombre de minutes écoulées depuis minuit pour un datetime.
@@ -66,9 +70,19 @@ class AgendaComparator:
def compare(self, real_lessons: list[Lesson], target_date: date) -> AgendaDiff:
"""Compare les cours réels aux cours théoriques pour la date cible.
Les changements sont émis dans un ordre déterministe : d'abord les cours
réels dans leur ordre d'entrée (ADDED ou MODIFIED), puis les cours
théoriques non appariés par existence (REMOVED) triés par identifiant.
L'appariement est un-à-un et déterministe : chaque cours théorique ne
peut être apparié qu'au plus un cours réel, et chaque cours réel ne
peut être apparié qu'au plus un cours théorique. Les changements sont
émis dans un ordre déterministe : d'abord les cours réels triés par
identifiant (ADDED ou MODIFIED), puis les cours théoriques restants non
appariés (REMOVED) triés par identifiant.
Seuls les cours réels dont la date de début est strictement égale à la
date cible :class:`target_date` sont pris en compte. Tout cours réel hors
de cette date est exclu du diff (il ne produit ni ``ADDED`` ni
``MODIFIED``) et un avertissement (``logging.warning``) est émis pour
chacun d'eux, sans divulguer de secret (seul l'identifiant du cours et
sa date sont logués).
:param real_lessons: Liste des cours réels (dans leur ordre d'entrée).
:param target_date: Date cible de la comparaison.
@@ -77,20 +91,37 @@ class AgendaComparator:
"""
theoretical_lessons = self._theoretical_provider.get_lessons(target_date)
#: Identifiants des cours théoriques candidats d'au moins un cours réel
#: (appariement par existence pour la détection des suppressions).
matched_by_existence: set[str] = set()
#: Cours réels restreints à la date cible : les cours hors date sont
#: exclus du diff et signalés par un warning.
filtered_real_lessons: list[Lesson] = []
for real in real_lessons:
if real.start.date() == target_date:
filtered_real_lessons.append(real)
else:
_logger.warning(
"Cours réel %s ignoré : date %s != date cible %s",
real.id,
real.start.date(),
target_date,
)
#: Identifiants des cours théoriques encore disponibles pour appariement.
available_theoretical_ids: set[str] = {
theoretical.id for theoretical in theoretical_lessons
}
changes: list[AgendaChange] = []
for real in real_lessons:
for real in sorted(filtered_real_lessons, key=lambda lesson: lesson.id):
candidates = [
theoretical
for theoretical in theoretical_lessons
if self._matches(real, theoretical, target_date)
if theoretical.id in available_theoretical_ids
and self._matches(real, theoretical, target_date)
]
matched_by_existence.update(candidate.id for candidate in candidates)
selected = min(candidates, key=lambda candidate: candidate.id) if candidates else None
if selected is not None:
available_theoretical_ids.discard(selected.id)
if selected is None:
changes.append(
AgendaChange(
@@ -111,7 +142,7 @@ class AgendaComparator:
)
for theoretical in sorted(theoretical_lessons, key=lambda lesson: lesson.id):
if theoretical.id not in matched_by_existence:
if theoretical.id in available_theoretical_ids:
changes.append(
AgendaChange(
type=AgendaChangeType.REMOVED,
@@ -156,17 +187,22 @@ class AgendaComparator:
def _is_modified(self, real: Lesson, theoretical: TheoreticalLesson) -> bool:
"""Détermine si un cours réel apparié diffère de son cours théorique.
Un cours est considéré modifié si au moins un horaire diffère à la
minute près, si la matière normalisée diffère, si les professeurs ou les
salles diffèrent (comparaison par ensemble), ou si le statut n'est pas
``NORMAL``.
Les horaires sont comparés à la minute près des deux côtés (les
secondes sont ignorées), cohérent avec les helpers
:func:`_minutes_since_midnight` et :func:`_time_minutes` utilisés par
:meth:`_matches`. Un cours est considéré modifié si au moins un horaire
diffère à la minute près, si la matière normalisée diffère, si les
professeurs ou les salles diffèrent (comparaison par ensemble), ou si
le statut n'est pas ``NORMAL``.
:param real: Cours réel apparié.
:param theoretical: Cours théorique apparié.
:return: ``True`` si le cours réel diffère du cours théorique.
:rtype: bool
"""
if real.start.time() != theoretical.start_time or real.end.time() != theoretical.end_time:
if _minutes_since_midnight(real.start) != _time_minutes(
theoretical.start_time
) or _minutes_since_midnight(real.end) != _time_minutes(theoretical.end_time):
return True
if normalize_subject(real.subject) != normalize_subject(theoretical.subject):
return True
@@ -198,9 +234,11 @@ class AgendaComparator:
if normalize_subject(real.subject) != normalize_subject(theoretical.subject):
parts.append(f"matière: {theoretical.subject}{real.subject}")
if set(real.teachers) != set(theoretical.teachers):
parts.append(f"professeurs: {set(theoretical.teachers)}{set(real.teachers)}")
parts.append(
f"professeurs: {sorted(set(theoretical.teachers))}{sorted(set(real.teachers))}"
)
if set(real.rooms) != set(theoretical.rooms):
parts.append(f"salles: {set(theoretical.rooms)}{set(real.rooms)}")
parts.append(f"salles: {sorted(set(theoretical.rooms))}{sorted(set(real.rooms))}")
if real.status != LessonStatus.NORMAL:
parts.append(f"statut: {real.status.value}")
return "; ".join(parts)

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

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime, time
from typing import override
@@ -153,6 +154,42 @@ def test_exact_match() -> None:
assert result.changes == ()
# ==================== Test Case 4bis: Seconds ignored in modification detection ====================
def test_seconds_ignored_in_modification_detection() -> None:
"""Real with seconds and theoretical without → same minutes → no MODIFIED.
The real lesson starts at 10:00:30 and ends at 11:00:45 while the
theoretical lesson is at 10:0011:00. The minute-level times match (10:00
and 11:00), so the real lesson matches the theoretical one within the
±15 min tolerance and is NOT marked MODIFIED despite the differing seconds.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 30),
end=datetime(2025, 9, 15, 11, 0, 45),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
# ==================== Test Case 5: Within tolerance (±14 min) ====================
@@ -323,11 +360,11 @@ def test_different_normalized_subjects() -> None:
def test_multi_candidate_selection_by_id() -> None:
"""Two theoretical candidates match one real → select the smaller id (theo_a).
"""1 real / 2 identical theoretical → the non-selected theoretical is REMOVED.
theo_a (smaller id) has teachers identical to the real lesson (no MODIFIED);
theo_b (larger id) has different teachers and would trigger MODIFIED if selected.
A zero-change diff therefore proves theo_a was selected.
Two theoretical candidates match one real; the real selects theo_a (the
smaller id, identical teachers → no MODIFIED). theo_b (larger id) is not
selected and, being unmatched, must be REMOVED.
"""
theoretical_lessons = [
TheoreticalLesson(
@@ -361,8 +398,11 @@ def test_multi_candidate_selection_by_id() -> None:
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# theo_a (smaller id) selected with identical teachers → no MODIFIED; theo_b matched by existence → not REMOVED
assert result.changes == ()
# theo_a (smaller id) selected with identical teachers → no change; theo_b unmatched REMOVED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].lesson is None
assert result.changes[0].theoretical_lesson == theoretical_lessons[0] # theo_b
# ==================== Test Case 11: MODIFIED — teachers differ (order-insensitive) ====================
@@ -428,7 +468,7 @@ def test_teachers_differ_different_sets() -> None:
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "professeurs: {'Mme Martin'}{'M. Dupont'}" in result.changes[0].details
assert "professeurs: ['Mme Martin']['M. Dupont']" in result.changes[0].details
# ==================== Test Case 13: MODIFIED — rooms differ ====================
@@ -462,10 +502,112 @@ def test_rooms_differ() -> None:
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert "salles: {'Salle 15'}{'Salle 12'}" in result.changes[0].details
assert "salles: ['Salle 15']['Salle 12']" in result.changes[0].details
# ==================== Test Case 14: MODIFIED — status != NORMAL ====================
# ==================== Test Case 14: Deterministic teachers formatting ====================
def test_teachers_sorted_in_details() -> None:
"""Multiple teachers → details list sorted alphabetically regardless of input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Chloe", "Alice", "Bob"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("Bob", "Chloe"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "professeurs: ['Alice', 'Bob', 'Chloe'] → ['Bob', 'Chloe']"
# ==================== Test Case 15: Deterministic rooms formatting ====================
def test_rooms_sorted_in_details() -> None:
"""Multiple rooms → details list sorted alphabetically regardless of input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
rooms=("C101", "A102", "B103"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
rooms=("B103", "C101"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "salles: ['A102', 'B103', 'C101'] → ['B103', 'C101']"
# ==================== Test Case 16: Inter-process deterministic details ====================
def test_details_deterministic_sorted_exact() -> None:
"""MODIFIED details are exactly sorted, independent of teachers input order."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
teachers=("Alice", "Bob"),
),
]
real_lessons = [
Lesson(
id="real_1",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("Bob", "Alice", "Chloe"),
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.MODIFIED
assert result.changes[0].details == "professeurs: ['Alice', 'Bob'] → ['Alice', 'Bob', 'Chloe']"
# ==================== Test Case 17: MODIFIED — status != NORMAL ====================
def test_status_not_normal() -> None:
@@ -502,7 +644,11 @@ def test_status_not_normal() -> None:
def test_removed_by_existence_not_selection() -> None:
"""Two theoretical match one real; real selects the smaller id; the other theoretical is a candidate (exists) → NOT REMOVED."""
"""1 real / 2 identical theoretical → the unmatched theoretical is REMOVED.
The matching is one-to-one: the real consumes theo_a (smaller id) and theo_b
remains available, hence REMOVED even though it is a candidate by existence.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_a",
@@ -532,8 +678,11 @@ def test_removed_by_existence_not_selection() -> None:
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# Both theoretical lessons are candidates (matched by existence), so neither is REMOVED
assert len(result.changes) == 0
# theo_a (smaller id) matched → no change; theo_b unmatched → REMOVED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].lesson is None
assert result.changes[0].theoretical_lesson == theoretical_lessons[1] # theo_b
# ==================== Test Case 16: Deterministic order ====================
@@ -638,3 +787,172 @@ def test_idempotence() -> None:
result1 = comparator.compare(real_lessons, TARGET_DATE)
result2 = comparator.compare(real_lessons, TARGET_DATE)
assert result1 == result2
# ==================== Test Case 18: 2 reals identical / 1 theoretical → 1 ADDED ====================
def test_two_reals_one_theoretical_added() -> None:
"""2 identical reals / 1 matching theoretical → the surplus real is ADDED.
The real with the smaller id is matched to the theoretical; the real with
the larger id has no remaining candidate and must be ADDED.
"""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_lessons = [
Lesson(
id="real_b",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
Lesson(
id="real_a",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
),
]
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result = comparator.compare(real_lessons, TARGET_DATE)
# real_a (smaller id) matched to theo_1; real_b (larger id) unmatched → ADDED
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.ADDED
assert result.changes[0].lesson == real_lessons[0] # real_b
assert result.changes[0].theoretical_lesson is None
# ==================== Test Case 19: Order stability ====================
def test_order_stability() -> None:
"""Presenting real lessons in different orders yields the same result."""
theoretical_lessons = [
TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
),
]
real_a = Lesson(
id="real_a",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
group=None,
content=None,
)
real_b = Lesson(
id="real_b",
start=datetime(2025, 9, 15, 10, 0, 0),
end=datetime(2025, 9, 15, 11, 0, 0),
subject="Mathématiques",
teachers=("M. Dupont",),
group=None,
content=None,
)
provider = _StubProvider(theoretical_lessons)
comparator = AgendaComparator(provider)
result_ab = comparator.compare([real_a, real_b], TARGET_DATE)
result_ba = comparator.compare([real_b, real_a], TARGET_DATE)
# real_a matched to theo_1 (no change); real_b unmatched → ADDED
assert result_ab == result_ba
assert len(result_ab.changes) == 1
assert result_ab.changes[0].type == AgendaChangeType.ADDED
assert result_ab.changes[0].lesson == real_b
# ============ Test Case 20: Off-target-date real lesson is strictly filtered ============
def _theoretical_monday() -> TheoreticalLesson:
"""Theoretical Monday 10:0011:00 in Mathematics."""
return TheoreticalLesson(
id="theo_1",
day_of_week=0,
start_time=time(10, 0),
end_time=time(11, 0),
subject="Mathématiques",
)
def _real_lesson(lesson_id: str, day: int, hour: int) -> Lesson:
"""Real lesson on 2025-09-15+``day`` days at ``hour``:00:60."""
return Lesson(
id=lesson_id,
start=datetime(2025, 9, 15 + day, hour, 0, 0),
end=datetime(2025, 9, 15 + day, hour + 1, 0, 0),
subject="Mathématiques",
group=None,
content=None,
)
def test_off_date_real_does_not_match() -> None:
"""A real on Tuesday must not match a theoretical Monday → REMOVED, no ADDED.
The Tuesday real is filtered out (never produces ADDED) and the Monday
theoretical, having no matching real, is REMOVED.
"""
theoretical_lessons = [_theoretical_monday()]
real_lessons = [_real_lesson("real_tue", day=1, hour=10)] # Tuesday 2025-09-16
comparator = AgendaComparator(_StubProvider(theoretical_lessons))
result = comparator.compare(real_lessons, TARGET_DATE)
assert len(result.changes) == 1
assert result.changes[0].type == AgendaChangeType.REMOVED
assert result.changes[0].lesson is None
assert result.changes[0].theoretical_lesson == theoretical_lessons[0]
def test_off_date_filtered_and_in_date_matched() -> None:
"""A Tuesday real is ignored while a Monday real still matches the theoretical.
The Tuesday real is excluded; the Monday real pairs with the theoretical, so
the theoretical is not REMOVED and the on-date real produces no change.
"""
theoretical_lessons = [_theoretical_monday()]
real_lessons = [
_real_lesson("real_tue", day=1, hour=14), # Tuesday, off target date
_real_lesson("real_mon", day=0, hour=10), # Monday, on target date
]
comparator = AgendaComparator(_StubProvider(theoretical_lessons))
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()
def test_off_date_real_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
"""An off-target-date real lesson logs a warning containing its id."""
theoretical_lessons = [_theoretical_monday()]
real_lessons = [_real_lesson("real_out", day=1, hour=10)]
comparator = AgendaComparator(_StubProvider(theoretical_lessons))
with caplog.at_level(logging.WARNING):
comparator.compare(real_lessons, TARGET_DATE)
assert any("real_out" in record.message for record in caplog.records)
assert all(record.levelno >= logging.WARNING for record in caplog.records)
def test_nominal_matching_produces_no_change() -> None:
"""An identical Monday real / Monday theoretical pair yields an empty diff.
Confirms the strict date filtering does not break the nominal case.
"""
theoretical_lessons = [_theoretical_monday()]
real_lessons = [_real_lesson("real_mon", day=0, hour=10)]
comparator = AgendaComparator(_StubProvider(theoretical_lessons))
result = comparator.compare(real_lessons, TARGET_DATE)
assert result.changes == ()

View File

@@ -150,7 +150,15 @@ from pronote_sync.models.xmpp import XmppMessage
group=None,
content=None,
),
theoretical_lesson=None,
theoretical_lesson=TheoreticalLesson(
id="theo-lesson-004",
day_of_week=4,
start_time=time(16, 0, 0),
end_time=time(17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
),
),
),
"messages": (
@@ -369,5 +377,11 @@ def test_agenda_change_type_enum_values() -> None:
group=None,
content=None,
),
theoretical_lesson=None,
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)

View File

@@ -240,7 +240,7 @@ class TestAgendaChangeConsistency:
assert instance.theoretical_lesson is not None
def test_agenda_change_modified_with_lesson_valid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
"""Vérifie que type=MODIFIED avec lesson et theoretical_lesson est valide."""
lesson = Lesson(
id="lesson-valid-mod",
start=datetime(2024, 9, 6, 10, 0, 0),
@@ -249,13 +249,97 @@ class TestAgendaChangeConsistency:
group=None,
content=None,
)
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-valid-mod",
day_of_week=0,
start_time=time(10, 0, 0),
end_time=time(11, 30, 0),
subject="Physique",
)
instance = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=None,
theoretical_lesson=theoretical_lesson,
)
assert instance.type == AgendaChangeType.MODIFIED
assert instance.lesson is not None
assert instance.theoretical_lesson is not None
def test_agenda_change_added_with_theoretical_lesson_invalid(self) -> None:
"""Vérifie que type=ADDED avec theoretical_lesson non-None lève une ValidationError."""
lesson = Lesson(
id="lesson-added-theo",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-added",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=theoretical_lesson,
)
assert any(
"theoretical_lesson doit être None pour le type" in str(error)
for error in exc_info.value.errors()
)
def test_agenda_change_removed_with_lesson_invalid(self) -> None:
"""Vérifie que type=REMOVED avec lesson non-None lève une ValidationError."""
lesson = Lesson(
id="lesson-removed",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-removed",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=lesson,
theoretical_lesson=theoretical_lesson,
)
assert any(
"lesson doit être None pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_modified_without_theoretical_lesson_invalid(self) -> None:
"""Vérifie que type=MODIFIED sans theoretical_lesson lève une ValidationError."""
lesson = Lesson(
id="lesson-mod-no-theo",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Physique",
group=None,
content=None,
)
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=None,
)
assert any(
"theoretical_lesson est requis pour le type" in str(error)
for error in exc_info.value.errors()
)
class TestCalDAVSyncResultInvariants:

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)