""" interface/api/routes/prediction_routes.py ─────────────────────────────────────────── REST endpoints for querying BP prediction history. Endpoints: GET /api/v1/predictions/{user_id} — Latest N predictions for a user GET /api/v1/predictions/{user_id}/signal/{signal_id} — Prediction for a specific signal Error handling: Domain exceptions bubble up to the global handlers in error_handlers.py. EntityNotFoundError is raised explicitly when a lookup returns None. """ from __future__ import annotations from datetime import datetime from typing import Annotated, Optional from fastapi import APIRouter, Depends, Query, status from src.application.dto.prediction_dto import PredictionHistoryResponse, PredictionResponse from src.application.use_cases.get_prediction_history import GetPredictionHistoryUseCase from src.domain.exceptions.domain_exceptions import EntityNotFoundError from src.domain.interfaces.repositories.prediction_repository import PredictionRepository from src.interface.api.dependencies import ( get_prediction_history_use_case, get_prediction_repository, ) from src.shared.constants import DEFAULT_PAGINATION_LIMIT from src.shared.logger import get_logger logger = get_logger(__name__) router = APIRouter( prefix="/predictions", tags=["Blood Pressure Predictions"], ) # ── Shared error response schemas for Swagger ───────────────────────────────── _NOT_FOUND_RESPONSE = { "description": "Resource not found.", "content": { "application/json": { "example": { "error": "not_found", "message": "BPPrediction with id='...' not found.", "context": {"entity_type": "BPPrediction", "entity_id": "..."}, "timestamp": "2026-05-31T09:00:00+00:00", } } }, } _DB_ERROR_RESPONSE = { "description": "Database temporarily unavailable.", "content": { "application/json": { "example": { "error": "database_unavailable", "message": "The database is temporarily unavailable. Please try again later.", "context": {}, "timestamp": "2026-05-31T09:00:00+00:00", } } }, } @router.get( "/{user_id}", response_model=PredictionHistoryResponse, status_code=status.HTTP_200_OK, summary="Get Prediction History for a User", description=( "Retrieve paginated BP prediction history for a patient. " "Optionally filter by date range (ISO 8601 UTC timestamps)." ), responses={ 200: {"description": "Prediction history retrieved successfully."}, 503: _DB_ERROR_RESPONSE, }, ) async def get_prediction_history( user_id: str, use_case: Annotated[GetPredictionHistoryUseCase, Depends(get_prediction_history_use_case)], limit: int = Query(default=DEFAULT_PAGINATION_LIMIT, ge=1, le=500), start: Optional[datetime] = Query(default=None, description="Start of date range (UTC ISO8601)"), end: Optional[datetime] = Query(default=None, description="End of date range (UTC ISO8601)"), ) -> PredictionHistoryResponse: """ Retrieve BP prediction history for a given user. - Without ``start``/``end``: returns the latest N predictions (``limit``). - With ``start`` and ``end``: returns predictions within the date range. **Possible errors:** - `503 database_unavailable` — Supabase is temporarily unreachable. """ logger.info( "GET /predictions/%s — limit=%d, start=%s, end=%s", user_id, limit, start, end, ) return await use_case.execute( user_id=user_id, limit=limit, start=start, end=end, ) @router.get( "/{user_id}/signal/{signal_id}", response_model=PredictionResponse, status_code=status.HTTP_200_OK, summary="Get Prediction for a Specific PPG Signal", description=( "Retrieve the AI-generated blood pressure prediction associated " "with a specific PPG signal ID." ), responses={ 200: {"description": "Prediction retrieved successfully."}, 404: _NOT_FOUND_RESPONSE, 503: _DB_ERROR_RESPONSE, }, ) async def get_prediction_by_signal( user_id: str, signal_id: str, prediction_repo: Annotated[PredictionRepository, Depends(get_prediction_repository)], ) -> PredictionResponse: """ Retrieve the BP prediction associated with a specific PPG signal. **Possible errors:** - `404 not_found` — No prediction exists for the given ``signal_id``. - `503 database_unavailable` — Supabase is temporarily unreachable. """ logger.info("GET /predictions/%s/signal/%s", user_id, signal_id) prediction = await prediction_repo.get_by_signal_id(signal_id) if prediction is None: raise EntityNotFoundError(entity_type="BPPrediction", entity_id=signal_id) return PredictionResponse( id=prediction.id, ppg_signal_id=prediction.ppg_signal_id, predicted_sbp=prediction.predicted_sbp, predicted_dbp=prediction.predicted_dbp, predicted_ecg=prediction.predicted_ecg, mean_arterial_pressure=prediction.mean_arterial_pressure, pulse_pressure=prediction.pulse_pressure, hypertension_stage=prediction.hypertension_stage, model_version=prediction.model_version, inference_time_ms=prediction.inference_time_ms, sa_log=prediction.sa_log, created_at=prediction.created_at, )