# ============================================================================== # EL DETECTIVE DE ALIMENTOS - EDICIÓN HÍBRIDA CON CORTA-FUEGOS Y ALTA DISPONIBILIDAD # Desarrollado por: Jairo Alexander & Diana Milena Soler Martinez # Motor: Gemini Flash + FooDB.ca Graph + Fast-Path Local Engine # ============================================================================== import streamlit as st from google import genai from google.genai import types import os import json import logging import re import pandas as pd import altair as alt from io import BytesIO import docx import difflib import concurrent.futures # --- CONFIGURACIÓN DE PÁGINA Y LOGGING --- st.set_page_config(page_title="El Detective de Alimentos", page_icon="🍎", layout="wide") logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger("food_detective_app") # --- TEXTO ESTÁTICO DE SEGURIDAD Y BANDERAS ROJAS --- TEXTO_BANDERAS_ROJAS = """ \n### **IMPORTANTE: Descargo de Responsabilidad y Banderas Rojas** Este análisis es una herramienta informativa de IA y **NO es un diagnóstico médico.** **🚩 BANDERAS ROJAS: ¡Atención!** Consulta a un médico si experimentas: 1. **Cáncer Gástrico/Colon:** Pérdida de peso involuntaria, sangre en heces. 2. **Enfermedad Inflamatoria Intestinal:** Diarrea crónica con sangre/moco, fiebre. 3. **Embarazo:** Náuseas matutinas severas, ausencia de menstruación. 4. **Isquemia Mesentérica:** Dolor fuerte 15-30 min después de comer. 5. **Vesícula:** Dolor agudo lado derecho tras grasas, piel/ojos amarillos. **Si tus síntomas son severos, busca ayuda médica urgente.** """ # --- CONFIGURACIÓN DE CLIENTE GEMINI CACHEADO --- @st.cache_resource def get_gemini_client(): api_key = os.getenv("GEMINI_API_KEY") if not api_key: try: if "GEMINI_API_KEY" in st.secrets: api_key = st.secrets["GEMINI_API_KEY"] elif "APIkeydetective" in st.secrets: api_key = st.secrets["APIkeydetective"] except Exception: pass if not api_key: api_key = os.getenv("APIkeydetective") if not api_key: logger.warning("❌ GEMINI_API_KEY no encontrada en variables de entorno ni en secretos.") return None try: # Timeout HTTP a nivel de socket ajustado a 4.0 segundos return genai.Client( api_key=api_key, http_options=types.HttpOptions(timeout=4.0) ) except Exception as e: logger.warning(f"❌ Error instanciando cliente Gemini: {e}") return None # --- CORTA-FUEGOS CON TIEMPO LÍMITE ESTRICTO DE 3.5 SEGUNDOS --- def generate_content_with_fallback(gemini_client, prompt): models_to_try = [ 'gemini-3.5-flash-lite', 'gemini-2.5-flash', 'gemini-1.5-flash' ] for model_name in models_to_try: try: response = gemini_client.models.generate_content( model=model_name, contents=prompt ) if response and response.text: logger.info(f"✅ Respuesta exitosa generada por el modelo: {model_name}") return response.text, model_name except Exception as e: logger.warning(f"⚠️ Modelo {model_name} no disponible ({e}), probando siguiente...") continue raise RuntimeError("Ningún modelo de Gemini respondió adecuadamente.") def generate_content_with_strict_timeout(gemini_client, prompt, timeout_seconds=3.5): """ Ejecuta la llamada a Gemini dentro de un hilo aislado. Si Gemini tarda más de 3.5s, corta la ejecución inmediatamente y activa el motor local sin bloquear la interfaz. """ executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) future = executor.submit(generate_content_with_fallback, gemini_client, prompt) try: result = future.result(timeout=timeout_seconds) executor.shutdown(wait=False) return result except concurrent.futures.TimeoutError: logger.warning(f"⏳ Gemini tardó más de {timeout_seconds}s. Se corta la espera y se activa el Motor Local.") executor.shutdown(wait=False, cancel_futures=True) raise TimeoutError("Tiempo límite de IA superado.") except Exception as e: executor.shutdown(wait=False) raise e # --- CARGA DE DATOS EN MEMORIA --- @st.cache_resource def load_data(): try: path_alimentos = os.path.join('DATOS', 'alimentos_enriquecido.json') path_foodb_index = os.path.join('DATOS', 'foodb_index_smart.json') data_alimentos = [] if os.path.exists(path_alimentos): with open(path_alimentos, 'r', encoding='utf-8') as f: data_alimentos = json.load(f) foodb_index = {} if os.path.exists(path_foodb_index): with open(path_foodb_index, 'r', encoding='utf-8') as f: foodb_index = json.load(f) return data_alimentos, foodb_index except Exception as e: logger.error(f"Error cargando archivos de datos: {e}") return [], {} alimentos_data, foodb_index = load_data() @st.cache_resource def get_foodb_lower_map(_foodb_index): if not _foodb_index: return {} return {str(k).lower(): k for k in _foodb_index.keys()} foodb_lower_map = get_foodb_lower_map(foodb_index) @st.cache_resource def get_global_learned_cache(): return {} global_learned_cache = get_global_learned_cache() # ============================================================================== # DICCIONARIOS MASTER BIOMÉDICOS # ============================================================================== FOOD_TO_COMPOUND_MAP = { # CEREALES Y GRANOS "trigo": ["gluten", "fodmaps", "lectinas", "fitatos"], "pan": ["gluten", "fodmaps", "lectinas"], "pasta": ["gluten", "fodmaps"], "galletas": ["gluten", "fodmaps", "azúcar refinado"], "cebada": ["gluten", "fodmaps", "fitatos"], "centeno": ["gluten", "fodmaps", "fitatos"], "avena": ["fodmaps", "avenina", "fitatos"], "arroz": ["fitatos", "lectinas"], "maíz": ["zeína", "lectinas", "fitatos", "fodmaps"], "quinoa": ["saponinas", "oxalatos", "fitatos"], "alforfón": ["oxalatos", "fagopirina"], # LÁCTEOS "leche": ["caseína", "lactosa", "fodmaps"], "queso": ["tiramina", "histamina", "caseína", "lactosa"], "queso fresco": ["caseína", "lactosa"], "queso maduro": ["tiramina", "histamina", "caseína"], "yogur": ["histamina", "caseína", "lactosa"], "mantequilla": ["caseína", "lactosa"], "crema de leche": ["caseína", "lactosa"], "helado": ["lactosa", "caseína", "azúcar refinado"], # PLATOS Y TÍPICOS DE COLOMBIA "bandeja paisa": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "histamina", "tiramina", "nitritos", "purinas", "zeína", "albúmina", "grasas saturadas"], "ajiaco": ["fodmaps", "solanina", "lactosa", "caseína", "zeína", "purinas", "salicilatos", "histamina"], "sancocho": ["fodmaps", "solanina", "histamina", "tiramina", "purinas", "zeína", "fitatos"], "tamal": ["fodmaps", "lectinas", "zeína", "histamina", "purinas", "salicilatos", "albúmina", "nitritos", "fitatos", "inhibidores_proteasas"], "changua": ["lactosa", "caseína", "albúmina", "gluten", "fodmaps", "compuestos azufrados"], "empanada": ["gluten", "zeína", "solanina", "lectinas"], "arepa": ["zeína", "lectinas", "fitatos"], "lechona": ["histamina", "nitritos", "grasas saturadas", "purinas"], # FRUTAS Y TROPICALES "noni": ["escopoletina", "fenoles", "salicilatos", "fructosa", "histamina"], "chontaduro": ["fenoles", "fructosa", "grasas saturadas", "tiramina", "salicilatos"], "naranja": ["fructosa", "histamina", "salicilatos", "fenoles"], "jugo de naranja": ["fructosa", "histamina", "salicilatos", "fenoles"], "limón": ["salicilatos", "histamina", "fenoles", "ácido cítrico"], "manzana": ["fructosa", "sorbitol", "fodmaps", "salicilatos"], "pera": ["fructosa", "sorbitol", "fodmaps", "salicilatos"], "plátano": ["tiramina", "histamina", "salicilatos", "fenoles"], "banano": ["tiramina", "histamina", "salicilatos", "fenoles"], "aguacate": ["histamina", "tiramina", "salicilatos", "fodmaps"], "fresa": ["histamina", "salicilatos", "oxalatos"], "mora": ["salicilatos", "oxalatos", "histamina", "taninos"], "uva": ["fructosa", "salicilatos", "histamina", "taninos"], "piña": ["salicilatos", "histamina", "fructosa"], "mango": ["fructosa", "salicilatos", "histamina"], "lulo": ["salicilatos", "fructosa", "ácido cítrico"], "maracuyá": ["fructosa", "histamina", "salicilatos"], "maracuya": ["fructosa", "histamina", "salicilatos"], "feijoa": ["fructosa", "salicilatos", "taninos"], "tomate de árbol": ["solanina", "histamina", "salicilatos"], "uchuva": ["solanina", "histamina", "salicilatos", "oxalatos"], # VERDURAS Y SOLANÁCEAS "tomate": ["histamina", "solanina", "salicilatos", "glutamato"], "papa": ["solanina", "oxalatos", "lectinas"], "patata": ["solanina", "oxalatos", "lectinas"], "berenjena": ["solanina", "histamina", "salicilatos"], "pimiento": ["capsaicina", "solanina", "salicilatos"], "espinaca": ["oxalatos", "nitratos", "histamina"], "cebolla": ["fodmaps", "compuestos azufrados", "salicilatos"], "ajo": ["fodmaps", "compuestos azufrados"], "brócoli": ["fodmaps", "compuestos azufrados", "salicilatos", "goitrógenos"], "yuca": ["fodmaps", "fitatos"], "ñame": ["oxalatos", "fitatos", "fodmaps"], # LEGUMBRES (PERFIL COMPLETO) "frijol": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas", "oxalatos", "taninos"], "frijoles": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas", "oxalatos", "taninos"], "frijol paisa": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas", "oxalatos", "taninos"], "lentejas": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas", "oxalatos", "taninos"], "garbanzos": ["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas", "oxalatos"], "arvejas": ["fodmaps", "lectinas", "fitatos", "inhibidores_proteasas"], "soja": ["soja", "isoflavonas", "lectinas", "fitatos", "fodmaps", "saponinas", "goitrógenos", "inhibidores_proteasas"], "tofu": ["soja", "isoflavonas", "lectinas", "fitatos"], "haba": ["vicina_convicina", "fodmaps", "lectinas", "fitatos", "purinas"], "habas": ["vicina_convicina", "fodmaps", "lectinas", "fitatos", "purinas"], # CARNES, EMBUTIDOS Y BEBIDAS "carne de cerdo": ["histamina", "alfa-gal", "grasas saturadas", "purinas"], "cerdo": ["histamina", "alfa-gal", "grasas saturadas", "purinas"], "chicharrón": ["histamina", "grasas saturadas"], "chicharron": ["histamina", "grasas saturadas"], "carne de res": ["histamina", "alfa-gal", "purinas"], "res": ["histamina", "alfa-gal", "purinas"], "café": ["cafeína", "salicilatos", "oxalatos"], "cafe": ["cafeína", "salicilatos", "oxalatos"], "té": ["cafeína", "taninos", "oxalatos", "salicilatos"], "chocolate": ["teobromina", "feniletilamina", "tiramina", "histamina", "oxalatos", "cafeína"], "vino": ["histamina", "tiramina", "sulfitos", "taninos", "alcohol"], "cerveza": ["gluten", "histamina", "sulfitos", "alcohol", "purinas"], "embutidos": ["histamina", "tiramina", "nitritos", "glutamato"], "pescado": ["histamina", "purinas", "albúmina"], "huevo": ["albúmina"] } FOOD_NAME_TO_FOODB_KEY = { "alforfón": ["buckwheat"], "arroz": ["rice"], "avena": ["oat", "oats"], "cebada": ["barley"], "queso": ["cheese"], "leche": ["milk"], "yogur": ["yogurt"], "mantequilla": ["butter"], "vino": ["wine"], "cerveza": ["beer"], "chocolate": ["chocolate", "cocoa"], "tomate": ["tomato"], "aguacate": ["avocado"], "plátano": ["banana", "plantain"], "banano": ["banana"], "naranja": ["orange"], "pan": ["bread", "wheat"], "trigo": ["wheat"], "café": ["coffee"], "cafe": ["coffee"], "manzana": ["apple"], "fresa": ["strawberry"], "uva": ["grape"], "piña": ["pineapple"], "papa": ["potato"], "patata": ["potato"], "frijol": ["bean", "beans", "kidney bean", "black bean"], "frijoles": ["bean", "beans"], "frijol paisa": ["bean", "pork", "rice"], "lentejas": ["lentil"], "garbanzos": ["chickpea", "garbanzo"], "arveja": ["pea"], "haba": ["broad bean", "fava bean"], "habas": ["broad bean", "fava bean"], "bandeja paisa": ["bean", "pork", "rice", "banana", "egg", "avocado"], "ajiaco": ["potato", "chicken", "corn", "milk", "cream"], "sancocho": ["cassava", "plantain", "potato", "pork", "beef", "chicken"], "tamal": ["corn", "pork", "chicken", "pea"], "changua": ["milk", "egg", "bread", "onion", "coriander"] } STRICT_COMPOUND_TRIGGERS = { "cafeína": ["cafeína", "caffeine", "café", "cafe", "coffee"], "teobromina": ["teobromina", "theobromine", "cacao", "chocolate"], "solanina": ["solanina", "solanine", "solanáceas", "tomate", "patata", "papa"], "capsaicina": ["capsaicina", "capsaicin", "chile", "ají", "picante"], "gluten": ["gluten", "trigo", "wheat", "cebada", "centeno"], "caseína": ["caseína", "casein", "queso", "lácteos"], "soja": ["soja", "soya", "soy", "tofu", "tempeh"], "albúmina": ["albúmina", "albumin", "huevo"], "lactosa": ["lactosa", "lactose"], "fructosa": ["fructosa", "fructose"], "fodmaps": ["fodmap", "fodmaps", "fructanos", "galactanos", "sorbitol", "rafinosa", "estaquiosa"], "histamina": ["histamina", "histamine", "dao"], "tiramina": ["tiramina", "tyramine"], "oxalatos": ["oxalato", "oxalatos", "oxalate"], "saponinas": ["saponina", "saponinas"], "lectinas": ["lectina", "lectinas", "frijoles", "legumbres"], "fitatos": ["fitato", "fitatos", "ácido fítico"], "salicilatos": ["salicilato", "salicilatos"], "sulfitos": ["sulfito", "sulfitos"], "glutamato": ["glutamato", "glutamate", "msg"], "nitritos": ["nitrito", "nitritos", "embutidos"], "alcohol": ["alcohol", "etanol", "cerveza", "vino"], "purinas": ["purina", "purinas", "ácido úrico", "gota"], "inhibidores_proteasas": ["inhibidor de tripsina", "inhibidores de proteasas", "tripsina"], "vicina_convicina": ["vicina", "convicina", "favismo", "g6pd", "haba", "habas"] } MEDICAL_CONDITION_SAFEGUARDS = { "farmacos_interacciones": { "condition_triggers": ["fármaco", "farmaco", "medicamento", "imao", "anticoagulante"], "required_user_keywords": ["fármaco", "farmaco", "medicamento", "imao", "pastilla", "remedio", "receta"] }, "respiratorio_asma": { "condition_triggers": ["asma", "sibilancia", "broncoespasmo"], "required_user_keywords": ["asma", "tos", "respirar", "pecho", "sibilancia", "ahogo"] }, "dermatologico_alergia": { "condition_triggers": ["urticaria", "angioedema", "anafilaxia", "dermatitis"], "required_user_keywords": ["piel", "pica", "picazón", "manchas", "rojo", "alergia", "ronchas"] }, "articular_gota": { "condition_triggers": ["gota", "artritis", "hiperuricemia"], "required_user_keywords": ["articulación", "coyuntura", "dedo gordo", "rodilla", "gota"] }, "renal_litiasis": { "condition_triggers": ["litiasis", "cálculo renal", "piedras en el riñón"], "required_user_keywords": ["riñón", "riñon", "orina", "orinar", "cálculo", "piedra"] }, "embarazo_lactancia": { "condition_triggers": ["embarazo", "gestación", "lactancia"], "required_user_keywords": ["embarazada", "embarazo", "bebé", "lactando"] }, "hepato_vesicular": { "condition_triggers": ["vesícula", "vesicula", "colelitiasis", "hígado"], "required_user_keywords": ["vesícula", "vesicula", "hígado", "higado", "piel amarilla"] } } INTEGRATED_NEURO_FOOD_MAP = { "gluten": { "efecto_neuropsicologico": "En individuos sensibles, puede desencadenar neuroinflamación manifestada como niebla mental, depresión, ansiedad, migrañas y ataxia.", "fuentes_comunes": ["pan", "trigo", "pasta", "cebada", "centeno"] }, "caseína": { "efecto_neuropsicologico": "Sus péptidos (casomorfinas) pueden actuar sobre receptores opioides en el SNC, afectando la cognición y el estado de ánimo.", "fuentes_comunes": ["leche", "queso", "yogur"] }, "fodmaps": { "efecto_neuropsicologico": "Mediante el eje intestino-cerebro, la fermentación intestinal excesiva por oligosacáridos (GOS/rafinosa) genera distensión visceral, enviando señales de estrés por el nervio vago que aumentan la ansiedad e hipersensibilidad.", "fuentes_comunes": ["frijoles", "lentejas", "cebolla", "ajo", "trigo"] }, "inhibidores_proteasas": { "efecto_neuropsicologico": "Impiden la descomposición enzimática adecuada de proteínas, produciendo indigestión severa, hipoperfusión gastrointestinal y letargo posprandial.", "fuentes_comunes": ["frijoles", "legumbres mal cocidas", "soja"] }, "vicina_convicina": { "efecto_neuropsicologico": "En personas con deficiencia de la enzima G6PD (favismo), desencadena estrés oxidativo eritrocitario que produce cefalea vascular, astenia y mareos.", "fuentes_comunes": ["habas"] }, "tiramina": { "efecto_neuropsicologico": "Amina vasoactiva. Provoca vasoconstricción inicial seguida de vasodilatación cerebral, desencadenando migrañas.", "fuentes_comunes": ["quesos madurados", "vino tinto", "embutidos"] }, "histamina": { "efecto_neuropsicologico": "Neurotransmisor estimulante central. Su exceso por bajo DAO provoca migrañas, insomnio, fragmentación del sueño y ansiedad.", "fuentes_comunes": ["queso", "vino tinto", "tomate", "pescado azul", "conservas"] }, "salicilatos": { "efecto_neuropsicologico": "En personas sensibles provocan estimulación excesiva del SNC, hiperactividad e insomnio.", "fuentes_comunes": ["cítricos", "manzanas", "tomate", "especias", "té"] }, "fenoles": { "efecto_neuropsicologico": "La sobrecarga de fenoles satura las vías de sulfatación hepática, acumulando catecolaminas que producen agitación nocturna e insomnio.", "fuentes_comunes": ["jugo de naranja", "chontaduro", "noni", "té"] }, "cafeína": { "efecto_neuropsicologico": "Bloquea los receptores de adenosina en el cerebro. Provoca hiperactivación del SNC, ansiedad e insomnio.", "fuentes_comunes": ["café", "té negro", "energizantes", "chocolate"] }, "lactosa": { "efecto_neuropsicologico": "La malabsorción provoca disbiosis e inflamación sistémica leve que se traduce en fatiga y letargo posprandial.", "fuentes_comunes": ["leche", "queso fresco", "crema de leche"] }, "fructosa": { "efecto_neuropsicologico": "Disminuye la biodisponibilidad de triptófano en el intestino, interfiriendo en la síntesis de serotonina y melatonina.", "fuentes_comunes": ["jugos concentrados", "miel", "frutas muy dulces"] }, "solanina": { "efecto_neuropsicologico": "Glicoalcaloide con leve efecto inhibidor de la acetilcolinesterasa; puede provocar letargo y rigidez corporal.", "fuentes_comunes": ["tomate", "patata", "berenjena"] } } CONSEJOS_POR_COMPUESTO = { "fodmaps": "Para reducir los GOS (rafinosa y estaquiosa) de los frijoles y legumbres, realiza un remojo de 12 a 24 horas desechando el agua, y cocina a alta presión adicionando comino o epazote.", "inhibidores_proteasas": "La cocción prolongada y el calor de la olla a presión inactivan casi por completo los inhibidores de tripsina en legumbres.", "vicina_convicina": "Si padeces favismo (deficiencia de G6PD), evita de forma estricta el consumo de habas.", "gluten": "La respuesta al gluten debe diferenciar Enfermedad Celíaca, SGNC y alergia al trigo. Realiza pruebas serológicas antes de retirarlo.", "lactosa": "La intolerancia se debe a baja lactasa. Se puede manejar con lactasa exógena o productos deslactosados.", "histamina": "La acumulación de histamina se asocia a bajo DAO. Una dieta baja en histamina y rica en vitamina B6 y C ayuda a restaurar el equilibrio.", "tiramina": "Controlar el consumo de alimentos añejados previene picos de presión arterial y cefaleas.", "salicilatos": "Moderar el consumo concentrado de jugos cítricos y especias ayuda a estabilizar el sueño.", "cafeína": "Moderar su consumo antes de mitad de tarde protege la arquitectura del sueño." } CONSEJO_GENERICO_DEFAULT = "Cada organismo posee una individualidad bioquímica única según su perfil enzimático y digestivo. Monitorear tus respuestas personales te permitirá adaptar la dieta a tu umbral de tolerancia." FOODB_EFFECTS_TRANSLATION = { "flatugenic": "genera gases/flatulencia", "antioxidant": "antioxidante", "hypercholesterolemic": "puede elevar colesterol", "triglycerigenic": "puede elevar triglicéridos", "uricogenic": "puede elevar ácido úrico", "vasodilator": "vasodilatador (posible migraña)", "laxative": "efecto laxante", "diuretic": "efecto diurético", "stimulant": "estimulante del SNC", "antiinflammatory": "antiinflamatorio" } COMPOUND_SYNONYM_MAP = { "gluten": ["gluten", "gliadin"], "caseína": ["casein"], "lactosa": ["lactose"], "fructosa": ["fructose"] } MASTER_SYMPTOM_MAP = { "dolor abdominal": { "frases_es": ["dolor de estómago", "dolor de panza", "dolor abdominal", "retortijones", "cólicos", "gastritis", "acidez", "reflujo", "pesadez"], "keywords_en": ["abdominal", "pain", "stomach", "cramp"] }, "hinchazón": { "frases_es": ["hinchazón abdominal", "vientre hinchado", "distensión abdominal", "hinchazón", "distensión", "inflamado", "gases", "flatulencia"], "keywords_en": ["bloating", "distension", "flatulence"] }, "diarrea": { "frases_es": ["diarrea", "diarreas", "soltura", "estómago suelto", "deposiciones líquidas"], "keywords_en": ["diarrhea", "loose stools"] }, "náuseas": { "frases_es": ["náuseas", "nauseas", "vómito", "vomito", "ganas de vomitar"], "keywords_en": ["nausea", "vomiting"] }, "dolor de cabeza": { "frases_es": ["dolor de cabeza", "migraña", "cefalea", "jaqueca", "me duele la cabeza"], "keywords_en": ["headache", "migraine"] }, "ansiedad": { "frases_es": ["ansiedad", "nerviosismo", "angustia", "irritabilidad"], "keywords_en": ["anxiety"] }, "insomnio": { "frases_es": ["insomnio", "no puedo dormir", "desvelado", "no duermo", "desvelo"], "keywords_en": ["insomnia"] }, "alergia cutánea": { "frases_es": ["piel", "rasca", "picazón", "urticaria", "erupción", "alergia", "ronchas"], "keywords_en": ["rash", "itching"] } } # ============================================================================== # FUNCIONES AUXILIARES Y DE BÚSQUEDA # ============================================================================== def sanitize_text(text): if not text: return "" text_clean = re.sub(r'[.,;()¿?¡!:"\'\-]', ' ', text).lower() text_clean = re.sub(r'\s+', ' ', text_clean).strip() return text_clean STOPWORDS_ES = { "de", "la", "el", "los", "las", "un", "una", "unos", "unas", "y", "o", "u", "en", "con", "por", "para", "que", "se", "su", "sus", "al", "del", "lo", "como", "es", "son", "muy", "mas", "más", "no", "si", "sí", "tu", "mi", "me", "te", "le", "les", "nos", "cuando", "donde", "porque", "pero", "tomo", "como", "da" } def extract_keywords(text): words = re.findall(r'\b\w+\b', sanitize_text(text)) return set(w for w in words if w not in STOPWORDS_ES and len(w) > 2) def get_compounds_for_food(food_name, food_map): if not food_name: return [] food_clean = food_name.lower().strip() if food_clean in food_map: return food_map[food_clean] for suffix in ["es", "s"]: if food_clean.endswith(suffix): norm = food_clean[:-len(suffix)] if norm in food_map: return food_map[norm] fuzzy_matches = difflib.get_close_matches(food_clean, list(food_map.keys()), n=1, cutoff=0.6) if fuzzy_matches: return food_map[fuzzy_matches[0]] matched_compounds = set() for key, compounds in food_map.items(): if re.search(r'\b' + re.escape(key) + r'\b', food_clean) or food_clean in key: matched_compounds.update(compounds) if matched_compounds: return list(matched_compounds) inferred = set() if any(w in food_clean for w in ["frijol", "lenteja", "garbanzo", "legumbre", "soja", "haba", "arveja"]): inferred.update(["fodmaps", "lectinas", "fitatos", "saponinas", "inhibidores_proteasas", "purinas"]) elif any(w in food_clean for w in ["carne", "cerdo", "res", "pollo", "pavo", "chicharrón"]): inferred.update(["histamina", "purinas"]) elif any(w in food_clean for w in ["jugo", "zumo", "batido", "fruta"]): inferred.update(["fructosa", "salicilatos", "histamina", "fenoles"]) elif any(w in food_clean for w in ["queso", "leche", "crema", "yogur"]): inferred.update(["caseína", "lactosa", "histamina", "tiramina"]) return list(inferred) def validar_seguro_logico(entry, user_foods, user_query, food_compound_map): if not user_foods: return True user_query_clean = user_query.lower() if user_query else "" texto_condicion = (entry.get("condicion_asociada", "") + " " + entry.get("compuesto_alimento", "")).lower() for category, rules in MEDICAL_CONDITION_SAFEGUARDS.items(): if any(trig in texto_condicion for trig in rules["condition_triggers"]): tiene_contexto = any(req_kw in user_query_clean for req_kw in rules["required_user_keywords"]) if not tiene_contexto: return False user_compounds = set() for food in user_foods: comps = get_compounds_for_food(food, food_compound_map) user_compounds.update(comps) user_compounds.add(food.lower().strip()) for compound_key, synonyms in STRICT_COMPOUND_TRIGGERS.items(): if any(syn in texto_condicion for syn in synonyms): tiene_compuesto = any( syn in user_compounds or any(syn in c.lower() for c in user_compounds) for syn in synonyms ) if not tiene_compuesto: return False return True def extract_entities_with_gemini(query): gemini_client = get_gemini_client() if not gemini_client: return {"alimentos": [], "sintomas": []} system_prompt = f""" Rol: Extractor Médico JSON de alimentos y síntomas. Extrae un JSON estricto con dos claves: "alimentos" (lista) y "sintomas" (lista). Input: "{query}" """ try: raw_text, _ = generate_content_with_strict_timeout(gemini_client, system_prompt, timeout_seconds=3.5) text = raw_text if "```json" in text: text = text.split("```json")[1].split("```")[0] elif "```" in text: text = text.split("```")[1].split("```")[0] return json.loads(text) except Exception as e: logger.warning(f"Extracción por IA no disponible o cancelada ({e}). Se usa extracción directa local.") return {"alimentos": [], "sintomas": []} def reinforce_entities_with_keywords(entities, query, food_map, master_symptom_map): if not entities: entities = {"alimentos": [], "sintomas": []} query_sanitized = sanitize_text(query) current_foods = set(entities.get("alimentos", [])) for food_keyword in food_map.keys(): if re.search(r'\b' + re.escape(food_keyword) + r'\b', query_sanitized): current_foods.add(food_keyword) entities["alimentos"] = list(current_foods) current_symptoms = set(entities.get("sintomas", [])) for main_symptom, details in master_symptom_map.items(): for phrase in details.get("frases_es", []): if phrase in query_sanitized: current_symptoms.add(main_symptom) break entities["sintomas"] = list(current_symptoms) return entities def translate_symptoms_local(symptoms_list, master_symptom_map): if not symptoms_list: return [] translated_symptoms = set() inverse_index = {} for clinical_term, details in master_symptom_map.items(): inverse_index[clinical_term.lower()] = clinical_term for phrase in details.get("frases_es", []): inverse_index[phrase.lower()] = clinical_term for symptom in symptoms_list: symptom_clean = symptom.lower().strip() if symptom_clean in inverse_index: translated_symptoms.add(inverse_index[symptom_clean]) else: translated_symptoms.add(symptom) return list(translated_symptoms) def find_best_matches_hybrid(entities, data, user_query="", food_compound_map=None): if not entities or not data: return [] user_symptoms_list = entities.get("sintomas", []) user_foods_list = entities.get("alimentos", []) if not user_foods_list: return [] expanded_foods = list(user_foods_list) if food_compound_map: for food in user_foods_list: expanded_foods.extend(get_compounds_for_food(food, food_compound_map)) food_keywords = extract_keywords(" ".join(expanded_foods)) symptom_keywords = extract_keywords(" ".join(user_symptoms_list)) results = [] for entry in data: if food_compound_map and not validar_seguro_logico(entry, user_foods_list, user_query, food_compound_map): continue db_food_keywords = extract_keywords(entry.get("compuesto_alimento", "")) db_symptom_keywords = extract_keywords(" ".join(entry.get("sintomas_clave", []))) food_intersection = food_keywords.intersection(db_food_keywords) symptom_intersection = symptom_keywords.intersection(db_symptom_keywords) if not food_intersection or not symptom_intersection: continue score = (len(food_intersection) ** 2) * 10 + (len(symptom_intersection) ** 2) * 5 if score > 10: results.append({ 'entry': entry, 'score': {'total': score}, 'matched_keywords': list(food_intersection | symptom_intersection) }) return sorted(results, key=lambda x: x['score']['total'], reverse=True) def build_local_hypothesis(entities, food_compound_map, neuro_map): user_foods = entities.get("alimentos", []) user_symptoms = entities.get("sintomas", []) if not user_foods or not user_symptoms: return None candidates = [] for food in user_foods: comps = get_compounds_for_food(food, food_compound_map) for compound in comps: info = neuro_map.get(compound, {}) effect = info.get('efecto_neuropsicologico', f'Sustancia bioactiva ({compound}) en {food}') candidates.append({"alimento": food, "compuesto": compound, "efecto": effect}) compuestos_unicos = sorted(set(c['compuesto'] for c in candidates)) if candidates else ["Compuestos bioactivos"] alimentos_unicos = sorted(set(user_foods)) compuestos_str = ", ".join(compuestos_unicos) alimentos_str = ", ".join(alimentos_unicos).capitalize() condicion = f"Hipersensibilidad o Intolerancia a {compuestos_str} en {alimentos_str}" mecanismo = " | ".join(sorted(set(c['efecto'] for c in candidates))) if candidates else "Proceso de malabsorción o reactividad digestiva/metabólica." return { "condicion_asociada": condicion, "mecanismo_fisiologico": mecanismo, "compuesto_alimento": f"{alimentos_str} ({compuestos_str})", "sintomas_clave": user_symptoms, "es_hipotesis_local": True } def find_best_foodb_matches(user_foods_es, _foodb_index, _foodb_lower_map, food_name_map, limit=3): if not user_foods_es or not _foodb_index: return [] search_terms_en = set() for food_es in user_foods_es: food_es_clean = food_es.lower().strip() for key_es, value_en_list in food_name_map.items(): if key_es in food_es_clean or food_es_clean in key_es: search_terms_en.update(value_en_list) if not search_terms_en: return [] found_matches = [] for term in search_terms_en: term_lower = term.lower().strip() if term_lower in _foodb_lower_map: found_matches.append(_foodb_lower_map[term_lower]) continue for k_lower, orig_key in _foodb_lower_map.items(): if term_lower in k_lower: found_matches.append(orig_key) if len(found_matches) >= limit: break seen = set() unique = [] for item in found_matches: if item not in seen: seen.add(item) unique.append(item) return unique[:limit] # ============================================================================== # GENERADORES DE INFORMES # ============================================================================== def generate_local_fallback_analysis(query, match, entities, food_compound_map, neuro_map): saludo = "### 📋 Informe de Análisis Clínico-Nutricional (Modo Local / Alta Disponibilidad)\n" hipotesis = f"**Hipótesis Probable:** Es muy probable que tus síntomas estén relacionados con **{match.get('condicion_asociada', 'una sensibilidad a compuestos alimentarios')}**.\n" user_foods = entities.get("alimentos", []) user_symptoms = entities.get("sintomas", []) compuestos_detectados = set() for food in user_foods: comps = get_compounds_for_food(food, food_compound_map) if comps: compuestos_detectados.update(comps) compuestos_str = ", ".join(sorted(list(compuestos_detectados))) if compuestos_detectados else match.get('compuesto_alimento', 'compuestos bioactivos') causa = f"\n### 🧬 Causa e Identificación Molecular\nLos síntomas descritos (**{', '.join(user_symptoms)}**) tras el consumo de **{', '.join(user_foods)}** están asociados a la presencia de **{compuestos_str}**.\n" detalles_compuestos = [] for comp in compuestos_detectados: if comp in neuro_map: detalles_compuestos.append(f"- **{comp.capitalize()}**: {neuro_map[comp]['efecto_neuropsicologico']}") if detalles_compuestos: causa += "\n**Mecanismo bioquímico:**\n" + "\n".join(detalles_compuestos) + "\n" evitar = f"\n### 🛑 Alimentos a Moderar o Evitar\n- **Alimentos evaluados:** {', '.join(user_foods).capitalize()}\n- Productos ricos en **{compuestos_str}**.\n" alternativas = "\n### 💡 Sugerencias\n- Llevar un diario de síntomas registrando alimentos y horas de consumo.\n- Reducir la ingesta del alimento para evaluar remisión de síntomas.\n" consejo_texto = None for comp in compuestos_detectados: comp_clean = comp.lower().strip() if comp_clean in CONSEJOS_POR_COMPUESTO: consejo_texto = CONSEJOS_POR_COMPUESTO[comp_clean] break if not consejo_texto: consejo_texto = CONSEJO_GENERICO_DEFAULT consejo = f"\n### 📌 Consejo Práctico Final\n{consejo_texto}\n" return saludo + hipotesis + causa + evitar + alternativas + consejo + TEXTO_BANDERAS_ROJAS def generate_detailed_analysis(query, match, entities=None, food_compound_map=None, neuro_map=None): gemini_client = get_gemini_client() if not gemini_client: return generate_local_fallback_analysis(query, match, entities, food_compound_map, neuro_map) compound_context = "" if entities and food_compound_map and neuro_map: lines = [] for food in entities.get("alimentos", []): comps = get_compounds_for_food(food, food_compound_map) for compound in comps: if compound in neuro_map: lines.append(f"- {food.capitalize()} contiene {compound}: {neuro_map[compound]['efecto_neuropsicologico']}") if lines: compound_context = "Compuestos detectados:\n" + "\n".join(lines) prompt = f""" Rol: Nutricionista Funcional. Caso: {query} Hipótesis clínica: {match.get('condicion_asociada')} Mecanismo: {match.get('mecanismo_fisiologico')} {compound_context} Escribe un análisis estructurado en Markdown (saludo, hipótesis, moléculas responsables, alimentos a evitar, alternativas y consejo). NO incluyas descargos de responsabilidad. """ try: raw_text, used_model = generate_content_with_strict_timeout(gemini_client, prompt, timeout_seconds=3.5) return f"🤖 **Análisis Redactado por IA Gemini ({used_model}):**\n\n" + raw_text + TEXTO_BANDERAS_ROJAS except Exception as e: logger.warning(f"Gemini no respondió en < 3.5s ({e}). Se activa la respuesta en modo local.") return generate_local_fallback_analysis(query, match, entities, food_compound_map, neuro_map) def generate_neuro_report_text(entities, food_map, neuro_map): report_lines = ["\n### 🧠 Efectos Neuropsicológicos Posibles"] user_foods = entities.get("alimentos", []) relevant_compounds = set() if user_foods: for food in user_foods: comps = get_compounds_for_food(food, food_map) relevant_compounds.update(comps) found = False if relevant_compounds: for compound in sorted(list(relevant_compounds)): if compound in neuro_map: found = True report_lines.append(f"**{compound.capitalize()}**: _{neuro_map[compound]['efecto_neuropsicologico']}_") if not found: report_lines.append("No se detectaron efectos neuropsicológicos específicos directos.") return "\n".join(report_lines) def generate_molecular_report_text(best_match, entities, _foodb_index, _foodb_lower_map, food_name_map, synonym_map, food_compound_map, neuro_map): report_lines = ["\n### 🔬 Análisis Molecular y Efectos Bioactivos"] user_foods = entities.get("alimentos", []) if not user_foods: return "No hay alimentos seleccionados." found_any = False for food in user_foods: compounds = get_compounds_for_food(food, food_compound_map) if compounds: found_any = True report_lines.append(f"\n**{food.capitalize()}** — compuestos clave identificados:") for compound in compounds: if compound in neuro_map: report_lines.append(f"- **{compound.capitalize()}**: {neuro_map[compound]['efecto_neuropsicologico']}") else: report_lines.append(f"- **{compound.capitalize()}**") if _foodb_index: best_food_matches = find_best_foodb_matches(user_foods, _foodb_index, _foodb_lower_map, food_name_map) for food_key in best_food_matches: compounds = _foodb_index.get(food_key, []) if not compounds: continue found_any = True report_lines.append(f"\n**{food_key.capitalize()}** (Efectos en FooDB):") for item in compounds[:5]: if isinstance(item, dict): comp_name = item.get('compound', 'N/A') effects = item.get('effects', []) eff_translated = [FOODB_EFFECTS_TRANSLATION.get(str(e).lower().strip(), str(e)) for e in effects[:4]] eff_str = f" — *Efectos: {', '.join(eff_translated)}*" if eff_translated else "" report_lines.append(f"- **{comp_name}**{eff_str}") if not found_any: report_lines.append("No se encontraron registros moleculares adicionales.") return "\n".join(report_lines) def generate_word_report(report_text): if not report_text: return None try: doc = docx.Document() doc.add_paragraph(report_text) doc_io = BytesIO() doc.save(doc_io) doc_io.seek(0) return doc_io.getvalue() except Exception as e: logger.error(f"Error generando informe Word: {e}") return None def create_relevance_chart(results): top_results = results[:10] data = pd.DataFrame({ "Condición": [re.sub(r'\(.*\)', '', r['entry'].get('condicion_asociada', '')).strip() for r in top_results], "Relevancia": [r.get('score', {}).get('total', 50) for r in top_results] }) return alt.Chart(data).mark_bar().encode( x='Relevancia', y=alt.Y('Condición', sort='-x'), tooltip=['Condición', 'Relevancia'] ).properties(title='Top Coincidencias') # ============================================================================== # INTERFAZ STREAMLIT # ============================================================================== if 'search_results' not in st.session_state: st.session_state.search_results = None if 'user_query' not in st.session_state: st.session_state.user_query = "" if 'entities' not in st.session_state: st.session_state.entities = None if 'main_analysis' not in st.session_state: st.session_state.main_analysis = "" if 'neuro_text_cache' not in st.session_state: st.session_state.neuro_text_cache = "" if 'mol_text_cache' not in st.session_state: st.session_state.mol_text_cache = "" if 'word_bytes_cache' not in st.session_state: st.session_state.word_bytes_cache = None if 'has_searched' not in st.session_state: st.session_state.has_searched = False col_img1, col_text, col_img2 = st.columns([1, 4, 1]) with col_img1: if os.path.exists("imagen.png"): st.image("imagen.png", width=150) with col_text: st.title("El Detective de Alimentos") st.markdown("##### Describe tus SÍNTOMAS y los ALIMENTOS que sospechas.") with col_img2: if os.path.exists("buho.png"): st.image("buho.png", width=120) gemini_test = get_gemini_client() if gemini_test: st.caption("🟢 **Motor de IA Gemini Conectado:** Extracción y Redacción Activadas (Timeout: 3.5s)") else: st.caption("🟡 **Modo Local / Alta Disponibilidad:** Activo") with st.form(key="search_form", clear_on_submit=False): query_input = st.text_area( "Tu Caso:", height=150, placeholder="Ej: Me da diarrea e hinchazón si como frijoles o una bandeja paisa.", key="input_query_text" ) submitted = st.form_submit_button("Analizar Caso", type="primary") if submitted: if query_input and query_input.strip(): # Limpieza explícita del estado para búsquedas consecutivas sin congelamiento st.session_state.search_results = None st.session_state.main_analysis = "" st.session_state.neuro_text_cache = "" st.session_state.mol_text_cache = "" st.session_state.word_bytes_cache = None current_query = query_input.strip() st.session_state.user_query = current_query st.session_state.has_searched = True with st.spinner("🔍 Analizando caso con corta-fuegos activado..."): raw_entities = extract_entities_with_gemini(current_query) reinforced = reinforce_entities_with_keywords(raw_entities, current_query, FOOD_TO_COMPOUND_MAP, MASTER_SYMPTOM_MAP) final_symptoms = translate_symptoms_local(reinforced.get("sintomas", []), MASTER_SYMPTOM_MAP) final_entities = {"alimentos": reinforced.get("alimentos", []), "sintomas": final_symptoms} st.session_state.entities = final_entities results = find_best_matches_hybrid(final_entities, alimentos_data, current_query, FOOD_TO_COMPOUND_MAP) if not results: local_hypothesis = build_local_hypothesis(final_entities, FOOD_TO_COMPOUND_MAP, INTEGRATED_NEURO_FOOD_MAP) if local_hypothesis: results = [{'entry': local_hypothesis, 'score': {'total': 50}, 'matched_keywords': []}] st.session_state.search_results = results if results else [] if st.session_state.search_results: best_match = st.session_state.search_results[0]['entry'] st.session_state.main_analysis = generate_detailed_analysis( st.session_state.user_query, best_match, st.session_state.entities, FOOD_TO_COMPOUND_MAP, INTEGRATED_NEURO_FOOD_MAP ) st.session_state.neuro_text_cache = generate_neuro_report_text( st.session_state.entities, FOOD_TO_COMPOUND_MAP, INTEGRATED_NEURO_FOOD_MAP ) st.session_state.mol_text_cache = generate_molecular_report_text( best_match, st.session_state.entities, foodb_index, foodb_lower_map, FOOD_NAME_TO_FOODB_KEY, COMPOUND_SYNONYM_MAP, FOOD_TO_COMPOUND_MAP, INTEGRATED_NEURO_FOOD_MAP ) st.session_state.word_bytes_cache = generate_word_report(f"REPORTE DE SALUD\n\n{st.session_state.main_analysis}") else: st.warning("⚠️ Por favor describe tus síntomas o alimentos consumidos antes de analizar.") # --- MOSTRAR RESULTADOS --- if st.session_state.has_searched: results = st.session_state.search_results if results and len(results) > 0: best_match = results[0]['entry'] st.success(f"🔎 Coincidencia Principal: **{best_match.get('condicion_asociada')}**") tab_main, tab_neuro, tab_mol = st.tabs(["💡 Interpretación Clínica", "🧠 Efectos Neuropsicológicos", "🔬 Análisis Molecular"]) with tab_main: st.markdown(st.session_state.get('main_analysis', 'Análisis no disponible.')) if len(results) > 1: st.altair_chart(create_relevance_chart(results), use_container_width=True) with tab_neuro: st.markdown(st.session_state.get('neuro_text_cache', 'No hay datos neuropsicológicos.')) with tab_mol: st.markdown(st.session_state.get('mol_text_cache', 'No hay datos moleculares.')) word_bytes = st.session_state.get('word_bytes_cache') if word_bytes: st.download_button( label="📄 Descargar Informe Word", data=word_bytes, file_name="Reporte_Detective_Alimentos.docx", mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", key="dl_btn_report" ) else: st.warning("⚠️ No pudimos identificar una causa clara para este caso específico.") st.info("Intenta especificar el alimento y el síntoma. Ej: 'Siento [SÍNTOMA] cuando como [ALIMENTO]'.")