| """ |
| FastAPI route handlers for the recommendation API. |
| All routes are registered on the router imported by main.py. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| from typing import Optional |
|
|
| from fastapi import APIRouter, Depends, HTTPException, Query |
|
|
| from api.event_logger import EventLogger |
| from api.schemas import ( |
| EventRequest, |
| EventResponse, |
| FeedbackRequest, |
| FeedbackResponse, |
| HealthResponse, |
| MovieDetailResponse, |
| RecommendationResponse, |
| RecommendedMovie, |
| SearchResponse, |
| TasteProfileResponse, |
| ) |
| from serving.inference import RecommendationEngine |
|
|
| logger = logging.getLogger(__name__) |
| router = APIRouter() |
|
|
|
|
| |
| |
| |
|
|
| _engine: Optional[RecommendationEngine] = None |
| _event_logger: Optional[EventLogger] = None |
|
|
|
|
| def set_engine(engine: RecommendationEngine) -> None: |
| global _engine |
| _engine = engine |
|
|
|
|
| def set_event_logger(el: EventLogger) -> None: |
| global _event_logger |
| _event_logger = el |
|
|
|
|
| def get_engine() -> RecommendationEngine: |
| if _engine is None: |
| raise HTTPException(status_code=503, detail="Models not loaded yet") |
| return _engine |
|
|
|
|
| def get_logger() -> EventLogger: |
| if _event_logger is None: |
| raise HTTPException(status_code=503, detail="Event logger not ready") |
| return _event_logger |
|
|
|
|
| |
| |
| |
|
|
| @router.get("/health", response_model=HealthResponse, tags=["system"]) |
| def health(engine: RecommendationEngine = Depends(get_engine)): |
| return HealthResponse( |
| status="ok", |
| models_loaded=True, |
| faiss_index_size=engine.faiss_index.num_items, |
| num_users=engine.prep.num_users, |
| num_movies=engine.prep.num_movies, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @router.get( |
| "/recommendations/{user_id}", |
| response_model=RecommendationResponse, |
| tags=["recommendations"], |
| ) |
| def get_recommendations( |
| user_id: int, |
| limit: int = Query(default=20, ge=1, le=100), |
| engine: RecommendationEngine = Depends(get_engine), |
| event_log: EventLogger = Depends(get_logger), |
| ): |
| """ |
| Return personalised movie recommendations for a user. |
| Runs the full Two-Tower → DeepFM → MMR pipeline. |
| """ |
| recs = engine.recommend(user_id, limit=limit) |
| event_log.log(user_id, "recommendation_served", metadata={"count": len(recs)}) |
|
|
| rec_movies = [ |
| RecommendedMovie( |
| movie_id=r["movie_id"], |
| title=r["title"], |
| year=r["year"], |
| genres=r["genres"], |
| score=r.get("score", 1.0), |
| explanation=r.get("explanation", ""), |
| ) |
| for r in recs |
| ] |
| return RecommendationResponse( |
| user_id=user_id, |
| recommendations=rec_movies, |
| total=len(rec_movies), |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @router.get( |
| "/movies/{movie_id}", |
| response_model=MovieDetailResponse, |
| tags=["movies"], |
| ) |
| def get_movie( |
| movie_id: int, |
| engine: RecommendationEngine = Depends(get_engine), |
| ): |
| """Return movie metadata and similar movies.""" |
| movie = engine.get_movie(movie_id) |
| if movie is None: |
| raise HTTPException(status_code=404, detail="Movie not found") |
|
|
| similar = engine.get_similar(movie_id, limit=10) |
|
|
| return MovieDetailResponse( |
| movie_id=movie["movie_id"], |
| title=movie["title"], |
| year=movie["year"], |
| genres=movie["genres"], |
| similar_movies=similar, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @router.get("/movies/popular", tags=["movies"]) |
| def get_popular( |
| limit: int = Query(default=20, ge=1, le=100), |
| engine: RecommendationEngine = Depends(get_engine), |
| ): |
| """Return a list of popular movies (used for cold-start / homepage hero).""" |
| return engine._popular_fallback(limit) |
|
|
|
|
| |
| |
| |
|
|
| @router.get("/search", response_model=SearchResponse, tags=["search"]) |
| def search( |
| q: str = Query(..., min_length=1, max_length=200), |
| limit: int = Query(default=20, ge=1, le=50), |
| engine: RecommendationEngine = Depends(get_engine), |
| event_log: EventLogger = Depends(get_logger), |
| ): |
| """Instant search over movie titles and genres.""" |
| results = engine.search(q, limit=limit) |
| event_log.log(0, "search", metadata={"query": q}) |
|
|
| from api.schemas import Movie |
| return SearchResponse( |
| query=q, |
| results=[ |
| Movie( |
| movie_id=r["movie_id"], |
| title=r["title"], |
| year=r["year"], |
| genres=r["genres"], |
| ) |
| for r in results |
| ], |
| total=len(results), |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @router.get( |
| "/taste-profile/{user_id}", |
| response_model=TasteProfileResponse, |
| tags=["users"], |
| ) |
| def get_taste_profile( |
| user_id: int, |
| engine: RecommendationEngine = Depends(get_engine), |
| ): |
| """Return a user's genre preferences and activity stats.""" |
| profile = engine.get_taste_profile(user_id) |
| if "error" in profile: |
| raise HTTPException(status_code=404, detail=profile["error"]) |
| return TasteProfileResponse(**profile) |
|
|
|
|
| |
| |
| |
|
|
| @router.post("/feedback", response_model=FeedbackResponse, tags=["feedback"]) |
| def post_feedback( |
| body: FeedbackRequest, |
| event_log: EventLogger = Depends(get_logger), |
| ): |
| """ |
| Record user feedback (thumbs up/down, mood, explicit rating). |
| Events are forwarded to the streaming layer for model updates. |
| """ |
| event_id = event_log.log( |
| user_id=body.user_id, |
| event_type="feedback", |
| movie_id=body.movie_id, |
| metadata={ |
| "action": body.action, |
| "mood": body.mood, |
| "rating": body.rating, |
| }, |
| ) |
| return FeedbackResponse(success=True, message=f"Feedback recorded (event {event_id})") |
|
|
|
|
| |
| |
| |
|
|
| @router.post("/events", response_model=EventResponse, tags=["events"]) |
| def log_event( |
| body: EventRequest, |
| event_log: EventLogger = Depends(get_logger), |
| ): |
| """Log a generic user interaction event.""" |
| event_id = event_log.log( |
| user_id=body.user_id, |
| event_type=body.event_type, |
| movie_id=body.movie_id, |
| metadata=body.metadata, |
| ) |
| return EventResponse(event_id=event_id, success=True) |
|
|
|
|
| |
| |
| |
|
|
| @router.get("/movies/{movie_id}/similar", tags=["movies"]) |
| def get_similar( |
| movie_id: int, |
| limit: int = Query(default=10, ge=1, le=50), |
| engine: RecommendationEngine = Depends(get_engine), |
| ): |
| """Return movies similar to the given movie.""" |
| similar = engine.get_similar(movie_id, limit=limit) |
| if not similar: |
| raise HTTPException(status_code=404, detail="Movie not found or no similar movies") |
| return {"movie_id": movie_id, "similar": similar, "total": len(similar)} |
|
|