From 68239371f6f810d3d19ab1357961201b88d86b34 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 22 Sep 2026 14:27:13 +0200 Subject: [PATCH] docs(ml,backend,etl): ordonnance la derive et corrige ce que le depot disait faux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois phrases du depot annonçaient une surveillance de derive inexistante, et une quatrieme disait qu'aucune base PostgreSQL n'etait joignable pour tester le chargement ML. Les quatre sont maintenant fausses, donc reecrites plutot que laissees en dette. - ADR 0011 : ou vit le calcul et pourquoi pas dans `ml/`, les deux dedoublonnages qu'impose la jointure, et quatre alternatives ecartees avec la contrainte qui les interdit (la metrique MLflow n'est pas la meme grandeur, `alert` borne ses valeurs et refuse un site nul, Prometheus n'a pas de collecteur, ne rien persister ne repond pas a la question du jury). - DAG `derive` quotidien, hors du DAG `alertes` : un echec de derive y ferait croire que la detection a echoue, et la fenetre de 168 h ne se recalcule pas toutes les heures. - ML-START : la limite de `--now` est dite au lieu d'etre decouverte en demonstration. Elle ne decale que l'instant de reference, pas la fenetre de lecture, donc aucun rattrapage ne peut fabriquer de paires prevu/realise sur un jeu fige. - 50-cicd : pourquoi le job ML installe aussi le backend (le schema n'a qu'une source), et ce que coute le filtre de chemins qui l'accompagne. --- README.md | 4 +- docs/ML-START.md | 30 ++++- docs/README.md | 1 + ...-surveillance-de-derive-dans-le-backend.md | 106 ++++++++++++++++++ docs/architecture/00-vue-ensemble.md | 8 +- docs/architecture/10-infra.md | 1 + docs/architecture/20-backend.md | 27 ++++- docs/architecture/40-data.md | 15 ++- docs/architecture/50-cicd.md | 37 +++++- etl/airflow/dags/derive.py | 46 ++++++++ etl/airflow/tests/test_dags.py | 16 ++- ml/README.md | 23 +++- 12 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0011-surveillance-de-derive-dans-le-backend.md create mode 100644 etl/airflow/dags/derive.py diff --git a/README.md b/README.md index daa08f4..879a491 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Backend | FastAPI, Python 3.14 | `apps/backend` | En place | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place | -| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs | +| ETL | Apache Airflow | `etl/airflow` | Cinq DAGs | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | En place | @@ -50,7 +50,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans │ ├── migrations/ Migrations SQL versionnees │ └── seeds/ Jeux de donnees de reference ├── etl/airflow/ -│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique) +│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import, dérive) │ ├── plugins/ Operateurs et hooks maison │ ├── include/ Requetes SQL et ressources des DAGs │ └── tests/ Tests d'integrite des DAGs diff --git a/docs/ML-START.md b/docs/ML-START.md index 68518ac..838cce1 100644 --- a/docs/ML-START.md +++ b/docs/ML-START.md @@ -90,14 +90,23 @@ consommation prévue de **l'heure suivant sa dernière lecture connue**, et écr ### Ce que le run écrit, et ce qu'il n'écrase pas La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run -insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra -possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui -n'existe pas encore. +insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend +possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle +retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que +sert `GET /api/v1/predictions`. Voir l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md). Trois contraintes de cohérence sont portées par la base et non par le code applicatif : `status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; `insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à -`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. +`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. Elles +sont vérifiées depuis le code qui écrit par `ml/tests/test_score_integration.py`, sur une vraie +base : un double ne prouverait rien d'une contrainte SQL. + +**Limite connue de `--now`.** L'option décale l'instant de référence, pas la fenêtre de lecture : +`load_recent_from_database` n'a pas de borne haute et `build_scoring_frame` part toujours de la +dernière lecture connue. `target_at` vaut donc « dernière lecture du jeu + 1 h » quelle que soit +la valeur passée, et aucune boucle de rattrapage ne peut fabriquer de paires prévu/réalisé sur un +jeu figé. ### `model_reference` est un hachage, pas un nom de fichier @@ -142,6 +151,10 @@ flowchart LR train -- "models/*.txt + run MLflow" --> score score -- "INSERT" --> prediction prediction -- "lecture seule" --> route + prediction -- "prévu" --> derive["app.monitoring.drift
écart prévu / réalisé"] + reading -- "réalisé" --> derive + derive -- "INSERT" --> rapport[("drift_report")] + rapport -- "lecture seule" --> monitoring["GET /api/v1/monitoring/drift"] ``` **La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** @@ -163,8 +176,12 @@ flowchart LR Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` (issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme -l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la -surveillance de dérive, portée par les issues #44 et #45. +l'entraînement, dont le DAG `ml_train` n'a pas de planification. + +La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**, +sans la percer : elle relit `prediction` et `reading` en SQL, ne charge aucun modèle, et n'appelle +pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans +l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md). --- @@ -175,3 +192,4 @@ surveillance de dérive, portée par les issues #44 et #45. - [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions - [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` - [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données +- [ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive diff --git a/docs/README.md b/docs/README.md index 1cba2bc..f7f5afd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,3 +17,4 @@ | [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement | | [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé | | [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application | +| [0011](adr/0011-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table | diff --git a/docs/adr/0011-surveillance-de-derive-dans-le-backend.md b/docs/adr/0011-surveillance-de-derive-dans-le-backend.md new file mode 100644 index 0000000..3f6c069 --- /dev/null +++ b/docs/adr/0011-surveillance-de-derive-dans-le-backend.md @@ -0,0 +1,106 @@ +# 0011 - La surveillance de dérive vit dans le backend et écrit sa propre table + +- Statut : accepté +- Date : 2026-09-22 + +## Contexte + +L'issue #45 demande des tests d'intégration API ↔ DB ↔ ML. Trois documents du dépôt annoncent +par ailleurs, depuis le jalon J3, une surveillance de dérive qui n'existe nulle part : +`docs/architecture/00-vue-ensemble.md` (« Surveillance de dérive (EC06, #44/#45) pas encore +construite »), `docs/ML-START.md` (« la dette qui subsiste est la surveillance de dérive »), et +le docstring de `write_predictions()` dans `ml/enervision_ml/score.py`, qui justifie l'absence +d'unicité sur `(site_id, target_at)` par la comparaison future entre prévu et réalisé. + +La matière première est en base : `prediction` porte ce que le modèle a annoncé, `reading` ce +qui est réellement arrivé. Restaient trois questions : où vit le calcul, à quoi on compare, et +où atterrit le résultat. + +## Décision + +**Le calcul vit dans `apps/backend`** : `repositories/drift.py` pour le SQL, `services/drift.py` +pour la logique, `monitoring/drift.py` pour la CLI, `api/v1/endpoints/monitoring.py` pour la +lecture. Le dossier `ml/` ne gagne pas une ligne. + +**Le résultat est persisté** dans une table `drift_report`, une ligne par site plus une ligne +globale que `site_id` à NULL désigne. + +**La comparaison oppose deux fenêtres vives de 168 h**, la récente et celle qui la précède, et +le verdict a trois valeurs : `stable`, `derive`, `indetermine`. + +### Pourquoi le backend, alors que le sujet est le modèle + +- **`prediction` n'est pas dans le périmètre de `ML_DATABASE_URL`.** `enervision_ml/config.py`, + `docs/ML-START.md` et l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md) désignent pour + cette variable un rôle PostgreSQL restreint **en lecture sur `reading` et `site`**. Mettre la + dérive dans `ml/` obligerait à élargir ce rôle à `prediction`, et à l'écriture : ce serait + contredire par le code la dette de moindre privilège que ces trois documents ont posée par + écrit. +- **L'alignement prévu contre réalisé existe déjà ici, une fois.** `AlertService._detect_anomaly` + croise `reading` et `prediction` sur le même instant, et `PredictionRepository.list_since` + porte déjà le piège des runs empilés. Le réécrire en SQL brut dans `ml/` créerait une seconde + source de vérité sur « quelle prédiction correspond à quelle lecture », ce que + l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a déjà refusé pour les règles. +- **La frontière de `docs/ML-START.md` tient.** FastAPI ne fait toujours pas tourner LightGBM : + la dérive lit deux tables et compare des nombres, elle n'évalue aucun modèle. + +**Conséquence assumée** : `enervision_ml.metrics.regression_metrics` n'est pas réutilisable, le +backend n'important pas `enervision_ml`. MAE, MAPE et biais sont donc réécrits, une quinzaine de +lignes. Cette duplication n'est pas celle que `build_features` interdit : une divergence de +features est silencieuse et ruine les prévisions sans erreur, une divergence sur une moyenne +d'écarts absolus est attrapée par le premier test à valeurs connues. + +### Ce qu'on mesure, et les deux dédoublonnages obligatoires + +La paire est `prediction ⋈ reading` sur `(site_id, target_at = timestamp)`, restreinte aux +prédictions `available`. Elle exige un `DISTINCT ON` **des deux côtés** : + +- `prediction` n'a pas d'unicité sur `(site_id, target_at)`, chaque run de scoring empile une + ligne. On retient la plus récente, celle que sert `GET /api/v1/predictions`, départagée par + `prediction_id` : `created_at` vaut l'heure de début de transaction et ne distingue pas deux + lignes du même run. +- `uq_reading_source` autorise deux lectures au même instant quand la `source` diffère. Sans + dédoublonnage, la jointure compterait cette heure deux fois et pondérerait doublement le site. + +La fenêtre est **fermée à droite par un délai de grâce de 2 h** : le réalisé de la dernière +heure n'est pas encore ingéré, et l'inclure ferait chuter le taux de couverture à chaque +exécution, pour une raison qui n'a rien à voir avec le modèle. + +Métriques retenues : `mae` (la métrique même qu'optimise LightGBM), **`bias` signé** (une MAE qui +monte dit « moins bon », un biais qui s'éloigne de zéro dit « le modèle se trompe toujours du +même côté », signature d'un décalage de distribution), `mape`, `n_observations`, +`coverage_ratio` et `insufficient_data_ratio` (qui mesurent le pipeline, pas le modèle), et la +liste des `model_references` vus dans la fenêtre : une MAE qui saute à l'instant exact où le +modèle change n'est pas une dérive, c'est une régression de réentraînement. + +## Alternatives écartées + +| Écartée | Raison | +|---|---| +| Comparer à la métrique MLflow de l'entraînement | Ce ne sont pas les mêmes grandeurs : `train.py` mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit une heure future dont la météo est `NaN` et dont `is_working_hours` est recopié. Le verdict serait « dérive » dès le premier jour. Et le backend devrait importer `mlflow`, ce que la frontière de ML-START interdit. | +| Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). | +| Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. | +| Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. | +| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes identiques par jour. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. | + +## Conséquences + +- Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à + `coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes + deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que + `uq_reading_source`. +- `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur + qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans + `tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais + jetons. +- Un DAG `derive` quotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à + l'identique. +- La CLI sort en code non nul sous `--fail-on-drift` seulement. Par défaut, constater une dérive + n'est pas un échec d'exécution. + +## Limite connue + +`enervision_ml.score --now` ne rejoue pas l'historique : `load_recent_from_database` n'a pas de +borne haute, et `build_scoring_frame` part toujours de la dernière lecture connue. Aucune boucle +de rattrapage ne peut donc fabriquer de paires prévu/réalisé sur des données figées, et la +dérive répond `indetermine` tant que le scoring n'a pas tourné plusieurs fois en exploitation. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 0aec09c..5819fd2 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -70,7 +70,7 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, intercepteur répond à sa place tant que les endpoints n'existent pas. Voir [30-frontend.md](30-frontend.md). -Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour +Le lien `airflow --> db` est maintenant en trait plein : cinq DAGs tournent, deux pour l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des @@ -86,16 +86,16 @@ collecteur ne vient le lire. | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | -| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md) | | Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | -| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Cinq DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import` et `derive` (quotidien, surveillance de dérive). Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow, formatage et validation du Terraform. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) | ## Flux bout en bout Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin -d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`, +d'ingestion dessiné ci-dessous n'existe pas** : les DAGs livrés (`ml_train`, `ml_score`, issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16). diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index cc8cf84..9f2fd72 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -86,6 +86,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` | | `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` | | `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` | +| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère | Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique. Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c499dca..cbc853a 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,7 +12,7 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors,
predictions, monitoring"] sc["schemas
Pydantic"] sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] @@ -151,6 +151,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | +| GET | `/api/v1/monitoring/drift` | Dernier rapport de dérive par site, plus la ligne globale. `operateur` | 401, 403, 422, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | @@ -223,6 +224,30 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. +### Surveillance de dérive + +`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)` +et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par +site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec +`ON CONFLICT DO NOTHING` sur `uq_drift_report_window` : rejouer la commande sur la même fenêtre +n'ajoute rien. + +| Métrique | Ce qu'elle dit | +|---|---| +| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM | +| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté | +| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls | +| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle | +| `insufficient_data_ratio` | Part des sites privés d'historique suffisant | +| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive | + +Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal +d'observations, le service dit qu'il ne sait pas plutôt que de rendre un chiffre trompeur. La +fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le +réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive` +l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les +arbitrages sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md). + ### Détection d'alertes internes `AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 82f769b..7931b36 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -48,7 +48,8 @@ Statut : `Fait`. et refuse de s'appliquer si l'extension TimescaleDB manque. - Les révisions suivantes créent les tables liées à l'authentification : `app_user`, `login_attempt`, `audit_log` et `refresh_token`. -- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. +- La révision `e6d2026091501` crée six des sept tables Data et déclare l'hypertable `reading`. +- La révision `d3f1a2b7c904` ajoute `drift_report`, la septième. - La révision `c0adab96238c` ajoute les tables `password_reset_attempt` et `password_reset_token`. @@ -261,8 +262,8 @@ Cette modélisation prend en compte : - leurs métadonnées JSON ; - les données de l'API Mock. -Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées -à l'utilisateur. +Elle comprend sept tables Data, depuis le stockage des mesures jusqu'aux recommandations +proposées à l'utilisateur, et jusqu'au suivi de la dérive du modèle. ### Schéma de données @@ -286,11 +287,18 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d | `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | | `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision | +| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision | Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. +Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnancé par le DAG +`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus : +c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte, +qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de +comparer sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md). + Les lignes de `recommendation` sont écrites par le moteur de règles du backend (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`, par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en @@ -304,6 +312,7 @@ n'ajoute aucune ligne. - Les mesures API ne sont pas rattachées à un dataset historique. - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. +- Un site possède plusieurs rapports de dérive ; un rapport global n'est rattaché à aucun site. ## Ingestion des données historiques diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index e0deb5d..d46455d 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -155,6 +155,8 @@ dans [10-infra.md](10-infra.md). | Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | | Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | | Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | +| Tests d'intégration ML ↔ DB | ml | marqueur `integration`, base réelle migrée par Alembic | Bloque | +| Chaîne ML → DB → API | ml | marqueur `chaine`, vrais binaires en sous-processus | Bloque | | Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | | Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | | **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | @@ -254,6 +256,28 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow. +### Pourquoi le job d'intégration ML installe aussi le backend + +Le schéma de la base n'a qu'une source, les six révisions Alembic de `apps/backend/alembic` : le +backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la +main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur +qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe +donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté +`ml/` et `-m chaine` côté backend. + +Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et +`apps/backend/app/models/**`. Sans eux, une migration qui renomme une colonne de `reading` ne +déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait +avant la production**. Le prix est qu'une PR touchant seulement une migration lance aussi le lint +et le typage de `ml/` : environ deux minutes de runner, en parallèle. Même arbitrage que le filtre +d'`airflow.yml`, qui écoute déjà `ml/**` et `apps/backend/app/**` parce que son image réunit les +deux. + +Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration` +de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les +binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout +poste où `ml/` n'est pas installé. + ## Ce qui manque, et pourquoi | Manque | Issue | Conséquence assumée | @@ -267,8 +291,17 @@ régénère, ce qui invalide les sessions et les connexions chiffrées par Airfl ## Reproduire la CI en local `make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job -`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration -demandent une base : `make db-up` puis `uv run pytest -m integration`. +`verification`. `make ml-check` fait la même chose pour le module ML. + +Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `enervision_test` que +vide : + +```bash +make db-up migrate-test # la base de test reçoit les six révisions Alembic +make test-integration # backend, marqueur `integration` +make ml-test-integration # pipeline ML, marqueur `integration` +make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine` +``` Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis diff --git a/etl/airflow/dags/derive.py b/etl/airflow/dags/derive.py new file mode 100644 index 0000000..ba9b973 --- /dev/null +++ b/etl/airflow/dags/derive.py @@ -0,0 +1,46 @@ +"""DAG de surveillance de la dérive du modèle de prévision (issue #45). + +Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait +vingt-quatre lignes presque identiques par jour et se heurterait à l'index d'idempotence +`uq_drift_report_window`. Planifié après les scorings de la nuit, et décalé de `ml_score` (à +l'heure pile) comme de `alertes` (à la quinzième minute). + +Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire +que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à +son pas horaire. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.providers.standard.operators.bash import BashOperator +from airflow.sdk import DAG + +# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et +# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +# Piège : aucune reprise. Une dérive n'est pas un échec transitoire, la rejouer la redéclarerait +# à l'identique ; et la cadence quotidienne pardonne une connexion perdue. +TENTATIVES = 0 +PLAFOND = timedelta(minutes=10) + +with DAG( + dag_id="derive", + description=( + "Compare les prévisions déjà écrites aux lectures réellement arrivées " + "(app.monitoring.drift)." + ), + schedule="30 5 * * *", + start_date=datetime(2026, 1, 1), + catchup=False, + max_active_runs=1, + tags=["ml", "monitoring"], +) as dag: + BashOperator( + task_id="derive", + bash_command=f"{COMMANDE_BACKEND} app.monitoring.drift", + retries=TENTATIVES, + execution_timeout=PLAFOND, + ) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 60978b5..bc6cb91 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -10,13 +10,14 @@ from airflow.sdk import BaseOperator DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" -DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"] +DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import", "derive"] TACHES = [ ("ml_train", "train"), ("ml_score", "score"), ("alertes", "detection"), ("alertes", "recommandations"), ("historical_import", "import_historical"), + ("derive", "derive"), ] @@ -159,6 +160,19 @@ def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1 +def test_derive_runs_once_a_day(dagbag: DagBag) -> None: + assert dagbag.dags["derive"].timetable.expression == "30 5 * * *" + + +def test_derive_calls_the_backend_drift_module(dagbag: DagBag) -> None: + assert "app.monitoring.drift" in dagbag.dags["derive"].get_task("derive").bash_command + + +def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None: + # Une derive n'est pas une panne passagere : la rejouer la redeclarerait a l'identique. + assert dagbag.dags["derive"].get_task("derive").retries == 0 + + @pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str diff --git a/ml/README.md b/ml/README.md index 7fc09b3..a0fa794 100644 --- a/ml/README.md +++ b/ml/README.md @@ -111,12 +111,23 @@ Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-li ## Ou ecrire les tests -Aucun test ne touche PostgreSQL ni un serveur MLflow distant : `enervision_ml.data.load_from_csv` -et le chargement CSV de test suffisent a exercer `build_features` sur des donnees reelles ou -synthetiques, et `enervision_ml.train.train()` accepte un `tracking_uri` SQLite isole (`tmp_path` -pytest) pour un test de bout en bout sans effet de bord. `enervision_ml.data.load_from_database` -n'est pas encore couvert : il n'existe aucune base PostgreSQL a interroger en CI ni dans cet -environnement de developpement pour le moment. +Deux regimes, separes par le marqueur `integration` que `pytest` ecarte par defaut. + +**Sans base** : `enervision_ml.data.load_from_csv` et le chargement CSV de test suffisent a +exercer `build_features` sur des donnees reelles ou synthetiques, et `enervision_ml.train.train()` +accepte un `tracking_uri` SQLite isole (`tmp_path` pytest) pour un test de bout en bout sans effet +de bord. + +**Avec base**, sous `integration` : `test_data_integration.py` confronte les neuf colonnes du +contrat au schema Alembic reel, et `test_score_integration.py` verifie les contraintes de +`prediction` depuis le code qui ecrit. Les fixtures sont dans `tests/conftest.py`, qui refuse de +demarrer si `ML_DATABASE_URL` ne vise pas `enervision_test`. + + make db-up migrate-test ml-test-integration + +Regle a tenir : **toute requete SQL nouvelle porte un test `integration`**. Le schema vit dans +`apps/backend/alembic`, pas ici : sans ce garde-fou, une migration qui renomme une colonne casse +le pipeline en production sans qu'aucun test ne rougisse. ## Piege a connaitre