Files changed (1) hide show
  1. app.py +117 -583
app.py CHANGED
@@ -4,7 +4,6 @@ import pandas as pd
4
  from sentence_transformers import SentenceTransformer
5
  from sklearn.metrics.pairwise import cosine_similarity
6
  import plotly.express as px
7
- from isodate import parse_duration, ISO8601Error
8
  import ast
9
  import numpy as np
10
  from transformers import pipeline
@@ -17,6 +16,7 @@ from PIL import Image
17
  import requests
18
  from io import BytesIO
19
  import traceback
 
20
 
21
  warnings.filterwarnings('ignore')
22
 
@@ -31,43 +31,15 @@ st.set_page_config(
31
  # CSS mejorado
32
  st.markdown("""
33
  <style>
34
- .main {background-color: #f8f9fa;}
35
- .stButton>button {
36
- background-color: #28a745;
37
- color: white;
38
- border-radius: 10px;
39
- padding: 0.5rem 1rem;
40
- font-weight: 600;
41
- border: none;
42
- transition: all 0.3s;
43
- }
44
- .stButton>button:hover {
45
- background-color: #218838;
46
- transform: translateY(-2px);
47
- box-shadow: 0 4px 12px rgba(40, 167, 69, 0.2);
48
- }
49
  .recipe-card {
50
- background: white;
 
51
  border-radius: 10px;
52
- padding: 1.5rem;
53
- margin: 1rem 0;
54
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
55
- border-left: 4px solid #28a745;
56
  }
57
- .highlight {
58
- background-color: #e8f5e9;
59
- padding: 0.5rem;
60
- border-radius: 5px;
61
- margin: 0.5rem 0;
62
- }
63
- .ingredient-item {
64
- padding: 0.3rem 0;
65
- border-bottom: 1px solid #eee;
66
- }
67
- .instruction-step {
68
- margin: 0.5rem 0;
69
- padding-left: 1rem;
70
- border-left: 3px solid #28a745;
71
  }
72
  </style>
73
  """, unsafe_allow_html=True)
@@ -75,626 +47,188 @@ st.markdown("""
75
  # ==================== FUNCIONES AUXILIARES ====================
76
 
77
  def parse_ingredient_string(ing_str):
78
- """Parsear cadena de ingredientes del formato R a lista Python"""
79
  try:
80
- if isinstance(ing_str, list):
81
- return ing_str
82
-
83
- if not isinstance(ing_str, str):
84
- return []
85
-
86
- # Limpiar la cadena
87
- ing_str = ing_str.strip()
88
-
89
- # Caso 1: Formato R c("item1", "item2")
90
- if ing_str.startswith('c('):
91
- # Remover c( y el último paréntesis
92
- ing_str = ing_str[2:-1] if ing_str.endswith(')') else ing_str[2:]
93
- # Reemplazar comillas dobles escapadas
94
- ing_str = ing_str.replace('\\"', '"')
95
- # Intentar evaluar como lista Python
96
- try:
97
- result = ast.literal_eval(ing_str)
98
- if isinstance(result, list):
99
- return [str(item).strip('"\'') for item in result]
100
- else:
101
- return [str(result).strip('"\'')]
102
- except:
103
- # Si falla, dividir por comas
104
- items = [item.strip().strip('"\'') for item in ing_str.split(',')]
105
- return [item for item in items if item and item != 'NA']
106
-
107
- # Caso 2: Lista JSON-like
108
- elif ing_str.startswith('[') and ing_str.endswith(']'):
109
- try:
110
- result = json.loads(ing_str)
111
- if isinstance(result, list):
112
- return [str(item) for item in result]
113
- except:
114
- pass
115
-
116
- # Caso 3: Separado por comas simple
117
- items = [item.strip().strip('"\'') for item in ing_str.split(',')]
118
- items = [item for item in items if item and item not in ['NA', 'character(0)']]
119
-
120
- return items
121
-
122
- except Exception as e:
123
- st.warning(f"Error al parsear ingredientes: {e}")
124
  return []
125
 
126
  def parse_instruction_string(instr_str):
127
- """Parsear instrucciones del formato R a lista Python"""
128
  try:
129
- if isinstance(instr_str, list):
130
- return instr_str
131
-
132
- if not isinstance(instr_str, str):
133
- return []
134
-
135
- instr_str = instr_str.strip()
136
-
137
- # Si es una cadena JSON-like o lista Python
138
- if (instr_str.startswith('[') and instr_str.endswith(']')):
139
- try:
140
- result = json.loads(instr_str)
141
- if isinstance(result, list):
142
- return [str(item) for item in result]
143
- except:
144
- pass
145
-
146
- # Dividir por puntos o números
147
- instructions = []
148
- # Patrón para dividir por números (1., 2., etc.) o puntos
149
- patterns = [r'\d+\.', r'\d+\)', r'Step \d+:', r'\n']
150
-
151
- for pattern in patterns:
152
- if re.search(pattern, instr_str):
153
- split_instr = re.split(pattern, instr_str)
154
- instructions = [instr.strip() for instr in split_instr if instr.strip()]
155
- if len(instructions) > 1:
156
- break
157
-
158
- # Si no se pudo dividir, usar toda la cadena como una instrucción
159
- if not instructions:
160
- instructions = [instr_str]
161
-
162
- return instructions
163
-
164
- except Exception as e:
165
- st.warning(f"Error al parsear instrucciones: {e}")
166
- return [str(instr_str)]
167
-
168
- def parse_image_string(img_str):
169
- """Parsear URLs de imágenes"""
170
- try:
171
- if isinstance(img_str, list):
172
- return img_str
173
-
174
- if not isinstance(img_str, str):
175
- return []
176
-
177
- img_str = img_str.strip()
178
-
179
- # Formato R c("url1", "url2")
180
- if img_str.startswith('c('):
181
- img_str = img_str[2:-1] if img_str.endswith(')') else img_str[2:]
182
- img_str = img_str.replace('\\"', '"')
183
- try:
184
- result = ast.literal_eval(img_str)
185
- if isinstance(result, list):
186
- return [str(item).strip('"\'') for item in result]
187
- except:
188
- pass
189
-
190
- # Lista JSON
191
- elif img_str.startswith('[') and img_str.endswith(']'):
192
- try:
193
- result = json.loads(img_str)
194
- if isinstance(result, list):
195
- return [str(item) for item in result]
196
- except:
197
- pass
198
-
199
- # URL única
200
- if img_str.startswith('http'):
201
- return [img_str]
202
-
203
- return []
204
-
205
- except Exception as e:
206
  return []
207
-
208
- @st.cache_resource(show_spinner="Cargando modelo de traducción...")
209
- def load_translator():
210
- return pipeline("translation_en_to_es", model="Helsinki-NLP/opus-mt-en-es")
211
 
212
  @st.cache_resource(show_spinner="Cargando modelo de embeddings...")
213
  def load_embedding_model():
214
- return SentenceTransformer('all-MiniLM-L6-v2')
 
 
 
 
215
 
216
  @lru_cache(maxsize=1000)
217
- def traducir_texto_cached(texto):
218
- """Cachea traducciones para mejorar rendimiento"""
219
- if not texto or pd.isna(texto) or str(texto).strip() == '':
220
- return ""
 
 
 
 
 
221
  try:
222
- texto = str(texto)
223
- if len(texto) > 500:
224
- texto = texto[:497] + "..."
225
- translator = load_translator()
226
- return translator(texto, max_length=512)[0]['translation_text']
227
- except Exception:
228
- return texto
229
-
230
- def load_image_from_url(url, max_size=(400, 300)):
231
- """Cargar imagen desde URL con manejo de errores"""
232
- try:
233
- if not url or not isinstance(url, str) or not url.startswith('http'):
234
- return None
235
-
236
- response = requests.get(url, timeout=5)
237
- response.raise_for_status()
238
-
239
- img = Image.open(BytesIO(response.content))
240
-
241
- # Convertir a RGB si es necesario
242
- if img.mode in ('RGBA', 'LA', 'P'):
243
- img = img.convert('RGB')
244
-
245
- # Redimensionar manteniendo aspecto
246
- img.thumbnail(max_size, Image.Resampling.LANCZOS)
247
-
248
- return img
249
- except Exception:
250
- return None
251
 
252
  # ==================== CARGA DE DATOS ====================
253
 
254
  @st.cache_data(show_spinner="Cargando y procesando datos de recetas...")
255
  def load_and_preprocess_data():
256
- """Carga y preprocesa los datos con validación robusta"""
257
  try:
258
- # Cargar dataset
259
- st.info("Descargando dataset de recetas... Esto puede tomar unos segundos.")
260
- ds = load_dataset("AkashPS11/recipes_data_food.com", trust_remote_code=True)
261
- df = ds['train'].to_pandas() if 'train' in ds else ds.to_pandas()
262
 
263
- # Limitar tamaño para mejor rendimiento pero mantener variedad
264
  df = df.head(8000).copy()
265
 
266
  # Procesar ingredientes
267
- df['ingredients_parsed'] = df['RecipeIngredientParts'].apply(parse_ingredient_string)
268
- df['ingredients_str'] = df['ingredients_parsed'].apply(
269
- lambda x: ' '.join([str(i).lower() for i in x]) if x else ''
270
- )
271
-
272
- # Procesar cantidades
273
- df['quantities_parsed'] = df['RecipeIngredientQuantities'].apply(parse_ingredient_string)
274
 
275
  # Procesar instrucciones
276
- df['instructions_parsed'] = df['RecipeInstructions'].apply(parse_instruction_string)
277
-
278
- # Procesar imágenes
279
- df['images_parsed'] = df['Images'].apply(parse_image_string)
280
 
281
- # Filtrar recetas saludables
282
- mask = (
283
- (df['Calories'] < 800) |
284
- df['Keywords'].str.contains('healthy|low fat|vegan|low calorie|vegetarian',
285
- na=False, case=False, regex=True)
286
- )
287
- df = df[mask].copy()
288
 
289
- # Procesar tiempos con manejo de errores
290
- def parse_time(time_str):
291
- try:
292
- if pd.isna(time_str) or not isinstance(time_str, str):
293
- return 0
294
- return parse_duration(time_str).total_seconds() / 60
295
- except (ISO8601Error, AttributeError, TypeError):
296
- return 0
297
 
298
- df['total_minutes'] = df['TotalTime'].apply(parse_time)
299
- df['prep_minutes'] = df['PrepTime'].apply(parse_time)
300
- df['cook_minutes'] = df['CookTime'].apply(parse_time)
301
-
302
- # Limpiar valores NaN
303
- numeric_cols = ['Calories', 'FatContent', 'SugarContent', 'ProteinContent', 'AggregatedRating']
304
- for col in numeric_cols:
305
- if col in df.columns:
306
- df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
307
-
308
- # Pre-calcular embeddings para mejor rendimiento
309
- st.info("Calculando embeddings para búsqueda rápida...")
310
  model = load_embedding_model()
311
- ingredients_texts = df['ingredients_str'].fillna('').tolist()
312
-
313
- # Calcular embeddings en lotes para evitar memory error
314
  batch_size = 100
315
  embeddings = []
316
  for i in range(0, len(ingredients_texts), batch_size):
317
  batch = ingredients_texts[i:i+batch_size]
318
  batch_embeddings = model.encode(batch, show_progress_bar=False)
319
  embeddings.extend(batch_embeddings)
 
320
 
321
- df['embedding'] = list(embeddings)
322
-
323
- st.success(f"✅ Dataset cargado: {len(df)} recetas procesadas")
324
  return df
325
 
326
  except Exception as e:
327
- st.error(f"Error crítico al cargar datos: {str(e)}")
328
- st.error(traceback.format_exc())
329
  return pd.DataFrame()
330
 
331
  # ==================== FUNCIONES DE RECOMENDACIÓN ====================
332
 
333
- def recommend_recipes_optimized(user_ingredients, category="", top_k=5, max_cal=500, is_vegan=False, max_time=60):
334
- """Función de recomendación optimizada con embeddings pre-calculados"""
335
  try:
336
  if df.empty:
337
  return pd.DataFrame()
338
 
339
- # Crear embedding de consulta del usuario
340
  model = load_embedding_model()
341
- user_text = ' '.join([str(i).lower() for i in user_ingredients])
342
  user_embedding = model.encode(user_text)
343
 
344
- # Calcular similitudes usando embeddings pre-calculados
345
- embeddings = np.vstack(df['embedding'].values)
346
  similarities = cosine_similarity([user_embedding], embeddings)[0]
347
  df['similarity'] = similarities
348
 
349
- # Aplicar filtros
350
- mask = (
351
- (df['Calories'] <= max_cal) &
352
- (df['total_minutes'] <= max_time) &
353
- (df['similarity'] > 0.1) # Umbral más bajo para más resultados
354
- )
355
 
356
- if category and category != "":
357
- mask &= df['RecipeCategory'].str.contains(category, case=False, na=False)
 
 
358
  if is_vegan:
359
- mask &= df['Keywords'].str.contains('vegan', case=False, na=False)
360
-
361
- filtered = df[mask].copy()
362
 
 
363
  if filtered.empty:
364
- # Relajar filtros si no hay resultados
365
- st.warning("No se encontraron recetas con esos filtros estrictos. Mostrando recetas más similares...")
366
- filtered = df[df['similarity'] > 0.05].copy()
367
 
368
- # Ordenar y retornar
369
- recs = filtered.nlargest(top_k, ['similarity', 'AggregatedRating'])
370
  return recs
371
 
372
  except Exception as e:
373
  st.error(f"Error en recomendación: {str(e)}")
374
  return pd.DataFrame()
375
 
376
- def get_similar_recipes(recipe_index, top_n=5):
377
- """Obtener recetas similares a una receta específica"""
378
- try:
379
- if df.empty or recipe_index not in df.index:
380
- return pd.DataFrame()
381
-
382
- # Obtener embedding de la receta de referencia
383
- target_embedding = df.loc[recipe_index, 'embedding'].reshape(1, -1)
384
-
385
- # Calcular similitudes con todas las recetas
386
- embeddings = np.vstack(df['embedding'].values)
387
- similarities = cosine_similarity(target_embedding, embeddings)[0]
388
-
389
- # Obtener índices de las recetas más similares (excluyendo la receta misma)
390
- similar_indices = np.argsort(similarities)[::-1][1:top_n+1]
391
- similar_recipes = df.iloc[similar_indices].copy()
392
- similar_recipes['similarity_to_recipe'] = similarities[similar_indices]
393
-
394
- return similar_recipes
395
-
396
- except Exception as e:
397
- st.error(f"Error al buscar recetas similares: {str(e)}")
398
- return pd.DataFrame()
399
-
400
  # ==================== INTERFAZ STREAMLIT ====================
401
 
402
- # Título y descripción
403
- st.title("🍎 Generador Inteligente de Recetas Saludables")
404
- st.markdown("""
405
- <div style='background-color: #e8f5e9; padding: 1rem; border-radius: 10px; margin: 1rem 0;'>
406
- <h4 style='color: #2e7d32; margin: 0;'>✨ Instrucciones de uso:</h4>
407
- <ol style='margin: 0.5rem 0 0 0; color: #555;'>
408
- <li>Ingresa los ingredientes que tienes disponibles (separados por comas)</li>
409
- <li>Ajusta los filtros según tus preferencias dietéticas</li>
410
- <li>Haz clic en "🔍 Buscar Recetas" para obtener recomendaciones personalizadas</li>
411
- <li>Explora cada receta haciendo clic en los detalles y busca recetas similares</li>
412
- </ol>
413
- </div>
414
- """, unsafe_allow_html=True)
415
 
416
- # Cargar datos con spinner
417
- with st.spinner("Cargando base de datos de recetas..."):
418
  df = load_and_preprocess_data()
419
 
420
  if df.empty:
421
- st.error("No se pudieron cargar los datos. Por favor, recarga la página.")
422
  st.stop()
423
 
424
- # ==================== BARRA LATERAL ====================
425
  with st.sidebar:
426
- st.header("⚙️ Filtros Avanzados")
427
-
428
- st.subheader("Preferencias Dietéticas")
429
- max_cal = st.slider(
430
- "Calorías máximas por porción",
431
- 100, 1000, 500,
432
- help="Limita las recetas por contenido calórico"
433
- )
434
- max_time = st.slider(
435
- "Tiempo máximo de preparación (minutos)",
436
- 10, 180, 60,
437
- help="Incluye tiempo de preparación y cocción"
438
- )
439
-
440
- col1, col2 = st.columns(2)
441
- with col1:
442
- is_vegan = st.checkbox("🌱 Vegano", help="Solo recetas veganas")
443
- with col2:
444
- is_healthy = st.checkbox("💚 Saludable", value=True,
445
- help="Priorizar recetas marcadas como saludables")
446
-
447
- st.subheader("Resultados")
448
- top_k = st.select_slider(
449
- "Número de recetas a mostrar",
450
- options=[3, 5, 7, 10],
451
- value=5
452
- )
453
-
454
- similar_recipes_count = st.slider(
455
- "Recetas similares a mostrar",
456
- 2, 8, 3,
457
- help="Número de recetas similares para mostrar en cada receta"
458
- )
459
-
460
- st.markdown("---")
461
- st.markdown("### 📊 Estadísticas del Dataset")
462
- st.metric("Recetas disponibles", len(df))
463
- st.metric("Calorías promedio", f"{df['Calories'].mean():.0f}")
464
- st.metric("Tiempo promedio", f"{df['total_minutes'].mean():.0f} min")
465
-
466
- # ==================== ENTRADA PRINCIPAL ====================
467
- st.header("🔍 Buscar Recetas por Ingredientes")
468
-
469
- col1, col2, col3 = st.columns([3, 2, 1])
470
- with col1:
471
- user_input = st.text_input(
472
- "Ingredientes disponibles (separados por comas):",
473
- "chicken, rice, vegetables",
474
- placeholder="Ej: tomate, pollo, arroz, cebolla, aceite de oliva"
475
- )
476
- with col2:
477
- # Extraer categorías únicas del dataset
478
- unique_categories = [""] + sorted(df['RecipeCategory'].dropna().unique().tolist()[:20])
479
- category_input = st.selectbox(
480
- "Categoría (opcional):",
481
- unique_categories,
482
- format_func=lambda x: "Todas las categorías" if x == "" else x[:30]
483
- )
484
- with col3:
485
- st.markdown("<br>", unsafe_allow_html=True)
486
- search_clicked = st.button("🔍 Buscar Recetas", use_container_width=True)
487
-
488
- # ==================== RESULTADOS ====================
489
- if search_clicked and user_input:
490
- with st.spinner("Buscando recetas que coincidan con tus ingredientes..."):
491
- ingredients = [ing.strip() for ing in user_input.split(',') if ing.strip()]
492
-
493
- if not ingredients:
494
- st.warning("Por favor, ingresa al menos un ingrediente.")
495
- else:
496
- recs = recommend_recipes_optimized(
497
- ingredients,
498
- category_input,
499
- top_k=top_k,
500
- max_cal=max_cal,
501
- is_vegan=is_vegan,
502
- max_time=max_time
503
- )
504
-
505
- st.session_state.recommendations = recs
506
- st.session_state.search_made = True
507
-
508
- if 'recommendations' in st.session_state and not st.session_state.recommendations.empty:
509
- recs = st.session_state.recommendations
510
-
511
- st.success(f"✅ Encontradas {len(recs)} recetas que coinciden con tus criterios")
512
-
513
- # Gráfico de visualización
514
- if len(recs) > 1:
515
- fig = px.scatter(
516
- recs,
517
- x='total_minutes',
518
- y='Calories',
519
- size='similarity',
520
- color='RecipeCategory',
521
- hover_name='Name',
522
- title="📊 Distribución de Recetas Encontradas",
523
- labels={
524
- 'total_minutes': 'Tiempo Total (minutos)',
525
- 'Calories': 'Calorías',
526
- 'RecipeCategory': 'Categoría'
527
- }
528
- )
529
- fig.update_layout(
530
- paper_bgcolor='rgba(0,0,0,0)',
531
- plot_bgcolor='rgba(0,0,0,0)',
532
- font_color='#333'
533
- )
534
- st.plotly_chart(fig, use_container_width=True)
535
-
536
- # Mostrar recetas
537
- st.header("🍽️ Recetas Recomendadas")
538
-
539
- for idx, row in recs.iterrows():
540
- recipe_index = row.name # Índice en el DataFrame original
541
-
542
- with st.container():
543
- st.markdown(f"<div class='recipe-card'>", unsafe_allow_html=True)
544
-
545
- # Título traducido
546
- recipe_name = traducir_texto_cached(row['Name'])
547
- st.markdown(f"### {recipe_name}")
548
-
549
- # Metadatos en columnas
550
- col_meta1, col_meta2, col_meta3, col_meta4 = st.columns(4)
551
- with col_meta1:
552
- st.metric("🔥 Calorías", f"{row['Calories']:.0f}")
553
- with col_meta2:
554
- st.metric("⏱️ Tiempo", f"{row['total_minutes']:.0f} min")
555
- with col_meta3:
556
- st.metric("⭐ Similitud", f"{row['similarity']:.3f}")
557
- with col_meta4:
558
- if 'AggregatedRating' in row and row['AggregatedRating'] > 0:
559
- st.metric("★ Valoración", f"{row['AggregatedRating']:.1f}/5")
560
- else:
561
- st.metric("★ Valoración", "N/A")
562
-
563
- # Contenedor principal con columnas para imagen y contenido
564
- col_img, col_content = st.columns([1, 2])
565
-
566
- with col_img:
567
- # Mostrar imagen si está disponible
568
- try:
569
- images = row['images_parsed'] if 'images_parsed' in row else []
570
- if images and len(images) > 0:
571
- img_url = images[0]
572
- img = load_image_from_url(img_url)
573
- if img:
574
- st.image(img, caption="Imagen de referencia", use_column_width=True)
575
- else:
576
- # Mostrar placeholder si no se puede cargar la imagen
577
- st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w=400&h=300&fit=crop",
578
- caption="Imagen representativa", use_column_width=True)
579
- else:
580
- st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w-400&h=300&fit=crop",
581
- caption="Imagen representativa", use_column_width=True)
582
- except Exception:
583
- st.image("https://images.unsplash.com/photo-1490818387583-1baba5e638af?w=400&h=300&fit=crop",
584
- caption="Imagen representativa", use_column_width=True)
585
-
586
- with col_content:
587
- # Descripción
588
- if pd.notna(row.get('Description')) and str(row['Description']).strip():
589
- with st.expander("📝 Descripción", expanded=False):
590
- desc = traducir_texto_cached(row['Description'])
591
- st.write(desc)
592
-
593
- # Ingredientes con cantidades
594
- with st.expander("🛒 Ingredientes", expanded=False):
595
- try:
596
- ingredients_list = row['ingredients_parsed'] if 'ingredients_parsed' in row else []
597
- quantities_list = row['quantities_parsed'] if 'quantities_parsed' in row else []
598
-
599
- if ingredients_list and len(ingredients_list) > 0:
600
- # Mostrar ingredientes con cantidades si están disponibles
601
- if quantities_list and len(quantities_list) == len(ingredients_list):
602
- for qty, ing in zip(quantities_list, ingredients_list):
603
- ing_translated = traducir_texto_cached(ing)
604
- st.markdown(f"""
605
- <div class='ingredient-item'>
606
- <strong>{qty}</strong> - {ing_translated}
607
- </div>
608
- """, unsafe_allow_html=True)
609
- else:
610
- # Mostrar solo ingredientes
611
- for ing in ingredients_list:
612
- ing_translated = traducir_texto_cached(ing)
613
- st.markdown(f"""
614
- <div class='ingredient-item'>
615
- • {ing_translated}
616
- </div>
617
- """, unsafe_allow_html=True)
618
- else:
619
- st.info("No hay información de ingredientes disponible para esta receta.")
620
- except Exception as e:
621
- st.error(f"Error al mostrar ingredientes: {e}")
622
 
623
- # Instrucciones
624
- with st.expander("👩‍🍳 Instrucciones de Preparación", expanded=False):
625
- try:
626
- instructions = row['instructions_parsed'] if 'instructions_parsed' in row else []
627
-
628
- if instructions and len(instructions) > 0:
629
- for i, step in enumerate(instructions, 1):
630
- step_translated = traducir_texto_cached(step)
631
- st.markdown(f"""
632
- <div class='instruction-step'>
633
- <strong>Paso {i}:</strong> {step_translated}
634
- </div>
635
- """, unsafe_allow_html=True)
636
- else:
637
- st.info("No hay instrucciones disponibles para esta receta.")
638
- except Exception as e:
639
- st.error(f"Error al mostrar instrucciones: {e}")
640
 
641
- # Información nutricional
642
- with st.expander("📊 Información Nutricional", expanded=False):
643
- nutri_data = {
644
- 'Nutriente': ['Calorías', 'Grasa Total', 'Azúcares', 'Proteína'],
645
- 'Cantidad': [
646
- f"{row.get('Calories', 0):.0f} kcal",
647
- f"{row.get('FatContent', 0):.1f} g" if pd.notna(row.get('FatContent')) else "N/A",
648
- f"{row.get('SugarContent', 0):.1f} g" if pd.notna(row.get('SugarContent')) else "N/A",
649
- f"{row.get('ProteinContent', 0):.1f} g" if pd.notna(row.get('ProteinContent')) else "N/A"
650
- ]
651
- }
652
- st.table(pd.DataFrame(nutri_data))
653
-
654
- # Recetas similares
655
- with st.expander(f"🔍 Ver {similar_recipes_count} recetas similares", expanded=False):
656
- similar_recipes = get_similar_recipes(recipe_index, top_n=similar_recipes_count)
657
 
658
- if not similar_recipes.empty:
659
- for sim_idx, sim_row in similar_recipes.iterrows():
660
- col_sim1, col_sim2 = st.columns([3, 1])
661
- with col_sim1:
662
- sim_name = traducir_texto_cached(sim_row['Name'])
663
- st.markdown(f"**{sim_name}**")
664
- st.caption(f"Calorías: {sim_row['Calories']:.0f} kcal Tiempo: {sim_row['total_minutes']:.0f} min")
665
- with col_sim2:
666
- st.metric("Similitud", f"{sim_row['similarity_to_recipe']:.3f}")
667
- st.markdown("---")
668
- else:
669
- st.info("No se encontraron recetas similares.")
670
-
671
- st.markdown("</div>", unsafe_allow_html=True)
672
- st.markdown("<br>", unsafe_allow_html=True)
673
-
674
- # ==================== INICIO CON RECETAS DE EJEMPLO ====================
675
- elif 'recommendations' not in st.session_state:
676
- st.info("👈 Ingresa ingredientes y ajusta los filtros para comenzar, o usa nuestro ejemplo:")
677
-
678
- # Mostrar algunas recetas de ejemplo al inicio
679
- example_ingredients = ["chicken", "rice", "vegetables"]
680
-
681
- if st.button("🍗 Usar ejemplo: Pollo con arroz y vegetales"):
682
- with st.spinner("Buscando recetas de ejemplo..."):
683
- example_recs = recommend_recipes_optimized(
684
- example_ingredients,
685
- top_k=3,
686
- max_cal=600,
687
- max_time=90
688
- )
689
- st.session_state.recommendations = example_recs
690
- st.rerun()
691
-
692
- # ==================== PIE DE PÁGINA ====================
693
- st.markdown("---")
694
- st.markdown("""
695
- <div style='text-align: center; color: #666; padding: 1rem;'>
696
- <p>🍎 <strong>Generador de Recetas Saludables con IA</strong> •
697
- Usa modelos de machine learning para encontrar recetas perfectas basadas en tus ingredientes</p>
698
- <p style='font-size: 0.9rem;'>Powered by Hugging Face 🤗 • Sentence Transformers • Streamlit</p>
699
- </div>
700
- """, unsafe_allow_html=True)
 
4
  from sentence_transformers import SentenceTransformer
5
  from sklearn.metrics.pairwise import cosine_similarity
6
  import plotly.express as px
 
7
  import ast
8
  import numpy as np
9
  from transformers import pipeline
 
16
  import requests
17
  from io import BytesIO
18
  import traceback
19
+ import datetime
20
 
21
  warnings.filterwarnings('ignore')
22
 
 
31
  # CSS mejorado
32
  st.markdown("""
33
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  .recipe-card {
35
+ background-color: #f8f9fa;
36
+ padding: 20px;
37
  border-radius: 10px;
38
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
39
+ margin-bottom: 20px;
 
 
40
  }
41
+ .stButton > button {
42
+ width: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
  </style>
45
  """, unsafe_allow_html=True)
 
47
  # ==================== FUNCIONES AUXILIARES ====================
48
 
49
  def parse_ingredient_string(ing_str):
50
+ """Parsear cadena de ingredientes separados por comas"""
51
  try:
52
+ if isinstance(ing_str, str):
53
+ items = [item.strip() for item in ing_str.split(',') if item.strip()]
54
+ return items
55
+ return []
56
+ except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  return []
58
 
59
  def parse_instruction_string(instr_str):
60
+ """Parsear instrucciones en pasos"""
61
  try:
62
+ if isinstance(instr_str, str):
63
+ # Dividir por puntos o números
64
+ steps = re.split(r'\.\s*|\n\s*', instr_str)
65
+ steps = [step.strip() for step in steps if step.strip()]
66
+ return steps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  return []
68
+ except Exception:
69
+ return [str(instr_str)]
 
 
70
 
71
  @st.cache_resource(show_spinner="Cargando modelo de embeddings...")
72
  def load_embedding_model():
73
+ return SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2') # Multilingual for Spanish support
74
+
75
+ @st.cache_resource(show_spinner="Cargando modelo para chatbot...")
76
+ def load_chat_model():
77
+ return pipeline("text-generation", model="flax-community/gpt-2-spanish") # Spanish-capable model for advice
78
 
79
  @lru_cache(maxsize=1000)
80
+ def get_chat_response(query, context=""):
81
+ """Generar respuesta de chatbot con contexto RAG-like"""
82
+ model = load_chat_model()
83
+ prompt = f"Usuario: {query}\nContexto de receta: {context[:500]}\nAsistente: "
84
+ response = model(prompt, max_length=150, num_return_sequences=1)[0]['generated_text']
85
+ return response.split("Asistente: ")[-1].strip()
86
+
87
+ def parse_duration_to_minutes(dur_str):
88
+ """Convertir HH:MM a minutos"""
89
  try:
90
+ if isinstance(dur_str, str) and ':' in dur_str:
91
+ h, m = map(int, dur_str.split(':'))
92
+ return h * 60 + m
93
+ return 0
94
+ except:
95
+ return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  # ==================== CARGA DE DATOS ====================
98
 
99
  @st.cache_data(show_spinner="Cargando y procesando datos de recetas...")
100
  def load_and_preprocess_data():
101
+ """Carga y preprocesa el dataset español"""
102
  try:
103
+ st.info("Descargando dataset de recetas españolas... Esto puede tomar unos segundos.")
104
+ ds = load_dataset("somosnlp/RecetasDeLaAbuela")
105
+ df = ds['train'].to_pandas()
 
106
 
107
+ # Limitar para rendimiento
108
  df = df.head(8000).copy()
109
 
110
  # Procesar ingredientes
111
+ df['ingredients_parsed'] = df['Ingredientes'].apply(parse_ingredient_string)
112
+ df['ingredients_str'] = df['ingredients_parsed'].apply(lambda x: ' '.join(x).lower())
 
 
 
 
 
113
 
114
  # Procesar instrucciones
115
+ df['instructions_parsed'] = df['Pasos'].apply(parse_instruction_string)
 
 
 
116
 
117
+ # Procesar tiempo
118
+ df['total_minutes'] = df['Duracion'].apply(parse_duration_to_minutes)
 
 
 
 
 
119
 
120
+ # Nutricional como filtro saludable (bajo en calorías, etc.)
121
+ df['is_healthy'] = df['Valor nutricional'].str.contains('Bajo en calorías|Bajo en grasas|vegetarianos|vegano', na=False, case=False)
 
 
 
 
 
 
122
 
123
+ # Pre-calcular embeddings
124
+ st.info("Calculando embeddings multilingües para búsqueda rápida...")
 
 
 
 
 
 
 
 
 
 
125
  model = load_embedding_model()
126
+ ingredients_texts = df['ingredients_str'].tolist()
 
 
127
  batch_size = 100
128
  embeddings = []
129
  for i in range(0, len(ingredients_texts), batch_size):
130
  batch = ingredients_texts[i:i+batch_size]
131
  batch_embeddings = model.encode(batch, show_progress_bar=False)
132
  embeddings.extend(batch_embeddings)
133
+ df['embedding'] = embeddings
134
 
135
+ st.success(f"Dataset cargado: {len(df)} recetas procesadas")
 
 
136
  return df
137
 
138
  except Exception as e:
139
+ st.error(f"Error al cargar datos: {str(e)}")
 
140
  return pd.DataFrame()
141
 
142
  # ==================== FUNCIONES DE RECOMENDACIÓN ====================
143
 
144
+ def recommend_recipes_optimized(user_ingredients, category="", top_k=5, is_healthy=True, is_vegan=False, max_time=60):
145
+ """Recomendación con embeddings multilingües (RAG-like para synonyms)"""
146
  try:
147
  if df.empty:
148
  return pd.DataFrame()
149
 
 
150
  model = load_embedding_model()
151
+ user_text = ' '.join(user_ingredients).lower()
152
  user_embedding = model.encode(user_text)
153
 
154
+ embeddings = np.vstack(df['embedding'])
 
155
  similarities = cosine_similarity([user_embedding], embeddings)[0]
156
  df['similarity'] = similarities
157
 
158
+ mask = (df['similarity'] > 0.1) & (df['total_minutes'] <= max_time)
 
 
 
 
 
159
 
160
+ if category:
161
+ mask &= df['Categoria'].str.contains(category, case=False, na=False)
162
+ if is_healthy:
163
+ mask &= df['is_healthy']
164
  if is_vegan:
165
+ mask &= df['Valor nutricional'].str.contains('vegano|vegetarianos', case=False, na=False)
 
 
166
 
167
+ filtered = df[mask]
168
  if filtered.empty:
169
+ st.warning("Relajando filtros...")
170
+ filtered = df[df['similarity'] > 0.05]
 
171
 
172
+ recs = filtered.nlargest(top_k, 'similarity')
 
173
  return recs
174
 
175
  except Exception as e:
176
  st.error(f"Error en recomendación: {str(e)}")
177
  return pd.DataFrame()
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  # ==================== INTERFAZ STREAMLIT ====================
180
 
181
+ st.title("Generador Inteligente de Recetas Saludables")
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ with st.spinner("Cargando base de datos..."):
 
184
  df = load_and_preprocess_data()
185
 
186
  if df.empty:
 
187
  st.stop()
188
 
189
+ # Barra lateral
190
  with st.sidebar:
191
+ st.header("Filtros")
192
+ max_time = st.slider("Tiempo máximo (minutos)", 10, 120, 60)
193
+ is_healthy = st.checkbox("Solo recetas saludables", value=True)
194
+ is_vegan = st.checkbox("Vegano", value=False)
195
+ category = st.text_input("Categoría (ej. postres)")
196
+
197
+ # Entrada usuario
198
+ user_input = st.text_input("Ingresa ingredientes (separados por comas, en español):", "tomate, cebolla, pollo")
199
+ user_ingredients = [i.strip().lower() for i in user_input.split(',') if i.strip()]
200
+
201
+ if st.button("Buscar Recetas"):
202
+ recs = recommend_recipes_optimized(user_ingredients, category, 5, is_healthy, is_vegan, max_time)
203
+
204
+ if not recs.empty:
205
+ for idx, row in recs.iterrows():
206
+ with st.container():
207
+ st.markdown(f"### {row['Nombre']}")
208
+ st.write(f"**Tiempo:** {row['Duracion']} | **Porciones:** {row.get('Comensales', 'N/A')} | **Nutrición:** {row['Valor nutricional']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
+ # Tabla de ingredientes
211
+ ing_df = pd.DataFrame(row['ingredients_parsed'], columns=["Ingrediente"])
212
+ st.table(ing_df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
+ # Instrucciones expandibles
215
+ for i, step in enumerate(row['instructions_parsed'], 1):
216
+ with st.expander(f"Paso {i}"):
217
+ st.write(step)
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
+ # Gráfico simple
220
+ fig = px.bar(x=['Tiempo Total'], y=[row['total_minutes']])
221
+ st.plotly_chart(fig, use_container_width=True)
222
+
223
+ # Chatbot section
224
+ st.header("Chatbot de Consejos")
225
+ chat_input = st.chat_input("Pregunta sobre una receta o modificaciones:")
226
+ if chat_input:
227
+ with st.chat_message("user"):
228
+ st.markdown(chat_input)
229
+ # Usar RAG: contexto de receta similar
230
+ similar_recs = recommend_recipes_optimized(user_input.split(','), top_k=1)
231
+ context = similar_recs['Pasos'].iloc[0] if not similar_recs.empty else ""
232
+ response = get_chat_response(chat_input, context)
233
+ with st.chat_message("assistant"):
234
+ st.markdown(response)