letxinet / modules /search_tab.py
C2MV's picture
Initial upload for Build Small Hackathon
68fb5e2 verified
Raw
History Blame Contribute Delete
25.4 kB
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>
PDF
</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('"', "&quot;")
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]
)