docs(M6): aligner guide, TODO et configuration pour l'agenda théorique JSON

Met à jour la documentation et la configuration pour le jalon M6 selon
les décisions d'architecture :

- GUIDE_DEV_PYTHON.md §8 : remplace iCal/CSV par JSON avec parité de
  semaine (paire/impaire) et calendrier de vacances scolaires séparé
- TODO.md M6 : nouveaux items (WeekParityService, SchoolHolidayCalendar,
  configuration, fixtures JSON)
- .env.example : THEORETICAL_AGENDA_PATH passe en .json, ajout de
  SCHOOL_HOLIDAYS_PATH, THEORETICAL_WEEK_ANCHOR_DATE et
  THEORETICAL_WEEK_ANCHOR_TYPE
- AppSettings : 3 nouveaux champs (school_holidays_path,
  theoretical_week_anchor_date, theoretical_week_anchor_type)

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-06 22:30:50 +02:00
parent 7fdca2ca55
commit 4ec827a945
5 changed files with 165 additions and 192 deletions

View File

@@ -28,7 +28,7 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
- Conservation des événements annulés avec `STATUS:CANCELLED`.
3. **Comparaison avec l'agenda théorique** :
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier iCal/CSV local).
- Détection des **changements** (ajouts, suppressions, modifications) entre l'agenda réel (Pronote) et un agenda théorique (fichier JSON local, avec parité des semaines et vacances scolaires).
- Génération d'une liste structurée des différences.
4. **Génération de la synthèse** :
@@ -206,7 +206,7 @@ pronote_sync/
│ │ └── state.py # État local (déduplication, cache HTTP)
│ └── theoretical/ # Agenda théorique
│ ├── __init__.py
│ ├── file.py # Lecture fichier iCal/CSV
│ ├── file.py # Lecture fichier JSON (parité + vacances)
│ └── provider.py # Interface TheoreticalAgendaProvider
├── sync/ # Synchronisation CalDAV + Blog
│ ├── __init__.py
@@ -306,7 +306,10 @@ d'un besoin réel et testé.
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
@@ -341,8 +344,11 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
SYNC_PAST_DAYS=7
SYNC_FUTURE_DAYS=30
# --- Agenda théorique ---
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
# --- Agenda théorique (JSON) ---
THEORETICAL_AGENDA_PATH=./data/theoretical.json
SCHOOL_HOLIDAYS_PATH=./data/school_holidays.json
THEORETICAL_WEEK_ANCHOR_DATE=2026-09-01
THEORETICAL_WEEK_ANCHOR_TYPE=even
# --- XMPP ---
XMPP_JID=user@example.com
@@ -3320,6 +3326,10 @@ class SyncState:
- **Agenda théorique** : Représente l'emploi du temps **attendu** (ex: emploi du temps officiel de l'établissement).
- **Agenda réel** : Représente l'emploi du temps **réel** (récupéré depuis Pronote).
- **Objectif** : Détecter les **changements** (ajouts, suppressions, modifications) entre les deux.
- **Format JSON** : L'agenda théorique est désormais décrit par un **fichier JSON** (et non plus iCal/CSV), avec gestion de la **parité des semaines** (paire/impaire) et des **vacances scolaires**.
- **Parité des semaines** : Chaque leçon peut s'appliquer à **toutes** les semaines (`all`), uniquement aux semaines **paires** (`even`) ou **impaires** (`odd`). La parité d'une date est calculée par rapport à une **date de référence** configurée.
- **Vacances scolaires** : Un **fichier JSON séparé** liste les périodes de vacances (ex: zone A) ; aucune leçon théorique n'est produite pendant ces périodes.
- **Encapsulation** : Le provider encapsule **en interne** le calcul de la parité et le filtrage des vacances ; l'appelant (ex: `AgendaComparator`) reçoit simplement les cours théoriques ou une liste vide.
- **Matching déterministe** : Utiliser des règles claires pour associer un cours réel à un cours théorique (décision [4](#4-agenda-théorique---interface-abstraite--implémentation-fichier)).
### 8.2 Interface `TheoreticalAgendaProvider` (`sources/theoretical/provider.py`)
@@ -3367,188 +3377,131 @@ class TheoreticalAgendaProvider(Protocol):
```
> **Note sur le périmètre du provider** : Le fournisseur gère **en interne** la **parité des semaines** (paire/impaire) et les **vacances scolaires**. L'appelant ne connaît ni la date de référence de parité, ni les périodes de vacances : en période de vacances (ou pour une semaine dont la parité ne correspond à aucune leçon), il reçoit simplement une **liste vide**. Le contrat `TheoreticalAgendaProvider` reste donc volontairement minimal et stable.
### 8.3 Implémentation par fichier (`sources/theoretical/file.py`)
#### 8.3.1 Fichier iCal
#### 8.3.1 Format du fichier JSON de l'agenda théorique
Si l'agenda théorique est fourni sous forme de **fichier iCal** (ex: export depuis un autre outil), on peut le parser de la même manière que le flux Pronote.
L'agenda théorique est fourni sous forme de **fichier JSON** (ex: `./data/theoretical.json`) :
```python
from typing import List
from datetime import date, time
from pathlib import Path
from icalendar import Calendar, Event
from ..models.agenda import TheoreticalLesson
from .provider import TheoreticalAgendaProvider
class ICalTheoreticalAgendaProvider:
"""Fournisseur d'agenda théorique depuis un fichier iCal."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._lessons: List[TheoreticalLesson] = []
self._load()
def _load(self) -> None:
"""Charge le fichier iCal et parse les cours."""
if not self.file_path.exists():
raise FileNotFoundError(f"Fichier iCal introuvable: {self.file_path}")
with open(self.file_path, "rb") as f:
cal = Calendar.from_ical(f.read())
for component in cal.walk():
if not isinstance(component, Event):
continue
# Ignorer les événements tout le jour (vacances, etc.)
if hasattr(component.get("dtstart"), "dt") and not hasattr(component.get("dtstart").dt, "hour"):
continue
start = component.get("dtstart").dt
end = component.get("dtend").dt
# Générer un ID stable (basé sur le jour, l'heure et la matière)
summary = str(component.get("summary", ""))
uid = f"theoretical-{start.strftime('%Y%m%d')}-{start.hour}{start.minute}-{summary}"
lesson = TheoreticalLesson(
id=uid,
day_of_week=start.weekday(),
start_time=time(start.hour, start.minute),
end_time=time(end.hour, end.minute),
subject=summary,
teachers=[], # À extraire de la description si disponible
rooms=[],
)
self._lessons.append(lesson)
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
"""Récupère les cours pour une date donnée."""
day_of_week = date.weekday()
return [
lesson for lesson in self._lessons
if lesson.day_of_week == day_of_week
]
def get_lessons_for_range(
self,
start_date: date,
end_date: date,
) -> List[TheoreticalLesson]:
"""Récupère les cours pour une plage de dates."""
from datetime import timedelta
result = []
current_date = start_date
while current_date <= end_date:
result.extend(self.get_lessons(current_date))
current_date += timedelta(days=1)
return result
#### 8.3.2 Fichier CSV
Si l'agenda théorique est fourni sous forme de **fichier CSV**, on peut le parser ainsi :
```csv
jour,semaine,heure_debut,heure_fin,matiere,professeur,salle
lundi,1,08:00,09:00,Mathématiques,M. Dupont,204
lundi,1,09:00,10:00,Français,Mme Martin,205
...
```json
{
"version": 1,
"lessons": [
{
"week": "all",
"day_of_week": 0,
"start_time": "08:00",
"end_time": "09:00",
"subject": "Mathématiques",
"teachers": ["M. Dupont"],
"rooms": ["101"]
},
{
"week": "even",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Anglais",
"teachers": [],
"rooms": []
},
{
"week": "odd",
"day_of_week": 1,
"start_time": "10:00",
"end_time": "11:00",
"subject": "Espagnol",
"teachers": [],
"rooms": []
}
]
}
```
```python
import csv
from typing import List
from datetime import date, time
from pathlib import Path
from ..models.agenda import TheoreticalLesson
from .provider import TheoreticalAgendaProvider
- `week` : `"all"` (toutes les semaines), `"even"` (semaines paires) ou `"odd"` (semaines impaires).
- `day_of_week` : entier de **0 (lundi)** à **6 (dimanche)**.
- `start_time` / `end_time` : chaînes au format `"HH:MM"`.
- `teachers` / `rooms` : listes de chaînes, **optionnelles** (défaut : liste vide).
- `id` : **optionnel** ; s'il est absent, le provider génère un identifiant **déterministe incluant le type de semaine**, afin que deux leçons de parité différente sur le même créneau aient des identifiants distincts.
#### 8.3.2 Format du fichier JSON des vacances scolaires (fichier séparé)
class CSVTheoreticalAgendaProvider:
"""Fournisseur d'agenda théorique depuis un fichier CSV."""
Les vacances scolaires sont décrites dans un **fichier JSON séparé** (ex: `./data/school_holidays.json`) :
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._lessons: List[TheoreticalLesson] = []
self._load()
def _load(self) -> None:
"""Charge le fichier CSV et parse les cours."""
if not self.file_path.exists():
raise FileNotFoundError(f"Fichier CSV introuvable: {self.file_path}")
with open(self.file_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
day_of_week = self._parse_day(row["jour"])
start_time = self._parse_time(row["heure_debut"])
end_time = self._parse_time(row["heure_fin"])
# Générer un ID stable
uid = f"theoretical-{day_of_week}-{start_time.isoformat()}-{row['matiere']}"
lesson = TheoreticalLesson(
id=uid,
day_of_week=day_of_week,
start_time=start_time,
end_time=end_time,
subject=row["matiere"],
teachers=[row["professeur"]] if row.get("professeur") else [],
rooms=[row["salle"]] if row.get("salle") else [],
)
self._lessons.append(lesson)
def _parse_day(self, day: str) -> int:
"""Convertit un nom de jour en index (0=lundi, 6=dimanche)."""
days = {
"lundi": 0,
"mardi": 1,
"mercredi": 2,
"jeudi": 3,
"vendredi": 4,
"samedi": 5,
"dimanche": 6,
}
return days.get(day.lower(), 0)
def _parse_time(self, time_str: str) -> time:
"""Parse une chaîne de temps (ex: 08:00)."""
hour, minute = map(int, time_str.split(":"))
return time(hour, minute)
def get_lessons(self, date: date) -> List[TheoreticalLesson]:
"""Récupère les cours pour une date donnée."""
day_of_week = date.weekday()
return [
lesson for lesson in self._lessons
if lesson.day_of_week == day_of_week
]
def get_lessons_for_range(
self,
start_date: date,
end_date: date,
) -> List[TheoreticalLesson]:
"""Récupère les cours pour une plage de dates."""
from datetime import timedelta
result = []
current_date = start_date
while current_date <= end_date:
result.extend(self.get_lessons(current_date))
current_date += timedelta(days=1)
return result
```json
{
"zone": "A",
"school_year": "2026-2027",
"periods": [
{
"start_date": "2026-10-17",
"end_date": "2026-11-02",
"label": "Toussaint"
}
]
}
```
- `start_date` et `end_date` sont des dates ISO (`YYYY-MM-DD`) **inclusives**.
- `is_holiday(date)` retourne `True` si la date tombe dans **l'une** des périodes (`start_date <= date <= end_date`).
#### 8.3.3 Service de parité de semaine
La parité des semaines est configurée via deux paramètres :
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence (ex: `2026-09-01`).
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"` (parité de la semaine de référence).
**Algorithme** : on calcule le nombre de semaines entre le **lundi de la semaine cible** et le **lundi de la semaine de référence**. Si ce décalage est **pair**, la semaine cible a la **même parité** que l'ancre ; s'il est **impair**, la parité est **opposée**.
```python
from datetime import date, timedelta
from typing import Literal
def week_parity(
target: date,
anchor_date: date,
anchor_type: Literal["even", "odd"],
) -> Literal["even", "odd"]:
"""Détermine la parité (paire/impaire) de la semaine d'une date cible.
:param target: Date dont on veut connaître la parité de semaine.
:param anchor_date: Date de référence (semaine de parité ``anchor_type``).
:param anchor_type: Parité de la semaine de référence (``"even"`` ou ``"odd"``).
:return: ``"even"`` ou ``"odd"`` selon la parité calculée.
:rtype: Literal["even", "odd"]
"""
target_monday = target - timedelta(days=target.weekday())
anchor_monday = anchor_date - timedelta(days=anchor_date.weekday())
offset_weeks = (target_monday - anchor_monday).days // 7
if offset_weeks % 2 == 0:
return anchor_type
return "odd" if anchor_type == "even" else "even"
```
#### 8.3.4 Comportement du provider JSON
- `get_lessons(date)` : si la date tombe pendant les **vacances scolaires**, retourner `[]`. Sinon, déterminer la **parité de la semaine**, filtrer les leçons selon le champ `week` (`all` correspond à toutes les semaines, `even`/`odd` à leur parité respective), construire les objets `TheoreticalLesson` et retourner la liste **triée par `id`**.
- `get_lessons_for_range(start_date, end_date)` : itérer sur **chaque date** de la plage, ignorer les **vacances scolaires**, appliquer le **filtrage de parité** à chaque jour et retourner la liste cumulée (éventuellement **dédupliquée par `id`**).
#### 8.3.5 Configuration
- `THEORETICAL_AGENDA_PATH` : chemin vers le fichier JSON de l'agenda théorique (ex: `./data/theoretical.json`).
- `SCHOOL_HOLIDAYS_PATH` : chemin vers le fichier JSON des vacances scolaires.
- `THEORETICAL_WEEK_ANCHOR_DATE` : date de référence pour la parité (ex: `2026-09-01`).
- `THEORETICAL_WEEK_ANCHOR_TYPE` : `"even"` ou `"odd"`.
- Si `THEORETICAL_AGENDA_PATH` est `None`, le provider est **désactivé** (M8 retourne un diff vide, non bloquant).
- Si le fichier d'agenda contient des leçons `even`/`odd` mais **aucune ancre n'est configurée**, lever une **erreur de configuration** explicite.
### 8.4 Politique de départage pour les collisions
**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 UID ou clé de matching (ex: `theoretical-{day_of_week}-{start_time}-{subject}`).
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.
3. **Choix de la première correspondance** : En cas de multiples correspondances admissibles, choisir la **première** après tri déterministe.
@@ -3567,22 +3520,22 @@ theoretical_lessons_sorted = sorted(
**Exemple de matching avec départage déterministe** :
```python
def match_theoretical_event(
def match_theoretical_lesson(
real_lesson: PronoteLesson,
theoretical_events: list[TheoreticalEvent],
theoretical_lessons: list[TheoreticalLesson],
tolerance_minutes: int = 15,
) -> TheoreticalEvent | None:
"""Trouve l'événement théorique correspondant, avec départage déterministe."""
) -> TheoreticalLesson | None:
"""Trouve le cours théorique correspondant, avec départage déterministe."""
candidates = [
t for t in theoretical_events
if abs((t.start - real_lesson.start).total_seconds()) <= tolerance_minutes * 60
t for t in theoretical_lessons
if t.day_of_week == real_lesson.start.weekday()
and abs((t.start_time - real_lesson.start.time()).total_seconds()) <= tolerance_minutes * 60
and normalize_subject(t.subject) == normalize_subject(real_lesson.subject)
and t.start.date() == real_lesson.start.date()
]
if not candidates:
return None
# Tri déterministe par UID stable, puis par créneau
candidates.sort(key=lambda t: (t.uid or "", t.start))
# Tri déterministe par ID stable, puis par créneau
candidates.sort(key=lambda t: (t.id, t.start_time))
return candidates[0]
```
@@ -3789,11 +3742,14 @@ class AgendaComparator:
### 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é**.
- **Parité des semaines** : Déterminée par `THEORETICAL_WEEK_ANCHOR_DATE` et `THEORETICAL_WEEK_ANCHOR_TYPE` (décalage en semaines entre le lundi de référence et le lundi cible).
- **Vacances scolaires** : Les jours de vacances retournent une **liste vide** (aucune leçon théorique).
- **Identifiants déterministes** : Générés par le provider (type de semaine inclus) pour garantir des IDs distincts et stables.
- **Matching déterministe** : Basé sur le jour, le créneau horaire (avec tolérance) et la matière normalisée.
- **Normalisation** : Les matières et heures sont normalisées pour éviter les faux négatifs.
- **Types de changements** : Ajout, suppression, modification.
- **Agenda théorique** : Peut être fourni via fichier iCal ou CSV (extensible à d'autres sources).
- **Politique de départage** : Tri par identifiant stable (UID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
- **Politique de départage** : Tri par identifiant stable (ID), puis comparaison exacte des créneaux et matière normalisée. En cas de multiples correspondances, choix de la première après tri déterministe.
---