From f4d5d4da89333f7cd30f4ffacd738c191826aac9 Mon Sep 17 00:00:00 2001 From: Shovon Date: Sat, 20 Sep 2025 02:17:30 +0600 Subject: [PATCH 01/13] Add AI-powered movie recommendation feature Introduces AIRecommendationController and AIRecommendationService in the backend to provide AI-driven movie recommendations, mood analysis, and user insights using OpenRouter. Adds new API routes for AI recommendations, updates .env.example for AI config, and implements a frontend integration (api/ai.js) and a new AIRecommendations page for users to request personalized movie suggestions in Bengali or English. --- backend/.env.example | 4 + .../AIRecommendationController.php | 296 ++++++++++ .../app/Services/AIRecommendationService.php | 362 +++++++++++++ backend/routes/api.php | 9 +- frontend/src/api/ai.js | 222 ++++++++ frontend/src/pages/AIRecommendations.jsx | 511 ++++++------------ 6 files changed, 1060 insertions(+), 344 deletions(-) create mode 100644 backend/app/Http/Controllers/AIRecommendationController.php create mode 100644 backend/app/Services/AIRecommendationService.php create mode 100644 frontend/src/api/ai.js diff --git a/backend/.env.example b/backend/.env.example index d7bebac..4057ced 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -35,3 +35,7 @@ AWS_DEFAULT_REGION=us-east-1 # ---------------- Other ---------------- LOG_CHANNEL=stack LOG_LEVEL=debug + +# ---------------- AI/OpenRouter ---------------- +OPENROUTER_API_KEY=your_openrouter_api_key_here +AI_MODEL=anthropic/claude-3-haiku diff --git a/backend/app/Http/Controllers/AIRecommendationController.php b/backend/app/Http/Controllers/AIRecommendationController.php new file mode 100644 index 0000000..82604d7 --- /dev/null +++ b/backend/app/Http/Controllers/AIRecommendationController.php @@ -0,0 +1,296 @@ +user(); + + if (!$user) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + // Validate request + $validator = Validator::make($request->all(), [ + 'mood' => 'nullable|string|max:500', + 'genres' => 'nullable|array', + 'genres.*' => 'string', + 'min_rating' => 'nullable|numeric|min:0|max:10', + 'year_range' => 'nullable|array|size:2', + 'year_range.*' => 'integer|min:1900|max:2030', + 'runtime_preference' => 'nullable|string' + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors() + ], 422); + } + + try { + // Extract preferences from request + $preferences = [ + 'mood' => $request->input('mood', ''), + 'genres' => $request->input('genres', []), + 'min_rating' => $request->input('min_rating', 0), + 'year_range' => $request->input('year_range', [2000, 2024]), + 'runtime_preference' => $request->input('runtime_preference', 'Any Length') + ]; + + // Generate AI recommendations + $result = AIRecommendationService::getAIRecommendations($user, $preferences); + + return response()->json([ + 'success' => true, + 'data' => $result, + 'message' => 'AI recommendations generated successfully' + ]); + + } catch (\Exception $e) { + \Log::error('AI Recommendation Controller Error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Failed to generate recommendations. Please try again.', + 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error' + ], 500); + } + } + + /** + * Analyze user mood from text input + */ + public function analyzeMood(Request $request) + { + $user = $request->user(); + + // Validate request + $validator = Validator::make($request->all(), [ + 'mood_text' => 'required|string|max:1000' + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors() + ], 422); + } + + try { + $moodText = $request->input('mood_text'); + + // Analyze mood using AI + $moodAnalysis = AIRecommendationService::analyzeMood($moodText, $user); + + return response()->json([ + 'success' => true, + 'data' => $moodAnalysis, + 'message' => 'Mood analysis completed successfully' + ]); + + } catch (\Exception $e) { + \Log::error('Mood Analysis Error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Failed to analyze mood. Please try again.', + 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error' + ], 500); + } + } + + /** + * Get user insights and preference patterns + */ + public function getUserInsights(Request $request) + { + $user = $request->user(); + + if (!$user) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + try { + // Generate user profile insights + $userProfile = $this->generateUserProfileData($user); + + return response()->json([ + 'success' => true, + 'data' => $userProfile, + 'message' => 'User insights retrieved successfully' + ]); + + } catch (\Exception $e) { + \Log::error('User Insights Error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Failed to retrieve user insights', + 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error' + ], 500); + } + } + + /** + * Quick AI recommendation based on minimal input + */ + public function quickRecommendation(Request $request) + { + $user = $request->user(); + + if (!$user) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + try { + // Use default preferences for quick recommendation + $preferences = [ + 'mood' => $request->input('mood', ''), + 'genres' => [], + 'min_rating' => 7.0, + 'year_range' => [2010, 2024], + 'runtime_preference' => 'Any Length' + ]; + + $result = AIRecommendationService::getAIRecommendations($user, $preferences); + + // Return only top 5 for quick recommendation + $quickResult = [ + 'recommendations' => $result['recommendations']->take(5), + 'confidence_score' => $result['confidence_score'] + ]; + + return response()->json([ + 'success' => true, + 'data' => $quickResult, + 'message' => 'Quick recommendations generated' + ]); + + } catch (\Exception $e) { + \Log::error('Quick Recommendation Error: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'Failed to generate quick recommendations', + 'error' => config('app.debug') ? $e->getMessage() : 'Internal server error' + ], 500); + } + } + + /** + * Generate user profile data for insights + */ + private function generateUserProfileData($user) + { + // Get user's movie statistics + $watchedCount = $user->films()->wherePivot('watched', true)->count(); + $watchlistCount = $user->films()->wherePivot('watchlist', true)->count(); + $reviewsCount = $user->reviews()->count(); + + // Get favorite genres + $favoriteGenres = $user->films() + ->wherePivot('watched', true) + ->with('genres') + ->get() + ->flatMap->genres + ->groupBy('name') + ->sortByDesc(function($genres) { return $genres->count(); }) + ->take(5) + ->keys() + ->toArray(); + + // Calculate average rating given + $averageRating = $user->reviews()->avg('rating') ?? 0; + + // Recent activity + $recentWatchedCount = $user->films() + ->wherePivot('watched', true) + ->wherePivot('updated_at', '>=', now()->subDays(30)) + ->count(); + + return [ + 'statistics' => [ + 'movies_watched' => $watchedCount, + 'watchlist_items' => $watchlistCount, + 'reviews_written' => $reviewsCount, + 'average_rating_given' => round($averageRating, 1), + 'recent_activity' => $recentWatchedCount + ], + 'preferences' => [ + 'favorite_genres' => $favoriteGenres, + 'most_active_period' => $this->getMostActivePeriod($user), + 'preference_strength' => $this->calculatePreferenceStrength($favoriteGenres, $watchedCount) + ], + 'insights' => [ + 'profile_completeness' => $this->calculateProfileCompleteness($user), + 'recommendation_confidence' => $this->calculateRecommendationConfidence($watchedCount, $reviewsCount), + 'discovery_potential' => $this->calculateDiscoveryPotential($favoriteGenres) + ] + ]; + } + + private function getMostActivePeriod($user) + { + // This is a simplified implementation + // You could analyze timestamps to find actual peak activity periods + return 'Evening'; // Default response + } + + private function calculatePreferenceStrength($favoriteGenres, $watchedCount) + { + if ($watchedCount == 0) return 'Unknown'; + if (count($favoriteGenres) <= 2) return 'Strong'; + if (count($favoriteGenres) <= 4) return 'Moderate'; + return 'Diverse'; + } + + private function calculateProfileCompleteness($user) + { + $score = 0; + + // Basic profile data + if ($user->name) $score += 20; + if ($user->email) $score += 10; + + // Activity data + $watchedCount = $user->films()->wherePivot('watched', true)->count(); + $reviewsCount = $user->reviews()->count(); + + if ($watchedCount > 0) $score += 30; + if ($watchedCount > 10) $score += 20; + if ($reviewsCount > 0) $score += 20; + + return min(100, $score); + } + + private function calculateRecommendationConfidence($watchedCount, $reviewsCount) + { + $confidence = 50; // Base confidence + + if ($watchedCount > 10) $confidence += 20; + if ($watchedCount > 50) $confidence += 15; + if ($reviewsCount > 5) $confidence += 15; + + return min(100, $confidence); + } + + private function calculateDiscoveryPotential($favoriteGenres) + { + // More diverse preferences = higher discovery potential + $genreCount = count($favoriteGenres); + + if ($genreCount >= 5) return 'High'; + if ($genreCount >= 3) return 'Medium'; + return 'Low'; + } +} \ No newline at end of file diff --git a/backend/app/Services/AIRecommendationService.php b/backend/app/Services/AIRecommendationService.php new file mode 100644 index 0000000..46d95c2 --- /dev/null +++ b/backend/app/Services/AIRecommendationService.php @@ -0,0 +1,362 @@ + $enhancedRecommendations->take(10), + 'user_insights' => self::generateUserInsights($userProfile), + 'confidence_score' => self::calculateConfidenceScore($userProfile) + ]; + + } catch (\Exception $e) { + Log::error('AI Recommendation failed: ' . $e->getMessage()); + + // Fallback to existing recommendation system + $fallbackRecommendations = RecommendationService::getRecommendations($user, 10); + + return [ + 'recommendations' => $fallbackRecommendations, + 'user_insights' => ['note' => 'Using traditional recommendations'], + 'confidence_score' => 75 + ]; + } + } + + /** + * Analyze user mood from text input + */ + public static function analyzeMood(string $moodText, User $user = null) + { + try { + $prompt = self::buildMoodAnalysisPrompt($moodText); + $aiResponse = self::callOpenRouter($prompt); + + return self::parseMoodAnalysis($aiResponse); + + } catch (\Exception $e) { + Log::error('Mood analysis failed: ' . $e->getMessage()); + + // Fallback mood analysis + return self::fallbackMoodAnalysis($moodText); + } + } + + /** + * Generate user profile from watching history and preferences + */ + private static function generateUserProfile(User $user) + { + // Get user's watched films with reviews + $watchedFilms = $user->films() + ->wherePivot('watched', true) + ->with(['genres', 'reviews' => function($q) use ($user) { + $q->where('user_id', $user->id); + }]) + ->get(); + + // Get user's watchlist + $watchlistFilms = $user->films() + ->wherePivot('watchlist', true) + ->with(['genres']) + ->get(); + + // Analyze preferences + $favoriteGenres = self::extractFavoriteGenres($watchedFilms); + $averageRating = self::calculateAverageRating($watchedFilms); + $recentActivity = self::getRecentActivity($user); + + return [ + 'total_watched' => $watchedFilms->count(), + 'total_watchlist' => $watchlistFilms->count(), + 'favorite_genres' => $favoriteGenres, + 'average_rating' => $averageRating, + 'recent_activity' => $recentActivity, + 'user_id' => $user->id, + 'join_date' => $user->created_at->format('Y-m-d') + ]; + } + + /** + * Build AI prompt for movie recommendations + */ + private static function buildAIPrompt(array $userProfile, array $preferences, $baseRecommendations) + { + $moviesData = $baseRecommendations->map(function($film) { + return [ + 'id' => $film->id, + 'title' => $film->title, + 'year' => $film->release_date ? date('Y', strtotime($film->release_date)) : 'Unknown', + 'rating' => $film->vote_average, + 'genres' => $film->genres->pluck('name')->toArray(), + 'overview' => substr($film->overview, 0, 200) . '...' + ]; + })->toArray(); + + $currentMood = $preferences['mood'] ?? ''; + $selectedGenres = $preferences['genres'] ?? []; + + $prompt = "You are a movie recommendation AI for RedPill, a movie platform. + +USER PROFILE: +- Watched {$userProfile['total_watched']} movies +- Average rating given: {$userProfile['average_rating']}/5 +- Favorite genres: " . implode(', ', $userProfile['favorite_genres']) . " +- Recent activity: {$userProfile['recent_activity']} + +CURRENT REQUEST: +- Current mood: \"{$currentMood}\" +- Preferred genres: " . implode(', ', $selectedGenres) . " + +AVAILABLE MOVIES (already filtered by user's taste): +" . json_encode($moviesData, JSON_PRETTY_PRINT) . " + +TASK: +1. Select the TOP 10 most suitable movies from the available list +2. Rank them by relevance to the user's current mood and preferences +3. Provide a personalized explanation (2-3 sentences) for each recommendation +4. Include a match confidence percentage (1-100) + +RESPONSE FORMAT (JSON): +{ + \"recommendations\": [ + { + \"film_id\": 123, + \"explanation\": \"Perfect for your current mood because...\", + \"match_percentage\": 95, + \"key_reasons\": [\"reason1\", \"reason2\"] + } + ], + \"overall_mood_match\": \"This selection perfectly captures your current emotional state\" +} + +Please respond in JSON format only."; + + return $prompt; + } + + /** + * Build mood analysis prompt + */ + private static function buildMoodAnalysisPrompt(string $moodText) + { + return "Analyze this mood description and extract movie preferences. Support both Bengali and English: + +MOOD TEXT: \"{$moodText}\" + +Extract: +1. Emotional state (happy, sad, stressed, excited, etc.) +2. Preferred movie genres based on this mood +3. Preferred movie characteristics (runtime, intensity, themes) +4. Energy level (high energy action vs calm drama) + +RESPONSE FORMAT (JSON): +{ + \"emotional_state\": \"stressed\", + \"recommended_genres\": [\"Comedy\", \"Romance\"], + \"avoid_genres\": [\"Horror\", \"Thriller\"], + \"characteristics\": { + \"runtime\": \"90-120 minutes\", + \"intensity\": \"light\", + \"themes\": \"uplifting\" + }, + \"confidence\": 85 +} + +Respond in JSON format only."; + } + + /** + * Call OpenRouter API + */ + private static function callOpenRouter(string $prompt) + { + $apiKey = env('OPENROUTER_API_KEY'); + + if (!$apiKey) { + throw new \Exception('OpenRouter API key not configured'); + } + + $client = new Client([ + 'timeout' => 30, + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => env('APP_URL', 'http://localhost'), + 'X-Title' => 'RedPill Movie Recommendations' + ] + ]); + + $response = $client->post('https://openrouter.ai/api/v1/chat/completions', [ + 'json' => [ + 'model' => env('AI_MODEL', 'anthropic/claude-3-haiku'), + 'messages' => [ + [ + 'role' => 'user', + 'content' => $prompt + ] + ], + 'max_tokens' => 2000, + 'temperature' => 0.7 + ] + ]); + + $responseData = json_decode($response->getBody(), true); + + if (!isset($responseData['choices'][0]['message']['content'])) { + throw new \Exception('Invalid AI response format'); + } + + return $responseData['choices'][0]['message']['content']; + } + + /** + * Enhance recommendations with AI insights + */ + private static function enhanceWithAI($baseRecommendations, string $aiResponse) + { + try { + $aiData = json_decode($aiResponse, true); + + if (!isset($aiData['recommendations'])) { + return $baseRecommendations; // Return original if parsing fails + } + + // Create a map of AI recommendations by film_id + $aiRecommendationsMap = collect($aiData['recommendations']) + ->keyBy('film_id'); + + // Enhance base recommendations with AI insights + return $baseRecommendations->map(function($film) use ($aiRecommendationsMap) { + if ($aiRecommendationsMap->has($film->id)) { + $aiData = $aiRecommendationsMap[$film->id]; + $film->ai_explanation = $aiData['explanation'] ?? ''; + $film->ai_match_percentage = $aiData['match_percentage'] ?? 80; + $film->ai_key_reasons = $aiData['key_reasons'] ?? []; + } else { + $film->ai_explanation = 'Selected based on your viewing history and preferences'; + $film->ai_match_percentage = 75; + $film->ai_key_reasons = ['Matches your taste']; + } + + return $film; + })->sortByDesc('ai_match_percentage'); + + } catch (\Exception $e) { + Log::error('AI enhancement failed: ' . $e->getMessage()); + return $baseRecommendations; + } + } + + /** + * Helper methods + */ + private static function extractFavoriteGenres($watchedFilms) + { + return $watchedFilms->flatMap->genres + ->groupBy('name') + ->sortByDesc(function($genres) { return $genres->count(); }) + ->take(5) + ->keys() + ->toArray(); + } + + private static function calculateAverageRating($watchedFilms) + { + $ratings = $watchedFilms->flatMap->reviews->pluck('rating')->filter(); + return $ratings->count() > 0 ? round($ratings->average(), 1) : 0; + } + + private static function getRecentActivity($user) + { + $recentWatched = $user->films() + ->wherePivot('watched', true) + ->wherePivot('updated_at', '>=', now()->subDays(30)) + ->count(); + + return "Watched {$recentWatched} movies in the last 30 days"; + } + + private static function generateUserInsights($userProfile) + { + $insights = []; + + if ($userProfile['total_watched'] > 100) { + $insights[] = 'You are a dedicated movie enthusiast'; + } + + if (count($userProfile['favorite_genres']) > 0) { + $topGenre = $userProfile['favorite_genres'][0]; + $insights[] = "You have a strong preference for {$topGenre} films"; + } + + if ($userProfile['average_rating'] > 4) { + $insights[] = 'You tend to watch high-quality films'; + } + + return $insights; + } + + private static function calculateConfidenceScore($userProfile) + { + $score = 60; // Base score + + if ($userProfile['total_watched'] > 50) $score += 20; + if ($userProfile['total_watched'] > 100) $score += 10; + if (count($userProfile['favorite_genres']) >= 3) $score += 10; + + return min(100, $score); + } + + private static function parseMoodAnalysis($aiResponse) + { + try { + return json_decode($aiResponse, true); + } catch (\Exception $e) { + return self::fallbackMoodAnalysis(''); + } + } + + private static function fallbackMoodAnalysis($moodText) + { + return [ + 'emotional_state' => 'neutral', + 'recommended_genres' => ['Drama', 'Comedy'], + 'avoid_genres' => [], + 'characteristics' => [ + 'runtime' => '90-120 minutes', + 'intensity' => 'medium', + 'themes' => 'balanced' + ], + 'confidence' => 50 + ]; + } +} \ No newline at end of file diff --git a/backend/routes/api.php b/backend/routes/api.php index 8b5612e..990f51b 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -19,6 +19,7 @@ use App\Http\Controllers\UserController; use App\Http\Controllers\ForumCommentController; use App\Http\Controllers\ForumPostController; +use App\Http\Controllers\AIRecommendationController; /* |-------------------------------------------------------------------------- @@ -59,11 +60,12 @@ // Film routes Route::get('/films/trending', [UserFilmController::class, 'trending']); Route::get('/films/top-rated', [UserFilmController::class, 'topRated'])->name('films.topRated'); -Route::get('/films/{film}', [UserFilmController::class, 'show'])->name('films.show'); // Search must be before /films/{film} if using GET params Route::get('/films/search', [UserFilmController::class, 'search'])->name('films.search'); +Route::get('/films/{film}', [UserFilmController::class, 'show'])->name('films.show'); + // Recommended films (auth) Route::middleware('auth:api')->get('/recommended', [RecommendationController::class, 'recommended'])->name('films.recommended'); @@ -139,3 +141,8 @@ Route::get('/search', [\App\Http\Controllers\SearchController::class, 'search'])->name('search'); Route::get('/search/history', [\App\Http\Controllers\SearchController::class, 'history'])->name('search.history'); +// AI Recommendation routes (authenticated users only) +Route::middleware('auth:api')->group(function () { + Route::post('/ai-recommendations', [AIRecommendationController::class, 'generate'])->name('ai.recommendations'); +}); + diff --git a/frontend/src/api/ai.js b/frontend/src/api/ai.js new file mode 100644 index 0000000..750c598 --- /dev/null +++ b/frontend/src/api/ai.js @@ -0,0 +1,222 @@ +import api from './api'; + +/** + * AI Recommendation API calls + */ + +// OpenRouter configuration (using same pattern as MovieDetails.jsx) +const OPENROUTER_API_KEY = import.meta.env.VITE_OPENROUTER_API_KEY; +const AI_MODEL = "deepseek/deepseek-chat-v3.1"; // Same model as movie summarizer + +// Direct OpenRouter API call (frontend approach) +const callOpenRouterAPI = async (prompt, systemMessage = "You are a helpful movie recommendation AI.") => { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${OPENROUTER_API_KEY}`, + }, + body: JSON.stringify({ + model: AI_MODEL, + messages: [ + { role: "system", content: systemMessage }, + { role: "user", content: prompt } + ], + max_tokens: 2000, + temperature: 0.8, // Increased for more variety + top_p: 0.9, // Added for more randomness + headers: { + "HTTP-Referer": window.location.origin, + "X-Title": "RedPill Movie Platform" + } + }) + }); + + if (!response.ok) { + throw new Error(`OpenRouter API failed: ${response.status}`); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content; +}; + +// Generate AI-powered movie recommendations (simplified) +export const generateAIRecommendations = async (preferences = {}) => { + try { + // Get different movies based on the prompt content + let moviesResponse; + const userPrompt = preferences.prompt?.toLowerCase() || ''; + + // Try to get relevant movies based on prompt keywords + if (userPrompt.includes('horror') || userPrompt.includes('bhoy') || userPrompt.includes('scary')) { + moviesResponse = await api.get('/films/search?genre=15&sort_by=popularity&order=desc'); // Horror genre ID + } else if (userPrompt.includes('comedy') || userPrompt.includes('funny') || userPrompt.includes('hasi') || userPrompt.includes('mojার')) { + moviesResponse = await api.get('/films/search?genre=10&sort_by=popularity&order=desc'); // Comedy genre ID + } else if (userPrompt.includes('action') || userPrompt.includes('fight') || userPrompt.includes('যুদ্ধ')) { + moviesResponse = await api.get('/films/search?genre=8&sort_by=popularity&order=desc'); // Action genre ID + } else if (userPrompt.includes('romance') || userPrompt.includes('love') || userPrompt.includes('romantic') || userPrompt.includes('প্রেম') || userPrompt.includes('ভালোবাসা')) { + moviesResponse = await api.get('/films/search?genre=11&sort_by=popularity&order=desc'); // Romance genre ID + } else if (userPrompt.includes('drama') || userPrompt.includes('serious') || userPrompt.includes('emotional')) { + moviesResponse = await api.get('/films/search?genre=1&sort_by=popularity&order=desc'); // Drama genre ID + } else if (userPrompt.includes('animated') || userPrompt.includes('cartoon') || userPrompt.includes('animation')) { + moviesResponse = await api.get('/films/search?genre=5&sort_by=popularity&order=desc'); // Animation genre ID + } else { + // For general requests, randomly choose between trending, top-rated, or mix + const randomChoice = Math.floor(Math.random() * 3); + if (randomChoice === 0) { + moviesResponse = await api.get('/films/trending'); + } else if (randomChoice === 1) { + moviesResponse = await api.get('/films/top-rated'); + } else { + moviesResponse = await api.get('/recommended'); + } + } + + // Extract movies from different response formats + let movies = moviesResponse.data?.results || moviesResponse.data || []; + + if (movies.length === 0) { + // Fallback to trending if no specific movies found + const fallbackResponse = await api.get('/films/trending'); + movies = fallbackResponse.data || []; + } + + // Shuffle the movies array to add more randomness + const shuffledMovies = [...movies].sort(() => Math.random() - 0.5); + + // Build improved AI prompt with mood analysis + const prompt = `USER REQUEST: "${preferences.prompt || 'good movies'}" + +INSTRUCTIONS FOR AI: +1. First analyze the user's emotional state and mood from their text +2. If they express sadness, depression, or bad mood - recommend uplifting, feel-good, or comfort movies +3. If they want specific genres, prioritize those strictly +4. If they're vague, recommend based on their emotional needs +5. Always explain how the movie will help their current mood +6. Generate DIFFERENT recommendations each time, even for similar prompts +7. IMPORTANT: Pick different movies from the list below, avoid repetition + +Available movies from database: +${shuffledMovies.slice(0, 40).map(m => { + const genres = m.genres?.map(g => g.name).join(', ') || 'Unknown'; + return `- ID: ${m.id} | TITLE: ${m.title} | YEAR: ${m.release_date ? new Date(m.release_date).getFullYear() : 'Unknown'} | GENRES: ${genres} | RATING: ${m.vote_average || 'N/A'} | PLOT: ${m.overview?.substring(0, 120) || 'No description'}`; +}).join('\n')} + +MOOD-BASED RECOMMENDATIONS: +- If user seems sad/depressed: Recommend Comedy, Feel-good, Uplifting movies +- If user wants distraction: Recommend Action, Adventure, Thriller +- If user wants emotional release: Recommend Drama, but with hopeful endings +- If user is bored: Recommend entertaining, engaging movies +- If user mentions specific genre: Follow that exactly + +IMPORTANT RANDOMIZATION RULES: +- NEVER recommend the same movies for similar prompts +- Vary your selections each time, even if the prompt is identical +- Consider different moods, sub-genres, and perspectives +- Mix popular and lesser-known films from the available list +- Generate fresh explanations and insights every time + +CURRENT REQUEST ID: ${Date.now()}-${Math.random()} + +TASK: +1. Analyze user's emotional state from their Bengali/English text +2. Select TOP 5 DIFFERENT movies that will help improve their mood or match their request +3. For each movie, provide comprehensive information including: + - Why it matches their request/mood + - Key plot details and themes + - Famous quotes from the movie + - Main cast and director information + - Interesting fun facts about the movie +4. Use empathetic language in explanations +5. Ensure variety - avoid repeating same movies from previous requests + +Return ONLY valid JSON: +{ + "recommendations": [ + { + "film_id": 123, + "explanation": "This [genre] movie will help because [specific mood benefit]. It's perfect when you're feeling [emotion] as it offers [what it provides]", + "match_percentage": 95, + "details": "Detailed plot summary, themes, and what makes this movie special. Include runtime, director, and key story elements.", + "quotes": ["Famous quote 1 from the movie", "Memorable dialogue 2", "Iconic line 3"], + "cast_info": "Director: [Name]. Stars: [Actor 1] as [Character], [Actor 2] as [Character]. Notable performances and what they're known for.", + "fun_facts": ["Interesting trivia about filming", "Awards or achievements", "Behind-the-scenes facts", "Cultural impact or legacy"] + } + ] +}`; + + // Call OpenRouter with comprehensive movie expert prompt + const aiResponse = await callOpenRouterAPI(prompt, + "You are a comprehensive movie expert and emotional counselor. Not only do you recommend movies based on emotions, but you also provide detailed information about each film including plot details, famous quotes, cast information, director details, and interesting trivia. You have extensive knowledge of cinema history, memorable dialogues, behind-the-scenes facts, and cultural impact of movies. Always provide rich, detailed responses that give users complete information about recommended films." + ); + + // Parse and enhance recommendations + const enhancedRecommendations = parseAIRecommendations(aiResponse, movies); + // Only show movies that have a real AI explanation (not the generic fallback) + const filtered = enhancedRecommendations.filter( + m => m.ai_explanation && !m.ai_explanation.startsWith('Recommended based on your request') + ); + return { + data: { + success: true, + data: { + recommendations: filtered.length > 0 ? filtered : [] + } + } + }; + + } catch (error) { + console.error('AI recommendation failed:', error); + // Fallback to backend only + return api.post('/ai-recommendations', preferences); + } +}; + +/** + * Helper functions for simple AI processing + */ + +const parseAIRecommendations = (aiResponse, originalMovies) => { + try { + const cleanResponse = aiResponse.replace(/```json|```/g, '').trim(); + const aiData = JSON.parse(cleanResponse); + + if (!aiData.recommendations) return []; + // Create map of AI recommendations + const aiMap = new Map(); + aiData.recommendations.forEach(rec => { + aiMap.set(rec.film_id, rec); + }); + // Only return movies that have real AI data + return originalMovies + .map(movie => { + const aiData = aiMap.get(movie.id); + if (aiData) { + return { + ...movie, + ai_explanation: aiData.explanation, + ai_match_percentage: aiData.match_percentage, + ai_details: aiData.details, + ai_quotes: aiData.quotes, + ai_cast_info: aiData.cast_info, + ai_fun_facts: aiData.fun_facts + }; + } + return null; + }) + .filter(Boolean) + .slice(0, 10); + + } catch (error) { + console.error('Failed to parse AI response:', error); + return originalMovies.slice(0, 10); + } +}; + +/** + * Fallback function when AI fails + */ +export const getFallbackRecommendations = () => { + // Use existing recommendation endpoint as fallback + return api.get('/recommended'); +}; \ No newline at end of file diff --git a/frontend/src/pages/AIRecommendations.jsx b/frontend/src/pages/AIRecommendations.jsx index cdf912d..2c50ef1 100644 --- a/frontend/src/pages/AIRecommendations.jsx +++ b/frontend/src/pages/AIRecommendations.jsx @@ -1,365 +1,190 @@ -import { useMemo, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { useState } from "react"; +import { motion } from "framer-motion"; +import { generateAIRecommendations } from "../api/ai"; +import { isLoggedIn } from "../utils/auth"; -// ----------------------------- -// Hardcoded seed data -// ----------------------------- -const GENRES = [ - "Action","Adventure","Animation","Comedy","Crime","Documentary","Drama","Family", - "Fantasy","Horror","Music","Mystery","Romance","Sci-Fi","Thriller","War","Western","Biography" -]; -const MOODS = ["Uplifting","Relaxing","Nostalgic","Mysterious","Intense","Thoughtful","Adventurous","Romantic"]; - -const RECS = [ - { - id: 1, - title: "The Grand Budapest Hotel", - year: 2014, - rating: 8.1, - match: 96, - tags: ["Visual Storytelling", "Comedy", "Wes Anderson"], - blurb: "Matches your love for visually stunning cinematography and witty dialogue", - poster: "/images/no-image-300x450.png", - }, - { - id: 2, - title: "Arrival", - year: 2016, - rating: 7.9, - match: 94, - tags: ["Intelligent Sci-Fi", "Emotional", "Linguistics"], - blurb: "Perfect blend of sci-fi and emotional depth, similar to 'Her'", - poster: "/images/no-image-300x450.png", - }, - { - id: 3, - title: "Inside Llewyn Davis", - year: 2013, - rating: 7.4, - match: 91, - tags: ["Character Study", "Music", "Coen Brothers"], - blurb: "Character-driven narrative with beautiful cinematography", - poster: "/images/no-image-300x450.png", - }, - { - id: 4, - title: "Moonlight", - year: 2016, - rating: 7.4, - match: 89, - tags: ["Coming of Age", "Drama", "LGBTQ+"], - blurb: "Intimate storytelling and stunning visual poetry", - poster: "/images/no-image-300x450.png", - }, -]; - -// ----------------------------- -// Small UI helpers -// ----------------------------- -const Tab = ({ active, children, onClick, icon }) => ( - -); - -const Chip = ({ children }) => ( - - {children} - -); - -// Circular match ring (pure CSS) -function MatchRing({ value = 90 }) { - const clamped = Math.max(0, Math.min(100, value)); - const deg = (clamped / 100) * 360; - return ( -
-
-
- {value}% -
-
- ); -} - -// ----------------------------- -// Page -// ----------------------------- export default function AIRecommendations() { - const [tab, setTab] = useState("for-you"); - const [checkedGenres, setCheckedGenres] = useState(["Drama", "Sci-Fi", "Crime", "Mystery"]); - const [checkedMoods, setCheckedMoods] = useState(["Nostalgic", "Thoughtful"]); - const [minRating, setMinRating] = useState(7); - const [years, setYears] = useState([2000, 2024]); - const [runtime, setRuntime] = useState("Any Length"); - - const filtered = useMemo(() => { - // simple filter by rating; you can also filter by genres/moods later - return RECS.filter((r) => r.rating >= minRating); - }, [minRating]); + const [prompt, setPrompt] = useState(""); + const [movies, setMovies] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleGetRecommendations = async () => { + if (!isLoggedIn()) { + setError("Please login to get recommendations"); + return; + } + + if (!prompt.trim()) { + setError("Please enter a prompt"); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await generateAIRecommendations({ prompt: prompt.trim() }); + + if (response.data?.success) { + setMovies(response.data.data.recommendations || []); + } else { + setError('Failed to get recommendations'); + } + } catch (error) { + console.error('AI recommendation failed:', error); + setError('Failed to get recommendations. Please try again.'); + } finally { + setLoading(false); + } + }; return (
- {/* Decorative gradient header */} -
-
-
-
+
+
+

AI Movie Recommendations

+

+ Tell me what you want to watch and I'll find the perfect movies for you +

-
-
-
- - - -
-
-

AI Movie Recommendations

-

- Tell us what you’re in the mood for—we’ll tailor the perfect list for you. -

-
-
- - {/* Tabs */} -
- setTab("for-you")} - icon={} - > - For You - - setTab("trending")} - icon={} +
+ + @@ -61,7 +61,7 @@ const PostForm = ({ post, onSubmit, onCancel }) => { onClick={onCancel} className={`px-4 py-2 rounded-lg font-semibold transition ${theme === 'dark' - ? 'bg-gray-700 hover:bg-red-600 text-gray-200' + ? 'bg-white/10 hover:bg-red-600 text-gray-200' : 'bg-gray-100 hover:bg-red-500 text-gray-700' }`} > @@ -72,7 +72,7 @@ const PostForm = ({ post, onSubmit, onCancel }) => { type="submit" className={`px-4 py-2 rounded-lg font-bold transition ${theme === 'dark' - ? 'bg-gray-600 hover:bg-red-600 text-white' + ? 'bg-white/10 hover:bg-red-600 text-white' : 'bg-gray-300 hover:bg-red-500 text-gray-900' }`} > diff --git a/frontend/src/pages/AIRecommendations.jsx b/frontend/src/pages/AIRecommendations.jsx index 2c50ef1..24cd7a5 100644 --- a/frontend/src/pages/AIRecommendations.jsx +++ b/frontend/src/pages/AIRecommendations.jsx @@ -43,31 +43,31 @@ export default function AIRecommendations() {
-

AI Movie Recommendations

+

🎬 Samantha - Your Movie Recommender

Tell me what you want to watch and I'll find the perfect movies for you

-
-