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,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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user