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>
This commit is contained in:
2026-09-07 15:59:17 +02:00
parent d2cf59c713
commit 5907c9aeaf
7 changed files with 562 additions and 225 deletions

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)