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 }