import gradio as gr import pandas as pd import asyncio import json from backend.tools.search_engine import search, expand_sources from backend.providers.sources import SOURCE_GROUPS from .research_tab import ALL_SOURCES from .utils import format_results_for_dataframe, format_error # ─── Source badge colors (matching Next.js PaperSearch) ─── SOURCE_COLORS = { "pubmed": "#3b82f6", "semantic_scholar": "#8b5cf6", "openalex": "#06b6d4", "crossref": "#f59e0b", "arxiv": "#ef4444", "doaj": "#10b981", "zenodo": "#6366f1", "dblp": "#ec4899", "openaire": "#14b8a6", "core": "#f97316", "scielo": "#22c55e", "redalyc": "#a855f7", "latindex": "#0ea5e9", "dialnet": "#e11d48", "la_referencia": "#84cc16", "rraae": "#fbbf24", "alicia": "#2dd4bf", "bdtd": "#f43f5e", "repositorio_usfq": "#818cf8", } # ─── Mode definitions (matching Next.js SearchBar modes) ─── SEARCH_MODES = [ {"key": "simple", "label": "Búsqueda Simple", "icon": "🔍", "color": "#38bdf8", "desc": "Búsqueda directa en fuentes"}, {"key": "smart", "label": "Búsqueda Smart", "icon": "✨", "color": "#a855f7", "desc": "Variaciones multilingües automáticas"}, {"key": "batch", "label": "Batch (Lote)", "icon": "📦", "color": "#f59e0b", "desc": "Múltiples queries separadas por línea"}, ] def _build_source_badge_html(source_name, count=None): """Build a colored source badge like the Next.js PaperCard""" color = SOURCE_COLORS.get(source_name.lower().replace(" ", "_"), "#6b7280") label = source_name.replace("_", " ").title() count_str = f" ({count})" if count else "" return f''' {label}{count_str} ''' def _build_paper_card_html(papers_data): """Build rich expandable paper cards with floating actions, matching Next.js PaperCard""" if not papers_data: return '''
🔬
Sin resultados aún
Ingresa una consulta y selecciona las fuentes para buscar documentos académicos.
''' cards_html = "" for i, paper in enumerate(papers_data): title = paper.get("title", "Sin título") authors_raw = paper.get("authors", "") if isinstance(authors_raw, list): authors_display = ", ".join(authors_raw[:3]) if len(authors_raw) > 3: authors_display += f" +{len(authors_raw) - 3} más" first_author = authors_raw[0].split()[-1] if authors_raw else "Autor" elif isinstance(authors_raw, str): authors_display = authors_raw first_author = authors_raw.split(",")[0].split()[-1] if authors_raw else "Autor" else: authors_display = "" first_author = "Autor" year = paper.get("year", "") doi = paper.get("doi", "") source = paper.get("source", "") pdf_url = paper.get("pdf_url", "") or paper.get("pdfUrl", "") abstract = paper.get("abstract", "") grade = paper.get("grade", "") source_color = SOURCE_COLORS.get(source.lower().replace(" ", "_"), "#6b7280") source_label = source.replace("_", " ").title() if source else "Desconocido" # APA 7 citation text apa_cite = f"{first_author} ({year})" if year else f"{first_author} (s.f.)" apa_full = f"{authors_display} ({year}). {title}." if doi: apa_full += f" https://doi.org/{doi}" # DOI action doi_html = "" if doi: doi_html = f''' DOI ''' # PDF action pdf_html = "" if pdf_url: pdf_html = f''' PDF ''' # GRADE badge grade_html = "" if grade: grade_colors = {"HIGH": "#10b981", "MODERATE": "#3b82f6", "LOW": "#f59e0b", "VERY LOW": "#ef4444"} gc = grade_colors.get(grade.upper(), "#6b7280") grade_html = f'''{grade}''' # Abstract expandable abstract_section = "" if abstract: abstract_id = f"abs_{i}" short_abs = abstract[:180] + "..." if len(abstract) > 180 else abstract abstract_section = f'''
{short_abs}
''' # Copy APA button apa_escaped = apa_full.replace("'", "\\'").replace('"', """) copy_btn = f'''''' delay = min(i * 0.04, 0.8) cards_html += f'''
✅ Cita copiada
{i+1}
{title}
{authors_display} {"📅 " + str(year) + "" if year else ""}
{source_label} {grade_html}
{copy_btn} {doi_html} {pdf_html}
{abstract_section}
''' return f'''
{cards_html}
''' def _build_stats_html(results_list, sources_used=None, query="", search_type="simple"): """Build a stats bar matching Next.js SourcesStatsBlock""" if not results_list: return "" total = len(results_list) source_counts = {} years = [] with_doi = 0 with_pdf = 0 for r in results_list: src = r.get("source", "unknown") source_counts[src] = source_counts.get(src, 0) + 1 if r.get("year"): try: years.append(int(r["year"])) except: pass if r.get("doi"): with_doi += 1 if r.get("pdf_url"): with_pdf += 1 year_range = "" if years: year_range = f"{min(years)}–{max(years)}" # Source badges badges = " ".join([_build_source_badge_html(src, cnt) for src, cnt in sorted(source_counts.items(), key=lambda x: -x[1])]) mode_labels = {"simple": "Simple", "smart": "Smart", "batch": "Batch"} mode_label = mode_labels.get(search_type, search_type) return f'''
📊
{total} documentos encontrados
Modo {mode_label} · {len(source_counts)} fuentes · {year_range if year_range else "Años variados"}
🔗 {with_doi} 📄 {with_pdf}
{badges}
''' async def search_handler(query, sources, max_results, year_start, year_end, university, search_type): if not query or not query.strip(): return "", "
", "
" try: results_list = [] search_info = {"type": search_type, "query": query.strip()} if search_type == "batch": queries = [q.strip() for q in query.split("\n") if q.strip()] if not queries: queries = [query.strip()] all_results = [] for q in queries: result = await search(q, sources=sources or ["latam", "global"], max_results=int(max_results)) all_results.extend(result.get("results", [])) seen = set() for r in all_results: key = r.get("doi") or r.get("title", "")[:60] if key not in seen: seen.add(key) results_list.append(r) results_list = results_list[:int(max_results)] elif search_type == "smart": variations = [query.strip()] translations = { "inteligencia artificial": "artificial intelligence", "aprendizaje automático": "machine learning", "aprendizaje profundo": "deep learning", "red neuronal": "neural network", "procesamiento de lenguaje natural": "natural language processing", } for es, en in translations.items(): if es in query.lower(): variations.append(query.lower().replace(es, en)) break all_results = [] for v in variations: result = await search(v, sources=sources or ["global", "latam"], max_results=int(max_results) // len(variations) + 10) all_results.extend(result.get("results", [])) seen = set() for r in all_results: key = r.get("doi") or r.get("title", "")[:60] if key not in seen: seen.add(key) results_list.append(r) results_list = results_list[:int(max_results)] else: result = await search(query.strip(), sources=sources or ["all"], max_results=int(max_results), year_start=year_start or None, year_end=year_end or None) results_list = result.get("results", []) if university and university.strip(): uni = university.strip().lower() results_list = [r for r in results_list if uni in (r.get("university") or "").lower() or uni in (r.get("title") or "").lower()] if not results_list: empty_html = '''
🔍
Sin resultados
Intente con otros términos o fuentes.
''' return "", empty_html, "" # Build the DataFrame for table view df = format_results_for_dataframe(results_list) # Build stats HTML stats_html = _build_stats_html(results_list, query=query.strip(), search_type=search_type) # Build cards HTML cards_html = _build_paper_card_html(results_list) return stats_html, cards_html, "" except Exception as e: error_html = f'''
❌ Error en la búsqueda
{str(e)}
''' return "", error_html, "" def create_search_tab(): with gr.Tab("🔍 Búsqueda", id="search"): # ─── Header matching Next.js PaperSearch header ─── gr.HTML('''
🔬
Búsqueda Académica
15+ fuentes · PubMed · Scopus · ArXiv · SciELO · OpenAlex · DOAJ
Online
''') with gr.Row(): # ─── LEFT: Controls ─── with gr.Column(scale=2): # Search input with glassmorphic wrapper gr.HTML('''
🔍 Consulta de búsqueda
''') query = gr.Textbox( label="", placeholder="Ej: machine learning for crop disease detection\n(separar por línea para modo batch)", lines=3, show_label=False, elem_classes=["glass-input-wrapper"] ) # Mode selector (matching Next.js SearchBar modes) gr.HTML('''
⚡ Modo de búsqueda
''') search_type = gr.Radio( choices=["simple", "smart", "batch"], value="simple", label="", show_label=False, ) gr.HTML('''
🔍 Simple: búsqueda directa ✨ Smart: auto-traduce ES↔EN 📦 Batch: múltiples queries
''') # Sources selector gr.HTML('''
🌐 Fuentes académicas
''') sources = gr.CheckboxGroup( choices=ALL_SOURCES, value=["all"], label="", show_label=False, ) # Filters with gr.Accordion("📅 Filtros avanzados", open=False): with gr.Row(): year_start = gr.Textbox(label="Año inicio", placeholder="2020", scale=1) year_end = gr.Textbox(label="Año fin", placeholder="2025", scale=1) university = gr.Textbox(label="Universidad / Institución", placeholder="Filtrar por universidad (opcional)") max_results = gr.Slider(minimum=5, maximum=120, value=50, step=5, label="Máximo de resultados") # Search button search_btn = gr.Button( "🚀 Buscar documentos", variant="primary", size="lg", elem_classes=["ejecutar-btn"] ) # ─── RIGHT: Results ─── with gr.Column(scale=3): # Stats bar stats_html = gr.HTML(value="", label="") # Results cards results_html = gr.HTML( value='''
🔬
Busca documentos académicos
Ingresa tu consulta a la izquierda y presiona "Buscar documentos".
Los resultados aparecerán aquí como tarjetas interactivas con metadatos completos.
''', label="" ) # Error output (hidden unless error) error_html = gr.HTML(value="", visible=True) # ─── Event binding ─── search_btn.click( fn=search_handler, inputs=[query, sources, max_results, year_start, year_end, university, search_type], outputs=[stats_html, results_html, error_html] )