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

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