feat(backend): surveille la derive du modele de prevision
EC06 attendait une reponse a « comment savez-vous que le modele se degrade ? ». Elle n'existait nulle part : `docs/architecture/00-vue-ensemble.md` et `docs/ML-START.md` le disaient tous les deux. Le calcul vit dans le backend, et `ml/` ne gagne pas une ligne. Trois raisons : `prediction` n'est pas dans le perimetre de lecture que `ML_DATABASE_URL` vise (ADR 0003 et ML-START le bornent a `reading` et `site`) ; l'alignement prevu contre realise existe deja une fois ici, dans `AlertService._detect_anomaly`, et le dupliquer en SQL brut creerait une seconde source de verite, ce que l'ADR 0006 refuse ; et FastAPI continue de ne jamais faire tourner LightGBM. Ce qui est mesure : la jointure `prediction` x `reading` sur `(site_id, target_at)`, avec un `DISTINCT ON` des deux cotes. Les runs de scoring s'empilent volontairement, et `uq_reading_source` autorise deux lectures au meme instant quand la source differe : sans ce dedoublonnage, la meme heure pesait plusieurs fois dans la moyenne. La fenetre est fermee a droite par un delai de grace, sinon la derniere heure, dont le realise n'est pas encore ingere, ferait chuter la couverture a chaque execution. Le verdict a trois valeurs, pas deux : avec trois points on ne declare pas une derive, on dit qu'on ne sait pas. La comparaison se fait entre deux fenetres vives de meme duree, jamais contre la metrique loguee a l'entrainement : celle-ci mesure un backtest a meteo connue, le scoring prevoit une heure dont la meteo ne l'est pas. `drift_report` porte une ligne par site plus une ligne globale, que `site_id` a NULL designe. L'idempotence passe par un index a `coalesce` et non par une contrainte d'unicite, sans quoi deux lignes globales ne seraient jamais egales.
This commit is contained in:
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
|
||||
from app.db.session import get_session
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.drift import DriftRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
|
||||
from app.repositories.password_reset_token import PasswordResetTokenRepository
|
||||
@@ -35,6 +36,7 @@ from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.alert import AlertService
|
||||
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
|
||||
from app.services.drift import DriftService
|
||||
from app.services.prediction import PredictionService
|
||||
from app.services.reading import ReadingService
|
||||
from app.services.recommendation import RecommendationService
|
||||
@@ -232,6 +234,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
|
||||
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
|
||||
|
||||
|
||||
def get_drift_service(session: SessionDep) -> DriftService:
|
||||
return DriftService(DriftRepository(session))
|
||||
|
||||
|
||||
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -90,6 +90,14 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "monitoring",
|
||||
"description": (
|
||||
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
|
||||
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
|
||||
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
@@ -153,6 +161,17 @@ REPONSES_ADMIN: Final[Reponses] = {
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_OPERATEUR: Final[Reponses] = {
|
||||
**REPONSES_AUTHENTIFIEES,
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
||||
"`password_change_required`."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
||||
# insuffisants, seulement pour le mot de passe provisoire.
|
||||
REPONSES_LECTEUR: Final[Reponses] = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import DriftServiceDep, OperateurDep
|
||||
from app.api.openapi import REPONSE_VALIDATION
|
||||
from app.schemas.drift import DriftReportResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/drift",
|
||||
response_model=list[DriftReportResponse],
|
||||
summary="Dernier rapport de dérive par site, plus la ligne globale",
|
||||
responses=REPONSE_VALIDATION,
|
||||
)
|
||||
async def get_drift(
|
||||
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
|
||||
) -> list[DriftReportResponse]:
|
||||
rapports = await service.derniers(site_id=site_id)
|
||||
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
|
||||
@@ -1,10 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.openapi import (
|
||||
REPONSE_SERVEUR,
|
||||
REPONSES_ADMIN,
|
||||
REPONSES_LECTEUR,
|
||||
REPONSES_OPERATEUR,
|
||||
)
|
||||
from app.api.v1.endpoints import (
|
||||
alerts,
|
||||
auth,
|
||||
health,
|
||||
monitoring,
|
||||
predictions,
|
||||
readings,
|
||||
recommendations,
|
||||
@@ -38,3 +44,6 @@ api_router.include_router(
|
||||
api_router.include_router(
|
||||
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
|
||||
)
|
||||
api_router.include_router(
|
||||
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user