diff --git a/Makefile b/Makefile index 2a4b3d2..a2a171f 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score + ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -77,6 +77,9 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel + cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/README.md b/apps/backend/README.md index 6c48b3a..2aebb52 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/api/v1/recommendations` | Liste les recommandations | `lecteur` | | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` | +| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c247f4c..abf6293 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -187,7 +187,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] def get_recommendation_service(session: SessionDep) -> RecommendationService: - return RecommendationService(recommendations=RecommendationRepository(session)) + return RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 11b2604..8ca8c08 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "recommendations", "description": ( "Consultation des recommandations issues des alertes. Accessible à partir du rôle " - "`lecteur`." + "`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." ), }, { diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py index 87e8be1..180808a 100644 --- a/apps/backend/app/api/v1/endpoints/recommendations.py +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -1,13 +1,18 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, RecommendationServiceDep -from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.recommendation import RecommendationResponse +from app.schemas.recommendation import ( + RecommendationGenerationResponse, + RecommendationResponse, +) from app.services.recommendation import RecommendationNotFoundError router = APIRouter() +REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION} + REPONSES_INTROUVABLE: Reponses = { **REPONSE_VALIDATION, 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, @@ -38,3 +43,22 @@ async def get_recommendation( status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" ) from erreur return RecommendationResponse.model_validate(recommendation) + + +@router.post( + "/generate", + response_model=RecommendationGenerationResponse, + summary="Génère les recommandations à partir des alertes", + responses=REPONSES_GENERATION, +) +async def generate_recommendations( + _: AdminDep, + service: RecommendationServiceDep, + site_id: str | None = None, +) -> RecommendationGenerationResponse: + rapport = await service.generate(site_id=site_id) + return RecommendationGenerationResponse( + alerts_examined=rapport.alertes_examinees, + recommendations_created=rapport.recommandations_creees, + already_present=rapport.deja_presentes, + ) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index f713fa5..5822e08 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -22,8 +22,11 @@ from app.core.hashing import build_hasher from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app +from app.repositories.alert import AlertRepository +from app.repositories.recommendation import RecommendationRepository from app.repositories.user import UserRepository from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite +from app.services.recommendation import RecommendationService LONGUEUR_MOT_DE_PASSE_GENERE = 24 CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -63,6 +66,22 @@ async def create_admin( ) +async def generate_recommendations(*, site_id: str | None) -> str: + async with get_session_factory()() as session: + service = RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) + rapport = await service.generate(site_id=site_id) + + return ( + f"{rapport.alertes_examinees} alerte(s) examinée(s), " + f"{rapport.recommandations_creees} recommandation(s) créée(s), " + f"{rapport.deja_presentes} déjà présente(s)" + ) + + # Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le # fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle # de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris. @@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser: "export-openapi", help="Écrit le contrat OpenAPI sur disque" ) contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) + + recommandations = sous_commandes.add_parser( + "generate-recommendations", + help="Applique le moteur de règles aux alertes en base", + ) + recommandations.add_argument( + "--site-id", default=None, help="Limite le traitement aux alertes d'un site" + ) return parser @@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int: print(export_openapi(Path(arguments.output))) return 0 + if arguments.commande == "generate-recommendations": + print(asyncio.run(generate_recommendations(site_id=arguments.site_id))) + return 0 + mot_de_passe = read_password(generate=arguments.generate) succes, message = asyncio.run( diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 7870131..8b07349 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -1,11 +1,21 @@ from collections.abc import Sequence +from dataclasses import asdict, dataclass from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Recommendation +@dataclass(frozen=True, slots=True) +class NouvelleRecommandation: + alert_id: int + action: str + explanation: str + rule_reference: str + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -20,3 +30,18 @@ class RecommendationRepository: ) recommendation: Recommendation | None = await self._session.scalar(requete) return recommendation + + # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une + # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + if not nouvelles: + return 0 + + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees = (await self._session.scalars(requete)).all() + return len(creees) diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py index 8764615..bb22d02 100644 --- a/apps/backend/app/schemas/recommendation.py +++ b/apps/backend/app/schemas/recommendation.py @@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel): explanation: str rule_reference: str created_at: datetime + + +class RecommendationGenerationResponse(BaseModel): + alerts_examined: int + recommendations_created: int + already_present: int diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py index 31115ae..6b0ceb6 100644 --- a/apps/backend/app/services/recommendation.py +++ b/apps/backend/app/services/recommendation.py @@ -1,7 +1,15 @@ from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol from app.models.energy import Recommendation +from app.repositories.alert import AlertRepository from app.repositories.recommendation import RecommendationRepository +from app.services.recommendation_rules import applique_les_regles + + +class Transaction(Protocol): + async def commit(self) -> None: ... class RecommendationError(Exception): @@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError): pass +@dataclass(frozen=True, slots=True) +class RapportGeneration: + alertes_examinees: int + recommandations_creees: int + deja_presentes: int + + class RecommendationService: - def __init__(self, *, recommendations: RecommendationRepository) -> None: + def __init__( + self, + *, + recommendations: RecommendationRepository, + alerts: AlertRepository, + transaction: Transaction, + ) -> None: self._recommendations = recommendations + self._alerts = alerts + self._transaction = transaction async def list_all(self) -> Sequence[Recommendation]: return await self._recommendations.list_all() @@ -24,3 +47,16 @@ class RecommendationService: if recommendation is None: raise RecommendationNotFoundError(recommendation_id) return recommendation + + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + alertes = await self._alerts.list_all(site_id=site_id) + nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)] + + creees = await self._recommendations.create_missing(nouvelles) + await self._transaction.commit() + + return RapportGeneration( + alertes_examinees=len(alertes), + recommandations_creees=creees, + deja_presentes=len(nouvelles) - creees, + ) diff --git a/apps/backend/app/services/recommendation_rules.py b/apps/backend/app/services/recommendation_rules.py new file mode 100644 index 0000000..72fa185 --- /dev/null +++ b/apps/backend/app/services/recommendation_rules.py @@ -0,0 +1,117 @@ +# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte +# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les +# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens +# prend donc une référence suffixée `-v2` - REGLES. + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from app.models.energy import Alert +from app.repositories.recommendation import NouvelleRecommandation +from app.schemas.alert import AlertSeverity, AlertType + +FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2 +POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100) + + +@dataclass(frozen=True, slots=True) +class Regle: + reference: str + action: str + declencheur: Callable[[Alert], bool] + motif: Callable[[Alert], str] + + +def _du_type(attendu: AlertType) -> Callable[[Alert], bool]: + return lambda alerte: alerte.type == attendu + + +def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]: + return lambda alerte: alerte.severity == attendue + + +# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne +# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas. +def _depasse_largement_le_seuil(alerte: Alert) -> bool: + if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0: + return False + return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR + + +REGLES: Final[tuple[Regle, ...]] = ( + Regle( + reference="spike-delestage-v1", + action="Délester les équipements non prioritaires sur le créneau du pic", + declencheur=_du_type(AlertType.SPIKE), + motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}", + ), + Regle( + reference="threshold-reduction-v1", + action="Ramener la puissance appelée sous le seuil contractuel", + declencheur=_du_type(AlertType.THRESHOLD), + motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}", + ), + Regle( + reference="outage-secours-v1", + action="Basculer sur l'alimentation de secours et prévenir l'exploitant", + declencheur=_du_type(AlertType.OUTAGE), + motif=lambda alerte: ( + f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}" + ), + ), + Regle( + reference="sensor-maintenance-v1", + action="Planifier une intervention de maintenance sur le capteur", + declencheur=_du_type(AlertType.SENSOR), + motif=lambda alerte: ( + f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables" + ), + ), + Regle( + reference="anomaly-verification-v1", + action="Confronter la mesure à la prévision et vérifier le paramétrage du site", + declencheur=_du_type(AlertType.ANOMALY), + motif=lambda alerte: ( + f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}" + ), + ), + Regle( + reference="escalade-astreinte-v1", + action="Escalader à l'astreinte sous une heure", + declencheur=_de_severite(AlertSeverity.CRITICAL), + motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}", + ), + Regle( + reference="contrat-puissance-v1", + action="Réévaluer la puissance souscrite au contrat", + declencheur=_depasse_largement_le_seuil, + motif=lambda alerte: ( + f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil " + f"sur le site {alerte.site_id}" + ), + ), +) + + +def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]: + contexte = _contexte_de_mesure(alerte) + return [ + NouvelleRecommandation( + alert_id=alerte.alert_id, + action=regle.action, + explanation=f"{regle.motif(alerte)}{contexte}.", + rule_reference=regle.reference, + ) + for regle in REGLES + if regle.declencheur(alerte) + ] + + +def _contexte_de_mesure(alerte: Alert) -> str: + if alerte.value is None: + return "" + grandeur = alerte.metric or "valeur" + if alerte.threshold is None: + return f" ({grandeur} mesurée à {alerte.value})" + return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})" diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index f7c445c..67b3877 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1453,6 +1453,90 @@ } } }, + "/api/v1/recommendations/generate": { + "post": { + "tags": [ + "recommendations" + ], + "summary": "Génère les recommandations à partir des alertes", + "operationId": "generate_recommendations_api_v1_recommendations_generate_post", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationGenerationResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + } + }, "/api/v1/stats/summary": { "get": { "tags": [ @@ -2344,6 +2428,29 @@ ], "title": "ReadingSource" }, + "RecommendationGenerationResponse": { + "properties": { + "alerts_examined": { + "type": "integer", + "title": "Alerts Examined" + }, + "recommendations_created": { + "type": "integer", + "title": "Recommendations Created" + }, + "already_present": { + "type": "integer", + "title": "Already Present" + } + }, + "type": "object", + "required": [ + "alerts_examined", + "recommendations_created", + "already_present" + ], + "title": "RecommendationGenerationResponse" + }, "RecommendationResponse": { "properties": { "recommendation_id": { @@ -3153,7 +3260,7 @@ }, { "name": "recommendations", - "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." }, { "name": "stats", diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py index e3b641b..2b38374 100644 --- a/apps/backend/tests/api/acces.py +++ b/apps/backend/tests/api/acces.py @@ -51,6 +51,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = { ("GET", "/api/v1/alerts"): Role.LECTEUR, ("GET", "/api/v1/recommendations"): Role.LECTEUR, ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("POST", "/api/v1/recommendations/generate"): Role.ADMIN, ("GET", "/api/v1/stats/summary"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR, diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py index d01db09..6e854bd 100644 --- a/apps/backend/tests/api/test_recommendations.py +++ b/apps/backend/tests/api/test_recommendations.py @@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Recommendation -from app.services.recommendation import RecommendationNotFoundError +from app.services.recommendation import RapportGeneration, RecommendationNotFoundError MOMENT = datetime(2024, 1, 1, tzinfo=UTC) @@ -40,6 +40,7 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.recommendation = recommendation() + self.site_demande: str | None = None async def list_all(self) -> list[Recommendation]: return [self.recommendation] @@ -49,6 +50,10 @@ class FauxService: raise self._erreur return self.recommendation + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + self.site_demande = site_id + return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1) + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/recommendations/404") assert response.status_code == 404 + + +@pytest.fixture +def admin_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_generate_recommendations_returns_the_generation_report( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + servi_en_admin() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 200 + assert response.json() == { + "alerts_examined": 2, + "recommendations_created": 3, + "already_present": 1, + } + + +async def test_generate_recommendations_forwards_the_requested_site( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi_en_admin() + + await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"}) + + assert service.site_demande == "SITE002" + + +async def test_generate_recommendations_refuses_a_reader( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 403 diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 075c9eb..64b3b5f 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,7 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site -from app.repositories.recommendation import RecommendationRepository +from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -83,3 +83,43 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier( await session.rollback() assert identifiants == sorted(identifiants) + + +def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation: + return NouvelleRecommandation( + alert_id=alert_id, + action="Délester les équipements non prioritaires", + explanation="Pic de consommation signalé.", + rule_reference=reference, + ) + + +async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + + creees = await depot.create_missing( + [nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")] + ) + await session.rollback() + + assert creees == 2 + + +async def test_create_missing_ignores_a_rule_already_held_for_the_alert( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + await depot.create_missing([nouvelle(alert_id)]) + + creees = await depot.create_missing([nouvelle(alert_id)]) + await session.rollback() + + assert creees == 0 + + +async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None: + creees = await RecommendationRepository(session).create_missing([]) + + assert creees == 0 diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py index e8ed2b2..725df25 100644 --- a/apps/backend/tests/services/test_recommendation.py +++ b/apps/backend/tests/services/test_recommendation.py @@ -1,10 +1,14 @@ +from collections.abc import Sequence from datetime import UTC, datetime import pytest -from app.models.energy import Recommendation +from app.models.energy import Alert, Recommendation +from app.repositories.recommendation import NouvelleRecommandation from app.services.recommendation import RecommendationNotFoundError, RecommendationService +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + def recommendation(recommendation_id: int = 1) -> Recommendation: return Recommendation( @@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation: action="Vérifier la consommation", explanation="Pic détecté", rule_reference="spike-v1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), + created_at=MOMENT, + ) + + +def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity=severity, + message="Pic de consommation", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, ) class FakeRepository: - def __init__(self, recommendations: list[Recommendation]) -> None: + def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None: self._recommendations = recommendations + self._creees = creees + self.recues: list[NouvelleRecommandation] = [] async def list_all(self) -> list[Recommendation]: return self._recommendations @@ -29,27 +53,111 @@ class FakeRepository: (r for r in self._recommendations if r.recommendation_id == recommendation_id), None ) + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + self.recues = list(nouvelles) + return len(self.recues) if self._creees is None else self._creees -async def test_list_all_returns_the_repository_recommendations() -> None: - service = RecommendationService( - recommendations=FakeRepository([recommendation(1), recommendation(2)]) + +class FakeAlertRepository: + def __init__(self, alertes: list[Alert]) -> None: + self._alertes = alertes + self.site_demande: str | None = None + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.site_demande = site_id + if site_id is None: + return self._alertes + return [a for a in self._alertes if a.site_id == site_id] + + +class FakeTransaction: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +def service( + recommendations: FakeRepository | None = None, + alerts: FakeAlertRepository | None = None, + transaction: FakeTransaction | None = None, +) -> RecommendationService: + return RecommendationService( + recommendations=recommendations or FakeRepository([]), + alerts=alerts or FakeAlertRepository([]), + transaction=transaction or FakeTransaction(), ) - recommendations = await service.list_all() + +async def test_list_all_returns_the_repository_recommendations() -> None: + depot = FakeRepository([recommendation(1), recommendation(2)]) + + recommendations = await service(recommendations=depot).list_all() assert [r.recommendation_id for r in recommendations] == [1, 2] async def test_get_by_id_returns_the_matching_recommendation() -> None: - service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) - - trouve = await service.get_by_id(1) + trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1) assert trouve.recommendation_id == 1 async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: - service = RecommendationService(recommendations=FakeRepository([])) - with pytest.raises(RecommendationNotFoundError): - await service.get_by_id(404) + await service().get_by_id(404) + + +async def test_generate_persists_one_proposal_per_triggered_rule() -> None: + depot = FakeRepository([]) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")]) + ).generate() + + assert {n.rule_reference for n in depot.recues} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + } + assert rapport.recommandations_creees == 2 + + +async def test_generate_commits_once() -> None: + transaction = FakeTransaction() + + await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate() + + assert transaction.commits == 1 + + +async def test_generate_restricts_the_alerts_to_the_requested_site() -> None: + alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")]) + depot = FakeRepository([]) + + rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002") + + assert alertes.site_demande == "SITE002" + assert rapport.alertes_examinees == 1 + assert {n.alert_id for n in depot.recues} == {2} + + +async def test_generate_reports_nothing_when_no_alert_matches() -> None: + rapport = await service().generate() + + assert rapport.alertes_examinees == 0 + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 0 + + +async def test_generate_counts_the_proposals_the_database_already_held() -> None: + depot = FakeRepository([], creees=0) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte()]) + ).generate() + + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 1 diff --git a/apps/backend/tests/services/test_recommendation_rules.py b/apps/backend/tests/services/test_recommendation_rules.py new file mode 100644 index 0000000..28a58fa --- /dev/null +++ b/apps/backend/tests/services/test_recommendation_rules.py @@ -0,0 +1,142 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Alert +from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +def alerte( + *, + alert_id: int = 1, + type_alerte: str = "spike", + severity: str = "high", + value: float | None = None, + threshold: float | None = None, + metric: str | None = None, + site_id: str = "SITE001", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type=type_alerte, + severity=severity, + message="Alerte de test", + value=value, + threshold=threshold, + metric=metric, + prediction_id=None, + raw_data={}, + ) + + +@pytest.mark.parametrize( + ("type_alerte", "attendue"), + [ + ("spike", "spike-delestage-v1"), + ("threshold", "threshold-reduction-v1"), + ("outage", "outage-secours-v1"), + ("sensor", "sensor-maintenance-v1"), + ("anomaly", "anomaly-verification-v1"), + ], + ids=["pic", "seuil", "coupure", "capteur", "anomalie"], +) +def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None: + proposees = applique_les_regles(alerte(type_alerte=type_alerte)) + + assert [p.rule_reference for p in proposees] == [attendue] + + +def test_a_critical_alert_adds_the_escalation_rule() -> None: + proposees = applique_les_regles(alerte(severity="critical")) + + assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"]) +def test_a_non_critical_alert_does_not_escalate(severity: str) -> None: + proposees = applique_les_regles(alerte(severity=severity)) + + assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees} + + +def test_a_large_overshoot_adds_the_contract_rule() -> None: + proposees = applique_les_regles( + alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0) + ) + + assert "contrat-puissance-v1" in {p.rule_reference for p in proposees} + + +def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None: + proposees = applique_les_regles(alerte(value=800.0, threshold=720.0)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize( + ("value", "threshold"), + [(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)], + ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"], +) +def test_the_contract_rule_stays_silent_without_an_exploitable_threshold( + value: float | None, threshold: float | None +) -> None: + proposees = applique_les_regles(alerte(value=value, threshold=threshold)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +def test_the_explanation_quotes_the_measure_and_the_threshold() -> None: + proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation + + +def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None: + proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation + + +def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None: + proposees = applique_les_regles(alerte()) + + assert "(" not in proposees[0].explanation + + +def test_the_explanation_names_the_site() -> None: + proposees = applique_les_regles(alerte(site_id="SITE042")) + + assert "SITE042" in proposees[0].explanation + + +def test_every_proposal_carries_the_alert_identifier() -> None: + proposees = applique_les_regles(alerte(alert_id=77, severity="critical")) + + assert {p.alert_id for p in proposees} == {77} + + +def test_an_alert_never_yields_the_same_rule_twice() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert len(proposees) == len({p.rule_reference for p in proposees}) + + +def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert {p.rule_reference for p in proposees} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + "contrat-puissance-v1", + } diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 7344bf7..2edf814 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password( assert code == 0 assert destination.exists() assert str(destination) in capsys.readouterr().out + + +def test_build_parser_reads_the_generate_recommendations_arguments() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"]) + + assert arguments.commande == "generate-recommendations" + assert arguments.site_id == "SITE002" + + +def test_build_parser_defaults_the_generation_to_every_site() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations"]) + + assert arguments.site_id is None + + +def test_main_generates_the_recommendations_without_asking_for_a_password( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_generation(*, site_id: str | None) -> str: + return f"génération lancée pour {site_id}" + + monkeypatch.setattr(cli, "generate_recommendations", fausse_generation) + + code = cli.main(["generate-recommendations", "--site-id", "SITE002"]) + + assert code == 0 + assert "SITE002" in capsys.readouterr().out diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md new file mode 100644 index 0000000..794825e --- /dev/null +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -0,0 +1,77 @@ +# 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 ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui + de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne + s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- 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. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index a650a1e..2f54762 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -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/` | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 09d3b3d..44d50be 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -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,19 @@ 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). +Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. + `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 diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index ffd5e6d..b02fd13 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -224,6 +224,11 @@ 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 `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`. 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.