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