`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.
5.0 KiB
0006 - Le moteur de règles de recommandation vit dans le backend
- Statut : accepté
- Date : 2026-09-18
Contexte
L'issue #38 demande un « moteur de règles pour recommandations », portée par le label ml. Le
schéma tranche déjà la forme du résultat : recommendation(alert_id, action, explanation, rule_reference), avec alert_id en clé étrangère NOT NULL et une contrainte d'unicité
uq_recommendation_alert_rule sur (alert_id, rule_reference). Une recommandation est donc
dérivée d'une alerte, jamais d'une mesure brute ni d'une prévision.
Deux emplacements se disputaient le code :
ml/enervision_ml/, sur le patron deenervision_ml.scorelivré par #37 : un script autonome qui se connecte parML_DATABASE_URL, écrit une table, et que l'API se contente de lire. L'ADR 0005 annonce d'ailleurs #38 de ce côté, en écrivant que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même moduleenervision_ml.features».apps/backend/app/services/, oùapps/backend/README.mdplace les « regles metier ».
Décision
Le moteur vit dans apps/backend/app/services/, sous la forme d'un module pur
recommendation_rules.py (le catalogue REGLES) et d'une méthode RecommendationService.generate()
qui l'applique, persiste et valide la transaction.
Trois raisons :
- Il n'utilise rien du ML. Le catalogue lit
alert.type,alert.severity,alert.valueetalert.threshold. Aucun modèle, aucune feature, aucunenervision_ml.features: la phrase de l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des features, pas pour des règles sur alertes. Le labelmlde #38 désigne le lot fonctionnel « prédiction et recommandation », pas l'emplacement du code. - Il lit et écrit deux tables déjà couvertes par des repositories.
AlertRepositorysait déjà filtrer par site. Le placer dansml/obligerait à réécrire ces accès en SQL brut, et à maintenir deux représentations du même domaine. - Le déclencheur HTTP n'a de sens que dans l'API.
POST /recommendations/generatedoit passer parrequire_role(Role.ADMIN)et par la session injectée : cela suppose d'être dans l'application FastAPI.
Le moteur reste néanmoins déclenchable hors HTTP, par python -m app.cli generate-recommendations (cible make recommendations), sur le patron de make ml-score : rien
n'oblige à exposer un port pour régénérer des recommandations.
Conséquences
- L'API gagne sa première route d'écriture métier. La checklist de
20-backend.mds'applique : entrée dansROLE_MINIMUMdetests/api/acces.py, etopenapi.jsonrégénéré dans le même commit. RecommendationServicen'est plus en lecture seule : il reçoit leTransactionProtocol déjà utilisé parAuthServiceetUserService, et commite lui-même. Les repositories continuent de ne pas commiter.- L'idempotence est déléguée à la base.
create_missing()insère enON CONFLICT DO NOTHINGsuruq_recommendation_alert_ruleplutôt que de relire avant d'écrire, ce qui supprime la fenêtre entre le contrôle et l'insertion. Corollaire :rule_referenceest une clé fonctionnelle. 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. - Le moteur est branché sur la détection interne, et sur elle seule.
alertest alimentée parapp/detection/internal_alerts.py(#104), lancée à la main commeenervision_ml.score; l'ingestion de l'API Mock/alertsreste à 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 deTAILLE_DE_LOTlignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes, et la détection interne peut alimenteralertau fil de l'eau. - 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.
Alternatives écartées
- Module et CLI dans
ml/enervision_ml/: cohérent avec le labelmlet avec la lettre de l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des features ou un modèle. - Génération à la volée, sans persistance, calculée à chaque
GET /recommendations: supprime le besoin d'écriture, mais rend la tablerecommendationet sa contrainte d'unicité inutiles, et interdit toute trace de ce qui a été proposé et quand. - Table de configuration des règles en base, plutôt qu'un catalogue en Python : plus souple, mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que rien n'exprime à ce stade.