mahmdshafee commited on
Commit
802c62c
·
verified ·
1 Parent(s): e5efd17

Upload 19 files

Browse files
app/backend/main.py ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production-Ready FastAPI Backend for DeBERTa Emotion Detection
3
+ Optimized for 710MB model with efficient tokenization and inference
4
+ """
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import JSONResponse
9
+ from pydantic import BaseModel, Field, field_validator
10
+ from contextlib import asynccontextmanager
11
+ import torch
12
+ import torch.nn as nn
13
+ from transformers import DebertaV2Model, DebertaV2Tokenizer
14
+ import uvicorn
15
+ import logging
16
+ import time
17
+ from typing import Dict, List, Optional
18
+ import gc
19
+ import psutil
20
+ import os
21
+ import json
22
+ import httpx
23
+ from dotenv import load_dotenv
24
+ import warnings
25
+
26
+ # Suppress HuggingFace deprecation warning
27
+ warnings.filterwarnings('ignore', category=FutureWarning, module='huggingface_hub')
28
+
29
+ # ==================== LOGGING SETUP ====================
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
33
+ )
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ==================== ENVIRONMENT SETUP ====================
37
+ load_dotenv() # Load from .env file
38
+ TMDB_API_KEY = os.getenv('TMDB_API_KEY', '')
39
+ TMDB_BASE_URL = "https://api.themoviedb.org/3"
40
+ TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"
41
+
42
+ # ==================== CONFIGURATION ====================
43
+ class Config:
44
+ MODEL_PATH = "./../../models/" # Path to your saved model
45
+ MAX_LENGTH = 128
46
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
47
+ BATCH_SIZE = 8 # For batch predictions
48
+ MAX_TEXT_LENGTH = 5000 # Character limit for input
49
+
50
+ EMOTION_CLASSES = ['anger', 'fear', 'joy', 'love', 'neutral', 'sadness', 'surprise']
51
+
52
+ # Emotion to Genre Mapping (4 genres per emotion)
53
+ EMOTION_GENRE_MAP = {
54
+ 'anger': ['Action', 'Crime', 'Thriller', 'Revenge-Drama'],
55
+ 'fear': ['Horror', 'Thriller', 'Mystery', 'Supernatural'],
56
+ 'joy': ['Comedy', 'Adventure', 'Family', 'Animation', 'Musical'],
57
+ 'love': ['Romance', 'Rom-Com', 'Emotional Drama', 'Fantasy'],
58
+ 'neutral': ['Documentary', 'Drama', 'Biography', 'Slice-of-Life'],
59
+ 'sadness': ['Drama', 'Romance', 'Indie', 'Healing-Stories'],
60
+ 'surprise': ['Mystery', 'Sci-Fi', 'Fantasy', 'Twist-Thriller']
61
+ }
62
+
63
+ # Performance settings
64
+ TORCH_THREADS = 4
65
+ USE_HALF_PRECISION = False # FP16 for GPU
66
+
67
+ config = Config()
68
+
69
+ # Set torch threads for CPU inference
70
+ torch.set_num_threads(config.TORCH_THREADS)
71
+
72
+ # ==================== MODEL DEFINITION ====================
73
+ class DeBERTaEmotionClassifier(nn.Module):
74
+ """DeBERTa model for emotion classification"""
75
+ def __init__(self, config_dict: Dict, num_labels: int):
76
+ super().__init__()
77
+ from transformers import DebertaV2Config
78
+ deberta_config = DebertaV2Config(**config_dict)
79
+ self.deberta = DebertaV2Model(deberta_config)
80
+ self.dropout = nn.Dropout(0.1)
81
+ self.classifier = nn.Linear(deberta_config.hidden_size, num_labels)
82
+
83
+ def forward(self, input_ids, attention_mask):
84
+ outputs = self.deberta(input_ids=input_ids, attention_mask=attention_mask)
85
+ sequence_output = outputs.last_hidden_state
86
+ cls_output = sequence_output[:, 0, :]
87
+ cls_output = self.dropout(cls_output)
88
+ logits = self.classifier(cls_output)
89
+ return logits
90
+
91
+ # ==================== MODEL MANAGER ====================
92
+ class ModelManager:
93
+ """Singleton class to manage model loading and inference"""
94
+ _instance = None
95
+
96
+ def __new__(cls):
97
+ if cls._instance is None:
98
+ cls._instance = super().__new__(cls)
99
+ cls._instance.initialized = False
100
+ return cls._instance
101
+
102
+ def __init__(self):
103
+ if not self.initialized:
104
+ self.model = None
105
+ self.tokenizer = None
106
+ self.initialized = True
107
+
108
+ def load_model(self):
109
+ """Load model and tokenizer with error handling"""
110
+ try:
111
+ logger.info(f"Loading model on device: {config.DEVICE}")
112
+ start_time = time.time()
113
+
114
+ # Load config from local file
115
+ logger.info("Loading model config...")
116
+ config_path = os.path.join(config.MODEL_PATH, "config.json")
117
+ if not os.path.exists(config_path):
118
+ raise FileNotFoundError(f"Model config not found at {config_path}")
119
+
120
+ with open(config_path, 'r') as f:
121
+ model_config = json.load(f)
122
+
123
+ # Load tokenizer from local files or fallback to HuggingFace
124
+ logger.info("Loading tokenizer...")
125
+ tokenizer_files = {
126
+ 'vocab.json': os.path.join(config.MODEL_PATH, 'vocab.json'),
127
+ 'merges.txt': os.path.join(config.MODEL_PATH, 'merges.txt')
128
+ }
129
+ if os.path.exists(tokenizer_files['vocab.json']):
130
+ logger.info("Loading tokenizer from local files...")
131
+ self.tokenizer = DebertaV2Tokenizer(vocab_file=tokenizer_files['vocab.json'])
132
+ else:
133
+ logger.info("Downloading tokenizer from HuggingFace...")
134
+ self.tokenizer = DebertaV2Tokenizer.from_pretrained("microsoft/deberta-v3-base")
135
+
136
+ # Load model architecture from config
137
+ logger.info("Loading model architecture from config...")
138
+ self.model = DeBERTaEmotionClassifier(config_dict=model_config, num_labels=len(config.EMOTION_CLASSES))
139
+
140
+ # Load trained weights: prefer full model file (safetensors) then fall back to checkpoint
141
+ safetensors_path = os.path.join(config.MODEL_PATH, "model.safetensors")
142
+ classifier_path = os.path.join(config.MODEL_PATH, "classifier.pt")
143
+
144
+ # Helper to map keys that were saved without proper prefixes
145
+ def _normalize_state_dict_keys(state_dict: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
146
+ """
147
+ Normalize state dict keys to match the model architecture:
148
+ - Add 'deberta.' prefix to encoder/embedding keys if missing
149
+ - Add 'classifier.' prefix to weight/bias keys if they're classifier weights
150
+ """
151
+ if not state_dict:
152
+ return state_dict
153
+
154
+ mapped = {}
155
+ first_key = next(iter(state_dict.keys()), None)
156
+
157
+ for k, v in state_dict.items():
158
+ # Check if this is a classifier weight/bias (2D/1D tensor from Linear layer)
159
+ # Classifier: weight [7, 768], bias [7]
160
+ if k in ['weight', 'bias']:
161
+ # Verify this is classifier-sized (num_classes=7, hidden_size=768)
162
+ if k == 'weight' and len(v.shape) == 2 and v.shape[0] == 7 and v.shape[1] == 768:
163
+ mapped['classifier.weight'] = v
164
+ elif k == 'bias' and len(v.shape) == 1 and v.shape[0] == 7:
165
+ mapped['classifier.bias'] = v
166
+ else:
167
+ mapped[k] = v
168
+ # Add deberta prefix to encoder/embedding keys if missing
169
+ elif k.startswith("deberta."):
170
+ # Already has prefix
171
+ mapped[k] = v
172
+ elif k.startswith("embeddings.") or k.startswith("encoder.") or k.startswith("rel_embeddings") or k.startswith("LayerNorm") or k.startswith("word_embeddings"):
173
+ # Encoder/embedding key without prefix
174
+ mapped[f"deberta.{k}"] = v
175
+ else:
176
+ # Keep as-is (dropout, etc.)
177
+ mapped[k] = v
178
+
179
+ return mapped
180
+
181
+ # 1) Try safetensors full-model file (fast and safe if present)
182
+ if os.path.exists(safetensors_path):
183
+ try:
184
+ logger.info("Loading full trained model from safetensors...")
185
+ try:
186
+ from safetensors.torch import load_file
187
+ except Exception as ie:
188
+ logger.error("safetensors package not available: %s", ie)
189
+ raise
190
+
191
+ state = load_file(safetensors_path)
192
+ # state is a dict of tensors; adjust keys if necessary
193
+ mapped = _normalize_state_dict_keys(state)
194
+ load_res = self.model.load_state_dict(mapped, strict=False)
195
+ logger.info("Loaded safetensors model (missing: %s, unexpected: %s)", getattr(load_res, 'missing_keys', []), getattr(load_res, 'unexpected_keys', []))
196
+ except Exception as e:
197
+ logger.error("Error loading safetensors model: %s", e, exc_info=True)
198
+ # fall through to try checkpoint
199
+
200
+ # 2) Fallback: try classic PyTorch checkpoint
201
+ if os.path.exists(classifier_path):
202
+ try:
203
+ logger.info("Loading trained checkpoint (torch) ...")
204
+ # In recent PyTorch versions the default weights_only may block some globals. Use weights_only=False
205
+ checkpoint = torch.load(classifier_path, map_location=config.DEVICE, weights_only=False)
206
+
207
+ # checkpoint might be a dict containing different keys depending on how it was saved
208
+ if isinstance(checkpoint, dict):
209
+ # Common key names used in training scripts
210
+ if 'model_state_dict' in checkpoint:
211
+ sd = checkpoint['model_state_dict']
212
+ elif 'state_dict' in checkpoint:
213
+ sd = checkpoint['state_dict']
214
+ elif 'classifier_state_dict' in checkpoint or 'dropout_state_dict' in checkpoint:
215
+ # old style: only classifier saved
216
+ if 'classifier_state_dict' in checkpoint:
217
+ try:
218
+ self.model.classifier.load_state_dict(checkpoint['classifier_state_dict'])
219
+ except Exception:
220
+ # try flexible load with key normalization
221
+ normalized = _normalize_state_dict_keys(checkpoint['classifier_state_dict'])
222
+ self.model.load_state_dict(normalized, strict=False)
223
+ if 'dropout_state_dict' in checkpoint:
224
+ try:
225
+ self.model.dropout.load_state_dict(checkpoint['dropout_state_dict'])
226
+ except Exception:
227
+ logger.warning('Could not load dropout state dict')
228
+ sd = None
229
+ else:
230
+ sd = checkpoint
231
+
232
+ if sd:
233
+ # sd may contain keys without proper prefixes
234
+ mapped = _normalize_state_dict_keys(sd)
235
+ load_res = self.model.load_state_dict(mapped, strict=False)
236
+ logger.info("Loaded checkpoint (missing: %s, unexpected: %s)", getattr(load_res, 'missing_keys', []), getattr(load_res, 'unexpected_keys', []))
237
+ else:
238
+ # Not a dict: cannot handle
239
+ logger.warning("Checkpoint loaded but is not a dict, skipping")
240
+
241
+ except Exception as e:
242
+ logger.error("Error loading checkpoint: %s", e, exc_info=True)
243
+ raise
244
+ else:
245
+ logger.warning(f"No trained model found at {safetensors_path} or {classifier_path}, using base model")
246
+
247
+ # Move to device
248
+ self.model.to(config.DEVICE)
249
+ self.model.eval()
250
+
251
+ # Apply half precision for GPU
252
+ if config.USE_HALF_PRECISION:
253
+ self.model.half()
254
+ logger.info("Applied FP16 precision")
255
+
256
+ # Optimize for inference
257
+ if config.DEVICE == "cuda":
258
+ torch.backends.cudnn.benchmark = True
259
+
260
+ load_time = time.time() - start_time
261
+ logger.info(f"Model loaded successfully in {load_time:.2f}s")
262
+ logger.info(f"Memory usage: {psutil.Process().memory_info().rss / 1024 ** 2:.2f} MB")
263
+
264
+ return True
265
+
266
+ except FileNotFoundError as e:
267
+ logger.error(f"Model files not found: {e}")
268
+ raise HTTPException(status_code=500, detail=f"Model files not found: {str(e)}")
269
+ except Exception as e:
270
+ logger.error(f"Error loading model: {e}", exc_info=True)
271
+ raise HTTPException(status_code=500, detail=f"Failed to load model: {str(e)}")
272
+
273
+ @torch.no_grad()
274
+ def predict(self, text: str) -> Dict:
275
+ """Run inference on input text"""
276
+ try:
277
+ if not self.model or not self.tokenizer:
278
+ raise ValueError("Model not loaded")
279
+
280
+ start_time = time.time()
281
+
282
+ # Tokenize
283
+ encoding = self.tokenizer(
284
+ text,
285
+ add_special_tokens=True,
286
+ max_length=config.MAX_LENGTH,
287
+ padding='max_length',
288
+ truncation=True,
289
+ return_attention_mask=True,
290
+ return_tensors='pt'
291
+ )
292
+
293
+ input_ids = encoding['input_ids'].to(config.DEVICE)
294
+ attention_mask = encoding['attention_mask'].to(config.DEVICE)
295
+
296
+ # Apply half precision if enabled
297
+ if config.USE_HALF_PRECISION:
298
+ input_ids = input_ids.half().long() # Convert back to long for embeddings
299
+
300
+ # Inference
301
+ with torch.cuda.amp.autocast(enabled=config.USE_HALF_PRECISION):
302
+ logits = self.model(input_ids, attention_mask)
303
+
304
+ # Get probabilities
305
+ probs = torch.softmax(logits, dim=-1)
306
+ confidence, predicted_class = torch.max(probs, dim=-1)
307
+
308
+ # Convert to CPU and numpy
309
+ probs_np = probs.cpu().float().numpy()[0]
310
+ predicted_idx = predicted_class.item()
311
+ confidence_score = confidence.item()
312
+
313
+ # Create emotion probability dict
314
+ emotion_probs = {
315
+ emotion: float(probs_np[i])
316
+ for i, emotion in enumerate(config.EMOTION_CLASSES)
317
+ }
318
+
319
+ inference_time = time.time() - start_time
320
+
321
+ return {
322
+ "emotion": config.EMOTION_CLASSES[predicted_idx],
323
+ "confidence": confidence_score,
324
+ "all_probabilities": emotion_probs,
325
+ "inference_time_ms": round(inference_time * 1000, 2)
326
+ }
327
+
328
+ except Exception as e:
329
+ logger.error(f"Prediction error: {e}", exc_info=True)
330
+ raise HTTPException(status_code=500, detail=f"Prediction failed: {str(e)}")
331
+
332
+ def cleanup(self):
333
+ """Cleanup resources"""
334
+ try:
335
+ if self.model:
336
+ del self.model
337
+ if self.tokenizer:
338
+ del self.tokenizer
339
+ gc.collect()
340
+ if torch.cuda.is_available():
341
+ torch.cuda.empty_cache()
342
+ logger.info("Model resources cleaned up")
343
+ except Exception as e:
344
+ logger.error(f"Cleanup error: {e}")
345
+
346
+ # ==================== LIFESPAN MANAGEMENT ====================
347
+ model_manager = ModelManager()
348
+
349
+ @asynccontextmanager
350
+ async def lifespan(app: FastAPI):
351
+ """Manage application lifecycle"""
352
+ # Startup
353
+ logger.info("Starting application...")
354
+ try:
355
+ model_manager.load_model()
356
+ logger.info("Application ready")
357
+ except Exception as e:
358
+ logger.error(f"Startup failed: {e}")
359
+ raise
360
+
361
+ yield
362
+
363
+ # Shutdown
364
+ logger.info("Shutting down application...")
365
+ model_manager.cleanup()
366
+ logger.info("Application stopped")
367
+
368
+ # ==================== FASTAPI APP ====================
369
+ app = FastAPI(
370
+ title="Emotion Detection API",
371
+ description="DeBERTa v3 based emotion detection for 7 emotion classes",
372
+ version="1.0.0",
373
+ lifespan=lifespan
374
+ )
375
+
376
+ # CORS middleware
377
+ app.add_middleware(
378
+ CORSMiddleware,
379
+ allow_origins=["*"], # Configure appropriately for production
380
+ allow_credentials=True,
381
+ allow_methods=["*"],
382
+ allow_headers=["*"],
383
+ )
384
+
385
+ # ==================== REQUEST/RESPONSE MODELS ====================
386
+ class PredictionRequest(BaseModel):
387
+ text: str = Field(..., min_length=1, max_length=config.MAX_TEXT_LENGTH)
388
+
389
+ @field_validator('text')
390
+ @classmethod
391
+ def validate_text(cls, v):
392
+ if not v or not v.strip():
393
+ raise ValueError('Text cannot be empty or whitespace only')
394
+ return v.strip()
395
+
396
+ class PredictionResponse(BaseModel):
397
+ emotion: str
398
+ confidence: float
399
+ all_probabilities: Dict[str, float]
400
+ inference_time_ms: float
401
+ text_length: int
402
+
403
+ class HealthResponse(BaseModel):
404
+ status: str
405
+ device: str
406
+ model_loaded: bool
407
+ memory_mb: float
408
+
409
+ class BatchPredictionRequest(BaseModel):
410
+ texts: List[str] = Field(..., min_length=1, max_length=10)
411
+
412
+ @field_validator('texts')
413
+ @classmethod
414
+ def validate_texts(cls, v):
415
+ cleaned = [t.strip() for t in v if t and t.strip()]
416
+ if not cleaned:
417
+ raise ValueError('At least one valid text required')
418
+ if len(cleaned) > 10:
419
+ raise ValueError('Maximum 10 texts allowed per batch')
420
+ return cleaned
421
+
422
+ # ==================== MOVIE MODELS ====================
423
+ class MovieItem(BaseModel):
424
+ id: int
425
+ title: str
426
+ poster_path: Optional[str] = None
427
+ vote_average: float
428
+ release_date: Optional[str] = None
429
+
430
+ class GenreMovies(BaseModel):
431
+ genre: str
432
+ movies: List[MovieItem]
433
+
434
+ class RecommendationResponse(BaseModel):
435
+ emotion: str
436
+ confidence: float
437
+ recommendations: List[GenreMovies]
438
+
439
+ # ==================== ENDPOINTS ====================
440
+ @app.get("/", response_model=Dict)
441
+ async def root():
442
+ """Root endpoint"""
443
+ return {
444
+ "message": "Emotion Detection API",
445
+ "version": "1.0.0",
446
+ "endpoints": {
447
+ "predict": "/predict",
448
+ "batch_predict": "/batch_predict",
449
+ "health": "/health",
450
+ "emotions": "/emotions"
451
+ }
452
+ }
453
+
454
+ @app.get("/health", response_model=HealthResponse)
455
+ async def health_check():
456
+ """Health check endpoint"""
457
+ try:
458
+ memory_mb = psutil.Process().memory_info().rss / 1024 ** 2
459
+ return HealthResponse(
460
+ status="healthy",
461
+ device=config.DEVICE,
462
+ model_loaded=model_manager.model is not None,
463
+ memory_mb=round(memory_mb, 2)
464
+ )
465
+ except Exception as e:
466
+ logger.error(f"Health check failed: {e}")
467
+ raise HTTPException(status_code=500, detail="Health check failed")
468
+
469
+ @app.get("/emotions", response_model=Dict)
470
+ async def get_emotions():
471
+ """Get list of supported emotions"""
472
+ return {
473
+ "emotions": config.EMOTION_CLASSES,
474
+ "count": len(config.EMOTION_CLASSES)
475
+ }
476
+
477
+ @app.post("/predict", response_model=PredictionResponse)
478
+ async def predict_emotion(request: PredictionRequest):
479
+ """Predict emotion for single text"""
480
+ try:
481
+ logger.info(f"Prediction request: {len(request.text)} chars")
482
+
483
+ result = model_manager.predict(request.text)
484
+
485
+ return PredictionResponse(
486
+ emotion=result["emotion"],
487
+ confidence=result["confidence"],
488
+ all_probabilities=result["all_probabilities"],
489
+ inference_time_ms=result["inference_time_ms"],
490
+ text_length=len(request.text)
491
+ )
492
+
493
+ except HTTPException:
494
+ raise
495
+ except Exception as e:
496
+ logger.error(f"Prediction endpoint error: {e}", exc_info=True)
497
+ raise HTTPException(status_code=500, detail=str(e))
498
+
499
+ @app.post("/batch_predict")
500
+ async def batch_predict_emotion(request: BatchPredictionRequest):
501
+ """Predict emotions for multiple texts"""
502
+ try:
503
+ logger.info(f"Batch prediction request: {len(request.texts)} texts")
504
+
505
+ results = []
506
+ for text in request.texts:
507
+ result = model_manager.predict(text)
508
+ results.append({
509
+ "text": text[:100] + "..." if len(text) > 100 else text,
510
+ "emotion": result["emotion"],
511
+ "confidence": result["confidence"],
512
+ "all_probabilities": result["all_probabilities"]
513
+ })
514
+
515
+ return {
516
+ "count": len(results),
517
+ "predictions": results
518
+ }
519
+
520
+ except Exception as e:
521
+ logger.error(f"Batch prediction error: {e}", exc_info=True)
522
+ raise HTTPException(status_code=500, detail=str(e))
523
+
524
+ @app.post("/recommendations", response_model=RecommendationResponse)
525
+ async def get_movie_recommendations(request: PredictionRequest):
526
+ """
527
+ Predict emotion and fetch movie recommendations based on detected emotion.
528
+ Maps emotion to genres and fetches 12 most popular movies per genre from TMDB.
529
+ """
530
+ try:
531
+ logger.info(f"Recommendation request: {len(request.text)} chars")
532
+
533
+ # 1. Detect emotion
534
+ emotion_result = model_manager.predict(request.text)
535
+ detected_emotion = emotion_result["emotion"]
536
+ confidence = emotion_result["confidence"]
537
+
538
+ logger.info(f"Detected emotion: {detected_emotion} (confidence: {confidence})")
539
+
540
+ # 2. Get mapped genres for this emotion
541
+ genres = config.EMOTION_GENRE_MAP.get(detected_emotion, [])
542
+ if not genres:
543
+ raise HTTPException(status_code=400, detail=f"No genres mapped for emotion: {detected_emotion}")
544
+
545
+ # 3. Fetch movies for these genres
546
+ movies_by_genre = await fetch_movies_by_genres(genres, limit=12)
547
+
548
+ # 4. Format response
549
+ recommendations = []
550
+ for genre in genres:
551
+ if genre in movies_by_genre:
552
+ recommendations.append(GenreMovies(
553
+ genre=genre,
554
+ movies=movies_by_genre[genre]
555
+ ))
556
+
557
+ return RecommendationResponse(
558
+ emotion=detected_emotion,
559
+ confidence=confidence,
560
+ recommendations=recommendations
561
+ )
562
+
563
+ except HTTPException:
564
+ raise
565
+ except Exception as e:
566
+ logger.error(f"Recommendation error: {e}", exc_info=True)
567
+ raise HTTPException(status_code=500, detail=f"Failed to get recommendations: {str(e)}")
568
+
569
+ # ==================== ERROR HANDLERS ====================
570
+ @app.exception_handler(Exception)
571
+ async def global_exception_handler(request: Request, exc: Exception):
572
+ """Global exception handler"""
573
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
574
+ return JSONResponse(
575
+ status_code=500,
576
+ content={
577
+ "error": "Internal server error",
578
+ "detail": str(exc),
579
+ "path": str(request.url)
580
+ }
581
+ )
582
+
583
+ # ==================== TMDB MOVIE FETCHING ====================
584
+ async def fetch_movies_by_genres(genres: List[str], limit: int = 12) -> Dict[str, List[MovieItem]]:
585
+ """
586
+ Fetch movies from TMDB API for given genres.
587
+ Returns dict with genre as key and list of MovieItem as value.
588
+ """
589
+ if not TMDB_API_KEY:
590
+ logger.error("TMDB_API_KEY not configured")
591
+ raise HTTPException(status_code=500, detail="Movie service not configured. Please set TMDB_API_KEY environment variable.")
592
+
593
+ movies_by_genre = {}
594
+
595
+ async with httpx.AsyncClient(timeout=10.0) as client:
596
+ for genre in genres:
597
+ try:
598
+ logger.info(f"Fetching movies for genre: {genre}")
599
+
600
+ # Get genre ID from genre name
601
+ genres_response = await client.get(
602
+ f"{TMDB_BASE_URL}/genre/movie/list",
603
+ params={"api_key": TMDB_API_KEY}
604
+ )
605
+ genres_response.raise_for_status()
606
+ genres_data = genres_response.json()
607
+
608
+ genre_id = None
609
+ for g in genres_data.get("genres", []):
610
+ if g["name"].lower() == genre.lower():
611
+ genre_id = g["id"]
612
+ break
613
+
614
+ if not genre_id:
615
+ logger.warning(f"Genre '{genre}' not found in TMDB")
616
+ continue
617
+
618
+ # Fetch movies for this genre, sorted by popularity (descending)
619
+ movies_response = await client.get(
620
+ f"{TMDB_BASE_URL}/discover/movie",
621
+ params={
622
+ "api_key": TMDB_API_KEY,
623
+ "with_genres": genre_id,
624
+ "sort_by": "popularity.desc",
625
+ "page": 1,
626
+ "language": "en-US"
627
+ }
628
+ )
629
+ movies_response.raise_for_status()
630
+ movies_data = movies_response.json()
631
+
632
+ # Parse movies
633
+ movie_list = []
634
+ for movie in movies_data.get("results", [])[:limit]:
635
+ movie_list.append(MovieItem(
636
+ id=movie.get("id"),
637
+ title=movie.get("title", "Unknown"),
638
+ poster_path=movie.get("poster_path"),
639
+ vote_average=movie.get("vote_average", 0.0),
640
+ release_date=movie.get("release_date", "N/A")
641
+ ))
642
+
643
+ if movie_list:
644
+ movies_by_genre[genre] = movie_list
645
+ logger.info(f"Fetched {len(movie_list)} movies for genre: {genre}")
646
+ else:
647
+ logger.warning(f"No movies found for genre: {genre}")
648
+
649
+ except httpx.HTTPError as e:
650
+ logger.error(f"HTTP error fetching movies for {genre}: {e}")
651
+ except Exception as e:
652
+ logger.error(f"Error fetching movies for {genre}: {e}", exc_info=True)
653
+
654
+ return movies_by_genre
655
+
656
+ # ==================== MAIN ====================
657
+ if __name__ == "__main__":
658
+ uvicorn.run(
659
+ "main:app",
660
+ host="0.0.0.0",
661
+ port=8000,
662
+ reload=False,
663
+ log_level="info",
664
+ access_log=True
665
+ )
app/backend/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ torch==2.2.0
4
+ transformers>=4.35.2
5
+ safetensors>=0.4.16
6
+ python-dotenv==1.0.0
7
+ httpx==0.25.1
8
+ pydantic==2.5.0
9
+ psutil==5.9.6
app/backend/runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.12
app/frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
app/frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs['recommended-latest'],
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
app/frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
app/frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
app/frontend/package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "lucide-react": "^0.553.0",
14
+ "react": "^19.2.0",
15
+ "react-dom": "^19.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@eslint/js": "^9.39.1",
19
+ "@tailwindcss/postcss": "^4.1.17",
20
+ "@types/react": "^19.2.2",
21
+ "@types/react-dom": "^19.2.2",
22
+ "@vitejs/plugin-react": "^5.1.0",
23
+ "autoprefixer": "^10.4.21",
24
+ "eslint": "^9.39.1",
25
+ "eslint-plugin-react-hooks": "^5.2.0",
26
+ "eslint-plugin-react-refresh": "^0.4.24",
27
+ "globals": "^16.5.0",
28
+ "postcss": "^8.5.6",
29
+ "tailwindcss": "^4.1.17",
30
+ "vite": "^7.2.2"
31
+ }
32
+ }
app/frontend/postcss.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ // postcss.config.js (New Way for Tailwind v4)
2
+ export default {
3
+ plugins: {
4
+ '@tailwindcss/postcss': {}, // <-- This is the correct plugin
5
+ autoprefixer: {},
6
+ },
7
+ }
app/frontend/public/vite.svg ADDED
app/frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
app/frontend/src/App.jsx ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Send, Loader2, Heart, Frown, Smile, Zap, Meh, AlertCircle, TrendingUp, Moon, Sun } from 'lucide-react';
3
+ import MovieCarousel from './components/MovieCarousel';
4
+
5
+ const EmotionDetector = () => {
6
+ const [text, setText] = useState('');
7
+ const [result, setResult] = useState(null);
8
+ const [movies, setMovies] = useState(null);
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState(null);
11
+ const [apiHealth, setApiHealth] = useState(null);
12
+ const [isDark, setIsDark] = useState(true);
13
+ const textareaRef = useRef(null);
14
+
15
+ // API endpoint - change this to your deployed backend URL
16
+ const API_URL = 'http://localhost:8000';
17
+
18
+ // Emotion configurations with colors and icons
19
+ const emotionConfig = {
20
+ joy: { color: '#FFD700', gradient: 'from-yellow-400 to-amber-500', icon: Smile, emoji: '😊' },
21
+ love: { color: '#FF69B4', gradient: 'from-pink-400 to-rose-500', icon: Heart, emoji: '❤️' },
22
+ surprise: { color: '#9370DB', gradient: 'from-purple-400 to-violet-500', icon: Zap, emoji: '😲' },
23
+ neutral: { color: '#94A3B8', gradient: 'from-slate-400 to-gray-500', icon: Meh, emoji: '😐' },
24
+ sadness: { color: '#4682B4', gradient: 'from-blue-400 to-cyan-600', icon: Frown, emoji: '😢' },
25
+ anger: { color: '#DC143C', gradient: 'from-red-500 to-orange-600', icon: AlertCircle, emoji: '😠' },
26
+ fear: { color: '#8B4513', gradient: 'from-amber-700 to-orange-800', icon: TrendingUp, emoji: '😨' }
27
+ };
28
+
29
+ // Check API health on mount
30
+ useEffect(() => {
31
+ checkHealth();
32
+ }, []);
33
+
34
+ const checkHealth = async () => {
35
+ try {
36
+ const response = await fetch(`${API_URL}/health`);
37
+ if (response.ok) {
38
+ const data = await response.json();
39
+ setApiHealth(data);
40
+ }
41
+ } catch (err) {
42
+ console.error('Health check failed:', err);
43
+ }
44
+ };
45
+
46
+ const handleSubmit = async (e) => {
47
+ e.preventDefault();
48
+
49
+ if (!text.trim()) {
50
+ setError('Please enter some text');
51
+ return;
52
+ }
53
+
54
+ if (text.length > 5000) {
55
+ setError('Text too long (max 5000 characters)');
56
+ return;
57
+ }
58
+
59
+ setLoading(true);
60
+ setError(null);
61
+ setResult(null);
62
+ setMovies(null);
63
+
64
+ try {
65
+ // Call the new /recommendations endpoint
66
+ const response = await fetch(`${API_URL}/recommendations`, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ },
71
+ body: JSON.stringify({ text: text.trim() }),
72
+ });
73
+
74
+ if (!response.ok) {
75
+ const errorData = await response.json();
76
+ throw new Error(errorData.detail || 'Prediction failed');
77
+ }
78
+
79
+ const data = await response.json();
80
+ setResult({
81
+ emotion: data.emotion,
82
+ confidence: data.confidence,
83
+ });
84
+ setMovies(data.recommendations);
85
+
86
+ } catch (err) {
87
+ setError(err.message || 'Failed to connect to server');
88
+ console.error('Prediction error:', err);
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ };
93
+
94
+ const handleTextChange = (e) => {
95
+ setText(e.target.value);
96
+ setError(null);
97
+ };
98
+
99
+ const handleClear = () => {
100
+ setText('');
101
+ setResult(null);
102
+ setMovies(null);
103
+ setError(null);
104
+ textareaRef.current?.focus();
105
+ };
106
+
107
+ const tryExample = (exampleText) => {
108
+ setText(exampleText);
109
+ setError(null);
110
+ setResult(null);
111
+ setMovies(null);
112
+ };
113
+
114
+ const exampleTexts = [
115
+ "I'm so excited about my vacation next week!",
116
+ "This situation makes me really frustrated and angry.",
117
+ "I miss my family so much, feeling lonely today.",
118
+ "You mean everything to me, I love you.",
119
+ "I'm worried about the exam results tomorrow.",
120
+ "Just another regular day at work.",
121
+ "Wow! I can't believe this just happened!"
122
+ ];
123
+
124
+ // Dark theme: Blade Runner 2049 - Neon pink/purple cyberpunk
125
+ // Light theme: Joker 2019 - Neon orange suit inspired
126
+ const bgLight = 'bg-gradient-to-br from-orange-50 via-amber-50 to-red-50';
127
+ const bgDark = 'bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950';
128
+
129
+ const headerLight = 'bg-gradient-to-r from-orange-500 via-red-500 to-orange-600 border-orange-400';
130
+ const headerDark = 'bg-gradient-to-r from-fuchsia-900 via-purple-900 to-violet-900 border-fuchsia-600';
131
+
132
+ const cardLight = 'bg-white border-orange-400 shadow-lg';
133
+ const cardDark = 'bg-slate-900/80 border-fuchsia-500 shadow-2xl shadow-fuchsia-500/20';
134
+
135
+ const sortedEmotions = result
136
+ ? Object.entries(result.all_probabilities || {}).sort((a, b) => b[1] - a[1])
137
+ : [];
138
+
139
+ return (
140
+ <div className={`min-h-screen transition-colors duration-300 ${isDark ? bgDark : bgLight}`}>
141
+ {/* Header */}
142
+ <div className={`border-b backdrop-blur-sm shadow-md ${isDark ? headerDark : headerLight}`}>
143
+ <div className="max-w-6xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
144
+ <div className="flex items-center justify-between">
145
+ <div className="flex items-center space-x-3">
146
+ <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-fuchsia-500 to-purple-600' : 'bg-gradient-to-br from-orange-500 to-red-600'}`}>
147
+ <Heart className="w-6 h-6 text-white" />
148
+ </div>
149
+ <div>
150
+ <h1 className={`text-2xl font-bold ${isDark ? 'text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-400 to-cyan-400' : 'text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-red-600'}`}>
151
+ MoodFlix
152
+ </h1>
153
+ <p className={`text-xs ${isDark ? 'text-fuchsia-300' : 'text-orange-700'}`}>Emotion-based Movie Recommendations</p>
154
+ </div>
155
+ </div>
156
+
157
+ <div className="flex items-center space-x-4">
158
+ {apiHealth && (
159
+ <div className={`hidden sm:flex items-center space-x-2 px-3 py-1 rounded-full border ${isDark ? 'bg-cyan-950/50 border-cyan-600' : 'bg-orange-50 border-orange-400'}`}>
160
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
161
+ <span className={`text-xs font-medium ${isDark ? 'text-cyan-400' : 'text-orange-700'}`}>
162
+ API Online
163
+ </span>
164
+ </div>
165
+ )}
166
+
167
+ {/* Theme Toggle */}
168
+ <button
169
+ onClick={() => setIsDark(!isDark)}
170
+ className={`p-2 rounded-lg transition-all ${isDark ? 'bg-fuchsia-900/50 hover:bg-fuchsia-800 text-cyan-300 border border-fuchsia-600' : 'bg-orange-300 hover:bg-orange-400 text-gray-900 border border-orange-500'}`}
171
+ >
172
+ {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
173
+ </button>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ {/* Main Content */}
180
+ <div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8 sm:py-8">
181
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
182
+
183
+ {/* Left Column - Input */}
184
+ <div className="lg:col-span-2 space-y-6">
185
+ {/* Input Card */}
186
+ <div className={`rounded-2xl border-2 overflow-hidden ${isDark ? cardDark : cardLight}`}>
187
+ <div className="p-6">
188
+ <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
189
+ How are you feeling?
190
+ </h2>
191
+
192
+ <form onSubmit={handleSubmit} className="space-y-4">
193
+ <div className="relative">
194
+ <textarea
195
+ ref={textareaRef}
196
+ value={text}
197
+ onChange={handleTextChange}
198
+ placeholder="Share your thoughts or feelings... (e.g., 'I'm feeling amazing today!')"
199
+ className={`w-full h-32 sm:h-40 px-4 py-3 border-2 rounded-xl focus:ring-4 transition-all resize-none ${
200
+ isDark
201
+ ? 'bg-slate-800 border-fuchsia-500 focus:border-cyan-400 focus:ring-fuchsia-500/20 text-white placeholder-slate-500'
202
+ : 'bg-white border-orange-400 focus:border-red-500 focus:ring-orange-200 text-gray-900 placeholder-gray-400'
203
+ }`}
204
+ disabled={loading}
205
+ />
206
+ <div className={`absolute bottom-3 right-3 text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
207
+ {text.length} / 5000
208
+ </div>
209
+ </div>
210
+
211
+ {error && (
212
+ <div className={`flex items-center space-x-2 p-3 border rounded-lg ${isDark ? 'bg-red-950/50 border-red-600' : 'bg-red-50 border-red-300'}`}>
213
+ <AlertCircle className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-red-400' : 'text-red-600'}`} />
214
+ <p className={`text-sm ${isDark ? 'text-red-300' : 'text-red-700'}`}>{error}</p>
215
+ </div>
216
+ )}
217
+
218
+ <div className="flex flex-col sm:flex-row gap-3">
219
+ <button
220
+ type="submit"
221
+ disabled={loading || !text.trim()}
222
+ className={`flex-1 flex items-center justify-center space-x-2 px-6 py-3 rounded-xl font-medium shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none ${
223
+ isDark
224
+ ? 'bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white hover:from-fuchsia-700 hover:to-purple-700 border border-fuchsia-500'
225
+ : 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:from-orange-600 hover:to-red-600 border border-orange-400'
226
+ }`}
227
+ >
228
+ {loading ? (
229
+ <>
230
+ <Loader2 className="w-5 h-5 animate-spin" />
231
+ <span>Analyzing & Finding Movies...</span>
232
+ </>
233
+ ) : (
234
+ <>
235
+ <Send className="w-5 h-5" />
236
+ <span>Detect & Suggest</span>
237
+ </>
238
+ )}
239
+ </button>
240
+
241
+ <button
242
+ type="button"
243
+ onClick={handleClear}
244
+ disabled={loading}
245
+ className={`px-6 py-3 rounded-xl font-medium transition-colors disabled:opacity-50 ${
246
+ isDark
247
+ ? 'bg-slate-800 text-slate-200 hover:bg-slate-700 border border-fuchsia-500'
248
+ : 'bg-orange-200 text-gray-800 hover:bg-orange-300 border border-orange-400'
249
+ }`}
250
+ >
251
+ Clear
252
+ </button>
253
+ </div>
254
+ </form>
255
+ </div>
256
+ </div>
257
+
258
+ {/* Results Card */}
259
+ {result && (
260
+ <div className={`rounded-2xl border-2 overflow-hidden animate-fadeIn ${isDark ? cardDark : cardLight}`}>
261
+ <div className="p-6 space-y-6">
262
+ {/* Primary Emotion */}
263
+ <div>
264
+ <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
265
+ Your Emotion
266
+ </h2>
267
+ <div className={`relative p-6 rounded-xl bg-gradient-to-r ${emotionConfig[result.emotion].gradient} overflow-hidden`}>
268
+ <div className="absolute top-0 right-0 text-8xl opacity-10">
269
+ {emotionConfig[result.emotion].emoji}
270
+ </div>
271
+ <div className="relative">
272
+ <div className="flex items-center space-x-3 mb-2">
273
+ {React.createElement(emotionConfig[result.emotion].icon, {
274
+ className: "w-8 h-8 text-white"
275
+ })}
276
+ <h3 className="text-3xl font-bold text-white capitalize">
277
+ {result.emotion}
278
+ </h3>
279
+ </div>
280
+ <p className="text-white/90 text-lg">
281
+ Confidence: {(result.confidence * 100).toFixed(1)}%
282
+ </p>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ {/* Movies Carousels */}
291
+ {movies && movies.length > 0 && (
292
+ <div className="space-y-4 animate-fadeIn">
293
+ <h2 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
294
+ Movies For You
295
+ </h2>
296
+ {movies.map((genreMovies, idx) => (
297
+ <MovieCarousel
298
+ key={idx}
299
+ genre={genreMovies.genre}
300
+ movies={genreMovies.movies}
301
+ isDark={isDark}
302
+ />
303
+ ))}
304
+ </div>
305
+ )}
306
+ </div>
307
+
308
+ {/* Right Column - Examples & Info */}
309
+ <div className="space-y-6">
310
+ {/* Try Examples */}
311
+ <div className={`rounded-2xl border-2 p-6 overflow-hidden ${isDark ? cardDark : cardLight}`}>
312
+ <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
313
+ Try Examples
314
+ </h2>
315
+ <div className="space-y-2">
316
+ {exampleTexts.map((example, idx) => (
317
+ <button
318
+ key={idx}
319
+ onClick={() => tryExample(example)}
320
+ disabled={loading}
321
+ className={`w-full text-left px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 border ${
322
+ isDark
323
+ ? 'text-slate-300 hover:bg-fuchsia-900/30 hover:text-fuchsia-200 border-fuchsia-600'
324
+ : 'text-gray-700 hover:bg-orange-100 hover:text-orange-700 border-orange-300'
325
+ }`}
326
+ >
327
+ "{example.length > 60 ? example.slice(0, 60) + '...' : example}"
328
+ </button>
329
+ ))}
330
+ </div>
331
+ </div>
332
+
333
+ {/* Emotion Legend */}
334
+ <div className={`rounded-2xl border-2 p-6 overflow-hidden ${isDark ? cardDark : cardLight}`}>
335
+ <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
336
+ Emotions We Detect
337
+ </h2>
338
+ <div className="space-y-3">
339
+ {Object.entries(emotionConfig).map(([emotion, config]) => (
340
+ <div key={emotion} className="flex items-center space-x-3">
341
+ <div className={`w-10 h-10 rounded-lg bg-gradient-to-r ${config.gradient} flex items-center justify-center text-xl`}>
342
+ {config.emoji}
343
+ </div>
344
+ <div>
345
+ <p className={`font-medium capitalize ${isDark ? 'text-white' : 'text-gray-900'}`}>
346
+ {emotion}
347
+ </p>
348
+ </div>
349
+ </div>
350
+ ))}
351
+ </div>
352
+ </div>
353
+
354
+ {/* Info Card */}
355
+ <div className={`rounded-2xl border-2 p-6 text-white overflow-hidden ${isDark ? 'bg-gradient-to-br from-fuchsia-900 to-purple-900 border-fuchsia-600' : 'bg-gradient-to-br from-orange-500 to-red-600 border-orange-400'}`}>
356
+ <h3 className="font-semibold mb-2">About MoodFlix</h3>
357
+ <p className="text-sm text-white/90">
358
+ AI-powered emotion detection meets cinema. Share your feelings and discover movies that match your mood. Powered by DeBERTa v3 and TMDB.
359
+ </p>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+
365
+ <style jsx>{`
366
+ @keyframes fadeIn {
367
+ from {
368
+ opacity: 0;
369
+ transform: translateY(10px);
370
+ }
371
+ to {
372
+ opacity: 1;
373
+ transform: translateY(0);
374
+ }
375
+ }
376
+
377
+ .animate-fadeIn {
378
+ animation: fadeIn 0.3s ease-out;
379
+ }
380
+ `}</style>
381
+ </div>
382
+ );
383
+ };
384
+
385
+ export default EmotionDetector;
app/frontend/src/assets/react.svg ADDED
app/frontend/src/components/MovieCard.jsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Star, Calendar } from 'lucide-react';
3
+
4
+ const MovieCard = ({ movie, isDark = false }) => {
5
+ const posterUrl = movie.poster_path
6
+ ? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
7
+ : 'https://via.placeholder.com/300x450?text=No+Image';
8
+
9
+ const releaseYear = movie.release_date
10
+ ? new Date(movie.release_date).getFullYear()
11
+ : 'N/A';
12
+
13
+ return (
14
+ <div
15
+ className={`flex-shrink-0 w-40 rounded-lg overflow-hidden shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-2xl cursor-pointer ${
16
+ isDark
17
+ ? 'bg-gray-800 border border-purple-600'
18
+ : 'bg-white border border-yellow-300'
19
+ }`}
20
+ >
21
+ {/* Poster Image */}
22
+ <div className="relative overflow-hidden h-64 bg-gradient-to-br from-gray-300 to-gray-400">
23
+ <img
24
+ src={posterUrl}
25
+ alt={movie.title}
26
+ className="w-full h-full object-cover"
27
+ loading="lazy"
28
+ />
29
+ {/* Rating Badge */}
30
+ <div
31
+ className={`absolute top-2 right-2 flex items-center space-x-1 px-2 py-1 rounded-full backdrop-blur-sm ${
32
+ isDark
33
+ ? 'bg-purple-600/90'
34
+ : 'bg-yellow-400/90'
35
+ }`}
36
+ >
37
+ <Star className="w-3 h-3 fill-current" />
38
+ <span className={`text-xs font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
39
+ {movie.vote_average.toFixed(1)}
40
+ </span>
41
+ </div>
42
+ </div>
43
+
44
+ {/* Card Content */}
45
+ <div className={`p-3 ${isDark ? 'bg-gray-900' : 'bg-white'}`}>
46
+ {/* Title */}
47
+ <h3
48
+ className={`text-sm font-bold line-clamp-2 mb-2 ${
49
+ isDark ? 'text-white' : 'text-gray-900'
50
+ }`}
51
+ title={movie.title}
52
+ >
53
+ {movie.title}
54
+ </h3>
55
+
56
+ {/* Release Year */}
57
+ <div className="flex items-center space-x-1">
58
+ <Calendar className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-yellow-600'}`} />
59
+ <span
60
+ className={`text-xs ${
61
+ isDark ? 'text-gray-400' : 'text-gray-600'
62
+ }`}
63
+ >
64
+ {releaseYear}
65
+ </span>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ export default MovieCard;
app/frontend/src/components/MovieCarousel.jsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+ import MovieCard from './MovieCard';
4
+
5
+ const MovieCarousel = ({ genre, movies, isDark = false }) => {
6
+ const scrollContainerRef = useRef(null);
7
+
8
+ const scroll = (direction) => {
9
+ if (scrollContainerRef.current) {
10
+ const scrollAmount = 400;
11
+ if (direction === 'left') {
12
+ scrollContainerRef.current.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
13
+ } else {
14
+ scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
15
+ }
16
+ }
17
+ };
18
+
19
+ return (
20
+ <div
21
+ className={`rounded-xl overflow-hidden ${
22
+ isDark
23
+ ? 'bg-gradient-to-r from-gray-900 via-purple-900 to-gray-900 border border-purple-700'
24
+ : 'bg-gradient-to-r from-yellow-50 via-green-50 to-yellow-50 border border-green-300'
25
+ }`}
26
+ >
27
+ {/* Genre Header */}
28
+ <div className={`px-6 py-4 flex items-center justify-between ${isDark ? 'bg-gray-800/50' : 'bg-green-100/50'}`}>
29
+ <h2
30
+ className={`text-2xl font-bold capitalize ${
31
+ isDark
32
+ ? 'text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400'
33
+ : 'text-transparent bg-clip-text bg-gradient-to-r from-green-700 to-yellow-700'
34
+ }`}
35
+ >
36
+ {genre}
37
+ </h2>
38
+ <span
39
+ className={`text-sm font-semibold px-3 py-1 rounded-full ${
40
+ isDark ? 'bg-purple-600 text-white' : 'bg-yellow-300 text-gray-900'
41
+ }`}
42
+ >
43
+ {movies.length} movies
44
+ </span>
45
+ </div>
46
+
47
+ {/* Carousel Container */}
48
+ <div className="relative group">
49
+ {/* Left Arrow */}
50
+ <button
51
+ onClick={() => scroll('left')}
52
+ className={`absolute left-2 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full opacity-0 group-hover:opacity-100 transform transition-all duration-300 ${
53
+ isDark
54
+ ? 'bg-purple-600 hover:bg-purple-700 text-white'
55
+ : 'bg-yellow-400 hover:bg-yellow-500 text-gray-900'
56
+ }`}
57
+ >
58
+ <ChevronLeft className="w-5 h-5" />
59
+ </button>
60
+
61
+ {/* Scrollable Container */}
62
+ <div
63
+ ref={scrollContainerRef}
64
+ className="flex gap-4 overflow-x-auto scroll-smooth px-6 py-4 scrollbar-hide"
65
+ style={{ scrollBehavior: 'smooth', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
66
+ >
67
+ {movies.map((movie) => (
68
+ <MovieCard key={movie.id} movie={movie} isDark={isDark} />
69
+ ))}
70
+ </div>
71
+
72
+ {/* Right Arrow */}
73
+ <button
74
+ onClick={() => scroll('right')}
75
+ className={`absolute right-2 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full opacity-0 group-hover:opacity-100 transform transition-all duration-300 ${
76
+ isDark
77
+ ? 'bg-purple-600 hover:bg-purple-700 text-white'
78
+ : 'bg-yellow-400 hover:bg-yellow-500 text-gray-900'
79
+ }`}
80
+ >
81
+ <ChevronRight className="w-5 h-5" />
82
+ </button>
83
+ </div>
84
+
85
+ {/* Custom Scrollbar Hide */}
86
+ <style jsx>{`
87
+ div::-webkit-scrollbar {
88
+ display: none;
89
+ }
90
+ `}</style>
91
+ </div>
92
+ );
93
+ };
94
+
95
+ export default MovieCarousel;
app/frontend/src/index.css ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /* src/index.css (New Way for Tailwind v4) */
2
+ @import "tailwindcss";
app/frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
app/frontend/tailwind.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: [
4
+ "./src/**/*.{js,jsx,ts,tsx}",
5
+ "./public/index.html"
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
app/frontend/vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 3000,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:8000',
11
+ changeOrigin: true,
12
+ rewrite: (path) => path.replace(/^\/api/, '')
13
+ }
14
+ }
15
+ }
16
+ })