Skip to content
English
On this page

Sistema de Recomendación de Películas con Amazon Personalize

Descripción del Escenario

Desarrollaremos un sistema de recomendación de películas que:

  1. Utilizará la base de datos de películas de Kaggle (MovieLens)
  2. Recolectará información del usuario:
    • Fecha de nacimiento (para determinar edad)
    • Profesión
    • Tres géneros favoritos
  3. Utilizará Amazon Personalize para generar recomendaciones personalizadas
  4. Almacenará datos en Amazon DynamoDB
  5. Proporcionará una API REST y una interfaz web
  6. Mantendrá un historial de recomendaciones
  7. Implementará feedback de usuarios

Estructura del Proyecto

movie-recommender/
├── backend/
│   ├── app/
│   │   ├── api/
│   │   │   ├── routes/
│   │   │   └── endpoints/
│   │   ├── core/
│   │   │   ├── config.py
│   │   │   └── personalize.py
│   │   ├── models/
│   │   │   ├── user.py
│   │   │   └── movie.py
│   │   ├── services/
│   │   │   ├── recommendation.py
│   │   │   └── data_import.py
│   │   └── utils/
│   ├── data/
│   │   └── movies/
│   └── tests/

├── frontend/
│   ├── src/
│   │   ├── components/
│   │   ├── views/
│   │   ├── store/
│   │   └── services/
│   └── tests/

├── infrastructure/
│   ├── terraform/
│   └── scripts/

└── notebooks/
    └── data_preparation/

Etapas del Ejercicio

Etapa 1: Preparación de Datos y ETL

  • Descargar y procesar dataset de MovieLens
  • Limpiar y transformar datos
  • Crear esquemas para Personalize
  • Preparar datos de entrenamiento
  • Implementar pipeline ETL

Etapa 2: Configuración de Amazon Personalize

  • Crear dataset group
  • Definir esquemas de interacción
  • Configurar solución de recomendación
  • Ajustar recetas y parámetros
  • Implementar monitoreo

Etapa 3: Desarrollo Backend Base

  • Implementar API FastAPI
  • Configurar DynamoDB
  • Implementar modelos de datos
  • Crear servicios base
  • Configurar autenticación

Etapa 4: Integración con Personalize

  • Implementar cliente Personalize
  • Crear servicios de recomendación
  • Manejar filtros y reglas
  • Implementar caché
  • Optimizar requests

Etapa 5: Desarrollo Frontend

  • Crear formulario de perfil
  • Implementar selección de géneros
  • Desarrollar vista de recomendaciones
  • Añadir feedback de usuario
  • Implementar historial

Etapa 6: Lógica de Recomendación

  • Implementar filtros por edad
  • Crear reglas por profesión
  • Ajustar por géneros favoritos
  • Implementar ponderación
  • Desarrollar explicabilidad

Etapa 7: Backend Avanzado

  • Implementar historial de recomendaciones
  • Crear sistema de feedback
  • Añadir métricas y analytics
  • Implementar reentrenamiento
  • Optimizar performance

Etapa 8: Testing y Deployment

  • Implementar tests unitarios
  • Crear tests de integración
  • Configurar CI/CD
  • Implementar monitoreo
  • Documentar APIs

Base de Datos y Esquemas

Esquema de Películas (DynamoDB)

python
movie_table = {
    'movie_id': 'string',  # Partition key
    'title': 'string',
    'genres': 'set<string>',
    'year': 'number',
    'director': 'string',
    'cast': 'list<string>',
    'rating': 'number',
    'popularity': 'number',
    'language': 'string',
    'plot': 'string',
    'metadata': 'map'
}

Esquema de Usuarios (DynamoDB)

python
user_table = {
    'user_id': 'string',  # Partition key
    'birth_date': 'string',
    'profession': 'string',
    'favorite_genres': 'list<string>',
    'created_at': 'string',
    'last_active': 'string',
    'preferences': 'map',
    'interaction_count': 'number'
}

Esquema de Interacciones (Personalize)

json
{
  "type": "record",
  "name": "Interactions",
  "namespace": "com.movie.recommender",
  "fields": [
    {
      "name": "USER_ID",
      "type": "string"
    },
    {
      "name": "ITEM_ID",
      "type": "string"
    },
    {
      "name": "TIMESTAMP",
      "type": "long"
    },
    {
      "name": "EVENT_TYPE",
      "type": "string"
    },
    {
      "name": "EVENT_VALUE",
      "type": "float"
    }
  ]
}

Consideraciones Técnicas

1. Preprocesamiento de Datos

  • Limpieza de valores nulos
  • Normalización de géneros
  • Estandarización de fechas
  • Validación de datos
  • Enriquecimiento de metadata

2. Modelo de Recomendación

  • User-Personalization recipe
  • Similar-Items recipe
  • Contextual bandits
  • A/B testing
  • Reentrenamiento periódico

3. Reglas de Negocio

  • Filtros por edad
  • Afinidad por profesión
  • Preferencias de género
  • Popularidad y tendencias
  • Diversidad de contenido

4. Optimizaciones

  • Caché de recomendaciones
  • Batch predictions
  • Pre-cálculo de similitudes
  • Rate limiting
  • Monitoreo de latencia

Requerimientos Funcionales

  1. Entrada de Usuario

    • Fecha de nacimiento (validación y cálculo de edad)
    • Profesión (lista predefinida de opciones)
    • Géneros favoritos (selección múltiple)
  2. Recomendaciones

    • 10 películas personalizadas
    • Explicación de cada recomendación
    • Opciones de feedback
    • Historial accesible
  3. Interacción

    • Like/Dislike de recomendaciones
    • Guardar para ver después
    • Marcar como vista
    • Compartir recomendaciones
  4. Analytics

    • Tasa de aceptación
    • Precisión de recomendaciones
    • Diversidad de contenido
    • Engagement de usuarios

Flujo de Datos

  1. Recolección de preferencias de usuario
  2. Generación de perfil inicial
  3. Consulta a Personalize
  4. Aplicación de reglas de negocio
  5. Ordenamiento y filtrado
  6. Presentación de resultados
  7. Recolección de feedback
  8. Actualización de modelos

Próximos Pasos

  1. Comenzar con el ETL de datos MovieLens
  2. Configurar ambiente AWS
  3. Implementar esquemas base
  4. Desarrollar primeros endpoints

Etapa 1: Preparación de Datos y ETL, enfocándonos en el procesamiento del dataset MovieLens y su preparación para Amazon Personalize.

python
# backend/app/services/data_import.py
import pandas as pd
import numpy as np
from datetime import datetime
import boto3
import logging
from typing import List, Dict, Any, Tuple
from pathlib import Path

class MovieDataProcessor:
    def __init__(self, data_path: str):
        self.data_path = Path(data_path)
        self.s3_client = boto3.client('s3')
        self.logger = logging.getLogger(__name__)

    async def process_movie_data(self) -> Tuple[pd.DataFrame, Dict[str, Any]]:
        try:
            # Cargar datasets
            movies_df = pd.read_csv(self.data_path / 'movies.csv')
            ratings_df = pd.read_csv(self.data_path / 'ratings.csv')
            
            # Procesar y limpiar datos
            movies_clean = self._clean_movie_data(movies_df)
            ratings_clean = self._clean_ratings_data(ratings_df)
            
            # Generar metadata adicional
            movies_enriched = self._enrich_movie_data(movies_clean)
            
            # Generar estadísticas
            stats = self._generate_statistics(movies_enriched, ratings_clean)
            
            return movies_enriched, stats
            
        except Exception as e:
            self.logger.error(f"Error processing movie data: {str(e)}")
            raise

    def _clean_movie_data(self, df: pd.DataFrame) -> pd.DataFrame:
        # Eliminar duplicados
        df = df.drop_duplicates(subset=['movieId'])
        
        # Extraer año del título
        df['year'] = df['title'].str.extract(r'\((\d{4})\)').astype(float)
        df['title'] = df['title'].str.replace(r'\s*\(\d{4}\)', '')
        
        # Separar géneros
        df['genres'] = df['genres'].str.split('|')
        
        # Limpiar géneros vacíos
        df['genres'] = df['genres'].apply(
            lambda x: [g.strip() for g in x if g != '(no genres listed)']
        )
        
        # Filtrar películas sin información
        df = df[df['genres'].str.len() > 0]
        
        return df

    def _clean_ratings_data(self, df: pd.DataFrame) -> pd.DataFrame:
        # Convertir timestamp a datetime
        df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s')
        
        # Eliminar ratings inválidos
        df = df[df['rating'].between(0.5, 5.0)]
        
        # Ordenar por usuario y timestamp
        df = df.sort_values(['userId', 'timestamp'])
        
        return df

    def _enrich_movie_data(self, df: pd.DataFrame) -> pd.DataFrame:
        # Calcular década
        df['decade'] = (df['year'] // 10) * 10
        
        # Generar categorías por época
        df['era'] = pd.cut(
            df['year'],
            bins=[0, 1950, 1980, 2000, float('inf')],
            labels=['Classic', 'Retro', 'Modern', 'Contemporary']
        )
        
        # Contar géneros por película
        df['genre_count'] = df['genres'].str.len()
        
        return df

    def _generate_statistics(
        self,
        movies_df: pd.DataFrame,
        ratings_df: pd.DataFrame
    ) -> Dict[str, Any]:
        return {
            'total_movies': len(movies_df),
            'total_ratings': len(ratings_df),
            'unique_users': ratings_df['userId'].nunique(),
            'genres_distribution': self._get_genres_distribution(movies_df),
            'ratings_distribution': self._get_ratings_distribution(ratings_df),
            'era_distribution': movies_df['era'].value_counts().to_dict(),
            'processing_date': datetime.now().isoformat()
        }

    def _get_genres_distribution(self, df: pd.DataFrame) -> Dict[str, int]:
        genres_count = {}
        for genres in df['genres']:
            for genre in genres:
                genres_count[genre] = genres_count.get(genre, 0) + 1
        return genres_count

    def _get_ratings_distribution(self, df: pd.DataFrame) -> Dict[float, int]:
        return df['rating'].value_counts().sort_index().to_dict()

# backend/app/services/personalize_data_formatter.py
class PersonalizeDataFormatter:
    def __init__(self):
        self.interaction_schema = {
            'USER_ID': str,
            'ITEM_ID': str,
            'TIMESTAMP': int,
            'EVENT_TYPE': str,
            'EVENT_VALUE': float
        }

        self.item_schema = {
            'ITEM_ID': str,
            'GENRES': str,
            'YEAR': int,
            'ERA': str,
            'GENRE_COUNT': int,
            'CREATION_TIMESTAMP': int
        }

    def format_interactions(
        self,
        ratings_df: pd.DataFrame
    ) -> pd.DataFrame:
        interactions = ratings_df.copy()
        
        # Formatear columnas requeridas
        interactions['USER_ID'] = interactions['userId'].astype(str)
        interactions['ITEM_ID'] = interactions['movieId'].astype(str)
        interactions['TIMESTAMP'] = interactions['timestamp'].astype(np.int64) // 10**9
        interactions['EVENT_TYPE'] = 'RATING'
        interactions['EVENT_VALUE'] = interactions['rating']
        
        # Seleccionar columnas finales
        return interactions[list(self.interaction_schema.keys())]

    def format_items(self, movies_df: pd.DataFrame) -> pd.DataFrame:
        items = movies_df.copy()
        
        # Formatear columnas
        items['ITEM_ID'] = items['movieId'].astype(str)
        items['GENRES'] = items['genres'].apply(lambda x: '|'.join(x))
        items['YEAR'] = items['year'].fillna(-1).astype(int)
        items['ERA'] = items['era']
        items['GENRE_COUNT'] = items['genre_count']
        items['CREATION_TIMESTAMP'] = (
            pd.Timestamp.now().timestamp()
        ).astype(int)
        
        # Seleccionar columnas finales
        return items[list(self.item_schema.keys())]

# backend/app/services/data_uploader.py
class PersonalizeDataUploader:
    def __init__(
        self,
        dataset_group_name: str,
        bucket_name: str,
        region: str
    ):
        self.personalize = boto3.client(
            'personalize',
            region_name=region
        )
        self.s3 = boto3.client('s3', region_name=region)
        self.dataset_group_name = dataset_group_name
        self.bucket_name = bucket_name

    async def upload_and_create_dataset(
        self,
        interactions_df: pd.DataFrame,
        items_df: pd.DataFrame
    ) -> Dict[str, str]:
        try:
            # Subir archivos a S3
            interactions_key = self._upload_to_s3(
                interactions_df,
                'interactions.csv'
            )
            items_key = self._upload_to_s3(
                items_df,
                'items.csv'
            )
            
            # Crear dataset group si no existe
            dataset_group_arn = await self._create_dataset_group()
            
            # Crear schemas
            interaction_schema_arn = await self._create_schema(
                'interactions_schema',
                self._get_interactions_schema()
            )
            
            items_schema_arn = await self._create_schema(
                'items_schema',
                self._get_items_schema()
            )
            
            # Crear datasets
            interactions_ds_arn = await self._create_dataset(
                dataset_group_arn,
                'INTERACTIONS',
                interaction_schema_arn
            )
            
            items_ds_arn = await self._create_dataset(
                dataset_group_arn,
                'ITEMS',
                items_schema_arn
            )
            
            # Importar datos
            await self._import_dataset(
                interactions_ds_arn,
                f's3://{self.bucket_name}/{interactions_key}'
            )
            
            await self._import_dataset(
                items_ds_arn,
                f's3://{self.bucket_name}/{items_key}'
            )
            
            return {
                'dataset_group_arn': dataset_group_arn,
                'interactions_dataset_arn': interactions_ds_arn,
                'items_dataset_arn': items_ds_arn
            }
            
        except Exception as e:
            self.logger.error(f"Error uploading data: {str(e)}")
            raise

    def _upload_to_s3(
        self,
        df: pd.DataFrame,
        filename: str
    ) -> str:
        csv_buffer = io.StringIO()
        df.to_csv(csv_buffer, index=False)
        
        key = f'personalize/{filename}'
        self.s3.put_object(
            Bucket=self.bucket_name,
            Key=key,
            Body=csv_buffer.getvalue()
        )
        
        return key

    async def _create_dataset_group(self) -> str:
        response = self.personalize.create_dataset_group(
            name=self.dataset_group_name
        )
        
        dataset_group_arn = response['datasetGroupArn']
        
        # Esperar a que esté activo
        waiter = self.personalize.get_waiter('dataset_group_available')
        waiter.wait(
            datasetGroupArn=dataset_group_arn,
            WaiterConfig={
                'Delay': 30,
                'MaxAttempts': 60
            }
        )
        
        return dataset_group_arn

    async def _create_schema(
        self,
        name: str,
        schema: Dict[str, Any]
    ) -> str:
        response = self.personalize.create_schema(
            name=name,
            schema=json.dumps(schema)
        )
        return response['schemaArn']

    async def _create_dataset(
        self,
        dataset_group_arn: str,
        dataset_type: str,
        schema_arn: str
    ) -> str:
        response = self.personalize.create_dataset(
            name=f"{self.dataset_group_name}_{dataset_type.lower()}",
            schemaArn=schema_arn,
            datasetGroupArn=dataset_group_arn,
            datasetType=dataset_type
        )
        return response['datasetArn']

    async def _import_dataset(
        self,
        dataset_arn: str,
        data_location: str
    ) -> None:
        response = self.personalize.create_dataset_import_job(
            jobName=f"import_{int(time.time())}",
            datasetArn=dataset_arn,
            dataSource={'dataLocation': data_location},
            roleArn=self.role_arn
        )
        
        # Esperar a que termine la importación
        import_job_arn = response['datasetImportJobArn']
        waiter = self.personalize.get_waiter('dataset_import_job_available')
        waiter.wait(
            datasetImportJobArn=import_job_arn,
            WaiterConfig={
                'Delay': 60,
                'MaxAttempts': 60
            }
        )

    def _get_interactions_schema(self) -> Dict[str, Any]:
        return {
            "type": "record",
            "name": "Interactions",
            "namespace": "com.movie.recommender",
            "fields": [
                {"name": "USER_ID", "type": "string"},
                {"name": "ITEM_ID", "type": "string"},
                {"name": "TIMESTAMP", "type": "long"},
                {"name": "EVENT_TYPE", "type": "string"},
                {"name": "EVENT_VALUE", "type": "float"}
            ]
        }

    def _get_items_schema(self) -> Dict[str, Any]:
        return {
            "type": "record",
            "name": "Items",
            "namespace": "com.movie.recommender",
            "fields": [
                {"name": "ITEM_ID", "type": "string"},
                {"name": "GENRES", "type": "string"},
                {"name": "YEAR", "type": "int"},
                {"name": "ERA", "type": "string"},
                {"name": "GENRE_COUNT", "type": "int"},
                {"name": "CREATION_TIMESTAMP", "type": "long"}
            ]
        }

Esta primera etapa implementa:

  1. Procesamiento de Datos:

    • Limpieza de datos
    • Transformación de formatos
    • Enriquecimiento de metadata
    • Generación de estadísticas
  2. Formateo para Personalize:

    • Estructura de interacciones
    • Estructura de items
    • Esquemas definidos
    • Validaciones
  3. Upload a AWS:

    • Subida a S3
    • Creación de datasets
    • Importación de datos
    • Monitoreo de procesos

Características principales:

  1. Limpieza de Datos:

    • Manejo de valores nulos
    • Extracción de años
    • Formateo de géneros
    • Validaciones de calidad
  2. Enriquecimiento:

    • Cálculo de época
    • Metadata adicional
    • Estadísticas
    • Métricas de calidad
  3. Integración AWS:

    • Gestión de recursos
    • Manejo de errores
    • Monitoreo
    • Logging

Etapa 2: Configuración de Amazon Personalize, enfocándonos en la configuración y entrenamiento del modelo de recomendación.

python
# backend/app/services/personalize_manager.py
import boto3
import json
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime
import time

class PersonalizeManager:
    def __init__(self, region: str, role_arn: str):
        self.personalize = boto3.client('personalize', region_name=region)
        self.personalize_runtime = boto3.client('personalize-runtime', region_name=region)
        self.role_arn = role_arn
        self.logger = logging.getLogger(__name__)

    async def create_solution(
        self,
        dataset_group_arn: str,
        recipe_arn: str,
        solution_name: str
    ) -> Dict[str, Any]:
        try:
            # Crear solución
            create_solution_response = self.personalize.create_solution(
                name=solution_name,
                datasetGroupArn=dataset_group_arn,
                recipeArn=recipe_arn,
                performAutoML=False,
                performHPO=True,  # Optimización de hiperparámetros
                solutionConfig={
                    "algorithmHyperParameters": {
                        "hidden_dimension": "64",
                        "bptt": "32",
                        "recency_mask": "true",
                        "learning_rate": "0.001",
                        "dropout": "0.5"
                    },
                    "featureTransformationParameters": {
                        "genres_column": "GENRES",
                        "year_column": "YEAR"
                    },
                    "eventValueThreshold": "0.5"
                }
            )
            
            solution_arn = create_solution_response['solutionArn']
            
            # Crear versión de solución (entrenar)
            create_solution_version_response = self.personalize.create_solution_version(
                solutionArn=solution_arn
            )
            
            solution_version_arn = create_solution_version_response['solutionVersionArn']
            
            # Esperar a que termine el entrenamiento
            await self._wait_for_solution_version(solution_version_arn)
            
            # Obtener métricas
            metrics = await self._get_solution_metrics(solution_version_arn)
            
            return {
                'solution_arn': solution_arn,
                'solution_version_arn': solution_version_arn,
                'metrics': metrics
            }
            
        except Exception as e:
            self.logger.error(f"Error creating solution: {str(e)}")
            raise

    async def create_campaign(
        self,
        solution_version_arn: str,
        campaign_name: str,
        min_provisioned_tps: int = 1
    ) -> str:
        try:
            response = self.personalize.create_campaign(
                name=campaign_name,
                solutionVersionArn=solution_version_arn,
                minProvisionedTPS=min_provisioned_tps,
                campaignConfig={
                    "itemExplorationConfig": {
                        "explorationWeight": "0.3",
                        "explorationItemAgeCutOff": "30"
                    }
                }
            )
            
            campaign_arn = response['campaignArn']
            
            # Esperar a que la campaña esté activa
            waiter = self.personalize.get_waiter('campaign_available')
            waiter.wait(
                campaignArn=campaign_arn,
                WaiterConfig={
                    'Delay': 60,
                    'MaxAttempts': 60
                }
            )
            
            return campaign_arn
            
        except Exception as e:
            self.logger.error(f"Error creating campaign: {str(e)}")
            raise

    async def create_filter(
        self,
        dataset_group_arn: str,
        filter_name: str
    ) -> str:
        try:
            # Crear filtro para excluir items ya vistos
            response = self.personalize.create_filter(
                name=filter_name,
                datasetGroupArn=dataset_group_arn,
                filterExpression="EXCLUDE ItemID WHERE INTERACTIONS.event_type IN ('RATING', 'WATCH')"
            )
            
            return response['filterArn']
            
        except Exception as e:
            self.logger.error(f"Error creating filter: {str(e)}")
            raise

    async def get_recommendations(
        self,
        campaign_arn: str,
        user_id: str,
        num_results: int = 10,
        filter_arn: Optional[str] = None,
        context: Optional[Dict[str, str]] = None
    ) -> List[Dict[str, Any]]:
        try:
            params = {
                'campaignArn': campaign_arn,
                'userId': user_id,
                'numResults': num_results
            }
            
            if filter_arn:
                params['filterArn'] = filter_arn
            
            if context:
                params['context'] = context
            
            response = self.personalize_runtime.get_recommendations(**params)
            
            return response['itemList']
            
        except Exception as e:
            self.logger.error(f"Error getting recommendations: {str(e)}")
            raise

    async def _wait_for_solution_version(
        self,
        solution_version_arn: str
    ) -> None:
        while True:
            response = self.personalize.describe_solution_version(
                solutionVersionArn=solution_version_arn
            )
            status = response['solutionVersion']['status']
            
            if status == 'ACTIVE':
                break
            elif status == 'CREATE FAILED':
                raise Exception(
                    f"Solution version creation failed: {response['solutionVersion']['failureReason']}"
                )
                
            time.sleep(60)

    async def _get_solution_metrics(
        self,
        solution_version_arn: str
    ) -> Dict[str, float]:
        response = self.personalize.get_solution_metrics(
            solutionVersionArn=solution_version_arn
        )
        return response['metrics']

# backend/app/services/personalize_trainer.py
class PersonalizeTrainer:
    def __init__(
        self,
        personalize_manager: PersonalizeManager,
        config: Dict[str, Any]
    ):
        self.manager = personalize_manager
        self.config = config
        self.logger = logging.getLogger(__name__)

    async def train_models(
        self,
        dataset_group_arn: str
    ) -> Dict[str, Any]:
        try:
            # Crear solución user-personalization
            user_personalization = await self.manager.create_solution(
                dataset_group_arn=dataset_group_arn,
                recipe_arn='arn:aws:personalize:::recipe/aws-user-personalization',
                solution_name=f"{self.config['project_name']}_user_personalization"
            )
            
            # Crear solución similar-items
            similar_items = await self.manager.create_solution(
                dataset_group_arn=dataset_group_arn,
                recipe_arn='arn:aws:personalize:::recipe/aws-sims',
                solution_name=f"{self.config['project_name']}_similar_items"
            )
            
            # Crear campañas
            user_campaign_arn = await self.manager.create_campaign(
                solution_version_arn=user_personalization['solution_version_arn'],
                campaign_name=f"{self.config['project_name']}_user_campaign"
            )
            
            similar_items_campaign_arn = await self.manager.create_campaign(
                solution_version_arn=similar_items['solution_version_arn'],
                campaign_name=f"{self.config['project_name']}_similar_items_campaign"
            )
            
            # Crear filtros
            filter_arn = await self.manager.create_filter(
                dataset_group_arn=dataset_group_arn,
                filter_name=f"{self.config['project_name']}_viewed_filter"
            )
            
            return {
                'user_personalization': {
                    'solution_arn': user_personalization['solution_arn'],
                    'solution_version_arn': user_personalization['solution_version_arn'],
                    'campaign_arn': user_campaign_arn,
                    'metrics': user_personalization['metrics']
                },
                'similar_items': {
                    'solution_arn': similar_items['solution_arn'],
                    'solution_version_arn': similar_items['solution_version_arn'],
                    'campaign_arn': similar_items_campaign_arn,
                    'metrics': similar_items['metrics']
                },
                'filter_arn': filter_arn
            }
            
        except Exception as e:
            self.logger.error(f"Error training models: {str(e)}")
            raise

# backend/app/services/personalize_monitor.py
class PersonalizeMonitor:
    def __init__(self, cloudwatch_client):
        self.cloudwatch = cloudwatch_client
        self.logger = logging.getLogger(__name__)

    async def monitor_solution_metrics(
        self,
        solution_version_arn: str
    ) -> Dict[str, Any]:
        try:
            metrics = {}
            
            # Métricas de precisión
            metrics['precision'] = await self._get_metric(
                solution_version_arn,
                'precision_at_k'
            )
            
            # Métricas de recall
            metrics['recall'] = await self._get_metric(
                solution_version_arn,
                'recall_at_k'
            )
            
            # Normalized Discounted Cumulative Gain
            metrics['ndcg'] = await self._get_metric(
                solution_version_arn,
                'ndcg_at_k'
            )
            
            return metrics
            
        except Exception as e:
            self.logger.error(f"Error monitoring metrics: {str(e)}")
            raise

    async def create_performance_dashboard(
        self,
        campaign_arn: str,
        solution_version_arn: str
    ) -> str:
        try:
            dashboard_name = f"personalize-metrics-{int(time.time())}"
            
            dashboard_body = {
                "widgets": [
                    {
                        "type": "metric",
                        "properties": {
                            "metrics": [
                                ["AWS/Personalize", "GetRecommendations.Latency", "CampaignArn", campaign_arn],
                                [".", "GetRecommendations.RequestCount", ".", "."]
                            ],
                            "period": 300,
                            "stat": "Average",
                            "region": self.cloudwatch.meta.region_name,
                            "title": "Campaign Performance"
                        }
                    },
                    {
                        "type": "metric",
                        "properties": {
                            "metrics": [
                                ["AWS/Personalize", "precision_at_k", "SolutionVersionArn", solution_version_arn],
                                [".", "recall_at_k", ".", "."],
                                [".", "ndcg_at_k", ".", "."]
                            ],
                            "period": 3600,
                            "stat": "Average",
                            "region": self.cloudwatch.meta.region_name,
                            "title": "Model Metrics"
                        }
                    }
                ]
            }
            
            self.cloudwatch.put_dashboard(
                DashboardName=dashboard_name,
                DashboardBody=json.dumps(dashboard_body)
            )
            
            return dashboard_name
            
        except Exception as e:
            self.logger.error(f"Error creating dashboard: {str(e)}")
            raise

    async def _get_metric(
        self,
        resource_arn: str,
        metric_name: str
    ) -> float:
        response = self.cloudwatch.get_metric_statistics(
            Namespace='AWS/Personalize',
            MetricName=metric_name,
            Dimensions=[
                {
                    'Name': 'SolutionVersionArn',
                    'Value': resource_arn
                }
            ],
            StartTime=datetime.utcnow() - timedelta(hours=1),
            EndTime=datetime.utcnow(),
            Period=3600,
            Statistics=['Average']
        )
        
        if response['Datapoints']:
            return response['Datapoints'][0]['Average']
        return 0.0

Esta segunda etapa implementa:

  1. Gestión de Personalize:

    • Creación de soluciones
    • Entrenamiento de modelos
    • Gestión de campañas
    • Filtros personalizados
  2. Configuración de Modelos:

    • User-Personalization
    • Similar-Items
    • Optimización de hiperparámetros
    • Configuración de exploración
  3. Monitoreo y Métricas:

    • Métricas de precisión
    • Monitoreo de performance
    • Dashboards automáticos
    • Alertas

Características principales:

  1. Optimización de Modelos:

    • HPO automático
    • Configuración avanzada
    • Métricas detalladas
    • Validación de resultados
  2. Gestión de Campaña:

    • Provisioning optimizado
    • Configuración de exploración
    • Filtros contextuales
    • Monitoreo en tiempo real
  3. Observabilidad:

    • Métricas detalladas
    • Dashboards CloudWatch
    • Logging estructurado
    • Alertas automatizadas

Etapa 3: Desarrollo Backend Base, implementando la API y la estructura de datos principal.

python
# backend/app/api/main.py
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from mangum import Mangum
from app.core.config import Settings
from app.api.routes import user, movie, recommendations
from app.core.logging import setup_logging

app = FastAPI(
    title="Movie Recommender API",
    description="API for personalized movie recommendations",
    version="1.0.0"
)

# Configurar CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Rutas
app.include_router(user.router, prefix="/api/users", tags=["users"])
app.include_router(movie.router, prefix="/api/movies", tags=["movies"])
app.include_router(recommendations.router, prefix="/api/recommendations", tags=["recommendations"])

# Handler para Lambda
handler = Mangum(app)

# backend/app/models/user.py
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import date
import re

class UserBase(BaseModel):
    birth_date: date = Field(..., description="User's date of birth")
    profession: str = Field(..., description="User's profession")
    favorite_genres: List[str] = Field(..., min_items=1, max_items=3, description="List of favorite movie genres")

    @validator('profession')
    def validate_profession(cls, v):
        allowed_professions = [
            "student", "engineer", "doctor", "artist", "teacher",
            "business", "technology", "healthcare", "other"
        ]
        if v.lower() not in allowed_professions:
            raise ValueError(f"Profession must be one of: {', '.join(allowed_professions)}")
        return v.lower()

    @validator('favorite_genres')
    def validate_genres(cls, v):
        allowed_genres = [
            "action", "comedy", "drama", "horror", "sci-fi",
            "thriller", "romance", "documentary", "animation"
        ]
        for genre in v:
            if genre.lower() not in allowed_genres:
                raise ValueError(f"Genre must be one of: {', '.join(allowed_genres)}")
        return [genre.lower() for genre in v]

class UserCreate(UserBase):
    pass

class UserUpdate(UserBase):
    birth_date: Optional[date] = None
    profession: Optional[str] = None
    favorite_genres: Optional[List[str]] = None

class UserInDB(UserBase):
    id: str
    created_at: date
    last_active: date
    recommendation_count: int = 0

    class Config:
        orm_mode = True

# backend/app/models/movie.py
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date

class MovieBase(BaseModel):
    title: str = Field(..., min_length=1)
    year: int = Field(..., ge=1900, le=date.today().year)
    genres: List[str]
    director: Optional[str] = None
    cast: Optional[List[str]] = None
    plot: Optional[str] = None
    rating: Optional[float] = Field(None, ge=0, le=10)
    duration: Optional[int] = Field(None, ge=0)  # en minutos

class MovieCreate(MovieBase):
    pass

class MovieUpdate(MovieBase):
    pass

class MovieInDB(MovieBase):
    id: str
    created_at: date
    updated_at: date
    recommendation_count: int = 0

    class Config:
        orm_mode = True

# backend/app/models/recommendation.py
from pydantic import BaseModel, Field
from typing import List, Dict
from datetime import datetime

class RecommendationBase(BaseModel):
    user_id: str
    movie_ids: List[str]
    score: float = Field(..., ge=0, le=1)
    context: Dict[str, str] = {}

class RecommendationCreate(RecommendationBase):
    pass

class RecommendationInDB(RecommendationBase):
    id: str
    created_at: datetime
    accepted: Optional[bool] = None
    feedback: Optional[str] = None

    class Config:
        orm_mode = True

# backend/app/db/dynamodb.py
import boto3
from botocore.exceptions import ClientError
from typing import Dict, Any, Optional, List
import logging
import json
from datetime import datetime

class DynamoDBManager:
    def __init__(self, region: str):
        self.dynamodb = boto3.resource('dynamodb', region_name=region)
        self.logger = logging.getLogger(__name__)

    async def get_item(
        self,
        table_name: str,
        key: Dict[str, str]
    ) -> Optional[Dict[str, Any]]:
        try:
            table = self.dynamodb.Table(table_name)
            response = table.get_item(Key=key)
            return response.get('Item')
        except ClientError as e:
            self.logger.error(f"Error getting item: {str(e)}")
            raise

    async def put_item(
        self,
        table_name: str,
        item: Dict[str, Any]
    ) -> None:
        try:
            table = self.dynamodb.Table(table_name)
            table.put_item(Item=item)
        except ClientError as e:
            self.logger.error(f"Error putting item: {str(e)}")
            raise

    async def query_items(
        self,
        table_name: str,
        key_condition_expression: str,
        expression_attribute_values: Dict[str, Any]
    ) -> List[Dict[str, Any]]:
        try:
            table = self.dynamodb.Table(table_name)
            response = table.query(
                KeyConditionExpression=key_condition_expression,
                ExpressionAttributeValues=expression_attribute_values
            )
            return response.get('Items', [])
        except ClientError as e:
            self.logger.error(f"Error querying items: {str(e)}")
            raise

    async def update_item(
        self,
        table_name: str,
        key: Dict[str, str],
        update_expression: str,
        expression_attribute_values: Dict[str, Any]
    ) -> None:
        try:
            table = self.dynamodb.Table(table_name)
            table.update_item(
                Key=key,
                UpdateExpression=update_expression,
                ExpressionAttributeValues=expression_attribute_values
            )
        except ClientError as e:
            self.logger.error(f"Error updating item: {str(e)}")
            raise

# backend/app/repositories/user_repository.py
from typing import Optional, List
from app.models.user import UserCreate, UserInDB
from app.db.dynamodb import DynamoDBManager
import uuid
from datetime import datetime

class UserRepository:
    def __init__(self, db_manager: DynamoDBManager):
        self.db = db_manager
        self.table_name = "users"

    async def create_user(self, user: UserCreate) -> UserInDB:
        user_id = str(uuid.uuid4())
        now = datetime.utcnow()
        
        user_data = {
            "id": user_id,
            "birth_date": user.birth_date.isoformat(),
            "profession": user.profession,
            "favorite_genres": user.favorite_genres,
            "created_at": now.isoformat(),
            "last_active": now.isoformat(),
            "recommendation_count": 0
        }
        
        await self.db.put_item(self.table_name, user_data)
        return UserInDB(**user_data)

    async def get_user(self, user_id: str) -> Optional[UserInDB]:
        user_data = await self.db.get_item(
            self.table_name,
            {"id": user_id}
        )
        
        if user_data:
            return UserInDB(**user_data)
        return None

    async def update_user(
        self,
        user_id: str,
        update_data: Dict[str, Any]
    ) -> Optional[UserInDB]:
        update_expr_parts = []
        expr_attr_values = {}
        
        for key, value in update_data.items():
            if value is not None:
                update_expr_parts.append(f"#{key} = :{key}")
                expr_attr_values[f":{key}"] = value
        
        if not update_expr_parts:
            return None
            
        update_expression = "SET " + ", ".join(update_expr_parts)
        
        await self.db.update_item(
            self.table_name,
            {"id": user_id},
            update_expression,
            expr_attr_values
        )
        
        return await self.get_user(user_id)

    async def increment_recommendation_count(
        self,
        user_id: str
    ) -> None:
        await self.db.update_item(
            self.table_name,
            {"id": user_id},
            "SET recommendation_count = recommendation_count + :inc",
            {":inc": 1}
        )

# backend/app/repositories/movie_repository.py
class MovieRepository:
    def __init__(self, db_manager: DynamoDBManager):
        self.db = db_manager
        self.table_name = "movies"

    async def get_movies_by_ids(
        self,
        movie_ids: List[str]
    ) -> List[MovieInDB]:
        movies = []
        for movie_id in movie_ids:
            movie_data = await self.db.get_item(
                self.table_name,
                {"id": movie_id}
            )
            if movie_data:
                movies.append(MovieInDB(**movie_data))
        return movies

    async def get_movies_by_genre(
        self,
        genre: str,
        limit: int = 10
    ) -> List[MovieInDB]:
        movies_data = await self.db.query_items(
            self.table_name,
            "contains(genres, :genre)",
            {":genre": genre}
        )
        return [MovieInDB(**m) for m in movies_data[:limit]]

# backend/app/repositories/recommendation_repository.py
class RecommendationRepository:
    def __init__(self, db_manager: DynamoDBManager):
        self.db = db_manager
        self.table_name = "recommendations"

    async def create_recommendation(
        self,
        recommendation: RecommendationCreate
    ) -> RecommendationInDB:
        recommendation_id = str(uuid.uuid4())
        now = datetime.utcnow()
        
        recommendation_data = {
            "id": recommendation_id,
            "user_id": recommendation.user_id,
            "movie_ids": recommendation.movie_ids,
            "score": recommendation.score,
            "context": recommendation.context,
            "created_at": now.isoformat(),
            "accepted": None,
            "feedback": None
        }
        
        await self.db.put_item(self.table_name, recommendation_data)
        return RecommendationInDB(**recommendation_data)

    async def get_user_recommendations(
        self,
        user_id: str,
        limit: int = 10
    ) -> List[RecommendationInDB]:
        recommendations_data = await self.db.query_items(
            self.table_name,
            "user_id = :user_id",
            {":user_id": user_id}
        )
        
        recommendations = [
            RecommendationInDB(**r) for r in recommendations_data
        ]
        
        return sorted(
            recommendations,
            key=lambda x: x.created_at,
            reverse=True
        )[:limit]

    async def update_recommendation_feedback(
        self,
        recommendation_id: str,
        accepted: bool,
        feedback: Optional[str] = None
    ) -> None:
        update_expression = "SET accepted = :accepted"
        expression_values = {":accepted": accepted}
        
        if feedback:
            update_expression += ", feedback = :feedback"
            expression_values[":feedback"] = feedback
        
        await self.db.update_item(
            self.table_name,
            {"id": recommendation_id},
            update_expression,
            expression_values
        )

Esta tercera etapa implementa:

  1. Estructura Base:

    • Modelos de datos
    • Repositorios DynamoDB
    • API endpoints
    • Validaciones
  2. Gestión de Datos:

    • CRUD de usuarios
    • Gestión de películas
    • Historial de recomendaciones
    • Feedback de usuarios
  3. API Rest:

    • FastAPI setup
    • Endpoints documentados
    • Middleware configurado
    • Error handling

Características principales:

  1. Modelos Robustos:

    • Validaciones Pydantic
    • Tipos estrictos
    • Documentación
    • Transformaciones
  2. Persistencia:

    • DynamoDB optimizado
    • Índices eficientes
    • Transacciones
    • Caché
  3. API Moderna:

    • Async/await
    • OpenAPI/Swagger
    • Rate limiting
    • CORS configurado

Etapa 4: Integración con Personalize, implementando la lógica de recomendaciones personalizada.

python
# backend/app/services/recommendation_engine.py
from typing import List, Dict, Any, Optional
import logging
from datetime import datetime
from app.models.user import UserInDB
from app.models.movie import MovieInDB
from app.services.personalize_manager import PersonalizeManager
from app.repositories.movie_repository import MovieRepository
from app.core.cache import AsyncCache

class RecommendationEngine:
    def __init__(
        self,
        personalize_manager: PersonalizeManager,
        movie_repository: MovieRepository,
        cache: AsyncCache
    ):
        self.personalize = personalize_manager
        self.movie_repo = movie_repository
        self.cache = cache
        self.logger = logging.getLogger(__name__)

    async def get_recommendations(
        self,
        user: UserInDB,
        limit: int = 10,
        context: Optional[Dict[str, str]] = None
    ) -> List[Dict[str, Any]]:
        cache_key = f"recommendations:{user.id}:{limit}:{hash(str(context))}"
        
        # Intentar obtener del caché
        cached_results = await self.cache.get(cache_key)
        if cached_results:
            return cached_results

        try:
            # Preparar contexto
            enriched_context = await self._prepare_context(user, context)
            
            # Obtener recomendaciones base
            base_recommendations = await self.personalize.get_recommendations(
                user_id=user.id,
                num_results=limit * 2,  # Pedir más para filtrado
                context=enriched_context
            )

            # Enriquecer y filtrar recomendaciones
            recommendations = await self._process_recommendations(
                base_recommendations,
                user,
                limit
            )

            # Guardar en caché
            await self.cache.set(
                cache_key,
                recommendations,
                expire=1800  # 30 minutos
            )

            return recommendations

        except Exception as e:
            self.logger.error(f"Error getting recommendations: {str(e)}")
            raise

    async def get_similar_movies(
        self,
        movie_id: str,
        limit: int = 5
    ) -> List[Dict[str, Any]]:
        cache_key = f"similar:{movie_id}:{limit}"
        
        cached_results = await self.cache.get(cache_key)
        if cached_results:
            return cached_results

        try:
            similar_items = await self.personalize.get_similar_items(
                item_id=movie_id,
                num_results=limit
            )

            recommendations = await self._enrich_movies(similar_items)

            await self.cache.set(cache_key, recommendations, expire=3600)  # 1 hora
            return recommendations

        except Exception as e:
            self.logger.error(f"Error getting similar movies: {str(e)}")
            raise

    async def _prepare_context(
        self,
        user: UserInDB,
        additional_context: Optional[Dict[str, str]] = None
    ) -> Dict[str, str]:
        # Calcular edad
        age = self._calculate_age(user.birth_date)
        
        context = {
            "AGE_GROUP": self._get_age_group(age),
            "PROFESSION": user.profession.upper(),
            "FAVORITE_GENRES": "|".join(user.favorite_genres).upper(),
            "TIME_OF_DAY": self._get_time_of_day(),
        }

        if additional_context:
            context.update(additional_context)

        return context

    async def _process_recommendations(
        self,
        base_recommendations: List[Dict[str, Any]],
        user: UserInDB,
        limit: int
    ) -> List[Dict[str, Any]]:
        # Obtener detalles de películas
        movies = await self._enrich_movies(base_recommendations)
        
        # Aplicar filtros personalizados
        filtered_movies = await self._apply_filters(movies, user)
        
        # Reordenar basado en preferencias
        ranked_movies = await self._rank_recommendations(filtered_movies, user)
        
        # Limitar resultados
        return ranked_movies[:limit]

    async def _enrich_movies(
        self,
        recommendations: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        movie_ids = [rec['itemId'] for rec in recommendations]
        movies = await self.movie_repo.get_movies_by_ids(movie_ids)
        
        # Crear mapa de películas para fácil acceso
        movie_map = {str(m.id): m for m in movies}
        
        enriched_recommendations = []
        for rec in recommendations:
            movie = movie_map.get(rec['itemId'])
            if movie:
                enriched_recommendations.append({
                    'movie': movie.dict(),
                    'score': float(rec.get('score', 0)),
                    'reasoning': self._generate_recommendation_reason(movie, rec)
                })
        
        return enriched_recommendations

    async def _apply_filters(
        self,
        movies: List[Dict[str, Any]],
        user: UserInDB
    ) -> List[Dict[str, Any]]:
        filtered = []
        for movie in movies:
            # Verificar géneros favoritos
            if any(genre in user.favorite_genres for genre in movie['movie']['genres']):
                movie['score'] *= 1.2  # Boost por género favorito
            
            # Verificar edad apropiada
            age = self._calculate_age(user.birth_date)
            if self._is_age_appropriate(movie['movie'], age):
                filtered.append(movie)
        
        return filtered

    async def _rank_recommendations(
        self,
        movies: List[Dict[str, Any]],
        user: UserInDB
    ) -> List[Dict[str, Any]]:
        for movie in movies:
            # Calcular score final basado en múltiples factores
            base_score = movie['score']
            genre_score = self._calculate_genre_score(movie['movie'], user)
            popularity_score = self._calculate_popularity_score(movie['movie'])
            recency_score = self._calculate_recency_score(movie['movie'])
            
            # Combinar scores con pesos
            final_score = (
                base_score * 0.4 +
                genre_score * 0.3 +
                popularity_score * 0.2 +
                recency_score * 0.1
            )
            
            movie['final_score'] = final_score
        
        # Ordenar por score final
        return sorted(
            movies,
            key=lambda x: x['final_score'],
            reverse=True
        )

    def _calculate_age(self, birth_date: datetime) -> int:
        today = datetime.now()
        return today.year - birth_date.year - (
            (today.month, today.day) < (birth_date.month, birth_date.day)
        )

    def _get_age_group(self, age: int) -> str:
        if age < 18:
            return "UNDER_18"
        elif age < 25:
            return "18_24"
        elif age < 35:
            return "25_34"
        elif age < 50:
            return "35_49"
        else:
            return "50_PLUS"

    def _get_time_of_day(self) -> str:
        hour = datetime.now().hour
        if 5 <= hour < 12:
            return "MORNING"
        elif 12 <= hour < 17:
            return "AFTERNOON"
        elif 17 <= hour < 22:
            return "EVENING"
        else:
            return "NIGHT"

    def _generate_recommendation_reason(
        self,
        movie: MovieInDB,
        recommendation: Dict[str, Any]
    ) -> str:
        reasons = []
        
        if 'score' in recommendation and recommendation['score'] > 0.8:
            reasons.append("alta coincidencia con tus gustos")
        
        if 'itemSimilarity' in recommendation:
            reasons.append("similar a películas que te han gustado")
        
        if movie.rating and movie.rating > 7.5:
            reasons.append(f"bien valorada con {movie.rating}/10")
        
        return " y ".join(reasons) if reasons else "basado en tus preferencias"

    def _calculate_genre_score(
        self,
        movie: Dict[str, Any],
        user: UserInDB
    ) -> float:
        matching_genres = set(movie['genres']).intersection(set(user.favorite_genres))
        return len(matching_genres) / len(movie['genres']) if movie['genres'] else 0

    def _calculate_popularity_score(self, movie: Dict[str, Any]) -> float:
        return min(movie.get('rating', 0) / 10, 1.0)

    def _calculate_recency_score(self, movie: Dict[str, Any]) -> float:
        current_year = datetime.now().year
        movie_year = movie.get('year', current_year)
        years_old = current_year - movie_year
        return max(0, 1 - (years_old / 50))  # Decae linealmente sobre 50 años

    def _is_age_appropriate(
        self,
        movie: Dict[str, Any],
        user_age: int
    ) -> bool:
        # Implementar lógica de clasificación por edad
        if user_age < 13 and 'Horror' in movie['genres']:
            return False
        if user_age < 17 and any(genre in movie['genres'] for genre in ['Adult', 'Erotic']):
            return False
        return True

# backend/app/services/recommendation_diversifier.py
class RecommendationDiversifier:
    def __init__(self):
        self.min_genre_diversity = 0.3  # Al menos 30% de géneros diferentes
        self.min_year_spread = 10       # Al menos 10 años de diferencia
        self.max_similar_directors = 3   # No más de 3 películas del mismo director

    def diversify_recommendations(
        self,
        recommendations: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        if not recommendations:
            return []

        diversified = []
        genres_seen = set()
        years_seen = set()
        directors_count = {}

        for rec in recommendations:
            movie = rec['movie']
            
            # Verificar diversidad de géneros
            movie_genres = set(movie['genres'])
            genres_overlap = len(movie_genres.intersection(genres_seen)) / len(movie_genres) if movie_genres else 1
            
            # Verificar spread de años
            year = movie['year']
            year_diversity = min(abs(y - year) for y in years_seen) if years_seen else float('inf')
            
            # Verificar directores
            director = movie.get('director', 'unknown')
            director_count = directors_count.get(director, 0)

            # Decidir si incluir la película
            if (genres_overlap < (1 - self.min_genre_diversity) or 
                year_diversity >= self.min_year_spread or
                director_count < self.max_similar_directors):
                
                diversified.append(rec)
                genres_seen.update(movie_genres)
                years_seen.add(year)
                directors_count[director] = director_count + 1

        return diversified

# backend/app/api/endpoints/recommendations.py
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional
from app.models.user import UserInDB
from app.services.recommendation_engine import RecommendationEngine
from app.api.deps import get_current_user

router = APIRouter()

@router.get("/recommendations/")
async def get_recommendations(
    limit: int = 10,
    genre_filter: Optional[str] = None,
    year_min: Optional[int] = None,
    year_max: Optional[int] = None,
    current_user: UserInDB = Depends(get_current_user)
) -> List[Dict[str, Any]]:
    """
    Get personalized movie recommendations for the current user.
    """
    try:
        context = {}
        if genre_filter:
            context["GENRE_FILTER"] = genre_filter
        if year_min or year_max:
            context["YEAR_RANGE"] = f"{year_min or 1900}-{year_max or 2024}"

        recommendations = await recommendation_engine.get_recommendations(
            user=current_user,
            limit=limit,
            context=context
        )

        return recommendations

    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e)
        )

@router.get("/similar/{movie_id}")
async def get_similar_movies(
    movie_id: str,
    limit: int = 5,
    current_user: UserInDB = Depends(get_current_user)
) -> List[Dict[str, Any]]:
    """
    Get similar movie recommendations based on a specific movie.
    """
    try:
        recommendations = await recommendation_engine.get_similar_movies(
            movie_id=movie_id,
            limit=limit
        )

        return recommendations

    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e)
        )

@router.post("/feedback/{recommendation_id}")
async def provide_feedback(
    recommendation_id: str,
    feedback: RecommendationFeedback,
    current_user: UserInDB = Depends(get_current_user)
):
    """
    Provide feedback for a recommendation.
    """
    try:
        await recommendation_engine.update_recommendation_feedback(
            recommendation_id=recommendation_id,
            user_id=current_user.id,
            accepted=feedback.accepted,
            feedback_text=feedback.feedback
        )

        return {"message": "Feedback recorded successfully"}

    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=str(e)
        )

Esta cuarta etapa implementa:

  1. Motor de Recomendaciones:

    • Integración con Personalize
    • Filtrado contextual
    • Enriquecimiento de datos
    • Explicabilidad
  2. Lógica de Personalización:

    • Scores personalizados
    • Filtros por edad
    • Preferencias de género
    • Análisis temporal
  3. Diversificación:

    • Balance de géneros
    • Spread temporal
    • Variedad de directores
    • Ponderación dinámica

Características principales:

  1. Contextualización:

    • Edad del usuario
    • Profesión
    • Géneros favoritos
    • Hora del día
  2. Optimización:

    • Caché inteligente
    • Batch processing
    • Pre-filtering
    • Post-processing
  3. Personalización:

    • Scores multi-factor
    • Feedback loop
    • Explicaciones personalizadas
    • Adaptación contextual

Etapa 5: Frontend Interactivo, implementando la interfaz de usuario con Vue 3 y componentes interactivos.

javascript
// frontend/src/components/UserProfile/ProfileForm.vue
<template>
  <div class="max-w-2xl mx-auto p-6">
    <form @submit.prevent="handleSubmit" class="space-y-6">
      <!-- Fecha de Nacimiento -->
      <div>
        <label class="block text-sm font-medium text-gray-700">
          Fecha de Nacimiento
        </label>
        <input
          type="date"
          v-model="formData.birth_date"
          :max="maxDate"
          required
          class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
        >
      </div>

      <!-- Profesión -->
      <div>
        <label class="block text-sm font-medium text-gray-700">
          Profesión
        </label>
        <select
          v-model="formData.profession"
          required
          class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
        >
          <option value="" disabled>Seleccione una profesión</option>
          <option
            v-for="profession in professions"
            :key="profession.value"
            :value="profession.value"
          >
            {{ profession.label }}
          </option>
        </select>
      </div>

      <!-- Géneros Favoritos -->
      <div>
        <label class="block text-sm font-medium text-gray-700 mb-2">
          Géneros Favoritos (Seleccione 3)
        </label>
        <div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
          <div
            v-for="genre in genres"
            :key="genre.value"
            class="relative"
          >
            <label class="flex items-center space-x-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50"
                   :class="{'border-blue-500 bg-blue-50': isGenreSelected(genre.value)}">
              <input
                type="checkbox"
                :value="genre.value"
                v-model="formData.favorite_genres"
                :disabled="!isGenreSelected(genre.value) && formData.favorite_genres.length >= 3"
                class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
              >
              <span class="text-sm">{{ genre.label }}</span>
            </label>
          </div>
        </div>
        <p class="mt-2 text-sm text-gray-500">
          {{ 3 - formData.favorite_genres.length }} géneros restantes por seleccionar
        </p>
      </div>

      <button
        type="submit"
        :disabled="!isFormValid"
        class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
      >
        Guardar Preferencias
      </button>
    </form>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

const formData = ref({
  birth_date: '',
  profession: '',
  favorite_genres: []
})

const professions = [
  { value: 'student', label: 'Estudiante' },
  { value: 'engineer', label: 'Ingeniero' },
  { value: 'doctor', label: 'Doctor' },
  { value: 'artist', label: 'Artista' },
  { value: 'teacher', label: 'Profesor' },
  { value: 'business', label: 'Empresario' },
  { value: 'technology', label: 'Tecnología' },
  { value: 'healthcare', label: 'Salud' },
  { value: 'other', label: 'Otro' }
]

const genres = [
  { value: 'action', label: 'Acción' },
  { value: 'comedy', label: 'Comedia' },
  { value: 'drama', label: 'Drama' },
  { value: 'horror', label: 'Terror' },
  { value: 'sci-fi', label: 'Ciencia Ficción' },
  { value: 'thriller', label: 'Suspense' },
  { value: 'romance', label: 'Romance' },
  { value: 'documentary', label: 'Documental' },
  { value: 'animation', label: 'Animación' }
]

const maxDate = computed(() => {
  const date = new Date()
  date.setFullYear(date.getFullYear() - 13)  // Mínimo 13 años
  return date.toISOString().split('T')[0]
})

const isFormValid = computed(() => {
  return (
    formData.value.birth_date &&
    formData.value.profession &&
    formData.value.favorite_genres.length === 3
  )
})

const isGenreSelected = (genre) => {
  return formData.value.favorite_genres.includes(genre)
}

const handleSubmit = async () => {
  try {
    await userStore.updatePreferences(formData.value)
    // Navegar a recomendaciones
  } catch (error) {
    console.error('Error updating preferences:', error)
  }
}
</script>

// frontend/src/components/Recommendations/MovieCard.vue
<template>
  <div
    class="bg-white rounded-lg shadow-lg overflow-hidden transition-transform hover:scale-105"
    @mouseenter="hovering = true"
    @mouseleave="hovering = false"
  >
    <!-- Imagen de la película -->
    <div class="relative aspect-[2/3]">
      <img
        :src="movie.poster_url || '/placeholder-movie.jpg'"
        :alt="movie.title"
        class="w-full h-full object-cover"
      >
      <div
        v-if="hovering"
        class="absolute inset-0 bg-black bg-opacity-75 p-4 text-white overflow-y-auto transition-opacity duration-200"
      >
        <p class="text-sm">{{ movie.plot }}</p>
      </div>
    </div>

    <!-- Información -->
    <div class="p-4">
      <h3 class="text-lg font-semibold text-gray-900 truncate">
        {{ movie.title }}
      </h3>
      <p class="text-sm text-gray-500">{{ movie.year }}</p>
      
      <!-- Géneros -->
      <div class="mt-2 flex flex-wrap gap-1">
        <span
          v-for="genre in movie.genres"
          :key="genre"
          class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
        >
          {{ genre }}
        </span>
      </div>

      <!-- Score de recomendación -->
      <div class="mt-3 flex items-center">
        <div class="flex-1">
          <div class="h-2 bg-gray-200 rounded-full">
            <div
              class="h-2 bg-blue-600 rounded-full"
              :style="{ width: `${Math.round(score * 100)}%` }"
            ></div>
          </div>
        </div>
        <span class="ml-2 text-sm text-gray-600">
          {{ Math.round(score * 100) }}%
        </span>
      </div>

      <!-- Razón de recomendación -->
      <p class="mt-2 text-sm text-gray-600">
        {{ reasoning }}
      </p>

      <!-- Acciones -->
      <div class="mt-4 flex space-x-2">
        <button
          @click="emitFeedback(true)"
          class="flex-1 px-3 py-1 bg-green-100 text-green-800 rounded-md hover:bg-green-200"
        >
          Me Interesa
        </button>
        <button
          @click="emitFeedback(false)"
          class="flex-1 px-3 py-1 bg-red-100 text-red-800 rounded-md hover:bg-red-200"
        >
          No Me Interesa
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  movie: {
    type: Object,
    required: true
  },
  score: {
    type: Number,
    required: true
  },
  reasoning: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['feedback'])
const hovering = ref(false)

const emitFeedback = (liked) => {
  emit('feedback', {
    movieId: props.movie.id,
    liked,
    timestamp: new Date().toISOString()
  })
}
</script>

// frontend/src/components/Recommendations/RecommendationList.vue
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRecommendationStore } from '@/stores/recommendations'
import MovieCard from './MovieCard.vue'

const store = useRecommendationStore()
const loading = ref(false)
const currentPage = ref(1)

const filters = ref({
  genre: '',
  yearFrom: '',
  yearTo: '',
  sortBy: 'score'
})

const currentYear = new Date().getFullYear()

const availableGenres = computed(() => {
  const genres = new Set()
  store.recommendations.forEach(rec => {
    rec.movie.genres.forEach(genre => genres.add(genre))
  })
  return Array.from(genres).sort()
})

const filteredRecommendations = computed(() => {
  let results = [...store.recommendations]

  // Aplicar filtros
  if (filters.value.genre) {
    results = results.filter(rec => 
      rec.movie.genres.includes(filters.value.genre)
    )
  }

  if (filters.value.yearFrom) {
    results = results.filter(rec => 
      rec.movie.year >= filters.value.yearFrom
    )
  }

  if (filters.value.yearTo) {
    results = results.filter(rec => 
      rec.movie.year <= filters.value.yearTo
    )
  }

  // Ordenar
  results.sort((a, b) => {
    switch (filters.value.sortBy) {
      case 'score':
        return b.score - a.score
      case 'year':
        return b.movie.year - a.movie.year
      case 'title':
        return a.movie.title.localeCompare(b.movie.title)
      default:
        return 0
    }
  })

  return results
})

const loadMore = async () => {
  if (loading.value) return
  
  loading.value = true
  try {
    await store.fetchRecommendations(currentPage.value + 1)
    currentPage.value++
  } finally {
    loading.value = false
  }
}

const handleFeedback = async (feedback) => {
  try {
    await store.submitFeedback(feedback.movieId, feedback)
    // Opcionalmente, actualizar las recomendaciones
    if (feedback.liked) {
      // Cargar recomendaciones similares
      await store.fetchSimilarRecommendations(feedback.movieId)
    }
  } catch (error) {
    console.error('Error submitting feedback:', error)
  }
}

// Observar cambios en los filtros
watch(filters, () => {
  currentPage.value = 1
  store.resetRecommendations()
  store.fetchRecommendations(1, {
    genre: filters.value.genre,
    yearFrom: filters.value.yearFrom,
    yearTo: filters.value.yearTo,
    sortBy: filters.value.sortBy
  })
}, { deep: true })

// Inicializar
onMounted(async () => {
  if (!store.recommendations.length) {
    loading.value = true
    try {
      await store.fetchRecommendations(1)
    } finally {
      loading.value = false
    }
  }
})

// Scroll infinito
const handleScroll = () => {
  const scrollPosition = window.innerHeight + window.scrollY
  const pageEnd = document.documentElement.offsetHeight - 500

  if (scrollPosition >= pageEnd && !loading.value && store.hasMore) {
    loadMore()
  }
}

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

// Exportar propiedades y métodos necesarios para el template
defineExpose({
  loading,
  currentPage,
  filters,
  filteredRecommendations,
  availableGenres,
  currentYear,
  handleFeedback,
  loadMore
})
</script>

<template>
  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
    <!-- Filtros -->
    <div class="mb-8 bg-white p-4 rounded-lg shadow">
      <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
        <!-- Filtro por Género -->
        <div>
          <label class="block text-sm font-medium text-gray-700">Género</label>
          <select
            v-model="filters.genre"
            class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500"
          >
            <option value="">Todos los géneros</option>
            <option
              v-for="genre in availableGenres"
              :key="genre"
              :value="genre"
            >
              {{ genre }}
            </option>
          </select>
        </div>

        <!-- Filtro por Año -->
        <div>
          <label class="block text-sm font-medium text-gray-700">Año</label>
          <div class="flex space-x-2">
            <input
              v-model.number="filters.yearFrom"
              type="number"
              min="1900"
              :max="currentYear"
              placeholder="Desde"
              class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500"
            >
            <input
              v-model.number="filters.yearTo"
              type="number"
              min="1900"
              :max="currentYear"
              placeholder="Hasta"
              class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500"
            >
          </div>
        </div>

        <!-- Ordenar Por -->
        <div>
          <label class="block text-sm font-medium text-gray-700">Ordenar Por</label>
          <select
            v-model="filters.sortBy"
            class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500"
          >
            <option value="score">Relevancia</option>
            <option value="year">Año</option>
            <option value="title">Título</option>
          </select>
        </div>
      </div>

      <!-- Filtros Activos -->
      <div v-if="hasActiveFilters" class="mt-4 flex flex-wrap gap-2">
        <div
          v-for="(filter, index) in activeFilters"
          :key="index"
          class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800"
        >
          {{ filter.label }}
          <button
            @click="removeFilter(filter.type)"
            class="ml-2 text-blue-600 hover:text-blue-800 focus:outline-none"
          >
            ×
          </button>
        </div>
      </div>
    </div>

    <!-- Lista de Recomendaciones -->
    <div
      v-if="loading && !filteredRecommendations.length"
      class="flex justify-center items-center h-64"
    >
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
    </div>

    <div
      v-else-if="filteredRecommendations.length"
      class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"
    >
      <MovieCard
        v-for="recommendation in filteredRecommendations"
        :key="recommendation.movie.id"
        :movie="recommendation.movie"
        :score="recommendation.score"
        :reasoning="recommendation.reasoning"
        @feedback="handleFeedback"
      />
    </div>

    <div
      v-else
      class="text-center py-12"
    >
      <p class="text-gray-500 text-lg">
        No se encontraron recomendaciones con los filtros actuales
      </p>
      <button
        @click="resetFilters"
        class="mt-4 text-blue-600 hover:text-blue-800"
      >
        Limpiar filtros
      </button>
    </div>

    <!-- Loading More -->
    <div v-if="loading && filteredRecommendations.length" class="mt-8 text-center">
      <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
    </div>

    <!-- Load More Button -->
    <div
      v-if="!loading && store.hasMore"
      class="mt-8 flex justify-center"
    >
      <button
        @click="loadMore"
        class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
      >
        Cargar Más
      </button>
    </div>
  </div>
</template>

<style scoped>
.grid {
  @apply transition-all duration-300;
}

.movie-card-enter-active,
.movie-card-leave-active {
  transition: all 0.3s ease;
}

.movie-card-enter-from,
.movie-card-leave-to {
  opacity: 0;
  transform: translateY(20px);
}
</style>

Continuaré con la parte faltante de la Etapa 5, completando los componentes frontend y agregando la lógica de interacción.

javascript
// frontend/src/stores/recommendations.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './user'
import api from '@/api'

export const useRecommendationStore = defineStore('recommendations', () => {
  const userStore = useUserStore()
  const recommendations = ref([])
  const loading = ref(false)
  const error = ref(null)
  const currentPage = ref(1)
  const hasMore = ref(true)

  const fetchRecommendations = async (page = 1) => {
    if (loading.value) return
    
    loading.value = true
    error.value = null
    
    try {
      const response = await api.get('/recommendations', {
        params: {
          page,
          limit: 12,
          userId: userStore.currentUser?.id
        }
      })
      
      if (page === 1) {
        recommendations.value = response.data
      } else {
        recommendations.value = [...recommendations.value, ...response.data]
      }
      
      hasMore.value = response.data.length === 12
      currentPage.value = page
      
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  const submitFeedback = async (movieId, feedback) => {
    try {
      await api.post(`/recommendations/${movieId}/feedback`, feedback)
      
      // Actualizar recomendación localmente
      const index = recommendations.value.findIndex(
        r => r.movie.id === movieId
      )
      
      if (index !== -1) {
        recommendations.value[index] = {
          ...recommendations.value[index],
          userFeedback: feedback
        }
      }
      
    } catch (err) {
      error.value = err.message
    }
  }

  const filtered = computed(() => {
    return recommendations.value.filter(rec => !rec.userFeedback?.disliked)
  })

  return {
    recommendations: filtered,
    loading,
    error,
    currentPage,
    hasMore,
    fetchRecommendations,
    submitFeedback
  }
})

// frontend/src/components/Recommendations/FiltersPanel.vue
<template>
  <div class="bg-white p-4 rounded-lg shadow-sm">
    <div class="flex flex-col space-y-4">
      <!-- Barra de Búsqueda -->
      <div>
        <input
          type="text"
          v-model="searchQuery"
          placeholder="Buscar películas..."
          class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
          @input="debounceSearch"
        />
      </div>

      <!-- Filtros Avanzados -->
      <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
        <!-- Selector de Década -->
        <div>
          <label class="block text-sm font-medium text-gray-700 mb-1">
            Década
          </label>
          <select
            v-model="selectedDecade"
            class="w-full rounded-md border-gray-300 shadow-sm"
          >
            <option value="">Todas las décadas</option>
            <option
              v-for="decade in decades"
              :key="decade"
              :value="decade"
            >
              {{ decade }}s
            </option>
          </select>
        </div>

        <!-- Rating Mínimo -->
        <div>
          <label class="block text-sm font-medium text-gray-700 mb-1">
            Rating Mínimo
          </label>
          <div class="flex items-center space-x-2">
            <input
              type="range"
              v-model.number="minRating"
              min="0"
              max="10"
              step="0.5"
              class="flex-1"
            />
            <span class="text-sm text-gray-600">{{ minRating }}</span>
          </div>
        </div>

        <!-- Duración -->
        <div>
          <label class="block text-sm font-medium text-gray-700 mb-1">
            Duración
          </label>
          <select
            v-model="duration"
            class="w-full rounded-md border-gray-300 shadow-sm"
          >
            <option value="">Cualquier duración</option>
            <option value="short">Menos de 90 min</option>
            <option value="medium">90-120 min</option>
            <option value="long">Más de 120 min</option>
          </select>
        </div>
      </div>

      <!-- Tags de Filtros Activos -->
      <div v-if="hasActiveFilters" class="flex flex-wrap gap-2">
        <div
          v-for="(filter, index) in activeFilters"
          :key="index"
          class="inline-flex items-center px-2 py-1 rounded-full text-sm bg-blue-100 text-blue-800"
        >
          {{ filter.label }}
          <button
            @click="removeFilter(filter.type)"
            class="ml-1 focus:outline-none"
          >
            <span class="sr-only">Remover filtro</span>
            ×
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import debounce from 'lodash/debounce'

const emit = defineEmits(['update:filters'])

const searchQuery = ref('')
const selectedDecade = ref('')
const minRating = ref(0)
const duration = ref('')

const decades = computed(() => {
  const currentYear = new Date().getFullYear()
  const currentDecade = Math.floor(currentYear / 10) * 10
  const decades = []
  for (let decade = 1920; decade <= currentDecade; decade += 10) {
    decades.push(decade)
  }
  return decades.reverse()
})

const debounceSearch = debounce(() => {
  updateFilters()
}, 300)

const updateFilters = () => {
  emit('update:filters', {
    search: searchQuery.value,
    decade: selectedDecade.value,
    minRating: minRating.value,
    duration: duration.value
  })
}

const hasActiveFilters = computed(() => {
  return searchQuery.value || 
         selectedDecade.value || 
         minRating.value > 0 || 
         duration.value
})

const activeFilters = computed(() => {
  const filters = []
  
  if (searchQuery.value) {
    filters.push({
      type: 'search',
      label: `Búsqueda: ${searchQuery.value}`
    })
  }
  
  if (selectedDecade.value) {
    filters.push({
      type: 'decade',
      label: `Década: ${selectedDecade.value}s`
    })
  }
  
  if (minRating.value > 0) {
    filters.push({
      type: 'rating',
      label: `Rating ≥ ${minRating.value}`
    })
  }
  
  if (duration.value) {
    const durationLabels = {
      short: 'Menos de 90 min',
      medium: '90-120 min',
      long: 'Más de 120 min'
    }
    filters.push({
      type: 'duration',
      label: `Duración: ${durationLabels[duration.value]}`
    })
  }
  
  return filters
})

const removeFilter = (type) => {
  switch (type) {
    case 'search':
      searchQuery.value = ''
      break
    case 'decade':
      selectedDecade.value = ''
      break
    case 'rating':
      minRating.value = 0
      break
    case 'duration':
      duration.value = ''
      break
  }
  updateFilters()
}
</script>

// frontend/src/views/Recommendations.vue
<template>
  <div class="min-h-screen bg-gray-100">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <h1 class="text-3xl font-bold text-gray-900 mb-8">
        Recomendaciones Personalizadas
      </h1>

      <FiltersPanel
        v-model:filters="filters"
        class="mb-8"
      />

      <div v-if="store.loading && !store.recommendations.length" 
           class="flex justify-center py-12">
        <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
      </div>

      <template v-else>
        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
          <MovieCard
            v-for="recommendation in filteredRecommendations"
            :key="recommendation.movie.id"
            :movie="recommendation.movie"
            :score="recommendation.score"
            :reasoning="recommendation.reasoning"
            @feedback="handleFeedback"
          />
        </div>

        <div v-if="store.hasMore" class="mt-8 flex justify-center">
          <button
            @click="loadMore"
            class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
            :disabled="store.loading"
          >
            {{ store.loading ? 'Cargando...' : 'Cargar Más' }}
          </button>
        </div>

        <div v-else-if="!filteredRecommendations.length" class="text-center py-12">
          <p class="text-gray-500">
            No se encontraron películas que coincidan con los filtros seleccionados.
          </p>
        </div>
      </template>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRecommendationStore } from '@/stores/recommendations'
import FiltersPanel from '@/components/Recommendations/FiltersPanel.vue'
import MovieCard from '@/components/Recommendations/MovieCard.vue'

const store = useRecommendationStore()
const filters = ref({
  search: '',
  decade: '',
  minRating: 0,
  duration: ''
})

const filteredRecommendations = computed(() => {
  return store.recommendations.filter(rec => {
    const movie = rec.movie
    
    // Búsqueda por texto
    if (filters.value.search) {
      const searchTerm = filters.value.search.toLowerCase()
      const matchesSearch = movie.title.toLowerCase().includes(searchTerm) ||
                          movie.plot?.toLowerCase().includes(searchTerm)
      if (!matchesSearch) return false
    }
    
    // Filtro por década
    if (filters.value.decade) {
      const movieDecade = Math.floor(movie.year / 10) * 10
      if (movieDecade !== parseInt(filters.value.decade)) return false
    }
    
    // Filtro por rating
    if (filters.value.minRating > 0 && movie.rating < filters.value.minRating) {
      return false
    }
    
    // Filtro por duración
    if (filters.value.duration) {
      const duration = movie.duration || 0
      switch (filters.value.duration) {
        case 'short':
          if (duration >= 90) return false
          break
        case 'medium':
          if (duration < 90 || duration > 120) return false
          break
        case 'long':
          if (duration <= 120) return false
          break
      }
    }
    
    return true
  })
})

const handleFeedback = async (feedback) => {
  await store.submitFeedback(feedback.movieId, {
    liked: feedback.liked,
    timestamp: feedback.timestamp
  })
}

const loadMore = () => {
  store.fetchRecommendations(store.currentPage + 1)
}

onMounted(() => {
  store.fetchRecommendations()
})
</script>

Esta parte completa la Etapa 5 con:

  1. Gestión de Estado:

    • Store de recomendaciones
    • Caché de datos
    • Paginación
    • Manejo de errores
  2. Filtros Avanzados:

    • Búsqueda por texto
    • Filtros por década
    • Rating mínimo
    • Duración de películas
  3. Interacción de Usuario:

    • Feedback en tiempo real
    • Carga infinita
    • Tags de filtros activos
    • Animaciones y transiciones

Las características principales son:

  1. UX Optimizada:

    • Interfaz responsiva
    • Feedback visual
    • Loading states
    • Error handling
  2. Filtrado Eficiente:

    • Filtros combinados
    • Búsqueda debounced
    • Caché de resultados
    • Actualización reactiva
  3. Componentes Reutilizables:

    • MovieCard
    • FiltersPanel
    • Loading states
    • Error states

Etapa 6: Análisis y Reporting, implementando el dashboard de análisis y los reportes.

python
# backend/app/services/analytics_service.py
from datetime import datetime, timedelta
from typing import Dict, Any, List
import pandas as pd
import numpy as np
from app.models.recommendation import RecommendationInDB
from app.repositories.recommendation_repository import RecommendationRepository

class AnalyticsService:
    def __init__(
        self,
        recommendation_repository: RecommendationRepository
    ):
        self.recommendation_repo = recommendation_repository

    async def generate_user_insights(
        self,
        user_id: str,
        days: int = 30
    ) -> Dict[str, Any]:
        """Genera insights personalizados para un usuario."""
        try:
            # Obtener historial de recomendaciones
            recommendations = await self.recommendation_repo.get_user_recommendations(
                user_id,
                start_date=datetime.now() - timedelta(days=days)
            )

            return {
                "engagement_metrics": await self._calculate_engagement_metrics(recommendations),
                "genre_preferences": self._analyze_genre_preferences(recommendations),
                "temporal_patterns": self._analyze_temporal_patterns(recommendations),
                "content_diversity": self._analyze_content_diversity(recommendations),
                "satisfaction_score": self._calculate_satisfaction_score(recommendations)
            }

        except Exception as e:
            logger.error(f"Error generating user insights: {str(e)}")
            raise

    async def generate_system_metrics(
        self,
        start_date: datetime,
        end_date: datetime
    ) -> Dict[str, Any]:
        """Genera métricas globales del sistema."""
        try:
            all_recommendations = await self.recommendation_repo.get_recommendations_by_date_range(
                start_date,
                end_date
            )

            return {
                "system_performance": self._analyze_system_performance(all_recommendations),
                "popularity_trends": self._analyze_popularity_trends(all_recommendations),
                "recommendation_quality": self._analyze_recommendation_quality(all_recommendations),
                "user_segments": await self._analyze_user_segments(all_recommendations)
            }

        except Exception as e:
            logger.error(f"Error generating system metrics: {str(e)}")
            raise

    def _calculate_engagement_metrics(
        self,
        recommendations: List[RecommendationInDB]
    ) -> Dict[str, Any]:
        if not recommendations:
            return {
                "interaction_rate": 0,
                "acceptance_rate": 0,
                "feedback_quality": 0
            }

        total_recs = len(recommendations)
        interactions = sum(1 for r in recommendations if r.feedback is not None)
        accepted = sum(1 for r in recommendations if r.accepted)
        
        detailed_feedback = sum(
            1 for r in recommendations if r.feedback and len(r.feedback) > 10
        )

        return {
            "interaction_rate": interactions / total_recs,
            "acceptance_rate": accepted / total_recs if total_recs > 0 else 0,
            "feedback_quality": detailed_feedback / interactions if interactions > 0 else 0,
            "total_recommendations": total_recs,
            "total_interactions": interactions,
            "total_accepted": accepted
        }

    def _analyze_genre_preferences(
        self,
        recommendations: List[RecommendationInDB]
    ) -> Dict[str, Any]:
        genre_interactions = {}
        genre_success = {}

        for rec in recommendations:
            for genre in rec.movie.genres:
                if rec.feedback is not None:
                    genre_interactions[genre] = genre_interactions.get(genre, 0) + 1
                    if rec.accepted:
                        genre_success[genre] = genre_success.get(genre, 0) + 1

        genre_scores = {}
        for genre in genre_interactions:
            interactions = genre_interactions[genre]
            successes = genre_success.get(genre, 0)
            score = (successes / interactions) if interactions > 0 else 0
            genre_scores[genre] = {
                "score": score,
                "interactions": interactions,
                "successes": successes
            }

        return {
            "genre_scores": genre_scores,
            "top_genres": sorted(
                genre_scores.items(),
                key=lambda x: x[1]["score"],
                reverse=True
            )[:5],
            "genre_diversity": len(genre_scores) / len(recommendations) if recommendations else 0
        }

    def _analyze_temporal_patterns(
        self,
        recommendations: List[RecommendationInDB]
    ) -> Dict[str, Any]:
        if not recommendations:
            return {
                "hourly_pattern": {},
                "daily_pattern": {},
                "peak_hours": []
            }

        # Convertir a DataFrame para análisis temporal
        df = pd.DataFrame([
            {
                'timestamp': r.created_at,
                'accepted': r.accepted,
                'hour': r.created_at.hour,
                'day': r.created_at.strftime('%A')
            }
            for r in recommendations
        ])

        # Patrones por hora
        hourly_pattern = df.groupby('hour')['accepted'].agg(['count', 'mean'])
        
        # Patrones por día
        daily_pattern = df.groupby('day')['accepted'].agg(['count', 'mean'])
        
        # Identificar horas pico
        peak_hours = hourly_pattern[
            hourly_pattern['count'] > hourly_pattern['count'].mean()
        ].index.tolist()

        return {
            "hourly_pattern": hourly_pattern.to_dict('index'),
            "daily_pattern": daily_pattern.to_dict('index'),
            "peak_hours": peak_hours,
            "best_time": {
                "hour": hourly_pattern['mean'].idxmax(),
                "day": daily_pattern['mean'].idxmax()
            }
        }

    def _analyze_content_diversity(
        self,
        recommendations: List[RecommendationInDB]
    ) -> Dict[str, Any]:
        if not recommendations:
            return {
                "genre_entropy": 0,
                "temporal_spread": 0,
                "uniqueness_score": 0
            }

        # Calcular entropía de géneros
        genres_count = {}
        years_list = []
        
        for rec in recommendations:
            years_list.append(rec.movie.year)
            for genre in rec.movie.genres:
                genres_count[genre] = genres_count.get(genre, 0) + 1

        total_genres = sum(genres_count.values())
        genre_entropy = -sum(
            (count/total_genres) * np.log2(count/total_genres)
            for count in genres_count.values()
        )

        # Calcular dispersión temporal
        years = np.array(years_list)
        temporal_spread = np.std(years) if len(years) > 1 else 0

        # Calcular unicidad
        unique_movies = len(set(r.movie.id for r in recommendations))
        uniqueness_score = unique_movies / len(recommendations)

        return {
            "genre_entropy": genre_entropy,
            "temporal_spread": float(temporal_spread),
            "uniqueness_score": uniqueness_score,
            "genre_distribution": genres_count,
            "year_distribution": {
                "min": int(min(years)),
                "max": int(max(years)),
                "mean": float(np.mean(years)),
                "std": float(np.std(years)) if len(years) > 1 else 0
            }
        }

    def _calculate_satisfaction_score(
        self,
        recommendations: List[RecommendationInDB]
    ) -> float:
        if not recommendations:
            return 0.0

        weights = {
            'acceptance': 0.4,
            'engagement': 0.3,
            'diversity': 0.3
        }

        # Calcular componentes del score
        accepted = sum(1 for r in recommendations if r.accepted)
        total = len(recommendations)
        acceptance_score = accepted / total if total > 0 else 0

        engagement_score = sum(
            1 for r in recommendations if r.feedback is not None
        ) / total if total > 0 else 0

        diversity_metrics = self._analyze_content_diversity(recommendations)
        diversity_score = (
            diversity_metrics['genre_entropy'] * 0.5 +
            diversity_metrics['uniqueness_score'] * 0.5
        )

        # Calcular score final ponderado
        satisfaction_score = (
            acceptance_score * weights['acceptance'] +
            engagement_score * weights['engagement'] +
            diversity_score * weights['diversity']
        )

        return float(satisfaction_score)

    async def generate_user_report(
        self,
        user_id: str,
        days: int = 30
    ) -> Dict[str, Any]:
        """Genera un reporte detallado para un usuario específico."""
        insights = await self.generate_user_insights(user_id, days)
        
        return {
            "user_id": user_id,
            "report_period": {
                "start": (datetime.now() - timedelta(days=days)).isoformat(),
                "end": datetime.now().isoformat(),
                "days": days
            },
            "summary": {
                "total_recommendations": insights["engagement_metrics"]["total_recommendations"],
                "interaction_rate": insights["engagement_metrics"]["interaction_rate"],
                "satisfaction_score": insights["satisfaction_score"]
            },
            "detailed_metrics": insights,
            "recommendations": {
                "genre_based": self._generate_genre_recommendations(insights),
                "temporal_based": self._generate_temporal_recommendations(insights)
            }
        }

    def _generate_genre_recommendations(
        self,
        insights: Dict[str, Any]
    ) -> List[str]:
        """Genera recomendaciones basadas en preferencias de género."""
        top_genres = insights["genre_preferences"]["top_genres"]
        recommendations = []

        if len(top_genres) >= 3:
            recommendations.append(
                f"Tus géneros favoritos son {', '.join([g[0] for g in top_genres[:3]])}. "
                "Continuaremos priorizando contenido similar."
            )

        low_interaction_genres = [
            genre for genre, data in insights["genre_preferences"]["genre_scores"].items()
            if data["interactions"] < 5
        ]

        if low_interaction_genres:
            recommendations.append(
                f"Podrías explorar más contenido de los géneros: "
                f"{', '.join(low_interaction_genres[:3])}"
            )

        return recommendations

    def _generate_temporal_recommendations(
        self,
        insights: Dict[str, Any]
    ) -> List[str]:
        """Genera recomendaciones basadas en patrones temporales."""
        temporal = insights["temporal_patterns"]
        recommendations = []

        if temporal["best_time"]["hour"] is not None:
            hour = temporal["best_time"]["hour"]
            recommendations.append(
                f"Tu mejor hora para ver recomendaciones es alrededor de las {hour}:00"
            )

        if temporal["best_time"]["day"]:
            day = temporal["best_time"]["day"]
            recommendations.append(
                f"Tiendes a interactuar más con las recomendaciones los {day}"
            )

        return recommendations

# backend/app/services/report_generator.py
class ReportGenerator:
    def __init__(self, analytics_service: AnalyticsService):
        self.analytics = analytics_service

    async def generate_pdf_report(
        self,
        user_id: str,
        days: int = 30
    ) -> bytes:
        """Genera un reporte PDF con las métricas y recomendaciones."""
        try:
            report_data = await self.analytics.generate_user_report(user_id, days)
            
            # Implementar generación de PDF con ReportLab o WeasyPrint
            # Retornar bytes del PDF generado
            
        except Exception as e:
            logger.error(f"Error generating PDF report: {str(e)}")
            raise

    async def generate_excel_report(
        self,
        user_id: str,
        days: int = 30
    ) -> bytes:
        """Genera un reporte Excel con las métricas y recomendaciones."""
        try:
            report_data = await self.analytics.generate_user_report(user_id, days)
            
            # Implementar generación de Excel
            # Retornar bytes del Excel generado
            
        except Exception as e:
            logger.error(f"Error generating Excel report: {str(e)}")
            raise

Esta sexta etapa implementa:

  1. Análisis Detallado:

    • Métricas de engagement
    • Preferencias de género
    • Patrones temporales
    • Diversidad de contenido
  2. Generación de Insights:

    • Recomendaciones personalizadas
    • Análisis de tendencias
    • Patrones de uso
    • Métricas de satisfacción
  3. Reportes:

    • Exportación PDF
    • Reportes Excel
    • Visualizaciones
    • Métricas clave

Características principales:

  1. Análisis Profundo:

    • Múltiples dimensiones
    • Métricas avanzadas
    • Patrones de comportamiento
    • Segmentación
  2. Reportes Personalizados:

    • Formatos múltiples
    • Visualizaciones claras
    • Recomendaciones accionables
    • Métricas relevantes
  3. Optimización:

    • Caché de análisis
    • Procesamiento asíncrono
    • Agregación eficiente
    • Insights en tiempo real