import pandas as pd
import re
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import dash
import dash_bootstrap_components as dbc
from dash import dcc, html, Input, Output, State, ALL, ctx
# --- Load and Preprocess Data ---
df = pd.read_csv("pycafe_bookshelf.csv")
# Ensure 'num_pages' is numeric and handle missing values
df['num_pages'] = pd.to_numeric(df['num_pages'], errors='coerce').fillna(0)
# Text cleaning for descriptions
def clean_text(text):
if pd.isna(text):
return ""
text = re.sub(r'<.*?>', '', text) # Remove HTML tags
text = re.sub(r'\n+', ' ', text) # Replace multiple newlines with a single space
text = re.sub(r'[^\w\s]', ' ', text) # Remove non-alphanumeric characters (except spaces)
text = re.sub(r'\s+', ' ', text) # Replace multiple spaces with a single space
return text.lower().strip()
df['clean_description'] = df['description'].apply(clean_text)
# TF-IDF Vectorization
vectorizer = TfidfVectorizer(
max_features=4000,
ngram_range=(1, 3),
stop_words='english',
min_df=3,
max_df=0.7,
sublinear_tf=True
)
tfidf_matrix = vectorizer.fit_transform(df['clean_description'])
feature_names = vectorizer.get_feature_names_out()
# --- INNOVATIVE READING DNA ANALYSIS SYSTEM ---
class ReadingDNAAnalyzer:
def __init__(self, df, tfidf_matrix, vectorizer):
self.df = df
self.tfidf_matrix = tfidf_matrix
self.vectorizer = vectorizer
def analyze_reading_dna(self, selected_vibes, selected_elementos, selected_depths):
"""Innovative analysis that creates a 'Reading DNA' profile"""
dna_analysis = {
'personality_type': self.determine_reader_personality(selected_vibes, selected_elementos, selected_depths),
'rarity_index': self.calculate_taste_rarity(selected_vibes, selected_elementos, selected_depths),
'discovery_potential': self.predict_discovery_potential(selected_vibes, selected_elementos, selected_depths),
'genre_crossover': self.analyze_genre_crossover_potential(selected_vibes, selected_elementos, selected_depths),
'reading_mood_map': self.create_mood_mapping(selected_vibes, selected_elementos, selected_depths)
}
return dna_analysis
def determine_reader_personality(self, vibes, elementos, depths):
"""Determine what type of reader you are based on selections"""
personality_indicators = {
'vibes': {
'Mysterious yet Cozy': ['introspective', 'comfort_seeker'],
'Tense yet Amusing': ['thrill_seeker', 'humor_lover'],
'Profound yet Approachable': ['wisdom_seeker', 'accessible_learner'],
'Intense yet Hopeful': ['emotional_processor', 'optimist'],
'Romantic yet Realistic': ['relationship_focused', 'authenticity_seeker'],
'Fantastic yet Believable': ['imagination_balanced', 'grounded_dreamer']
},
'elementos': {
'Clever Dialogues': ['conversation_lover', 'wit_appreciator'],
'Unfolding Mysteries': ['puzzle_solver', 'patience_rewarded'],
'Complex Characters': ['psychology_interested', 'depth_seeker'],
'Immersive Worlds': ['escapist', 'detail_oriented'],
'Unexpected Twists': ['surprise_lover', 'flexible_mindset'],
'Authentic Emotions': ['empathy_driven', 'emotional_intelligence']
},
'depths': {
'Quietly Profound': ['contemplative_soul', 'subtle_appreciator'],
'Layered Storytelling': ['analytical_reader', 'meaning_seeker'],
'Understated Intensity': ['sophisticated_taste', 'nuance_detector'],
'Philosophical Undertones': ['deep_thinker', 'question_asker'],
'Psychological Nuance': ['mind_explorer', 'complexity_lover'],
'Subtle Social Commentary': ['society_observer', 'critical_thinker']
}
}
# Collect all traits
traits = []
for vibe in vibes:
traits.extend(personality_indicators['vibes'].get(vibe, []))
for elemento in elementos:
traits.extend(personality_indicators['elementos'].get(elemento, []))
for depth in depths:
traits.extend(personality_indicators['depths'].get(depth, []))
# Determine primary personality type
trait_counts = {}
for trait in traits:
trait_counts[trait] = trait_counts.get(trait, 0) + 1
if not trait_counts:
return {'type': 'Curious Explorer 🧭', 'description': 'Open to all reading adventures!', 'confidence': 50}
dominant_traits = sorted(trait_counts.items(), key=lambda x: x[1], reverse=True)[:3]
# Map combinations to personality types
personality_types = {
'introspective': 'The Contemplative Reader 🧘',
'thrill_seeker': 'The Adrenaline Reader ⚡',
'wisdom_seeker': 'The Philosophical Explorer 🎯',
'emotional_processor': 'The Heart-Centered Reader 💝',
'conversation_lover': 'The Social Intellect 🗣️',
'puzzle_solver': 'The Literary Detective 🔍',
'psychology_interested': 'The Human Nature Scholar 🧠',
'escapist': 'The World Traveler 🌍',
'contemplative_soul': 'The Quiet Wisdom Seeker 🕯️',
'deep_thinker': 'The Philosophy Explorer 💭'
}
primary_trait = dominant_traits[0][0]
reader_type = personality_types.get(primary_trait, 'The Unique Reader 🦄')
return {
'type': reader_type,
'dominant_traits': [trait[0] for trait in dominant_traits],
'confidence': min(100, dominant_traits[0][1] * 25)
}
def calculate_taste_rarity(self, vibes, elementos, depths):
"""Calculate how unique/rare the user's taste is"""
all_matches = []
total_selections = len(vibes) + len(elementos) + len(depths)
if total_selections == 0:
return {'rarity': 'Explorer', 'percentile': 50, 'description': 'Charting new territories!'}
# Simulate pattern matching (simplified for this example)
patterns_dict = {
'Mysterious yet Cozy': 0.15, 'Tense yet Amusing': 0.25, 'Profound yet Approachable': 0.12,
'Intense yet Hopeful': 0.20, 'Romantic yet Realistic': 0.30, 'Fantastic yet Believable': 0.18,
'Clever Dialogues': 0.22, 'Unfolding Mysteries': 0.28, 'Complex Characters': 0.16,
'Immersive Worlds': 0.35, 'Unexpected Twists': 0.32, 'Authentic Emotions': 0.24,
'Quietly Profound': 0.08, 'Layered Storytelling': 0.10, 'Understated Intensity': 0.06,
'Philosophical Undertones': 0.12, 'Psychological Nuance': 0.14, 'Subtle Social Commentary': 0.09
}
for selection in vibes + elementos + depths:
match_rate = patterns_dict.get(selection, 0.15)
matches = int(len(self.df) * match_rate)
all_matches.append(matches)
if not all_matches:
return {'rarity': 'Explorer', 'percentile': 50, 'description': 'Charting new territories!'}
avg_matches = np.mean(all_matches)
total_books = len(self.df)
rarity_score = (total_books - avg_matches) / total_books * 100
if rarity_score > 85:
return {'rarity': 'Ultra Rare Unicorn 🦄', 'percentile': int(rarity_score),
'description': 'You have exquisitely unique taste! Only a few books will truly captivate you.'}
elif rarity_score > 70:
return {'rarity': 'Sophisticated Connoisseur 🍷', 'percentile': int(rarity_score),
'description': 'You appreciate the finer nuances that most readers miss.'}
elif rarity_score > 50:
return {'rarity': 'Discerning Reader 🎭', 'percentile': int(rarity_score),
'description': 'You know what you like and have refined preferences.'}
elif rarity_score > 30:
return {'rarity': 'Popular Taste 📈', 'percentile': int(rarity_score),
'description': 'You enjoy books that resonate with many readers.'}
else:
return {'rarity': 'Universal Appeal 🌟', 'percentile': int(rarity_score),
'description': 'You love the classics and crowd favorites!'}
def predict_discovery_potential(self, vibes, elementos, depths):
"""Predict likelihood of discovering hidden gems vs popular hits"""
discovery_indicators = {
'hidden_gem_signals': ['Quietly Profound', 'Understated Intensity', 'Mysterious yet Cozy', 'Complex Characters'],
'mainstream_signals': ['Tense yet Amusing', 'Unexpected Twists', 'Intense yet Hopeful', 'Immersive Worlds'],
'literary_fiction_signals': ['Philosophical Undertones', 'Subtle Social Commentary', 'Layered Storytelling'],
'genre_blend_signals': ['Fantastic yet Believable', 'Romantic yet Realistic']
}
all_selections = vibes + elementos + depths
hidden_gems = sum(1 for s in all_selections if s in discovery_indicators['hidden_gem_signals'])
mainstream = sum(1 for s in all_selections if s in discovery_indicators['mainstream_signals'])
literary = sum(1 for s in all_selections if s in discovery_indicators['literary_fiction_signals'])
genre_blend = sum(1 for s in all_selections if s in discovery_indicators['genre_blend_signals'])
total = len(all_selections)
if total == 0:
return {'type': 'Open Explorer', 'likelihood': 50, 'description': 'Ready for any adventure!', 'prediction': 'Expect variety!'}
if hidden_gems / total > 0.5:
return {
'type': 'Hidden Gem Hunter 💎',
'likelihood': 85,
'description': 'You\'re likely to discover underrated masterpieces that others overlook.',
'prediction': 'Expect to find 2-3 books under 1000 ratings that will become your new favorites!'
}
elif literary / total > 0.4:
return {
'type': 'Literary Archaeologist 📚',
'likelihood': 75,
'description': 'You dig deep into meaningful, thought-provoking literature.',
'prediction': 'Award-winning literary fiction and philosophical novels await you.'
}
elif mainstream / total > 0.6:
return {
'type': 'Crowd Pleaser Finder 🎯',
'likelihood': 60,
'description': 'You enjoy books that have wide appeal and proven track records.',
'prediction': 'Bestsellers and highly-rated popular fiction will dominate your list.'
}
else:
return {
'type': 'Genre Boundary Crosser 🌈',
'likelihood': 70,
'description': 'You blend different styles and discover unique cross-genre gems.',
'prediction': 'Expect surprising combinations that defy traditional categorization!'
}
def analyze_genre_crossover_potential(self, vibes, elementos, depths):
"""Analyze how likely the user is to enjoy books outside their comfort zone"""
flexibility_scores = {
'Fantastic yet Believable': 0.9, 'Profound yet Approachable': 0.8, 'Romantic yet Realistic': 0.7,
'Complex Characters': 0.8, 'Immersive Worlds': 0.9, 'Layered Storytelling': 0.9,
'Philosophical Undertones': 0.7, 'Psychological Nuance': 0.8
}
all_selections = vibes + elementos + depths
if not all_selections:
return {'flexibility': 'Unknown Explorer', 'score': 50, 'description': 'Ready to explore!', 'recommendation': 'Try anything!'}
scores = [flexibility_scores.get(selection, 0.5) for selection in all_selections]
avg_flexibility = np.mean(scores)
if avg_flexibility > 0.8:
return {
'flexibility': 'Genre Shapeshifter 🦋',
'score': int(avg_flexibility * 100),
'description': 'You easily move between genres and find gems everywhere!',
'recommendation': 'Try books tagged with multiple genres - you\'ll love the variety!'
}
elif avg_flexibility > 0.6:
return {
'flexibility': 'Adventurous Reader 🎢',
'score': int(avg_flexibility * 100),
'description': 'You\'re open to exploring beyond your comfort zone.',
'recommendation': 'Mix in 1-2 books from unfamiliar genres for delightful surprises!'
}
else:
return {
'flexibility': 'Comfort Zone Dweller 🏠',
'score': int(avg_flexibility * 100),
'description': 'You prefer to stick with what you know you\'ll love.',
'recommendation': 'That\'s perfectly fine! We\'ll find the best within your preferred style.'
}
def create_mood_mapping(self, vibes, elementos, depths):
"""Create a 'mood map' showing what emotional states these books serve"""
mood_mappings = {
'Mysterious yet Cozy': ['contemplative_evening', 'rainy_afternoon', 'bedtime_reading'],
'Tense yet Amusing': ['commute_entertainment', 'weekend_adventure', 'stress_relief'],
'Profound yet Approachable': ['personal_growth', 'quiet_morning', 'life_transition'],
'Intense yet Hopeful': ['emotional_processing', 'need_inspiration', 'difficult_times'],
'Romantic yet Realistic': ['relationship_reflection', 'emotional_connection', 'heart_warming'],
'Fantastic yet Believable': ['imagination_escape', 'wonder_seeking', 'creative_inspiration'],
'Clever Dialogues': ['intellectual_stimulation', 'social_energy', 'wit_appreciation'],
'Unfolding Mysteries': ['puzzle_solving_mood', 'patient_discovery', 'mental_engagement'],
'Complex Characters': ['human_understanding', 'empathy_building', 'psychological_insight'],
'Immersive Worlds': ['complete_escape', 'world_building_love', 'transportation_need'],
'Unexpected Twists': ['surprise_seeking', 'mind_bending', 'predictability_escape'],
'Authentic Emotions': ['emotional_validation', 'feeling_connection', 'heart_opening']
}
all_moods = []
for selection in vibes + elementos + depths:
all_moods.extend(mood_mappings.get(selection, []))
mood_counts = {}
for mood in all_moods:
mood_counts[mood] = mood_counts.get(mood, 0) + 1
if mood_counts:
top_moods = sorted(mood_counts.items(), key=lambda x: x[1], reverse=True)[:3]
return {
'primary_moods': [mood[0].replace('_', ' ').title() for mood in top_moods],
'mood_versatility': len(set(all_moods)),
'description': f'Your books serve {len(set(all_moods))} different emotional needs!'
}
else:
return {'primary_moods': ['Exploration'], 'mood_versatility': 1, 'description': 'Ready for any reading adventure!'}
# --- Recommendation Logic Class ---
class IntuitiveBookFinder:
def __init__(self, df, tfidf_matrix, vectorizer):
self.df = df
self.tfidf_matrix = tfidf_matrix
self.vectorizer = vectorizer
# Define vibe patterns
self.vibes_patterns = {
"Mysterious yet Cozy": {
"keywords": ["mysterious", "cozy", "atmospheric", "intimate", "secrets", "warm", "comfort", "puzzle", "intrigue", "homey"],
"weight": 1.5,
"description": "Books that wrap you in intrigue but feel like coming home.",
"icon": "fas fa-house-chimney-user"
},
"Tense yet Amusing": {
"keywords": ["suspenseful", "witty", "entertaining", "thrilling", "humor", "fast paced", "exciting", "clever", "amusing", "engaging"],
"weight": 1.3,
"description": "Reads that keep you on the edge of your seat, but still make you chuckle.",
"icon": "fas fa-face-grin-squint-tears"
},
"Profound yet Approachable": {
"keywords": ["profound", "accessible", "thoughtful", "readable", "philosophical", "meaningful", "simple", "clear", "insightful", "gentle"],
"weight": 1.4,
"description": "Books that delve into deep themes without being a heavy read.",
"icon": "fas fa-lightbulb"
},
"Intense yet Hopeful": {
"keywords": ["intense", "powerful", "hopeful", "uplifting", "emotional", "inspiring", "dramatic", "moving", "optimistic", "resilient"],
"weight": 1.2,
"description": "Stories that hit you hard but leave you with a sense of optimism.",
"icon": "fas fa-sun"
},
"Romantic yet Realistic": {
"keywords": ["romantic", "realistic", "authentic", "genuine", "love", "relationship", "believable", "honest", "mature", "contemporary"],
"weight": 1.3,
"description": "Love stories that feel authentic and truly possible.",
"icon": "fas fa-heart-circle-check"
},
"Fantastic yet Believable": {
"keywords": ["fantasy", "magical", "believable", "grounded", "imaginative", "realistic", "wonder", "enchanting", "plausible", "vivid"],
"weight": 1.4,
"description": "Imaginative tales that could almost exist in our world.",
"icon": "fas fa-wand-magic-sparkles"
}
}
self.elementos_enganche = {
"Clever Dialogues": {
"keywords": ["dialogue", "conversation", "witty", "sharp", "banter", "clever", "verbal", "exchange", "talk", "discussion"],
"weight": 1.5,
"description": "Books where the conversations alone captivate you.",
"icon": "fas fa-comments"
},
"Unfolding Mysteries": {
"keywords": ["mystery", "reveals", "secrets", "unraveling", "clues", "discovery", "unveiling", "hidden", "solving", "truth"],
"weight": 1.4,
"description": "Reads that slowly unveil their secrets, one revelation at a time.",
"icon": "fas fa-magnifying-glass"
},
"Complex Characters": {
"keywords": ["complex", "flawed", "nuanced", "developed", "multifaceted", "realistic", "depth", "layered", "human", "relatable"],
"weight": 1.6,
"description": "Stories with people who feel real, flawed, and deeply intricate.",
"icon": "fas fa-user-tie"
},
"Immersive Worlds": {
"keywords": ["world", "setting", "atmosphere", "immersive", "vivid", "detailed", "rich", "environment", "place", "landscape"],
"weight": 1.3,
"description": "Books that completely transport you to another time or place.",
"icon": "fas fa-earth-americas"
},
"Unexpected Twists": {
"keywords": ["twist", "unexpected", "surprise", "shocking", "revelation", "turn", "unpredictable", "plot", "stunning", "jaw dropping"],
"weight": 1.2,
"description": "Reads that shock you when you least expect it.",
"icon": "fas fa-shuffle"
},
"Authentic Emotions": {
"keywords": ["emotional", "heartfelt", "genuine", "moving", "touching", "authentic", "real", "feelings", "poignant", "affecting"],
"weight": 1.4,
"description": "Books that stir genuine feelings and resonate deeply.",
"icon": "fas fa-face-grin-hearts"
}
}
# Literary Depths patterns
self.literary_depths = {
"Quietly Profound": {
"keywords": ["subtle", "understated", "quiet", "contemplative", "reflective", "meditative", "nuanced", "gentle wisdom", "soft insights", "unspoken"],
"weight": 1.4,
"description": "Books that offer deep insights without dramatic flourishes.",
"icon": "fas fa-dove"
},
"Layered Storytelling": {
"keywords": ["layered", "multiple levels", "beneath surface", "hidden meanings", "symbolic", "metaphorical", "allegory", "subtext", "deeper meaning", "interconnected"],
"weight": 1.5,
"description": "Narratives that reveal new meanings with each reading.",
"icon": "fas fa-layer-group"
},
"Understated Intensity": {
"keywords": ["tension beneath", "quiet intensity", "simmering", "restrained", "controlled", "implicit", "unspoken tension", "subtle power", "contained emotion"],
"weight": 1.3,
"description": "Stories where the most powerful moments whisper rather than shout.",
"icon": "fas fa-compress"
},
"Philosophical Undertones": {
"keywords": ["philosophical", "existential", "moral questions", "ethical", "meaning of life", "human condition", "deeper questions", "thoughtful exploration", "wisdom"],
"weight": 1.4,
"description": "Books that explore life's big questions through compelling stories.",
"icon": "fas fa-brain"
},
"Psychological Nuance": {
"keywords": ["psychological", "mental landscape", "inner life", "consciousness", "psyche", "emotional complexity", "mental state", "introspection", "self awareness"],
"weight": 1.5,
"description": "Deep dives into the intricacies of human psychology.",
"icon": "fas fa-head-side-virus"
},
"Subtle Social Commentary": {
"keywords": ["social commentary", "society", "cultural critique", "social issues", "class", "inequality", "human nature", "civilization", "community", "social dynamics"],
"weight": 1.3,
"description": "Elegant examination of society and human relationships.",
"icon": "fas fa-users"
}
}
def find_books_by_preferences(self, selected_vibes, selected_elementos, selected_depths, num_books=8):
"""Enhanced method to handle all three preference types"""
if not selected_vibes and not selected_elementos and not selected_depths:
return self.df.sort_values(by='avg_rating', ascending=False).head(num_books).to_dict('records')
preference_vector = np.zeros(self.tfidf_matrix.shape[1])
for vibe in selected_vibes:
if vibe in self.vibes_patterns:
vibe_data = self.vibes_patterns[vibe]
vibe_text = ' '.join(vibe_data['keywords'])
vibe_vector = self.vectorizer.transform([vibe_text])
preference_vector += vibe_vector.toarray().flatten() * vibe_data['weight']
for elemento in selected_elementos:
if elemento in self.elementos_enganche:
elem_data = self.elementos_enganche[elemento]
elem_text = ' '.join(elem_data['keywords'])
elem_vector = self.vectorizer.transform([elem_text])
preference_vector += elem_vector.toarray().flatten() * elem_data['weight']
for depth in selected_depths:
if depth in self.literary_depths:
depth_data = self.literary_depths[depth]
depth_text = ' '.join(depth_data['keywords'])
depth_vector = self.vectorizer.transform([depth_text])
preference_vector += depth_vector.toarray().flatten() * depth_data['weight']
if np.linalg.norm(preference_vector) > 0:
preference_vector = preference_vector / np.linalg.norm(preference_vector)
else:
return []
similarities = cosine_similarity([preference_vector], self.tfidf_matrix).flatten()
top_indices = similarities.argsort()[-num_books*3:][::-1]
recommended_books = []
for idx in top_indices:
if similarities[idx] > 0.05:
book = self.df.iloc[idx].copy()
book['similarity_score'] = similarities[idx]
book['match_percentage'] = min(100, similarities[idx] * 100 * 8)
recommended_books.append(book)
if len(recommended_books) >= num_books:
break
return recommended_books
# Initialize the Finder class and DNA Analyzer
finder = IntuitiveBookFinder(df, tfidf_matrix, vectorizer)
finder.dna_analyzer = ReadingDNAAnalyzer(df, tfidf_matrix, vectorizer)
# --- Dash Application Configuration ---
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.FLATLY,
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
])
app.title = "☀️ Summer Reading List Builder"
app.config.suppress_callback_exceptions = True
# Custom CSS
custom_css = """
body {
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #007bff 0%, #6a00ff 100%);
min-height: 100vh;
}
.hover-card:hover {
transform: translateY(-5px) !important;
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
transition: all 0.3s ease !important;
}
.shadow-custom {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
.btn-primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 6px 20px rgba(255,165,0,0.4) !important;
}
.gradient-text {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: bold;
font-size: 3rem;
}
.card-hover {
transition: all 0.3s ease;
border-radius: 10px;
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,0.1);
}
.selection-cards-container {
height: 400px;
overflow-y: auto;
padding-right: 15px;
}
.selection-cards-container::-webkit-scrollbar {
width: 8px;
}
.selection-cards-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.selection-cards-container::-webkit-scrollbar-thumb {
background: #888;
border-radius: 10px;
}
.selection-cards-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
"""
# --- DNA Analysis Interface Functions ---
def create_empty_analysis_interface():
"""Interface when no selections are made"""
return html.Div([
html.Div([
html.I(className="fas fa-dna fa-3x text-muted mb-3"),
html.H4("🧬 Your Reading DNA Awaits Discovery", className="text-muted mb-3"),
html.P("Select your preferences to unlock your unique literary genetic profile!",
className="text-muted")
], className="text-center py-5")
])
def create_dna_analysis_interface(dna_analysis):
"""Create the innovative DNA analysis interface"""
return html.Div([
# Header
html.Div([
html.H4([
html.I(className="fas fa-dna me-2", style={'color': '#9C27B0'}),
"🧬 Your Reading DNA Profile"
], className="mb-3 text-center", style={'color': '#333', 'font-weight': 'bold'}),
html.P("Your unique literary genetic makeup revealed!",
className="text-center text-muted mb-4", style={'font-style': 'italic'})
]),
# DNA Cards Row 1: Personality & Rarity
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H6([
html.I(className="fas fa-user-circle me-2"),
"Reader Personality"
], className="card-title", style={'color': '#E91E63', 'font-weight': 'bold'}),
html.H5(dna_analysis['personality_type']['type'],
className="mb-2", style={'color': '#333'}),
html.P(f"Confidence: {dna_analysis['personality_type']['confidence']}%",
className="text-muted small"),
dbc.Progress(
value=dna_analysis['personality_type']['confidence'],
color="info",
className="mb-2",
style={'height': '6px'}
)
])
], className="h-100 shadow-sm", style={'border-left': '4px solid #E91E63'})
], width=12, md=6, className="mb-3"),
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H6([
html.I(className="fas fa-gem me-2"),
"Taste Rarity Index"
], className="card-title", style={'color': '#9C27B0', 'font-weight': 'bold'}),
html.H5(dna_analysis['rarity_index']['rarity'],
className="mb-2", style={'color': '#333'}),
html.P(f"{dna_analysis['rarity_index']['percentile']}th percentile",
className="text-muted small"),
html.P(dna_analysis['rarity_index']['description'],
className="small", style={'font-size': '0.85rem', 'line-height': '1.3'})
])
], className="h-100 shadow-sm", style={'border-left': '4px solid #9C27B0'})
], width=12, md=6, className="mb-3")
]),
# DNA Cards Row 2: Discovery & Flexibility
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H6([
html.I(className="fas fa-compass me-2"),
"Discovery Potential"
], className="card-title", style={'color': '#FF9800', 'font-weight': 'bold'}),
html.H5(dna_analysis['discovery_potential']['type'],
className="mb-2", style={'color': '#333'}),
html.P(f"Likelihood: {dna_analysis['discovery_potential']['likelihood']}%",
className="text-muted small mb-2"),
html.P(dna_analysis['discovery_potential']['prediction'],
className="small", style={'font-size': '0.85rem', 'line-height': '1.3'})
])
], className="h-100 shadow-sm", style={'border-left': '4px solid #FF9800'})
], width=12, md=6, className="mb-3"),
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H6([
html.I(className="fas fa-shuffle me-2"),
"Genre Flexibility"
], className="card-title", style={'color': '#4CAF50', 'font-weight': 'bold'}),
html.H5(dna_analysis['genre_crossover']['flexibility'],
className="mb-2", style={'color': '#333'}),
html.P(f"Adaptability: {dna_analysis['genre_crossover']['score']}%",
className="text-muted small"),
dbc.Progress(
value=dna_analysis['genre_crossover']['score'],
color="success",
className="mb-2",
style={'height': '6px'}
),
html.P(dna_analysis['genre_crossover']['recommendation'],
className="small", style={'font-size': '0.85rem', 'line-height': '1.3'})
])
], className="h-100 shadow-sm", style={'border-left': '4px solid #4CAF50'})
], width=12, md=6, className="mb-3")
]),
# Mood Map Section
html.Hr(className="my-3"),
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H6([
html.I(className="fas fa-heart me-2"),
"Reading Mood Map"
], className="card-title", style={'color': '#FF5722', 'font-weight': 'bold'}),
html.P(dna_analysis['reading_mood_map']['description'],
className="mb-3", style={'font-size': '0.9rem'}),
html.Div([
dbc.Badge(mood, color="primary", className="me-2 mb-2",
style={'font-size': '0.8rem', 'padding': '5px 10px'})
for mood in dna_analysis['reading_mood_map']['primary_moods']
])
])
], className="shadow-sm", style={'border-left': '4px solid #FF5722'})
], width=12)
])
])
# --- Application Layout ---
app.layout = html.Div([
# Store components for maintaining state
dcc.Store(id='last-recommendations', data=[]),
dcc.Store(id='last-selections', data={'vibes': [], 'elementos': [], 'depths': []}),
dcc.Download(id="download-csv"),
dcc.Download(id="download-txt"),
# Custom CSS
html.Link(
rel='stylesheet',
href='data:text/css;charset=utf-8,' + custom_css.replace('\n', ' '),
),
dbc.Container([
# Header
dbc.Row([
dbc.Col([
html.Div([
# Fila del título principal con icono
dbc.Row([
dbc.Col([
html.H1("☀️ Your Perfect Summer Reading List Awaits",
className="mb-0",
style={
'color': 'white',
'font-weight': 'bold',
'font-size': '2.3rem',
'text-align': 'left',
'text-shadow': '2px 2px 4px rgba(0,0,0,0.3)'
})
], width=8, className="d-flex align-items-center"),
dbc.Col([
html.Div([
html.I(className="fas fa-books",
style={
'font-size': '4rem',
'color': '#FFD700',
'text-shadow': '2px 2px 4px rgba(0,0,0,0.3)'
}),
html.I(className="fas fa-book-open ms-3",
style={
'font-size': '3.5rem',
'color': '#FFD700',
'text-shadow': '2px 2px 4px rgba(0,0,0,0.3)'
})
], style={'text-align': 'right', 'padding-right': '0px'})
], width=4, className="d-flex align-items-center justify-content-end")
], className="align-items-center mb-3"),
# Fila del subtítulo centrado
dbc.Row([
dbc.Col([
html.P("Curate your perfect literary escape for the sun-drenched days ahead.",
className="text-muted mb-4 text-center",
style={'font-size': '1.2rem', 'font-style': 'italic'})
], width=12)
])
], style={
'padding': '25px 35px',
'background': 'linear-gradient(135deg, #FFE066 0%, #FF6B35 25%, #F7931E 50%, #FFD23F 75%, #06FFA5 100%)',
'border-radius': '20px',
'box-shadow': '0 10px 30px rgba(255, 107, 53, 0.4), 0 6px 20px rgba(0, 0, 0, 0.15)',
'border': '2px solid rgba(255, 255, 255, 0.3)',
'backdrop-filter': 'blur(10px)'
})
])
], className="mb-5"),
# Main controls row
dbc.Row(className="mb-5", style={'min-height': '650px'}, children=[
# Left Column: Selection
dbc.Col(className="d-flex flex-column", width=12, lg=6, children=[
dbc.Card(className="shadow-custom flex-grow-1 d-flex flex-column", style={'border-radius': '10px'}, children=[
dbc.CardHeader([
html.H3("✨ Curate Your Reading Experience",
style={'color': 'white', 'font-weight': 'bold'}),
html.P("Choose your preferred reading style",
className="text-white-50", style={'font-size': '1.1rem'})
], style={'background': 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)', 'color': 'white', 'border-radius': '10px 10px 0 0'}),
dbc.CardBody(className="flex-grow-1 overflow-auto", children=[
# Mode selection with reset button
html.Div([
dcc.RadioItems(
id='selection-mode-radio',
options=[
{'label': '🌈 Reading Vibes', 'value': 'vibes'},
{'label': '🎣 Engaging Elements', 'value': 'elements'},
{'label': '🎭 Literary Depths', 'value': 'depths'}
],
value='vibes',
inline=True,
className="mb-3 d-flex justify-content-around flex-wrap",
inputStyle={"margin-right": "8px"},
labelStyle={"margin-right": "15px", "font-weight": "bold", "font-size": "1rem"}
),
# Reset button
dbc.Button(
[html.I(className="fas fa-broom me-2"), "Clear All Selections"],
id='reset-selections-btn',
color='warning',
size='sm',
className='w-100 mb-4',
style={'border-radius': '6px', 'font-weight': 'bold'}
)
]),
# Vibes container
html.Div(id='vibes-container', className="selection-cards-container", children=[
dbc.Row(justify="center", children=[
dbc.Col([
dbc.Card([
dbc.CardBody([
html.Div([
dbc.Checkbox(
id=f"vibe-{vibe.replace(' ', '-').lower()}",
label=html.Span([
html.I(className=data['icon'], style={'margin-right': '10px'}),
html.Strong(vibe, style={'font-size': '1.1rem'}),
]),
value=False,
className="mb-2"
),
html.P(data['description'],
className="text-muted mt-2",
style={'font-size': '0.95rem', 'line-height': '1.4'})
])
])
], color="light", outline=True, className="h-100 shadow-sm card-hover",
style={'transition': 'all 0.3s ease', 'border-width': '2px', 'border-radius': '8px'})
], width=10, className="mb-3")
for vibe, data in finder.vibes_patterns.items()
])
]),
# Elements container
html.Div(id='elements-container', className="selection-cards-container", style={'display': 'none'}, children=[
dbc.Row(justify="center", children=[
dbc.Col([
dbc.Card([
dbc.CardBody([
html.Div([
dbc.Checkbox(
id=f"elemento-{elemento.replace(' ', '-').lower()}",
label=html.Span([
html.I(className=data['icon'], style={'margin-right': '10px'}),
html.Strong(elemento, style={'font-size': '1.1rem'}),
]),
value=False,
className="mb-2"
),
html.P(data['description'],
className="text-muted mt-2",
style={'font-size': '0.95rem', 'line-height': '1.4'})
])
])
], color="light", outline=True, className="h-100 shadow-sm card-hover",
style={'transition': 'all 0.3s ease', 'border-width': '2px', 'border-radius': '8px'})
], width=10, className="mb-3")
for elemento, data in finder.elementos_enganche.items()
])
]),
# Depths container
html.Div(id='depths-container', className="selection-cards-container", style={'display': 'none'}, children=[
dbc.Row(justify="center", children=[
dbc.Col([
dbc.Card([
dbc.CardBody([
html.Div([
dbc.Checkbox(
id=f"depth-{depth.replace(' ', '-').lower()}",
label=html.Span([
html.I(className=data['icon'], style={'margin-right': '10px'}),
html.Strong(depth, style={'font-size': '1.1rem'}),
]),
value=False,
className="mb-2"
),
html.P(data['description'],
className="text-muted mt-2",
style={'font-size': '0.95rem', 'line-height': '1.4'})
])
])
], color="light", outline=True, className="h-100 shadow-sm card-hover",
style={'transition': 'all 0.3s ease', 'border-width': '2px', 'border-radius': '8px'})
], width=10, className="mb-3")
for depth, data in finder.literary_depths.items()
])
])
])
])
]),
# Right Column: Search Options and DNA Analysis
dbc.Col(className="d-flex flex-column", width=12, lg=6, children=[
dbc.Card(className="shadow-custom flex-grow-1 d-flex flex-column", style={'border-radius': '10px'}, children=[
dbc.CardHeader([
html.H3("⚙️ Refine Your Search",
style={'color': 'white', 'font-weight': 'bold'}),
html.P("Specify how many literary treasures you seek",
className="text-white-50", style={'font-size': '1.1rem'})
], style={'background': 'linear-gradient(135deg, #2196F3 0%, #64B5F6 100%)', 'color': 'white', 'border-radius': '10px 10px 0 0'}),
dbc.CardBody(className="flex-grow-1 overflow-auto", children=[
dbc.Row(align="center", className="g-3 mb-4", children=[
dbc.Col([
html.Label("How many books?",
className="fw-bold mb-2",
style={'font-size': '1.2rem', 'color': '#333'}),
dcc.Input(
id='num-books-input',
type='number',
min=5, max=12, step=1, value=8,
className="form-control",
style={'border-radius': '8px', 'padding': '5px 8px', 'font-size': '0.9rem', 'width': '60px'}
)
], width=12, md=6),
dbc.Col([
dbc.Button(
[
html.I(className="fas fa-search me-2"),
"Uncover My Perfect Books"
],
id='find-books-btn',
color='primary',
size='lg',
className='w-100 shadow-custom',
style={
'background': 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
'border': 'none',
'font-weight': 'bold',
'font-size': '1.1rem',
'padding': '12px 20px',
'transition': 'all 0.3s ease',
'border-radius': '8px'
}
)
], width=12, md=6)
]),
# Separator
html.Hr(className="my-4"),
# DNA Analysis Container
html.Div([
dbc.CardBody(id='analysis-container', style={'maxHeight': '350px', 'overflowY': 'auto', 'paddingRight': '15px'},
children=[create_empty_analysis_interface()])
])
])
])
])
]),
# Results Row
dbc.Row([
dbc.Col([
html.Div(id='results-container')
])
]),
# Footer
dbc.Row([
dbc.Col([
html.Hr(className="my-4", style={'border-color': '#dee2e6', 'opacity': '0.5'}),
dbc.Card([
dbc.CardBody([
html.Div([
html.H5("☀️ Summer Reading List Builder",
className="text-center mb-3",
style={'color': '#495057', 'font-weight': 'bold'}),
html.P([
"Built with ",
html.Strong("Python"), "|",
html.Strong("Pandas"), "|",
html.Strong("Scikit-learn"), "|",
html.Strong("Dash"),
], className="text-center text-muted mb-2"),
html.P([
"Developed by ",
html.Strong("Alexander Cabrera", style={'color': '#007bff'})
], className="text-center text-muted mb-0")
], className="text-center")
], style={'padding': '20px'})
], className="shadow-sm",
style={'background': 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)',
'border': '1px solid #dee2e6',
'border-radius': '10px'})
])
], className="mt-4"),
# Container for the book details modal
html.Div(id='book-modal-container', children=dbc.Modal(id="book-modal", is_open=False))
], fluid=True, style={
'padding': '10px',
'background': 'linear-gradient(135deg, #FAF7F0 0%, #F0E6D6 25%, #E8DCC0 50%, #DDD1B8 100%)'#cafe literario
})
])
# --- Application Callbacks ---
# Callback to control visibility of sections
@app.callback(
Output('vibes-container', 'style'),
Output('elements-container', 'style'),
Output('depths-container', 'style'),
Input('selection-mode-radio', 'value')
)
def toggle_sections_visibility(selected_mode):
base_style = {'height': '400px', 'overflowY': 'auto', 'paddingRight': '15px'}
vibes_style = base_style.copy()
elements_style = base_style.copy()
depths_style = base_style.copy()
if selected_mode == 'vibes':
elements_style['display'] = 'none'
depths_style['display'] = 'none'
elif selected_mode == 'elements':
vibes_style['display'] = 'none'
depths_style['display'] = 'none'
elif selected_mode == 'depths':
vibes_style['display'] = 'none'
elements_style['display'] = 'none'
return vibes_style, elements_style, depths_style
# Reset selections callback
@app.callback(
[Output(f"vibe-{vibe.replace(' ', '-').lower()}", 'value') for vibe in finder.vibes_patterns.keys()] +
[Output(f"elemento-{elemento.replace(' ', '-').lower()}", 'value') for elemento in finder.elementos_enganche.keys()] +
[Output(f"depth-{depth.replace(' ', '-').lower()}", 'value') for depth in finder.literary_depths.keys()],
Input('reset-selections-btn', 'n_clicks'),
prevent_initial_call=True
)
def reset_all_selections(n_clicks):
if n_clicks:
return [False] * (len(finder.vibes_patterns) + len(finder.elementos_enganche) + len(finder.literary_depths))
return [False] * (len(finder.vibes_patterns) + len(finder.elementos_enganche) + len(finder.literary_depths))
# Main search callback with DNA Analysis
@app.callback(
Output('results-container', 'children'),
Output('book-modal', 'is_open', allow_duplicate=True),
Output('book-modal-container', 'children', allow_duplicate=True),
Output('analysis-container', 'children'),
Output('last-recommendations', 'data'),
Output('last-selections', 'data'),
Input('find-books-btn', 'n_clicks'),
[State(f"vibe-{vibe.replace(' ', '-').lower()}", 'value')
for vibe in finder.vibes_patterns.keys()] +
[State(f"elemento-{elemento.replace(' ', '-').lower()}", 'value')
for elemento in finder.elementos_enganche.keys()] +
[State(f"depth-{depth.replace(' ', '-').lower()}", 'value')
for depth in finder.literary_depths.keys()] +
[State('num-books-input', 'value')],
prevent_initial_call=True
)
def find_and_display_books(n_clicks, *args):
if not n_clicks:
return html.Div(), False, dbc.Modal(id="book-modal", is_open=False), create_empty_analysis_interface(), [], {'vibes': [], 'elementos': [], 'depths': []}
num_books = args[-1]
# Parse arguments
vibe_values = args[:len(finder.vibes_patterns)]
elemento_values = args[len(finder.vibes_patterns):len(finder.vibes_patterns) + len(finder.elementos_enganche)]
depth_values = args[len(finder.vibes_patterns) + len(finder.elementos_enganche):-1]
selected_vibes = [vibe for vibe, selected in zip(finder.vibes_patterns.keys(), vibe_values) if selected]
selected_elementos = [elemento for elemento, selected in zip(finder.elementos_enganche.keys(), elemento_values) if selected]
selected_depths = [depth for depth, selected in zip(finder.literary_depths.keys(), depth_values) if selected]
# Store selections for export
selections_data = {
'vibes': selected_vibes,
'elementos': selected_elementos,
'depths': selected_depths
}
# ============ DNA ANALYSIS MAGIC ============
if selected_vibes or selected_elementos or selected_depths:
dna_analysis = finder.dna_analyzer.analyze_reading_dna(selected_vibes, selected_elementos, selected_depths)
analysis_content = create_dna_analysis_interface(dna_analysis)
else:
analysis_content = create_empty_analysis_interface()
# ==========================================
# Handle no selections
if not selected_vibes and not selected_elementos and not selected_depths:
alert_message = dbc.Alert([
html.H4("🤔 What Sparks Your Interest?"),
html.P("Select at least one preference to discover books perfectly suited for you.")
], color="info", className="text-center")
return alert_message, False, dbc.Modal(id="book-modal", is_open=False), analysis_content, [], selections_data
# Get recommendations
recommended_books = finder.find_books_by_preferences(selected_vibes, selected_elementos, selected_depths, num_books)
if not recommended_books:
alert_message = dbc.Alert([
html.H4("😔 No Matches Found"),
html.P("Try different combinations of preferences.")
], color="warning", className="text-center")
return alert_message, False, dbc.Modal(id="book-modal", is_open=False), analysis_content, [], selections_data
# Convert recommended_books to proper format for storage
books_for_storage = []
for book in recommended_books:
if hasattr(book, 'to_dict'):
book_dict = book.to_dict()
else:
book_dict = dict(book)
books_for_storage.append(book_dict)
# Create result cards
result_cards = []
for i, book in enumerate(recommended_books):
if book['match_percentage'] > 70:
border_color = "#66BB6A"
badge_color = "success"
elif book['match_percentage'] > 50:
border_color = "#FFCA28"
badge_color = "warning"
else:
border_color = "#42A5F5"
badge_color = "info"
card = dbc.Col([
dbc.Card([
dbc.CardHeader([
html.Div([
html.H6(f"📚 Match #{i+1}", className="mb-0"),
dbc.Badge(f"{book['match_percentage']:.0f}% Match", color=badge_color)
], className="d-flex justify-content-between align-items-center")
], style={'background': 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)', 'border-radius': '8px 8px 0 0'}),
dbc.CardBody([
html.H5(book['original_title'], className="card-title fw-bold text-truncate mb-1"),
html.P(f"✍️ Author: {book['author']}", className="text-muted mb-2"),
html.P(f"⭐ Average Rating: {book['avg_rating']:.1f}/5 ({int(book['ratings_count'])} votes)", className="mb-3"),
dbc.Button(
"View Details",
id={'type': 'open-book-modal', 'index': str(book.name)},
color="secondary",
className="mt-auto w-100",
n_clicks=0,
style={'border-radius': '5px'}
)
])
], className="h-100 card-hover d-flex flex-column", style={'border': f'3px solid {border_color}', 'border-radius': '10px'})
], width=12, sm=6, md=4, lg=3, className="mb-4")
result_cards.append(card)
results_display = html.Div([
dbc.Card([
dbc.CardHeader([
html.Div([
html.H2([
html.I(className="fas fa-star me-3"),
"Your Perfect Books Await"
], className="text-center mb-0",
style={'color': 'white', 'font-weight': 'bold'}),
html.P(f"We found {len(recommended_books)} books that align with your preferences.",
className="text-center mb-0 mt-2",
style={'color': 'rgba(255,255,255,0.9)', 'font-size': '1.1rem'})
])
], style={'background': 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', 'padding': '30px', 'border-radius': '10px 10px 0 0'}),
dbc.CardBody([
html.Div([
dbc.ButtonGroup([
dbc.Button(
[html.I(className="fas fa-download me-2"), "Download CSV"],
id="download-csv-btn",
color="success",
outline=True,
size="sm"
),
dbc.Button(
[html.I(className="fas fa-file-text me-2"), "Download TXT"],
id="download-txt-btn",
color="info",
outline=True,
size="sm"
)
], className="mb-4")
], className="text-center"),
dbc.Row(result_cards)
], style={'padding': '30px'})
], className="shadow-lg", style={'border-radius': '10px'})
])
return results_display, False, dbc.Modal(id="book-modal", is_open=False), analysis_content, books_for_storage, selections_data
# Book modal callbacks
@app.callback(
Output('book-modal-container', 'children', allow_duplicate=True),
Output('book-modal', 'is_open', allow_duplicate=True),
Input({'type': 'open-book-modal', 'index': ALL}, 'n_clicks'),
State('book-modal', 'is_open'),
prevent_initial_call=True
)
def open_book_modal(n_clicks, is_open):
if not any(n_clicks) or all(n is None for n in n_clicks):
raise dash.exceptions.PreventUpdate
ctx = dash.callback_context
if not ctx.triggered:
raise dash.exceptions.PreventUpdate
triggered_id = ctx.triggered[0]['prop_id'].split('.')[0]
book_index = eval(triggered_id)['index']
try:
book_index = int(book_index)
book = df.iloc[book_index]
# Crear el contenido del modal
modal_content = dbc.Modal([
dbc.ModalHeader([
html.H4([
html.I(className="fas fa-book-open me-2"),
book['original_title']
], className="modal-title")
]),
dbc.ModalBody([
dbc.Row([
dbc.Col([
html.H5("📖 Book Details", className="mb-3"),
html.P([html.Strong("Author: "), book['author']]),
html.P([html.Strong("Average Rating: "), f"{book['avg_rating']:.1f}/5"]),
html.P([html.Strong("Total Ratings: "), f"{int(book['ratings_count']):,}"]),
html.P([html.Strong("Pages: "), f"{int(book['num_pages']) if book['num_pages'] > 0 else 'N/A'}"]),
html.P([html.Strong("Publication Year: "), f"{int(book['original_publication_year']) if pd.notna(book['original_publication_year']) else 'N/A'}"]),
], width=12)
]),
html.Hr(),
html.H5("📝 Description", className="mb-3"),
html.P(book['description'][:500] + "..." if len(str(book['description'])) > 500 else book['description'],
style={'line-height': '1.6', 'text-align': 'justify'})
]),
dbc.ModalFooter([
dbc.Button("Close", id="close-modal", className="ms-auto", color="secondary")
])
], id="book-modal", is_open=True, size="lg")
return modal_content, True
except Exception as e:
# En caso de error, mostrar modal de error
error_modal = dbc.Modal([
dbc.ModalHeader([
html.H4("Error", className="modal-title")
]),
dbc.ModalBody([
html.P("Could not load book details. Please try again.")
]),
dbc.ModalFooter([
dbc.Button("Close", id="close-modal", className="ms-auto", color="secondary")
])
], id="book-modal", is_open=True)
return error_modal, True
# Callback para cerrar el modal
@app.callback(
Output('book-modal', 'is_open', allow_duplicate=True),
Input('close-modal', 'n_clicks'),
State('book-modal', 'is_open'),
prevent_initial_call=True
)
def close_modal(n_clicks, is_open):
if n_clicks:
return False
return is_open
# Callback para descargar CSV
@app.callback(
Output("download-csv", "data"),
Input("download-csv-btn", "n_clicks"),
State('last-recommendations', 'data'),
State('last-selections', 'data'),
prevent_initial_call=True
)
def download_csv(n_clicks, recommendations, selections):
if n_clicks and recommendations:
# Convertir recomendaciones a DataFrame
df_export = pd.DataFrame(recommendations)
# Seleccionar columnas relevantes para exportar
columns_to_export = ['original_title', 'author', 'avg_rating', 'ratings_count',
'num_pages', 'original_publication_year', 'match_percentage']
# Filtrar columnas que existen
available_columns = [col for col in columns_to_export if col in df_export.columns]
df_filtered = df_export[available_columns]
# Agregar información de selecciones como metadatos
timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")
filename = f"summer_reading_list_{timestamp}.csv"
return dcc.send_data_frame(df_filtered.to_csv, filename, index=False)
# Callback para descargar TXT
@app.callback(
Output("download-txt", "data"),
Input("download-txt-btn", "n_clicks"),
State('last-recommendations', 'data'),
State('last-selections', 'data'),
prevent_initial_call=True
)
def download_txt(n_clicks, recommendations, selections):
if n_clicks and recommendations:
# Crear contenido del archivo de texto
content = "☀️ YOUR SUMMER READING LIST ☀️\n"
content += "=" * 50 + "\n\n"
# Agregar información de selecciones
if selections['vibes']:
content += f"🌈 Selected Vibes: {', '.join(selections['vibes'])}\n"
if selections['elementos']:
content += f"🎣 Selected Elements: {', '.join(selections['elementos'])}\n"
if selections['depths']:
content += f"🎭 Selected Depths: {', '.join(selections['depths'])}\n"
content += "\n" + "-" * 50 + "\n\n"
# Agregar libros recomendados
for i, book in enumerate(recommendations, 1):
content += f"{i}. {book.get('original_title', 'N/A')}\n"
content += f" Author: {book.get('author', 'N/A')}\n"
content += f" Rating: {book.get('avg_rating', 0):.1f}/5 ({book.get('ratings_count', 0)} votes)\n"
content += f" Match: {book.get('match_percentage', 0):.0f}%\n"
if book.get('num_pages', 0) > 0:
content += f" Pages: {int(book['num_pages'])}\n"
content += "\n"
content += "\n" + "=" * 50 + "\n"
content += "Generated by Summer Reading List Builder\n"
content += f"Created on: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")
filename = f"summer_reading_list_{timestamp}.txt"
return dict(content=content, filename=filename)