diff --git a/docs/architecture/70-pilotage.md b/docs/architecture/70-pilotage.md new file mode 100644 index 0000000..4ae0bbc --- /dev/null +++ b/docs/architecture/70-pilotage.md @@ -0,0 +1,105 @@ +# 70 · Pilotage des traitements automatisés + +Ce document dit à l'opérateur quoi surveiller, comment lire ce qu'il voit et quoi faire quand un +traitement déraille. Il ne redit pas le fonctionnement : la table des DAGs vit dans +[10-infra.md](10-infra.md), la chaîne de données dans [40-data.md](40-data.md), la dérive dans +l'[ADR 0013](../adr/0013-surveillance-de-derive-dans-le-backend.md), la rétention dans +l'[ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md), la supervision +dans [60-observabilite.md](60-observabilite.md). + +## Carte des traitements + +Les planifications sont en **UTC** : Airflow n'a pas de fuseau configuré. En heure de Paris +l'été, ajouter deux heures. + +| DAG | Planification (UTC) | Ce qu'il fait | Même traitement à la main | +|---|---|---|---| +| `mock_api_import` | chaque heure à :45 | importe la mesure de l'heure pile de l'API Mock, une par site | `python -m app.etl.mock_api_import --start-time … --end-time …` | +| `ml_score` | chaque heure pile | score le pas horaire suivant, écrit `prediction` | `make ml-score` | +| `alertes` | chaque heure à :15 | détecte les alertes internes, puis génère les recommandations | `make detect-alerts`, puis `make recommendations` | +| `retention` | 03:20 | exporte vers Garage les chunks de `reading` de plus de 1 095 jours, puis les supprime | `python -m app.etl.reading_retention` | +| `derive` | 05:30 | calcule le rapport de dérive du modèle sur 168 h | `python -m app.monitoring.drift` | +| `ml_train` | manuel | réentraîne LightGBM et **écrase** le modèle | `make ml-train` | +| `historical_import` | manuel | importe le jeu historique CSV | `python -m app.etl.historical_import --csv … --metadata …` | + +Les modules `app.*` se lancent depuis `apps/backend` (`uv run python -m …`). Tous les DAGs ont +`max_active_runs=1` et `catchup=False` : un retard ne rejoue pas les heures manquées. + +## Où regarder + +Sur la machine, chaque environnement vit dans `/srv/enervision/` et n'écoute que sur la +boucle locale : on y accède par tunnel SSH. + +| Quoi | Production | Recette | Dev | +|---|---|---|---| +| Interface Airflow | `127.0.0.1:8080` | `127.0.0.1:8082` | `127.0.0.1:8084` | +| Grafana (production seulement) | `127.0.0.1:3001` | - | - | +| Mailpit, où arrivent les alertes | `127.0.0.1:8025` | `127.0.0.1:8026` | `127.0.0.1:8027` | + +```bash +ssh -L 8080:127.0.0.1:8080 -L 3001:127.0.0.1:3001 -L 8025:127.0.0.1:8025 root@ +``` + +En ligne de commande, depuis `/srv/enervision/` : + +```bash +compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml" +$compose exec airflow-apiserver airflow dags list-runs derive # derniers passages +$compose exec airflow-apiserver airflow dags trigger ml_train # lancement manuel +$compose logs --tail=100 airflow-scheduler # les tâches tournent ici +``` + +**Aucune alerte ne signale l'échec d'un DAG** ([60-observabilite.md](60-observabilite.md)) : +un coup d'œil quotidien à l'interface Airflow reste nécessaire. + +## Lire le rapport de dérive + +`GET /api/v1/monitoring/drift` (rôle `operateur`) rend le dernier rapport par site et une ligne +globale. Le verdict suit cet ordre (`app/services/drift.py`) : + +| Verdict | Condition | Ce que ça veut dire | Quoi faire | +|---|---|---|---| +| `indetermine` | moins de 24 prévisions vérifiées sur la fenêtre | pas assez de recul pour conclure | attendre ; si ça dure, vérifier `ml_score` | +| `derive`, couverture | moins de 80 % des prévisions ont trouvé leur mesure réelle | **le pipeline**, pas le modèle | vérifier `mock_api_import` et `ml_score` dans Airflow | +| `derive`, erreur | MAE au-delà de 1,25 fois celle de la fenêtre de référence | le modèle se trompe plus qu'avant | réentraîner (ci-dessous) | +| `derive`, biais | biais absolu au-delà du seuil, désactivé par défaut | le modèle se trompe toujours du même côté | réentraîner, après en avoir cherché la cause dans les données | +| `stable` | aucune des conditions précédentes | rien à faire | - | + +## Réentraîner sans perdre le modèle en service + +`ml_train` reste manuel parce que `train.py` écrase le modèle sans comparer ses métriques à +celles de l'ancien ([10-infra.md](10-infra.md)). Garder une copie avant de lancer : + +```bash +$compose exec airflow-scheduler cp /opt/ml/state/models/lightgbm-consumption.txt \ + /opt/ml/state/models/lightgbm-consumption.txt.avant +$compose exec airflow-apiserver airflow dags trigger ml_train +``` + +Comparer ensuite les métriques des deux derniers runs, que `train.py` enregistre dans le magasin +MLflow du volume (`/opt/ml/state/mlflow.db`). Aucune interface MLflow n'est servie sur la +machine : `ml/README.md` décrit `mlflow ui`. Si le nouveau modèle est moins bon, remettre la +copie en place : le prochain `ml_score` l'utilisera. + +## Rétention et archives + +`retention` supprime de la base les chunks de `reading` plus vieux que +`APP_READING_RETENTION_DAYS` (1 095 jours), **après** les avoir exportés en CSV gzip dans le +bucket Garage de l'environnement, chiffrés en SSE-C. Il est idempotent : une reprise ne réécrit +pas un objet déjà exporté et ne retrouve plus un chunk déjà supprimé. + +- **La clé `GARAGE_SSE_KEY` du `.env` est la seule qui déchiffre les archives.** Garage ne la + garde pas. Perdue, les archives sont illisibles : elle se sauvegarde hors de la machine. +- Restaurer un chunk : `get_object` avec la clé SSE-C, `gunzip`, puis + `COPY reading FROM STDIN CSV HEADER`. La contrainte `uq_reading_source` refuse les doublons + ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)). + +## Reprendre après un incident + +| Situation | Reprise | +|---|---| +| `mock_api_import` a manqué des heures | relancer à la main avec `--start-time` et `--end-time` sur la fenêtre manquante ; le module refuse toute fenêtre qui recouvre le CSV historique | +| `alertes` a échoué | relancer : les deux tâches sont idempotentes (`ON CONFLICT DO NOTHING`) | +| `derive` a échoué | relancer : un index d'unicité par fenêtre empêche les doublons | +| `retention` a échoué | relancer : idempotent, voir plus haut | +| `ml_score` en échec répété | lire les journaux du scheduler ; deux tentatives et un plafond de 30 minutes par passage |