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:
Johan LEROY
2026-09-18 15:49:54 +02:00
parent 619024f547
commit aeb07e14db
21 changed files with 854 additions and 23 deletions
+1
View File
@@ -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,
+59 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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",
}
+27
View File
@@ -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