Spaces:
Sleeping
Sleeping
| 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 | |
| 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} | |
| 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 | |
| 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] | |
| 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} | |
| 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 --- | |
| 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": []} | |
| 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 | |
| } |