`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
48 lines
1.7 KiB
Python
48 lines
1.7 KiB
Python
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
|
|
|
|
async def list_all(self) -> Sequence[Recommendation]:
|
|
requete = select(Recommendation).order_by(Recommendation.recommendation_id)
|
|
return (await self._session.scalars(requete)).all()
|
|
|
|
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
|
|
requete = select(Recommendation).where(
|
|
Recommendation.recommendation_id == recommendation_id
|
|
)
|
|
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)
|