resonate-api / app /api /routes.py
sandy898's picture
Initial backend deployment
d597de7
Raw
History Blame Contribute Delete
10.8 kB
import json
import unicodedata
import re
import pandas as pd # <--- ADDED THIS IMPORT
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
from app.core.ml_manager import ml_manager
from app.services.recommender import RecommenderService
from app.services.explainer import RecommendationExplainer
router = APIRouter()
embedder = None
def get_embedder():
global embedder
if embedder is None:
print("Loading mixedbread-ai embedding model for semantic search...")
embedder = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1")
print("Embedding model ready!")
return embedder
def safe_str(val) -> str:
if isinstance(val, (list, np.ndarray)):
return str(val)
if pd.isna(val):
return ""
return str(val).strip()
def normalize_text(text: str) -> str:
if not isinstance(text, str): return ""
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('utf-8')
text = re.sub(r'[^a-zA-Z0-9\s]', '', text)
text = re.sub(r'\s+', ' ', text)
return text.lower().strip()
class Interaction(BaseModel):
itemIndex: int
rating: float
timestamp: int
class RecommendRequest(BaseModel):
interactions: List[Interaction]
top_k: int = 15
@router.post("/recommend")
def get_recommendations(req: RecommendRequest):
interactions_list = [{"itemIndex": i.itemIndex, "rating": i.rating, "timestamp": i.timestamp} for i in req.interactions]
# Extract raw history for the explainer
history_indices = [i.itemIndex for i in req.interactions]
history_ratings = [i.rating for i in req.interactions]
recommendations = RecommenderService.get_recommendations(
interactions=interactions_list,
top_k=req.top_k
)
results = []
for rec in recommendations:
idx = rec["item_index"]
try:
meta = ml_manager.movie_meta.loc[idx]
badges = rec.get("badges", ["Recommended"])
# --- Generate the Explanation on the fly! ---
reason = RecommendationExplainer.generate_reason(
target_index=int(idx),
history_indices=history_indices,
history_ratings=history_ratings,
sources=badges
)
results.append({
"item_index": int(idx),
"tmdb_id": str(meta["tmdb_id"]),
"title": str(meta["title"]),
"poster_path": safe_str(meta.get("poster_path", "")),
"sources": badges,
"reason": reason # <--- Added to payload
})
except KeyError:
continue
return {"status": "success", "recommendations": results}
@router.get("/trending")
def get_trending():
# scalar index 7 is popularity (as seen in your cold-start logic)
top_pop_idx = np.argsort(ml_manager.scalars[:, 7])[-10:][::-1]
results = []
for idx in top_pop_idx:
try:
meta = ml_manager.movie_meta.loc[idx]
# Ensure we only grab movies that actually have a backdrop image
if pd.isna(meta.get("backdrop_path")) or not meta.get("backdrop_path"):
continue
results.append({
"item_index": int(idx),
"tmdb_id": str(meta["tmdb_id"]),
"title": str(meta["title"]),
"backdrop_path": safe_str(meta.get("backdrop_path", "")),
"sources": ["Trending This Week"]
})
except KeyError:
continue
return {"status": "success", "trending": results[:8]} # Return top 8
class SemanticSearchRequest(BaseModel):
query: str
top_k: int = 15
@router.post("/semantic-search")
def semantic_search(req: SemanticSearchRequest):
query_norm = normalize_text(req.query)
normalized_titles = ml_manager.movie_meta['title'].apply(normalize_text)
mask = normalized_titles.str.contains(query_norm, regex=False, na=False)
if not mask.any():
query_squashed = query_norm.replace(" ", "")
mask = normalized_titles.str.replace(" ", "").str.contains(query_squashed, regex=False, na=False)
title_matches_df = ml_manager.movie_meta[mask].copy()
title_matches_df['title_len'] = title_matches_df['title'].str.len()
title_matches_df = title_matches_df.sort_values('title_len').head(10)
title_results = []
for idx, row in title_matches_df.iterrows():
title_results.append({
"item_index": int(idx), "tmdb_id": str(row["tmdb_id"]),
"title": str(row["title"]), "poster_path": safe_str(row.get("poster_path", "")), "sources": ["Exact Match"]
})
raw_embedding = get_embedder().encode(req.query, convert_to_numpy=True).astype(np.float32)
query_vector = raw_embedding.reshape(1, -1)
faiss.normalize_L2(query_vector)
distances, indices = ml_manager.faiss_index.search(query_vector, req.top_k)
semantic_results = []
title_match_indices = set(title_matches_df.index)
for rank, idx in enumerate(indices[0]):
if idx in title_match_indices: continue
try:
meta = ml_manager.movie_meta.loc[idx]
semantic_results.append({
"item_index": int(idx), "tmdb_id": str(meta["tmdb_id"]),
"title": str(meta["title"]), "poster_path": safe_str(meta.get("poster_path", "")), "sources": ["Semantic"]
})
except KeyError:
continue
return {"status": "success", "title_matches": title_results, "semantic_matches": semantic_results}
class MetadataRequest(BaseModel):
item_indices: List[int]
@router.post("/metadata")
def get_batch_metadata(req: MetadataRequest):
results = []
for idx in req.item_indices:
try:
meta = ml_manager.movie_meta.loc[idx]
results.append({
"item_index": int(idx),
"tmdb_id": str(meta["tmdb_id"]),
"title": str(meta["title"]),
"poster_path": safe_str(meta.get("poster_path", ""))
})
except KeyError:
continue
return {"metadata": results}
@router.get("/movie/{item_index}")
def get_movie_details(item_index: int):
try:
meta = ml_manager.movie_meta.loc[item_index]
except KeyError:
raise HTTPException(status_code=404, detail="Movie not found")
tmdb_id = meta["tmdb_id"]
clean_details = {
"title": meta.get("title", "Unknown Title"),
"overview": meta.get("overview", "Plot overview not available."),
"release_date": str(meta.get("release_date", "")),
"runtime": int(meta.get("runtime", 0)) if not pd.isna(meta.get("runtime", 0)) else 0,
"genres": meta.get("genres", []) if isinstance(meta.get("genres", []), list) else [],
"backdrop_path": safe_str(meta.get("backdrop_path", "")),
"poster_path": safe_str(meta.get("poster_path", ""))
}
ease_row = np.array(ml_manager.ease_matrix[item_index])
ease_row[item_index] = -np.inf
ease_results = []
if np.max(ease_row) > 0.015:
top_similar_idx = np.argsort(ease_row)[-10:][::-1]
for idx in top_similar_idx:
try:
smeta = ml_manager.movie_meta.loc[idx]
ease_results.append({
"item_index": int(idx), "tmdb_id": str(smeta["tmdb_id"]),
"title": str(smeta["title"]), "poster_path": safe_str(smeta.get("poster_path", "")), "sources": ["Collaborative"]
})
except KeyError: continue
item_vector = np.zeros((1, ml_manager.d_semantic), dtype=np.float32)
ml_manager.faiss_index.reconstruct(item_index, item_vector[0])
distances, semantic_idx = ml_manager.faiss_index.search(item_vector, 11)
semantic_results = []
for idx in semantic_idx[0]:
if idx == item_index: continue
try:
smeta = ml_manager.movie_meta.loc[idx]
semantic_results.append({
"item_index": int(idx), "tmdb_id": str(smeta["tmdb_id"]),
"title": str(smeta["title"]), "poster_path": safe_str(smeta.get("poster_path", "")), "sources": ["Semantic"]
})
except KeyError: continue
return {
"status": "success", "details": clean_details,
"people_also_watch": ease_results, "similar_theme": semantic_results[:10]
}
# --- BROWSE ENDPOINTS ---
@router.get("/browse/options/{category}")
def get_browse_options(category: str):
"""Returns the list of available keys for a category (e.g., all genres)"""
if category not in ml_manager.browse_data:
raise HTTPException(status_code=404, detail="Category not found")
data = ml_manager.browse_data[category]
if isinstance(data, dict):
# Return sorted keys (e.g., ["Action", "Comedy", ...])
return {"options": sorted(list(data.keys()))}
return {"options": []}
@router.get("/browse/movies")
def get_browse_movies(category: str, key: Optional[str] = None, page: int = 1, limit: int = 30):
"""Returns movies for a specific category and optional key."""
if category not in ml_manager.browse_data:
raise HTTPException(status_code=404, detail="Category not found")
data = ml_manager.browse_data[category]
if isinstance(data, dict):
if not key or key not in data:
raise HTTPException(status_code=404, detail="Key not found in category")
item_indices = data[key]
else:
# For direct arrays like 'anime', 'family', 'awards'
item_indices = data
# Pagination
start = (page - 1) * limit
end = start + limit
paged_indices = item_indices[start:end]
results = []
for idx in paged_indices:
try:
meta = ml_manager.movie_meta.loc[idx]
results.append({
"item_index": int(idx),
"tmdb_id": str(meta["tmdb_id"]),
"title": str(meta["title"]),
"poster_path": safe_str(meta.get("poster_path", "")),
"sources": [category.capitalize()]
})
except KeyError:
continue
return {
"status": "success",
"total_results": len(item_indices),
"movies": results
}