Spaces:
Runtime error
Runtime error
| 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'''<span style=" | |
| display: inline-flex; align-items: center; gap: 5px; | |
| padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; | |
| background: {color}18; border: 1px solid {color}40; color: {color}; | |
| white-space: nowrap; | |
| "> | |
| <span style="width:6px;height:6px;border-radius:50%;background:{color};box-shadow:0 0 6px {color}60;"></span> | |
| {label}{count_str} | |
| </span>''' | |
| def _build_paper_card_html(papers_data): | |
| """Build rich expandable paper cards with floating actions, matching Next.js PaperCard""" | |
| if not papers_data: | |
| return '''<div style=" | |
| text-align:center; padding:60px 20px; color:#6b7280; | |
| font-size:14px; | |
| "> | |
| <div style="font-size:48px; margin-bottom:16px; opacity:0.5;">🔬</div> | |
| <div style="font-weight:600; margin-bottom:8px;">Sin resultados aún</div> | |
| <div style="font-size:12px; opacity:0.7;">Ingresa una consulta y selecciona las fuentes para buscar documentos académicos.</div> | |
| </div>''' | |
| 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'''<a href="https://doi.org/{doi}" target="_blank" class="paper-action-btn" style=" | |
| color:#3b82f6; border-color:rgba(59,130,246,0.25); background:rgba(59,130,246,0.06); | |
| " title="Abrir DOI"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> | |
| DOI | |
| </a>''' | |
| # PDF action | |
| pdf_html = "" | |
| if pdf_url: | |
| pdf_html = f'''<a href="{pdf_url}" target="_blank" class="paper-action-btn" style=" | |
| color:#ef4444; border-color:rgba(239,68,68,0.25); background:rgba(239,68,68,0.06); | |
| " title="Descargar PDF"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> | |
| </a>''' | |
| # 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'''<span style=" | |
| font-size:9px; font-weight:700; padding:2px 7px; border-radius:6px; | |
| background:{gc}18; border:1px solid {gc}40; color:{gc}; | |
| text-transform:uppercase; letter-spacing:0.5px; | |
| ">{grade}</span>''' | |
| # Abstract expandable | |
| abstract_section = "" | |
| if abstract: | |
| abstract_id = f"abs_{i}" | |
| short_abs = abstract[:180] + "..." if len(abstract) > 180 else abstract | |
| abstract_section = f''' | |
| <div style="margin-top:10px; padding-top:10px; border-top:1px solid rgba(255,255,255,0.06);"> | |
| <div id="{abstract_id}_short" style=" | |
| font-size:12px; color:var(--text-muted, #9ca3af); line-height:1.6; | |
| ">{short_abs}</div> | |
| <div id="{abstract_id}_full" style=" | |
| font-size:12px; color:var(--text-muted, #9ca3af); line-height:1.6; | |
| display:none; | |
| ">{abstract}</div> | |
| <button onclick=" | |
| var s=document.getElementById('{abstract_id}_short'); | |
| var f=document.getElementById('{abstract_id}_full'); | |
| var btn=this; | |
| if(s.style.display!=='none'){{s.style.display='none';f.style.display='block';btn.textContent='▲ Menos';}} | |
| else{{s.style.display='block';f.style.display='none';btn.textContent='▼ Más';}} | |
| " style=" | |
| background:none; border:none; color:#8b5cf6; font-size:11px; | |
| font-weight:600; cursor:pointer; padding:4px 0; margin-top:4px; | |
| ">▼ Más</button> | |
| </div>''' | |
| # Copy APA button | |
| apa_escaped = apa_full.replace("'", "\\'").replace('"', """) | |
| copy_btn = f'''<button onclick=" | |
| navigator.clipboard.writeText('{apa_escaped}'); | |
| var t=document.getElementById('toast_{i}'); | |
| t.style.display='flex';t.style.opacity='1'; | |
| setTimeout(function(){{t.style.opacity='0';setTimeout(function(){{t.style.display='none';}},300);}},2000); | |
| " class="paper-action-btn" style=" | |
| color:#8b5cf6; border-color:rgba(139,92,246,0.25); background:rgba(139,92,246,0.06); | |
| " title="Copiar cita APA 7"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> | |
| APA | |
| </button>''' | |
| delay = min(i * 0.04, 0.8) | |
| cards_html += f''' | |
| <div class="paper-card" style="animation:slideIn 0.35s ease {delay}s both;"> | |
| <!-- Toast notification --> | |
| <div id="toast_{i}" style=" | |
| display:none; position:absolute; top:10px; right:10px; z-index:100; | |
| align-items:center; gap:6px; padding:6px 14px; border-radius:8px; | |
| background:rgba(16,185,129,0.9); color:white; font-size:11px; font-weight:600; | |
| backdrop-filter:blur(8px); transition:opacity 0.3s ease; | |
| box-shadow:0 4px 12px rgba(0,0,0,0.3); | |
| ">✅ Cita copiada</div> | |
| <!-- Source color bar --> | |
| <div style=" | |
| position:absolute; left:0; top:12px; bottom:12px; width:3px; | |
| background:{source_color}; border-radius:0 3px 3px 0; | |
| box-shadow:0 0 8px {source_color}40; | |
| "></div> | |
| <div style="display:flex; gap:14px; align-items:flex-start;"> | |
| <!-- Number badge --> | |
| <div style=" | |
| flex-shrink:0; width:32px; height:32px; border-radius:10px; | |
| background:linear-gradient(135deg, {source_color}25, {source_color}10); | |
| border:1px solid {source_color}40; | |
| display:flex; align-items:center; justify-content:center; | |
| font-size:13px; font-weight:700; color:{source_color}; | |
| ">{i+1}</div> | |
| <!-- Content --> | |
| <div style="flex:1; min-width:0;"> | |
| <!-- Title --> | |
| <div style=" | |
| font-size:14px; font-weight:600; line-height:1.45; | |
| color:var(--text, #ffffff); margin-bottom:5px; | |
| ">{title}</div> | |
| <!-- Authors + Year --> | |
| <div style=" | |
| font-size:12px; color:var(--text-muted, #9ca3af); | |
| margin-bottom:8px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; | |
| "> | |
| <span>{authors_display}</span> | |
| {"<span style='color:" + source_color + "; font-weight:600;'>📅 " + str(year) + "</span>" if year else ""} | |
| </div> | |
| <!-- Badges row --> | |
| <div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center; margin-bottom:6px;"> | |
| <span style=" | |
| display:inline-flex; align-items:center; gap:4px; | |
| padding:2px 9px; border-radius:20px; font-size:10px; font-weight:600; | |
| background:{source_color}12; border:1px solid {source_color}30; color:{source_color}; | |
| "> | |
| <span style="width:5px;height:5px;border-radius:50%;background:{source_color};"></span> | |
| {source_label} | |
| </span> | |
| {grade_html} | |
| </div> | |
| <!-- Action buttons (visible on hover via CSS) --> | |
| <div class="paper-actions"> | |
| {copy_btn} | |
| {doi_html} | |
| {pdf_html} | |
| </div> | |
| {abstract_section} | |
| </div> | |
| </div> | |
| </div>''' | |
| return f'''<div style=" | |
| max-height:650px; overflow-y:auto; padding-right:4px; | |
| ">{cards_html}</div>''' | |
| 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''' | |
| <div style=" | |
| background: var(--glass, rgba(17,24,39,0.6)); | |
| backdrop-filter: blur(16px); | |
| border: 1px solid var(--glass-border, rgba(255,255,255,0.08)); | |
| border-radius: 14px; | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| animation: fadeIn 0.3s ease; | |
| "> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> | |
| <div style="display:flex; align-items:center; gap:10px;"> | |
| <span style=" | |
| font-size:20px; width:36px; height:36px; display:flex; align-items:center; justify-content:center; | |
| background:linear-gradient(135deg, rgba(139,92,246,0.15), rgba(99,102,241,0.1)); | |
| border-radius:10px; | |
| ">📊</span> | |
| <div> | |
| <div style="font-size:14px; font-weight:700; color:var(--text, #fff);"> | |
| {total} documentos encontrados | |
| </div> | |
| <div style="font-size:11px; color:var(--text-muted, #9ca3af);"> | |
| Modo {mode_label} · {len(source_counts)} fuentes · {year_range if year_range else "Años variados"} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="display:flex; gap:12px; font-size:11px; color:var(--text-muted, #9ca3af);"> | |
| <span title="Con DOI">🔗 {with_doi}</span> | |
| <span title="Con PDF">📄 {with_pdf}</span> | |
| </div> | |
| </div> | |
| <div style="display:flex; flex-wrap:wrap; gap:6px;"> | |
| {badges} | |
| </div> | |
| </div>''' | |
| async def search_handler(query, sources, max_results, year_start, year_end, university, search_type): | |
| if not query or not query.strip(): | |
| return "", "<div></div>", "<div></div>" | |
| 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 = '''<div style="text-align:center; padding:40px; color:#6b7280;"> | |
| <div style="font-size:36px; margin-bottom:12px;">🔍</div> | |
| <div style="font-weight:600;">Sin resultados</div> | |
| <div style="font-size:12px; opacity:0.7; margin-top:8px;">Intente con otros términos o fuentes.</div> | |
| </div>''' | |
| 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'''<div style=" | |
| background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.3); | |
| border-radius:12px; padding:16px 20px; color:#ef4444; | |
| "> | |
| <div style="font-weight:600; margin-bottom:4px;">❌ Error en la búsqueda</div> | |
| <div style="font-size:12px; opacity:0.8;">{str(e)}</div> | |
| </div>''' | |
| return "", error_html, "" | |
| def create_search_tab(): | |
| with gr.Tab("🔍 Búsqueda", id="search"): | |
| # ─── Header matching Next.js PaperSearch header ─── | |
| gr.HTML(''' | |
| <div style=" | |
| display:flex; justify-content:space-between; align-items:center; | |
| padding:12px 20px; margin-bottom:12px; | |
| background: linear-gradient(135deg, rgba(139,92,246,0.06), rgba(99,102,241,0.03)); | |
| border: 1px solid rgba(139,92,246,0.15); | |
| border-radius:14px; | |
| "> | |
| <div style="display:flex; align-items:center; gap:12px;"> | |
| <div style=" | |
| width:36px; height:36px; border-radius:10px; | |
| background:linear-gradient(135deg, #8b5cf6, #6366f1); | |
| display:flex; align-items:center; justify-content:center; | |
| font-size:18px; box-shadow:0 4px 15px rgba(139,92,246,0.3); | |
| ">🔬</div> | |
| <div> | |
| <div style="font-size:15px; font-weight:700; color:var(--text, #fff);"> | |
| Búsqueda Académica | |
| </div> | |
| <div style="font-size:11px; color:var(--text-muted, #9ca3af);"> | |
| 15+ fuentes · PubMed · Scopus · ArXiv · SciELO · OpenAlex · DOAJ | |
| </div> | |
| </div> | |
| </div> | |
| <div style=" | |
| display:inline-flex; align-items:center; gap:6px; | |
| padding:4px 12px; border-radius:20px; font-size:11px; font-weight:600; | |
| background:rgba(16,185,129,0.1); border:1px solid rgba(16,185,129,0.3); color:#10b981; | |
| "> | |
| <span style="width:6px;height:6px;border-radius:50%;background:#10b981;box-shadow:0 0 8px rgba(16,185,129,0.4);animation:pulse 2s infinite;"></span> | |
| Online | |
| </div> | |
| </div> | |
| ''') | |
| with gr.Row(): | |
| # ─── LEFT: Controls ─── | |
| with gr.Column(scale=2): | |
| # Search input with glassmorphic wrapper | |
| gr.HTML('''<div class="section-header">🔍 Consulta de búsqueda</div>''') | |
| 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('''<div class="section-header" style="margin-top:8px;">⚡ Modo de búsqueda</div>''') | |
| search_type = gr.Radio( | |
| choices=["simple", "smart", "batch"], | |
| value="simple", | |
| label="", | |
| show_label=False, | |
| ) | |
| gr.HTML(''' | |
| <div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:4px; margin-bottom:12px;"> | |
| <span style="font-size:10px; color:var(--text-muted, #9ca3af); padding:2px 8px; border-radius:6px; background:rgba(56,189,248,0.08); border:1px solid rgba(56,189,248,0.2); color:#38bdf8;"> | |
| 🔍 Simple: búsqueda directa | |
| </span> | |
| <span style="font-size:10px; color:var(--text-muted, #9ca3af); padding:2px 8px; border-radius:6px; background:rgba(168,85,247,0.08); border:1px solid rgba(168,85,247,0.2); color:#a855f7;"> | |
| ✨ Smart: auto-traduce ES↔EN | |
| </span> | |
| <span style="font-size:10px; color:var(--text-muted, #9ca3af); padding:2px 8px; border-radius:6px; background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.2); color:#f59e0b;"> | |
| 📦 Batch: múltiples queries | |
| </span> | |
| </div> | |
| ''') | |
| # Sources selector | |
| gr.HTML('''<div class="section-header" style="margin-top:4px;">🌐 Fuentes académicas</div>''') | |
| 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='''<div style=" | |
| text-align:center; padding:60px 20px; color:#6b7280; | |
| "> | |
| <div style="font-size:48px; margin-bottom:16px; opacity:0.5;">🔬</div> | |
| <div style="font-weight:600; margin-bottom:8px; font-size:15px;">Busca documentos académicos</div> | |
| <div style="font-size:12px; opacity:0.7; max-width:400px; margin:0 auto; line-height:1.6;"> | |
| Ingresa tu consulta a la izquierda y presiona "Buscar documentos".<br/> | |
| Los resultados aparecerán aquí como tarjetas interactivas con metadatos completos. | |
| </div> | |
| </div>''', | |
| 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] | |
| ) | |