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/Http/Controllers/UserFilmController.php b/backend/app/Http/Controllers/UserFilmController.php index aaa1a6c..93297d5 100644 --- a/backend/app/Http/Controllers/UserFilmController.php +++ b/backend/app/Http/Controllers/UserFilmController.php @@ -100,7 +100,7 @@ public function removeFromWatchlist(Film $film, Request $request) public function trending() { - $films = Film::orderBy('popularity', 'desc')->limit(10)->get(); + $films = Film::orderBy('popularity', 'desc')->limit(50)->get(); return response()->json($films); } @@ -112,7 +112,7 @@ public function topRated() $films = \App\Models\Film::with('genres', 'people') ->withAvg('reviews', 'rating') // calculates average rating ->orderByDesc('reviews_avg_rating') - ->take(20) // limit to top 20 + ->take(50) // limit to top 20 ->get(); return response()->json($films); 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 9182b45..adc03d7 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -18,8 +18,11 @@ use App\Http\Controllers\UserController; use App\Http\Controllers\ForumCommentController; use App\Http\Controllers\ForumPostController; + +use App\Http\Controllers\AIRecommendationController; use App\Http\Controllers\FriendActivityController; + /* |-------------------------------------------------------------------------- | API Routes @@ -71,11 +74,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'); @@ -151,6 +155,14 @@ 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'); +}); + + Route::middleware('auth:api')->get('/watched', [App\Http\Controllers\UserFilmController::class, 'getWatched']); -Route::middleware('auth:api')->get('/friends/activities', [FriendActivityController::class, 'index']); \ No newline at end of file +Route::middleware('auth:api')->get('/friends/activities', [FriendActivityController::class, 'index']); + diff --git a/frontend/index.html b/frontend/index.html index 531d952..953ce9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - Vite + React + RedPill