fix(backend,airflow,ml): cloture la reconciliation entre les deux sources de lectures (#15)
This commit is contained in:
@@ -70,11 +70,17 @@ 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 : cinq DAGs tournent, deux pour
|
||||
Le lien `airflow --> db` est maintenant en trait plein : six 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), `historical_import` pour le dataset historique
|
||||
(issue #119) et `mock_api_import` pour l'ingestion horaire de l'API Mock (issue #15).
|
||||
La réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
|
||||
génération des recommandations (issue #116), un pour la surveillance de dérive (issue #45),
|
||||
`historical_import` pour le dataset historique (issue #119) et `mock_api_import` pour l'ingestion
|
||||
horaire de l'API Mock (issue #15). La réconciliation entre les deux sources de lectures (issue
|
||||
#15) est tranchée : le trou entre la fin de l'historique (31/12/2024) et le début de l'ingestion
|
||||
API Mock est accepté comme définitivement perdu, aucune mesure réelle n'existant pour cette
|
||||
période. `mock_api_import` refuse toute fenêtre qui recouvrirait des lectures déjà importées du
|
||||
CSV plutôt que de laisser les deux sources dupliquer silencieusement un même instant, et le
|
||||
pipeline ML déduplique par construction (`DISTINCT ON`, source `csv` préférée) au cas où un
|
||||
recouvrement se produirait malgré tout — voir [40-data.md](40-data.md).
|
||||
|
||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||
collecteur ne vient le lire.
|
||||
@@ -89,7 +95,7 @@ collecteur ne vient le lire.
|
||||
| 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 0013](../adr/0013-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 et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. La réconciliation globale des deux sources reste à compléter dans l'issue #15. |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver et scheduler avec LocalExecutor via Docker Compose, sur une base PostgreSQL dédiée. Six DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import`, `mock_api_import` et `derive` (quotidien, surveillance de dérive). L'import historique reste manuel et l'import API Mock s'exécute chaque heure. Réconciliation entre les deux sources (issue #15) : trou temporel accepté, recouvrement refusé à l'ingestion et dédupliqué en défense côté ML, voir [40-data.md](40-data.md). |
|
||||
| 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
|
||||
@@ -99,7 +105,8 @@ Statut : `En cours`. **Le chemin de lecture tourne** entre la base, l'API et le
|
||||
dataset CSV/JSON sur déclenchement manuel et `mock_api_import` collecte chaque heure les mesures
|
||||
de l'API Mock. Les DAGs `ml_train` et `ml_score` (issue #115), `alertes` (issue #116) et `derive`
|
||||
(issue #45) portent le pipeline ML, la détection d'alertes et la surveillance de dérive. La
|
||||
réconciliation globale des données provenant des deux sources reste à compléter dans l'issue #15.
|
||||
réconciliation entre les deux sources de lectures (issue #15) est close : voir
|
||||
[40-data.md](40-data.md) pour le détail du garde-fou d'ingestion et de la déduplication ML.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -441,6 +441,16 @@ Les paramètres de ligne de commande disponibles pour l'import sont :
|
||||
--dry-run
|
||||
```
|
||||
|
||||
**Piège sur `--limit`** : l'API ne renvoie pas un flux à un rythme naturel, elle répartit
|
||||
exactement `limit` lectures, espacées uniformément, sur toute la fenêtre `[start_time, end_time)`
|
||||
demandée. Une fenêtre d'une heure avec `limit=1000` renvoie donc 1000 lectures espacées de 3,6
|
||||
secondes à l'intérieur de cette heure, pas une lecture horaire — vérifié empiriquement en
|
||||
interrogeant directement l'API. Le seul réglage qui produise une lecture par heure, alignée sur
|
||||
l'heure et cohérente avec le grain horaire du reste du schéma (`period_minutes=60`, historique
|
||||
CSV à une ligne par heure), est `limit` = nombre d'heures de la fenêtre. Le DAG `mock_api_import`
|
||||
interroge toujours une fenêtre d'1h (`interval=timedelta(hours=1)`, voir
|
||||
[10-infra.md](10-infra.md)), donc `limit=1`.
|
||||
|
||||
### Flux d'ingestion API Mock
|
||||
|
||||
```text
|
||||
@@ -492,7 +502,7 @@ réponse est donc traitée comme une entrée hostile, conformément à API10 dan
|
||||
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
||||
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
||||
|
||||
Quatre garde-fous, tous dans `mock_api_import.py` :
|
||||
Cinq garde-fous, tous dans `mock_api_import.py` :
|
||||
|
||||
| Garde-fou | Mise en œuvre |
|
||||
|---|---|
|
||||
@@ -500,6 +510,7 @@ Quatre garde-fous, tous dans `mock_api_import.py` :
|
||||
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
||||
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
||||
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
||||
| Refus de recouvrir l'historique | `refuse_if_overlaps_historical_dataset()`, voir ci-dessous |
|
||||
|
||||
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
||||
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
||||
@@ -510,6 +521,30 @@ d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
|
||||
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
||||
lui-même demanderait une lecture en flux, et reste à faire.
|
||||
|
||||
### Réconciliation entre les deux sources (issue #15)
|
||||
|
||||
`historical_import` (source `csv`) et `mock_api_import` (source `api_history`) écrivent toutes
|
||||
deux dans `reading`. Trois décisions ferment cette réconciliation :
|
||||
|
||||
- **Le trou temporel est accepté.** Le dataset historique s'arrête au 31/12/2024, et
|
||||
`mock_api_import` n'importe que l'heure précédant chaque déclenchement : rien ne comble
|
||||
automatiquement la période intermédiaire, et rien ne le pourra jamais — aucune mesure réelle
|
||||
n'existe pour ces instants.
|
||||
- **Le recouvrement est refusé à l'ingestion.** `uq_reading_source` autorise deux lignes au même
|
||||
`(site_id, timestamp)` dès que `source` diffère : rien dans le schéma n'empêche donc un import
|
||||
Mock API manuel avec une fenêtre passée (le script accepte `--start-time`/`--end-time`
|
||||
arbitraires) de dupliquer un point déjà couvert par le CSV. `import_mock_api_history()` appelle
|
||||
`refuse_if_overlaps_historical_dataset()` avant toute écriture : si la fenêtre demandée recouvre
|
||||
au moins une lecture `source='csv'`, l'import est refusé (`ValueError`) plutôt que d'écrire un
|
||||
doublon inter-source silencieux.
|
||||
- **Le pipeline ML déduplique en défense.** Le garde-fou ci-dessus protège l'ingestion, pas
|
||||
la lecture : si un recouvrement se produisait malgré tout (import direct en base, contournement
|
||||
du script), `ml/enervision_ml/data.py` ne doit pas casser silencieusement l'hypothèse de
|
||||
`build_features` (« une ligne par `(site_id, timestamp)` »). `load_from_database()` et
|
||||
`load_recent_from_database()` utilisent donc `SELECT DISTINCT ON (site_id, timestamp)`, `source
|
||||
= 'csv'` gagnant sur `'api_history'` en cas d'égalité — l'historique étant une source vérifiée,
|
||||
l'API Mock une entrée hostile (cf. ci-dessus).
|
||||
|
||||
### Qualité des données de l'API Mock
|
||||
|
||||
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
||||
|
||||
Reference in New Issue
Block a user