core / app.py
Jairo
Update app.py
6c9d656 verified
Raw
History Blame Contribute Delete
21.8 kB
from deep_translator import GoogleTranslator
import gradio as gr
import pandas as pd
import duckdb
import time
import re
import threading
from concurrent.futures import ThreadPoolExecutor
# ── ConfiguraciΓ³n ─────────────────────────────────────────────────────────────
PARQUET_PATH = 'vesta_master_database.parquet'
con_parquet = duckdb.connect()
translator_es_en = GoogleTranslator(source='es', target='en')
translator_en_es = GoogleTranslator(source='en', target='es')
# ── Mapa completo de los 266 nutrientes ──────────────────────────────────────
NUTRIENT_MAP = {
# 01. ENERGÍA
'Energy': '01. ENERGÍA',
'Energy (Atwater General Factors)': '01. ENERGÍA',
'Energy (Atwater Specific Factors)': '01. ENERGÍA',
# 02. AGUA Y CENIZA
'Water': '02. AGUA Y CENIZA',
'Ash': '02. AGUA Y CENIZA',
'Nitrogen': '02. AGUA Y CENIZA',
'Specific Gravity': '02. AGUA Y CENIZA',
# 03. PROTEÍNAS
'Protein': '03. PROTEÍNAS',
'Alanine': '03. PROTEÍNAS',
'Arginine': '03. PROTEÍNAS',
'Aspartic acid': '03. PROTEÍNAS',
'Cystine': '03. PROTEÍNAS',
'Cysteine': '03. PROTEÍNAS',
'Glutamic acid': '03. PROTEÍNAS',
'Glutamine': '03. PROTEÍNAS',
'Glutathione': '03. PROTEÍNAS',
'Glycine': '03. PROTEÍNAS',
'Histidine': '03. PROTEÍNAS',
'Hydroxyproline': '03. PROTEÍNAS',
'Isoleucine': '03. PROTEÍNAS',
'Leucine': '03. PROTEÍNAS',
'Lysine': '03. PROTEÍNAS',
'Methionine': '03. PROTEÍNAS',
'Phenylalanine': '03. PROTEÍNAS',
'Proline': '03. PROTEÍNAS',
'Serine': '03. PROTEÍNAS',
'Taurine': '03. PROTEÍNAS',
'Threonine': '03. PROTEÍNAS',
'Tryptophan': '03. PROTEÍNAS',
'Tyrosine': '03. PROTEÍNAS',
'Valine': '03. PROTEÍNAS',
# 04. CARBOHIDRATOS
'Carbohydrate, by difference': '04. CARBOHIDRATOS',
'Carbohydrate, by summation': '04. CARBOHIDRATOS',
'Carbohydrate, other': '04. CARBOHIDRATOS',
'Fiber, total dietary': '04. CARBOHIDRATOS',
'Fiber, insoluble': '04. CARBOHIDRATOS',
'Fiber, soluble': '04. CARBOHIDRATOS',
'High Molecular Weight Dietary Fiber (HMWDF)': '04. CARBOHIDRATOS',
'Low Molecular Weight Dietary Fiber (LMWDF)': '04. CARBOHIDRATOS',
'Total Sugars': '04. CARBOHIDRATOS',
'Sugars, added': '04. CARBOHIDRATOS',
'Sugars, total including NLEA': '04. CARBOHIDRATOS',
'Starch': '04. CARBOHIDRATOS',
'Sucrose': '04. CARBOHIDRATOS',
'Glucose': '04. CARBOHIDRATOS',
'Fructose': '04. CARBOHIDRATOS',
'Lactose': '04. CARBOHIDRATOS',
'Maltose': '04. CARBOHIDRATOS',
'Galactose': '04. CARBOHIDRATOS',
'Ribose': '04. CARBOHIDRATOS',
'Sorbitol': '04. CARBOHIDRATOS',
'Xylitol': '04. CARBOHIDRATOS',
'Inositol': '04. CARBOHIDRATOS',
'Stachyose': '04. CARBOHIDRATOS',
'Verbascose': '04. CARBOHIDRATOS',
'Beta-glucan': '04. CARBOHIDRATOS',
'Beta-glucans': '04. CARBOHIDRATOS',
'Lignin': '04. CARBOHIDRATOS',
# 05. LÍPIDOS
'Total lipid (fat)': '05. LÍPIDOS',
'Cholesterol': '05. LÍPIDOS',
'Fatty acids, total saturated': '05. LÍPIDOS',
'Fatty acids, total monounsaturated': '05. LÍPIDOS',
'Fatty acids, total polyunsaturated': '05. LÍPIDOS',
'Fatty acids, total trans': '05. LÍPIDOS',
'Fatty acids, total trans-monoenoic': '05. LÍPIDOS',
'Fatty acids, total trans-dienoic': '05. LÍPIDOS',
'Fatty acids, total trans-polyenoic': '05. LÍPIDOS',
# 05a. SFA
'SFA 4:0':'05a. SFA (Saturados)','SFA 5:0':'05a. SFA (Saturados)',
'SFA 6:0':'05a. SFA (Saturados)','SFA 7:0':'05a. SFA (Saturados)',
'SFA 8:0':'05a. SFA (Saturados)','SFA 9:0':'05a. SFA (Saturados)',
'SFA 10:0':'05a. SFA (Saturados)','SFA 11:0':'05a. SFA (Saturados)',
'SFA 12:0':'05a. SFA (Saturados)','SFA 13:0':'05a. SFA (Saturados)',
'SFA 14:0':'05a. SFA (Saturados)','SFA 15:0':'05a. SFA (Saturados)',
'SFA 16:0':'05a. SFA (Saturados)','SFA 17:0':'05a. SFA (Saturados)',
'SFA 18:0':'05a. SFA (Saturados)','SFA 20:0':'05a. SFA (Saturados)',
'SFA 21:0':'05a. SFA (Saturados)','SFA 22:0':'05a. SFA (Saturados)',
'SFA 23:0':'05a. SFA (Saturados)','SFA 24:0':'05a. SFA (Saturados)',
# 05b. MUFA
'MUFA 12:1':'05b. MUFA (Monoinsaturados)','MUFA 14:1':'05b. MUFA (Monoinsaturados)',
'MUFA 14:1 c':'05b. MUFA (Monoinsaturados)','MUFA 15:1':'05b. MUFA (Monoinsaturados)',
'MUFA 16:1':'05b. MUFA (Monoinsaturados)','MUFA 16:1 c':'05b. MUFA (Monoinsaturados)',
'MUFA 17:1':'05b. MUFA (Monoinsaturados)','MUFA 17:1 c':'05b. MUFA (Monoinsaturados)',
'MUFA 18:1':'05b. MUFA (Monoinsaturados)','MUFA 18:1 c':'05b. MUFA (Monoinsaturados)',
'MUFA 18:1-11 t (18:1t n-7)':'05b. MUFA (Monoinsaturados)',
'MUFA 20:1':'05b. MUFA (Monoinsaturados)','MUFA 20:1 c':'05b. MUFA (Monoinsaturados)',
'MUFA 22:1':'05b. MUFA (Monoinsaturados)','MUFA 22:1 c':'05b. MUFA (Monoinsaturados)',
'MUFA 22:1 n-11':'05b. MUFA (Monoinsaturados)','MUFA 22:1 n-9':'05b. MUFA (Monoinsaturados)',
'MUFA 24:1 c':'05b. MUFA (Monoinsaturados)',
# 05c. PUFA
'PUFA 18:2':'05c. PUFA (Poliinsaturados)','PUFA 18:2 CLAs':'05c. PUFA (Poliinsaturados)',
'PUFA 18:2 c':'05c. PUFA (Poliinsaturados)','PUFA 18:2 i':'05c. PUFA (Poliinsaturados)',
'PUFA 18:2 n-6 c,c':'05c. PUFA (Poliinsaturados)','PUFA 18:3':'05c. PUFA (Poliinsaturados)',
'PUFA 18:3 c':'05c. PUFA (Poliinsaturados)','PUFA 18:3 n-3 c,c,c (ALA)':'05c. PUFA (Poliinsaturados)',
'PUFA 18:3 n-6 c,c,c':'05c. PUFA (Poliinsaturados)','PUFA 18:3i':'05c. PUFA (Poliinsaturados)',
'PUFA 18:4':'05c. PUFA (Poliinsaturados)','PUFA 20:2 c':'05c. PUFA (Poliinsaturados)',
'PUFA 20:2 n-6 c,c':'05c. PUFA (Poliinsaturados)','PUFA 20:3':'05c. PUFA (Poliinsaturados)',
'PUFA 20:3 c':'05c. PUFA (Poliinsaturados)','PUFA 20:3 n-3':'05c. PUFA (Poliinsaturados)',
'PUFA 20:3 n-6':'05c. PUFA (Poliinsaturados)','PUFA 20:3 n-9':'05c. PUFA (Poliinsaturados)',
'PUFA 20:4':'05c. PUFA (Poliinsaturados)','PUFA 20:4 n-6':'05c. PUFA (Poliinsaturados)',
'PUFA 20:4c':'05c. PUFA (Poliinsaturados)','PUFA 20:5 n-3 (EPA)':'05c. PUFA (Poliinsaturados)',
'PUFA 20:5c':'05c. PUFA (Poliinsaturados)','PUFA 21:5':'05c. PUFA (Poliinsaturados)',
'PUFA 22:2':'05c. PUFA (Poliinsaturados)','PUFA 22:3':'05c. PUFA (Poliinsaturados)',
'PUFA 22:4':'05c. PUFA (Poliinsaturados)','PUFA 22:5 c':'05c. PUFA (Poliinsaturados)',
'PUFA 22:5 n-3 (DPA)':'05c. PUFA (Poliinsaturados)','PUFA 22:6 c':'05c. PUFA (Poliinsaturados)',
'PUFA 22:6 n-3 (DHA)':'05c. PUFA (Poliinsaturados)',
# 05d. TFA
'TFA 14:1 t':'05d. TFA (Trans)','TFA 16:1 t':'05d. TFA (Trans)',
'TFA 18:1 t':'05d. TFA (Trans)','TFA 18:2 t':'05d. TFA (Trans)',
'TFA 18:2 t not further defined':'05d. TFA (Trans)','TFA 18:2 t,t':'05d. TFA (Trans)',
'TFA 18:3 t':'05d. TFA (Trans)','TFA 20:1 t':'05d. TFA (Trans)',
'TFA 22:1 t':'05d. TFA (Trans)',
# 05e. ESTEROLES
'Phytosterols':'05e. ESTEROLES','Phytosterols, other':'05e. ESTEROLES',
'Beta-sitosterol':'05e. ESTEROLES','Beta-sitostanol':'05e. ESTEROLES',
'Campesterol':'05e. ESTEROLES','Campestanol':'05e. ESTEROLES',
'Brassicasterol':'05e. ESTEROLES','Stigmastadiene':'05e. ESTEROLES',
'Delta-5-avenasterol':'05e. ESTEROLES','Delta-7-Stigmastenol':'05e. ESTEROLES',
'Ergosterol':'05e. ESTEROLES','Ergosta-5,7-dienol':'05e. ESTEROLES',
'Ergosta-7,22-dienol':'05e. ESTEROLES','Ergosta-7-enol':'05e. ESTEROLES',
# 06. MINERALES
'Calcium, Ca':'06. MINERALES','Iron, Fe':'06. MINERALES',
'Magnesium, Mg':'06. MINERALES','Phosphorus, P':'06. MINERALES',
'Potassium, K':'06. MINERALES','Sodium, Na':'06. MINERALES',
'Zinc, Zn':'06. MINERALES','Copper, Cu':'06. MINERALES',
'Manganese, Mn':'06. MINERALES','Selenium, Se':'06. MINERALES',
'Fluoride, F':'06. MINERALES','Chromium, Cr':'06. MINERALES',
'Iodine, I':'06. MINERALES','Molybdenum, Mo':'06. MINERALES',
'Boron, B':'06. MINERALES','Cobalt, Co':'06. MINERALES',
'Nickel, Ni':'06. MINERALES','Chlorine, Cl':'06. MINERALES',
'Salt, NaCl':'06. MINERALES',
# 07. VITAMINAS LIPOSOLUBLES
'Vitamin A, IU':'07. VITAMINAS LIPOSOLUBLES','Vitamin A, RAE':'07. VITAMINAS LIPOSOLUBLES',
'Retinol':'07. VITAMINAS LIPOSOLUBLES','Carotene, alpha':'07. VITAMINAS LIPOSOLUBLES',
'Carotene, beta':'07. VITAMINAS LIPOSOLUBLES','Carotene, gamma':'07. VITAMINAS LIPOSOLUBLES',
'Cryptoxanthin, beta':'07. VITAMINAS LIPOSOLUBLES','Lycopene':'07. VITAMINAS LIPOSOLUBLES',
'Lutein + zeaxanthin':'07. VITAMINAS LIPOSOLUBLES','Lutein':'07. VITAMINAS LIPOSOLUBLES',
'Zeaxanthin':'07. VITAMINAS LIPOSOLUBLES','Phytoene':'07. VITAMINAS LIPOSOLUBLES',
'Phytofluene':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin D (D2 + D3)':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin D (D2 + D3), International Units':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin D2 (ergocalciferol)':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin D3 (cholecalciferol)':'07. VITAMINAS LIPOSOLUBLES',
'25-hydroxycholecalciferol':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin E (alpha-tocopherol)':'07. VITAMINAS LIPOSOLUBLES',
'Tocopherol, beta':'07. VITAMINAS LIPOSOLUBLES','Tocopherol, gamma':'07. VITAMINAS LIPOSOLUBLES',
'Tocopherol, delta':'07. VITAMINAS LIPOSOLUBLES','Tocotrienol, alpha':'07. VITAMINAS LIPOSOLUBLES',
'Tocotrienol, beta':'07. VITAMINAS LIPOSOLUBLES','Tocotrienol, gamma':'07. VITAMINAS LIPOSOLUBLES',
'Tocotrienol, delta':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin K (phylloquinone)':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin K (Menaquinone-4)':'07. VITAMINAS LIPOSOLUBLES',
'Vitamin K (Dihydrophylloquinone)':'07. VITAMINAS LIPOSOLUBLES',
# 08. VITAMINAS HIDROSOLUBLES
'Vitamin C, total ascorbic acid':'08. VITAMINAS HIDROSOLUBLES',
'Thiamin':'08. VITAMINAS HIDROSOLUBLES','Riboflavin':'08. VITAMINAS HIDROSOLUBLES',
'Niacin':'08. VITAMINAS HIDROSOLUBLES','Pantothenic acid':'08. VITAMINAS HIDROSOLUBLES',
'Vitamin B-6':'08. VITAMINAS HIDROSOLUBLES','Vitamin B-12':'08. VITAMINAS HIDROSOLUBLES',
'Vitamin B-12, added':'08. VITAMINAS HIDROSOLUBLES',
'Folate, total':'08. VITAMINAS HIDROSOLUBLES','Folate, food':'08. VITAMINAS HIDROSOLUBLES',
'Folate, DFE':'08. VITAMINAS HIDROSOLUBLES','Folic acid':'08. VITAMINAS HIDROSOLUBLES',
'5-methyl tetrahydrofolate (5-MTHF)':'08. VITAMINAS HIDROSOLUBLES',
'5-Formyltetrahydrofolic acid (5-HCOH4':'08. VITAMINAS HIDROSOLUBLES',
'10-Formyl folic acid (10HCOFA)':'08. VITAMINAS HIDROSOLUBLES',
'Biotin':'08. VITAMINAS HIDROSOLUBLES','Choline, total':'08. VITAMINAS HIDROSOLUBLES',
'Choline, free':'08. VITAMINAS HIDROSOLUBLES',
'Choline, from phosphocholine':'08. VITAMINAS HIDROSOLUBLES',
'Choline, from phosphotidyl choline':'08. VITAMINAS HIDROSOLUBLES',
'Choline, from glycerophosphocholine':'08. VITAMINAS HIDROSOLUBLES',
'Choline, from sphingomyelin':'08. VITAMINAS HIDROSOLUBLES',
'Betaine':'08. VITAMINAS HIDROSOLUBLES','Niacin equivalent':'08. VITAMINAS HIDROSOLUBLES',
# 09. FITONUTRIENTES Y BIOACTIVOS
'Caffeine':'09. FITONUTRIENTES Y BIOACTIVOS','Theobromine':'09. FITONUTRIENTES Y BIOACTIVOS',
'Alcohol, ethyl':'09. FITONUTRIENTES Y BIOACTIVOS','Oxalic acid':'09. FITONUTRIENTES Y BIOACTIVOS',
'Citric acid':'09. FITONUTRIENTES Y BIOACTIVOS','Malic acid':'09. FITONUTRIENTES Y BIOACTIVOS',
'Lactic acid':'09. FITONUTRIENTES Y BIOACTIVOS','Acetic acid':'09. FITONUTRIENTES Y BIOACTIVOS',
'Pyruvic acid':'09. FITONUTRIENTES Y BIOACTIVOS','Quinic acid':'09. FITONUTRIENTES Y BIOACTIVOS',
'Daidzein':'09. FITONUTRIENTES Y BIOACTIVOS','Daidzin':'09. FITONUTRIENTES Y BIOACTIVOS',
'Genistein':'09. FITONUTRIENTES Y BIOACTIVOS','Genistin':'09. FITONUTRIENTES Y BIOACTIVOS',
'Glycitin':'09. FITONUTRIENTES Y BIOACTIVOS',
'Epigallocatechin-3-gallate':'09. FITONUTRIENTES Y BIOACTIVOS',
'Ergothioneine':'09. FITONUTRIENTES Y BIOACTIVOS',
}
# ── CatΓ‘logo maestro (cargado una vez al inicio) ─────────────────────────────
_MASTER_CATALOG = None
_catalog_lock = threading.Lock()
def get_master_catalog() -> pd.DataFrame:
global _MASTER_CATALOG
with _catalog_lock:
if _MASTER_CATALOG is not None:
return _MASTER_CATALOG
df = con_parquet.execute(f"""
SELECT DISTINCT Nutriente, MAX(Unidad) AS Unidad
FROM read_parquet('{PARQUET_PATH}')
WHERE Nutriente IS NOT NULL AND TRIM(Nutriente) != ''
GROUP BY Nutriente
ORDER BY Nutriente ASC
""").df()
df['Categoria'] = df['Nutriente'].map(NUTRIENT_MAP).fillna('10. OTROS')
_MASTER_CATALOG = df
return _MASTER_CATALOG
def get_nutrients(food_name: str) -> pd.DataFrame:
"""LEFT JOIN catΓ‘logo maestro ← datos del alimento. Siempre devuelve todas las filas."""
df_food = con_parquet.execute(f"""
SELECT Nutriente, ANY_VALUE(Cantidad) AS Cantidad
FROM read_parquet('{PARQUET_PATH}')
WHERE Alimento = ?
GROUP BY Nutriente
""", [food_name]).df()
catalog = get_master_catalog().copy()
df_full = catalog.merge(df_food[['Nutriente', 'Cantidad']], on='Nutriente', how='left')
df_full = df_full.sort_values(['Categoria', 'Nutriente']).reset_index(drop=True)
return df_full
# ── Mapa interno label β†’ nombre EN (compartido entre funciones) ───────────────
_label_to_en: dict[str, str] = {}
# ── FunciΓ³n de bΓΊsqueda ───────────────────────────────────────────────────────
def buscar_alimentos(termino: str):
global _label_to_en
if not termino or not termino.strip():
return (
gr.update(choices=[], value=None),
"⚠️ Escribe un ingrediente para buscar.",
)
t0 = time.time()
try:
term_en = translator_es_en.translate(termino.strip()).lower()
words = term_en.split()
where = " AND ".join(["Alimento ILIKE ?" for _ in words])
query_db = f"""
SELECT
Alimento,
COUNT(DISTINCT Nutriente) AS nutrientes_unicos
FROM read_parquet('{PARQUET_PATH}')
WHERE {where}
GROUP BY Alimento
ORDER BY nutrientes_unicos DESC
LIMIT 100
"""
df_results = con_parquet.execute(query_db, [f"%{w}%" for w in words]).df()
if df_results.empty:
return (
gr.update(choices=[], value=None),
f"❌ Sin resultados para «{termino}» (buscado como: {term_en})",
)
names_en = df_results['Alimento'].tolist()
counts = df_results['nutrientes_unicos'].tolist()
with ThreadPoolExecutor(max_workers=1) as exc:
names_es = exc.submit(lambda: translator_en_es.translate_batch(names_en)).result()
labels = [f"[{cnt}] {es} ({en})" for es, en, cnt in zip(names_es, names_en, counts)]
_label_to_en = {lbl: en for lbl, en in zip(labels, names_en)}
elapsed = time.time() - t0
status = f"βœ… {len(labels)} resultado(s) para Β«{termino}Β» β€” {elapsed:.2f}s"
return gr.update(choices=labels, value=labels[0]), status
except Exception as e:
return gr.update(choices=[], value=None), f"❌ Error: {e}"
# ── FunciΓ³n ver nutrientes ────────────────────────────────────────────────────
def ver_nutrientes(label_seleccionado: str | None):
if not label_seleccionado:
return "Selecciona un alimento primero.", pd.DataFrame()
food_en = _label_to_en.get(label_seleccionado)
# Fallback: extraer el nombre EN del parΓ©ntesis final del label
if not food_en:
m = re.search(r'\(([^)]+)\)$', label_seleccionado)
food_en = m.group(1) if m else label_seleccionado
try:
df = get_nutrients(food_en)
total = len(df)
tiene = int(df['Cantidad'].notna().sum())
resumen = (
f"**{food_en}**\n\n"
f"Nutrientes con datos: **{tiene}** / {total}  |  "
f"Sin datos: {total - tiene}"
)
tabla = df[['Categoria', 'Nutriente', 'Cantidad', 'Unidad']].copy()
tabla['Cantidad'] = tabla['Cantidad'].apply(
lambda x: f"{x:.4g}" if pd.notna(x) else ""
)
return resumen, tabla
except Exception as e:
return f"❌ Error al cargar nutrientes: {e}", pd.DataFrame()
# ── Interfaz Gradio ───────────────────────────────────────────────────────────
CSS = """
#buscar-btn { background: #378ADD !important; color: white !important; }
#buscar-btn:hover { background: #185FA5 !important; }
#ver-btn { background: #1D9E75 !important; color: white !important; }
#ver-btn:hover { background: #0F6E56 !important; }
#tabla-nutrientes { font-size: 13px; }
"""
with gr.Blocks(title="Buscador de Nutrientes Vesta", css=CSS) as demo:
gr.Markdown("## πŸ₯— Buscador de Nutrientes Vesta")
gr.Markdown(
"Busca alimentos en espaΓ±ol o inglΓ©s y consulta su perfil completo de nutrientes."
)
with gr.Row():
txt_buscar = gr.Textbox(
label="Ingrediente",
placeholder="ej: fresa, pollo, aguacate, chocolate...",
value="fresa",
scale=4,
)
btn_buscar = gr.Button("πŸ” Buscar", variant="primary", scale=1, elem_id="buscar-btn")
txt_status = gr.Markdown(value="")
with gr.Row():
dd_alimento = gr.Dropdown(
label="Alimento encontrado β€” ordenados por riqueza nutricional ↓",
choices=[],
interactive=True,
scale=5,
)
btn_ver = gr.Button("πŸ‘ Ver nutrientes", variant="secondary", scale=1, elem_id="ver-btn")
md_resumen = gr.Markdown(value="")
tbl_nutrientes = gr.Dataframe(
label="Perfil nutricional completo",
headers=["CategorΓ­a", "Nutriente", "Cantidad", "Unidad"],
datatype=["str", "str", "str", "str"],
interactive=False,
wrap=False,
elem_id="tabla-nutrientes",
)
# ── Eventos ───────────────────────────────────────────────────────────────
btn_buscar.click(
fn=buscar_alimentos,
inputs=txt_buscar,
outputs=[dd_alimento, txt_status],
)
txt_buscar.submit(
fn=buscar_alimentos,
inputs=txt_buscar,
outputs=[dd_alimento, txt_status],
)
btn_ver.click(
fn=ver_nutrientes,
inputs=dd_alimento,
outputs=[md_resumen, tbl_nutrientes],
)
# Al cambiar selecciΓ³n en dropdown limpiar tabla anterior
dd_alimento.change(
fn=lambda _: ("", pd.DataFrame()),
inputs=dd_alimento,
outputs=[md_resumen, tbl_nutrientes],
)
# ── Pre-cargar catΓ‘logo en background ────────────────────────────────────────
threading.Thread(target=get_master_catalog, daemon=True).start()
if __name__ == "__main__":
demo.launch()