`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
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
from fastapi import APIRouter, HTTPException, status
|
|
|
|
from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep
|
|
from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses
|
|
from app.schemas.errors import ErrorResponse
|
|
from app.schemas.recommendation import (
|
|
RecommendationGenerationResponse,
|
|
RecommendationResponse,
|
|
)
|
|
from app.services.recommendation import RecommendationNotFoundError
|
|
|
|
router = APIRouter()
|
|
|
|
REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION}
|
|
|
|
REPONSES_INTROUVABLE: Reponses = {
|
|
**REPONSE_VALIDATION,
|
|
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
|
}
|
|
|
|
|
|
@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
|
|
async def list_recommendations(
|
|
_: LecteurDep, service: RecommendationServiceDep
|
|
) -> list[RecommendationResponse]:
|
|
recommendations = await service.list_all()
|
|
return [RecommendationResponse.model_validate(r) for r in recommendations]
|
|
|
|
|
|
@router.get(
|
|
"/{recommendation_id}",
|
|
response_model=RecommendationResponse,
|
|
summary="Décrit une recommandation",
|
|
responses=REPONSES_INTROUVABLE,
|
|
)
|
|
async def get_recommendation(
|
|
recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
|
|
) -> RecommendationResponse:
|
|
try:
|
|
recommendation = await service.get_by_id(recommendation_id)
|
|
except RecommendationNotFoundError as erreur:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
|
) from erreur
|
|
return RecommendationResponse.model_validate(recommendation)
|
|
|
|
|
|
@router.post(
|
|
"/generate",
|
|
response_model=RecommendationGenerationResponse,
|
|
summary="Génère les recommandations à partir des alertes",
|
|
responses=REPONSES_GENERATION,
|
|
)
|
|
async def generate_recommendations(
|
|
_: AdminDep,
|
|
service: RecommendationServiceDep,
|
|
site_id: str | None = None,
|
|
) -> RecommendationGenerationResponse:
|
|
rapport = await service.generate(site_id=site_id)
|
|
return RecommendationGenerationResponse(
|
|
alerts_examined=rapport.alertes_examinees,
|
|
recommendations_created=rapport.recommandations_creees,
|
|
already_present=rapport.deja_presentes,
|
|
)
|