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:
@@ -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é**.
|
||||
|
||||
Reference in New Issue
Block a user