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:
+108
-1
@@ -1453,6 +1453,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/recommendations/generate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"recommendations"
|
||||
],
|
||||
"summary": "Génère les recommandations à partir des alertes",
|
||||
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RecommendationGenerationResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/stats/summary": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2344,6 +2428,29 @@
|
||||
],
|
||||
"title": "ReadingSource"
|
||||
},
|
||||
"RecommendationGenerationResponse": {
|
||||
"properties": {
|
||||
"alerts_examined": {
|
||||
"type": "integer",
|
||||
"title": "Alerts Examined"
|
||||
},
|
||||
"recommendations_created": {
|
||||
"type": "integer",
|
||||
"title": "Recommendations Created"
|
||||
},
|
||||
"already_present": {
|
||||
"type": "integer",
|
||||
"title": "Already Present"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"alerts_examined",
|
||||
"recommendations_created",
|
||||
"already_present"
|
||||
],
|
||||
"title": "RecommendationGenerationResponse"
|
||||
},
|
||||
"RecommendationResponse": {
|
||||
"properties": {
|
||||
"recommendation_id": {
|
||||
@@ -3153,7 +3260,7 @@
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
|
||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`."
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
|
||||
Reference in New Issue
Block a user