import streamlit as st from datasets import load_dataset import pandas as pd from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity import plotly.express as px from isodate import parse_duration, ISO8601Error import ast import numpy as np from transformers import pipeline import time from functools import lru_cache import warnings import re import json from PIL import Image import requests from io import BytesIO import traceback warnings.filterwarnings('ignore') # ==================== CONFIGURACIÓN ==================== st.set_page_config( page_title="Recetas Saludables IA 🍎", page_icon="🍎", layout="wide", initial_sidebar_state="expanded" ) # CSS mejorado con más estilos para listas # MEJORA: Mejor presentación st.markdown(""" """, unsafe_allow_html=True) # ==================== FUNCIONES AUXILIARES ==================== def parse_ingredient_string(ing_str): """Parsear cadena de ingredientes del formato R a lista Python""" try: if isinstance(ing_str, list): return ing_str if not isinstance(ing_str, str): return [] # Limpiar la cadena # MEJORA: Más robusto contra errores ing_str = ing_str.strip().replace('NA', '').replace('character(0)', '') # Caso 1: Formato R c("item1", "item2") if ing_str.startswith('c('): # Remover c( y el último paréntesis ing_str = ing_str[2:-1] if ing_str.endswith(')') else ing_str[2:] # Reemplazar comillas dobles escapadas ing_str = ing_str.replace('\\"', '"') # Intentar evaluar como lista Python try: result = ast.literal_eval(ing_str) if isinstance(result, list): return [str(item).strip('"\'') for item in result] else: return [str(result).strip('"\'')] except: # Si falla, dividir por comas items = [item.strip().strip('"\'') for item in ing_str.split(',')] return [item for item in items if item] # Caso 2: Lista JSON-like elif ing_str.startswith('[') and ing_str.endswith(']'): try: result = json.loads(ing_str) if isinstance(result, list): return [str(item) for item in result] except: pass # Caso 3: Separado por comas simple items = [item.strip().strip('"\'') for item in ing_str.split(',')] items = [item for item in items if item] return items except Exception as e: st.warning(f"Error al parsear ingredientes: {e}") return [] def parse_instruction_string(instr_str): """Parsear instrucciones del formato R a lista Python""" try: if isinstance(instr_str, list): return instr_str if not isinstance(instr_str, str): return [] instr_str = instr_str.strip().replace('NA', '').replace('character(0)', '') # MEJORA: Limpieza extra # Si es una cadena JSON-like o lista Python if (instr_str.startswith('[') and instr_str.endswith(']')): try: result = json.loads(instr_str) if isinstance(result, list): return [str(item) for item in result] except: pass # Dividir por puntos o números instructions = [] # Patrón para dividir por números (1., 2., etc.) o puntos patterns = [r'\d+\.', r'\d+\)', r'Step \d+:', r'\n'] for pattern in patterns: if re.search(pattern, instr_str): split_instr = re.split(pattern, instr_str) instructions = [instr.strip() for instr in split_instr if instr.strip()] if len(instructions) > 1: break # Si no se pudo dividir, usar toda la cadena como una instrucción if not instructions: instructions = [instr_str] return instructions except Exception as e: st.warning(f"Error al parsear instrucciones: {e}") return [str(instr_str)] def parse_image_string(img_str): """Parsear URLs de imágenes""" try: if isinstance(img_str, list): return img_str if not isinstance(img_str, str): return [] img_str = img_str.strip() # Formato R c("url1", "url2") if img_str.startswith('c('): img_str = img_str[2:-1] if img_str.endswith(')') else img_str[2:] img_str = img_str.replace('\\"', '"') try: result = ast.literal_eval(img_str) if isinstance(result, list): return [str(item).strip('"\'') for item in result] except: pass # Lista JSON elif img_str.startswith('[') and img_str.endswith(']'): try: result = json.loads(img_str) if isinstance(result, list): return [str(item) for item in result] except: pass # URL única if img_str.startswith('http'): return [img_str] return [] except Exception as e: return [] @st.cache_resource(show_spinner="Cargando modelo de traducción...") def load_translator(): return pipeline("translation_en_to_es", model="Helsinki-NLP/opus-mt-en-es") @st.cache_resource(show_spinner="Cargando modelo de embeddings...") def load_embedding_model(): return SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2') # MEJORA: Modelo multilingüe para español y sinónimos @lru_cache(maxsize=1000) def traducir_texto_cached(texto): """Cachea traducciones para mejorar rendimiento""" if not texto or pd.isna(texto) or str(texto).strip() == '': return "" try: texto = str(texto) if len(texto) > 500: texto = texto[:497] + "..." translator = load_translator() return translator(texto, max_length=512)[0]['translation_text'] except Exception: return texto def load_image_from_url(url, max_size=(400, 300)): """Cargar imagen desde URL con manejo de errores""" try: if not url or not isinstance(url, str) or not url.startswith('http'): return None response = requests.get(url, timeout=5) response.raise_for_status() img = Image.open(BytesIO(response.content)) # Convertir a RGB si es necesario if img.mode in ('RGBA', 'LA', 'P'): img = img.convert('RGB') # Redimensionar manteniendo aspecto img.thumbnail(max_size, Image.Resampling.LANCZOS) return img except Exception: return None # ==================== CARGA DE DATOS ==================== @st.cache_data(show_spinner="Cargando y procesando datos de recetas...") def load_and_preprocess_data(): """Carga y preprocesa los datos con validación robusta""" try: # Cargar dataset st.info("Descargando dataset de recetas... Esto puede tomar unos segundos.") ds = load_dataset("AkashPS11/recipes_data_food.com", trust_remote_code=True) df = ds['train'].to_pandas() if 'train' in ds else ds.to_pandas() # Limitar tamaño para mejor rendimiento pero mantener variedad df = df.head(8000).copy() # Procesar ingredientes df['ingredients_parsed'] = df['RecipeIngredientParts'].apply(parse_ingredient_string) df['ingredients_str'] = df['ingredients_parsed'].apply( lambda x: ' '.join([str(i).lower() for i in x]) if x else '' ) # Procesar cantidades df['quantities_parsed'] = df['RecipeIngredientQuantities'].apply(parse_ingredient_string) # Procesar instrucciones df['instructions_parsed'] = df['RecipeInstructions'].apply(parse_instruction_string) # Procesar imágenes df['images_parsed'] = df['Images'].apply(parse_image_string) # Filtrar recetas saludables mask = ( (df['Calories'] < 800) | df['Keywords'].str.contains('healthy|low fat|vegan|low calorie|vegetarian', na=False, case=False, regex=True) ) df = df[mask].copy() # Procesar tiempos con manejo de errores def parse_time(time_str): try: if pd.isna(time_str) or not isinstance(time_str, str): return 0 return parse_duration(time_str).total_seconds() / 60 except (ISO8601Error, AttributeError, TypeError): return 0 df['total_minutes'] = df['TotalTime'].apply(parse_time) df['prep_minutes'] = df['PrepTime'].apply(parse_time) df['cook_minutes'] = df['CookTime'].apply(parse_time) # Limpiar valores NaN numeric_cols = ['Calories', 'FatContent', 'SugarContent', 'ProteinContent', 'AggregatedRating'] for col in numeric_cols: if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) # Pre-calcular embeddings para mejor rendimiento st.info("Calculando embeddings para búsqueda rápida...") model = load_embedding_model() ingredients_texts = df['ingredients_str'].fillna('').tolist() # Calcular embeddings en lotes para evitar memory error # MEJORA: Batch más pequeño para mejor rendimiento batch_size = 50 embeddings = [] for i in range(0, len(ingredients_texts), batch_size): batch = ingredients_texts[i:i+batch_size] batch_embeddings = model.encode(batch, show_progress_bar=False) embeddings.extend(batch_embeddings) df['embedding'] = list(embeddings) st.success(f"✅ Dataset cargado: {len(df)} recetas procesadas") return df except Exception as e: st.error(f"Error crítico al cargar datos: {str(e)}") st.error(traceback.format_exc()) return pd.DataFrame() # ==================== FUNCIONES DE RECOMENDACIÓN ==================== @lru_cache(maxsize=50) # MEJORA: Cache extra para recomendaciones repetidas def recommend_recipes_optimized(user_ingredients, category="", top_k=5, max_cal=500, is_vegan=False, max_time=60): """Función de recomendación optimizada con embeddings pre-calculados""" try: if df.empty: return pd.DataFrame() # Crear embedding de consulta del usuario # MEJORA: Soporte multilingüe, no traduce input model = load_embedding_model() user_text = ' '.join([str(i) for i in user_ingredients]) # No lower, modelo maneja user_embedding = model.encode(user_text) # Calcular similitudes usando embeddings pre-calculados embeddings = np.vstack(df['embedding'].values) similarities = cosine_similarity([user_embedding], embeddings)[0] df['similarity'] = similarities # Aplicar filtros mask = ( (df['Calories'] <= max_cal) & (df['total_minutes'] <= max_time) & (df['similarity'] > 0.1) # Umbral más bajo para más resultados ) if category and category != "": mask &= df['RecipeCategory'].str.contains(category, case=False, na=False) if is_vegan: mask &= df['Keywords'].str.contains('vegan', case=False, na=False) filtered = df[mask].copy() if filtered.empty: # Relajar filtros si no hay resultados st.warning("No se encontraron recetas con esos filtros estrictos. Mostrando recetas más similares...") filtered = df[df['similarity'] > 0.05].copy() # Ordenar y retornar recs = filtered.nlargest(top_k, ['similarity', 'AggregatedRating']) return recs except Exception as e: st.error(f"Error en recomendación: {str(e)}") return pd.DataFrame() def get_similar_recipes(recipe_index, top_n=5): """Obtener recetas similares a una receta específica""" try: if df.empty or recipe_index not in df.index: return pd.DataFrame() # Obtener embedding de la receta de referencia target_embedding = df.loc[recipe_index, 'embedding'].reshape(1, -1) # Calcular similitudes con todas las recetas embeddings = np.vstack(df['embedding'].values) similarities = cosine_similarity(target_embedding, embeddings)[0] # Obtener índices de las recetas más similares (excluyendo la receta misma) similar_indices = np.argsort(similarities)[::-1][1:top_n+1] similar_recipes = df.iloc[similar_indices].copy() similar_recipes['similarity_to_recipe'] = similarities[similar_indices] return similar_recipes except Exception as e: st.error(f"Error al buscar recetas similares: {str(e)}") return pd.DataFrame() # ==================== INTERFAZ STREAMLIT ==================== # Título y descripción st.title("🍎 Generador Inteligente de Recetas Saludables") st.markdown("""

✨ Instrucciones de uso:

  1. Ingresa los ingredientes que tienes disponibles (separados por comas, en español o inglés)
  2. Ajusta los filtros según tus preferencias dietéticas
  3. Haz clic en "🔍 Buscar Recetas" para obtener recomendaciones personalizadas
  4. Explora cada receta haciendo clic en los detalles y busca recetas similares
""", unsafe_allow_html=True) # MEJORA: Agregado "en español o inglés" # Cargar datos con spinner with st.spinner("Cargando base de datos de recetas..."): df = load_and_preprocess_data() if df.empty: st.error("No se pudieron cargar los datos. Por favor, recarga la página.") st.stop() # ==================== BARRA LATERAL ==================== with st.sidebar: st.header("⚙️ Filtros Avanzados") st.subheader("Preferencias Dietéticas") max_cal = st.slider( "Calorías máximas por porción", 100, 1000, 500, help="Limita las recetas por contenido calórico" ) max_time = st.slider( "Tiempo máximo de preparación (minutos)", 10, 180, 60, help="Incluye tiempo de preparación y cocción" ) col1, col2 = st.columns(2) with col1: is_vegan = st.checkbox("🌱 Vegano", help="Solo recetas veganas") with col2: is_healthy = st.checkbox("💚 Saludable", value=True, help="Priorizar recetas marcadas como saludables") st.subheader("Resultados") top_k = st.select_slider( "Número de recetas a mostrar", options=[3, 5, 7, 10], value=5 ) similar_recipes_count = st.slider( "Recetas similares a mostrar", 2, 8, 3, help="Número de recetas similares para mostrar en cada receta" ) st.markdown("---") st.markdown("### 📊 Estadísticas del Dataset") st.metric("Recetas disponibles", len(df)) st.metric("Calorías promedio", f"{df['Calories'].mean():.0f}") st.metric("Tiempo promedio", f"{df['total_minutes'].mean():.0f} min") # ==================== ENTRADA PRINCIPAL ==================== st.header("🔍 Buscar Recetas por Ingredientes") col1, col2, col3 = st.columns([3, 2, 1]) with col1: user_input = st.text_input( "Ingredientes disponibles (separados por comas):", "pollo, arroz, vegetales", # MEJORA: Ejemplo en español placeholder="Ej: tomate, pollo, arroz, cebolla, aceite de oliva" ) with col2: # Extraer categorías únicas del dataset unique_categories = [""] + sorted(df['RecipeCategory'].dropna().unique().tolist()[:20]) category_input = st.selectbox( "Categoría (opcional):", unique_categories, format_func=lambda x: "Todas las categorías" if x == "" else x[:30] ) with col3: st.markdown("
", unsafe_allow_html=True) search_clicked = st.button("🔍 Buscar Recetas", use_container_width=True) # ==================== RESULTADOS ==================== if search_clicked and user_input: with st.spinner("Buscando recetas que coincidan con tus ingredientes..."): ingredients = [ing.strip() for ing in user_input.split(',') if ing.strip()] if not ingredients: st.warning("Por favor, ingresa al menos un ingrediente.") else: recs = recommend_recipes_optimized( tuple(ingredients), # MEJORA: Tuple para cache category_input, top_k=top_k, max_cal=max_cal, is_vegan=is_vegan, max_time=max_time ) st.session_state.recommendations = recs st.session_state.search_made = True if 'recommendations' in st.session_state and not st.session_state.recommendations.empty: recs = st.session_state.recommendations st.success(f"✅ Encontradas {len(recs)} recetas que coinciden con tus criterios") # Gráfico de visualización if len(recs) > 1: fig = px.scatter( recs, x='total_minutes', y='Calories', size='similarity', color='RecipeCategory', hover_name='Name', title="📊 Distribución de Recetas Encontradas", labels={ 'total_minutes': 'Tiempo Total (minutos)', 'Calories': 'Calorías', 'RecipeCategory': 'Categoría' } ) fig.update_layout( paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', font_color='#333' ) st.plotly_chart(fig, use_container_width=True) # Mostrar recetas st.header("🍽️ Recetas Recomendadas") for idx, row in recs.iterrows(): recipe_index = row.name # Índice en el DataFrame original with st.container(): st.markdown(f"
", unsafe_allow_html=True) # Título traducido recipe_name = traducir_texto_cached(row['Name']) st.markdown(f"### {recipe_name}") # Metadatos en columnas col_meta1, col_meta2, col_meta3, col_meta4 = st.columns(4) with col_meta1: st.metric("🔥 Calorías", f"{row['Calories']:.0f}") with col_meta2: st.metric("⏱️ Tiempo", f"{row['total_minutes']:.0f} min") with col_meta3: st.metric("⭐ Similitud", f"{row['similarity']:.3f}") with col_meta4: if 'AggregatedRating' in row and row['AggregatedRating'] > 0: st.metric("★ Valoración", f"{row['AggregatedRating']:.1f}/5") else: st.metric("★ Valoración", "N/A") # Contenedor principal con columnas para imagen y contenido col_img, col_content = st.columns([1, 2]) with col_img: # Mostrar imagen si está disponible try: images = row['images_parsed'] if 'images_parsed' in row else [] if images and len(images) > 0: img_url = images[0] img = load_image_from_url(img_url) if img: st.image(img, caption="Imagen de referencia", use_column_width=True) else: # Mostrar placeholder si no se puede cargar la imagen st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w=400&h=300&fit=crop", caption="Imagen representativa", use_column_width=True) else: st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w-400&h=300&fit=crop", caption="Imagen representativa", use_column_width=True) except Exception: st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w=400&h=300&fit=crop", caption="Imagen representativa", use_column_width=True) with col_content: # Descripción if pd.notna(row.get('Description')) and str(row['Description']).strip(): with st.expander("📝 Descripción", expanded=False): desc = traducir_texto_cached(row['Description']) st.write(desc) # Ingredientes con cantidades # MEJORA: Usar ul para mejor presentación with st.expander("🛒 Ingredientes", expanded=False): try: ingredients_list = row['ingredients_parsed'] if 'ingredients_parsed' in row else [] quantities_list = row['quantities_parsed'] if 'quantities_parsed' in row else [] if ingredients_list and len(ingredients_list) > 0: st.markdown("", unsafe_allow_html=True) else: st.info("No hay información de ingredientes disponible para esta receta.") except Exception as e: st.error(f"Error al mostrar ingredientes: {e}") # Instrucciones # MEJORA: Usar ol para pasos numerados with st.expander("👩‍🍳 Instrucciones de Preparación", expanded=False): try: instructions = row['instructions_parsed'] if 'instructions_parsed' in row else [] if instructions and len(instructions) > 0: st.markdown("
    ", unsafe_allow_html=True) for step in instructions: step_translated = traducir_texto_cached(step) st.markdown(f"
  1. {step_translated}
  2. ", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) else: st.info("No hay instrucciones disponibles para esta receta.") except Exception as e: st.error(f"Error al mostrar instrucciones: {e}") # Información nutricional with st.expander("📊 Información Nutricional", expanded=False): nutri_data = { 'Nutriente': ['Calorías', 'Grasa Total', 'Azúcares', 'Proteína'], 'Cantidad': [ f"{row.get('Calories', 0):.0f} kcal", f"{row.get('FatContent', 0):.1f} g" if pd.notna(row.get('FatContent')) else "N/A", f"{row.get('SugarContent', 0):.1f} g" if pd.notna(row.get('SugarContent')) else "N/A", f"{row.get('ProteinContent', 0):.1f} g" if pd.notna(row.get('ProteinContent')) else "N/A" ] } st.table(pd.DataFrame(nutri_data)) # Recetas similares with st.expander(f"🔍 Ver {similar_recipes_count} recetas similares", expanded=False): similar_recipes = get_similar_recipes(recipe_index, top_n=similar_recipes_count) if not similar_recipes.empty: for sim_idx, sim_row in similar_recipes.iterrows(): col_sim1, col_sim2 = st.columns([3, 1]) with col_sim1: sim_name = traducir_texto_cached(sim_row['Name']) st.markdown(f"**{sim_name}**") st.caption(f"Calorías: {sim_row['Calories']:.0f} kcal • Tiempo: {sim_row['total_minutes']:.0f} min") with col_sim2: st.metric("Similitud", f"{sim_row['similarity_to_recipe']:.3f}") st.markdown("---") else: st.info("No se encontraron recetas similares.") st.markdown("
", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # ==================== INICIO CON RECETAS DE EJEMPLO ==================== elif 'recommendations' not in st.session_state: st.info("👈 Ingresa ingredientes y ajusta los filtros para comenzar, o usa nuestro ejemplo:") # Mostrar algunas recetas de ejemplo al inicio example_ingredients = ["pollo", "arroz", "vegetales"] # MEJORA: Ejemplo en español if st.button("🍗 Usar ejemplo: Pollo con arroz y vegetales"): with st.spinner("Buscando recetas de ejemplo..."): example_recs = recommend_recipes_optimized( tuple(example_ingredients), top_k=3, max_cal=600, max_time=90 ) st.session_state.recommendations = example_recs st.rerun() # ==================== PIE DE PÁGINA ==================== st.markdown("---") st.markdown("""

🍎 Generador de Recetas Saludables con IA • Usa modelos de machine learning para encontrar recetas perfectas basadas en tus ingredientes

Powered by Hugging Face 🤗 • Sentence Transformers • Streamlit

""", unsafe_allow_html=True)