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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user