| 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_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", |
| } |
|
|
| |
| 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_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_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_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_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_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>''' |
| |
| |
| 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)}" |
| |
| |
| 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, "" |
| |
| |
| df = format_results_for_dataframe(results_list) |
| |
| |
| stats_html = _build_stats_html(results_list, query=query.strip(), search_type=search_type) |
| |
| |
| 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"): |
| |
| 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(): |
| |
| with gr.Column(scale=2): |
| |
| 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"] |
| ) |
| |
| |
| 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> |
| ''') |
| |
| |
| 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, |
| ) |
| |
| |
| 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_btn = gr.Button( |
| "🚀 Buscar documentos", |
| variant="primary", |
| size="lg", |
| elem_classes=["ejecutar-btn"] |
| ) |
| |
| |
| with gr.Column(scale=3): |
| |
| stats_html = gr.HTML(value="", label="") |
| |
| |
| 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_html = gr.HTML(value="", visible=True) |
| |
| |
| 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] |
| ) |
|
|