Merge remote-tracking branch 'origin/dev' into feat/mock-api-import
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# 0006 - Le moteur de règles de recommandation vit dans le backend
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-18
|
||||
|
||||
## Contexte
|
||||
|
||||
L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le
|
||||
schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation,
|
||||
rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité
|
||||
`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc
|
||||
**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision.
|
||||
|
||||
Deux emplacements se disputaient le code :
|
||||
|
||||
1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome
|
||||
qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire.
|
||||
L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant
|
||||
que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même
|
||||
module `enervision_ml.features` ».
|
||||
2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ».
|
||||
|
||||
## Décision
|
||||
|
||||
**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur
|
||||
`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()`
|
||||
qui l'applique, persiste et valide la transaction.
|
||||
|
||||
Trois raisons :
|
||||
|
||||
- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et
|
||||
`alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de
|
||||
l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des
|
||||
features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel
|
||||
« prédiction et recommandation », pas l'emplacement du code.
|
||||
- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà
|
||||
filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à
|
||||
maintenir deux représentations du même domaine.
|
||||
- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer
|
||||
par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans
|
||||
l'application FastAPI.
|
||||
|
||||
Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli
|
||||
generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien
|
||||
n'oblige à exposer un port pour régénérer des recommandations.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique :
|
||||
entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même
|
||||
commit.
|
||||
- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà
|
||||
utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de
|
||||
ne pas commiter.
|
||||
- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING`
|
||||
sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la
|
||||
fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle.
|
||||
Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée
|
||||
ferait réapparaître ses recommandations à côté des anciennes.
|
||||
- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée
|
||||
par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ;
|
||||
l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant
|
||||
que la détection n'a pas tourné, sans que le moteur soit à retoucher.
|
||||
- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT`
|
||||
lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes,
|
||||
et la détection interne peut alimenter `alert` au fil de l'eau.
|
||||
- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait
|
||||
à rouvrir : le moteur redeviendrait consommateur du pipeline ML.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de
|
||||
l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération
|
||||
hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des
|
||||
features ou un modèle.
|
||||
- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime
|
||||
le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles,
|
||||
et interdit toute trace de ce qui a été proposé et quand.
|
||||
- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple,
|
||||
mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que
|
||||
rien n'exprime à ce stade.
|
||||
@@ -165,3 +165,8 @@ Elles vivent dans `../adr/`, pas ici.
|
||||
| ADR | Objet |
|
||||
|---|---|
|
||||
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
|
||||
| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||
| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête |
|
||||
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||
|
||||
@@ -146,6 +146,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
|
||||
| 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 |
|
||||
@@ -194,6 +195,21 @@ plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`erro
|
||||
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
|
||||
[ML-START.md](../../ML-START.md) section 3.
|
||||
|
||||
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
|
||||
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
||||
feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et
|
||||
son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit
|
||||
[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service :
|
||||
`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur
|
||||
`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et
|
||||
le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est
|
||||
disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make
|
||||
recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend
|
||||
plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md).
|
||||
Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la
|
||||
génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est
|
||||
découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres.
|
||||
|
||||
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,
|
||||
donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/
|
||||
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
|
||||
|
||||
@@ -287,6 +287,12 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset
|
||||
|
||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||
|
||||
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`
|
||||
ou par `make recommendations`, à partir des alertes déjà en base. Le couple
|
||||
`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune
|
||||
ligne.
|
||||
|
||||
### Relations entre les tables
|
||||
|
||||
- Un site possède plusieurs mesures, prévisions et alertes.
|
||||
|
||||
Reference in New Issue
Block a user