fix(backend): decoupe l'insertion des recommandations en lots et remet les docs a jour
Backend / Tests exigeant une base (push) Failing after 34s
Backend / Lint, typage et tests (push) Successful in 1m24s
Backend / Audit des dépendances (push) Successful in 57s
SonarQube / build-back (push) Successful in 1m5s
SonarQube / build-front (push) Successful in 9m39s
SonarQube / test-back (push) Failing after 51s
SonarQube / test-front (push) Failing after 5m6s
SonarQube / SonarQube (push) Skipped

`create_missing()` construisait un seul `INSERT ... VALUES` pour la totalite des
propositions. Avec quatre colonnes par ligne et le plafond asyncpg de 32 767
parametres, la route echouait au-dela de 8 191 recommandations par appel, cas
devenu realiste maintenant que la detection interne (#104) alimente `alert` en
continu. L'insertion passe par des lots de `TAILLE_DE_LOT` lignes, sur le patron
de `app/etl/historical_import.py`.

L'ADR 0006, `20-backend.md` et la description de la PR annoncaient qu'aucune
source n'alimentait `alert` et que #104 n'etait pas commencee. #104 est livree
sur `dev` depuis la #113 : les phrases sont corrigees plutot que laissees a
vieillir dans un ADR.
This commit is contained in:
Johan LEROY
2026-09-21 09:45:25 +02:00
parent 9a1af94d88
commit 19c38fe571
5 changed files with 45 additions and 17 deletions
+15 -11
View File
@@ -16,6 +16,9 @@ class NouvelleRecommandation:
rule_reference: str rule_reference: str
TAILLE_DE_LOT = 1000
class RecommendationRepository: class RecommendationRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
@@ -34,14 +37,15 @@ class RecommendationRepository:
# Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une # 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. # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion.
async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int:
if not nouvelles: creees = 0
return 0 # Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre
# colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait.
requete = ( for debut in range(0, len(nouvelles), TAILLE_DE_LOT):
insert(Recommendation) requete = (
.values([asdict(nouvelle) for nouvelle in nouvelles]) insert(Recommendation)
.on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") .values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]])
.returning(Recommendation.recommendation_id) .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule")
) .returning(Recommendation.recommendation_id)
creees = (await self._session.scalars(requete)).all() )
return len(creees) creees += len((await self._session.scalars(requete)).all())
return creees
@@ -5,6 +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 import recommendation as module_recommendation
from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -123,3 +124,19 @@ async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSe
creees = await RecommendationRepository(session).create_missing([]) creees = await RecommendationRepository(session).create_missing([])
assert creees == 0 assert creees == 0
async def test_create_missing_inserts_every_proposal_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2)
depot = RecommendationRepository(session)
alert_id = await creer_alerte(session)
propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)]
creees = await depot.create_missing(propositions)
enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id]
await session.rollback()
assert creees == 5
assert len(enregistrees) == 5
@@ -57,9 +57,13 @@ n'oblige à exposer un port pour régénérer des recommandations.
fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. 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 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. 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 - **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée
de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ;
s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant
que la détection n'a pas tourné, sans que le moteur soit à retoucher.
- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT`
lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes,
et la détection interne peut alimenter `alert` au fil de l'eau.
- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait - 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. à rouvrir : le moteur redeviendrait consommateur du pipeline ML.
+3 -1
View File
@@ -206,7 +206,9 @@ le rapport rendu distingue `recommendations_created` de `already_present`. Le m
disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make 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 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). 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. Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la
génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est
découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres.
`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/
+3 -2
View File
@@ -226,8 +226,9 @@ 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 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` (`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 ou par `make recommendations`, à partir des alertes déjà en base. Le couple
moteur sur les mêmes alertes n'ajoute aucune ligne. `(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