IntimateUser6969
Deploy CineMatch backend: Two-Tower + DeepFM + MMR + Upstash Redis
1359487
Raw
History Blame Contribute Delete
8.09 kB
"""
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()
# ------------------------------------------------------------------
# Dependency injection helpers (populated at startup in main.py)
# ------------------------------------------------------------------
_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
# ------------------------------------------------------------------
# Health
# ------------------------------------------------------------------
@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,
)
# ------------------------------------------------------------------
# Recommendations
# ------------------------------------------------------------------
@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),
)
# ------------------------------------------------------------------
# Movie detail
# ------------------------------------------------------------------
@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,
)
# ------------------------------------------------------------------
# Popular / trending
# ------------------------------------------------------------------
@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)
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
@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),
)
# ------------------------------------------------------------------
# Taste profile
# ------------------------------------------------------------------
@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)
# ------------------------------------------------------------------
# Feedback
# ------------------------------------------------------------------
@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})")
# ------------------------------------------------------------------
# Event logging (generic)
# ------------------------------------------------------------------
@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)
# ------------------------------------------------------------------
# Similar movies (standalone endpoint)
# ------------------------------------------------------------------
@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)}