feat(backend): moteur de règles de recommandations et route de génération
`recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient livrées, mais rien ne produisait de ligne. Le moteur comble ce trou. Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`, `alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait #38 du côté ML, est tranché par l'ADR 0006. Sept règles, cinq par type d'alerte et deux transverses (sévérité critique, dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte. L'idempotence est portée par la base : `create_missing()` insère en `ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence. Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et `python -m app.cli generate-recommendations` (cible `make recommendations`). Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne (#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la chaîne s'allume sans retoucher le moteur le jour où les alertes existent. Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée contre PostgreSQL. Closes #38
This commit is contained in:
@@ -6,7 +6,7 @@ ML := ml
|
|||||||
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
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 \
|
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
|
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}'
|
@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
|
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),)
|
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: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
|||||||
@@ -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/sites/{site_id}` | Décrit un site | `lecteur` |
|
||||||
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
||||||
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `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` |
|
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
|
|||||||
|
|
||||||
|
|
||||||
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
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)]
|
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
"name": "recommendations",
|
"name": "recommendations",
|
||||||
"description": (
|
"description": (
|
||||||
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
|
"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`."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import LecteurDep, RecommendationServiceDep
|
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
|
||||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
|
||||||
from app.schemas.errors import ErrorResponse
|
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
|
from app.services.recommendation import RecommendationNotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
|
||||||
|
|
||||||
REPONSES_INTROUVABLE: Reponses = {
|
REPONSES_INTROUVABLE: Reponses = {
|
||||||
**REPONSE_VALIDATION,
|
**REPONSE_VALIDATION,
|
||||||
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
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"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
||||||
) from erreur
|
) from erreur
|
||||||
return RecommendationResponse.model_validate(recommendation)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ from app.core.hashing import build_hasher
|
|||||||
from app.core.roles import Role
|
from app.core.roles import Role
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
from app.main import create_app
|
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.repositories.user import UserRepository
|
||||||
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite
|
||||||
|
from app.services.recommendation import RecommendationService
|
||||||
|
|
||||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
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
|
# 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
|
# 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.
|
# 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"
|
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||||
)
|
)
|
||||||
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(export_openapi(Path(arguments.output)))
|
print(export_openapi(Path(arguments.output)))
|
||||||
return 0
|
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)
|
mot_de_passe = read_password(generate=arguments.generate)
|
||||||
|
|
||||||
succes, message = asyncio.run(
|
succes, message = asyncio.run(
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
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:
|
class RecommendationRepository:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
@@ -20,3 +30,18 @@ class RecommendationRepository:
|
|||||||
)
|
)
|
||||||
recommendation: Recommendation | None = await self._session.scalar(requete)
|
recommendation: Recommendation | None = await self._session.scalar(requete)
|
||||||
return recommendation
|
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)
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel):
|
|||||||
explanation: str
|
explanation: str
|
||||||
rule_reference: str
|
rule_reference: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationGenerationResponse(BaseModel):
|
||||||
|
alerts_examined: int
|
||||||
|
recommendations_created: int
|
||||||
|
already_present: int
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
from app.models.energy import Recommendation
|
||||||
|
from app.repositories.alert import AlertRepository
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
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):
|
class RecommendationError(Exception):
|
||||||
@@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RapportGeneration:
|
||||||
|
alertes_examinees: int
|
||||||
|
recommandations_creees: int
|
||||||
|
deja_presentes: int
|
||||||
|
|
||||||
|
|
||||||
class RecommendationService:
|
class RecommendationService:
|
||||||
def __init__(self, *, recommendations: RecommendationRepository) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recommendations: RecommendationRepository,
|
||||||
|
alerts: AlertRepository,
|
||||||
|
transaction: Transaction,
|
||||||
|
) -> None:
|
||||||
self._recommendations = recommendations
|
self._recommendations = recommendations
|
||||||
|
self._alerts = alerts
|
||||||
|
self._transaction = transaction
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[Recommendation]:
|
async def list_all(self) -> Sequence[Recommendation]:
|
||||||
return await self._recommendations.list_all()
|
return await self._recommendations.list_all()
|
||||||
@@ -24,3 +47,16 @@ class RecommendationService:
|
|||||||
if recommendation is None:
|
if recommendation is None:
|
||||||
raise RecommendationNotFoundError(recommendation_id)
|
raise RecommendationNotFoundError(recommendation_id)
|
||||||
return recommendation
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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})"
|
||||||
+108
-1
@@ -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": {
|
"/api/v1/stats/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2344,6 +2428,29 @@
|
|||||||
],
|
],
|
||||||
"title": "ReadingSource"
|
"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": {
|
"RecommendationResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"recommendation_id": {
|
"recommendation_id": {
|
||||||
@@ -3153,7 +3260,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "recommendations",
|
"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",
|
"name": "stats",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
|||||||
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/recommendations/{recommendation_id}"): 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/stats/summary"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
("GET", "/api/v1/readings"): Role.LECTEUR,
|
||||||
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
("GET", "/api/v1/predictions"): Role.LECTEUR,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service
|
|||||||
from app.core.principal import Principal
|
from app.core.principal import Principal
|
||||||
from app.core.roles import AccountKind, Role
|
from app.core.roles import AccountKind, Role
|
||||||
from app.models.energy import Recommendation
|
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)
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ class FauxService:
|
|||||||
def __init__(self, erreur: Exception | None = None) -> None:
|
def __init__(self, erreur: Exception | None = None) -> None:
|
||||||
self._erreur = erreur
|
self._erreur = erreur
|
||||||
self.recommendation = recommendation()
|
self.recommendation = recommendation()
|
||||||
|
self.site_demande: str | None = None
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
async def list_all(self) -> list[Recommendation]:
|
||||||
return [self.recommendation]
|
return [self.recommendation]
|
||||||
@@ -49,6 +50,10 @@ class FauxService:
|
|||||||
raise self._erreur
|
raise self._erreur
|
||||||
return self.recommendation
|
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
|
@pytest.fixture
|
||||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
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")
|
response = await client.get("/api/v1/recommendations/404")
|
||||||
|
|
||||||
assert response.status_code == 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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Alert, Recommendation, Site
|
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
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@@ -83,3 +83,43 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier(
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
assert identifiants == sorted(identifiants)
|
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
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
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
|
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
||||||
|
|
||||||
|
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
||||||
return Recommendation(
|
return Recommendation(
|
||||||
@@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation:
|
|||||||
action="Vérifier la consommation",
|
action="Vérifier la consommation",
|
||||||
explanation="Pic détecté",
|
explanation="Pic détecté",
|
||||||
rule_reference="spike-v1",
|
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:
|
class FakeRepository:
|
||||||
def __init__(self, recommendations: list[Recommendation]) -> None:
|
def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None:
|
||||||
self._recommendations = recommendations
|
self._recommendations = recommendations
|
||||||
|
self._creees = creees
|
||||||
|
self.recues: list[NouvelleRecommandation] = []
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
async def list_all(self) -> list[Recommendation]:
|
||||||
return self._recommendations
|
return self._recommendations
|
||||||
@@ -29,27 +53,111 @@ class FakeRepository:
|
|||||||
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
|
(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(
|
class FakeAlertRepository:
|
||||||
recommendations=FakeRepository([recommendation(1), recommendation(2)])
|
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]
|
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
||||||
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
|
trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1)
|
||||||
|
|
||||||
trouve = await service.get_by_id(1)
|
|
||||||
|
|
||||||
assert trouve.recommendation_id == 1
|
assert trouve.recommendation_id == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
||||||
service = RecommendationService(recommendations=FakeRepository([]))
|
|
||||||
|
|
||||||
with pytest.raises(RecommendationNotFoundError):
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password(
|
|||||||
assert code == 0
|
assert code == 0
|
||||||
assert destination.exists()
|
assert destination.exists()
|
||||||
assert str(destination) in capsys.readouterr().out
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -165,3 +165,8 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| ADR | Objet |
|
| ADR | Objet |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` |
|
| [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/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` | Liste les recommandations. `lecteur` | 401, 403, 500 |
|
||||||
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 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/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/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/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.
|
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.
|
[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,
|
`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/
|
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
|
owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni
|
||||||
|
|||||||
@@ -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
|
dans `dataset.metadata`. Elles servent à l’analyse des données
|
||||||
et ne sont pas considérées comme des alertes actuelles.
|
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
|
### Relations entre les tables
|
||||||
|
|
||||||
- Un site possède plusieurs mesures, prévisions et alertes.
|
- Un site possède plusieurs mesures, prévisions et alertes.
|
||||||
|
|||||||
Reference in New Issue
Block a user