Spaces:
Runtime error
Runtime error
Initial upload for Build Small Hackathon
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .chroma_db/chroma.sqlite3 +3 -0
- .env.example +26 -0
- .gitattributes +1 -0
- .gitignore +9 -0
- ANALISIS_BUGS_REFACTORIZACION.md +591 -0
- Dockerfile +12 -0
- Letxinet_Explicacion.html +62 -0
- Letxinet_Explicacion.pdf +99 -0
- README.md +104 -9
- app.py +224 -0
- assets/custom.js +460 -0
- assets/graphs/graph_01ea5af0.html +201 -0
- assets/graphs/graph_5a169899.html +201 -0
- assets/graphs/graph_77739dd7.html +201 -0
- assets/styles.css +899 -0
- backend/__init__.py +1 -0
- backend/database/models.py +58 -0
- backend/guardrails.py +33 -0
- backend/metadata_recovery.py +211 -0
- backend/persistence.py +56 -0
- backend/pipeline.py +1050 -0
- backend/prompts/__init__.py +30 -0
- backend/prompts/planning.py +59 -0
- backend/prompts/profiles.py +260 -0
- backend/prompts/synthesis.py +203 -0
- backend/providers/__init__.py +36 -0
- backend/providers/arxiv.py +71 -0
- backend/providers/base.py +62 -0
- backend/providers/core_.py +27 -0
- backend/providers/crossref.py +61 -0
- backend/providers/dblp.py +64 -0
- backend/providers/doaj.py +38 -0
- backend/providers/latam_repositories.py +114 -0
- backend/providers/openaire.py +36 -0
- backend/providers/openalex.py +70 -0
- backend/providers/pubmed.py +85 -0
- backend/providers/redalyc.py +28 -0
- backend/providers/scopus.py +33 -0
- backend/providers/semantic_scholar.py +56 -0
- backend/providers/serpapi.py +36 -0
- backend/providers/sources.py +45 -0
- backend/providers/zenodo.py +27 -0
- backend/smart_fusion.py +119 -0
- backend/synthesis.py +923 -0
- backend/tools/__init__.py +0 -0
- backend/tools/dme_extractor.py +105 -0
- backend/tools/export_utils.py +370 -0
- backend/tools/graph_generator.py +65 -0
- backend/tools/latex_compiler.py +74 -0
- backend/tools/metadata.py +69 -0
.chroma_db/chroma.sqlite3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6383dc1c0cefbd3b30e42ef6ac1c49139ba41868383b2f882ea9cdaae1980255
|
| 3 |
+
size 188416
|
.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LetXipu Gradio - Variables de Entorno (Ejemplo)
|
| 2 |
+
# Copia este archivo como .env y rellena con tus propias llaves API
|
| 3 |
+
|
| 4 |
+
# Backend URL (Opcional, si tienes un servicio externo conectado)
|
| 5 |
+
NEXTJS_API_URL=http://localhost:3000
|
| 6 |
+
API_V1_KEY=
|
| 7 |
+
|
| 8 |
+
# AI Providers (Llena los que planees utilizar)
|
| 9 |
+
GROQ_API_KEY=tu_api_key_aqui
|
| 10 |
+
OPENROUTER_API_KEY=tu_api_key_aqui
|
| 11 |
+
MISTRAL_API_KEY=tu_api_key_aqui
|
| 12 |
+
GEMINI_API_KEY=tu_api_key_aqui
|
| 13 |
+
DEEPSEEK_API_KEY=tu_api_key_aqui
|
| 14 |
+
NEBIUS_API_KEY=tu_api_key_aqui
|
| 15 |
+
HF_TOKEN=tu_token_huggingface_aqui
|
| 16 |
+
|
| 17 |
+
# Academic Providers (Opcional, mejora los limites de busqueda)
|
| 18 |
+
CORE_API_KEY=tu_api_key_aqui
|
| 19 |
+
SCOPUS_API_KEY=tu_api_key_aqui
|
| 20 |
+
SEMANTIC_SCHOLAR_API_KEY=tu_api_key_aqui
|
| 21 |
+
SERP_API_KEY=tu_api_key_aqui
|
| 22 |
+
|
| 23 |
+
# Azure (Solo si utilizas modelos hospedados en Azure)
|
| 24 |
+
AZURE_API_KEY=tu_api_key_aqui
|
| 25 |
+
AZURE_ENDPOINT=tu_endpoint_aqui
|
| 26 |
+
AZURE_AI_ENDPOINT=tu_ai_endpoint_aqui
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
.chroma_db/chroma.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
backend/letxipu.db
|
| 6 |
+
data/
|
| 7 |
+
.letxipu/
|
| 8 |
+
.idea/
|
| 9 |
+
.vscode/
|
ANALISIS_BUGS_REFACTORIZACION.md
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Analisis de bugs y refactorizacion
|
| 2 |
+
|
| 3 |
+
Fecha de revision: 2026-06-08
|
| 4 |
+
|
| 5 |
+
## Resumen ejecutivo
|
| 6 |
+
|
| 7 |
+
El proyecto compila y la app importa correctamente, pero hay fallos funcionales y de seguridad que pueden hacer que la interfaz prometa capacidades que el backend no ejecuta, que algunas busquedas fallen silenciosamente, y que datos externos se rendericen como HTML sin escape.
|
| 8 |
+
|
| 9 |
+
Hallazgos mas importantes:
|
| 10 |
+
|
| 11 |
+
- La configuracion de fuentes esta desalineada: la UI usa IDs en mayusculas y el motor usa IDs en minusculas.
|
| 12 |
+
- Scopus, CORE y SerpAPI no reciben API keys aunque existan en `.env`.
|
| 13 |
+
- La normalizacion de anos puede romper busquedas con `ValueError` o `TypeError`.
|
| 14 |
+
- Hay varias rutas de XSS/HTML injection al insertar metadatos externos en `gr.HTML`.
|
| 15 |
+
- Los controles de sistema pueden matar todos los procesos `python.exe`.
|
| 16 |
+
- La app crea y muestra credenciales por defecto `admin/admin123`.
|
| 17 |
+
- Las API keys se devuelven al frontend en la pestana de modelos.
|
| 18 |
+
- El mecanismo de detener pipeline usa `StopAsyncIteration` de forma insegura para async generators.
|
| 19 |
+
- La refactorizacion prioritaria debe centralizar fuentes/providers, sanitizar salidas HTML y separar configuracion sensible del cliente.
|
| 20 |
+
|
| 21 |
+
## Verificaciones realizadas
|
| 22 |
+
|
| 23 |
+
- `python -m compileall -q .`: OK.
|
| 24 |
+
- `venv\Scripts\python.exe -c "import app; print('app import ok')"`: OK.
|
| 25 |
+
- `python -m pytest -q`: falla porque `pytest` no esta instalado.
|
| 26 |
+
- `venv\Scripts\python.exe -m pytest -q`: falla porque `pytest` no esta instalado en el `venv`.
|
| 27 |
+
- Se reprodujeron fallos con providers simulados:
|
| 28 |
+
- filtro de fuentes con `OPENALEX` devuelve 0 resultados, con `openalex` devuelve 1.
|
| 29 |
+
- `year='s.f.'` rompe el filtro de anos.
|
| 30 |
+
- mezcla `year='2020'` y `year=2019` rompe el ordenamiento.
|
| 31 |
+
|
| 32 |
+
## Severidad
|
| 33 |
+
|
| 34 |
+
- P0: riesgo de seguridad o accion destructiva.
|
| 35 |
+
- P1: rompe flujo principal o fuente importante.
|
| 36 |
+
- P2: inconsistencia funcional, deuda tecnica con alto costo futuro.
|
| 37 |
+
- P3: mejora de robustez, UX o mantenibilidad.
|
| 38 |
+
|
| 39 |
+
## Hallazgos detallados
|
| 40 |
+
|
| 41 |
+
### 1. Configuracion de fuentes inconsistente
|
| 42 |
+
|
| 43 |
+
Severidad: P1
|
| 44 |
+
|
| 45 |
+
Archivos:
|
| 46 |
+
|
| 47 |
+
- `modules/config/sources_config_tab.py`
|
| 48 |
+
- `backend/tools/search_engine.py`
|
| 49 |
+
- `backend/providers/sources.py`
|
| 50 |
+
- `config.py`
|
| 51 |
+
- `modules/research_tab.py`
|
| 52 |
+
|
| 53 |
+
Sintoma:
|
| 54 |
+
|
| 55 |
+
La pestana de configuracion guarda fuentes como `OPENALEX`, `SCOPUS`, `LA_REFERENCIA`, pero el motor de busqueda filtra contra `openalex`, `scopus`, `lareferencia`.
|
| 56 |
+
|
| 57 |
+
Causa raiz:
|
| 58 |
+
|
| 59 |
+
Hay varias fuentes de verdad:
|
| 60 |
+
|
| 61 |
+
- `modules/config/sources_config_tab.py` define IDs en mayusculas.
|
| 62 |
+
- `backend/providers/sources.py` define grupos reales usados por `search_engine`.
|
| 63 |
+
- `config.py` define otro mapa mas amplio con fuentes que no estan implementadas.
|
| 64 |
+
- `modules/research_tab.py` define `ALL_SOURCES` manualmente.
|
| 65 |
+
|
| 66 |
+
Ademas, en `sources_config_tab.py`, la linea que inicializa `_enabled_sources` dentro de `create_sources_config_tab()` no declara `global`, por lo que crea una variable local. Al arrancar, la configuracion queda ignorada. Cuando el usuario cambia un checkbox, `_update_enabled()` si usa `global`, pero guarda IDs en mayusculas y puede filtrar todo.
|
| 67 |
+
|
| 68 |
+
Impacto:
|
| 69 |
+
|
| 70 |
+
- La UI puede decir que una fuente esta activa, pero el backend no la usa.
|
| 71 |
+
- El usuario puede desactivar/activar fuentes y dejar la busqueda sin providers efectivos.
|
| 72 |
+
- Dificulta diagnosticar por que "no hay resultados".
|
| 73 |
+
|
| 74 |
+
Correccion recomendada:
|
| 75 |
+
|
| 76 |
+
- Crear un registro unico de fuentes, por ejemplo `backend/providers/registry.py`.
|
| 77 |
+
- Usar siempre IDs canonicos en minusculas.
|
| 78 |
+
- Hacer que la UI derive sus opciones desde ese registro.
|
| 79 |
+
- Normalizar aliases antes de guardar configuracion.
|
| 80 |
+
- Eliminar o consolidar `config.py` si no es la fuente real.
|
| 81 |
+
|
| 82 |
+
Criterio de aceptacion:
|
| 83 |
+
|
| 84 |
+
- Activar `OpenAlex` en UI guarda `openalex`.
|
| 85 |
+
- Desactivar `openalex` realmente evita llamadas a OpenAlex.
|
| 86 |
+
- Seleccionar `all` expande solo providers implementados o marca claramente los no implementados.
|
| 87 |
+
|
| 88 |
+
Pruebas minimas:
|
| 89 |
+
|
| 90 |
+
- `expand_sources(["all"])` solo devuelve IDs canonicos.
|
| 91 |
+
- `enabled_sources=["OPENALEX"]` se normaliza a `["openalex"]`.
|
| 92 |
+
- `enabled_sources=["openalex"]` permite resultados de OpenAlex.
|
| 93 |
+
|
| 94 |
+
### 2. Scopus, CORE y SerpAPI no reciben API keys
|
| 95 |
+
|
| 96 |
+
Severidad: P1
|
| 97 |
+
|
| 98 |
+
Archivos:
|
| 99 |
+
|
| 100 |
+
- `backend/tools/search_engine.py`
|
| 101 |
+
- `backend/providers/scopus.py`
|
| 102 |
+
- `backend/providers/core_.py`
|
| 103 |
+
- `backend/providers/serpapi.py`
|
| 104 |
+
|
| 105 |
+
Sintoma:
|
| 106 |
+
|
| 107 |
+
Los providers premium o con clave devuelven lista vacia si `api_key` no se pasa como argumento. El motor los llama asi:
|
| 108 |
+
|
| 109 |
+
```python
|
| 110 |
+
PROVIDERS[src](query, limit=min(max_results, 50))
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Causa raiz:
|
| 114 |
+
|
| 115 |
+
Las claves estan en `.env`, pero no existe una capa que lea `SCOPUS_API_KEY`, `CORE_API_KEY` o `SERPAPI_API_KEY` y las inyecte al provider correspondiente.
|
| 116 |
+
|
| 117 |
+
Impacto:
|
| 118 |
+
|
| 119 |
+
- Scopus, CORE y SerpAPI aparecen configurables pero no funcionan.
|
| 120 |
+
- El sistema no distingue "sin resultados" de "no habia credencial".
|
| 121 |
+
|
| 122 |
+
Correccion recomendada:
|
| 123 |
+
|
| 124 |
+
- En el registro unico, cada provider debe declarar `env_key`, `requires_key` y `callable`.
|
| 125 |
+
- `search_engine.search()` debe resolver la clave por provider y pasarla como `api_key`.
|
| 126 |
+
- Si falta una clave requerida, devolver metadata tipo `sourceStatus` en lugar de silencio.
|
| 127 |
+
|
| 128 |
+
Criterio de aceptacion:
|
| 129 |
+
|
| 130 |
+
- Con `SCOPUS_API_KEY` en `.env`, `search(..., sources=["scopus"])` llama a Scopus con la clave.
|
| 131 |
+
- Sin clave, el resultado indica `scopus: missing_api_key`.
|
| 132 |
+
|
| 133 |
+
### 3. Filtro y ordenamiento por ano rompen con datos heterogeneos
|
| 134 |
+
|
| 135 |
+
Severidad: P1
|
| 136 |
+
|
| 137 |
+
Archivo:
|
| 138 |
+
|
| 139 |
+
- `backend/tools/search_engine.py`
|
| 140 |
+
|
| 141 |
+
Sintoma:
|
| 142 |
+
|
| 143 |
+
El filtro usa `int(r.get("year", 0))` sin validar. Si un provider devuelve `s.f.`, `N/A`, una fecha completa o un string no numerico, la busqueda completa falla.
|
| 144 |
+
|
| 145 |
+
Reproduccion:
|
| 146 |
+
|
| 147 |
+
- `year='s.f.'` + `year_start='2020'` lanza `ValueError`.
|
| 148 |
+
- `year='2020'` y `year=2019` sin filtro lanza `TypeError` al ordenar.
|
| 149 |
+
|
| 150 |
+
Causa raiz:
|
| 151 |
+
|
| 152 |
+
No hay normalizacion de metadatos a la entrada del motor.
|
| 153 |
+
|
| 154 |
+
Correccion recomendada:
|
| 155 |
+
|
| 156 |
+
- Crear `parse_year(value) -> Optional[int]`.
|
| 157 |
+
- Normalizar todos los resultados inmediatamente despues de recibirlos.
|
| 158 |
+
- Ordenar con una key que siempre devuelva int: `parse_year(x.get("year")) or 0`.
|
| 159 |
+
- Los filtros deben ignorar o conservar documentos sin ano segun politica explicita.
|
| 160 |
+
|
| 161 |
+
Criterio de aceptacion:
|
| 162 |
+
|
| 163 |
+
- `year="2020"`, `year=2020`, `year="2020-05-01"` se tratan como 2020.
|
| 164 |
+
- `year="s.f."`, `None`, `"N/A"` no rompen.
|
| 165 |
+
|
| 166 |
+
### 4. HTML injection / XSS en resultados, referencias y grafo
|
| 167 |
+
|
| 168 |
+
Severidad: P0
|
| 169 |
+
|
| 170 |
+
Archivos:
|
| 171 |
+
|
| 172 |
+
- `modules/search_tab.py`
|
| 173 |
+
- `modules/research_tab.py`
|
| 174 |
+
- `modules/graph_module.py`
|
| 175 |
+
- `assets/custom.js`
|
| 176 |
+
|
| 177 |
+
Sintoma:
|
| 178 |
+
|
| 179 |
+
Campos de proveedores externos se insertan directo en HTML:
|
| 180 |
+
|
| 181 |
+
- titulo
|
| 182 |
+
- autores
|
| 183 |
+
- abstract
|
| 184 |
+
- DOI
|
| 185 |
+
- PDF URL
|
| 186 |
+
- fuente
|
| 187 |
+
- contenido generado por IA
|
| 188 |
+
|
| 189 |
+
Tambien se usan `onclick` inline y `innerHTML`.
|
| 190 |
+
|
| 191 |
+
Impacto:
|
| 192 |
+
|
| 193 |
+
Un resultado academico malicioso o un dato corrupto puede inyectar HTML/JS en la app. Aunque sea local, el riesgo aumenta si se comparte con usuarios, se usa `share=True`, o se abre en red.
|
| 194 |
+
|
| 195 |
+
Correccion recomendada:
|
| 196 |
+
|
| 197 |
+
- Escapar texto con `html.escape`.
|
| 198 |
+
- Validar URLs con `urllib.parse`; permitir solo `http` y `https`.
|
| 199 |
+
- Construir atributos JS con `json.dumps`, no con reemplazos manuales.
|
| 200 |
+
- Evitar `onclick` inline; delegar eventos desde JS con `data-*`.
|
| 201 |
+
- En grafo, usar `textContent` para texto y crear nodos DOM en vez de concatenar strings con `innerHTML`.
|
| 202 |
+
- Revisar salida Markdown a HTML; si se usa `markdown`, sanitizar o restringir tags.
|
| 203 |
+
|
| 204 |
+
Criterio de aceptacion:
|
| 205 |
+
|
| 206 |
+
- Un titulo como `<img src=x onerror=alert(1)>` se muestra como texto, no ejecuta codigo.
|
| 207 |
+
- Una URL `javascript:alert(1)` no se renderiza como link.
|
| 208 |
+
|
| 209 |
+
### 5. Controles destructivos: reiniciar y matar procesos
|
| 210 |
+
|
| 211 |
+
Severidad: P0
|
| 212 |
+
|
| 213 |
+
Archivo:
|
| 214 |
+
|
| 215 |
+
- `app.py`
|
| 216 |
+
|
| 217 |
+
Sintoma:
|
| 218 |
+
|
| 219 |
+
Los botones de control ejecutan:
|
| 220 |
+
|
| 221 |
+
- `taskkill /F /IM python.exe /T`
|
| 222 |
+
- `taskkill` con `shell=True`
|
| 223 |
+
|
| 224 |
+
Impacto:
|
| 225 |
+
|
| 226 |
+
- Puede matar la app actual.
|
| 227 |
+
- Puede matar otros procesos Python del usuario.
|
| 228 |
+
- Puede interrumpir trabajos no relacionados.
|
| 229 |
+
|
| 230 |
+
Correccion recomendada:
|
| 231 |
+
|
| 232 |
+
- Eliminar el boton "Matar Procesos" de la UI normal.
|
| 233 |
+
- Si se requiere restart, reiniciar solo el proceso actual con un supervisor controlado.
|
| 234 |
+
- Evitar `shell=True`.
|
| 235 |
+
- No matar por nombre de proceso global.
|
| 236 |
+
|
| 237 |
+
Criterio de aceptacion:
|
| 238 |
+
|
| 239 |
+
- Ningun boton de UI mata todos los `python.exe`.
|
| 240 |
+
- Reinicio, si existe, afecta solo a la instancia actual.
|
| 241 |
+
|
| 242 |
+
### 6. Credenciales por defecto y hash debil
|
| 243 |
+
|
| 244 |
+
Severidad: P0/P1 segun despliegue
|
| 245 |
+
|
| 246 |
+
Archivo:
|
| 247 |
+
|
| 248 |
+
- `app.py`
|
| 249 |
+
|
| 250 |
+
Sintoma:
|
| 251 |
+
|
| 252 |
+
La app crea `admin/admin123` y lo muestra en el mensaje de login. La contrasena se almacena con SHA-256 simple, sin sal ni factor de coste.
|
| 253 |
+
|
| 254 |
+
Impacto:
|
| 255 |
+
|
| 256 |
+
- Cualquier usuario que vea el login sabe la credencial.
|
| 257 |
+
- Si se filtra la base, el hash es barato de romper.
|
| 258 |
+
|
| 259 |
+
Correccion recomendada:
|
| 260 |
+
|
| 261 |
+
- Exigir `LETXIPU_ADMIN_PASSWORD` o crear usuario en un comando setup.
|
| 262 |
+
- No mostrar credenciales en UI.
|
| 263 |
+
- Usar `passlib` con bcrypt/argon2 o `werkzeug.security`.
|
| 264 |
+
- Forzar cambio de password inicial.
|
| 265 |
+
|
| 266 |
+
Criterio de aceptacion:
|
| 267 |
+
|
| 268 |
+
- Sin password configurado, la app no crea admin debil.
|
| 269 |
+
- El mensaje de login no contiene credenciales.
|
| 270 |
+
|
| 271 |
+
### 7. API keys expuestas al frontend
|
| 272 |
+
|
| 273 |
+
Severidad: P0/P1
|
| 274 |
+
|
| 275 |
+
Archivo:
|
| 276 |
+
|
| 277 |
+
- `modules/config/ai_tab.py`
|
| 278 |
+
|
| 279 |
+
Sintoma:
|
| 280 |
+
|
| 281 |
+
La pestana de modelos precarga `MISTRAL_API_KEY` en un textbox y al cambiar provider devuelve la clave al cliente.
|
| 282 |
+
|
| 283 |
+
Impacto:
|
| 284 |
+
|
| 285 |
+
- Cualquier persona autenticada puede inspeccionar el HTML/estado y extraer secretos.
|
| 286 |
+
- Aumenta el riesgo si la app se comparte.
|
| 287 |
+
|
| 288 |
+
Correccion recomendada:
|
| 289 |
+
|
| 290 |
+
- Mostrar solo estado: configurada/no configurada.
|
| 291 |
+
- Si se permite actualizar claves, hacerlo con un flujo de escritura al `.env` cuidadosamente autorizado.
|
| 292 |
+
- Nunca devolver claves existentes al navegador.
|
| 293 |
+
|
| 294 |
+
Criterio de aceptacion:
|
| 295 |
+
|
| 296 |
+
- El frontend no recibe valores completos de API keys.
|
| 297 |
+
- El endpoint/evento solo devuelve mascara, por ejemplo `sk-...abcd`.
|
| 298 |
+
|
| 299 |
+
### 8. Detener pipeline puede terminar como error generico
|
| 300 |
+
|
| 301 |
+
Severidad: P1/P2
|
| 302 |
+
|
| 303 |
+
Archivos:
|
| 304 |
+
|
| 305 |
+
- `backend/pipeline.py`
|
| 306 |
+
- `modules/research_tab.py`
|
| 307 |
+
|
| 308 |
+
Sintoma:
|
| 309 |
+
|
| 310 |
+
`_checkpoint()` levanta `StopAsyncIteration`. En async generators, Python convierte esto en `RuntimeError: async generator raised StopAsyncIteration`.
|
| 311 |
+
|
| 312 |
+
Impacto:
|
| 313 |
+
|
| 314 |
+
- El boton detener puede mostrar error generico en vez de estado "detenido".
|
| 315 |
+
- La limpieza final puede no ejecutarse como se esperaba.
|
| 316 |
+
|
| 317 |
+
Correccion recomendada:
|
| 318 |
+
|
| 319 |
+
- Crear excepcion propia:
|
| 320 |
+
|
| 321 |
+
```python
|
| 322 |
+
class PipelineStopped(Exception):
|
| 323 |
+
pass
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
- Levantar `PipelineStopped`.
|
| 327 |
+
- Capturar `PipelineStopped` en handlers.
|
| 328 |
+
|
| 329 |
+
Criterio de aceptacion:
|
| 330 |
+
|
| 331 |
+
- Pulsar detener muestra estado detenido y no un traceback/error generico.
|
| 332 |
+
|
| 333 |
+
### 9. Cambio de proveedor IA en Research devuelve forma incorrecta
|
| 334 |
+
|
| 335 |
+
Severidad: P2
|
| 336 |
+
|
| 337 |
+
Archivo:
|
| 338 |
+
|
| 339 |
+
- `modules/research_tab.py`
|
| 340 |
+
|
| 341 |
+
Sintoma:
|
| 342 |
+
|
| 343 |
+
`update_models()` devuelve un solo `gr.update`, pero esta conectado a tres outputs: busqueda, sintesis y traduccion.
|
| 344 |
+
|
| 345 |
+
Impacto:
|
| 346 |
+
|
| 347 |
+
- Al cambiar proveedor, Gradio puede fallar o actualizar solo un componente.
|
| 348 |
+
|
| 349 |
+
Correccion recomendada:
|
| 350 |
+
|
| 351 |
+
- Devolver tres updates:
|
| 352 |
+
|
| 353 |
+
```python
|
| 354 |
+
update = gr.update(choices=models, value=models[0])
|
| 355 |
+
return update, update, update
|
| 356 |
+
```
|
| 357 |
+
|
| 358 |
+
o una lista de tres updates.
|
| 359 |
+
|
| 360 |
+
Criterio de aceptacion:
|
| 361 |
+
|
| 362 |
+
- Cambiar de `mistral` a `groq` actualiza los tres dropdowns.
|
| 363 |
+
|
| 364 |
+
### 10. Descarga de PDFs: SSRF, TLS deshabilitado y sin limite de tamano
|
| 365 |
+
|
| 366 |
+
Severidad: P0/P1
|
| 367 |
+
|
| 368 |
+
Archivos:
|
| 369 |
+
|
| 370 |
+
- `backend/tools/pdf_tools.py`
|
| 371 |
+
- `backend/tools/pdf_processor.py`
|
| 372 |
+
- `modules/pdf_tab.py`
|
| 373 |
+
- `modules/chat_tab.py`
|
| 374 |
+
|
| 375 |
+
Sintoma:
|
| 376 |
+
|
| 377 |
+
Se descargan URLs ingresadas por el usuario desde el servidor. Algunas descargas usan `verify=False`. No hay limite de tamano ni allowlist/bloqueo de IPs internas.
|
| 378 |
+
|
| 379 |
+
Impacto:
|
| 380 |
+
|
| 381 |
+
- SSRF contra servicios internos si la app se expone.
|
| 382 |
+
- Descarga de archivos enormes que agotan memoria o disco.
|
| 383 |
+
- TLS sin verificar permite MITM.
|
| 384 |
+
|
| 385 |
+
Correccion recomendada:
|
| 386 |
+
|
| 387 |
+
- Activar verificacion TLS.
|
| 388 |
+
- Bloquear IPs privadas/locales: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, link-local, metadata cloud.
|
| 389 |
+
- Limitar tamano por `Content-Length` y streaming con max bytes.
|
| 390 |
+
- Permitir solo `http`/`https`.
|
| 391 |
+
- Reusar un downloader comun.
|
| 392 |
+
|
| 393 |
+
Criterio de aceptacion:
|
| 394 |
+
|
| 395 |
+
- URL `http://127.0.0.1/...` se rechaza.
|
| 396 |
+
- PDF mayor al limite se corta con error claro.
|
| 397 |
+
|
| 398 |
+
### 11. Catalogo de fuentes promete providers no implementados
|
| 399 |
+
|
| 400 |
+
Severidad: P2
|
| 401 |
+
|
| 402 |
+
Archivos:
|
| 403 |
+
|
| 404 |
+
- `README.md`
|
| 405 |
+
- `config.py`
|
| 406 |
+
- `modules/config/sources_config_tab.py`
|
| 407 |
+
- `backend/providers/sources.py`
|
| 408 |
+
- `backend/tools/search_engine.py`
|
| 409 |
+
|
| 410 |
+
Sintoma:
|
| 411 |
+
|
| 412 |
+
Se mencionan fuentes como SciELO, CONAHCyT, UNAM, ANID, OasisBR, SNRD, MinCiencias, OpenReview, PapersWithCode o HuggingFace, pero el mapa real `PROVIDERS` no contiene implementaciones para muchas de ellas.
|
| 413 |
+
|
| 414 |
+
Impacto:
|
| 415 |
+
|
| 416 |
+
- La UI genera expectativas falsas.
|
| 417 |
+
- Los grupos `all`, `latam`, `ai_ml` pueden incluir fuentes que no hacen nada.
|
| 418 |
+
|
| 419 |
+
Correccion recomendada:
|
| 420 |
+
|
| 421 |
+
- El registro unico debe marcar `implemented=True/False`.
|
| 422 |
+
- La UI debe ocultar fuentes no implementadas o mostrarlas como "proximamente".
|
| 423 |
+
- Los grupos operativos deben incluir solo implementadas.
|
| 424 |
+
|
| 425 |
+
Criterio de aceptacion:
|
| 426 |
+
|
| 427 |
+
- No se puede seleccionar una fuente no implementada como si estuviera activa.
|
| 428 |
+
|
| 429 |
+
### 12. Manejo de errores silencioso en providers
|
| 430 |
+
|
| 431 |
+
Severidad: P2
|
| 432 |
+
|
| 433 |
+
Archivos:
|
| 434 |
+
|
| 435 |
+
- `backend/providers/*.py`
|
| 436 |
+
- `backend/providers/base.py`
|
| 437 |
+
- `backend/tools/search_engine.py`
|
| 438 |
+
|
| 439 |
+
Sintoma:
|
| 440 |
+
|
| 441 |
+
Muchos providers hacen `except Exception: return []`. `fetch_json()` tambien convierte cualquier error en `{"error": str(e)}`.
|
| 442 |
+
|
| 443 |
+
Impacto:
|
| 444 |
+
|
| 445 |
+
- Timeouts, credenciales faltantes, 403/429 y errores de parseo se ven igual que "0 resultados".
|
| 446 |
+
- Dificulta depurar fuentes rotas.
|
| 447 |
+
|
| 448 |
+
Correccion recomendada:
|
| 449 |
+
|
| 450 |
+
- Devolver estructura por fuente:
|
| 451 |
+
|
| 452 |
+
```python
|
| 453 |
+
{
|
| 454 |
+
"source": "openalex",
|
| 455 |
+
"ok": true,
|
| 456 |
+
"results": [],
|
| 457 |
+
"error": None,
|
| 458 |
+
"status": "ok"
|
| 459 |
+
}
|
| 460 |
+
```
|
| 461 |
+
|
| 462 |
+
- `search()` debe conservar `sourceErrors` y mostrarlos en UI.
|
| 463 |
+
|
| 464 |
+
Criterio de aceptacion:
|
| 465 |
+
|
| 466 |
+
- Si PubMed falla por timeout, la UI muestra "PubMed timeout" y no solo "sin resultados".
|
| 467 |
+
|
| 468 |
+
## Plan de refactorizacion recomendado
|
| 469 |
+
|
| 470 |
+
### Fase 1: estabilizacion funcional
|
| 471 |
+
|
| 472 |
+
Objetivo: que las busquedas basicas sean confiables.
|
| 473 |
+
|
| 474 |
+
Tareas:
|
| 475 |
+
|
| 476 |
+
- Crear `backend/providers/registry.py`.
|
| 477 |
+
- Consolidar grupos y aliases en un solo lugar.
|
| 478 |
+
- Normalizar IDs de fuentes a minusculas.
|
| 479 |
+
- Pasar API keys por provider desde `.env`.
|
| 480 |
+
- Agregar `parse_year()` y normalizacion de resultados.
|
| 481 |
+
- Corregir `update_models()` para multiples outputs.
|
| 482 |
+
- Reemplazar `StopAsyncIteration` por `PipelineStopped`.
|
| 483 |
+
|
| 484 |
+
Resultado esperado:
|
| 485 |
+
|
| 486 |
+
- `search()` no se cae por anos raros.
|
| 487 |
+
- Fuentes activadas en UI coinciden con providers usados.
|
| 488 |
+
- Scopus/CORE/SerpAPI usan claves si existen.
|
| 489 |
+
- Detener pipeline funciona sin error generico.
|
| 490 |
+
|
| 491 |
+
### Fase 2: seguridad de interfaz y secretos
|
| 492 |
+
|
| 493 |
+
Objetivo: eliminar riesgos P0.
|
| 494 |
+
|
| 495 |
+
Tareas:
|
| 496 |
+
|
| 497 |
+
- Escapar HTML en `search_tab`, `research_tab` y `graph_module`.
|
| 498 |
+
- Validar links antes de renderizar.
|
| 499 |
+
- Quitar `onclick` inline donde sea posible.
|
| 500 |
+
- No devolver API keys al frontend.
|
| 501 |
+
- Remover credenciales por defecto o exigir password por env.
|
| 502 |
+
- Eliminar controles `taskkill` globales.
|
| 503 |
+
|
| 504 |
+
Resultado esperado:
|
| 505 |
+
|
| 506 |
+
- Datos externos no ejecutan HTML/JS.
|
| 507 |
+
- Las claves no viajan al navegador.
|
| 508 |
+
- La app no mata procesos ajenos.
|
| 509 |
+
|
| 510 |
+
### Fase 3: downloader PDF seguro
|
| 511 |
+
|
| 512 |
+
Objetivo: robustecer lectura/vectorizacion/chat con PDFs.
|
| 513 |
+
|
| 514 |
+
Tareas:
|
| 515 |
+
|
| 516 |
+
- Crear `backend/tools/downloader.py`.
|
| 517 |
+
- Bloquear IPs internas y esquemas no permitidos.
|
| 518 |
+
- Descargar en streaming con limite de tamano.
|
| 519 |
+
- Activar TLS verification.
|
| 520 |
+
- Unificar `pdf_tools.py`, `pdf_processor.py` y `chat_tab.py`.
|
| 521 |
+
|
| 522 |
+
Resultado esperado:
|
| 523 |
+
|
| 524 |
+
- El PDF local funciona sin abrir SSRF ni OOM.
|
| 525 |
+
|
| 526 |
+
### Fase 4: arquitectura de resultados y errores
|
| 527 |
+
|
| 528 |
+
Objetivo: dejar de mezclar "sin resultados" con "fuente rota".
|
| 529 |
+
|
| 530 |
+
Tareas:
|
| 531 |
+
|
| 532 |
+
- Definir `SearchResult` y `SourceSearchOutcome`.
|
| 533 |
+
- Hacer que providers devuelvan resultados normalizados.
|
| 534 |
+
- Agregar `sourcesUsed`, `sourcesSkipped`, `sourceErrors`.
|
| 535 |
+
- Mostrar estado por fuente en UI.
|
| 536 |
+
|
| 537 |
+
Resultado esperado:
|
| 538 |
+
|
| 539 |
+
- La UI puede decir: "OpenAlex OK, PubMed timeout, Scopus falta API key".
|
| 540 |
+
|
| 541 |
+
### Fase 5: pruebas y CI local
|
| 542 |
+
|
| 543 |
+
Objetivo: evitar regresiones.
|
| 544 |
+
|
| 545 |
+
Tareas:
|
| 546 |
+
|
| 547 |
+
- Agregar `pytest` a `requirements-dev.txt` o `requirements.txt`.
|
| 548 |
+
- Tests unitarios para:
|
| 549 |
+
- expansion de fuentes
|
| 550 |
+
- normalizacion de aliases
|
| 551 |
+
- parseo de anos
|
| 552 |
+
- providers con/sin API key
|
| 553 |
+
- escape HTML
|
| 554 |
+
- detener pipeline
|
| 555 |
+
- Tests de integracion con providers simulados.
|
| 556 |
+
|
| 557 |
+
Resultado esperado:
|
| 558 |
+
|
| 559 |
+
- `pytest -q` corre sin depender de red.
|
| 560 |
+
- Los bugs reproducidos quedan cubiertos.
|
| 561 |
+
|
| 562 |
+
## Orden de implementacion sugerido
|
| 563 |
+
|
| 564 |
+
1. `registry.py` de fuentes/providers.
|
| 565 |
+
2. Normalizacion de resultados y anos.
|
| 566 |
+
3. API keys por provider.
|
| 567 |
+
4. Correccion de `update_models()` y `PipelineStopped`.
|
| 568 |
+
5. Escape HTML y validacion de URLs.
|
| 569 |
+
6. Retiro de `taskkill`, credenciales por defecto y exposicion de secrets.
|
| 570 |
+
7. Downloader PDF seguro.
|
| 571 |
+
8. Tests.
|
| 572 |
+
|
| 573 |
+
## Archivos mas importantes para tocar
|
| 574 |
+
|
| 575 |
+
- `backend/tools/search_engine.py`
|
| 576 |
+
- `backend/providers/sources.py`
|
| 577 |
+
- `backend/providers/registry.py` nuevo
|
| 578 |
+
- `modules/config/sources_config_tab.py`
|
| 579 |
+
- `modules/research_tab.py`
|
| 580 |
+
- `modules/search_tab.py`
|
| 581 |
+
- `modules/graph_module.py`
|
| 582 |
+
- `modules/config/ai_tab.py`
|
| 583 |
+
- `backend/tools/pdf_tools.py`
|
| 584 |
+
- `backend/tools/pdf_processor.py`
|
| 585 |
+
- `modules/chat_tab.py`
|
| 586 |
+
- `app.py`
|
| 587 |
+
- `requirements.txt` o `requirements-dev.txt`
|
| 588 |
+
|
| 589 |
+
## Nota sobre el estado del repo
|
| 590 |
+
|
| 591 |
+
El arbol de trabajo ya tenia cambios modificados y archivos sin seguimiento antes de este informe. No se debe hacer `reset` ni revertir esos cambios sin revisar su origen.
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["python", "app.py"]
|
Letxinet_Explicacion.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>Letxinet Gradio - Explicación</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: Arial, sans-serif; line-height: 1.6; margin: 40px; color: #333; }
|
| 8 |
+
h1 { color: #4338ca; border-bottom: 2px solid #e0e7ff; padding-bottom: 10px; }
|
| 9 |
+
h2 { color: #3730a3; margin-top: 30px; }
|
| 10 |
+
p { text-align: justify; }
|
| 11 |
+
ul { margin-bottom: 20px; }
|
| 12 |
+
li { margin-bottom: 10px; }
|
| 13 |
+
</style>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
|
| 17 |
+
<h1>LETXINET GRADIO - Asistente de Investigación Académica</h1>
|
| 18 |
+
|
| 19 |
+
<p><strong>Letxinet Gradio</strong> es una avanzada plataforma de investigación científica impulsada por Inteligencia Artificial y agentes autónomos. Desarrollada y diseñada íntegramente de forma independiente por el equipo <strong>c2mv</strong>, esta aplicación está especialmente ajustada para entornos de investigación universitaria y para el <em>Build Small Hackathon de HuggingFace</em>.</p>
|
| 20 |
+
|
| 21 |
+
<h2>CARACTERÍSTICAS PRINCIPALES</h2>
|
| 22 |
+
|
| 23 |
+
<ul>
|
| 24 |
+
<li><strong>1. Motor de Búsqueda Multi-Repositorio y Bypass "Anubis":</strong><br>
|
| 25 |
+
El núcleo del programa permite realizar búsquedas simultáneas en docenas de bases de datos. A diferencia de un buscador tradicional, Letxinet Gradio implementa un "DME" (Deep Metadata Enhancement) y mecanismos de bypass heurísticos que permiten burlar escudos anti-bot como Cloudflare o DSpace v7. Esto le permite extraer PDFs ocultos de repositorios latinoamericanos como ALICIA o RENATI, lo cual es vital para investigaciones en Perú. Además incluye un "Fallback Manager" que lanza búsquedas de rescate en OpenAlex y Semantic Scholar si los repositorios locales fallan o bloquean el acceso.</li>
|
| 26 |
+
|
| 27 |
+
<li><strong>2. Ecosistema de Agentes y Arquitectura ARA+</strong><br>
|
| 28 |
+
El sistema emplea un pipeline jerárquico. No le hace una sola pregunta a la IA, sino que lanza un enjambre de agentes:
|
| 29 |
+
<ul>
|
| 30 |
+
<li><em>El Metodólogo:</em> Revisa que el diseño científico sea sólido (usa protocolo GRADE).</li>
|
| 31 |
+
<li><em>El Teórico:</em> Elabora el marco conceptual.</li>
|
| 32 |
+
<li><em>El Arquitecto:</em> Orquesta el plan maestro de investigación.</li>
|
| 33 |
+
<li><em>El ARA+ (Agente de Refinamiento Académico):</em> La última fase, se encarga de corregir anglicismos, asegurar la cohesión geográfica (ej. priorizar datos de la "Universidad Nacional del Santa", Perú) y garantizar la escritura en formato científico impecable.</li>
|
| 34 |
+
</ul>
|
| 35 |
+
</li>
|
| 36 |
+
|
| 37 |
+
<li><strong>3. Análisis de PDFs con PyMuPDF</strong><br>
|
| 38 |
+
Se incluye un potente procesador de PDFs locales. En lugar de procesar ciegamente el texto, utiliza expresiones regulares complejas para separar el documento en "Metodología", "Resultados", "Conclusiones" y extraer estadísticas críticas (p-values, intervalos de confianza). El sistema soporta la inclusión de datos directamente a un ChromaDB (base de datos vectorial local) para el sistema de RAG (Retrieval-Augmented Generation).</li>
|
| 39 |
+
|
| 40 |
+
<li><strong>4. Clasificación GRADE Exhaustiva</strong><br>
|
| 41 |
+
Se integraron 4 modos de evaluación de evidencia médica y científica: Keywords, Oxford, LLM e Híbrido. El algoritmo evalúa cada fuente y determina si es una evidencia de nivel ALTO (ej. Metaanálisis) o MUY BAJO (ej. Opinión experta), ponderando los resultados finales para evitar sesgos en el informe redactado por la IA.</li>
|
| 42 |
+
|
| 43 |
+
<li><strong>5. Visualización Avanzada y Exportaciones Universales</strong><br>
|
| 44 |
+
La herramienta incluye:
|
| 45 |
+
<ul>
|
| 46 |
+
<li>Interfaz gráfica basada en Gradio (Glassmorphism, Dark Mode).</li>
|
| 47 |
+
<li>Generación de grafos interactivos (Pyvis y NetworkX) que mapean co-citaciones.</li>
|
| 48 |
+
<li>Sistema unificado de exportación de resultados a: Markdown, BibTeX (para gestores como Zotero o Mendeley), Word (.docx) y empaquetamiento del workspace en ZIP (informe, CSV de metadatos, configuraciones).</li>
|
| 49 |
+
</ul>
|
| 50 |
+
</li>
|
| 51 |
+
</ul>
|
| 52 |
+
|
| 53 |
+
<h2>CÓMO EJECUTAR ESTA APLICACIÓN</h2>
|
| 54 |
+
|
| 55 |
+
<ol>
|
| 56 |
+
<li>El sistema opera completamente en local con un entorno virtual Python (venv).</li>
|
| 57 |
+
<li>Todo el código fuente está alojado en GitHub y usa HuggingFace Spaces/Modelos a través de las APIs correspondientes.</li>
|
| 58 |
+
<li>El frontend de Gradio se lanza usando <code>python app.py</code>. Se ha eliminado cualquier capa de autenticación restrictiva; ahora es <strong>libre y de código abierto (Open Source)</strong>, listo para demostraciones y despliegues sin contraseña.</li>
|
| 59 |
+
</ol>
|
| 60 |
+
|
| 61 |
+
</body>
|
| 62 |
+
</html>
|
Letxinet_Explicacion.pdf
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.4
|
| 2 |
+
%���� ReportLab Generated PDF document (opensource)
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/F1 2 0 R /F2 3 0 R /F3 5 0 R
|
| 6 |
+
>>
|
| 7 |
+
endobj
|
| 8 |
+
2 0 obj
|
| 9 |
+
<<
|
| 10 |
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
| 11 |
+
>>
|
| 12 |
+
endobj
|
| 13 |
+
3 0 obj
|
| 14 |
+
<<
|
| 15 |
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
| 16 |
+
>>
|
| 17 |
+
endobj
|
| 18 |
+
4 0 obj
|
| 19 |
+
<<
|
| 20 |
+
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
|
| 21 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 22 |
+
>> /Rotate 0 /Trans <<
|
| 23 |
+
|
| 24 |
+
>>
|
| 25 |
+
/Type /Page
|
| 26 |
+
>>
|
| 27 |
+
endobj
|
| 28 |
+
5 0 obj
|
| 29 |
+
<<
|
| 30 |
+
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
| 31 |
+
>>
|
| 32 |
+
endobj
|
| 33 |
+
6 0 obj
|
| 34 |
+
<<
|
| 35 |
+
/Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
|
| 36 |
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
| 37 |
+
>> /Rotate 0 /Trans <<
|
| 38 |
+
|
| 39 |
+
>>
|
| 40 |
+
/Type /Page
|
| 41 |
+
>>
|
| 42 |
+
endobj
|
| 43 |
+
7 0 obj
|
| 44 |
+
<<
|
| 45 |
+
/PageMode /UseNone /Pages 9 0 R /Type /Catalog
|
| 46 |
+
>>
|
| 47 |
+
endobj
|
| 48 |
+
8 0 obj
|
| 49 |
+
<<
|
| 50 |
+
/Author (\(anonymous\)) /CreationDate (D:20260615184121-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260615184121-05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
| 51 |
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
| 52 |
+
>>
|
| 53 |
+
endobj
|
| 54 |
+
9 0 obj
|
| 55 |
+
<<
|
| 56 |
+
/Count 2 /Kids [ 4 0 R 6 0 R ] /Type /Pages
|
| 57 |
+
>>
|
| 58 |
+
endobj
|
| 59 |
+
10 0 obj
|
| 60 |
+
<<
|
| 61 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2261
|
| 62 |
+
>>
|
| 63 |
+
stream
|
| 64 |
+
Gat=+?$G&5&Ui!/Qo?IrXGW2=OhB)IP%a0b9%om1*5+T_7)K0/"X(3RIXU[8,%Wi>DR[Y6Yt5"a4#;*K$mHVJ-OTl$3RRM_rq7Y4BLp;U7H=B7QFA\VIe`mc3ip!,U5Mf7/B5:fGiu@CfG6=l@8Dn^L(mq`.Dm=$dCl)'s#%E\0'V[a^6q-3UuaBas2&FK"$^_M^\Ans*s\$3iV_=Y?V"*d8gVa+:AR:g,#PDg`Q1lGLibL8O)gMA<n$(`Rst,*r6q;?oENA"MLe13]b%OO_l5B.@p-Gm,7FA57&he0:SeXKG/F.gWM$@/L"KLo$\kb>6V\?.!Trt_0U'YgV=n>eFmuSr>%Q,-Wc=pU]0]r.&t8M>16.&9;&8+MRp4dJ)sN&t<bjm4:T<mt?R(#/9A#@eOk3I3YUPdYXYd!aYM$M:ND95^<*N#S3M[q=TUQA]h;MW[QYm<7.,b)*_k5XS]NU<7&UnXE,HkpAk7UG@=5a;@Q$(+CP>Nfhg.CI-+l'+pljl2N:WT\3V,LRY6>QEEP5g?tadVs#g![>bbEU,_Ld^sk(it^e-Q"YRqXYDm`:J5ZSBb32k^9Y*:#Z=>c=cm8fjfCk?*o2#%8MJ1>3fkSJ-]I_7.*Nm]!DZ%a5D8$\fCS8V*lg$8mSC%\_L?kgH+hL:Y5KGn=D\G.G)^d5.<ft%pQkT*^ULQ75NJ)(dt*p"X6ZFB:5KeL30s-=.Bt.r=G"?8$JStb(5nYAL&=nU!tF;Ld7]Bn1sp(MLqlQDKqAFNHCkopiC._?$[n(U/>k=-d!@$Hh>%X;4LfFFTq6ugtYUc[-HL=\YVC06nXMLS=1g6P-B9*&M_L@,)l)<`U@pad3i'X/kAf%_$;r"qob(kL:bk]AVtEC+>%A5:kB<uXA#uQdgal"Z?3G*K#P+/m*jH#Q?70daVQo`*Bi8,nRN<OXMWqVH1hB3;T;$>*%I9_nVsOCFuGc-0Jf?Xa3.VH[&'RO#U.2Le>9<_nQ"[S,=>'EV;n'h!NF:'=!"CT?1()D4'tgZCQp7'#m32/S]2qq6aMZa9mXU7I'I%5*["XkQ7n'a6KT8bKGM[njktCfPl@5?2A$kC2D?Wi*?AC#$NkVWqM?J[hR3d.3'(&tU\::)\eYmH.ItfEX_JL=6.P]D4","jPG1H]Tc8JuK/>^D^TK3Cq\O"N2D(?TJ_XJ;7HZCgSiM)VLg_R;:\TE@:WtV4EZSXYXida+U(GB]c/Kei1R='S/'Y>]QtMPb?0FA*hGqZhpj2XtNWU7nbGO%u[*@t#T/:pJGKPhO%>h,iSUJsg/U=kf_]^M;.,***D?sWZ_.0':VQ5dV5<uO8UIV8C'/6:eXDQLGEoCC\YpfWhJI;4JA"+I13;*C169f$UAA71-O3X>3P".THK74N*?ZOcomIuQk98GsS1d5b/2p-rHULmlZ;0MX&B3;W*86cYP._qJ^F;?n"C.s<(!eV0P^^B1r>8[Wr+FLkk"bQd_]/$VE0kh9t,lOFeB+iY=S-oGHW[j_=8<lO7ZMXqZB+*ABq=QEVU`uX^;9<up*t.:_kk&WS@0LNsQpIUB&jIkh&/(m-F[8[;,ZMG>%V-[@^"2QQ_XQW:0YA8#Gs*!1.Lem5.o[iW)g!$XU<!4<M2*u3dALuRan*?j%_Rn,7<r[L)&jk'+UjpH7WHdN8<tE)Ag2U_XE,AllnDQd%0cTgm87"7mbLAFG_'E#W^!_2E;p:$#3tdgJIdaY9E+$>;57X_JX=GT-g1T22WFp[OumaLdF!KmG8cKa3tog:<+\1Qd^f[(3MglMKs`25i9Kc2GC-b3)C=!&3""p>:sf`gh^DM]a992>9Q)X8(T`"DAQ@EcfNaIZ:aUr15K"m^m,Z:A>hiT%.ZZ("V*!60`rgbr2V;5p\]5c==I[?<EPPfp:._*AMp![(8Em03d\3RC;bPm61$34qK"B>u/!!G(GMK*Q(7m0-c0QM$Q)631!oq#K[9h(OW3os3]@^igL.dLnNJo<qBpCQ98D*tL02u*aIQ?^uaqD%1HO'FAJ!^&iX@pB\LDNKJMFm=2NfG:_`_4nnSKq#tkR?,oEZ<6-hE]H_>-fYFZ>fo/"V5(t0NENc4<aeWehLk/mIHq`]Z(Z3Q/\_GTRs#P?iNjPnLoZo(k@jMY?<Lqp^0j)LC2%L%RPqg=,fIfM%Iim%mUl-G]^ir,^j;'X"MY-;hjt,LTON$]JnUQ6]5lZe#-,2GkHRMi-CsoWI,Qfs5o&C?`Wp/<%,GtTR#`)m+OO?o\mR9l2`L~>endstream
|
| 65 |
+
endobj
|
| 66 |
+
11 0 obj
|
| 67 |
+
<<
|
| 68 |
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 874
|
| 69 |
+
>>
|
| 70 |
+
stream
|
| 71 |
+
Gas2H;/b/B&BE]*.IJoa'F4_?F\e5fb60C9fiD*u3>*^L:$gAQB^)GP?(e9t<FlICgNWDds*bb+3jeh]2_G(_6>0/Bn@K7/i:gD<#\Ph$:EE=0rQVNY0^*ZD1@<J+s,^VR0h!*CgZ@X<N_[8]#2Tbs/W@M4$KVhpMXtR5c5R>O",($s4,'YS_PHr.R>HNeT?8!ZKbb=)U#=@,+uMdFAWG=Wg6?AeM^EgAo)Nj&LoA^IS*a*%@5+!E0F/@<K!Jjc/QHu,n)T6&7,DCbOVYBE/_lOJ&%>C<*\.6pQ3%f>qD=LuZ=kQN;>R,r:)!u!Ot8H;>XCQ>g:4U\NNcFNkh#s._jn(\qFNR'6EWXp9D(q7#aqnldoEDLBXlNe:kPpoEOE>RM9*XoC,FnQ2@5)F&fj3#eh)]P6rNApcE`BKpa*Ae=KldhdffnGQ"j6R-B+7?=BIj$#s)pmLGH,8WQZiuJ*sA0F@6E4UihV^^s'IGm<!Mj]QX:AOrd!=Sob:A)R2*<`>_632jiL<pr@XO3un6OnM2*$E&!iBL8-1>?@SiJJ(FQ`8`(V;J8)di3Mq65`];\"F*1'QE)XoL%46R?7ro\\:>:Z;%rAgS)E-$RMRF=*\#,A1gBb2I@^=a?:_P8,TU5aOA>UNPG$1qWjoKOOcS.*CdToj'l>U;HY,X%:.[tBQQeY`uB'+0s$Cnq,'_HgsNMYl!FEG$[1/iqS.^_kcO<]^:qR*<M['(+&hG9R$4R**\[N[_S8:)!9rUS+e,I&e(j`Z.KC8TU7V1:E4D(Z3qpbacDo2nRm+N>%nc9WUS<8#Y!^!7$XF[kqHi]"Z\-N@d4Ek*rWX<<5sXhtlbFSn_?.ESciY"UD';[XuQ!3^`k3r~>endstream
|
| 72 |
+
endobj
|
| 73 |
+
xref
|
| 74 |
+
0 12
|
| 75 |
+
0000000000 65535 f
|
| 76 |
+
0000000061 00000 n
|
| 77 |
+
0000000112 00000 n
|
| 78 |
+
0000000219 00000 n
|
| 79 |
+
0000000331 00000 n
|
| 80 |
+
0000000525 00000 n
|
| 81 |
+
0000000640 00000 n
|
| 82 |
+
0000000834 00000 n
|
| 83 |
+
0000000902 00000 n
|
| 84 |
+
0000001182 00000 n
|
| 85 |
+
0000001247 00000 n
|
| 86 |
+
0000003600 00000 n
|
| 87 |
+
trailer
|
| 88 |
+
<<
|
| 89 |
+
/ID
|
| 90 |
+
[<6395a77bd2f5083957b1812ea19c317b><6395a77bd2f5083957b1812ea19c317b>]
|
| 91 |
+
% ReportLab generated PDF document -- digest (opensource)
|
| 92 |
+
|
| 93 |
+
/Info 8 0 R
|
| 94 |
+
/Root 7 0 R
|
| 95 |
+
/Size 12
|
| 96 |
+
>>
|
| 97 |
+
startxref
|
| 98 |
+
4565
|
| 99 |
+
%%EOF
|
README.md
CHANGED
|
@@ -1,13 +1,108 @@
|
|
| 1 |
---
|
| 2 |
-
title: Letxinet
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "Letxinet Gradio"
|
| 3 |
+
emoji: "🔬"
|
| 4 |
+
colorFrom: "purple"
|
| 5 |
+
colorTo: "indigo"
|
| 6 |
+
sdk: "gradio"
|
| 7 |
+
sdk_version: "4.0.0"
|
| 8 |
+
app_file: "app.py"
|
|
|
|
| 9 |
pinned: false
|
| 10 |
+
tags:
|
| 11 |
+
- build-small-hackathon
|
| 12 |
+
- agents
|
| 13 |
+
- research
|
| 14 |
+
- smolagents
|
| 15 |
+
- langchain
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# Letxinet Gradio: Asistente de Investigación Académica 🔬🤖
|
| 19 |
+
|
| 20 |
+
**Hackathon Submission: Build Small Hackathon**
|
| 21 |
+
|
| 22 |
+
* 🎥 **Demo Video:** [Ver Demo en YouTube](https://youtube.com/) *(Inserta el enlace final de tu demo aquí)*
|
| 23 |
+
* 🌐 **Social Link:** [Publicación en X / LinkedIn](https://twitter.com/) *(Inserta el enlace final de tu publicación aquí)*
|
| 24 |
+
* 👥 **Team Usernames:** [@c2mv](https://huggingface.co/c2mv)
|
| 25 |
+
|
| 26 |
+
Letxinet Gradio es una avanzada plataforma de investigación científica impulsada por Inteligencia Artificial y agentes autónomos. Permite realizar búsquedas profundas en docenas de repositorios académicos globales y regionales, sintetizar miles de documentos, construir mapas de conocimiento y renderizar informes matemáticos y científicos con alta fidelidad (LaTeX y Markdown).
|
| 27 |
+
|
| 28 |
+
## 🌟 Características Principales
|
| 29 |
+
|
| 30 |
+
* **Búsqueda Multi-Repositorio:** Conexión nativa con OpenAlex, PubMed, arXiv, Scopus, Crossref, DOAJ, Zenodo, repositorios de LATAM (ALICIA, RENATI, SciELO, Redalyc) y más.
|
| 31 |
+
* **Agentes de Síntesis IA:** Un ecosistema de agentes (Arquitecto, Redactor, Validador, ARA+) que leen, analizan y redactan informes científicos complejos sin alucinaciones.
|
| 32 |
+
* **Formatos Científicos Precisos:** Soporte nativo para matemáticas, símbolos químicos, notación científica y estructuración estricta en LaTeX adaptada al navegador.
|
| 33 |
+
* **Mapeo de Conocimiento:** Generación de grafos interactivos de redes de citas, coautoría e instituciones utilizando análisis relacional profundo.
|
| 34 |
+
* **Vectores y Memoria Local:** Procesamiento avanzado de PDFs y embeddings para chatear con tus propios documentos y recuperar datos clave instantáneamente.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## ⚙️ Requisitos y Dependencias
|
| 39 |
+
|
| 40 |
+
Asegúrate de tener instalado **Python 3.10 o superior**.
|
| 41 |
+
|
| 42 |
+
Las dependencias clave que hacen posible este proyecto son:
|
| 43 |
+
* **Gradio 4+:** Interfaz web interactiva e intuitiva.
|
| 44 |
+
* **LangChain & ChromaDB:** Cerebro vectorial y RAG (Retrieval-Augmented Generation) para leer PDFs.
|
| 45 |
+
* **PyMuPDF:** Extracción ultrarrápida de texto de documentos científicos.
|
| 46 |
+
* **NetworkX & Pyvis:** Renderizado de grafos relacionales y redes.
|
| 47 |
+
* **SQLAlchemy:** Almacenamiento local de historial de investigaciones.
|
| 48 |
+
* **Modelos IA:** Soporte múltiple para Mistral, Llama, OpenAI, Anthropic a través de Groq, OpenRouter y APIs nativas.
|
| 49 |
+
|
| 50 |
+
Puedes ver la lista técnica completa en el archivo `requirements.txt`.
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## 🚀 Instalación y Despliegue Local
|
| 55 |
+
|
| 56 |
+
Sigue estos pasos para levantar la plataforma en tu máquina:
|
| 57 |
+
|
| 58 |
+
### 1. Clonar el Repositorio
|
| 59 |
+
```bash
|
| 60 |
+
git clone https://github.com/C2MV96/letxinet-gradio.git
|
| 61 |
+
cd letxinet-gradio
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 2. Crear un Entorno Virtual
|
| 65 |
+
Se recomienda aislar las dependencias en un entorno virtual (`venv`):
|
| 66 |
+
```bash
|
| 67 |
+
# En Windows:
|
| 68 |
+
python -m venv venv
|
| 69 |
+
venv\Scripts\activate
|
| 70 |
+
|
| 71 |
+
# En Linux/Mac:
|
| 72 |
+
python3 -m venv venv
|
| 73 |
+
source venv/bin/activate
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### 3. Instalar Dependencias
|
| 77 |
+
```bash
|
| 78 |
+
pip install -r requirements.txt
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### 4. Configurar Variables de Entorno
|
| 82 |
+
El sistema necesita credenciales para acceder a los LLMs (Inteligencias Artificiales) y a las bases de datos académicas de pago/privadas.
|
| 83 |
+
1. Copia el archivo `.env.example` y renómbralo a `.env`:
|
| 84 |
+
```bash
|
| 85 |
+
cp .env.example .env
|
| 86 |
+
```
|
| 87 |
+
2. Abre el archivo `.env` y pega tus llaves API (ej. `MISTRAL_API_KEY`, `GROQ_API_KEY`). *No necesitas llenar todas, solo las que planees usar.*
|
| 88 |
+
|
| 89 |
+
### 5. Iniciar la Aplicación
|
| 90 |
+
Ejecuta el archivo principal para iniciar el servidor local:
|
| 91 |
+
```bash
|
| 92 |
+
python app.py
|
| 93 |
+
```
|
| 94 |
+
O si estás en Windows, simplemente dale doble clic al archivo `start.bat`.
|
| 95 |
+
|
| 96 |
+
La aplicación se abrirá en tu navegador (por defecto en `http://127.0.0.1:7860`).
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## 📖 Estructura del Proyecto
|
| 101 |
+
|
| 102 |
+
* `/backend`: Contiene el núcleo lógico, los agentes IA, parsers de repositorios (`/providers`), y prompts científicos (`/prompts`).
|
| 103 |
+
* `/modules`: Componentes de interfaz (UI) escritos en Gradio (pestañas, configuraciones, reportes).
|
| 104 |
+
* `/assets`: Archivos estáticos como estilos CSS personalizados, librerías de interfaz de cristal (Glassmorphism) y scripts.
|
| 105 |
+
* `/lib`: Librerías frontend pesadas de terceros empaquetadas localmente (vis.js, tom-select).
|
| 106 |
+
|
| 107 |
+
## 🛡️ Seguridad
|
| 108 |
+
* Las carpetas `venv`, bases de datos SQLite y el archivo `.env` están debidamente ignoradas (`.gitignore`) para que tu información y llaves API permanezcan completamente seguras y locales.
|
app.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LetXipu Beta SX - Gradio Interface v9.0
|
| 3 |
+
Dark/Light Theme · Glassmorphism · Chatbot-like Interface
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 11 |
+
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
| 14 |
+
|
| 15 |
+
from modules.search_tab import create_search_tab
|
| 16 |
+
from modules.metadata_tab import create_metadata_tab
|
| 17 |
+
from modules.pdf_tab import create_pdf_tab
|
| 18 |
+
from modules.research_tab import create_research_tab
|
| 19 |
+
from modules.sources_tab import create_sources_tab
|
| 20 |
+
from modules.prompts_config_tab import create_prompts_config_tab
|
| 21 |
+
from modules.config.agents_tab import create_agents_tab
|
| 22 |
+
from modules.config.sources_config_tab import create_sources_config_tab
|
| 23 |
+
from modules.config.ai_tab import create_ai_tab
|
| 24 |
+
from modules.config.mining_tab import create_mining_tab
|
| 25 |
+
from modules.history_tab import create_history_tab
|
| 26 |
+
|
| 27 |
+
from backend.database.models import init_db
|
| 28 |
+
init_db()
|
| 29 |
+
|
| 30 |
+
VERSION = "9.0.0"
|
| 31 |
+
|
| 32 |
+
# Note: Gradio loads external CSS and JS
|
| 33 |
+
assets_dir = os.path.join(os.path.dirname(__file__), "assets")
|
| 34 |
+
|
| 35 |
+
def create_app():
|
| 36 |
+
with gr.Blocks(title="LetXipu Beta SX") as app:
|
| 37 |
+
|
| 38 |
+
# ─── Theme Toggle Button ───
|
| 39 |
+
gr.HTML("""
|
| 40 |
+
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Cambiar a modo claro">
|
| 41 |
+
☀️
|
| 42 |
+
</button>
|
| 43 |
+
""")
|
| 44 |
+
|
| 45 |
+
# ─── Header ───
|
| 46 |
+
gr.HTML(f"""
|
| 47 |
+
<div class="header-banner">
|
| 48 |
+
<div style="display: flex; justify-content: space-between; align-items: center; position: relative; z-index: 2;">
|
| 49 |
+
<div>
|
| 50 |
+
<h1>🔬 LetXipu Beta SX</h1>
|
| 51 |
+
<p>Motor de Búsqueda Académica Independiente · Python Backend</p>
|
| 52 |
+
</div>
|
| 53 |
+
<div style="display: flex; gap: 8px; align-items: center;">
|
| 54 |
+
<div class="header-badge">v{VERSION}</div>
|
| 55 |
+
<div class="header-badge" style="background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981;">17 fuentes</div>
|
| 56 |
+
<div class="header-badge" style="background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6;">87 modelos</div>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
""")
|
| 61 |
+
|
| 62 |
+
# ─── Status ───
|
| 63 |
+
gr.HTML("""
|
| 64 |
+
<div class="status-banner status-connected">
|
| 65 |
+
<span class="status-dot connected"></span>
|
| 66 |
+
<span>✅ Backend Python independiente activo — Pipeline completo con 12 fases</span>
|
| 67 |
+
</div>
|
| 68 |
+
""")
|
| 69 |
+
|
| 70 |
+
# ─── Tabs ───
|
| 71 |
+
with gr.Tabs(elem_id="main-tabs") as tabs:
|
| 72 |
+
with gr.TabItem("🔍 Búsqueda y Extracción", id="search"):
|
| 73 |
+
create_search_tab()
|
| 74 |
+
with gr.TabItem("🔬 Agente de Research", id="research"):
|
| 75 |
+
create_research_tab()
|
| 76 |
+
with gr.TabItem("📄 Análisis PDF Local", id="pdf"):
|
| 77 |
+
create_pdf_tab()
|
| 78 |
+
with gr.TabItem("⚙️ Configuración (Core)", id="config_core"):
|
| 79 |
+
create_sources_tab()
|
| 80 |
+
create_prompts_config_tab()
|
| 81 |
+
with gr.TabItem("🛠️ Ajustes Avanzados", id="config_adv"):
|
| 82 |
+
create_agents_tab()
|
| 83 |
+
create_sources_config_tab()
|
| 84 |
+
create_ai_tab()
|
| 85 |
+
create_mining_tab()
|
| 86 |
+
with gr.TabItem("🕰️ Historial", id="history"):
|
| 87 |
+
create_history_tab()
|
| 88 |
+
|
| 89 |
+
# ─── Control Buttons ───
|
| 90 |
+
with gr.Row():
|
| 91 |
+
with gr.Column(scale=1):
|
| 92 |
+
gr.Markdown("### 🛠️ Controles del Sistema")
|
| 93 |
+
with gr.Column(scale=2):
|
| 94 |
+
with gr.Row():
|
| 95 |
+
restart_btn = gr.Button("🔄 Reiniciar App", variant="secondary", size="sm")
|
| 96 |
+
clear_cache_btn = gr.Button("🗑️ Limpiar Cache", variant="secondary", size="sm")
|
| 97 |
+
clear_processes_btn = gr.Button("🧹 Matar Procesos", variant="secondary", size="sm")
|
| 98 |
+
control_output = gr.Markdown("")
|
| 99 |
+
|
| 100 |
+
def do_restart():
|
| 101 |
+
import subprocess
|
| 102 |
+
import sys
|
| 103 |
+
try:
|
| 104 |
+
# Kill all python processes except current
|
| 105 |
+
os.system("taskkill /F /IM python.exe /T 2>nul")
|
| 106 |
+
# Restart the app
|
| 107 |
+
subprocess.Popen([sys.executable, "app.py"], cwd=os.path.dirname(os.path.abspath(__file__)))
|
| 108 |
+
return "🔄 Reiniciando app... La página se recargará en unos segundos."
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return f"❌ Error al reiniciar: {str(e)}"
|
| 111 |
+
|
| 112 |
+
def do_clear_cache():
|
| 113 |
+
import shutil
|
| 114 |
+
cleared = []
|
| 115 |
+
# Clear Gradio cache
|
| 116 |
+
gradio_cache = os.path.join(os.path.expanduser("~"), ".cache", "gradio")
|
| 117 |
+
if os.path.exists(gradio_cache):
|
| 118 |
+
shutil.rmtree(gradio_cache, ignore_errors=True)
|
| 119 |
+
cleared.append("Gradio cache")
|
| 120 |
+
# Clear Python __pycache__
|
| 121 |
+
for root, dirs, files in os.walk(os.path.dirname(os.path.abspath(__file__))):
|
| 122 |
+
for d in dirs:
|
| 123 |
+
if d == "__pycache__":
|
| 124 |
+
shutil.rmtree(os.path.join(root, d), ignore_errors=True)
|
| 125 |
+
cleared.append(root)
|
| 126 |
+
# Clear prompts config cache
|
| 127 |
+
prompts_cache = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prompts_config.json")
|
| 128 |
+
if os.path.exists(prompts_cache):
|
| 129 |
+
os.remove(prompts_cache)
|
| 130 |
+
cleared.append("prompts_config.json")
|
| 131 |
+
return f"🗑️ Cache limpiado: {', '.join(cleared) if cleared else 'nada que limpiar'}"
|
| 132 |
+
|
| 133 |
+
def do_clear_processes():
|
| 134 |
+
import subprocess
|
| 135 |
+
try:
|
| 136 |
+
# Kill orphaned python processes
|
| 137 |
+
result = subprocess.run(["taskkill", "/F", "/IM", "python.exe", "/T"],
|
| 138 |
+
capture_output=True, text=True, shell=True)
|
| 139 |
+
return f"🧹 Procesos limpiados: {result.stdout.strip() if result.stdout else 'completado'}"
|
| 140 |
+
except Exception as e:
|
| 141 |
+
return f"❌ Error: {str(e)}"
|
| 142 |
+
|
| 143 |
+
restart_btn.click(fn=do_restart, outputs=[control_output])
|
| 144 |
+
clear_cache_btn.click(fn=do_clear_cache, outputs=[control_output])
|
| 145 |
+
clear_processes_btn.click(fn=do_clear_processes, outputs=[control_output])
|
| 146 |
+
|
| 147 |
+
# ─── Footer ───
|
| 148 |
+
gr.HTML(f"""
|
| 149 |
+
<div class="app-footer">
|
| 150 |
+
<span>LetXipu Beta SX v{VERSION} · Independiente · 2026</span>
|
| 151 |
+
<span>Backend: Python + httpx · Frontend: Gradio · 17 fuentes · 87 modelos · Pipeline de 12 fases</span>
|
| 152 |
+
</div>
|
| 153 |
+
""")
|
| 154 |
+
|
| 155 |
+
return app
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
from backend.database.models import SessionLocal, User
|
| 160 |
+
import hashlib
|
| 161 |
+
|
| 162 |
+
# Asegurar que existe al menos un usuario administrador
|
| 163 |
+
def init_admin():
|
| 164 |
+
db = SessionLocal()
|
| 165 |
+
admin = db.query(User).filter(User.username == "admin").first()
|
| 166 |
+
if not admin:
|
| 167 |
+
hashed = hashlib.sha256("admin123".encode()).hexdigest()
|
| 168 |
+
db.add(User(username="admin", hashed_password=hashed, role="admin"))
|
| 169 |
+
db.commit()
|
| 170 |
+
db.close()
|
| 171 |
+
|
| 172 |
+
def check_auth(username, password):
|
| 173 |
+
db = SessionLocal()
|
| 174 |
+
user = db.query(User).filter(User.username == username).first()
|
| 175 |
+
db.close()
|
| 176 |
+
if user and user.hashed_password == hashlib.sha256(password.encode()).hexdigest():
|
| 177 |
+
return True
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
init_admin()
|
| 181 |
+
app = create_app()
|
| 182 |
+
with open("assets/styles.css", "r", encoding="utf-8") as f:
|
| 183 |
+
custom_css = f.read()
|
| 184 |
+
|
| 185 |
+
with open("assets/custom.js", "r", encoding="utf-8") as f:
|
| 186 |
+
custom_js = f.read()
|
| 187 |
+
|
| 188 |
+
app.launch(
|
| 189 |
+
server_name="127.0.0.1",
|
| 190 |
+
share=False,
|
| 191 |
+
show_error=True,
|
| 192 |
+
allowed_paths=[assets_dir],
|
| 193 |
+
theme=gr.themes.Base(
|
| 194 |
+
primary_hue="purple",
|
| 195 |
+
secondary_hue="indigo",
|
| 196 |
+
).set(
|
| 197 |
+
body_background_fill="#0a0a0c",
|
| 198 |
+
body_background_fill_dark="#0a0a0c",
|
| 199 |
+
block_background_fill="#111827",
|
| 200 |
+
block_background_fill_dark="#111827",
|
| 201 |
+
block_border_color="#374151",
|
| 202 |
+
block_border_color_dark="#374151",
|
| 203 |
+
block_label_text_color="#9ca3af",
|
| 204 |
+
block_label_text_color_dark="#9ca3af",
|
| 205 |
+
block_title_text_color="#ffffff",
|
| 206 |
+
block_title_text_color_dark="#ffffff",
|
| 207 |
+
input_background_fill="#1f2937",
|
| 208 |
+
input_background_fill_dark="#1f2937",
|
| 209 |
+
input_border_color="#374151",
|
| 210 |
+
input_border_color_dark="#374151",
|
| 211 |
+
button_primary_background_fill="#8b5cf6",
|
| 212 |
+
button_primary_background_fill_dark="#8b5cf6",
|
| 213 |
+
button_primary_text_color="#ffffff",
|
| 214 |
+
button_secondary_background_fill="#1f2937",
|
| 215 |
+
button_secondary_background_fill_dark="#1f2937",
|
| 216 |
+
button_secondary_text_color="#9ca3af",
|
| 217 |
+
checkbox_background_color="#1f2937",
|
| 218 |
+
checkbox_background_color_dark="#1f2937",
|
| 219 |
+
slider_color="#8b5cf6",
|
| 220 |
+
slider_color_dark="#8b5cf6",
|
| 221 |
+
),
|
| 222 |
+
css=custom_css,
|
| 223 |
+
js=custom_js
|
| 224 |
+
)
|
assets/custom.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ─── Theme Management ───
|
| 2 |
+
let currentTheme = 'dark';
|
| 3 |
+
|
| 4 |
+
function toggleTheme() {
|
| 5 |
+
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
| 6 |
+
const html = document.documentElement;
|
| 7 |
+
|
| 8 |
+
// Set theme attribute — CSS variables handle the rest
|
| 9 |
+
html.setAttribute('data-theme', currentTheme);
|
| 10 |
+
|
| 11 |
+
if (currentTheme === 'light') {
|
| 12 |
+
document.body.classList.remove('dark');
|
| 13 |
+
} else {
|
| 14 |
+
document.body.classList.add('dark');
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Override Gradio internal CSS variables
|
| 18 |
+
const root = document.documentElement;
|
| 19 |
+
const isLight = currentTheme === 'light';
|
| 20 |
+
|
| 21 |
+
const vars = isLight ? {
|
| 22 |
+
'--body-background-fill': '#f8fafc',
|
| 23 |
+
'--block-background-fill': '#ffffff',
|
| 24 |
+
'--block-border-color': '#e2e8f0',
|
| 25 |
+
'--block-label-text-color': '#475569',
|
| 26 |
+
'--block-title-text-color': '#0f172a',
|
| 27 |
+
'--input-background-fill': '#f1f5f9',
|
| 28 |
+
'--input-border-color': '#cbd5e1',
|
| 29 |
+
'--body-text-color': '#0f172a',
|
| 30 |
+
'--neutral-100': '#f1f5f9',
|
| 31 |
+
'--neutral-200': '#e2e8f0',
|
| 32 |
+
'--neutral-300': '#cbd5e1',
|
| 33 |
+
'--neutral-400': '#94a3b8',
|
| 34 |
+
'--neutral-500': '#64748b',
|
| 35 |
+
'--neutral-600': '#475569',
|
| 36 |
+
'--neutral-700': '#334155',
|
| 37 |
+
'--neutral-800': '#1e293b',
|
| 38 |
+
'--neutral-900': '#0f172a',
|
| 39 |
+
} : {
|
| 40 |
+
'--body-background-fill': '#0a0a0c',
|
| 41 |
+
'--block-background-fill': '#111827',
|
| 42 |
+
'--block-border-color': '#374151',
|
| 43 |
+
'--block-label-text-color': '#9ca3af',
|
| 44 |
+
'--block-title-text-color': '#ffffff',
|
| 45 |
+
'--input-background-fill': '#1f2937',
|
| 46 |
+
'--input-border-color': '#374151',
|
| 47 |
+
'--body-text-color': '#ffffff',
|
| 48 |
+
'--neutral-100': '#1f2937',
|
| 49 |
+
'--neutral-200': '#374151',
|
| 50 |
+
'--neutral-300': '#4b5563',
|
| 51 |
+
'--neutral-400': '#6b7280',
|
| 52 |
+
'--neutral-500': '#9ca3af',
|
| 53 |
+
'--neutral-600': '#d1d5db',
|
| 54 |
+
'--neutral-700': '#e5e7eb',
|
| 55 |
+
'--neutral-800': '#f3f4f6',
|
| 56 |
+
'--neutral-900': '#ffffff',
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// Apply Gradio vars to all containers
|
| 60 |
+
document.querySelectorAll('.gradio-container').forEach(el => {
|
| 61 |
+
Object.entries(vars).forEach(([k, v]) => el.style.setProperty(k, v));
|
| 62 |
+
el.style.background = isLight ? '#f8fafc' : '#0a0a0c';
|
| 63 |
+
el.style.color = isLight ? '#0f172a' : '#ffffff';
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// Force Gradio block/form/panel backgrounds
|
| 67 |
+
const bgColor = isLight ? '#ffffff' : '#111827';
|
| 68 |
+
const borderColor = isLight ? '#e2e8f0' : '#374151';
|
| 69 |
+
const textColor = isLight ? '#0f172a' : '#ffffff';
|
| 70 |
+
const subTextColor = isLight ? '#475569' : '#9ca3af';
|
| 71 |
+
const inputBg = isLight ? '#f1f5f9' : '#1f2937';
|
| 72 |
+
|
| 73 |
+
document.querySelectorAll('.block, .form, .panel').forEach(el => {
|
| 74 |
+
el.style.backgroundColor = bgColor;
|
| 75 |
+
el.style.borderColor = borderColor;
|
| 76 |
+
});
|
| 77 |
+
document.querySelectorAll('input, textarea, select').forEach(el => {
|
| 78 |
+
if (!el.closest('.glass-input-wrapper')) {
|
| 79 |
+
el.style.backgroundColor = inputBg;
|
| 80 |
+
el.style.borderColor = borderColor;
|
| 81 |
+
}
|
| 82 |
+
el.style.color = textColor;
|
| 83 |
+
});
|
| 84 |
+
document.querySelectorAll('label, .label-wrap, .block-label').forEach(el => {
|
| 85 |
+
if (!el.closest('.header-banner') && !el.closest('.glass-input-wrapper')) {
|
| 86 |
+
el.style.color = subTextColor;
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
// Update toggle button icon
|
| 91 |
+
const btn = document.getElementById('theme-toggle');
|
| 92 |
+
if (btn) {
|
| 93 |
+
btn.innerHTML = currentTheme === 'dark' ? '☀️' : '🌙';
|
| 94 |
+
btn.title = currentTheme === 'dark' ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro';
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Save preference
|
| 98 |
+
try { localStorage.setItem('letxipu-theme', currentTheme); } catch(e) {}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Initialize theme on load (respect saved preference)
|
| 102 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 103 |
+
try {
|
| 104 |
+
var saved = localStorage.getItem('letxipu-theme');
|
| 105 |
+
if (saved === 'light') {
|
| 106 |
+
currentTheme = 'dark'; // will be toggled to light
|
| 107 |
+
toggleTheme();
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
} catch(e) {}
|
| 111 |
+
document.body.classList.add('dark');
|
| 112 |
+
document.documentElement.setAttribute('data-theme', 'dark');
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
window._copyCitation = function(btn, citeText) {
|
| 116 |
+
navigator.clipboard.writeText(citeText).then(function() {
|
| 117 |
+
var originalHtml = btn.innerHTML;
|
| 118 |
+
btn.innerHTML = '✅ Copiado';
|
| 119 |
+
btn.style.color = '#10b981';
|
| 120 |
+
btn.style.borderColor = 'rgba(16,185,129,0.3)';
|
| 121 |
+
setTimeout(function() {
|
| 122 |
+
btn.innerHTML = originalHtml;
|
| 123 |
+
btn.style.color = 'var(--foreground, #fff)';
|
| 124 |
+
btn.style.borderColor = 'var(--border, rgba(255,255,255,0.1))';
|
| 125 |
+
}, 2000);
|
| 126 |
+
});
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
// ─── Citation Floating Card (Global) ───
|
| 130 |
+
window.showCiteCard = function(el) {
|
| 131 |
+
var b64 = el.getAttribute('data-cite-b64');
|
| 132 |
+
var raw = el.getAttribute('data-cite');
|
| 133 |
+
if (!b64 && !raw) return;
|
| 134 |
+
var data;
|
| 135 |
+
try {
|
| 136 |
+
if (b64) {
|
| 137 |
+
var decoded = decodeURIComponent(escape(atob(b64)));
|
| 138 |
+
data = JSON.parse(decoded);
|
| 139 |
+
} else {
|
| 140 |
+
data = JSON.parse(raw.replace(/"/g,'"').replace(/'/g,"'"));
|
| 141 |
+
}
|
| 142 |
+
} catch(e) { console.error('CiteCard parse error', e); return; }
|
| 143 |
+
|
| 144 |
+
var card = document.getElementById('cite-card-global');
|
| 145 |
+
var overlay = document.getElementById('cite-card-overlay-global');
|
| 146 |
+
|
| 147 |
+
if (!card) {
|
| 148 |
+
overlay = document.createElement('div');
|
| 149 |
+
overlay.id = 'cite-card-overlay-global';
|
| 150 |
+
overlay.style.cssText = 'display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:999998;background:transparent;';
|
| 151 |
+
overlay.addEventListener('click', function(){ window.closeCiteCard(); });
|
| 152 |
+
document.body.appendChild(overlay);
|
| 153 |
+
|
| 154 |
+
card = document.createElement('div');
|
| 155 |
+
card.id = 'cite-card-global';
|
| 156 |
+
card.style.cssText = 'display:none;position:fixed;z-index:999999;width:340px;max-width:90vw;background:var(--popup-bg, rgba(17,24,39,0.95));backdrop-filter:blur(10px);border:1px solid var(--popup-border, rgba(139,92,246,0.3));border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,0.5),0 0 30px rgba(139,92,246,0.1);font-family:Inter,sans-serif;transition:opacity 0.2s ease,transform 0.2s ease;opacity:0;transform:translateY(8px);color:var(--foreground, #fff);display:flex;flex-direction:column;';
|
| 157 |
+
card.innerHTML = '<div id="cite-card-content-global" style="display:flex;flex-direction:column;height:100%;"></div>';
|
| 158 |
+
document.body.appendChild(card);
|
| 159 |
+
|
| 160 |
+
// Setup dragging
|
| 161 |
+
var isDragging = false;
|
| 162 |
+
var dragOffset = {x: 0, y: 0};
|
| 163 |
+
|
| 164 |
+
document.addEventListener('mousemove', function(e) {
|
| 165 |
+
if (isDragging && card) {
|
| 166 |
+
card.style.left = (e.clientX - dragOffset.x) + 'px';
|
| 167 |
+
card.style.top = (e.clientY - dragOffset.y) + 'px';
|
| 168 |
+
card.style.transform = 'none'; // Clear animation transform
|
| 169 |
+
}
|
| 170 |
+
});
|
| 171 |
+
document.addEventListener('mouseup', function(e) {
|
| 172 |
+
isDragging = false;
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
window._startCardDrag = function(e) {
|
| 176 |
+
isDragging = true;
|
| 177 |
+
var rect = card.getBoundingClientRect();
|
| 178 |
+
dragOffset.x = e.clientX - rect.left;
|
| 179 |
+
dragOffset.y = e.clientY - rect.top;
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
};
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
overlay = document.getElementById('cite-card-overlay-global');
|
| 185 |
+
var content = document.getElementById('cite-card-content-global');
|
| 186 |
+
|
| 187 |
+
var doi = data.DOI || data.doi || '';
|
| 188 |
+
var pdfUrl = data.pdf_url || data.PDF || '';
|
| 189 |
+
var title = data['Título'] || data.title || 'Sin título';
|
| 190 |
+
var authors = data['Autores'] || data.authors || 'Autor desconocido';
|
| 191 |
+
var year = data['Año'] || data.year || '?';
|
| 192 |
+
var source = data['Fuente'] || data.source || 'Desconocido';
|
| 193 |
+
var abstract = data['Abstract'] || data.abstract || '';
|
| 194 |
+
|
| 195 |
+
// Icon SVGs
|
| 196 |
+
var iconMove = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"></polyline><polyline points="9 5 12 2 15 5"></polyline><polyline points="19 9 22 12 19 15"></polyline><polyline points="9 19 12 22 15 19"></polyline><line x1="2" y1="12" x2="22" y2="12"></line><line x1="12" y1="2" x2="12" y2="22"></line></svg>';
|
| 197 |
+
var iconX = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
|
| 198 |
+
var iconMsg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>';
|
| 199 |
+
var iconDl = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
| 200 |
+
var iconQuote = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>';
|
| 201 |
+
var iconLang = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"></path><path d="M4 14l6-6 2-3"></path><path d="M2 5h12"></path><path d="M7 2h1"></path><path d="M22 22l-5-10-5 10"></path><path d="M14 18h6"></path></svg>';
|
| 202 |
+
var iconBranch = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(90deg);"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>';
|
| 203 |
+
var iconExt = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>';
|
| 204 |
+
var iconSearch = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';
|
| 205 |
+
|
| 206 |
+
// Header
|
| 207 |
+
var headerHtml =
|
| 208 |
+
'<div onmousedown="window._startCardDrag(event)" style="padding:12px;border-bottom:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-between;align-items:center;cursor:grab;user-select:none;background:rgba(255,255,255,0.02);border-top-left-radius:12px;border-top-right-radius:12px;">' +
|
| 209 |
+
'<div style="display:flex;align-items:center;gap:6px;color:var(--accent, #8b5cf6);">' +
|
| 210 |
+
iconMove +
|
| 211 |
+
'<span style="font-size:11px;font-weight:700;letter-spacing:0.5px;">CITAR</span>' +
|
| 212 |
+
'</div>' +
|
| 213 |
+
'<button class="close-cite-btn" style="background:transparent;border:none;color:#9ca3af;cursor:pointer;display:flex;">' +
|
| 214 |
+
iconX +
|
| 215 |
+
'</button>' +
|
| 216 |
+
'</div>';
|
| 217 |
+
|
| 218 |
+
var authorStr = Array.isArray(authors) ? authors.join(', ') : authors;
|
| 219 |
+
var firstAuthor = Array.isArray(authors) ? authors[0] : authors.split(",")[0];
|
| 220 |
+
|
| 221 |
+
// Compute source URL and button styling
|
| 222 |
+
var sourceUrl = data.url || data.URL || '';
|
| 223 |
+
if (!sourceUrl && doi) sourceUrl = 'https://doi.org/' + doi;
|
| 224 |
+
if (!sourceUrl) sourceUrl = '#';
|
| 225 |
+
|
| 226 |
+
var sourceBtnText = 'Ver fuente';
|
| 227 |
+
var sourceBtnColor = '#a78bfa';
|
| 228 |
+
var sourceBtnBg = 'rgba(139,92,246,0.1)';
|
| 229 |
+
var sourceBtnBorder = 'rgba(139,92,246,0.3)';
|
| 230 |
+
var sourceBtnIcon = iconExt;
|
| 231 |
+
|
| 232 |
+
if (source.toLowerCase().includes('pubmed')) {
|
| 233 |
+
sourceBtnText = 'PubMed'; sourceBtnColor = '#22c55e'; sourceBtnBg = 'rgba(34,197,94,0.1)'; sourceBtnBorder = 'rgba(34,197,94,0.3)'; sourceBtnIcon = iconSearch;
|
| 234 |
+
} else if (source.toLowerCase().includes('semantic')) {
|
| 235 |
+
sourceBtnText = 'Semantic Scholar'; sourceBtnColor = '#3b82f6'; sourceBtnBg = 'rgba(59,130,246,0.1)'; sourceBtnBorder = 'rgba(59,130,246,0.3)'; sourceBtnIcon = iconSearch;
|
| 236 |
+
} else if (source.toLowerCase().includes('crossref')) {
|
| 237 |
+
sourceBtnText = 'Crossref'; sourceBtnColor = '#f59e0b'; sourceBtnBg = 'rgba(245,158,11,0.1)'; sourceBtnBorder = 'rgba(245,158,11,0.3)'; sourceBtnIcon = iconSearch;
|
| 238 |
+
} else if (source.toLowerCase().includes('openalex')) {
|
| 239 |
+
sourceBtnText = 'OpenAlex'; sourceBtnColor = '#ec4899'; sourceBtnBg = 'rgba(236,72,153,0.1)'; sourceBtnBorder = 'rgba(236,72,153,0.3)'; sourceBtnIcon = iconSearch;
|
| 240 |
+
} else if (doi) {
|
| 241 |
+
sourceBtnText = 'DOI'; sourceBtnColor = '#06b6d4'; sourceBtnBg = 'rgba(6,182,212,0.1)'; sourceBtnBorder = 'rgba(6,182,212,0.3)'; sourceBtnIcon = iconExt;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
var citeString = firstAuthor + " et al. (" + year + "). " + title + ". " + sourceUrl;
|
| 245 |
+
var escapedCiteString = citeString.replace(/'/g, "\\'").replace(/"/g, """).replace(/\n/g, " ").replace(/\r/g, "");
|
| 246 |
+
|
| 247 |
+
// Buttons
|
| 248 |
+
var actionBtnsHtml =
|
| 249 |
+
'<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">' +
|
| 250 |
+
'<button onclick="alert(\'Función Chat IA se implementará nativamente pronto.\')" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:6px;padding:6px;cursor:pointer;font-size:11px;font-weight:600;background:var(--popup-god-bg, rgba(139,92,246,0.15));border:1px solid var(--popup-god-border, rgba(139,92,246,0.3));color:var(--popup-god-color, #c084fc);">' +
|
| 251 |
+
iconMsg + ' Chat IA' +
|
| 252 |
+
'</button>' +
|
| 253 |
+
(pdfUrl ?
|
| 254 |
+
'<a href="'+pdfUrl+'" target="_blank" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:6px;padding:6px;cursor:pointer;font-size:11px;font-weight:600;background:var(--input-bg, rgba(255,255,255,0.05));border:1px solid var(--border, rgba(255,255,255,0.1));color:var(--foreground, #fff);text-decoration:none;">' +
|
| 255 |
+
iconDl + ' Descargar' +
|
| 256 |
+
'</a>' : '') +
|
| 257 |
+
'<button onclick="window._copyCitation(this, \'' + escapedCiteString + '\')" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:6px;padding:6px;cursor:pointer;font-size:11px;font-weight:600;background:var(--input-bg, rgba(255,255,255,0.05));border:1px solid var(--border, rgba(255,255,255,0.1));color:var(--foreground, #fff);">' +
|
| 258 |
+
iconQuote + ' Citar' +
|
| 259 |
+
'</button>' +
|
| 260 |
+
'<button onclick="alert(\'Traducción en la vista web disponible próximamente.\')" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:6px;padding:6px;cursor:pointer;font-size:11px;font-weight:600;background:var(--input-bg, rgba(255,255,255,0.05));border:1px solid var(--border, rgba(255,255,255,0.1));color:var(--foreground, #fff);">' +
|
| 261 |
+
iconLang + ' Traducir' +
|
| 262 |
+
'</button>' +
|
| 263 |
+
'<button onclick="alert(\'Añadido al flujo (simulación).\')" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:6px;padding:6px;cursor:pointer;font-size:11px;font-weight:600;background:rgba(249,115,22,0.15);border:1px solid rgba(249,115,22,0.3);color:var(--warning, #f59e0b);">' +
|
| 264 |
+
iconBranch + ' Flujo' +
|
| 265 |
+
'</button>' +
|
| 266 |
+
'</div>';
|
| 267 |
+
|
| 268 |
+
// Abstract box
|
| 269 |
+
var abstractHtml = '';
|
| 270 |
+
if (abstract) {
|
| 271 |
+
abstractHtml =
|
| 272 |
+
'<div class="custom-scrollbar" style="max-height:120px;overflow-y:auto;font-size:11px;line-height:1.5;color:var(--foreground, #fff);margin-bottom:16px;padding:8px;background:var(--input-bg, rgba(0,0,0,0.2));border-radius:6px;text-align:justify;">' +
|
| 273 |
+
abstract +
|
| 274 |
+
'</div>';
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
var bodyHtml =
|
| 278 |
+
'<div style="padding:16px;">' +
|
| 279 |
+
'<div style="font-size:14px;font-weight:700;margin-bottom:8px;line-height:1.4;color:white;word-break:break-word;white-space:normal;">' + title + '</div>' +
|
| 280 |
+
actionBtnsHtml +
|
| 281 |
+
'<div style="font-size:12px;color:#9ca3af;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.08);word-break:break-word;white-space:normal;">' +
|
| 282 |
+
authorStr + ' <span style="margin:0 6px;color:rgba(255,255,255,0.2);">|</span> ' + year +
|
| 283 |
+
'</div>' +
|
| 284 |
+
abstractHtml +
|
| 285 |
+
'<div style="display:flex;gap:8px;">' +
|
| 286 |
+
'<a href="'+sourceUrl+'" target="_blank" class="paper-action-btn" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;font-size:11px;font-weight:600;color:'+sourceBtnColor+';background:'+sourceBtnBg+';text-decoration:none;padding:8px;border-radius:6px;border:1px solid '+sourceBtnBorder+';">' +
|
| 287 |
+
sourceBtnIcon + sourceBtnText +
|
| 288 |
+
'</a>' +
|
| 289 |
+
'</div>' +
|
| 290 |
+
'</div>';
|
| 291 |
+
|
| 292 |
+
content.innerHTML = headerHtml + bodyHtml;
|
| 293 |
+
|
| 294 |
+
// Centering Logic
|
| 295 |
+
var rect = el.getBoundingClientRect();
|
| 296 |
+
var cardW = 340;
|
| 297 |
+
var left = rect.left + rect.width/2 - cardW/2;
|
| 298 |
+
if (left < 10) left = 10;
|
| 299 |
+
if (left + cardW > window.innerWidth - 10) left = window.innerWidth - cardW - 10;
|
| 300 |
+
|
| 301 |
+
// Position slightly offset from cursor or element
|
| 302 |
+
var top = rect.bottom + 10;
|
| 303 |
+
if (top + 400 > window.innerHeight) {
|
| 304 |
+
top = window.innerHeight - 410;
|
| 305 |
+
if (top < 10) top = 10;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
card.style.left = left + 'px';
|
| 309 |
+
card.style.top = top + 'px';
|
| 310 |
+
overlay.style.display = 'block';
|
| 311 |
+
card.style.display = 'flex';
|
| 312 |
+
setTimeout(function(){ card.style.opacity='1'; card.style.transform='translateY(0)'; }, 10);
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
window.closeCiteCard = function() {
|
| 316 |
+
var card = document.getElementById('cite-card-global');
|
| 317 |
+
var overlay = document.getElementById('cite-card-overlay-global');
|
| 318 |
+
if (card) {
|
| 319 |
+
card.style.opacity = '0';
|
| 320 |
+
card.style.transform = 'translateY(8px)';
|
| 321 |
+
setTimeout(function(){ card.style.display='none'; if(overlay) overlay.style.display='none'; }, 200);
|
| 322 |
+
}
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
// Event Delegation for Cite Links (Fix for Gradio 6 removing inline onclick handlers)
|
| 326 |
+
document.addEventListener('click', function(e) {
|
| 327 |
+
const citeLink = e.target.closest('.cite-link');
|
| 328 |
+
if (citeLink) {
|
| 329 |
+
e.preventDefault();
|
| 330 |
+
window.showCiteCard(citeLink);
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// Check for close button
|
| 335 |
+
if (e.target.closest('.close-cite-btn')) {
|
| 336 |
+
e.preventDefault();
|
| 337 |
+
window.closeCiteCard();
|
| 338 |
+
}
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
// References Pagination Logic
|
| 342 |
+
window.initRefsPagination = function() {
|
| 343 |
+
var container = document.getElementById('refs-container');
|
| 344 |
+
if (!container) return;
|
| 345 |
+
var items = Array.from(container.querySelectorAll('.ref-item'));
|
| 346 |
+
var filterCb = document.getElementById('refs-filter-cited');
|
| 347 |
+
var citedFilter = filterCb ? filterCb.checked : false;
|
| 348 |
+
|
| 349 |
+
var visibleItems = items.filter(function(item) {
|
| 350 |
+
if (citedFilter && item.getAttribute('data-cited') !== 'true') return false;
|
| 351 |
+
return true;
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
var perPage = 10;
|
| 355 |
+
var totalPages = Math.ceil(visibleItems.length / perPage);
|
| 356 |
+
var currentPage = parseInt(container.getAttribute('data-page')) || 1;
|
| 357 |
+
if (currentPage > totalPages && totalPages > 0) currentPage = totalPages;
|
| 358 |
+
if (currentPage < 1) currentPage = 1;
|
| 359 |
+
|
| 360 |
+
items.forEach(function(item) { item.style.display = 'none'; });
|
| 361 |
+
|
| 362 |
+
var start = (currentPage - 1) * perPage;
|
| 363 |
+
var end = start + perPage;
|
| 364 |
+
for (var i = start; i < end && i < visibleItems.length; i++) {
|
| 365 |
+
visibleItems[i].style.display = 'flex';
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
var pagContainer = document.getElementById('refs-pagination');
|
| 369 |
+
if (pagContainer) {
|
| 370 |
+
pagContainer.innerHTML = '';
|
| 371 |
+
if (totalPages > 1) {
|
| 372 |
+
var createBtn = function(text, page, disabled, active) {
|
| 373 |
+
var b = document.createElement('button');
|
| 374 |
+
b.innerHTML = text;
|
| 375 |
+
b.style.cssText = 'padding: 6px 14px; margin: 0 2px; border: 1px solid var(--border, #374151); background: ' + (active ? 'var(--accent, #8b5cf6)' : 'transparent') + '; color: ' + (active ? '#fff' : 'var(--foreground, #d1d5db)') + '; border-radius: 6px; cursor: ' + (disabled ? 'default' : 'pointer') + '; opacity: ' + (disabled ? '0.5' : '1') + '; font-size: 13px; font-weight: 600; transition: all 0.2s;';
|
| 376 |
+
if (!disabled && !active) {
|
| 377 |
+
b.onmouseover = function() { b.style.background = 'rgba(139,92,246,0.15)'; };
|
| 378 |
+
b.onmouseout = function() { b.style.background = 'transparent'; };
|
| 379 |
+
b.onclick = function() {
|
| 380 |
+
container.setAttribute('data-page', page);
|
| 381 |
+
window.initRefsPagination();
|
| 382 |
+
// Scroll to top of container smoothly
|
| 383 |
+
var y = container.getBoundingClientRect().top + window.scrollY - 100;
|
| 384 |
+
window.scrollTo({top: y, behavior: 'smooth'});
|
| 385 |
+
};
|
| 386 |
+
}
|
| 387 |
+
return b;
|
| 388 |
+
};
|
| 389 |
+
|
| 390 |
+
pagContainer.appendChild(createBtn('Anterior', currentPage - 1, currentPage === 1, false));
|
| 391 |
+
|
| 392 |
+
var startP = Math.max(1, currentPage - 2);
|
| 393 |
+
var endP = Math.min(totalPages, startP + 4);
|
| 394 |
+
if (endP - startP < 4) startP = Math.max(1, endP - 4);
|
| 395 |
+
|
| 396 |
+
for (var p = startP; p <= endP; p++) {
|
| 397 |
+
pagContainer.appendChild(createBtn(p, p, false, p === currentPage));
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
pagContainer.appendChild(createBtn('Siguiente', currentPage + 1, currentPage === totalPages, false));
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
var stats = document.getElementById('refs-stats');
|
| 405 |
+
if (stats) stats.innerHTML = 'Mostrando ' + (visibleItems.length > 0 ? start + 1 : 0) + ' - ' + Math.min(end, visibleItems.length) + ' de <b>' + visibleItems.length + '</b> referencias';
|
| 406 |
+
};
|
| 407 |
+
|
| 408 |
+
// Make Headers Collapsible
|
| 409 |
+
window.makeHeadersCollapsible = function() {
|
| 410 |
+
var container = document.getElementById('report-content');
|
| 411 |
+
if (!container) return;
|
| 412 |
+
|
| 413 |
+
// Prevent double processing
|
| 414 |
+
if (container.getAttribute('data-collapsible-processed') === 'true') return;
|
| 415 |
+
container.setAttribute('data-collapsible-processed', 'true');
|
| 416 |
+
|
| 417 |
+
var headers = Array.from(container.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
| 418 |
+
if (headers.length === 0) return;
|
| 419 |
+
|
| 420 |
+
headers.forEach(function(h) {
|
| 421 |
+
if (h.parentElement && h.parentElement.tagName.toLowerCase() === 'summary') return;
|
| 422 |
+
|
| 423 |
+
var details = document.createElement('details');
|
| 424 |
+
details.open = true; // Open by default as requested
|
| 425 |
+
|
| 426 |
+
var summary = document.createElement('summary');
|
| 427 |
+
summary.innerHTML = h.innerHTML;
|
| 428 |
+
summary.className = h.className;
|
| 429 |
+
|
| 430 |
+
h.parentNode.insertBefore(details, h);
|
| 431 |
+
details.appendChild(summary);
|
| 432 |
+
|
| 433 |
+
var headerLevel = parseInt(h.tagName[1]);
|
| 434 |
+
var next = h.nextSibling;
|
| 435 |
+
|
| 436 |
+
while (next) {
|
| 437 |
+
var current = next;
|
| 438 |
+
next = next.nextSibling;
|
| 439 |
+
|
| 440 |
+
if (current.nodeType === 1 && current.tagName.match(/^H[1-6]$/i)) {
|
| 441 |
+
var currentLevel = parseInt(current.tagName[1]);
|
| 442 |
+
if (currentLevel <= headerLevel) {
|
| 443 |
+
break;
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
details.appendChild(current);
|
| 447 |
+
}
|
| 448 |
+
h.remove();
|
| 449 |
+
});
|
| 450 |
+
};
|
| 451 |
+
|
| 452 |
+
// MathJax Loading
|
| 453 |
+
window.MathJax = {
|
| 454 |
+
tex: { inlineMath: [['$','$'], ['\\\\(','\\\\)']], displayMath: [['$$','$$'], ['\\\\[','\\\\]']] },
|
| 455 |
+
options: { skipHtmlTags: ['script','noscript','style','textarea','pre','code'] }
|
| 456 |
+
};
|
| 457 |
+
const script = document.createElement('script');
|
| 458 |
+
script.src = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js";
|
| 459 |
+
script.async = true;
|
| 460 |
+
document.head.appendChild(script);
|
assets/graphs/graph_01ea5af0.html
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<meta charset="utf-8">
|
| 4 |
+
|
| 5 |
+
<script src="lib/bindings/utils.js"></script>
|
| 6 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/dist/vis-network.min.css" integrity="sha512-WgxfT5LWjfszlPHXRmBWHkV2eceiWTOBvrKCNbdgDYTHrT2AeLCGbF4sZlZw3UMN3WtL0tGUoIAKsu8mllg/XA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
| 7 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js" integrity="sha512-LnvoEWDFrqGHlHmDD2101OrLcbsfkrzoSpvtSQtxK3RMnRV0eOkhhBN2dXHKRrUU8p2DGRTk35n4O8nWSVe1mQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
| 8 |
+
|
| 9 |
+
<link href="lib/tom-select/tom-select.css" rel="stylesheet">
|
| 10 |
+
<script src="lib/tom-select/tom-select.complete.min.js"></script>
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
<center>
|
| 14 |
+
<h1></h1>
|
| 15 |
+
</center>
|
| 16 |
+
|
| 17 |
+
<!-- <link rel="stylesheet" href="../node_modules/vis/dist/vis.min.css" type="text/css" />
|
| 18 |
+
<script type="text/javascript" src="../node_modules/vis/dist/vis.js"> </script>-->
|
| 19 |
+
<link
|
| 20 |
+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
|
| 21 |
+
rel="stylesheet"
|
| 22 |
+
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
|
| 23 |
+
crossorigin="anonymous"
|
| 24 |
+
/>
|
| 25 |
+
<script
|
| 26 |
+
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
|
| 27 |
+
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
|
| 28 |
+
crossorigin="anonymous"
|
| 29 |
+
></script>
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
<center>
|
| 33 |
+
<h1></h1>
|
| 34 |
+
</center>
|
| 35 |
+
<style type="text/css">
|
| 36 |
+
|
| 37 |
+
#mynetwork {
|
| 38 |
+
width: 100%;
|
| 39 |
+
height: 600px;
|
| 40 |
+
background-color: #0f172a;
|
| 41 |
+
border: 1px solid lightgray;
|
| 42 |
+
position: relative;
|
| 43 |
+
float: left;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
<body>
|
| 56 |
+
<div class="card" style="width: 100%">
|
| 57 |
+
|
| 58 |
+
<div id="select-menu" class="card-header">
|
| 59 |
+
<div class="row no-gutters">
|
| 60 |
+
<div class="col-10 pb-2">
|
| 61 |
+
<select
|
| 62 |
+
class="form-select"
|
| 63 |
+
aria-label="Default select example"
|
| 64 |
+
onchange="selectNode([value]);"
|
| 65 |
+
id="select-node"
|
| 66 |
+
placeholder="Select node..."
|
| 67 |
+
>
|
| 68 |
+
<option selected>Select a Node by ID</option>
|
| 69 |
+
|
| 70 |
+
<option value="Desconocido...">Desconocido...</option>
|
| 71 |
+
|
| 72 |
+
<option value="Fuente Desconocida">Fuente Desconocida</option>
|
| 73 |
+
|
| 74 |
+
</select>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="col-2 pb-2">
|
| 77 |
+
<button type="button" class="btn btn-primary btn-block" onclick="neighbourhoodHighlight({nodes: []});">Reset Selection</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
<div id="mynetwork" class="card-body"></div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
<script type="text/javascript">
|
| 90 |
+
|
| 91 |
+
// initialize global variables.
|
| 92 |
+
var edges;
|
| 93 |
+
var nodes;
|
| 94 |
+
var allNodes;
|
| 95 |
+
var allEdges;
|
| 96 |
+
var nodeColors;
|
| 97 |
+
var originalNodes;
|
| 98 |
+
var network;
|
| 99 |
+
var container;
|
| 100 |
+
var options, data;
|
| 101 |
+
var filter = {
|
| 102 |
+
item : '',
|
| 103 |
+
property : '',
|
| 104 |
+
value : []
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
new TomSelect("#select-node",{
|
| 109 |
+
create: false,
|
| 110 |
+
sortField: {
|
| 111 |
+
field: "text",
|
| 112 |
+
direction: "asc"
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// This method is responsible for drawing the graph, returns the drawn network
|
| 120 |
+
function drawGraph() {
|
| 121 |
+
var container = document.getElementById('mynetwork');
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
// parsing and collecting nodes and edges from the python
|
| 126 |
+
nodes = new vis.DataSet([{"color": "#8b5cf6", "font": {"color": "white"}, "id": "Desconocido...", "label": "Desconocido...", "shape": "dot", "size": 20, "title": ""}, {"color": "#10b981", "font": {"color": "white"}, "id": "Fuente Desconocida", "label": "Fuente Desconocida", "shape": "square", "size": 25}]);
|
| 127 |
+
edges = new vis.DataSet([{"from": "Desconocido...", "to": "Fuente Desconocida", "width": 1}]);
|
| 128 |
+
|
| 129 |
+
nodeColors = {};
|
| 130 |
+
allNodes = nodes.get({ returnType: "Object" });
|
| 131 |
+
for (nodeId in allNodes) {
|
| 132 |
+
nodeColors[nodeId] = allNodes[nodeId].color;
|
| 133 |
+
}
|
| 134 |
+
allEdges = edges.get({ returnType: "Object" });
|
| 135 |
+
// adding nodes and edges to the graph
|
| 136 |
+
data = {nodes: nodes, edges: edges};
|
| 137 |
+
|
| 138 |
+
var options = {
|
| 139 |
+
"configure": {
|
| 140 |
+
"enabled": false
|
| 141 |
+
},
|
| 142 |
+
"edges": {
|
| 143 |
+
"color": {
|
| 144 |
+
"inherit": true
|
| 145 |
+
},
|
| 146 |
+
"smooth": {
|
| 147 |
+
"enabled": true,
|
| 148 |
+
"type": "dynamic"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"interaction": {
|
| 152 |
+
"dragNodes": true,
|
| 153 |
+
"hideEdgesOnDrag": false,
|
| 154 |
+
"hideNodesOnDrag": false
|
| 155 |
+
},
|
| 156 |
+
"physics": {
|
| 157 |
+
"enabled": true,
|
| 158 |
+
"forceAtlas2Based": {
|
| 159 |
+
"avoidOverlap": 0,
|
| 160 |
+
"centralGravity": 0.01,
|
| 161 |
+
"damping": 0.4,
|
| 162 |
+
"gravitationalConstant": -50,
|
| 163 |
+
"springConstant": 0.08,
|
| 164 |
+
"springLength": 100
|
| 165 |
+
},
|
| 166 |
+
"solver": "forceAtlas2Based",
|
| 167 |
+
"stabilization": {
|
| 168 |
+
"enabled": true,
|
| 169 |
+
"fit": true,
|
| 170 |
+
"iterations": 1000,
|
| 171 |
+
"onlyDynamicEdges": false,
|
| 172 |
+
"updateInterval": 50
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
network = new vis.Network(container, data, options);
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
network.on("selectNode", neighbourhoodHighlight);
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
return network;
|
| 196 |
+
|
| 197 |
+
}
|
| 198 |
+
drawGraph();
|
| 199 |
+
</script>
|
| 200 |
+
</body>
|
| 201 |
+
</html>
|
assets/graphs/graph_5a169899.html
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<meta charset="utf-8">
|
| 4 |
+
|
| 5 |
+
<script src="lib/bindings/utils.js"></script>
|
| 6 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/dist/vis-network.min.css" integrity="sha512-WgxfT5LWjfszlPHXRmBWHkV2eceiWTOBvrKCNbdgDYTHrT2AeLCGbF4sZlZw3UMN3WtL0tGUoIAKsu8mllg/XA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
| 7 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js" integrity="sha512-LnvoEWDFrqGHlHmDD2101OrLcbsfkrzoSpvtSQtxK3RMnRV0eOkhhBN2dXHKRrUU8p2DGRTk35n4O8nWSVe1mQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
| 8 |
+
|
| 9 |
+
<link href="lib/tom-select/tom-select.css" rel="stylesheet">
|
| 10 |
+
<script src="lib/tom-select/tom-select.complete.min.js"></script>
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
<center>
|
| 14 |
+
<h1></h1>
|
| 15 |
+
</center>
|
| 16 |
+
|
| 17 |
+
<!-- <link rel="stylesheet" href="../node_modules/vis/dist/vis.min.css" type="text/css" />
|
| 18 |
+
<script type="text/javascript" src="../node_modules/vis/dist/vis.js"> </script>-->
|
| 19 |
+
<link
|
| 20 |
+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
|
| 21 |
+
rel="stylesheet"
|
| 22 |
+
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
|
| 23 |
+
crossorigin="anonymous"
|
| 24 |
+
/>
|
| 25 |
+
<script
|
| 26 |
+
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
|
| 27 |
+
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
|
| 28 |
+
crossorigin="anonymous"
|
| 29 |
+
></script>
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
<center>
|
| 33 |
+
<h1></h1>
|
| 34 |
+
</center>
|
| 35 |
+
<style type="text/css">
|
| 36 |
+
|
| 37 |
+
#mynetwork {
|
| 38 |
+
width: 100%;
|
| 39 |
+
height: 600px;
|
| 40 |
+
background-color: #0f172a;
|
| 41 |
+
border: 1px solid lightgray;
|
| 42 |
+
position: relative;
|
| 43 |
+
float: left;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
<body>
|
| 56 |
+
<div class="card" style="width: 100%">
|
| 57 |
+
|
| 58 |
+
<div id="select-menu" class="card-header">
|
| 59 |
+
<div class="row no-gutters">
|
| 60 |
+
<div class="col-10 pb-2">
|
| 61 |
+
<select
|
| 62 |
+
class="form-select"
|
| 63 |
+
aria-label="Default select example"
|
| 64 |
+
onchange="selectNode([value]);"
|
| 65 |
+
id="select-node"
|
| 66 |
+
placeholder="Select node..."
|
| 67 |
+
>
|
| 68 |
+
<option selected>Select a Node by ID</option>
|
| 69 |
+
|
| 70 |
+
<option value="Desconocido...">Desconocido...</option>
|
| 71 |
+
|
| 72 |
+
<option value="Fuente Desconocida">Fuente Desconocida</option>
|
| 73 |
+
|
| 74 |
+
</select>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="col-2 pb-2">
|
| 77 |
+
<button type="button" class="btn btn-primary btn-block" onclick="neighbourhoodHighlight({nodes: []});">Reset Selection</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
<div id="mynetwork" class="card-body"></div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
<script type="text/javascript">
|
| 90 |
+
|
| 91 |
+
// initialize global variables.
|
| 92 |
+
var edges;
|
| 93 |
+
var nodes;
|
| 94 |
+
var allNodes;
|
| 95 |
+
var allEdges;
|
| 96 |
+
var nodeColors;
|
| 97 |
+
var originalNodes;
|
| 98 |
+
var network;
|
| 99 |
+
var container;
|
| 100 |
+
var options, data;
|
| 101 |
+
var filter = {
|
| 102 |
+
item : '',
|
| 103 |
+
property : '',
|
| 104 |
+
value : []
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
new TomSelect("#select-node",{
|
| 109 |
+
create: false,
|
| 110 |
+
sortField: {
|
| 111 |
+
field: "text",
|
| 112 |
+
direction: "asc"
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// This method is responsible for drawing the graph, returns the drawn network
|
| 120 |
+
function drawGraph() {
|
| 121 |
+
var container = document.getElementById('mynetwork');
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
// parsing and collecting nodes and edges from the python
|
| 126 |
+
nodes = new vis.DataSet([{"color": "#8b5cf6", "font": {"color": "white"}, "id": "Desconocido...", "label": "Desconocido...", "shape": "dot", "size": 20, "title": ""}, {"color": "#10b981", "font": {"color": "white"}, "id": "Fuente Desconocida", "label": "Fuente Desconocida", "shape": "square", "size": 25}]);
|
| 127 |
+
edges = new vis.DataSet([{"from": "Desconocido...", "to": "Fuente Desconocida", "width": 1}]);
|
| 128 |
+
|
| 129 |
+
nodeColors = {};
|
| 130 |
+
allNodes = nodes.get({ returnType: "Object" });
|
| 131 |
+
for (nodeId in allNodes) {
|
| 132 |
+
nodeColors[nodeId] = allNodes[nodeId].color;
|
| 133 |
+
}
|
| 134 |
+
allEdges = edges.get({ returnType: "Object" });
|
| 135 |
+
// adding nodes and edges to the graph
|
| 136 |
+
data = {nodes: nodes, edges: edges};
|
| 137 |
+
|
| 138 |
+
var options = {
|
| 139 |
+
"configure": {
|
| 140 |
+
"enabled": false
|
| 141 |
+
},
|
| 142 |
+
"edges": {
|
| 143 |
+
"color": {
|
| 144 |
+
"inherit": true
|
| 145 |
+
},
|
| 146 |
+
"smooth": {
|
| 147 |
+
"enabled": true,
|
| 148 |
+
"type": "dynamic"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"interaction": {
|
| 152 |
+
"dragNodes": true,
|
| 153 |
+
"hideEdgesOnDrag": false,
|
| 154 |
+
"hideNodesOnDrag": false
|
| 155 |
+
},
|
| 156 |
+
"physics": {
|
| 157 |
+
"enabled": true,
|
| 158 |
+
"forceAtlas2Based": {
|
| 159 |
+
"avoidOverlap": 0,
|
| 160 |
+
"centralGravity": 0.01,
|
| 161 |
+
"damping": 0.4,
|
| 162 |
+
"gravitationalConstant": -50,
|
| 163 |
+
"springConstant": 0.08,
|
| 164 |
+
"springLength": 100
|
| 165 |
+
},
|
| 166 |
+
"solver": "forceAtlas2Based",
|
| 167 |
+
"stabilization": {
|
| 168 |
+
"enabled": true,
|
| 169 |
+
"fit": true,
|
| 170 |
+
"iterations": 1000,
|
| 171 |
+
"onlyDynamicEdges": false,
|
| 172 |
+
"updateInterval": 50
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
network = new vis.Network(container, data, options);
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
network.on("selectNode", neighbourhoodHighlight);
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
return network;
|
| 196 |
+
|
| 197 |
+
}
|
| 198 |
+
drawGraph();
|
| 199 |
+
</script>
|
| 200 |
+
</body>
|
| 201 |
+
</html>
|
assets/graphs/graph_77739dd7.html
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<meta charset="utf-8">
|
| 4 |
+
|
| 5 |
+
<script src="lib/bindings/utils.js"></script>
|
| 6 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/dist/vis-network.min.css" integrity="sha512-WgxfT5LWjfszlPHXRmBWHkV2eceiWTOBvrKCNbdgDYTHrT2AeLCGbF4sZlZw3UMN3WtL0tGUoIAKsu8mllg/XA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
| 7 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.2/dist/vis-network.min.js" integrity="sha512-LnvoEWDFrqGHlHmDD2101OrLcbsfkrzoSpvtSQtxK3RMnRV0eOkhhBN2dXHKRrUU8p2DGRTk35n4O8nWSVe1mQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
| 8 |
+
|
| 9 |
+
<link href="lib/tom-select/tom-select.css" rel="stylesheet">
|
| 10 |
+
<script src="lib/tom-select/tom-select.complete.min.js"></script>
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
<center>
|
| 14 |
+
<h1></h1>
|
| 15 |
+
</center>
|
| 16 |
+
|
| 17 |
+
<!-- <link rel="stylesheet" href="../node_modules/vis/dist/vis.min.css" type="text/css" />
|
| 18 |
+
<script type="text/javascript" src="../node_modules/vis/dist/vis.js"> </script>-->
|
| 19 |
+
<link
|
| 20 |
+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css"
|
| 21 |
+
rel="stylesheet"
|
| 22 |
+
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6"
|
| 23 |
+
crossorigin="anonymous"
|
| 24 |
+
/>
|
| 25 |
+
<script
|
| 26 |
+
src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
|
| 27 |
+
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf"
|
| 28 |
+
crossorigin="anonymous"
|
| 29 |
+
></script>
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
<center>
|
| 33 |
+
<h1></h1>
|
| 34 |
+
</center>
|
| 35 |
+
<style type="text/css">
|
| 36 |
+
|
| 37 |
+
#mynetwork {
|
| 38 |
+
width: 100%;
|
| 39 |
+
height: 600px;
|
| 40 |
+
background-color: #0f172a;
|
| 41 |
+
border: 1px solid lightgray;
|
| 42 |
+
position: relative;
|
| 43 |
+
float: left;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
<body>
|
| 56 |
+
<div class="card" style="width: 100%">
|
| 57 |
+
|
| 58 |
+
<div id="select-menu" class="card-header">
|
| 59 |
+
<div class="row no-gutters">
|
| 60 |
+
<div class="col-10 pb-2">
|
| 61 |
+
<select
|
| 62 |
+
class="form-select"
|
| 63 |
+
aria-label="Default select example"
|
| 64 |
+
onchange="selectNode([value]);"
|
| 65 |
+
id="select-node"
|
| 66 |
+
placeholder="Select node..."
|
| 67 |
+
>
|
| 68 |
+
<option selected>Select a Node by ID</option>
|
| 69 |
+
|
| 70 |
+
<option value="Desconocido...">Desconocido...</option>
|
| 71 |
+
|
| 72 |
+
<option value="Fuente Desconocida">Fuente Desconocida</option>
|
| 73 |
+
|
| 74 |
+
</select>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="col-2 pb-2">
|
| 77 |
+
<button type="button" class="btn btn-primary btn-block" onclick="neighbourhoodHighlight({nodes: []});">Reset Selection</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
<div id="mynetwork" class="card-body"></div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
<script type="text/javascript">
|
| 90 |
+
|
| 91 |
+
// initialize global variables.
|
| 92 |
+
var edges;
|
| 93 |
+
var nodes;
|
| 94 |
+
var allNodes;
|
| 95 |
+
var allEdges;
|
| 96 |
+
var nodeColors;
|
| 97 |
+
var originalNodes;
|
| 98 |
+
var network;
|
| 99 |
+
var container;
|
| 100 |
+
var options, data;
|
| 101 |
+
var filter = {
|
| 102 |
+
item : '',
|
| 103 |
+
property : '',
|
| 104 |
+
value : []
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
new TomSelect("#select-node",{
|
| 109 |
+
create: false,
|
| 110 |
+
sortField: {
|
| 111 |
+
field: "text",
|
| 112 |
+
direction: "asc"
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// This method is responsible for drawing the graph, returns the drawn network
|
| 120 |
+
function drawGraph() {
|
| 121 |
+
var container = document.getElementById('mynetwork');
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
// parsing and collecting nodes and edges from the python
|
| 126 |
+
nodes = new vis.DataSet([{"color": "#8b5cf6", "font": {"color": "white"}, "id": "Desconocido...", "label": "Desconocido...", "shape": "dot", "size": 20, "title": ""}, {"color": "#10b981", "font": {"color": "white"}, "id": "Fuente Desconocida", "label": "Fuente Desconocida", "shape": "square", "size": 25}]);
|
| 127 |
+
edges = new vis.DataSet([{"from": "Desconocido...", "to": "Fuente Desconocida", "width": 1}]);
|
| 128 |
+
|
| 129 |
+
nodeColors = {};
|
| 130 |
+
allNodes = nodes.get({ returnType: "Object" });
|
| 131 |
+
for (nodeId in allNodes) {
|
| 132 |
+
nodeColors[nodeId] = allNodes[nodeId].color;
|
| 133 |
+
}
|
| 134 |
+
allEdges = edges.get({ returnType: "Object" });
|
| 135 |
+
// adding nodes and edges to the graph
|
| 136 |
+
data = {nodes: nodes, edges: edges};
|
| 137 |
+
|
| 138 |
+
var options = {
|
| 139 |
+
"configure": {
|
| 140 |
+
"enabled": false
|
| 141 |
+
},
|
| 142 |
+
"edges": {
|
| 143 |
+
"color": {
|
| 144 |
+
"inherit": true
|
| 145 |
+
},
|
| 146 |
+
"smooth": {
|
| 147 |
+
"enabled": true,
|
| 148 |
+
"type": "dynamic"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"interaction": {
|
| 152 |
+
"dragNodes": true,
|
| 153 |
+
"hideEdgesOnDrag": false,
|
| 154 |
+
"hideNodesOnDrag": false
|
| 155 |
+
},
|
| 156 |
+
"physics": {
|
| 157 |
+
"enabled": true,
|
| 158 |
+
"forceAtlas2Based": {
|
| 159 |
+
"avoidOverlap": 0,
|
| 160 |
+
"centralGravity": 0.01,
|
| 161 |
+
"damping": 0.4,
|
| 162 |
+
"gravitationalConstant": -50,
|
| 163 |
+
"springConstant": 0.08,
|
| 164 |
+
"springLength": 100
|
| 165 |
+
},
|
| 166 |
+
"solver": "forceAtlas2Based",
|
| 167 |
+
"stabilization": {
|
| 168 |
+
"enabled": true,
|
| 169 |
+
"fit": true,
|
| 170 |
+
"iterations": 1000,
|
| 171 |
+
"onlyDynamicEdges": false,
|
| 172 |
+
"updateInterval": 50
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
network = new vis.Network(container, data, options);
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
network.on("selectNode", neighbourhoodHighlight);
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
return network;
|
| 196 |
+
|
| 197 |
+
}
|
| 198 |
+
drawGraph();
|
| 199 |
+
</script>
|
| 200 |
+
</body>
|
| 201 |
+
</html>
|
assets/styles.css
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* LetXipu Beta SX - Custom Gradio Theme */
|
| 2 |
+
/* Dark/Light mode support, Glassmorphism, Animations */
|
| 3 |
+
|
| 4 |
+
/* ─── Google Fonts ─── */
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
| 6 |
+
|
| 7 |
+
/* ─── Dark Theme (Default) ─── */
|
| 8 |
+
:root, [data-theme="dark"] {
|
| 9 |
+
--bg: #0a0a0c;
|
| 10 |
+
--surface: #111827;
|
| 11 |
+
--surface-2: #1a1a2e;
|
| 12 |
+
--border: #374151;
|
| 13 |
+
--text: #ffffff;
|
| 14 |
+
--text-muted: #9ca3af;
|
| 15 |
+
--accent: #8b5cf6;
|
| 16 |
+
--accent-hover: #a78bfa;
|
| 17 |
+
--primary: #3b82f6;
|
| 18 |
+
--primary-hover: #60a5fa;
|
| 19 |
+
--success: #10b981;
|
| 20 |
+
--danger: #ef4444;
|
| 21 |
+
--warning: #f59e0b;
|
| 22 |
+
--input-bg: #1f2937;
|
| 23 |
+
--glass: rgba(10, 10, 12, 0.7);
|
| 24 |
+
--glass-border: rgba(255, 255, 255, 0.1);
|
| 25 |
+
--glass-results: rgba(17, 24, 39, 0.6);
|
| 26 |
+
--glass-results-border: rgba(255, 255, 255, 0.08);
|
| 27 |
+
--shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
| 28 |
+
--shadow-lg: 0 12px 64px rgba(0, 0, 0, 0.7);
|
| 29 |
+
--section-header-bg: linear-gradient(135deg, rgba(139, 92, 246, 0.1), rgba(99, 102, 241, 0.05));
|
| 30 |
+
--section-header-border: rgba(139, 92, 246, 0.2);
|
| 31 |
+
--section-header-color: #a78bfa;
|
| 32 |
+
--banner-bg: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
| 33 |
+
--banner-text: white;
|
| 34 |
+
--tab-bg: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
| 35 |
+
--tab-text: #b0b8c8;
|
| 36 |
+
--tab-hover-bg: rgba(255, 255, 255, 0.08);
|
| 37 |
+
--accordion-bg: rgba(17, 24, 39, 0.5);
|
| 38 |
+
--accordion-border: rgba(255, 255, 255, 0.08);
|
| 39 |
+
--prose-text: rgba(255, 255, 255, 0.85);
|
| 40 |
+
--prose-h2: #d1d5db;
|
| 41 |
+
--prose-h3: #e5e7eb;
|
| 42 |
+
--prose-em: #a78bfa;
|
| 43 |
+
--prose-details-bg: rgba(17, 24, 39, 0.3);
|
| 44 |
+
--prose-details-border: rgba(255, 255, 255, 0.05);
|
| 45 |
+
--radius: 12px;
|
| 46 |
+
--radius-lg: 20px;
|
| 47 |
+
--transition: 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ─── Light Theme ─── */
|
| 51 |
+
[data-theme="light"] {
|
| 52 |
+
--bg: #f8fafc;
|
| 53 |
+
--surface: #ffffff;
|
| 54 |
+
--surface-2: #f1f5f9;
|
| 55 |
+
--border: #e2e8f0;
|
| 56 |
+
--text: #0f172a;
|
| 57 |
+
--text-muted: #64748b;
|
| 58 |
+
--accent: #7c3aed;
|
| 59 |
+
--accent-hover: #6d28d9;
|
| 60 |
+
--primary: #2563eb;
|
| 61 |
+
--primary-hover: #1d4ed8;
|
| 62 |
+
--success: #059669;
|
| 63 |
+
--danger: #dc2626;
|
| 64 |
+
--warning: #d97706;
|
| 65 |
+
--input-bg: #f1f5f9;
|
| 66 |
+
--glass: rgba(255, 255, 255, 0.85);
|
| 67 |
+
--glass-border: rgba(0, 0, 0, 0.1);
|
| 68 |
+
--glass-results: rgba(255, 255, 255, 0.75);
|
| 69 |
+
--glass-results-border: rgba(0, 0, 0, 0.08);
|
| 70 |
+
--shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
| 71 |
+
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.12);
|
| 72 |
+
--section-header-bg: linear-gradient(135deg, rgba(124, 58, 237, 0.06), rgba(99, 102, 241, 0.03));
|
| 73 |
+
--section-header-border: rgba(124, 58, 237, 0.15);
|
| 74 |
+
--section-header-color: #7c3aed;
|
| 75 |
+
--banner-bg: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #ddd6fe 100%);
|
| 76 |
+
--banner-text: #1e1b4b;
|
| 77 |
+
--tab-bg: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #ddd6fe 100%);
|
| 78 |
+
--tab-text: #475569;
|
| 79 |
+
--tab-hover-bg: rgba(0, 0, 0, 0.05);
|
| 80 |
+
--accordion-bg: rgba(241, 245, 249, 0.8);
|
| 81 |
+
--accordion-border: rgba(0, 0, 0, 0.08);
|
| 82 |
+
--prose-text: #1e293b;
|
| 83 |
+
--prose-h2: #334155;
|
| 84 |
+
--prose-h3: #1e293b;
|
| 85 |
+
--prose-em: #7c3aed;
|
| 86 |
+
--prose-details-bg: rgba(241, 245, 249, 0.6);
|
| 87 |
+
--prose-details-border: rgba(0, 0, 0, 0.06);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* ─── Global ─── */
|
| 91 |
+
.gradio-container {
|
| 92 |
+
max-width: 1400px !important;
|
| 93 |
+
margin: auto !important;
|
| 94 |
+
background: var(--bg) !important;
|
| 95 |
+
color: var(--text) !important;
|
| 96 |
+
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif !important;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ─── Header Banner ─── */
|
| 100 |
+
.header-banner {
|
| 101 |
+
background: var(--banner-bg);
|
| 102 |
+
color: var(--banner-text);
|
| 103 |
+
padding: 1.5rem 2rem;
|
| 104 |
+
border-radius: 16px;
|
| 105 |
+
margin-bottom: 1rem;
|
| 106 |
+
position: relative;
|
| 107 |
+
overflow: hidden;
|
| 108 |
+
box-shadow: 0 8px 32px rgba(48, 43, 99, 0.3);
|
| 109 |
+
}
|
| 110 |
+
.header-banner::before {
|
| 111 |
+
content: '';
|
| 112 |
+
position: absolute;
|
| 113 |
+
top: -50%;
|
| 114 |
+
right: -20%;
|
| 115 |
+
width: 400px;
|
| 116 |
+
height: 400px;
|
| 117 |
+
background: radial-gradient(circle, rgba(139, 92, 246, 0.25) 0%, transparent 70%);
|
| 118 |
+
border-radius: 50%;
|
| 119 |
+
animation: float 6s ease-in-out infinite;
|
| 120 |
+
}
|
| 121 |
+
.header-banner h1 {
|
| 122 |
+
margin: 0 0 0.3rem 0 !important;
|
| 123 |
+
font-size: 1.8rem !important;
|
| 124 |
+
font-weight: 700 !important;
|
| 125 |
+
position: relative;
|
| 126 |
+
z-index: 1;
|
| 127 |
+
color: var(--banner-text) !important;
|
| 128 |
+
}
|
| 129 |
+
.header-banner p {
|
| 130 |
+
margin: 0 !important;
|
| 131 |
+
font-size: 0.9rem !important;
|
| 132 |
+
opacity: 0.85;
|
| 133 |
+
position: relative;
|
| 134 |
+
z-index: 1;
|
| 135 |
+
color: var(--banner-text) !important;
|
| 136 |
+
}
|
| 137 |
+
.header-badge {
|
| 138 |
+
display: inline-block;
|
| 139 |
+
background: rgba(255, 255, 255, 0.15);
|
| 140 |
+
backdrop-filter: blur(4px);
|
| 141 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 142 |
+
border-radius: 20px;
|
| 143 |
+
padding: 0.2rem 0.7rem;
|
| 144 |
+
font-size: 0.75rem !important;
|
| 145 |
+
margin-top: 0.6rem;
|
| 146 |
+
position: relative;
|
| 147 |
+
z-index: 1;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ─── Status Banner ─── */
|
| 151 |
+
.status-banner {
|
| 152 |
+
display: flex;
|
| 153 |
+
align-items: center;
|
| 154 |
+
gap: 0.6rem;
|
| 155 |
+
padding: 0.7rem 1.2rem;
|
| 156 |
+
border-radius: 10px;
|
| 157 |
+
margin-bottom: 1rem;
|
| 158 |
+
font-size: 0.85rem;
|
| 159 |
+
font-weight: 500;
|
| 160 |
+
backdrop-filter: blur(10px);
|
| 161 |
+
}
|
| 162 |
+
.status-connected {
|
| 163 |
+
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(16, 185, 129, 0.05));
|
| 164 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 165 |
+
color: #10b981;
|
| 166 |
+
}
|
| 167 |
+
.status-dot {
|
| 168 |
+
width: 10px;
|
| 169 |
+
height: 10px;
|
| 170 |
+
border-radius: 50%;
|
| 171 |
+
flex-shrink: 0;
|
| 172 |
+
}
|
| 173 |
+
.status-dot.connected {
|
| 174 |
+
background: #10b981;
|
| 175 |
+
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
| 176 |
+
animation: pulse 2s infinite;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/* ─── Tabs ─── */
|
| 180 |
+
.tab-nav {
|
| 181 |
+
background: var(--tab-bg) !important;
|
| 182 |
+
border-radius: 12px 12px 0 0 !important;
|
| 183 |
+
padding: 6px !important;
|
| 184 |
+
gap: 4px !important;
|
| 185 |
+
}
|
| 186 |
+
.tab-nav button {
|
| 187 |
+
color: var(--tab-text) !important;
|
| 188 |
+
font-weight: 500 !important;
|
| 189 |
+
font-size: 0.85rem !important;
|
| 190 |
+
border: none !important;
|
| 191 |
+
border-radius: 8px !important;
|
| 192 |
+
padding: 0.5rem 1rem !important;
|
| 193 |
+
transition: all 0.2s ease !important;
|
| 194 |
+
background: transparent !important;
|
| 195 |
+
}
|
| 196 |
+
.tab-nav button:hover {
|
| 197 |
+
color: var(--text) !important;
|
| 198 |
+
background: var(--tab-hover-bg) !important;
|
| 199 |
+
}
|
| 200 |
+
.tab-nav button.selected {
|
| 201 |
+
color: white !important;
|
| 202 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
| 203 |
+
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.4) !important;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* ─── Progress Bar ─── */
|
| 207 |
+
.progress-container {
|
| 208 |
+
background: var(--surface);
|
| 209 |
+
border: 1px solid var(--border);
|
| 210 |
+
border-radius: 12px;
|
| 211 |
+
padding: 1rem 1.5rem;
|
| 212 |
+
margin-bottom: 1rem;
|
| 213 |
+
}
|
| 214 |
+
.progress-bar {
|
| 215 |
+
height: 8px;
|
| 216 |
+
background: var(--border);
|
| 217 |
+
border-radius: 4px;
|
| 218 |
+
overflow: hidden;
|
| 219 |
+
margin: 0.5rem 0;
|
| 220 |
+
}
|
| 221 |
+
.progress-fill {
|
| 222 |
+
height: 100%;
|
| 223 |
+
background: linear-gradient(90deg, #667eea, #764ba2);
|
| 224 |
+
border-radius: 4px;
|
| 225 |
+
transition: width 0.5s ease;
|
| 226 |
+
box-shadow: 0 0 10px rgba(102, 126, 234, 0.3);
|
| 227 |
+
}
|
| 228 |
+
.progress-text {
|
| 229 |
+
font-size: 0.85rem;
|
| 230 |
+
color: var(--text-muted);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/* ─── Glassmorphic Cards ─── */
|
| 234 |
+
.glass-card {
|
| 235 |
+
background: var(--glass);
|
| 236 |
+
backdrop-filter: blur(24px);
|
| 237 |
+
border: 1px solid var(--glass-border);
|
| 238 |
+
border-radius: var(--radius-lg);
|
| 239 |
+
padding: 1.25rem;
|
| 240 |
+
box-shadow: var(--shadow);
|
| 241 |
+
transition: all var(--transition);
|
| 242 |
+
}
|
| 243 |
+
.glass-card:hover {
|
| 244 |
+
border-color: var(--accent);
|
| 245 |
+
box-shadow: var(--shadow-lg), 0 0 20px rgba(139, 92, 246, 0.15);
|
| 246 |
+
}
|
| 247 |
+
.glass-card-focused {
|
| 248 |
+
border-color: var(--accent) !important;
|
| 249 |
+
box-shadow: var(--shadow-lg), 0 0 25px rgba(139, 92, 246, 0.25) !important;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/* ─── Section Header ─── */
|
| 253 |
+
.section-header {
|
| 254 |
+
display: flex;
|
| 255 |
+
align-items: center;
|
| 256 |
+
gap: 0.5rem;
|
| 257 |
+
padding: 0.6rem 1rem;
|
| 258 |
+
background: var(--section-header-bg);
|
| 259 |
+
border: 1px solid var(--section-header-border);
|
| 260 |
+
border-radius: 10px;
|
| 261 |
+
margin-bottom: 0.6rem;
|
| 262 |
+
font-weight: 600;
|
| 263 |
+
font-size: 0.85rem;
|
| 264 |
+
color: var(--section-header-color);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/* ─── Source Status Dots ─── */
|
| 268 |
+
.source-dot {
|
| 269 |
+
display: inline-block;
|
| 270 |
+
width: 8px;
|
| 271 |
+
height: 8px;
|
| 272 |
+
border-radius: 50%;
|
| 273 |
+
margin-right: 4px;
|
| 274 |
+
}
|
| 275 |
+
.source-dot.online { background: #10b981; box-shadow: 0 0 6px rgba(16, 185, 129, 0.4); }
|
| 276 |
+
.source-dot.offline { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
| 277 |
+
.source-dot.checking { background: #f59e0b; animation: pulse 1s infinite; }
|
| 278 |
+
|
| 279 |
+
.source-badge {
|
| 280 |
+
display: inline-flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
gap: 6px;
|
| 283 |
+
padding: 4px 10px;
|
| 284 |
+
border-radius: 20px;
|
| 285 |
+
font-size: 0.75rem;
|
| 286 |
+
font-weight: 500;
|
| 287 |
+
}
|
| 288 |
+
.source-online {
|
| 289 |
+
background: rgba(16, 185, 129, 0.1);
|
| 290 |
+
border: 1px solid rgba(16, 185, 129, 0.3);
|
| 291 |
+
color: #10b981;
|
| 292 |
+
}
|
| 293 |
+
.source-offline {
|
| 294 |
+
background: rgba(239, 68, 68, 0.1);
|
| 295 |
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
| 296 |
+
color: #ef4444;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/* ─── Toggle Switch ─── */
|
| 300 |
+
.toggle-switch {
|
| 301 |
+
position: relative;
|
| 302 |
+
width: 44px;
|
| 303 |
+
height: 24px;
|
| 304 |
+
background: var(--border);
|
| 305 |
+
border-radius: 12px;
|
| 306 |
+
cursor: pointer;
|
| 307 |
+
transition: background 0.3s ease;
|
| 308 |
+
}
|
| 309 |
+
.toggle-switch.active {
|
| 310 |
+
background: var(--accent);
|
| 311 |
+
}
|
| 312 |
+
.toggle-switch::after {
|
| 313 |
+
content: '';
|
| 314 |
+
position: absolute;
|
| 315 |
+
top: 2px;
|
| 316 |
+
left: 2px;
|
| 317 |
+
width: 20px;
|
| 318 |
+
height: 20px;
|
| 319 |
+
background: white;
|
| 320 |
+
border-radius: 50%;
|
| 321 |
+
transition: transform 0.3s ease;
|
| 322 |
+
}
|
| 323 |
+
.toggle-switch.active::after {
|
| 324 |
+
transform: translateX(20px);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/* ─── Result Tabs ─── */
|
| 328 |
+
.result-tabs {
|
| 329 |
+
display: flex;
|
| 330 |
+
gap: 6px;
|
| 331 |
+
padding: 6px;
|
| 332 |
+
background: var(--surface);
|
| 333 |
+
border: 1px solid var(--border);
|
| 334 |
+
border-radius: 14px;
|
| 335 |
+
margin-bottom: 1rem;
|
| 336 |
+
}
|
| 337 |
+
.result-tab {
|
| 338 |
+
padding: 10px 16px;
|
| 339 |
+
border-radius: 10px;
|
| 340 |
+
border: none;
|
| 341 |
+
background: transparent;
|
| 342 |
+
color: var(--text-muted);
|
| 343 |
+
font-weight: 600;
|
| 344 |
+
font-size: 0.85rem;
|
| 345 |
+
cursor: pointer;
|
| 346 |
+
transition: all 0.2s ease;
|
| 347 |
+
}
|
| 348 |
+
.result-tab:hover {
|
| 349 |
+
color: var(--text);
|
| 350 |
+
background: var(--tab-hover-bg);
|
| 351 |
+
}
|
| 352 |
+
.result-tab.active {
|
| 353 |
+
color: white;
|
| 354 |
+
background: var(--accent);
|
| 355 |
+
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.35);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* ─── Prompt Editor ─── */
|
| 359 |
+
.prompt-editor,
|
| 360 |
+
.prompt-editor textarea {
|
| 361 |
+
background: var(--input-bg) !important;
|
| 362 |
+
border: 1px solid var(--border) !important;
|
| 363 |
+
border-radius: 10px !important;
|
| 364 |
+
padding: 1rem;
|
| 365 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
| 366 |
+
font-size: 0.8rem !important;
|
| 367 |
+
line-height: 1.5 !important;
|
| 368 |
+
resize: vertical;
|
| 369 |
+
min-height: 120px;
|
| 370 |
+
color: var(--text) !important;
|
| 371 |
+
transition: border-color 0.2s ease;
|
| 372 |
+
}
|
| 373 |
+
.prompt-editor:focus,
|
| 374 |
+
.prompt-editor textarea:focus {
|
| 375 |
+
border-color: var(--accent) !important;
|
| 376 |
+
outline: none;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/* ─── Glass Input Wrapper ─── */
|
| 380 |
+
.glass-input-wrapper {
|
| 381 |
+
background: var(--glass);
|
| 382 |
+
backdrop-filter: blur(24px);
|
| 383 |
+
border: 2px solid var(--glass-border);
|
| 384 |
+
border-radius: 20px;
|
| 385 |
+
padding: 14px 18px;
|
| 386 |
+
box-shadow: var(--shadow);
|
| 387 |
+
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
|
| 388 |
+
margin-bottom: 1rem;
|
| 389 |
+
}
|
| 390 |
+
.glass-input-wrapper:focus-within {
|
| 391 |
+
border-color: rgba(139, 92, 246, 0.6);
|
| 392 |
+
box-shadow: var(--shadow-lg), 0 0 25px rgba(139, 92, 246, 0.15);
|
| 393 |
+
}
|
| 394 |
+
.glass-input-wrapper textarea,
|
| 395 |
+
.glass-input-wrapper input[type="text"] {
|
| 396 |
+
background: transparent !important;
|
| 397 |
+
border: none !important;
|
| 398 |
+
padding: 8px 4px !important;
|
| 399 |
+
font-size: 14px !important;
|
| 400 |
+
font-family: 'Inter', system-ui, sans-serif !important;
|
| 401 |
+
color: var(--text) !important;
|
| 402 |
+
}
|
| 403 |
+
.glass-input-wrapper textarea:focus,
|
| 404 |
+
.glass-input-wrapper input[type="text"]:focus {
|
| 405 |
+
box-shadow: none !important;
|
| 406 |
+
outline: none !important;
|
| 407 |
+
}
|
| 408 |
+
.glass-input-wrapper label {
|
| 409 |
+
color: var(--accent) !important;
|
| 410 |
+
font-weight: 600 !important;
|
| 411 |
+
font-size: 13px !important;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* ─── Glassmorphic Results Wrapper ─── */
|
| 415 |
+
.glass-results-wrapper {
|
| 416 |
+
background: var(--glass-results);
|
| 417 |
+
backdrop-filter: blur(16px);
|
| 418 |
+
border: 1px solid var(--glass-results-border);
|
| 419 |
+
border-radius: 16px;
|
| 420 |
+
padding: 1.25rem;
|
| 421 |
+
box-shadow: var(--shadow);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
/* ─── Premium Execute Button ─── */
|
| 425 |
+
.ejecutar-btn {
|
| 426 |
+
background: linear-gradient(135deg, #6366f1, #8b5cf6) !important;
|
| 427 |
+
color: white !important;
|
| 428 |
+
font-weight: 700 !important;
|
| 429 |
+
font-size: 15px !important;
|
| 430 |
+
border-radius: 14px !important;
|
| 431 |
+
padding: 14px 28px !important;
|
| 432 |
+
border: none !important;
|
| 433 |
+
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4), inset 0 1px 0 rgba(255,255,255,0.15) !important;
|
| 434 |
+
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1) !important;
|
| 435 |
+
position: relative !important;
|
| 436 |
+
overflow: hidden !important;
|
| 437 |
+
}
|
| 438 |
+
.ejecutar-btn:hover {
|
| 439 |
+
transform: translateY(-2px) !important;
|
| 440 |
+
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.5), inset 0 1px 0 rgba(255,255,255,0.2) !important;
|
| 441 |
+
}
|
| 442 |
+
.ejecutar-btn:active {
|
| 443 |
+
transform: translateY(0) !important;
|
| 444 |
+
}
|
| 445 |
+
.ejecutar-btn:disabled {
|
| 446 |
+
opacity: 0.5 !important;
|
| 447 |
+
transform: none !important;
|
| 448 |
+
box-shadow: none !important;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
/* ─── Gradio Accordion Glass ─── */
|
| 452 |
+
.gradio-accordion {
|
| 453 |
+
border: 1px solid var(--glass-border) !important;
|
| 454 |
+
border-radius: 12px !important;
|
| 455 |
+
overflow: hidden !important;
|
| 456 |
+
background: var(--glass) !important;
|
| 457 |
+
backdrop-filter: blur(12px) !important;
|
| 458 |
+
}
|
| 459 |
+
.gradio-accordion .label-wrap {
|
| 460 |
+
padding: 10px 16px !important;
|
| 461 |
+
font-weight: 600 !important;
|
| 462 |
+
color: var(--text) !important;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/* ─── Config Accordion ─── */
|
| 466 |
+
.config-accordion {
|
| 467 |
+
background: var(--accordion-bg) !important;
|
| 468 |
+
border: 1px solid var(--accordion-border) !important;
|
| 469 |
+
border-radius: 12px !important;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
/* ─── Gradio Blocks Glass Panels ─── */
|
| 473 |
+
.gradio-group {
|
| 474 |
+
border: 1px solid var(--glass-border) !important;
|
| 475 |
+
border-radius: 14px !important;
|
| 476 |
+
background: var(--glass) !important;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
/* ─── Scrollbar ─── */
|
| 480 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 481 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 482 |
+
::-webkit-scrollbar-thumb {
|
| 483 |
+
background: var(--accent);
|
| 484 |
+
opacity: 0.3;
|
| 485 |
+
border-radius: 10px;
|
| 486 |
+
}
|
| 487 |
+
::-webkit-scrollbar-thumb:hover { opacity: 0.5; }
|
| 488 |
+
|
| 489 |
+
/* ─── Pipeline Control Buttons ─── */
|
| 490 |
+
.control-btn-pause button {
|
| 491 |
+
background: rgba(245, 158, 11, 0.1) !important;
|
| 492 |
+
border: 1px solid rgba(245, 158, 11, 0.4) !important;
|
| 493 |
+
color: #f59e0b !important;
|
| 494 |
+
font-weight: 600 !important;
|
| 495 |
+
border-radius: 10px !important;
|
| 496 |
+
transition: all 0.2s ease !important;
|
| 497 |
+
}
|
| 498 |
+
.control-btn-pause button:hover {
|
| 499 |
+
background: rgba(245, 158, 11, 0.2) !important;
|
| 500 |
+
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2) !important;
|
| 501 |
+
}
|
| 502 |
+
.control-btn-resume button {
|
| 503 |
+
background: rgba(16, 185, 129, 0.1) !important;
|
| 504 |
+
border: 1px solid rgba(16, 185, 129, 0.4) !important;
|
| 505 |
+
color: #10b981 !important;
|
| 506 |
+
font-weight: 600 !important;
|
| 507 |
+
border-radius: 10px !important;
|
| 508 |
+
transition: all 0.2s ease !important;
|
| 509 |
+
}
|
| 510 |
+
.control-btn-resume button:hover {
|
| 511 |
+
background: rgba(16, 185, 129, 0.2) !important;
|
| 512 |
+
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2) !important;
|
| 513 |
+
}
|
| 514 |
+
.control-btn-stop button {
|
| 515 |
+
background: rgba(239, 68, 68, 0.1) !important;
|
| 516 |
+
border: 1px solid rgba(239, 68, 68, 0.4) !important;
|
| 517 |
+
color: #ef4444 !important;
|
| 518 |
+
font-weight: 600 !important;
|
| 519 |
+
border-radius: 10px !important;
|
| 520 |
+
transition: all 0.2s ease !important;
|
| 521 |
+
}
|
| 522 |
+
.control-btn-stop button:hover {
|
| 523 |
+
background: rgba(239, 68, 68, 0.2) !important;
|
| 524 |
+
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2) !important;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
/* ─── Paper Card ─── */
|
| 528 |
+
.paper-card {
|
| 529 |
+
position: relative;
|
| 530 |
+
background: var(--glass);
|
| 531 |
+
backdrop-filter: blur(14px);
|
| 532 |
+
border: 1px solid var(--glass-border);
|
| 533 |
+
border-radius: 14px;
|
| 534 |
+
padding: 16px 20px 16px 24px;
|
| 535 |
+
margin-bottom: 10px;
|
| 536 |
+
transition: all 0.25s cubic-bezier(0.23, 1, 0.32, 1);
|
| 537 |
+
overflow: hidden;
|
| 538 |
+
}
|
| 539 |
+
.paper-card:hover {
|
| 540 |
+
border-color: rgba(139, 92, 246, 0.35);
|
| 541 |
+
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.12), 0 2px 8px rgba(0,0,0,0.2);
|
| 542 |
+
transform: translateY(-2px);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
/* Paper action buttons */
|
| 546 |
+
.paper-actions {
|
| 547 |
+
display: flex;
|
| 548 |
+
gap: 6px;
|
| 549 |
+
flex-wrap: wrap;
|
| 550 |
+
align-items: center;
|
| 551 |
+
opacity: 0;
|
| 552 |
+
max-height: 0;
|
| 553 |
+
overflow: hidden;
|
| 554 |
+
transition: all 0.25s ease;
|
| 555 |
+
}
|
| 556 |
+
.paper-card:hover .paper-actions {
|
| 557 |
+
opacity: 1;
|
| 558 |
+
max-height: 50px;
|
| 559 |
+
margin-top: 6px;
|
| 560 |
+
}
|
| 561 |
+
.paper-action-btn {
|
| 562 |
+
display: inline-flex;
|
| 563 |
+
align-items: center;
|
| 564 |
+
gap: 5px;
|
| 565 |
+
padding: 4px 10px;
|
| 566 |
+
border-radius: 8px;
|
| 567 |
+
font-size: 11px;
|
| 568 |
+
font-weight: 600;
|
| 569 |
+
text-decoration: none;
|
| 570 |
+
border: 1px solid;
|
| 571 |
+
cursor: pointer;
|
| 572 |
+
transition: all 0.2s ease;
|
| 573 |
+
white-space: nowrap;
|
| 574 |
+
}
|
| 575 |
+
.paper-action-btn:hover {
|
| 576 |
+
transform: translateY(-1px);
|
| 577 |
+
box-shadow: 0 3px 10px rgba(0,0,0,0.15);
|
| 578 |
+
filter: brightness(1.15);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
/* ─── Section Card ─── */
|
| 582 |
+
.section-card {
|
| 583 |
+
background: var(--glass);
|
| 584 |
+
backdrop-filter: blur(14px);
|
| 585 |
+
border: 1px solid var(--glass-border);
|
| 586 |
+
border-radius: 14px;
|
| 587 |
+
margin-bottom: 10px;
|
| 588 |
+
overflow: hidden;
|
| 589 |
+
transition: all 0.25s cubic-bezier(0.23, 1, 0.32, 1);
|
| 590 |
+
}
|
| 591 |
+
.section-card:hover {
|
| 592 |
+
border-color: rgba(139, 92, 246, 0.3);
|
| 593 |
+
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.08);
|
| 594 |
+
}
|
| 595 |
+
.section-card-header {
|
| 596 |
+
display: flex;
|
| 597 |
+
align-items: center;
|
| 598 |
+
justify-content: space-between;
|
| 599 |
+
padding: 12px 16px;
|
| 600 |
+
cursor: pointer;
|
| 601 |
+
transition: background 0.2s ease;
|
| 602 |
+
}
|
| 603 |
+
.section-card-header:hover {
|
| 604 |
+
background: rgba(139, 92, 246, 0.04);
|
| 605 |
+
}
|
| 606 |
+
.section-card-body {
|
| 607 |
+
padding: 0 16px 16px;
|
| 608 |
+
border-top: 1px solid var(--glass-border);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/* ─── Stat Card Hover ─── */
|
| 612 |
+
.stat-card {
|
| 613 |
+
background: var(--glass);
|
| 614 |
+
border: 1px solid var(--glass-border);
|
| 615 |
+
border-radius: 12px;
|
| 616 |
+
padding: 16px;
|
| 617 |
+
text-align: center;
|
| 618 |
+
min-width: 100px;
|
| 619 |
+
transition: all 0.25s cubic-bezier(0.23, 1, 0.32, 1);
|
| 620 |
+
}
|
| 621 |
+
.stat-card:hover {
|
| 622 |
+
transform: translateY(-3px);
|
| 623 |
+
border-color: rgba(139, 92, 246, 0.3);
|
| 624 |
+
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.1);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
/* ─── Theme Toggle Button ─── */
|
| 628 |
+
.theme-toggle {
|
| 629 |
+
position: fixed;
|
| 630 |
+
top: 12px;
|
| 631 |
+
right: 12px;
|
| 632 |
+
z-index: 9999;
|
| 633 |
+
width: 40px;
|
| 634 |
+
height: 40px;
|
| 635 |
+
border-radius: 50%;
|
| 636 |
+
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
| 637 |
+
border: 2px solid rgba(255,255,255,0.2);
|
| 638 |
+
color: white;
|
| 639 |
+
font-size: 18px;
|
| 640 |
+
cursor: pointer;
|
| 641 |
+
display: flex;
|
| 642 |
+
align-items: center;
|
| 643 |
+
justify-content: center;
|
| 644 |
+
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
| 645 |
+
transition: all 0.3s ease;
|
| 646 |
+
}
|
| 647 |
+
.theme-toggle:hover {
|
| 648 |
+
transform: scale(1.1);
|
| 649 |
+
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.6);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
/* ─── Animations ─── */
|
| 653 |
+
@keyframes pulse {
|
| 654 |
+
0%, 100% { opacity: 1; }
|
| 655 |
+
50% { opacity: 0.5; }
|
| 656 |
+
}
|
| 657 |
+
@keyframes float {
|
| 658 |
+
0%, 100% { transform: translateY(0); }
|
| 659 |
+
50% { transform: translateY(-12px); }
|
| 660 |
+
}
|
| 661 |
+
@keyframes glowPulse {
|
| 662 |
+
0%, 100% { box-shadow: 0 0 15px rgba(139, 92, 246, 0.15); }
|
| 663 |
+
50% { box-shadow: 0 0 30px rgba(139, 92, 246, 0.35); }
|
| 664 |
+
}
|
| 665 |
+
@keyframes slideIn {
|
| 666 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 667 |
+
to { opacity: 1; transform: translateY(0); }
|
| 668 |
+
}
|
| 669 |
+
@keyframes fadeIn {
|
| 670 |
+
from { opacity: 0; }
|
| 671 |
+
to { opacity: 1; }
|
| 672 |
+
}
|
| 673 |
+
@keyframes shimmer {
|
| 674 |
+
0% { background-position: -200% center; }
|
| 675 |
+
100% { background-position: 200% center; }
|
| 676 |
+
}
|
| 677 |
+
@keyframes toastSlideIn {
|
| 678 |
+
from { transform: translateY(-10px); opacity: 0; }
|
| 679 |
+
to { transform: translateY(0); opacity: 1; }
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
/* ─── Footer ─── */
|
| 683 |
+
.app-footer {
|
| 684 |
+
background: var(--banner-bg);
|
| 685 |
+
color: var(--text-muted);
|
| 686 |
+
padding: 1rem 2rem;
|
| 687 |
+
border-radius: 12px;
|
| 688 |
+
margin-top: 1.5rem;
|
| 689 |
+
font-size: 0.8rem;
|
| 690 |
+
display: flex;
|
| 691 |
+
justify-content: space-between;
|
| 692 |
+
align-items: center;
|
| 693 |
+
flex-wrap: wrap;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
/* ─── Hide default Gradio footer ─── */
|
| 697 |
+
footer { display: none !important; }
|
| 698 |
+
|
| 699 |
+
/* ─── Report Typography (.prose) ─── */
|
| 700 |
+
.prose {
|
| 701 |
+
font-family: 'Inter', system-ui, sans-serif !important;
|
| 702 |
+
font-size: 15px !important;
|
| 703 |
+
line-height: 1.65 !important;
|
| 704 |
+
color: var(--prose-text) !important;
|
| 705 |
+
max-width: 850px !important;
|
| 706 |
+
margin: 0 auto !important;
|
| 707 |
+
padding-bottom: 40px !important;
|
| 708 |
+
}
|
| 709 |
+
.prose h1 {
|
| 710 |
+
font-size: 1.4rem !important;
|
| 711 |
+
font-weight: 700 !important;
|
| 712 |
+
margin-top: 1.8rem !important;
|
| 713 |
+
margin-bottom: 0.8rem !important;
|
| 714 |
+
color: var(--accent) !important;
|
| 715 |
+
}
|
| 716 |
+
.prose h2 {
|
| 717 |
+
font-size: 1.25rem !important;
|
| 718 |
+
font-weight: 600 !important;
|
| 719 |
+
margin-top: 1.5rem !important;
|
| 720 |
+
margin-bottom: 0.6rem !important;
|
| 721 |
+
color: var(--prose-h2) !important;
|
| 722 |
+
}
|
| 723 |
+
.prose h3 {
|
| 724 |
+
font-size: 1.1rem !important;
|
| 725 |
+
font-weight: 600 !important;
|
| 726 |
+
margin-top: 1.2rem !important;
|
| 727 |
+
margin-bottom: 0.5rem !important;
|
| 728 |
+
color: var(--prose-h3) !important;
|
| 729 |
+
}
|
| 730 |
+
.prose p {
|
| 731 |
+
margin-bottom: 1rem !important;
|
| 732 |
+
}
|
| 733 |
+
.prose li {
|
| 734 |
+
margin-bottom: 0.4rem !important;
|
| 735 |
+
}
|
| 736 |
+
.prose em {
|
| 737 |
+
color: var(--prose-em) !important;
|
| 738 |
+
font-style: italic !important;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/* Style for details/summary collapsible headers */
|
| 742 |
+
.prose details > summary {
|
| 743 |
+
list-style: none;
|
| 744 |
+
position: relative;
|
| 745 |
+
padding-left: 20px;
|
| 746 |
+
cursor: pointer;
|
| 747 |
+
transition: color 0.2s;
|
| 748 |
+
}
|
| 749 |
+
.prose details > summary::-webkit-details-marker {
|
| 750 |
+
display: none;
|
| 751 |
+
}
|
| 752 |
+
.prose details > summary::before {
|
| 753 |
+
content: "▶";
|
| 754 |
+
position: absolute;
|
| 755 |
+
left: 0;
|
| 756 |
+
top: 50%;
|
| 757 |
+
transform: translateY(-50%);
|
| 758 |
+
font-size: 0.7em;
|
| 759 |
+
color: var(--accent);
|
| 760 |
+
transition: transform 0.2s;
|
| 761 |
+
}
|
| 762 |
+
.prose details[open] > summary::before {
|
| 763 |
+
transform: translateY(-50%) rotate(90deg);
|
| 764 |
+
}
|
| 765 |
+
.prose details[open] > summary {
|
| 766 |
+
margin-bottom: 0.5rem;
|
| 767 |
+
}
|
| 768 |
+
.prose details {
|
| 769 |
+
margin-bottom: 0.5rem;
|
| 770 |
+
background: var(--prose-details-bg);
|
| 771 |
+
border: 1px solid var(--prose-details-border);
|
| 772 |
+
border-radius: 12px;
|
| 773 |
+
padding: 8px 16px;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
/* ─── Responsive: Tablet ─── */
|
| 777 |
+
@media (max-width: 1024px) {
|
| 778 |
+
.gradio-container {
|
| 779 |
+
max-width: 100% !important;
|
| 780 |
+
padding: 0 8px !important;
|
| 781 |
+
}
|
| 782 |
+
.gradio-row {
|
| 783 |
+
flex-direction: column !important;
|
| 784 |
+
}
|
| 785 |
+
.gradio-column {
|
| 786 |
+
max-width: 100% !important;
|
| 787 |
+
flex: 1 1 100% !important;
|
| 788 |
+
}
|
| 789 |
+
.header-banner {
|
| 790 |
+
padding: 1.2rem 1.5rem;
|
| 791 |
+
}
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
/* ─── Responsive: Mobile ─── */
|
| 795 |
+
@media (max-width: 768px) {
|
| 796 |
+
.gradio-container {
|
| 797 |
+
padding: 0 4px !important;
|
| 798 |
+
}
|
| 799 |
+
.header-banner {
|
| 800 |
+
padding: 1rem;
|
| 801 |
+
border-radius: 12px;
|
| 802 |
+
}
|
| 803 |
+
.header-banner h1 {
|
| 804 |
+
font-size: 1.3rem !important;
|
| 805 |
+
}
|
| 806 |
+
.header-banner::before {
|
| 807 |
+
width: 200px;
|
| 808 |
+
height: 200px;
|
| 809 |
+
}
|
| 810 |
+
.app-footer {
|
| 811 |
+
flex-direction: column;
|
| 812 |
+
text-align: center;
|
| 813 |
+
gap: 0.5rem;
|
| 814 |
+
padding: 1rem;
|
| 815 |
+
}
|
| 816 |
+
.status-banner {
|
| 817 |
+
flex-wrap: wrap;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.paper-card {
|
| 821 |
+
padding: 12px 14px 12px 18px;
|
| 822 |
+
border-radius: 12px;
|
| 823 |
+
}
|
| 824 |
+
.paper-card:hover {
|
| 825 |
+
transform: none;
|
| 826 |
+
}
|
| 827 |
+
.paper-actions {
|
| 828 |
+
opacity: 1;
|
| 829 |
+
max-height: 50px;
|
| 830 |
+
margin-top: 6px;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.ejecutar-btn {
|
| 834 |
+
padding: 12px 20px !important;
|
| 835 |
+
font-size: 14px !important;
|
| 836 |
+
width: 100% !important;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.control-btn-pause button,
|
| 840 |
+
.control-btn-resume button,
|
| 841 |
+
.control-btn-stop button {
|
| 842 |
+
padding: 8px 12px !important;
|
| 843 |
+
font-size: 12px !important;
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
/* Stack tabs horizontally scrollable */
|
| 847 |
+
.gradio-tabs > .tab-nav {
|
| 848 |
+
overflow-x: auto;
|
| 849 |
+
flex-wrap: nowrap;
|
| 850 |
+
}
|
| 851 |
+
.gradio-tabs > .tab-nav button {
|
| 852 |
+
white-space: nowrap;
|
| 853 |
+
flex-shrink: 0;
|
| 854 |
+
font-size: 0.78rem !important;
|
| 855 |
+
padding: 0.4rem 0.75rem !important;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
.glass-input-wrapper {
|
| 859 |
+
padding: 10px 14px;
|
| 860 |
+
border-radius: 14px;
|
| 861 |
+
}
|
| 862 |
+
.glass-results-wrapper {
|
| 863 |
+
padding: 0.75rem;
|
| 864 |
+
border-radius: 12px;
|
| 865 |
+
}
|
| 866 |
+
.section-header {
|
| 867 |
+
font-size: 0.8rem;
|
| 868 |
+
padding: 0.5rem 0.75rem;
|
| 869 |
+
}
|
| 870 |
+
.prose {
|
| 871 |
+
font-size: 14px !important;
|
| 872 |
+
padding-bottom: 20px !important;
|
| 873 |
+
}
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
/* ─── Responsive: Small Mobile ─── */
|
| 877 |
+
@media (max-width: 480px) {
|
| 878 |
+
.header-banner h1 {
|
| 879 |
+
font-size: 1.1rem !important;
|
| 880 |
+
}
|
| 881 |
+
.paper-card {
|
| 882 |
+
padding: 10px 12px 10px 16px;
|
| 883 |
+
margin-bottom: 8px;
|
| 884 |
+
}
|
| 885 |
+
.ejecutar-btn {
|
| 886 |
+
padding: 10px 16px !important;
|
| 887 |
+
font-size: 13px !important;
|
| 888 |
+
}
|
| 889 |
+
.control-btn-pause, .control-btn-resume, .control-btn-stop {
|
| 890 |
+
flex: 1 !important;
|
| 891 |
+
}
|
| 892 |
+
.theme-toggle {
|
| 893 |
+
width: 36px;
|
| 894 |
+
height: 36px;
|
| 895 |
+
font-size: 16px;
|
| 896 |
+
top: 8px;
|
| 897 |
+
right: 8px;
|
| 898 |
+
}
|
| 899 |
+
}
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""LetXipu Backend - AI-powered research synthesis pipeline."""
|
backend/database/models.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, Boolean, ForeignKey
|
| 4 |
+
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
| 5 |
+
|
| 6 |
+
# Configuración de SQLite
|
| 7 |
+
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "letxipu.db")
|
| 8 |
+
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
|
| 9 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 10 |
+
|
| 11 |
+
Base = declarative_base()
|
| 12 |
+
|
| 13 |
+
class User(Base):
|
| 14 |
+
__tablename__ = "users"
|
| 15 |
+
|
| 16 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 17 |
+
username = Column(String, unique=True, index=True, nullable=False)
|
| 18 |
+
hashed_password = Column(String, nullable=False)
|
| 19 |
+
role = Column(String, default="user")
|
| 20 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 21 |
+
|
| 22 |
+
projects = relationship("Project", back_populates="owner", cascade="all, delete-orphan")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Project(Base):
|
| 26 |
+
__tablename__ = "projects"
|
| 27 |
+
|
| 28 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 29 |
+
title = Column(String, nullable=False)
|
| 30 |
+
description = Column(Text, nullable=True)
|
| 31 |
+
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
| 32 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 33 |
+
|
| 34 |
+
owner = relationship("User", back_populates="projects")
|
| 35 |
+
jobs = relationship("ResearchJob", back_populates="project", cascade="all, delete-orphan")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ResearchJob(Base):
|
| 39 |
+
__tablename__ = "research_jobs"
|
| 40 |
+
|
| 41 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 42 |
+
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
| 43 |
+
query = Column(Text, nullable=False)
|
| 44 |
+
status = Column(String, default="pending") # pending, running, completed, error
|
| 45 |
+
progress_pct = Column(Integer, default=0)
|
| 46 |
+
report_md = Column(Text, nullable=True)
|
| 47 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 48 |
+
completed_at = Column(DateTime, nullable=True)
|
| 49 |
+
|
| 50 |
+
project = relationship("Project", back_populates="jobs")
|
| 51 |
+
|
| 52 |
+
# Crear las tablas en la base de datos si no existen
|
| 53 |
+
def init_db():
|
| 54 |
+
Base.metadata.create_all(bind=engine)
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
init_db()
|
| 58 |
+
print(f"Base de datos inicializada en: {DB_PATH}")
|
backend/guardrails.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
XSS_PATTERNS = [r"<script", r"javascript:", r"on\w+\s*=", r"<iframe", r"<object", r"<embed"]
|
| 5 |
+
|
| 6 |
+
PROFANITY_WORDS = [
|
| 7 |
+
"fuck", "shit", "damn", "hell", "ass", "bitch", "crap", "piss",
|
| 8 |
+
"puta", "mierda", "pendejo", "cabrón", "estúpido", "idiota",
|
| 9 |
+
"marica", "gonorrea", "hijueputa", "malparido", "chucha",
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Guardrails:
|
| 14 |
+
def __init__(self, max_length: int = 500):
|
| 15 |
+
self.max_length = max_length
|
| 16 |
+
|
| 17 |
+
def validate(self, query: str) -> tuple[bool, str]:
|
| 18 |
+
if not query or not query.strip():
|
| 19 |
+
return False, "La consulta está vacía"
|
| 20 |
+
|
| 21 |
+
if len(query) > self.max_length:
|
| 22 |
+
return False, f"La consulta excede {self.max_length} caracteres (tiene {len(query)})"
|
| 23 |
+
|
| 24 |
+
query_lower = query.lower()
|
| 25 |
+
for pattern in XSS_PATTERNS:
|
| 26 |
+
if re.search(pattern, query_lower):
|
| 27 |
+
return False, "Se detectó contenido potencialmente inseguro (XSS)"
|
| 28 |
+
|
| 29 |
+
for word in PROFANITY_WORDS:
|
| 30 |
+
if word in query_lower:
|
| 31 |
+
return False, "Se detectó lenguaje inapropiado"
|
| 32 |
+
|
| 33 |
+
return True, ""
|
backend/metadata_recovery.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Metadata Recovery Engine - Fiel al app original Next.js
|
| 3 |
+
Implementa la cascada de 7 pasos de recuperación de metadatos
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
import httpx
|
| 8 |
+
import asyncio
|
| 9 |
+
from typing import List, Dict, Any, Optional
|
| 10 |
+
|
| 11 |
+
RESCUE_SEARCH_RESULT_LIMIT = 10
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def compute_completeness_score(paper: dict) -> int:
|
| 15 |
+
"""Score paper completeness from 0-100."""
|
| 16 |
+
score = 0
|
| 17 |
+
abstract = paper.get("abstract", "") or ""
|
| 18 |
+
if len(abstract) > 50 and "..." not in abstract[-10:]:
|
| 19 |
+
score += 30
|
| 20 |
+
elif len(abstract) > 20:
|
| 21 |
+
score += 10
|
| 22 |
+
if paper.get("doi"):
|
| 23 |
+
score += 15
|
| 24 |
+
university = paper.get("university", "") or ""
|
| 25 |
+
generic_unis = ["Universidad Peruana", "Universidad", "Universidad Nacional"]
|
| 26 |
+
if university and not any(g in university for g in generic_unis):
|
| 27 |
+
score += 15
|
| 28 |
+
if paper.get("year") and paper["year"] != 0:
|
| 29 |
+
score += 10
|
| 30 |
+
authors = paper.get("authors", [])
|
| 31 |
+
if authors and "Autor Desconocido" not in str(authors):
|
| 32 |
+
score += 15
|
| 33 |
+
pdf_url = paper.get("pdfUrl", "") or ""
|
| 34 |
+
if pdf_url and not pdf_url.startswith("http://hdl.handle.net"):
|
| 35 |
+
score += 15
|
| 36 |
+
return min(score, 100)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def is_abstract_truncated(text: str) -> bool:
|
| 40 |
+
"""Detect truncated abstracts."""
|
| 41 |
+
if not text:
|
| 42 |
+
return True
|
| 43 |
+
return bool(re.search(r'\.{3}\s*(Descripci[oó]n\s+completa\s*)?$', text)) or text.endswith('\u2026')
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def is_generic_university(name: str) -> bool:
|
| 47 |
+
"""Detect generic university names."""
|
| 48 |
+
if not name:
|
| 49 |
+
return True
|
| 50 |
+
generics = ["Universidad Peruana", "Universidad", "Universidad Nacional", "Instituto"]
|
| 51 |
+
return any(g in name for g in generics)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class MetadataRecoveryEngine:
|
| 55 |
+
"""7-step metadata recovery cascade."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, api_key: str = ""):
|
| 58 |
+
self.api_key = api_key
|
| 59 |
+
self.stats = {
|
| 60 |
+
"totalAttempted": 0, "recoveredAbstract": 0, "recoveredYear": 0,
|
| 61 |
+
"recoveredDoi": 0, "recoveredPdf": 0, "recoveredUniversity": 0,
|
| 62 |
+
"recoveredAuthors": 0, "fullyFailed": 0, "skipped": 0,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async def recover_batch(self, papers: List[dict], min_score: int = 60, batch_size: int = 10) -> dict:
|
| 66 |
+
"""Recover metadata for a batch of papers."""
|
| 67 |
+
candidates = []
|
| 68 |
+
for p in papers:
|
| 69 |
+
score = compute_completeness_score(p)
|
| 70 |
+
if score < min_score:
|
| 71 |
+
p["completenessScore"] = score
|
| 72 |
+
candidates.append(p)
|
| 73 |
+
else:
|
| 74 |
+
self.stats["skipped"] += 1
|
| 75 |
+
|
| 76 |
+
for i in range(0, len(candidates), batch_size):
|
| 77 |
+
batch = candidates[i:i+batch_size]
|
| 78 |
+
await asyncio.gather(*[self._recover_one(p) for p in batch])
|
| 79 |
+
|
| 80 |
+
return {"papers": papers, "stats": self.stats}
|
| 81 |
+
|
| 82 |
+
async def _recover_one(self, paper: dict):
|
| 83 |
+
"""7-step recovery cascade for a single paper."""
|
| 84 |
+
self.stats["totalAttempted"] += 1
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
if not paper.get("abstract") or len(paper.get("abstract", "")) < 50:
|
| 88 |
+
await self._step_openalex(paper)
|
| 89 |
+
|
| 90 |
+
if not paper.get("abstract") or len(paper.get("abstract", "")) < 50:
|
| 91 |
+
await self._step_pubmed(paper)
|
| 92 |
+
|
| 93 |
+
if paper.get("doi") and (not paper.get("abstract") or len(paper.get("abstract", "")) < 50):
|
| 94 |
+
await self._step_crossref(paper)
|
| 95 |
+
|
| 96 |
+
if paper.get("doi") and (not paper.get("abstract") or len(paper.get("abstract", "")) < 50):
|
| 97 |
+
await self._step_semantic_scholar(paper)
|
| 98 |
+
|
| 99 |
+
paper["completenessScore"] = compute_completeness_score(paper)
|
| 100 |
+
|
| 101 |
+
if paper.get("abstract") and len(paper.get("abstract", "")) > 50:
|
| 102 |
+
self.stats["recoveredAbstract"] += 1
|
| 103 |
+
if paper.get("year"):
|
| 104 |
+
self.stats["recoveredYear"] += 1
|
| 105 |
+
if paper.get("doi"):
|
| 106 |
+
self.stats["recoveredDoi"] += 1
|
| 107 |
+
if paper.get("pdfUrl"):
|
| 108 |
+
self.stats["recoveredPdf"] += 1
|
| 109 |
+
if paper.get("university") and not is_generic_university(paper.get("university", "")):
|
| 110 |
+
self.stats["recoveredUniversity"] += 1
|
| 111 |
+
if paper.get("authors") and "Autor Desconocido" not in str(paper.get("authors", [])):
|
| 112 |
+
self.stats["recoveredAuthors"] += 1
|
| 113 |
+
except Exception:
|
| 114 |
+
self.stats["fullyFailed"] += 1
|
| 115 |
+
|
| 116 |
+
async def _step_openalex(self, paper: dict):
|
| 117 |
+
"""Step 4: Search OpenAlex by title or DOI."""
|
| 118 |
+
try:
|
| 119 |
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
| 120 |
+
if paper.get("doi"):
|
| 121 |
+
url = f"https://api.openalex.org/works/https://doi.org/{paper['doi']}"
|
| 122 |
+
r = await client.get(url)
|
| 123 |
+
elif paper.get("title"):
|
| 124 |
+
url = f"https://api.openalex.org/works?filter=title.search:{paper['title'][:100]}&per-page=1"
|
| 125 |
+
r = await client.get(url)
|
| 126 |
+
else:
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
if r.status_code == 200:
|
| 130 |
+
data = r.json()
|
| 131 |
+
work = data if "title" in data else (data.get("results", [{}])[0] if data.get("results") else {})
|
| 132 |
+
|
| 133 |
+
if work:
|
| 134 |
+
if not paper.get("abstract") and work.get("abstract_inverted_index"):
|
| 135 |
+
paper["abstract"] = self._decode_inverted_index(work["abstract_inverted_index"])
|
| 136 |
+
if not paper.get("year") and work.get("publication_year"):
|
| 137 |
+
paper["year"] = work["publication_year"]
|
| 138 |
+
if not paper.get("doi") and work.get("ids", {}).get("doi"):
|
| 139 |
+
paper["doi"] = work["ids"]["doi"].replace("https://doi.org/", "")
|
| 140 |
+
if not paper.get("pdfUrl") and work.get("open_access", {}).get("oa_url"):
|
| 141 |
+
paper["pdfUrl"] = work["open_access"]["oa_url"]
|
| 142 |
+
if not paper.get("university") and work.get("authorships"):
|
| 143 |
+
for a in work["authorships"]:
|
| 144 |
+
for inst in a.get("institutions", []):
|
| 145 |
+
if inst.get("display_name"):
|
| 146 |
+
paper["university"] = inst["display_name"]
|
| 147 |
+
break
|
| 148 |
+
if not paper.get("authors") and work.get("authorships"):
|
| 149 |
+
paper["authors"] = [a.get("author", {}).get("display_name", "") for a in work["authorships"] if a.get("author", {}).get("display_name")]
|
| 150 |
+
except:
|
| 151 |
+
pass
|
| 152 |
+
|
| 153 |
+
async def _step_pubmed(self, paper: dict):
|
| 154 |
+
"""Step 5: Search PubMed by PMID."""
|
| 155 |
+
try:
|
| 156 |
+
pmid = self._extract_pmid(paper)
|
| 157 |
+
if not pmid:
|
| 158 |
+
return
|
| 159 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 160 |
+
url = f"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id={pmid}&rettype=abstract&retmode=text"
|
| 161 |
+
r = await client.get(url)
|
| 162 |
+
if r.status_code == 200 and len(r.text) > 50:
|
| 163 |
+
paper["abstract"] = r.text[:50000]
|
| 164 |
+
except:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
async def _step_crossref(self, paper: dict):
|
| 168 |
+
"""Step 6: Search Crossref by DOI for abstract."""
|
| 169 |
+
try:
|
| 170 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 171 |
+
url = f"https://api.crossref.org/works/{paper['doi']}"
|
| 172 |
+
r = await client.get(url, headers={"User-Agent": "LetXipuResearch/1.0"})
|
| 173 |
+
if r.status_code == 200:
|
| 174 |
+
item = r.json().get("message", {})
|
| 175 |
+
if item.get("abstract") and len(item["abstract"]) > len(paper.get("abstract", "")):
|
| 176 |
+
paper["abstract"] = re.sub(r'<[^>]+>', '', item["abstract"])[:50000]
|
| 177 |
+
except:
|
| 178 |
+
pass
|
| 179 |
+
|
| 180 |
+
async def _step_semantic_scholar(self, paper: dict):
|
| 181 |
+
"""Step 7a: Search Semantic Scholar by DOI."""
|
| 182 |
+
try:
|
| 183 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 184 |
+
url = f"https://api.semanticscholar.org/graph/v1/paper/DOI:{paper['doi']}?fields=title,abstract,year,authors,openAccessPdf"
|
| 185 |
+
headers = {}
|
| 186 |
+
if self.api_key:
|
| 187 |
+
headers["x-api-key"] = self.api_key
|
| 188 |
+
r = await client.get(url, headers=headers)
|
| 189 |
+
if r.status_code == 200:
|
| 190 |
+
ss = r.json()
|
| 191 |
+
if ss.get("abstract") and len(ss["abstract"]) > len(paper.get("abstract", "")):
|
| 192 |
+
paper["abstract"] = ss["abstract"][:50000]
|
| 193 |
+
if ss.get("openAccessPdf", {}).get("url") and not paper.get("pdfUrl"):
|
| 194 |
+
paper["pdfUrl"] = ss["openAccessPdf"]["url"]
|
| 195 |
+
except:
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
def _extract_pmid(self, paper: dict) -> Optional[str]:
|
| 199 |
+
"""Extract PubMed ID from paper."""
|
| 200 |
+
url = paper.get("url", "") or paper.get("handleUrl", "") or ""
|
| 201 |
+
match = re.search(r'(\d{6,})', url)
|
| 202 |
+
return match.group(1) if match else None
|
| 203 |
+
|
| 204 |
+
def _decode_inverted_index(self, inverted_index: dict) -> str:
|
| 205 |
+
"""Decode OpenAlex inverted index to text."""
|
| 206 |
+
word_positions = []
|
| 207 |
+
for word, positions in inverted_index.items():
|
| 208 |
+
for pos in positions:
|
| 209 |
+
word_positions.append((pos, word))
|
| 210 |
+
word_positions.sort()
|
| 211 |
+
return " ".join([w for _, w in word_positions])
|
backend/persistence.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
CONFIG_DIR = ".letxipu"
|
| 8 |
+
CONFIG_FILE = "search-config.json"
|
| 9 |
+
PRESETS_FILE = "presets.json"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PersistenceManager:
|
| 13 |
+
def __init__(self, project_path: str = "."):
|
| 14 |
+
self.config_dir = os.path.join(project_path, CONFIG_DIR)
|
| 15 |
+
self.config_file = os.path.join(self.config_dir, CONFIG_FILE)
|
| 16 |
+
self.presets_file = os.path.join(self.config_dir, PRESETS_FILE)
|
| 17 |
+
os.makedirs(self.config_dir, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
def save_config(self, config: Dict[str, Any]):
|
| 20 |
+
with open(self.config_file, "w", encoding="utf-8") as f:
|
| 21 |
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
| 22 |
+
|
| 23 |
+
def load_config(self) -> Dict[str, Any]:
|
| 24 |
+
if os.path.exists(self.config_file):
|
| 25 |
+
with open(self.config_file, "r", encoding="utf-8") as f:
|
| 26 |
+
return json.load(f)
|
| 27 |
+
return {}
|
| 28 |
+
|
| 29 |
+
def save_preset(self, name: str, config: Dict[str, Any]):
|
| 30 |
+
presets = self.load_presets()
|
| 31 |
+
presets[name] = {
|
| 32 |
+
"config": config,
|
| 33 |
+
"timestamp": datetime.datetime.now().isoformat(),
|
| 34 |
+
}
|
| 35 |
+
with open(self.presets_file, "w", encoding="utf-8") as f:
|
| 36 |
+
json.dump(presets, f, indent=2, ensure_ascii=False)
|
| 37 |
+
|
| 38 |
+
def load_presets(self) -> Dict[str, Any]:
|
| 39 |
+
if os.path.exists(self.presets_file):
|
| 40 |
+
with open(self.presets_file, "r", encoding="utf-8") as f:
|
| 41 |
+
return json.load(f)
|
| 42 |
+
return {}
|
| 43 |
+
|
| 44 |
+
def load_preset(self, name: str) -> Optional[Dict[str, Any]]:
|
| 45 |
+
presets = self.load_presets()
|
| 46 |
+
return presets.get(name, {}).get("config")
|
| 47 |
+
|
| 48 |
+
def delete_preset(self, name: str):
|
| 49 |
+
presets = self.load_presets()
|
| 50 |
+
if name in presets:
|
| 51 |
+
del presets[name]
|
| 52 |
+
with open(self.presets_file, "w", encoding="utf-8") as f:
|
| 53 |
+
json.dump(presets, f, indent=2, ensure_ascii=False)
|
| 54 |
+
|
| 55 |
+
def list_presets(self) -> list:
|
| 56 |
+
return list(self.load_presets().keys())
|
backend/pipeline.py
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Complete Research Pipeline - Fiel al app original Next.js
|
| 3 |
+
Implementa: Query Optimizer, Iterations, Planning Context,
|
| 4 |
+
Adaptive Tier 2, Gap Detection, Rescue Search, Deduplicación persistente,
|
| 5 |
+
Infinite Output, Hierarchical Synthesis, GRADE Classification,
|
| 6 |
+
Year/University Filtering, Source Health Check, Retry on LLM calls
|
| 7 |
+
Como async generator para streaming en tiempo real con Gradio
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
import asyncio
|
| 14 |
+
import re
|
| 15 |
+
from typing import List, Dict, Any, Optional, Set, AsyncGenerator
|
| 16 |
+
from backend.synthesis import SynthesisEngine, PROVIDERS, classify_grade, grade_label
|
| 17 |
+
from backend.tools.search_engine import search
|
| 18 |
+
from backend.prompts.profiles import AGENT_PROFILES
|
| 19 |
+
from backend.utils import (
|
| 20 |
+
robust_json_parse,
|
| 21 |
+
with_retry,
|
| 22 |
+
clean_agent_content,
|
| 23 |
+
strip_latex,
|
| 24 |
+
is_plan_weak,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Configure logging
|
| 28 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s')
|
| 29 |
+
logger = logging.getLogger("pipeline")
|
| 30 |
+
|
| 31 |
+
MIN_SEARCH_DOCS_FOR_TIER2 = 5
|
| 32 |
+
RESCUE_SEARCH_RESULT_LIMIT = 10
|
| 33 |
+
PLANNING_CONTEXT_DOCS_LIMIT = 5
|
| 34 |
+
INTER_ITERATION_DELAY = 1.5
|
| 35 |
+
MAX_SYNTHESIS_DOCS = 150
|
| 36 |
+
CONTINUATION_MAX_TOKENS = 4000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def optimize_query(engine: SynthesisEngine, query: str, profile: str = "general") -> dict:
|
| 40 |
+
"""Phase 0: AI generates optimized queries in 3 languages (EN, ES, PT)."""
|
| 41 |
+
prompt = f"""You are an Academic Search Query Optimizer. Generate TWO versions of this query.
|
| 42 |
+
|
| 43 |
+
ORIGINAL QUERY: "{query}"
|
| 44 |
+
|
| 45 |
+
RULES:
|
| 46 |
+
1. "local" (Spanish): Keep in Spanish, use natural keywords, max 15 words
|
| 47 |
+
2. "international" (English): MUST be in ENGLISH with Boolean operators. Max 10 scientific keywords.
|
| 48 |
+
|
| 49 |
+
CRITICAL: The "international" field MUST be in ENGLISH. Use scientific terminology.
|
| 50 |
+
Example: "optimización de producción de ácido indolacético" → "indoleacetic acid production optimization bacteria"
|
| 51 |
+
|
| 52 |
+
RESPOND ONLY IN JSON:
|
| 53 |
+
{{"local": "...", "international": "..."}}"""
|
| 54 |
+
|
| 55 |
+
system = "You are an academic search query optimizer. Generate queries in Spanish and English."
|
| 56 |
+
|
| 57 |
+
response = await with_retry(
|
| 58 |
+
lambda: engine._call_llm(system, prompt, role="search"),
|
| 59 |
+
retries=1,
|
| 60 |
+
delay=1.0,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
result = robust_json_parse(response)
|
| 64 |
+
local_query = result.get("local", query) if result else query
|
| 65 |
+
international_query = result.get("international", query) if result else query
|
| 66 |
+
|
| 67 |
+
spanish_indicators = [
|
| 68 |
+
" de ", " del ", " la ", " el ", " los ", " las ", " en ", " con ",
|
| 69 |
+
" para ", " por ", " una ", " un ", " que ", " como ",
|
| 70 |
+
]
|
| 71 |
+
es_count = sum(1 for ind in spanish_indicators if ind in international_query.lower())
|
| 72 |
+
|
| 73 |
+
if es_count >= 2:
|
| 74 |
+
translate_prompt = f"""Translate this Spanish academic query to English. Return ONLY the English translation, nothing else.
|
| 75 |
+
Spanish: "{international_query}"
|
| 76 |
+
English:"""
|
| 77 |
+
try:
|
| 78 |
+
translated = await with_retry(
|
| 79 |
+
lambda: engine._call_llm(
|
| 80 |
+
"You are a strict academic translator. Return ONLY the translation.",
|
| 81 |
+
translate_prompt,
|
| 82 |
+
temperature=0.0,
|
| 83 |
+
role="translation",
|
| 84 |
+
),
|
| 85 |
+
retries=1,
|
| 86 |
+
delay=1.0,
|
| 87 |
+
)
|
| 88 |
+
translated = translated.strip().strip('"').strip("'").strip(".")
|
| 89 |
+
if translated and len(translated) > 3:
|
| 90 |
+
international_query = translated
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
return {"local": local_query, "international": international_query}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def build_planning_context(all_docs: list, query: str, iteration: int) -> str:
|
| 98 |
+
if iteration == 0:
|
| 99 |
+
return ""
|
| 100 |
+
prev_titles = [d.get("title", "") for d in all_docs[-PLANNING_CONTEXT_DOCS_LIMIT:]]
|
| 101 |
+
return (
|
| 102 |
+
f"PREVIOUS FINDINGS: We have already found {len(all_docs)} documents.\n"
|
| 103 |
+
f'Some recent titles: "{", ".join(prev_titles)}".\n'
|
| 104 |
+
"Your task is finding MISSING or COMPLEMENTARY information.\n"
|
| 105 |
+
"DO NOT repeat the same queries."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def clean_query(q: str) -> str:
|
| 110 |
+
q = re.sub(r'\b(AND|OR|NOT)\b', ' ', q, flags=re.IGNORECASE)
|
| 111 |
+
accents = {
|
| 112 |
+
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ú': 'u', 'ñ': 'n',
|
| 113 |
+
'Á': 'A', 'É': 'E', 'Í': 'I', 'Ó': 'O', 'Ú': 'U', 'Ñ': 'N',
|
| 114 |
+
}
|
| 115 |
+
q = re.sub(
|
| 116 |
+
r'[áéíóúñÁÉÍÓÚÑ]',
|
| 117 |
+
lambda m: accents.get(m.group(), m.group()),
|
| 118 |
+
q,
|
| 119 |
+
)
|
| 120 |
+
return q.strip()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def filter_by_year(docs: list, year_start: Optional[int], year_end: Optional[int]) -> list:
|
| 124 |
+
"""Filter documents by year range. Keeps docs without a year."""
|
| 125 |
+
if not year_start and not year_end:
|
| 126 |
+
return docs
|
| 127 |
+
filtered = []
|
| 128 |
+
for doc in docs:
|
| 129 |
+
year = doc.get("year")
|
| 130 |
+
if year is None:
|
| 131 |
+
filtered.append(doc)
|
| 132 |
+
continue
|
| 133 |
+
try:
|
| 134 |
+
year_int = int(year)
|
| 135 |
+
except (ValueError, TypeError):
|
| 136 |
+
filtered.append(doc)
|
| 137 |
+
continue
|
| 138 |
+
if year_start and year_int < year_start:
|
| 139 |
+
continue
|
| 140 |
+
if year_end and year_int > year_end:
|
| 141 |
+
continue
|
| 142 |
+
filtered.append(doc)
|
| 143 |
+
return filtered
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def filter_by_university(docs: list, university: str) -> list:
|
| 147 |
+
"""Filter documents that match a university/institution keyword."""
|
| 148 |
+
if not university:
|
| 149 |
+
return docs
|
| 150 |
+
kw = university.lower()
|
| 151 |
+
return [
|
| 152 |
+
doc
|
| 153 |
+
for doc in docs
|
| 154 |
+
if kw in (doc.get("affiliation") or "").lower()
|
| 155 |
+
or kw in (doc.get("institution") or "").lower()
|
| 156 |
+
or kw in ", ".join(doc.get("authors", [])).lower()
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def check_source_health(sources: list) -> dict:
|
| 161 |
+
"""Quick health check on search sources before running full search."""
|
| 162 |
+
status = {}
|
| 163 |
+
for source in sources:
|
| 164 |
+
if source == "all":
|
| 165 |
+
status["all"] = True
|
| 166 |
+
continue
|
| 167 |
+
try:
|
| 168 |
+
result = await search("test", sources=[source], max_results=1)
|
| 169 |
+
status[source] = bool(result.get("results"))
|
| 170 |
+
except Exception:
|
| 171 |
+
status[source] = False
|
| 172 |
+
return status
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class ResearchPipeline:
|
| 176 |
+
def __init__(
|
| 177 |
+
self,
|
| 178 |
+
provider: str = "mistral",
|
| 179 |
+
search_model: str = None,
|
| 180 |
+
synthesis_model: str = None,
|
| 181 |
+
translation_model: str = None,
|
| 182 |
+
api_key: str = None,
|
| 183 |
+
):
|
| 184 |
+
self.engine = SynthesisEngine(
|
| 185 |
+
provider=provider,
|
| 186 |
+
model=synthesis_model,
|
| 187 |
+
api_key=api_key,
|
| 188 |
+
search_model=search_model,
|
| 189 |
+
translation_model=translation_model,
|
| 190 |
+
)
|
| 191 |
+
self.seen_titles: Set[str] = set()
|
| 192 |
+
self.seen_dois: Set[str] = set()
|
| 193 |
+
self.all_docs: List[dict] = []
|
| 194 |
+
# ─── Pipeline Control Flags ───
|
| 195 |
+
self._stopped = False
|
| 196 |
+
self._paused = False
|
| 197 |
+
|
| 198 |
+
def stop(self):
|
| 199 |
+
"""Signal the pipeline to stop after the current phase."""
|
| 200 |
+
self._stopped = True
|
| 201 |
+
self._paused = False
|
| 202 |
+
logger.info("Pipeline STOP requested")
|
| 203 |
+
|
| 204 |
+
def pause(self):
|
| 205 |
+
"""Signal the pipeline to pause after the current phase."""
|
| 206 |
+
self._paused = True
|
| 207 |
+
logger.info("Pipeline PAUSE requested")
|
| 208 |
+
|
| 209 |
+
def resume(self):
|
| 210 |
+
"""Resume a paused pipeline."""
|
| 211 |
+
self._paused = False
|
| 212 |
+
logger.info("Pipeline RESUME requested")
|
| 213 |
+
|
| 214 |
+
@property
|
| 215 |
+
def is_stopped(self):
|
| 216 |
+
return self._stopped
|
| 217 |
+
|
| 218 |
+
@property
|
| 219 |
+
def is_paused(self):
|
| 220 |
+
return self._paused
|
| 221 |
+
|
| 222 |
+
async def _checkpoint(self):
|
| 223 |
+
"""Check control flags between phases. Raises StopAsyncIteration if stopped."""
|
| 224 |
+
if self._stopped:
|
| 225 |
+
raise StopAsyncIteration("Pipeline detenido por el usuario")
|
| 226 |
+
while self._paused:
|
| 227 |
+
await asyncio.sleep(0.5)
|
| 228 |
+
if self._stopped:
|
| 229 |
+
raise StopAsyncIteration("Pipeline detenido por el usuario")
|
| 230 |
+
|
| 231 |
+
def _track_doc(self, doc: dict) -> bool:
|
| 232 |
+
title = (doc.get("title") or "").lower().replace(" ", "")
|
| 233 |
+
title_norm = re.sub(r'[^a-z0-9]', '', title)
|
| 234 |
+
doi = (doc.get("doi") or "").lower().strip()
|
| 235 |
+
if doi.startswith("https://doi.org/"):
|
| 236 |
+
doi = doi[16:]
|
| 237 |
+
if title_norm not in self.seen_titles and (not doi or doi not in self.seen_dois):
|
| 238 |
+
self.seen_titles.add(title_norm)
|
| 239 |
+
if doi:
|
| 240 |
+
self.seen_dois.add(doi)
|
| 241 |
+
self.all_docs.append(doc)
|
| 242 |
+
return True
|
| 243 |
+
return False
|
| 244 |
+
|
| 245 |
+
async def run_tier(
|
| 246 |
+
self, eng_query: str, sources: list, max_docs: int = 50,
|
| 247 |
+
year_start: Optional[int] = None, year_end: Optional[int] = None,
|
| 248 |
+
university: str = None,
|
| 249 |
+
) -> List[dict]:
|
| 250 |
+
result = await search(eng_query, sources=sources, max_results=max_docs)
|
| 251 |
+
new_docs = []
|
| 252 |
+
for doc in result.get("results", []):
|
| 253 |
+
if self._track_doc(doc):
|
| 254 |
+
new_docs.append(doc)
|
| 255 |
+
if year_start or year_end:
|
| 256 |
+
new_docs = filter_by_year(new_docs, year_start, year_end)
|
| 257 |
+
if university:
|
| 258 |
+
new_docs = filter_by_university(new_docs, university)
|
| 259 |
+
return new_docs
|
| 260 |
+
|
| 261 |
+
async def run_rescue_search(self, missing_aspects: list, sources: list) -> List[dict]:
|
| 262 |
+
rescue_docs = []
|
| 263 |
+
for aspect in missing_aspects[:3]:
|
| 264 |
+
for q in [f"{aspect} research study", f"{aspect} investigación estudio"]:
|
| 265 |
+
try:
|
| 266 |
+
result = await with_retry(
|
| 267 |
+
lambda q=q: search(q, sources=sources, max_results=RESCUE_SEARCH_RESULT_LIMIT),
|
| 268 |
+
retries=1,
|
| 269 |
+
delay=1.0,
|
| 270 |
+
)
|
| 271 |
+
for doc in result.get("results", []):
|
| 272 |
+
if self._track_doc(doc):
|
| 273 |
+
rescue_docs.append(doc)
|
| 274 |
+
except Exception:
|
| 275 |
+
pass
|
| 276 |
+
return rescue_docs
|
| 277 |
+
|
| 278 |
+
def _build_docs_df(self):
|
| 279 |
+
import pandas as pd
|
| 280 |
+
|
| 281 |
+
rows = []
|
| 282 |
+
for d in self.all_docs:
|
| 283 |
+
autores = d.get("authors", [])
|
| 284 |
+
if isinstance(autores, list):
|
| 285 |
+
autores = ", ".join(autores)
|
| 286 |
+
grade_level = d.get("grade_level", "")
|
| 287 |
+
grade_lbl = d.get("evidenceLevel") or d.get("grade_label", "")
|
| 288 |
+
rows.append({
|
| 289 |
+
"Título": d.get("title") or "N/A",
|
| 290 |
+
"Autores": autores or "N/A",
|
| 291 |
+
"Año": d.get("year", "N/A"),
|
| 292 |
+
"DOI": d.get("doi", ""),
|
| 293 |
+
"Fuente": d.get("source", "N/A"),
|
| 294 |
+
"GRADE": grade_lbl or grade_level or "N/A",
|
| 295 |
+
"PDF URL": d.get("pdfUrl", ""),
|
| 296 |
+
})
|
| 297 |
+
cols = ["Título", "Autores", "Año", "DOI", "Fuente", "GRADE", "PDF URL"]
|
| 298 |
+
return pd.DataFrame(rows) if rows else pd.DataFrame(columns=cols)
|
| 299 |
+
|
| 300 |
+
def _append_section_with_continuation(
|
| 301 |
+
self,
|
| 302 |
+
section_content: str,
|
| 303 |
+
section_name: str,
|
| 304 |
+
full_report_parts: list,
|
| 305 |
+
) -> str:
|
| 306 |
+
"""Strip and clean section content before appending."""
|
| 307 |
+
cleaned = clean_agent_content(section_content)
|
| 308 |
+
cleaned = strip_latex(cleaned)
|
| 309 |
+
full_report_parts.append(f"### {section_name}\n\n{cleaned}\n\n")
|
| 310 |
+
return cleaned
|
| 311 |
+
|
| 312 |
+
async def _write_section_with_continuation(
|
| 313 |
+
self,
|
| 314 |
+
section_name: str,
|
| 315 |
+
section_prompt: str,
|
| 316 |
+
section_context: str,
|
| 317 |
+
geo_context: str = "Automático",
|
| 318 |
+
infinite_output: bool = False,
|
| 319 |
+
max_continuation_passes: int = 2,
|
| 320 |
+
) -> str:
|
| 321 |
+
"""Write a section, optionally continuing for long content."""
|
| 322 |
+
content = await with_retry(
|
| 323 |
+
lambda: self.engine.write_section(section_name, section_prompt, section_context, geo_context),
|
| 324 |
+
retries=1,
|
| 325 |
+
delay=1.0,
|
| 326 |
+
)
|
| 327 |
+
content = clean_agent_content(content)
|
| 328 |
+
|
| 329 |
+
if not infinite_output or max_continuation_passes <= 0:
|
| 330 |
+
return content
|
| 331 |
+
|
| 332 |
+
for _ in range(max_continuation_passes):
|
| 333 |
+
if len(content) < 2000:
|
| 334 |
+
break
|
| 335 |
+
continue_prompt = (
|
| 336 |
+
f"Continue writing the section '{section_name}'. "
|
| 337 |
+
"Add more depth, examples, and citations. "
|
| 338 |
+
"Do NOT repeat what is already written."
|
| 339 |
+
)
|
| 340 |
+
try:
|
| 341 |
+
continuation = await with_retry(
|
| 342 |
+
lambda: self.engine.write_section(
|
| 343 |
+
section_name, continue_prompt, content + "\n\n" + section_context[:2000], geo_context
|
| 344 |
+
),
|
| 345 |
+
retries=1,
|
| 346 |
+
delay=1.0,
|
| 347 |
+
)
|
| 348 |
+
continuation = clean_agent_content(continuation)
|
| 349 |
+
if continuation and len(continuation) > 100:
|
| 350 |
+
content += "\n\n" + continuation
|
| 351 |
+
else:
|
| 352 |
+
break
|
| 353 |
+
except Exception:
|
| 354 |
+
break
|
| 355 |
+
|
| 356 |
+
return content
|
| 357 |
+
|
| 358 |
+
async def _execute_grade_classification(self, mode: str = "keywords") -> None:
|
| 359 |
+
"""Enrich all docs with GRADE evidence levels using the selected algorithm."""
|
| 360 |
+
self.all_docs = await self.engine.classify_documents(self.all_docs, mode=mode)
|
| 361 |
+
self.all_docs = self.engine.sort_by_evidence(self.all_docs)
|
| 362 |
+
|
| 363 |
+
async def _retrieve_full_text(self) -> None:
|
| 364 |
+
"""Download PDFs for top N docs to get real text for synthesis using native PyMuPDF."""
|
| 365 |
+
from backend.tools.pdf_tools import resolve_pdf, download_pdf, read_pdf
|
| 366 |
+
|
| 367 |
+
docs_with_pdf = [d for d in self.all_docs if d.get("pdfUrl") or d.get("doi")][:10]
|
| 368 |
+
|
| 369 |
+
for doc in docs_with_pdf:
|
| 370 |
+
try:
|
| 371 |
+
identifier = doc.get("doi") or doc.get("pdfUrl") or ""
|
| 372 |
+
if not identifier:
|
| 373 |
+
continue
|
| 374 |
+
|
| 375 |
+
result = await resolve_pdf(identifier)
|
| 376 |
+
pdf_url = result.get("pdfUrl", "")
|
| 377 |
+
|
| 378 |
+
if pdf_url:
|
| 379 |
+
dl_res = await download_pdf(pdf_url)
|
| 380 |
+
if dl_res.get("success"):
|
| 381 |
+
read_res = await read_pdf(dl_res["path"])
|
| 382 |
+
if read_res.get("success"):
|
| 383 |
+
doc["fullText"] = read_res["text"][:50000]
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.warning(f"Failed to retrieve full text: {e}")
|
| 386 |
+
|
| 387 |
+
async def run(
|
| 388 |
+
self,
|
| 389 |
+
query: str,
|
| 390 |
+
sources: list = None,
|
| 391 |
+
profile: str = "general",
|
| 392 |
+
iterations: int = 1,
|
| 393 |
+
depth: int = 3,
|
| 394 |
+
include_validation: bool = True,
|
| 395 |
+
docs_text: str = None,
|
| 396 |
+
enable_dme: bool = True,
|
| 397 |
+
synthesis_strategy: str = "auto",
|
| 398 |
+
infinite_output: bool = False,
|
| 399 |
+
max_continuation_passes: int = 2,
|
| 400 |
+
year_start: Optional[int] = None,
|
| 401 |
+
year_end: Optional[int] = None,
|
| 402 |
+
university: str = None,
|
| 403 |
+
skip_gap_detection: bool = False,
|
| 404 |
+
grade_mode: str = "original",
|
| 405 |
+
geo_context: str = "Automático",
|
| 406 |
+
) -> AsyncGenerator[tuple[str, Any], None]:
|
| 407 |
+
"""Async generator that yields (report_text, docs_df) pairs for real-time Gradio streaming."""
|
| 408 |
+
import pandas as pd
|
| 409 |
+
|
| 410 |
+
if not sources:
|
| 411 |
+
sources = ["all"]
|
| 412 |
+
|
| 413 |
+
report = []
|
| 414 |
+
report.append(f"<div style='font-family: monospace; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 8px; border: 1px solid #374151; font-size: 13px; color: #a78bfa;'>\n")
|
| 415 |
+
report.append(f"▶ <b>Research Pipeline</b><br>")
|
| 416 |
+
report.append(f"▶ <b>Consulta:</b> {query.strip()}<br><br>")
|
| 417 |
+
|
| 418 |
+
def get_report_md():
|
| 419 |
+
return "\n".join(report)
|
| 420 |
+
|
| 421 |
+
# ─── PHASE -1: Source Health Check ───
|
| 422 |
+
report.append("▶ <b>Verificación de Fuentes</b><br>")
|
| 423 |
+
yield get_report_md(), pd.DataFrame()
|
| 424 |
+
|
| 425 |
+
health = await check_source_health(sources)
|
| 426 |
+
for src, ok in health.items():
|
| 427 |
+
status_icon = "✅" if ok else "❌"
|
| 428 |
+
report.append(f" {status_icon} {src}")
|
| 429 |
+
report.append("<br>")
|
| 430 |
+
yield get_report_md(), pd.DataFrame()
|
| 431 |
+
|
| 432 |
+
# ─── PHASE 0: Query Optimizer ───
|
| 433 |
+
await self._checkpoint()
|
| 434 |
+
report.append("▶ <b>Fase 0: Optimización de Queries</b><br>")
|
| 435 |
+
yield get_report_md(), pd.DataFrame()
|
| 436 |
+
|
| 437 |
+
optimized = await optimize_query(self.engine, query, profile)
|
| 438 |
+
eng_query = optimized.get("international", query)
|
| 439 |
+
esp_query = optimized.get("local", query)
|
| 440 |
+
|
| 441 |
+
report.append(f" └ Query EN: <code>{eng_query[:80]}</code><br>")
|
| 442 |
+
report.append(f" └ Query ES: <code>{esp_query[:80]}</code><br><br>")
|
| 443 |
+
yield get_report_md(), pd.DataFrame()
|
| 444 |
+
|
| 445 |
+
# ─── SYNTHESIS ONLY MODE ───
|
| 446 |
+
if iterations == 0 and docs_text:
|
| 447 |
+
report.append("▶ <b>Modo Síntesis (sin búsqueda)</b><br>")
|
| 448 |
+
yield get_report_md(), pd.DataFrame()
|
| 449 |
+
|
| 450 |
+
docs_lines = [l.strip() for l in docs_text.strip().split("\n") if l.strip()]
|
| 451 |
+
docs_context = "\n".join([f"[{i+1}] {l}" for i, l in enumerate(docs_lines[:50])])
|
| 452 |
+
|
| 453 |
+
master_plan = await with_retry(
|
| 454 |
+
lambda: self.engine.generate_master_plan(
|
| 455 |
+
query=query, docs_context=docs_context, profile=profile, geo_context=geo_context
|
| 456 |
+
),
|
| 457 |
+
retries=1,
|
| 458 |
+
delay=1.0,
|
| 459 |
+
)
|
| 460 |
+
plan_items = master_plan.get("plan", [])
|
| 461 |
+
|
| 462 |
+
if is_plan_weak(master_plan):
|
| 463 |
+
report.append(" ⚠ Plan débil detectado, reintentando...<br>")
|
| 464 |
+
master_plan = await with_retry(
|
| 465 |
+
lambda: self.engine.generate_master_plan(
|
| 466 |
+
query=query, docs_context=docs_context, profile=profile,
|
| 467 |
+
),
|
| 468 |
+
retries=1,
|
| 469 |
+
delay=2.0,
|
| 470 |
+
)
|
| 471 |
+
plan_items = master_plan.get("plan", [])
|
| 472 |
+
|
| 473 |
+
report.append(f" └ Plan: {len(plan_items)} secciones<br><br>")
|
| 474 |
+
yield get_report_md(), pd.DataFrame()
|
| 475 |
+
|
| 476 |
+
full_report_parts = [f"## Resumen Ejecutivo\n\n{master_plan.get('summary', 'N/A')}\n\n"]
|
| 477 |
+
|
| 478 |
+
for i, item in enumerate(plan_items):
|
| 479 |
+
section_name = item.get("section", f"Sección {i+1}")
|
| 480 |
+
section_prompt = item.get("prompt", "Genera contenido detallado.")
|
| 481 |
+
relevant_indices = item.get("relevant_indices", [])
|
| 482 |
+
section_docs = [docs_lines[idx - 1] for idx in relevant_indices if 1 <= idx <= len(docs_lines)]
|
| 483 |
+
section_context = "\n".join(section_docs) if section_docs else docs_context[:3000]
|
| 484 |
+
|
| 485 |
+
report.append(f"▶ <b>Redactando:</b> {section_name}<br>")
|
| 486 |
+
yield get_report_md(), pd.DataFrame()
|
| 487 |
+
|
| 488 |
+
section_content = await self._write_section_with_continuation(
|
| 489 |
+
section_name,
|
| 490 |
+
section_prompt,
|
| 491 |
+
section_context,
|
| 492 |
+
geo_context=geo_context,
|
| 493 |
+
infinite_output=infinite_output,
|
| 494 |
+
max_continuation_passes=max_continuation_passes,
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
if include_validation:
|
| 498 |
+
try:
|
| 499 |
+
validation = await with_retry(
|
| 500 |
+
lambda: self.engine.validate_citations(
|
| 501 |
+
docs_context[:3000], section_content[:3000],
|
| 502 |
+
),
|
| 503 |
+
retries=1,
|
| 504 |
+
delay=1.0,
|
| 505 |
+
)
|
| 506 |
+
if not validation.get("is_valid", True):
|
| 507 |
+
corrections = validation.get("corrections", [])
|
| 508 |
+
if corrections:
|
| 509 |
+
findings = "\n".join(
|
| 510 |
+
[f"- {c.get('explanation', '')}" for c in corrections]
|
| 511 |
+
)
|
| 512 |
+
section_content = await with_retry(
|
| 513 |
+
lambda: self.engine.refine_section(
|
| 514 |
+
section_content[:3000], findings,
|
| 515 |
+
),
|
| 516 |
+
retries=1,
|
| 517 |
+
delay=1.0,
|
| 518 |
+
)
|
| 519 |
+
except Exception:
|
| 520 |
+
pass
|
| 521 |
+
|
| 522 |
+
self._append_section_with_continuation(
|
| 523 |
+
section_content, section_name, full_report_parts,
|
| 524 |
+
)
|
| 525 |
+
report.append(f" ✔ {section_name}<br>")
|
| 526 |
+
yield get_report_md(), pd.DataFrame()
|
| 527 |
+
|
| 528 |
+
full_report = "".join(full_report_parts)
|
| 529 |
+
yield full_report, pd.DataFrame()
|
| 530 |
+
return
|
| 531 |
+
|
| 532 |
+
# ─── PHASE 1: Iterative Search Loop ───
|
| 533 |
+
await self._checkpoint()
|
| 534 |
+
for i in range(iterations):
|
| 535 |
+
report.append(f"<br>▶ <b>Ronda {i+1}/{iterations} ↻</b><br>")
|
| 536 |
+
yield get_report_md(), pd.DataFrame()
|
| 537 |
+
|
| 538 |
+
planning_context = build_planning_context(self.all_docs, query, i)
|
| 539 |
+
if i > 0 and planning_context:
|
| 540 |
+
report.append(f" └ Refinando queries con contexto de {len(self.all_docs)} docs previos...<br>")
|
| 541 |
+
yield get_report_md(), pd.DataFrame()
|
| 542 |
+
refined = await optimize_query(
|
| 543 |
+
self.engine, f"{query}\n\n{planning_context}", profile,
|
| 544 |
+
)
|
| 545 |
+
eng_query = refined.get("international", eng_query)
|
| 546 |
+
esp_query = refined.get("local", esp_query)
|
| 547 |
+
|
| 548 |
+
report.append(f" └ Query EN: <code>{eng_query[:60]}</code><br>")
|
| 549 |
+
report.append(f" └ Query ES: <code>{esp_query[:60]}</code><br>")
|
| 550 |
+
report.append(" └ Buscando en fuentes académicas...<br>")
|
| 551 |
+
yield get_report_md(), pd.DataFrame()
|
| 552 |
+
|
| 553 |
+
max_docs = min(depth * 25, 100)
|
| 554 |
+
new_docs = await self.run_tier(
|
| 555 |
+
eng_query, sources, max_docs,
|
| 556 |
+
year_start=year_start, year_end=year_end, university=university,
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
tier2_count = 0
|
| 560 |
+
if len(new_docs) < MIN_SEARCH_DOCS_FOR_TIER2:
|
| 561 |
+
report.append(
|
| 562 |
+
f"⚠️ Solo {len(new_docs)} docs encontrados. "
|
| 563 |
+
"Intentando con queries simplificadas (Adaptive Tier 2)...\n"
|
| 564 |
+
)
|
| 565 |
+
yield get_report_md(), pd.DataFrame()
|
| 566 |
+
cleaned_eng = clean_query(eng_query)
|
| 567 |
+
tier2_docs = await self.run_tier(
|
| 568 |
+
cleaned_eng, sources, max_docs,
|
| 569 |
+
year_start=year_start, year_end=year_end, university=university,
|
| 570 |
+
)
|
| 571 |
+
tier2_count = len(tier2_docs)
|
| 572 |
+
new_docs.extend(tier2_docs)
|
| 573 |
+
|
| 574 |
+
report.append(
|
| 575 |
+
f"**Ronda {i+1}:** +{len(new_docs)} nuevos docs"
|
| 576 |
+
+ (f" (Tier 2: +{tier2_count})" if tier2_count else "")
|
| 577 |
+
+ f" → **Total: {len(self.all_docs)}**\n"
|
| 578 |
+
)
|
| 579 |
+
yield get_report_md(), self._build_docs_df()
|
| 580 |
+
|
| 581 |
+
if i < iterations - 1:
|
| 582 |
+
report.append(f" ⏸ Pausa de {INTER_ITERATION_DELAY}s...<br>")
|
| 583 |
+
yield get_report_md(), self._build_docs_df()
|
| 584 |
+
await asyncio.sleep(INTER_ITERATION_DELAY)
|
| 585 |
+
|
| 586 |
+
# ─── YEAR / UNIVERSITY FILTER (post-search) ───
|
| 587 |
+
if year_start or year_end:
|
| 588 |
+
before = len(self.all_docs)
|
| 589 |
+
self.all_docs = filter_by_year(self.all_docs, year_start, year_end)
|
| 590 |
+
removed = before - len(self.all_docs)
|
| 591 |
+
if removed:
|
| 592 |
+
report.append(
|
| 593 |
+
f"📅 Filtro de años ({year_start or '...'}-{year_end or '...'}): "
|
| 594 |
+
f"eliminados {removed} docs fuera de rango\n"
|
| 595 |
+
)
|
| 596 |
+
yield get_report_md(), self._build_docs_df()
|
| 597 |
+
|
| 598 |
+
if university:
|
| 599 |
+
before = len(self.all_docs)
|
| 600 |
+
self.all_docs = filter_by_university(self.all_docs, university)
|
| 601 |
+
removed = before - len(self.all_docs)
|
| 602 |
+
if removed:
|
| 603 |
+
report.append(
|
| 604 |
+
f"🏛️ Filtro universidad ({university}): "
|
| 605 |
+
f"eliminados {removed} docs no relacionados\n"
|
| 606 |
+
)
|
| 607 |
+
yield get_report_md(), self._build_docs_df()
|
| 608 |
+
|
| 609 |
+
# ─── METADATA RECOVERY PHASE ───
|
| 610 |
+
if enable_dme and self.all_docs:
|
| 611 |
+
logger.info(f"DME: Recuperando metadatos para {len(self.all_docs)} docs**")
|
| 612 |
+
report.append("<br>▶ <b>Fase: Recuperación de Metadatos</b><br>")
|
| 613 |
+
yield get_report_md(), self._build_docs_df()
|
| 614 |
+
|
| 615 |
+
from backend.metadata_recovery import MetadataRecoveryEngine, compute_completeness_score
|
| 616 |
+
|
| 617 |
+
recovery_engine = MetadataRecoveryEngine(api_key=self.engine.api_key)
|
| 618 |
+
enrich_result = await recovery_engine.recover_batch(
|
| 619 |
+
self.all_docs, min_score=60, batch_size=10,
|
| 620 |
+
)
|
| 621 |
+
self.all_docs = enrich_result.get("papers", self.all_docs)
|
| 622 |
+
|
| 623 |
+
stats = enrich_result.get("stats", {})
|
| 624 |
+
report.append(
|
| 625 |
+
f" └ Recuperados: {stats.get('recoveredAbstract', 0)} abstracts, "
|
| 626 |
+
f"{stats.get('recoveredYear', 0)} años, {stats.get('recoveredDoi', 0)} DOIs, "
|
| 627 |
+
f"{stats.get('recoveredPdf', 0)} PDFs<br>"
|
| 628 |
+
)
|
| 629 |
+
yield get_report_md(), self._build_docs_df()
|
| 630 |
+
|
| 631 |
+
# ─── SMART FUSION PHASE ───
|
| 632 |
+
if self.all_docs:
|
| 633 |
+
from backend.smart_fusion import smart_fusion_rank
|
| 634 |
+
from backend.metadata_recovery import compute_completeness_score
|
| 635 |
+
|
| 636 |
+
for doc in self.all_docs:
|
| 637 |
+
doc["completenessScore"] = compute_completeness_score(doc)
|
| 638 |
+
self.all_docs = smart_fusion_rank(
|
| 639 |
+
self.all_docs,
|
| 640 |
+
query,
|
| 641 |
+
weights={"topN": MAX_SYNTHESIS_DOCS},
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
# ─── GRADE CLASSIFICATION ───
|
| 645 |
+
await self._checkpoint()
|
| 646 |
+
if include_validation and self.all_docs:
|
| 647 |
+
report.append("<br>▶ <b>Clasificación GRADE de Evidencia</b><br>")
|
| 648 |
+
report.append(" └ Clasificando calidad de evidencia...<br>")
|
| 649 |
+
yield get_report_md(), self._build_docs_df()
|
| 650 |
+
yield get_report_md(), self._build_docs_df()
|
| 651 |
+
|
| 652 |
+
await self._execute_grade_classification(mode=grade_mode)
|
| 653 |
+
evidence = self.engine.evidence_summary(self.all_docs)
|
| 654 |
+
for entry in evidence.get("distribution", []):
|
| 655 |
+
report.append(f" - {entry['label']}: {entry['count']} docs<br>")
|
| 656 |
+
report.append(f" └ Total: {evidence.get('total', 0)} documentos<br>")
|
| 657 |
+
yield get_report_md(), self._build_docs_df()
|
| 658 |
+
|
| 659 |
+
# ─── FULL TEXT RETRIEVAL ───
|
| 660 |
+
await self._checkpoint()
|
| 661 |
+
if self.all_docs:
|
| 662 |
+
report.append("<br>▶ <b>Full Text Retrieval</b><br>")
|
| 663 |
+
report.append(" └ Descargando PDFs para los documentos principales...<br>")
|
| 664 |
+
yield get_report_md(), self._build_docs_df()
|
| 665 |
+
|
| 666 |
+
try:
|
| 667 |
+
await asyncio.wait_for(self._retrieve_full_text(), timeout=30.0)
|
| 668 |
+
except asyncio.TimeoutError:
|
| 669 |
+
logger.warning("Full Text Retrieval timed out after 30s, continuing...")
|
| 670 |
+
report.append(" ⚠ Tiempo límite alcanzado, continuando con datos parciales...<br>")
|
| 671 |
+
fulltext_count = sum(1 for d in self.all_docs if d.get("fullText"))
|
| 672 |
+
report.append(f" └ {fulltext_count} documentos con texto completo obtenido<br>")
|
| 673 |
+
yield get_report_md(), self._build_docs_df()
|
| 674 |
+
|
| 675 |
+
# ─── PHASE 2: Gap Detection ───
|
| 676 |
+
await self._checkpoint()
|
| 677 |
+
missing_aspects = []
|
| 678 |
+
if skip_gap_detection or len(self.all_docs) < 10:
|
| 679 |
+
logger.info("Gap Detection: Skipped (< 10 docs or skip_gap_detection=True)")
|
| 680 |
+
report.append("<br>▶ <b>Fase 2: Detección de Vacíos</b><br>")
|
| 681 |
+
report.append(" └ Omitida (menos de 10 documentos o desactivada)<br>")
|
| 682 |
+
yield get_report_md(), self._build_docs_df()
|
| 683 |
+
else:
|
| 684 |
+
logger.info("Gap Detection: Analizando cobertura...")
|
| 685 |
+
report.append("<br>▶ <b>Fase 2: Detección de Vacíos</b><br>")
|
| 686 |
+
report.append(" └ Analizando cobertura de información...<br>")
|
| 687 |
+
yield get_report_md(), self._build_docs_df()
|
| 688 |
+
|
| 689 |
+
doc_titles = [d.get("title", "") for d in self.all_docs[:20]]
|
| 690 |
+
|
| 691 |
+
try:
|
| 692 |
+
gap_prompt = (
|
| 693 |
+
'Eres un Auditor de Cobertura Científica. Compara la pregunta con los documentos.\n'
|
| 694 |
+
f'PREGUNTA: "{query}"\n'
|
| 695 |
+
f'DOCUMENTOS: {json.dumps(doc_titles[:10])}\n'
|
| 696 |
+
'Identifica si faltan aspectos CRÍTICOS. RESPONDE EN JSON:\n'
|
| 697 |
+
'{"missing": ["aspecto 1"], "requires_rescue": true/false}'
|
| 698 |
+
)
|
| 699 |
+
report.append(" └ La IA está analizando la cobertura...<br>")
|
| 700 |
+
yield get_report_md(), self._build_docs_df()
|
| 701 |
+
|
| 702 |
+
response = await with_retry(
|
| 703 |
+
lambda: self.engine._call_llm(
|
| 704 |
+
"Eres un Auditor de Cobertura.", gap_prompt,
|
| 705 |
+
temperature=0.0, role="search",
|
| 706 |
+
),
|
| 707 |
+
retries=1,
|
| 708 |
+
delay=0.3,
|
| 709 |
+
)
|
| 710 |
+
gap_data = robust_json_parse(response)
|
| 711 |
+
if gap_data and gap_data.get("requires_rescue") and gap_data.get("missing"):
|
| 712 |
+
missing_aspects = gap_data["missing"]
|
| 713 |
+
except Exception as e:
|
| 714 |
+
logger.warning(f"Gap Detection failed: {e}")
|
| 715 |
+
report.append(" ⚠ Análisis de cobertura no disponible (timeout o error)<br>")
|
| 716 |
+
|
| 717 |
+
if missing_aspects:
|
| 718 |
+
report.append(f" └ Vacíos detectados: {', '.join(missing_aspects)}<br>")
|
| 719 |
+
else:
|
| 720 |
+
report.append(" └ Cobertura completa - no se detectaron vacíos críticos<br>")
|
| 721 |
+
yield get_report_md(), self._build_docs_df()
|
| 722 |
+
|
| 723 |
+
# ─── PHASE 3: Rescue Search ───
|
| 724 |
+
await self._checkpoint()
|
| 725 |
+
rescue_docs = []
|
| 726 |
+
if missing_aspects and iterations > 1:
|
| 727 |
+
report.append("<br>▶ <b>Fase 3: Búsqueda de Rescate</b><br>")
|
| 728 |
+
yield get_report_md(), self._build_docs_df()
|
| 729 |
+
|
| 730 |
+
rescue_docs = await self.run_rescue_search(missing_aspects, sources)
|
| 731 |
+
if rescue_docs:
|
| 732 |
+
report.append(
|
| 733 |
+
f" └ {len(rescue_docs)} documentos rescatados<br>"
|
| 734 |
+
)
|
| 735 |
+
yield get_report_md(), self._build_docs_df()
|
| 736 |
+
|
| 737 |
+
# ─── PHASE 4: Master Plan (Linear or Hierarchical) ───
|
| 738 |
+
await self._checkpoint()
|
| 739 |
+
logger.info("Master Plan: Generando plan de síntesis...")
|
| 740 |
+
report.append("<br>▶ <b>Fase 4: Plan Maestro de Síntesis</b><br>")
|
| 741 |
+
report.append(" └ Generando plan de investigación con IA...<br>")
|
| 742 |
+
yield get_report_md(), self._build_docs_df()
|
| 743 |
+
|
| 744 |
+
effective_strategy = synthesis_strategy
|
| 745 |
+
# Normalize Spanish labels to English
|
| 746 |
+
if effective_strategy == "jerárquica":
|
| 747 |
+
effective_strategy = "hierarchical"
|
| 748 |
+
elif effective_strategy == "lineal":
|
| 749 |
+
effective_strategy = "linear"
|
| 750 |
+
|
| 751 |
+
if effective_strategy == "auto":
|
| 752 |
+
effective_strategy = "hierarchical" if len(self.all_docs) > 30 else "linear"
|
| 753 |
+
|
| 754 |
+
docs_context = "\n".join([
|
| 755 |
+
(
|
| 756 |
+
f"[{i+1}] {d.get('title', 'N/A')} ({d.get('year', '?')}) "
|
| 757 |
+
f"- {d.get('source', 'N/A')} | ID: {d.get('id', i + 1)} "
|
| 758 |
+
f"| DOI: {d.get('doi', 'N/A')} "
|
| 759 |
+
f"| GRADE: {d.get('evidenceLevel') or d.get('grade_label') or 'PENDIENTE'}"
|
| 760 |
+
)
|
| 761 |
+
for i, d in enumerate(self.all_docs[:50])
|
| 762 |
+
])
|
| 763 |
+
|
| 764 |
+
if effective_strategy == "hierarchical" and len(self.all_docs) > 10:
|
| 765 |
+
report.append(f" └ Estrategia: Hierarchical (Map-Reduce) - {len(self.all_docs)} docs<br>")
|
| 766 |
+
report.append(" └ Ejecutando destilación Map-Reduce...<br>")
|
| 767 |
+
yield get_report_md(), self._build_docs_df()
|
| 768 |
+
|
| 769 |
+
master_plan = await with_retry(
|
| 770 |
+
lambda: self.engine.hierarchical_synthesis(
|
| 771 |
+
query=query,
|
| 772 |
+
documents=self.all_docs,
|
| 773 |
+
profile=profile,
|
| 774 |
+
chunk_size=10,
|
| 775 |
+
geo_context=geo_context,
|
| 776 |
+
),
|
| 777 |
+
retries=1,
|
| 778 |
+
delay=2.0,
|
| 779 |
+
)
|
| 780 |
+
else:
|
| 781 |
+
report.append(f" └ Estrategia: Linear - {len(self.all_docs)} docs<br>")
|
| 782 |
+
yield get_report_md(), self._build_docs_df()
|
| 783 |
+
|
| 784 |
+
master_plan = await with_retry(
|
| 785 |
+
lambda: self.engine.generate_master_plan(
|
| 786 |
+
query=query, docs_context=docs_context, profile=profile,
|
| 787 |
+
),
|
| 788 |
+
retries=1,
|
| 789 |
+
delay=2.0,
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
if is_plan_weak(master_plan):
|
| 793 |
+
report.append(" ⚠ Plan débil detectado, reintentando...<br>")
|
| 794 |
+
yield get_report_md(), self._build_docs_df()
|
| 795 |
+
master_plan = await with_retry(
|
| 796 |
+
lambda: self.engine.generate_master_plan(
|
| 797 |
+
query=query,
|
| 798 |
+
docs_context=docs_context[:6000],
|
| 799 |
+
profile=profile,
|
| 800 |
+
),
|
| 801 |
+
retries=1,
|
| 802 |
+
delay=2.0,
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
plan_items = master_plan.get("plan", [])
|
| 806 |
+
|
| 807 |
+
if missing_aspects and rescue_docs:
|
| 808 |
+
plan_items.append({
|
| 809 |
+
"section": f"Análisis Complementario: {missing_aspects[0]}",
|
| 810 |
+
"summary": f"Información rescatada sobre: {', '.join(missing_aspects)}",
|
| 811 |
+
"prompt": "Sintetiza la información complementaria encontrada en la búsqueda de rescate.",
|
| 812 |
+
"relevant_indices": list(
|
| 813 |
+
range(
|
| 814 |
+
len(self.all_docs) - len(rescue_docs) + 1,
|
| 815 |
+
len(self.all_docs) + 1,
|
| 816 |
+
)
|
| 817 |
+
),
|
| 818 |
+
})
|
| 819 |
+
|
| 820 |
+
report.append(f" └ Plan: {len(plan_items)} secciones<br>")
|
| 821 |
+
for item in plan_items:
|
| 822 |
+
report.append(f" - {item.get('section', '?')}: {item.get('summary', '')[:80]}<br>")
|
| 823 |
+
report.append("<br>")
|
| 824 |
+
yield get_report_md(), self._build_docs_df()
|
| 825 |
+
|
| 826 |
+
# ─── PHASE 5: Write Sections ───
|
| 827 |
+
await self._checkpoint()
|
| 828 |
+
report.append("▶ <b>Fase 5: Redacción de Secciones</b><br>")
|
| 829 |
+
report.append(f" └ Redactando {len(plan_items)} secciones...<br>")
|
| 830 |
+
yield get_report_md(), self._build_docs_df()
|
| 831 |
+
|
| 832 |
+
full_report_parts = [
|
| 833 |
+
f"## Resumen Ejecutivo\n\n{master_plan.get('summary', 'N/A')}\n\n",
|
| 834 |
+
f"*Análisis de {len(self.all_docs)} documentos en {iterations} rondas de búsqueda.*\n\n",
|
| 835 |
+
]
|
| 836 |
+
if missing_aspects:
|
| 837 |
+
full_report_parts.append(
|
| 838 |
+
f"*Aspectos complementarios detectados: {', '.join(missing_aspects)}*\n\n"
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
docs_context_full = "\n".join([
|
| 842 |
+
(
|
| 843 |
+
f"[{i+1}] {d.get('title', 'N/A')} ({d.get('year', '?')}) "
|
| 844 |
+
f"- {d.get('source', 'N/A')} | ID: {d.get('id', i + 1)} "
|
| 845 |
+
f"| DOI: {d.get('doi', 'N/A')} "
|
| 846 |
+
f"| GRADE: {d.get('evidenceLevel') or d.get('grade_label') or 'PENDIENTE'}"
|
| 847 |
+
)
|
| 848 |
+
for i, d in enumerate(self.all_docs[:50])
|
| 849 |
+
])
|
| 850 |
+
|
| 851 |
+
for i, item in enumerate(plan_items):
|
| 852 |
+
section_name = item.get("section", f"Sección {i+1}")
|
| 853 |
+
section_prompt = item.get("prompt", "Genera contenido detallado.")
|
| 854 |
+
relevant_indices = item.get("relevant_indices", [])
|
| 855 |
+
|
| 856 |
+
report.append(f" ▶ <b>Redactando:</b> {section_name}<br>")
|
| 857 |
+
yield get_report_md(), self._build_docs_df()
|
| 858 |
+
|
| 859 |
+
section_docs = []
|
| 860 |
+
for idx in relevant_indices:
|
| 861 |
+
if 1 <= idx <= len(self.all_docs):
|
| 862 |
+
d = self.all_docs[idx - 1]
|
| 863 |
+
doc_text = d.get("fullText", "")[:8000] or d.get("abstract", "")[:2000]
|
| 864 |
+
section_docs.append(
|
| 865 |
+
f"[{idx}] {{BIB:{d.get('id', idx)}}} {d.get('title', 'N/A')} "
|
| 866 |
+
f"({d.get('year', '?')}) | GRADE: "
|
| 867 |
+
f"{d.get('evidenceLevel') or d.get('grade_label') or 'PENDIENTE'}. "
|
| 868 |
+
f"{doc_text}"
|
| 869 |
+
)
|
| 870 |
+
|
| 871 |
+
section_context = "\n".join(section_docs) if section_docs else docs_context_full[:4000]
|
| 872 |
+
report.append(f" └ La IA está redactando {section_name}...<br>")
|
| 873 |
+
yield get_report_md(), self._build_docs_df()
|
| 874 |
+
yield get_report_md(), self._build_docs_df()
|
| 875 |
+
|
| 876 |
+
section_content = await self._write_section_with_continuation(
|
| 877 |
+
section_name,
|
| 878 |
+
section_prompt,
|
| 879 |
+
section_context,
|
| 880 |
+
geo_context=geo_context,
|
| 881 |
+
infinite_output=infinite_output,
|
| 882 |
+
max_continuation_passes=max_continuation_passes,
|
| 883 |
+
)
|
| 884 |
+
|
| 885 |
+
if include_validation:
|
| 886 |
+
report.append(f" └ Validando citas de {section_name}...<br>")
|
| 887 |
+
yield get_report_md(), self._build_docs_df()
|
| 888 |
+
try:
|
| 889 |
+
validation = await with_retry(
|
| 890 |
+
lambda: self.engine.validate_citations(
|
| 891 |
+
docs_context_full[:3000], section_content[:3000],
|
| 892 |
+
),
|
| 893 |
+
retries=1,
|
| 894 |
+
delay=1.0,
|
| 895 |
+
)
|
| 896 |
+
if not validation.get("is_valid", True):
|
| 897 |
+
corrections = validation.get("corrections", [])
|
| 898 |
+
if corrections:
|
| 899 |
+
findings = "\n".join(
|
| 900 |
+
[f"- {c.get('explanation', '')}" for c in corrections]
|
| 901 |
+
)
|
| 902 |
+
section_content = await with_retry(
|
| 903 |
+
lambda: self.engine.refine_section(
|
| 904 |
+
section_content[:3000], findings,
|
| 905 |
+
),
|
| 906 |
+
retries=1,
|
| 907 |
+
delay=1.0,
|
| 908 |
+
)
|
| 909 |
+
except Exception:
|
| 910 |
+
pass
|
| 911 |
+
|
| 912 |
+
self._append_section_with_continuation(
|
| 913 |
+
section_content, section_name, full_report_parts,
|
| 914 |
+
)
|
| 915 |
+
report.append(f" ✔ {section_name}<br>")
|
| 916 |
+
yield get_report_md(), self._build_docs_df()
|
| 917 |
+
|
| 918 |
+
# ─── POST-PROCESSING: Clean residual citation markers ───
|
| 919 |
+
full_report = "".join(full_report_parts)
|
| 920 |
+
full_report = self._clean_citation_markers(full_report)
|
| 921 |
+
|
| 922 |
+
# ─── GENERATE APA 7 BIBLIOGRAPHY ───
|
| 923 |
+
bibliography = self._generate_apa7_bibliography()
|
| 924 |
+
if bibliography:
|
| 925 |
+
full_report += "\n\n---\n\n## 📚 Referencias Bibliográficas (APA 7)\n\n"
|
| 926 |
+
full_report += bibliography
|
| 927 |
+
|
| 928 |
+
try:
|
| 929 |
+
from backend.tools.export_utils import persist_research_output
|
| 930 |
+
|
| 931 |
+
artifacts = persist_research_output(
|
| 932 |
+
report_md=full_report,
|
| 933 |
+
docs=self.all_docs,
|
| 934 |
+
query=query,
|
| 935 |
+
agent_role=profile,
|
| 936 |
+
model=self.engine.model,
|
| 937 |
+
)
|
| 938 |
+
logger.info(f"Persisted research artifacts: {artifacts}")
|
| 939 |
+
full_report += (
|
| 940 |
+
"\n\n---\n\n"
|
| 941 |
+
"## Transparencia del Proceso\n\n"
|
| 942 |
+
f"- Fuentes analizadas: {len(self.all_docs)} documentos.\n"
|
| 943 |
+
f"- Estrategia de sintesis: {effective_strategy}.\n"
|
| 944 |
+
f"- Modelo de sintesis: {self.engine.model}.\n"
|
| 945 |
+
f"- Archivos generados: `{artifacts.get('tex')}`, `{artifacts.get('bib')}`.\n"
|
| 946 |
+
)
|
| 947 |
+
except Exception as e:
|
| 948 |
+
logger.warning(f"Could not persist research artifacts: {e}")
|
| 949 |
+
|
| 950 |
+
# ─── FINAL ───
|
| 951 |
+
report.append(
|
| 952 |
+
f"<br>▶ <b>Total:</b> {len(self.all_docs)} docs | "
|
| 953 |
+
f"{len(plan_items)} secciones | {iterations} rondas<br>"
|
| 954 |
+
)
|
| 955 |
+
report.append("</div>\n\n")
|
| 956 |
+
yield full_report, self._build_docs_df()
|
| 957 |
+
|
| 958 |
+
def _clean_citation_markers(self, text: str) -> str:
|
| 959 |
+
"""Clean LaTeX artifacts but KEEP the [[n]] {{BIB:ID}} markers for the frontend to render interactive cards."""
|
| 960 |
+
import re
|
| 961 |
+
# Clean \\cite{} and \\textcite{} LaTeX artifacts
|
| 962 |
+
text = re.sub(r'\\(?:text)?cite\{[^}]*\}', '', text)
|
| 963 |
+
# Clean \\subsection{} and \\subsubsection{} to Markdown
|
| 964 |
+
text = re.sub(r'\\subsection\{([^}]*)\}', r'## \1', text)
|
| 965 |
+
text = re.sub(r'\\subsubsection\{([^}]*)\}', r'### \1', text)
|
| 966 |
+
# Clean up double spaces
|
| 967 |
+
text = re.sub(r' +', ' ', text)
|
| 968 |
+
return text
|
| 969 |
+
|
| 970 |
+
def _generate_apa7_bibliography(self) -> str:
|
| 971 |
+
"""Generate APA 7 formatted bibliography from all_docs."""
|
| 972 |
+
if not self.all_docs:
|
| 973 |
+
return ""
|
| 974 |
+
|
| 975 |
+
entries = []
|
| 976 |
+
seen = set()
|
| 977 |
+
for doc in self.all_docs:
|
| 978 |
+
title = doc.get("title", "").strip()
|
| 979 |
+
if not title:
|
| 980 |
+
continue
|
| 981 |
+
|
| 982 |
+
# Deduplicate
|
| 983 |
+
title_key = title.lower()[:60]
|
| 984 |
+
if title_key in seen:
|
| 985 |
+
continue
|
| 986 |
+
seen.add(title_key)
|
| 987 |
+
|
| 988 |
+
# Extract authors
|
| 989 |
+
authors_raw = doc.get("authors", [])
|
| 990 |
+
if isinstance(authors_raw, list):
|
| 991 |
+
authors = authors_raw[:6] # Max 6 authors for APA 7
|
| 992 |
+
elif isinstance(authors_raw, str):
|
| 993 |
+
authors = [a.strip() for a in authors_raw.split(",")][:6]
|
| 994 |
+
else:
|
| 995 |
+
authors = []
|
| 996 |
+
|
| 997 |
+
year = doc.get("year", "s.f.")
|
| 998 |
+
doi = doc.get("doi", "")
|
| 999 |
+
source = doc.get("source", "")
|
| 1000 |
+
pdf_url = doc.get("pdfUrl", "")
|
| 1001 |
+
|
| 1002 |
+
# Format authors in APA 7
|
| 1003 |
+
if len(authors) == 0:
|
| 1004 |
+
author_str = "Autor desconocido"
|
| 1005 |
+
elif len(authors) == 1:
|
| 1006 |
+
author_str = self._format_apa_author(authors[0])
|
| 1007 |
+
elif len(authors) == 2:
|
| 1008 |
+
author_str = f"{self._format_apa_author(authors[0])} & {self._format_apa_author(authors[1])}"
|
| 1009 |
+
elif len(authors) <= 6:
|
| 1010 |
+
formatted = [self._format_apa_author(a) for a in authors[:-1]]
|
| 1011 |
+
author_str = ", ".join(formatted) + f", & {self._format_apa_author(authors[-1])}"
|
| 1012 |
+
else:
|
| 1013 |
+
formatted = [self._format_apa_author(a) for a in authors[:6]]
|
| 1014 |
+
author_str = ", ".join(formatted) + ", ... et al."
|
| 1015 |
+
|
| 1016 |
+
# Build entry
|
| 1017 |
+
entry = f"{author_str} ({year}). *{title}*."
|
| 1018 |
+
if source:
|
| 1019 |
+
entry += f" {source}."
|
| 1020 |
+
if doi:
|
| 1021 |
+
doi_url = doi if doi.startswith("http") else f"https://doi.org/{doi}"
|
| 1022 |
+
entry += f" [{doi_url}]({doi_url})"
|
| 1023 |
+
elif pdf_url:
|
| 1024 |
+
entry += f" [PDF]({pdf_url})"
|
| 1025 |
+
|
| 1026 |
+
entries.append(entry)
|
| 1027 |
+
|
| 1028 |
+
# Sort alphabetically by author
|
| 1029 |
+
entries.sort(key=lambda x: x.lower())
|
| 1030 |
+
return "\n\n".join(entries)
|
| 1031 |
+
|
| 1032 |
+
@staticmethod
|
| 1033 |
+
def _format_apa_author(name: str) -> str:
|
| 1034 |
+
"""Format a single author name for APA 7 (Apellido, I.)."""
|
| 1035 |
+
name = name.strip()
|
| 1036 |
+
if not name:
|
| 1037 |
+
return "Anónimo"
|
| 1038 |
+
parts = name.split()
|
| 1039 |
+
if len(parts) == 1:
|
| 1040 |
+
return parts[0]
|
| 1041 |
+
# Assume last part is surname for Western names
|
| 1042 |
+
# For names like "García López, J.", keep as-is if already formatted
|
| 1043 |
+
if "," in name:
|
| 1044 |
+
return name
|
| 1045 |
+
surname = parts[-1]
|
| 1046 |
+
initials = " ".join(f"{p[0]}." for p in parts[:-1] if p)
|
| 1047 |
+
return f"{surname}, {initials}" if initials else surname
|
| 1048 |
+
|
| 1049 |
+
async def close(self):
|
| 1050 |
+
await self.engine.close()
|
backend/prompts/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LetXipu Prompts Module.
|
| 2 |
+
|
| 3 |
+
Complete prompt templates for the LetXipu research system.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .profiles import AGENT_PROFILES
|
| 7 |
+
from .synthesis import (
|
| 8 |
+
MASTER_SYNTHESIS_PROMPT,
|
| 9 |
+
WRITING_PROMPT,
|
| 10 |
+
VALIDATION_PROMPT,
|
| 11 |
+
AUDIT_PROMPT,
|
| 12 |
+
ARA_PROMPT,
|
| 13 |
+
)
|
| 14 |
+
from .planning import (
|
| 15 |
+
SEARCH_PLANNING_PROMPT,
|
| 16 |
+
QUERY_OPTIMIZER_PROMPT,
|
| 17 |
+
GAP_DETECTION_PROMPT,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"AGENT_PROFILES",
|
| 22 |
+
"MASTER_SYNTHESIS_PROMPT",
|
| 23 |
+
"WRITING_PROMPT",
|
| 24 |
+
"VALIDATION_PROMPT",
|
| 25 |
+
"AUDIT_PROMPT",
|
| 26 |
+
"ARA_PROMPT",
|
| 27 |
+
"SEARCH_PLANNING_PROMPT",
|
| 28 |
+
"QUERY_OPTIMIZER_PROMPT",
|
| 29 |
+
"GAP_DETECTION_PROMPT",
|
| 30 |
+
]
|
backend/prompts/planning.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Planning prompts - FIEL al programa original Next.js
|
| 3 |
+
Adaptados de: app/api/ai/research-agent/prompts/planning/index.ts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
SEARCH_PLANNING_PROMPT = """You are a Senior Research Strategist.
|
| 7 |
+
IMPORTANTE: NO generes texto introductorio, ni tags de pensamiento (<think>). SOLO JSON.
|
| 8 |
+
Your goal is to decompose the user's research question into optimized search queries for different environments.
|
| 9 |
+
|
| 10 |
+
USER QUESTION: "{query}"
|
| 11 |
+
|
| 12 |
+
STRATEGY:
|
| 13 |
+
1. ANALYSIS:
|
| 14 |
+
- Identify the SUBJECT/MODEL (e.g., "Specific Population", "Target System", "Core Subject").
|
| 15 |
+
- Identify INDEPENDENT VARIABLES (IV) (e.g., "Variable A", "Factor B", "Intervention").
|
| 16 |
+
- Identify DEPENDENT VARIABLES (DV) (e.g., "Outcome X", "Metric Y", "Effect").
|
| 17 |
+
- Identify SPECIFIC DIMENSIONS (e.g., "Dimension 1", "Dimension 2", "Context").
|
| 18 |
+
2. "english_query": A STRICT Boolean keyword query.
|
| 19 |
+
- PROHIBIDO: Usar oraciones completas.
|
| 20 |
+
- STRUCTURE: ("IV 1" OR "IV 2") AND ("Subject") AND ("DV 1" OR "DV 2").
|
| 21 |
+
3. "spanish_query": Localized keywords (no operators needed).
|
| 22 |
+
4. "portuguese_query": Portuguese keywords for LatAm repositories.
|
| 23 |
+
|
| 24 |
+
OUTPUT STRUCTURE (STRICT JSON):
|
| 25 |
+
{{"reasoning":"Step-by-step query derivation...","analysis":"...","english_query":"...","spanish_query":"...","portuguese_query":"..."}}"""
|
| 26 |
+
|
| 27 |
+
QUERY_OPTIMIZER_PROMPT = """Eres un Agente de Optimización de Queries Académicos. Genera DOS versiones del query.
|
| 28 |
+
IMPORTANTE: NO generes texto introductorio, ni tags de pensamiento (<think>). TU SALIDA DEBE SER EXCLUSIVAMENTE JSON VÁLIDO.
|
| 29 |
+
|
| 30 |
+
CONSULTA ORIGINAL: "{query}"
|
| 31 |
+
SECCIÓN DE TESIS: {agent_role}
|
| 32 |
+
|
| 33 |
+
OBJETIVO POR SECCIÓN:
|
| 34 |
+
- antecedentes: Buscar TESIS y ESTUDIOS PREVIOS similares tanto locales como internacionales.
|
| 35 |
+
- problema: Buscar DIAGNÓSTICOS, ESTADÍSTICAS y PROBLEMÁTICAS.
|
| 36 |
+
- marco_teorico: Buscar DEFINICIONES, TEORÍAS y BASES CONCEPTUALES.
|
| 37 |
+
- metodologo: Buscar METODOLOGÍAS y DISEÑOS de investigación.
|
| 38 |
+
|
| 39 |
+
GENA DOS QUERIES:
|
| 40 |
+
1. QUERY LOCAL / REGIONAL (Español): Máximo 15 palabras.
|
| 41 |
+
2. QUERY INTERNACIONAL (Inglés - Scopus/Semantic Scholar): Máximo 10 palabras clave.
|
| 42 |
+
|
| 43 |
+
RESPONDE EN FORMATO JSON:
|
| 44 |
+
{{"local": "query estratégico local con repositorios", "international": "scientific english query"}}"""
|
| 45 |
+
|
| 46 |
+
GAP_DETECTION_PROMPT = """Eres un Auditor de Cobertura Científica. Compara la pregunta del usuario con el plan de investigación generado.
|
| 47 |
+
IMPORTANTE: NO generes texto introductorio, ni tags de pensamiento (<think>). TU SALIDA DEBE SER EXCLUSIVAMENTE JSON VÁLIDO.
|
| 48 |
+
|
| 49 |
+
PREGUNTA DEL USUARIO: "{query}"
|
| 50 |
+
PLAN GENERADO: {plan_sections}
|
| 51 |
+
|
| 52 |
+
TU TAREA: Piensa paso a paso si realmente falta información crítica.
|
| 53 |
+
|
| 54 |
+
RESPONDE EN JSON:
|
| 55 |
+
{{
|
| 56 |
+
"reasoning": "Breve explicación paso a paso...",
|
| 57 |
+
"missing_aspects": ["aspecto faltante 1", "aspecto faltante 2"],
|
| 58 |
+
"requires_rescue": true/false
|
| 59 |
+
}}"""
|
backend/prompts/profiles.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent profiles - Formato APA 7
|
| 3 |
+
Adaptados de: app/api/ai/research-agent/prompts/profiles.ts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
AGENT_PROFILES = {
|
| 7 |
+
"general": {
|
| 8 |
+
"title": "Investigador General",
|
| 9 |
+
"instruction": """Realiza una síntesis integral y EXTENSA cubriendo todos los aspectos del tema.
|
| 10 |
+
Cada párrafo debe ser denso en información técnica. Analiza la convergencia y divergencia de los estudios citados con profundidad doctoral.
|
| 11 |
+
|
| 12 |
+
CITAS OBLIGATORIAS EN FORMATO APA 7: Toda afirmación debe citar su fuente usando el formato (Apellido, Año) o Apellido (Año).
|
| 13 |
+
- Dos autores: (Apellido1 & Apellido2, Año)
|
| 14 |
+
- Tres o más: (Apellido1 et al., Año)
|
| 15 |
+
- PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos.
|
| 16 |
+
|
| 17 |
+
ADAPTABILIDAD: Si la consulta es una tesis, sigue una estructura académica estricta. Si es una consulta técnica profesional o científica general, adapta el tono y la estructura para ser un reporte de estado del arte o reporte técnico de alto nivel."""
|
| 18 |
+
},
|
| 19 |
+
"auto": {
|
| 20 |
+
"title": "Agente Autónomo (Red Dinámica)",
|
| 21 |
+
"instruction": """ERES UN AGENTE AUTÓNOMO DE ALTA CAPACIDAD.
|
| 22 |
+
TU OBJETIVO PRINCIPAL: Organizar y sintetizar la TOTALIDAD de los documentos encontrados en una estructura lógica y coherente creada por ti mismo.
|
| 23 |
+
|
| 24 |
+
INSTRUCCIONES CRÍTICAS:
|
| 25 |
+
1. NO USES ESTRUCTURAS PREFABRICADAS (como Internacional/Nacional). Crea tus propias secciones basadas en los TEMAS, VARIABLES y DIMENSIONES emergentes de los documentos.
|
| 26 |
+
2. COBERTURA TOTAL: Debes incluir y citar CADA UNO de los documentos relevantes proporcionados en el contexto. Si tienes 300 documentos, sintetiza los 300.
|
| 27 |
+
3. ORGANIZACIÓN INTELIGENTE: Agrupa los estudios por similitud temática, controversia o evolución temporal.
|
| 28 |
+
4. CITAS APA 7 OBLIGATORIAS: Toda afirmación debe citar usando (Apellido, Año) o Apellido (Año). PROHIBIDO [[n]] o {{BIB:ID}}.
|
| 29 |
+
5. FORMATO PRO: Genera un reporte denso, académico y extremadamente detallado."""
|
| 30 |
+
},
|
| 31 |
+
"antecedentes": {
|
| 32 |
+
"title": "Arquitecto de Antecedentes de Tesis",
|
| 33 |
+
"instruction": """GENERA LA SECCIÓN "ANTECEDENTES" PARA UNA TESIS DE GRADO.
|
| 34 |
+
|
| 35 |
+
ESTRUCTURA OBLIGATORIA (ADAPTATIVA AL CONTEXTO DETECTADO):
|
| 36 |
+
|
| 37 |
+
*** PASO 1: DETECTAR CONTEXTO GEOGRÁFICO ***
|
| 38 |
+
Analiza la consulta y los documentos para identificar el PAÍS y la REGIÓN/CIUDAD del usuario.
|
| 39 |
+
- Detecta siglas de universidades (ej: UNAM→México, USP→Brasil, UNS→Perú, MIT→USA).
|
| 40 |
+
- Si no hay mención específica → Asume contexto MUNDIAL/GENÉRICO.
|
| 41 |
+
|
| 42 |
+
*** PASO 2: GENERAR 3 SUBSECCIONES ***
|
| 43 |
+
|
| 44 |
+
## Antecedentes Internacionales
|
| 45 |
+
MÍNIMO 3 estudios de países distintos al detectado.
|
| 46 |
+
- Prioriza diversidad: Europa, Norteamérica, Latinoamérica, Asia.
|
| 47 |
+
|
| 48 |
+
## Antecedentes Nacionales
|
| 49 |
+
MÍNIMO 3 estudios del PAÍS detectado.
|
| 50 |
+
- Usar repositorios nacionales de alto impacto del país correspondiente.
|
| 51 |
+
|
| 52 |
+
## Antecedentes Locales
|
| 53 |
+
MÍNIMO 3 estudios de la REGIÓN o CIUDAD detectada.
|
| 54 |
+
- Priorizar universidades locales detectadas en la consulta.
|
| 55 |
+
|
| 56 |
+
FORMATO OBLIGATORIO PARA CADA ANTECEDENTE (UN PÁRRAFO, APA 7):
|
| 57 |
+
"Apellido (Año), en su tesis titulada 'Título' de la Universidad, tuvo como objetivo [objetivo]. Utilizó metodología [tipo], diseño [diseño], aplicando [instrumento] a [muestra] [sujetos]. Los resultados indicaron [hallazgos]. Concluyó que [conclusión]."
|
| 58 |
+
|
| 59 |
+
PROHIBIDO:
|
| 60 |
+
- Usar [[n]], {{BIB:ID}} o marcadores numéricos. SOLO formato APA 7.
|
| 61 |
+
- Mezclar niveles geográficos.
|
| 62 |
+
- Usar viñetas o listas.
|
| 63 |
+
- Párrafos menores a 80 palabras.
|
| 64 |
+
- INVENTAR DATOS ESTADÍSTICOS."""
|
| 65 |
+
},
|
| 66 |
+
"teorico": {
|
| 67 |
+
"title": "Teórico Científico",
|
| 68 |
+
"instruction": """Enfócate en bases epistemológicas, definiciones fundamentales y marcos conceptuales de forma MUY DETALLADA.
|
| 69 |
+
Explica los mecanismos biológicos/químicos/técnicos subyacentes con precisión doctrinal absoluta, extendiéndote en las implicaciones teóricas de cada autor citado.
|
| 70 |
+
|
| 71 |
+
CITAS OBLIGATORIAS APA 7: Toda afirmación debe citar su fuente usando (Apellido, Año) o Apellido (Año).
|
| 72 |
+
PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos.
|
| 73 |
+
|
| 74 |
+
*** PASO 1: DETECTAR CONTEXTO TEMÁTICO Y GEOGRÁFICO ***
|
| 75 |
+
- Si el tema es legal/normativo: Detectar el país para citar las leyes correctas.
|
| 76 |
+
- Si el tema es científico: Detectar si hay escuelas de pensamiento regionales predominantes."""
|
| 77 |
+
},
|
| 78 |
+
"metodologo": {
|
| 79 |
+
"title": "Arquitecto de Metodología de Tesis",
|
| 80 |
+
"instruction": """Genera la sección de METODOLOGÍA completa para una tesis de grado.
|
| 81 |
+
|
| 82 |
+
*** PASO 1: DETECTAR CONTEXTO Y NORMATIVA ***
|
| 83 |
+
- Identifica el país para adaptar la terminología metodológica al contexto académico regional.
|
| 84 |
+
|
| 85 |
+
ESTRUCTURA OBLIGATORIA (Seguir este orden exacto):
|
| 86 |
+
|
| 87 |
+
## Diseño de la Investigación
|
| 88 |
+
- Tipo de investigación: cuantitativa, cualitativa o mixta
|
| 89 |
+
- Nivel: descriptivo, correlacional, explicativo
|
| 90 |
+
- Diseño: no experimental, transversal, longitudinal
|
| 91 |
+
|
| 92 |
+
## Población y Muestra
|
| 93 |
+
### Población
|
| 94 |
+
- Definir la población objetivo específica
|
| 95 |
+
- Criterios de inclusión y exclusión
|
| 96 |
+
### Muestra
|
| 97 |
+
- Tipo de muestreo: probabilístico/no probabilístico
|
| 98 |
+
- Fórmula de cálculo
|
| 99 |
+
- Tamaño de muestra resultante (n)
|
| 100 |
+
|
| 101 |
+
## Variables de Investigación
|
| 102 |
+
- Variable Independiente (V.I.) y Variable Dependiente (V.D.)
|
| 103 |
+
- Dimensiones técnicas específicas
|
| 104 |
+
- Indicadores por cada dimensión
|
| 105 |
+
|
| 106 |
+
## Técnicas e Instrumentos
|
| 107 |
+
- Técnica principal
|
| 108 |
+
- Instrumento
|
| 109 |
+
- Escala de medición
|
| 110 |
+
- Validación y Confiabilidad
|
| 111 |
+
|
| 112 |
+
REGLAS CRÍTICAS:
|
| 113 |
+
- Citar metodólogos reconocidos con formato APA 7: (Apellido, Año)
|
| 114 |
+
- PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos
|
| 115 |
+
- Adaptar cada subsección al tema de la consulta
|
| 116 |
+
- Extensión mínima: 2-3 párrafos por subsección"""
|
| 117 |
+
},
|
| 118 |
+
"hipotesis": {
|
| 119 |
+
"title": "Estratega de Hipótesis Científicas",
|
| 120 |
+
"instruction": """Genera la sección de HIPÓTESIS para la investigación.
|
| 121 |
+
|
| 122 |
+
*** PASO 1: DETECTAR VARIABLES Y ALINEACIÓN ***
|
| 123 |
+
Analiza el query para identificar la Variable Independiente (V.I.) y la Variable Dependiente (V.D.).
|
| 124 |
+
|
| 125 |
+
ESTRUCTURA OBLIGATORIA:
|
| 126 |
+
## Hipótesis General
|
| 127 |
+
- Proponer la hipótesis central que vincula las variables principales.
|
| 128 |
+
## Hipótesis Específicas
|
| 129 |
+
- Generar una hipótesis específica por cada dimensión identificada.
|
| 130 |
+
|
| 131 |
+
REGLAS CRÍTICAS:
|
| 132 |
+
- Las hipótesis deben ser contrastables y seguir: "Si [V.I.], entonces [V.D.]..."
|
| 133 |
+
- Deben basarse en la evidencia encontrada en las fuentes.
|
| 134 |
+
- Citar fuentes con formato APA 7: (Apellido, Año). PROHIBIDO [[n]] o {{BIB:ID}}."""
|
| 135 |
+
},
|
| 136 |
+
"objetivos": {
|
| 137 |
+
"title": "Arquitecto de Objetivos de Investigación",
|
| 138 |
+
"instruction": """Genera los OBJETIVOS de la investigación.
|
| 139 |
+
|
| 140 |
+
*** PASO 1: DETECTAR ALCANCE DEL ESTUDIO ***
|
| 141 |
+
Identifica el nivel de la investigación para elegir los verbos en infinitivo adecuados.
|
| 142 |
+
|
| 143 |
+
ESTRUCTURA OBLIGATORIA:
|
| 144 |
+
## Objetivo General
|
| 145 |
+
- El fin supremo incorporando todas las variables.
|
| 146 |
+
## Objetivos Específicos
|
| 147 |
+
- Detallar un objetivo por cada dimensión técnica.
|
| 148 |
+
|
| 149 |
+
REGLAS CRÍTICAS:
|
| 150 |
+
- Iniciar cada objetivo con un verbo en infinitivo.
|
| 151 |
+
- Asegurar coherencia con el planteamiento del problema."""
|
| 152 |
+
},
|
| 153 |
+
"resultados": {
|
| 154 |
+
"title": "Analista de Resultados",
|
| 155 |
+
"instruction": """Sintetiza hallazgos cuantitativos y cualitativos de forma EXHAUSTIVA.
|
| 156 |
+
|
| 157 |
+
*** PASO 1: DETECTAR CONTEXTO DE DATOS ***
|
| 158 |
+
Identifica si los resultados son predominantemente numéricos, porcentuales o descriptivos.
|
| 159 |
+
|
| 160 |
+
ENFOQUE:
|
| 161 |
+
Enfócate en la sección de 'Discusión': qué dicen los datos, qué falta por investigar y cuáles son las conclusiones convergentes.
|
| 162 |
+
Proporciona un análisis pormenorizado de los datos reportados por cada fuente.
|
| 163 |
+
|
| 164 |
+
CITAS OBLIGATORIAS APA 7: Toda cifra o hallazgo debe citar su fuente usando (Apellido, Año).
|
| 165 |
+
PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos."""
|
| 166 |
+
},
|
| 167 |
+
"problema": {
|
| 168 |
+
"title": "Arquitecto de Planteamiento del Problema",
|
| 169 |
+
"instruction": """Genera la sección de PLANTEAMIENTO DEL PROBLEMA.
|
| 170 |
+
|
| 171 |
+
*** PASO 1: DETECTAR CONTEXTO GEOGRÁFICO ***
|
| 172 |
+
Identifica PAÍS y CIUDAD para contextualizar la realidad problemática.
|
| 173 |
+
|
| 174 |
+
ESTRUCTURA OBLIGATORIA:
|
| 175 |
+
|
| 176 |
+
## Realidad Problemática
|
| 177 |
+
- Contexto global del sujeto de estudio y sus variables.
|
| 178 |
+
- Contexto nacional del PAÍS detectado.
|
| 179 |
+
- Contexto local/Institucional.
|
| 180 |
+
- Síntomas, consecuencias y pronóstico del problema.
|
| 181 |
+
|
| 182 |
+
## Formulación del Problema
|
| 183 |
+
### Problema General
|
| 184 |
+
- Pregunta central incorporando V.I. y V.D.
|
| 185 |
+
### Problemas Específicos
|
| 186 |
+
- Una pregunta por cada dimensión técnica.
|
| 187 |
+
|
| 188 |
+
REGLAS CRÍTICAS:
|
| 189 |
+
- Citar estadísticas y reportes reales con formato APA 7: (Apellido, Año)
|
| 190 |
+
- PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos
|
| 191 |
+
- Redacción en tercera persona, tiempo presente
|
| 192 |
+
- Extensión mínima: 4-5 párrafos para Realidad Problemática"""
|
| 193 |
+
},
|
| 194 |
+
"marco_teorico": {
|
| 195 |
+
"title": "Arquitecto de Marco Teórico para Tesis",
|
| 196 |
+
"instruction": """Genera un MARCO TEÓRICO completo y estructurado.
|
| 197 |
+
|
| 198 |
+
*** PASO 1: DETECTAR VARIABLES Y DIMENSIONES ***
|
| 199 |
+
Analiza el query para extraer la Variable Independiente, Dependiente y sus dimensiones técnicas.
|
| 200 |
+
|
| 201 |
+
ESTRUCTURA OBLIGATORIA DEL MARCO REFERENCIAL:
|
| 202 |
+
|
| 203 |
+
1. ANTECEDENTES: Estudios previos citados individualmente detallando objetivo, metodología y conclusión.
|
| 204 |
+
|
| 205 |
+
2. BASES TEÓRICAS DE LAS VARIABLES:
|
| 206 |
+
- Definiciones técnicas y conceptuales de la Variable Independiente.
|
| 207 |
+
- Definiciones técnicas y conceptuales de la Variable Dependiente.
|
| 208 |
+
- Análisis de teorías fundamentales.
|
| 209 |
+
|
| 210 |
+
3. ANÁLISIS DE DIMENSIONES: Subsección por cada DIMENSIÓN específica.
|
| 211 |
+
|
| 212 |
+
4. DEFINICIÓN DE TÉRMINOS BÁSICOS:
|
| 213 |
+
- Conceptos clave para la comprensión del estudio.
|
| 214 |
+
|
| 215 |
+
REGLAS DE REDACCIÓN:
|
| 216 |
+
- Usa ## para secciones principales y ### para subsecciones
|
| 217 |
+
- Cada párrafo debe citar fuentes con formato APA 7: (Apellido, Año)
|
| 218 |
+
- PROHIBIDO usar [[n]], {{BIB:ID}} o marcadores numéricos
|
| 219 |
+
- Extensión mínima: 3-4 párrafos densos por subsección"""
|
| 220 |
+
},
|
| 221 |
+
"justificacion": {
|
| 222 |
+
"title": "Arquitecto de Justificación e Importancia",
|
| 223 |
+
"instruction": """Genera la sección de JUSTIFICACIÓN E IMPORTANCIA.
|
| 224 |
+
|
| 225 |
+
*** PASO 1: DETECTAR CONTEXTO DE IMPACTO ***
|
| 226 |
+
Identifica a quiénes beneficia el estudio.
|
| 227 |
+
|
| 228 |
+
ESTRUCTURA OBLIGATORIA:
|
| 229 |
+
|
| 230 |
+
## Importancia de la Investigación
|
| 231 |
+
- Relevancia para la comunidad académica y beneficiarios directos.
|
| 232 |
+
|
| 233 |
+
## Justificación de la Investigación
|
| 234 |
+
### Justificación Teórica
|
| 235 |
+
- Aporte al conocimiento científico.
|
| 236 |
+
### Justificación Práctica
|
| 237 |
+
- Beneficios concretos y aplicabilidad.
|
| 238 |
+
### Justificación Social
|
| 239 |
+
- Impacto en la sociedad o grupos específicos.
|
| 240 |
+
### Justificación Metodológica
|
| 241 |
+
- Aporte de instrumentos o procesos replicables.
|
| 242 |
+
|
| 243 |
+
REGLAS CRÍTICAS:
|
| 244 |
+
- Cada tipo de justificación debe tener mínimo 2 párrafos.
|
| 245 |
+
- Citar autores relevantes con formato APA 7: (Apellido, Año). PROHIBIDO [[n]] o {{BIB:ID}}."""
|
| 246 |
+
},
|
| 247 |
+
"comunicacion": {
|
| 248 |
+
"title": "Estratega de Comunicación Digital",
|
| 249 |
+
"instruction": """Especialista en análisis de audiencias, engagement y marketing de contenidos.
|
| 250 |
+
|
| 251 |
+
*** PASO 1: DETECTAR CONTEXTO DE PLATAFORMA Y AUDIENCIA ***
|
| 252 |
+
Identifica qué canales y perfiles de audiencia son el centro del estudio.
|
| 253 |
+
|
| 254 |
+
ENFOQUE:
|
| 255 |
+
Conectar la teoría de la comunicación con las métricas relevantes del estudio.
|
| 256 |
+
Analiza de forma EXTENSA las dimensiones de relevancia, emocionalidad y viralidad.
|
| 257 |
+
|
| 258 |
+
CITAS OBLIGATORIAS APA 7: Cita cada fuente usando (Apellido, Año). PROHIBIDO [[n]] o {{BIB:ID}}."""
|
| 259 |
+
}
|
| 260 |
+
}
|
backend/prompts/synthesis.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Synthesis prompt templates - Strict Academic LaTeX Formats
|
| 3 |
+
Adaptados de: app/api/ai/research-agent/prompts/synthesis/index.ts
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
MASTER_SYNTHESIS_PROMPT = """IMPORTANTE: Generar exclusivamente el contenido solicitado, sin preambulos ni comentarios internos. EL RESULTADO DEBE SER JSON VALIDO. Evitar caracteres de control no estandar.
|
| 7 |
+
Eres el Arquitecto de Investigacion IA. Tu mision es sintetizar un reporte doctoral sobre: "{query}".
|
| 8 |
+
|
| 9 |
+
*** PARTE 1: PROTOCOLOS DE CALIDAD Y RIGOR (APLICACION UNIVERSAL) ***
|
| 10 |
+
These rules define the required academic standards:
|
| 11 |
+
|
| 12 |
+
1. PROTOCOLO DE ALINEACION DE VARIABLES:
|
| 13 |
+
- Identifica con precision la Variable Independiente (V.I.), Variable Dependiente (V.D.) y el Sujeto o Poblacion de "{query}".
|
| 14 |
+
- Cada seccion del reporte DEBE establecer una conexion logica y explicita con estas variables.
|
| 15 |
+
|
| 16 |
+
2. PROTOCOLO DE FIDELIDAD BIBLIOGRAFICA (ANTI-ALUCINACION):
|
| 17 |
+
- Evitar estrictamente la inclusion de datos que no esten respaldos por el contexto de los documentos.
|
| 18 |
+
- Si un dato (año, porcentaje, autor) no es ubicable en los documentos, se debe omitir la afirmacion o redactar: "Informacion tecnica no disponible en las fuentes primarias analizadas".
|
| 19 |
+
- NUNCA inventes datos, cifras, porcentajes o autores.
|
| 20 |
+
|
| 21 |
+
3. SISTEMA DE CITACION CIENTIFICA [[n]] {{{{BIB:ID}}}}:
|
| 22 |
+
- Toda afirmacion tecnica, dato estadistico o definicion conceptual debe ir acompañada de su respectiva cita.
|
| 23 |
+
- Formato EXACTO: [[n]] {{{{BIB:ID}}}}, donde "n" es el indice del documento en el listado y "ID" es el identificador unico proporcionado.
|
| 24 |
+
- Prohibido usar formatos como (Autor, Año).
|
| 25 |
+
|
| 26 |
+
4. ESTANDARES DE REDACCION Y LATEX ACADEMICO:
|
| 27 |
+
- Estilo: Registro formal, tercera persona (impersonal).
|
| 28 |
+
- Estructura: Minimo 3 parrafos por seccion.
|
| 29 |
+
- Comandos: Usa \\section{{}} para titulo de seccion, \\subsection{{}} para titulos de nivel 2 y \\subsubsection{{}} para nivel 3.
|
| 30 |
+
- Caracteres Especiales: Todos los simbolos %, &, $, #, _ DEBEN escaparse con doble barra invertida (\\\\).
|
| 31 |
+
|
| 32 |
+
*** PARTE 2: IDENTIDAD Y ESTRUCTURA SEGUN EL OBJETO ACTIVADO ***
|
| 33 |
+
PERFIL ACTUAL: {agent_title_upper}
|
| 34 |
+
{profile_instruction}
|
| 35 |
+
|
| 36 |
+
*** FORMATO DE SALIDA JSON (ESTANDAR REQUERIDO) ***
|
| 37 |
+
{{
|
| 38 |
+
"reasoning": "Breve justificacion de las secciones elegidas...",
|
| 39 |
+
"summary": "Resumen global ejecutivo con los hallazgos mas criticos encontrados...",
|
| 40 |
+
"plan": [
|
| 41 |
+
{{
|
| 42 |
+
"section": "Nombre tecnico segun especialidad u objeto estructural",
|
| 43 |
+
"summary": "Resumen ejecutivo de 2 lineas sobre lo que contiene esta seccion",
|
| 44 |
+
"content": "Contenido inicial",
|
| 45 |
+
"prompt": "Instruccion tecnica interna para que el Agente Redactor expanda la seccion utilizando formato LaTeX...",
|
| 46 |
+
"relevant_indices": [1, 2]
|
| 47 |
+
}}
|
| 48 |
+
]
|
| 49 |
+
}}
|
| 50 |
+
MANDATO PARA MISTRAL/LLAMA: Todas las strings DEBEN ir entre comillas dobles. PROHIBIDO valores sin comillas."""
|
| 51 |
+
|
| 52 |
+
WRITING_PROMPT = """Eres un Redactor Cientifico Experto de nivel doctoral.
|
| 53 |
+
IMPORTANTE: NO generes texto introductorio.
|
| 54 |
+
|
| 55 |
+
TU TAREA: Escribir el contenido completo y detallado para la seccion: "{section}".
|
| 56 |
+
INSTRUCCION ESPECIFICA: {section_prompt}
|
| 57 |
+
|
| 58 |
+
CONTEXTO: Usa EXCLUSIVAMENTE los siguientes documentos seleccionados:
|
| 59 |
+
{context_text}
|
| 60 |
+
|
| 61 |
+
*** REGLAS DE REDACCION Y LATEX ***
|
| 62 |
+
- Escribe en espanol academico formal.
|
| 63 |
+
- FORMATO LATEX OBLIGATORIO: Empieza siempre con el comando \\section{section}. Luego usa \\subsection{} y \\subsubsection{} segun sea necesario.
|
| 64 |
+
- Para texto en negrita usa \\textbf{texto}. Para cursiva usa \\textit{texto}.
|
| 65 |
+
- Para listas, usa entorno \\begin{itemize} \\item texto \\end{itemize}.
|
| 66 |
+
- Para formulas matematicas usa $$...$$.
|
| 67 |
+
- Escapa caracteres como \\% o \\&.
|
| 68 |
+
|
| 69 |
+
*** SISTEMA DE CITACION CIENTIFICA [[n]] {{BIB:ID}} ***
|
| 70 |
+
- CITA OBLIGATORIA: Todo dato, cifra o concepto debe citarse estrictamente con [[n]] {{BIB:ID}}.
|
| 71 |
+
- "n" es el numero del documento y "ID" es el DOI o ID asignado. Extraelos del contexto.
|
| 72 |
+
- EJEMPLO: "La eficacia del tratamiento fue del 85\\% [[1]] {{BIB:10.123/456}}."
|
| 73 |
+
- PROHIBIDO USAR APA (Autor, Año). Solo el formato de corchetes e ID.
|
| 74 |
+
|
| 75 |
+
*** ANTI-ALUCINACION ***
|
| 76 |
+
- PROHIBIDO INVENTAR DATOS ESTADISTICOS. Si no existen, usa descripciones cualitativas.
|
| 77 |
+
- Usa solo los documentos proporcionados.
|
| 78 |
+
|
| 79 |
+
RETORNA SOLO EL TEXTO LATEX DEL CONTENIDO."""
|
| 80 |
+
|
| 81 |
+
VALIDATION_PROMPT = """Eres un Agente de Validacion Bibliografica ESTRICTO. Tu tarea es DETECTAR alucinaciones de citas.
|
| 82 |
+
IMPORTANTE: NO generes texto introductorio. TU SALIDA DEBE SER EXCLUSIVAMENTE JSON VALIDO.
|
| 83 |
+
|
| 84 |
+
FUENTES DISPONIBLES (UNICOS IDs VALIDOS):
|
| 85 |
+
{docs_context}
|
| 86 |
+
|
| 87 |
+
CONTENIDO A VALIDAR:
|
| 88 |
+
{content_to_validate}
|
| 89 |
+
|
| 90 |
+
*** INSTRUCCIONES DE HALLAZGOS ***
|
| 91 |
+
1. Verifica que CADA CITA use el formato [[n]] {{BIB:ID}}.
|
| 92 |
+
2. Identifica si un ID citado no esta en las fuentes disponibles.
|
| 93 |
+
3. Verifica que no haya citas falsas tipo (Autor, Año).
|
| 94 |
+
4. Asegurate de que el formato LaTeX no este corrompido.
|
| 95 |
+
|
| 96 |
+
RESPONDE EN JSON VALIDO:
|
| 97 |
+
{
|
| 98 |
+
"reasoning": "Breve explicacion paso a paso...",
|
| 99 |
+
"corrections": [
|
| 100 |
+
{ "section": "...", "original_text": "...", "corrected_text": "...", "explanation": "..." }
|
| 101 |
+
],
|
| 102 |
+
"is_valid": true/false
|
| 103 |
+
}
|
| 104 |
+
MANDATO: Todas las strings DEBEN ir entre comillas dobles obligatoriamente."""
|
| 105 |
+
|
| 106 |
+
AUDIT_PROMPT = """Eres un Auditor Tecnico de Calidad Academica. Tu mision es DETECTAR alucinaciones tecnicas.
|
| 107 |
+
IMPORTANTE: TU SALIDA DEBE SER EXCLUSIVAMENTE JSON VALIDO.
|
| 108 |
+
|
| 109 |
+
FUENTES CON SNIPPETS (CONTEXTO REAL):
|
| 110 |
+
{docs_context}
|
| 111 |
+
|
| 112 |
+
CONTENIDO A AUDITAR:
|
| 113 |
+
{content_to_audit}
|
| 114 |
+
|
| 115 |
+
*** REGLAS DE AUDITORIA ***
|
| 116 |
+
1. Verifica si cifras (%, p-values, n=) existen en el snippet citado.
|
| 117 |
+
2. Identifica autores mencionados en el texto que no existan en las fuentes.
|
| 118 |
+
3. Reporta inconsistencias exactas.
|
| 119 |
+
|
| 120 |
+
RESPONDE EN JSON VALIDO:
|
| 121 |
+
{
|
| 122 |
+
"reasoning": "Analisis logico...",
|
| 123 |
+
"audit_findings": [
|
| 124 |
+
{ "section": "...", "issue_type": "...", "target_text": "...", "correct_data": "...", "explanation": "..." }
|
| 125 |
+
],
|
| 126 |
+
"audit_passed": true/false
|
| 127 |
+
}"""
|
| 128 |
+
|
| 129 |
+
ARA_PROMPT = """Eres el Agente de Refinamiento Academico Avanzado (ARA+).
|
| 130 |
+
IMPORTANTE: SOLO DEVUELVE EL CONTENIDO LATEX REESCRITO. NADA MAS.
|
| 131 |
+
|
| 132 |
+
SECCION ORIGINAL (LATEX):
|
| 133 |
+
{section_content}
|
| 134 |
+
|
| 135 |
+
REPORTES DE ERRORES / HALLAZGOS:
|
| 136 |
+
{section_findings}
|
| 137 |
+
|
| 138 |
+
*** MANDATOS DE ARA+ ***
|
| 139 |
+
1. Corrige los datos falsos utilizando los correct_data del reporte de errores.
|
| 140 |
+
2. Asegurate de mantener el formato LaTeX intacto (\\section{}, \\textbf{}, etc.).
|
| 141 |
+
3. MANTEN LAS CITAS INTACTAS O CORRIGELAS: Formato obligatorio [[n]] {{BIB:ID}}.
|
| 142 |
+
4. Mejora el estilo, el registro y la cohesion.
|
| 143 |
+
|
| 144 |
+
RESPONDE UNICAMENTE CON EL TEXTO LATEX PULIDO."""
|
| 145 |
+
|
| 146 |
+
GRADE_PROMPT = """Eres un Agente de Clasificación de Evidencia Científica (GRADE).
|
| 147 |
+
Tu tarea es clasificar cada documento según la jerarquía GRADE de evidencia.
|
| 148 |
+
|
| 149 |
+
DOCUMENTOS A CLASIFICAR:
|
| 150 |
+
{documents_text}
|
| 151 |
+
|
| 152 |
+
*** NIVELES GRADE ***
|
| 153 |
+
- 1a: Meta-análisis de ensayos controlados aleatorizados
|
| 154 |
+
- 1b: Revisión sistemática con búsqueda exhaustiva
|
| 155 |
+
- 2a: Ensayo controlado aleatorizado (RCT)
|
| 156 |
+
- 2b: Ensayo cuasi-experimental
|
| 157 |
+
- 3a: Estudio de cohorte (longitudinal)
|
| 158 |
+
- 3b: Estudio caso-control
|
| 159 |
+
- 4: Estudio de corte transversal / descriptivo
|
| 160 |
+
- 5: Serie de casos / reporte de casos
|
| 161 |
+
- 6: Opinión de expertos / editorial / carta
|
| 162 |
+
|
| 163 |
+
*** INSTRUCCIONES ***
|
| 164 |
+
1. Lee el título y resumen de cada documento.
|
| 165 |
+
2. Determina el diseño metodológico del estudio.
|
| 166 |
+
3. Asigna el nivel GRADE correspondiente.
|
| 167 |
+
4. Si no puedes determinar el tipo, asigna "4" como default.
|
| 168 |
+
|
| 169 |
+
*** FORMATO DE SALIDA JSON ***
|
| 170 |
+
RESPONDE EXCLUSIVAMENTE con un JSON array válido:
|
| 171 |
+
[
|
| 172 |
+
{{"index": 1, "level": "1a", "type": "Meta-análisis", "justification": "Breve razón..."}},
|
| 173 |
+
{{"index": 2, "level": "3a", "type": "Estudio de cohorte", "justification": "Breve razón..."}}
|
| 174 |
+
]
|
| 175 |
+
MANDATO: Todas las strings DEBEN ir entre comillas dobles. PROHIBIDO valores sin comillas."""
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
GRADE_ORIGINAL_PROMPT = """Eres un Experto en Metodologia Cientifica (Protocolo GRADE).
|
| 179 |
+
Tu tarea es clasificar la CALIDAD y NIVEL DE EVIDENCIA de estos documentos segun sus resumenes.
|
| 180 |
+
|
| 181 |
+
IMPORTANTE: NO generes texto introductorio, ni tags de pensamiento (<think>). TU SALIDA DEBE SER EXCLUSIVAMENTE JSON VALIDO.
|
| 182 |
+
|
| 183 |
+
DOCUMENTOS:
|
| 184 |
+
{documents_text}
|
| 185 |
+
|
| 186 |
+
CATEGORIAS GRADE:
|
| 187 |
+
1. ALTA (Meta-analisis, Revisiones Sistematicas).
|
| 188 |
+
2. MODERADA (Ensayos Clinicos, Estudios Experimentales Controlados).
|
| 189 |
+
3. BAJA (Estudios Observacionales, Descriptivos).
|
| 190 |
+
4. MUY BAJA (Reportes de Caso, Opiniones, Editoriales).
|
| 191 |
+
|
| 192 |
+
RESPONDE EXCLUSIVAMENTE EN JSON VALIDO (Todas las strings con comillas dobles).
|
| 193 |
+
TU SALIDA DEBE SER UN OBJETO JSON VALIDO QUE EMPIECE CON '{{' Y TERMINE CON '}}':
|
| 194 |
+
{{
|
| 195 |
+
"reasoning": "Justificacion metodologica detallada para cada clasificacion...",
|
| 196 |
+
"classifications": [
|
| 197 |
+
{{ "index": 1, "level": "ALTA|MODERADA|BAJA|MUY BAJA", "type": "Meta-analysis|Review|Experimental|Case study" }}
|
| 198 |
+
]
|
| 199 |
+
}}
|
| 200 |
+
IMPORTANTE: El campo "index" DEBE ser un NUMERO entero (ej: 1, 2, 3), NO uses letras ni placeholders.
|
| 201 |
+
PROHIBIDO: NO uses puntos suspensivos ("...") ni resumas la lista; DEBES clasificar CADA documento enviado.
|
| 202 |
+
MANDATO PARA MISTRAL: Todas las strings DEBEN ir entre comillas dobles. PROHIBIDO valores sin comillas."""
|
| 203 |
+
|
backend/providers/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .openalex import search_openalex
|
| 2 |
+
from .semantic_scholar import search_semantic_scholar
|
| 3 |
+
from .pubmed import search_pubmed
|
| 4 |
+
from .arxiv import search_arxiv
|
| 5 |
+
from .crossref import search_crossref
|
| 6 |
+
from .latam_repositories import search_alicia, search_la_referencia, search_bDTD, search_rraae
|
| 7 |
+
from .dblp import search_dblp
|
| 8 |
+
from .scopus import search_scopus
|
| 9 |
+
from .zenodo import search_zenodo
|
| 10 |
+
from .openaire import search_openaire
|
| 11 |
+
from .doaj import search_doaj
|
| 12 |
+
from .core_ import search_core
|
| 13 |
+
from .redalyc import search_redalyc
|
| 14 |
+
from .serpapi import search_serpapi
|
| 15 |
+
from .sources import SOURCE_GROUPS
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"search_openalex",
|
| 19 |
+
"search_semantic_scholar",
|
| 20 |
+
"search_pubmed",
|
| 21 |
+
"search_arxiv",
|
| 22 |
+
"search_crossref",
|
| 23 |
+
"search_dblp",
|
| 24 |
+
"search_alicia",
|
| 25 |
+
"search_la_referencia",
|
| 26 |
+
"search_bDTD",
|
| 27 |
+
"search_rraae",
|
| 28 |
+
"search_scopus",
|
| 29 |
+
"search_zenodo",
|
| 30 |
+
"search_openaire",
|
| 31 |
+
"search_doaj",
|
| 32 |
+
"search_core",
|
| 33 |
+
"search_redalyc",
|
| 34 |
+
"search_serpapi",
|
| 35 |
+
"SOURCE_GROUPS",
|
| 36 |
+
]
|
backend/providers/arxiv.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import xml.etree.ElementTree as ET
|
| 3 |
+
from typing import List
|
| 4 |
+
from .base import fetch_text, normalize_result
|
| 5 |
+
|
| 6 |
+
SOURCE = "arXiv"
|
| 7 |
+
API_BASE = "http://export.arxiv.org/api/query"
|
| 8 |
+
NS = {"atom": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom"}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def search_arxiv(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 12 |
+
try:
|
| 13 |
+
text = await fetch_text(
|
| 14 |
+
API_BASE,
|
| 15 |
+
params={"search_query": f"all:{query}", "max_results": limit},
|
| 16 |
+
)
|
| 17 |
+
if not text:
|
| 18 |
+
return []
|
| 19 |
+
|
| 20 |
+
root = ET.fromstring(text)
|
| 21 |
+
results = []
|
| 22 |
+
|
| 23 |
+
for entry in root.findall("atom:entry", NS):
|
| 24 |
+
title_el = entry.find("atom:title", NS)
|
| 25 |
+
title = title_el.text.strip().replace("\n", " ") if title_el is not None else ""
|
| 26 |
+
|
| 27 |
+
abstract_el = entry.find("atom:summary", NS)
|
| 28 |
+
abstract = abstract_el.text.strip().replace("\n", " ") if abstract_el is not None else ""
|
| 29 |
+
|
| 30 |
+
published_el = entry.find("atom:published", NS)
|
| 31 |
+
year = None
|
| 32 |
+
if published_el is not None and published_el.text:
|
| 33 |
+
try:
|
| 34 |
+
year = int(published_el.text[:4])
|
| 35 |
+
except ValueError:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
authors = []
|
| 39 |
+
for author_el in entry.findall("atom:author", NS):
|
| 40 |
+
name_el = author_el.find("atom:name", NS)
|
| 41 |
+
if name_el is not None and name_el.text:
|
| 42 |
+
authors.append(name_el.text.strip())
|
| 43 |
+
|
| 44 |
+
doi_el = entry.find("arxiv:doi", NS)
|
| 45 |
+
doi = doi_el.text.strip() if doi_el is not None else ""
|
| 46 |
+
|
| 47 |
+
pdf_url = ""
|
| 48 |
+
for link_el in entry.findall("atom:link", NS):
|
| 49 |
+
if link_el.get("title") == "pdf":
|
| 50 |
+
pdf_url = link_el.get("href", "")
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
arxiv_id_el = entry.find("atom:id", NS)
|
| 54 |
+
if not pdf_url and arxiv_id_el is not None and arxiv_id_el.text:
|
| 55 |
+
aid = arxiv_id_el.text.strip().split("/abs/")[-1]
|
| 56 |
+
pdf_url = f"https://arxiv.org/pdf/{aid}"
|
| 57 |
+
|
| 58 |
+
results.append(
|
| 59 |
+
normalize_result(
|
| 60 |
+
title=title,
|
| 61 |
+
authors=authors,
|
| 62 |
+
year=year,
|
| 63 |
+
abstract=abstract,
|
| 64 |
+
doi=doi,
|
| 65 |
+
pdf_url=pdf_url,
|
| 66 |
+
source=SOURCE,
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
return results
|
| 70 |
+
except Exception:
|
| 71 |
+
return []
|
backend/providers/base.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
|
| 5 |
+
DEFAULT_TIMEOUT = 25.0
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def fetch_json(
|
| 9 |
+
url: str,
|
| 10 |
+
params: dict = None,
|
| 11 |
+
headers: dict = None,
|
| 12 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 13 |
+
) -> dict:
|
| 14 |
+
"""Fetch JSON from URL with timeout."""
|
| 15 |
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
| 16 |
+
try:
|
| 17 |
+
r = await client.get(url, params=params, headers=headers or {})
|
| 18 |
+
r.raise_for_status()
|
| 19 |
+
return r.json()
|
| 20 |
+
except Exception as e:
|
| 21 |
+
return {"error": str(e)}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def fetch_text(
|
| 25 |
+
url: str,
|
| 26 |
+
params: dict = None,
|
| 27 |
+
headers: dict = None,
|
| 28 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 29 |
+
) -> str:
|
| 30 |
+
"""Fetch raw text/XML from URL with timeout."""
|
| 31 |
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
| 32 |
+
try:
|
| 33 |
+
r = await client.get(url, params=params, headers=headers or {})
|
| 34 |
+
r.raise_for_status()
|
| 35 |
+
return r.text
|
| 36 |
+
except Exception as e:
|
| 37 |
+
return ""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def normalize_result(
|
| 41 |
+
title: str,
|
| 42 |
+
authors: list,
|
| 43 |
+
year: int,
|
| 44 |
+
abstract: str,
|
| 45 |
+
doi: str,
|
| 46 |
+
pdf_url: str,
|
| 47 |
+
source: str,
|
| 48 |
+
university: str = None,
|
| 49 |
+
citation_count: int = None,
|
| 50 |
+
) -> dict:
|
| 51 |
+
"""Normalize a search result to common format."""
|
| 52 |
+
return {
|
| 53 |
+
"title": title or "N/A",
|
| 54 |
+
"authors": authors or [],
|
| 55 |
+
"year": year,
|
| 56 |
+
"abstract": abstract or "",
|
| 57 |
+
"doi": doi or "",
|
| 58 |
+
"pdfUrl": pdf_url or "",
|
| 59 |
+
"source": source,
|
| 60 |
+
"university": university or "",
|
| 61 |
+
"citationCount": citation_count,
|
| 62 |
+
}
|
backend/providers/core_.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_core(query: str, limit: int = 50, api_key: str = "", **kwargs) -> List[dict]:
|
| 7 |
+
if not api_key:
|
| 8 |
+
return []
|
| 9 |
+
try:
|
| 10 |
+
headers = {"Authorization": f"Bearer {api_key}"}
|
| 11 |
+
data = await fetch_json(f"https://api.core.ac.uk/v3/search/works?q={query}&limit={limit}", headers=headers)
|
| 12 |
+
if "error" in data:
|
| 13 |
+
return []
|
| 14 |
+
results = []
|
| 15 |
+
for item in data.get("results", []):
|
| 16 |
+
results.append(normalize_result(
|
| 17 |
+
title=item.get("title", ""),
|
| 18 |
+
authors=[a.get("name", "") for a in item.get("authors", []) if a.get("name")],
|
| 19 |
+
year=item.get("yearPublished"),
|
| 20 |
+
abstract=item.get("abstract", ""),
|
| 21 |
+
doi=item.get("doiExternalIds", {}).get("doi", "") if item.get("doiExternalIds") else "",
|
| 22 |
+
pdf_url=item.get("downloadUrl", ""),
|
| 23 |
+
source="CORE",
|
| 24 |
+
))
|
| 25 |
+
return results[:limit]
|
| 26 |
+
except Exception:
|
| 27 |
+
return []
|
backend/providers/crossref.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from .base import fetch_json, normalize_result
|
| 3 |
+
|
| 4 |
+
SOURCE = "Crossref"
|
| 5 |
+
API_BASE = "https://api.crossref.org/works"
|
| 6 |
+
HEADERS = {"User-Agent": "LetXipuSearch/1.0 (mailto:research@letxipu.org)"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def search_crossref(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 10 |
+
try:
|
| 11 |
+
params = {"query": query, "rows": limit}
|
| 12 |
+
data = await fetch_json(API_BASE, params=params, headers=HEADERS)
|
| 13 |
+
if "error" in data:
|
| 14 |
+
return []
|
| 15 |
+
|
| 16 |
+
results = []
|
| 17 |
+
for item in data.get("message", {}).get("items", []):
|
| 18 |
+
title_list = item.get("title", [])
|
| 19 |
+
title = title_list[0] if title_list else ""
|
| 20 |
+
|
| 21 |
+
authors = []
|
| 22 |
+
for a in item.get("author", []):
|
| 23 |
+
name = f"{a.get('given', '')} {a.get('family', '')}".strip()
|
| 24 |
+
if name:
|
| 25 |
+
authors.append(name)
|
| 26 |
+
|
| 27 |
+
year = None
|
| 28 |
+
dp = item.get("published-print") or item.get("published-online") or item.get("created")
|
| 29 |
+
if dp:
|
| 30 |
+
parts = dp.get("date-parts", [[]])
|
| 31 |
+
if parts and parts[0]:
|
| 32 |
+
year = parts[0][0]
|
| 33 |
+
|
| 34 |
+
abstract = item.get("abstract", "")
|
| 35 |
+
# Crossref wraps abstract in <jats:p> tags
|
| 36 |
+
if abstract.startswith("<jats:p>"):
|
| 37 |
+
import re
|
| 38 |
+
abstract = re.sub(r"<[^>]+>", "", abstract).strip()
|
| 39 |
+
|
| 40 |
+
doi = item.get("DOI", "")
|
| 41 |
+
|
| 42 |
+
pdf_url = ""
|
| 43 |
+
for link in item.get("link", []):
|
| 44 |
+
if "pdf" in link.get("content-type", "").lower():
|
| 45 |
+
pdf_url = link.get("URL", "")
|
| 46 |
+
break
|
| 47 |
+
|
| 48 |
+
results.append(
|
| 49 |
+
normalize_result(
|
| 50 |
+
title=title,
|
| 51 |
+
authors=authors,
|
| 52 |
+
year=year,
|
| 53 |
+
abstract=abstract,
|
| 54 |
+
doi=doi,
|
| 55 |
+
pdf_url=pdf_url,
|
| 56 |
+
source=SOURCE,
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
return results
|
| 60 |
+
except Exception:
|
| 61 |
+
return []
|
backend/providers/dblp.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from .base import fetch_json, normalize_result
|
| 3 |
+
|
| 4 |
+
SOURCE = "DBLP"
|
| 5 |
+
API_BASE = "https://dblp.org/search/publ/api"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def search_dblp(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 9 |
+
try:
|
| 10 |
+
params = {"q": query, "h": limit, "format": "json"}
|
| 11 |
+
data = await fetch_json(API_BASE, params=params)
|
| 12 |
+
if "error" in data:
|
| 13 |
+
return []
|
| 14 |
+
|
| 15 |
+
hits = data.get("result", {}).get("hits", {}).get("hit", [])
|
| 16 |
+
results = []
|
| 17 |
+
|
| 18 |
+
for hit in hits:
|
| 19 |
+
info = hit.get("info", {})
|
| 20 |
+
|
| 21 |
+
title = info.get("title", "")
|
| 22 |
+
# Clean trailing period
|
| 23 |
+
if title.endswith("."):
|
| 24 |
+
title = title[:-1]
|
| 25 |
+
|
| 26 |
+
authors_raw = info.get("authors", {}).get("author", [])
|
| 27 |
+
if isinstance(authors_raw, dict):
|
| 28 |
+
authors_raw = [authors_raw]
|
| 29 |
+
authors = []
|
| 30 |
+
for a in authors_raw:
|
| 31 |
+
if isinstance(a, dict):
|
| 32 |
+
name = a.get("text", "")
|
| 33 |
+
else:
|
| 34 |
+
name = str(a)
|
| 35 |
+
if name:
|
| 36 |
+
authors.append(name)
|
| 37 |
+
|
| 38 |
+
year = None
|
| 39 |
+
year_str = info.get("year")
|
| 40 |
+
if year_str:
|
| 41 |
+
try:
|
| 42 |
+
year = int(year_str)
|
| 43 |
+
except ValueError:
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
abstract = ""
|
| 47 |
+
doi = info.get("doi", "")
|
| 48 |
+
ee = info.get("ee", "")
|
| 49 |
+
pdf_url = ee if ee and ee.endswith(".pdf") else ""
|
| 50 |
+
|
| 51 |
+
results.append(
|
| 52 |
+
normalize_result(
|
| 53 |
+
title=title,
|
| 54 |
+
authors=authors,
|
| 55 |
+
year=year,
|
| 56 |
+
abstract=abstract,
|
| 57 |
+
doi=doi,
|
| 58 |
+
pdf_url=pdf_url,
|
| 59 |
+
source=SOURCE,
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
return results
|
| 63 |
+
except Exception:
|
| 64 |
+
return []
|
backend/providers/doaj.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_doaj(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 7 |
+
try:
|
| 8 |
+
data = await fetch_json(f"https://doaj.org/api/search/articles/{query}?pageSize={limit}")
|
| 9 |
+
if "error" in data:
|
| 10 |
+
return []
|
| 11 |
+
results = []
|
| 12 |
+
for item in data.get("results", []):
|
| 13 |
+
bibjson = item.get("bibjson", {})
|
| 14 |
+
identifiers = bibjson.get("identifier", [])
|
| 15 |
+
doi = ""
|
| 16 |
+
for ident in identifiers:
|
| 17 |
+
if ident.get("type") == "doi":
|
| 18 |
+
doi = ident.get("id", "")
|
| 19 |
+
break
|
| 20 |
+
links = bibjson.get("link", [])
|
| 21 |
+
pdf_url = ""
|
| 22 |
+
for link in links:
|
| 23 |
+
if link.get("type") == "fulltext":
|
| 24 |
+
pdf_url = link.get("url", "")
|
| 25 |
+
break
|
| 26 |
+
authors = bibjson.get("author", [])
|
| 27 |
+
results.append(normalize_result(
|
| 28 |
+
title=bibjson.get("title", ""),
|
| 29 |
+
authors=[a.get("name", "") for a in authors if a.get("name")],
|
| 30 |
+
year=int(bibjson.get("year", "0000")) if bibjson.get("year") else None,
|
| 31 |
+
abstract=bibjson.get("abstract", ""),
|
| 32 |
+
doi=doi,
|
| 33 |
+
pdf_url=pdf_url,
|
| 34 |
+
source="DOAJ",
|
| 35 |
+
))
|
| 36 |
+
return results[:limit]
|
| 37 |
+
except Exception:
|
| 38 |
+
return []
|
backend/providers/latam_repositories.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
def _simplify_query(query: str, max_words: int = 4) -> str:
|
| 6 |
+
stop_words = ["de", "la", "el", "en", "para", "los", "las", "un", "una", "y", "o", "con", "sobre", "su", "como", "estrategia", "mejorar", "estudio", "analisis", "efecto", "influencia"]
|
| 7 |
+
words = [w for w in re.split(r'\W+', query) if w.lower() not in stop_words and len(w) > 2]
|
| 8 |
+
return " ".join(words[:max_words])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def _search_vufind(url: str, source: str, query: str, limit: int, headers: dict = None) -> List[dict]:
|
| 12 |
+
try:
|
| 13 |
+
params = {"lookfor": query, "type": "AllFields", "limit": limit}
|
| 14 |
+
data = await fetch_json(url, params=params, headers=headers)
|
| 15 |
+
if "error" in data:
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
results = []
|
| 19 |
+
for record in data.get("records", []):
|
| 20 |
+
title = record.get("title", "")
|
| 21 |
+
if isinstance(title, list):
|
| 22 |
+
title = title[0] if title else ""
|
| 23 |
+
|
| 24 |
+
authors_raw = record.get("author", [])
|
| 25 |
+
if isinstance(authors_raw, str):
|
| 26 |
+
authors_raw = [authors_raw]
|
| 27 |
+
authors = [a for a in authors_raw if a]
|
| 28 |
+
|
| 29 |
+
year = None
|
| 30 |
+
pub_date = record.get("publishDate") or record.get("date")
|
| 31 |
+
if pub_date:
|
| 32 |
+
try:
|
| 33 |
+
year = int(str(pub_date)[:4])
|
| 34 |
+
except (ValueError, IndexError):
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
abstract = record.get("description", "")
|
| 38 |
+
if isinstance(abstract, list):
|
| 39 |
+
abstract = abstract[0] if abstract else ""
|
| 40 |
+
|
| 41 |
+
doi = record.get("doi", "")
|
| 42 |
+
if isinstance(doi, list):
|
| 43 |
+
doi = doi[0] if doi else ""
|
| 44 |
+
|
| 45 |
+
pdf_url = ""
|
| 46 |
+
for url_entry in record.get("urls", []):
|
| 47 |
+
if isinstance(url_entry, str):
|
| 48 |
+
pdf_url = url_entry
|
| 49 |
+
break
|
| 50 |
+
elif isinstance(url_entry, dict):
|
| 51 |
+
pdf_url = url_entry.get("url", "")
|
| 52 |
+
break
|
| 53 |
+
|
| 54 |
+
results.append(
|
| 55 |
+
normalize_result(
|
| 56 |
+
title=title,
|
| 57 |
+
authors=authors,
|
| 58 |
+
year=year,
|
| 59 |
+
abstract=abstract,
|
| 60 |
+
doi=doi,
|
| 61 |
+
pdf_url=pdf_url,
|
| 62 |
+
source=source,
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
if not results and len(query.split()) > 3:
|
| 66 |
+
simplified = _simplify_query(query)
|
| 67 |
+
if simplified and simplified != query:
|
| 68 |
+
return await _search_vufind(url, source, simplified, limit, headers)
|
| 69 |
+
|
| 70 |
+
return results
|
| 71 |
+
except Exception:
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def search_alicia(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 76 |
+
return await _search_vufind(
|
| 77 |
+
"https://alicia.concytec.gob.pe/vufind/api/v1/search",
|
| 78 |
+
"ALICIA",
|
| 79 |
+
query,
|
| 80 |
+
limit,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def search_la_referencia(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 85 |
+
headers = {
|
| 86 |
+
'Accept': 'application/json',
|
| 87 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
| 88 |
+
'Referer': 'https://www.lareferencia.info/'
|
| 89 |
+
}
|
| 90 |
+
return await _search_vufind(
|
| 91 |
+
"https://www.lareferencia.info/vufind/api/v1/search",
|
| 92 |
+
"La Referencia",
|
| 93 |
+
query,
|
| 94 |
+
limit,
|
| 95 |
+
headers=headers
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def search_bDTD(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 100 |
+
return await _search_vufind(
|
| 101 |
+
"https://bdtd.ibict.br/vufind/api/v1/search",
|
| 102 |
+
"BDTD",
|
| 103 |
+
query,
|
| 104 |
+
limit,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def search_rraae(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 109 |
+
return await _search_vufind(
|
| 110 |
+
"https://rraae.cedia.edu.ec/vufind/api/v1/search",
|
| 111 |
+
"RRAAE",
|
| 112 |
+
query,
|
| 113 |
+
limit,
|
| 114 |
+
)
|
backend/providers/openaire.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_openaire(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 7 |
+
try:
|
| 8 |
+
data = await fetch_json(f"http://api.openaire.eu/search/publications?keywords={query}&format=json&size={limit}")
|
| 9 |
+
if "error" in data:
|
| 10 |
+
return []
|
| 11 |
+
results_data = data.get("response", {}).get("results", {}).get("result", [])
|
| 12 |
+
if isinstance(results_data, dict):
|
| 13 |
+
results_data = [results_data]
|
| 14 |
+
results = []
|
| 15 |
+
for item in results_data:
|
| 16 |
+
entity = item.get("oaf:entity", {})
|
| 17 |
+
result = entity.get("oaf:result", {})
|
| 18 |
+
pid_list = result.get("pid", [])
|
| 19 |
+
if isinstance(pid_list, dict):
|
| 20 |
+
pid_list = [pid_list]
|
| 21 |
+
doi = ""
|
| 22 |
+
for pid in pid_list:
|
| 23 |
+
if pid.get("@class") == "pid" and "doi" in str(pid.get("classname", "")):
|
| 24 |
+
doi = pid.get("$", "")
|
| 25 |
+
break
|
| 26 |
+
results.append(normalize_result(
|
| 27 |
+
title=result.get("title", ""),
|
| 28 |
+
authors=[result.get("creator", {}).get("$", "")] if result.get("creator") else [],
|
| 29 |
+
year=int(result.get("dateofacceptance", "0000")[:4]) if result.get("dateofacceptance") else None,
|
| 30 |
+
abstract=result.get("description", ""),
|
| 31 |
+
doi=doi,
|
| 32 |
+
source="OpenAIRE",
|
| 33 |
+
))
|
| 34 |
+
return results[:limit]
|
| 35 |
+
except Exception:
|
| 36 |
+
return []
|
backend/providers/openalex.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from .base import fetch_json, normalize_result
|
| 3 |
+
|
| 4 |
+
SOURCE = "OpenAlex"
|
| 5 |
+
API_BASE = "https://api.openalex.org/works"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _decode_abstract_inverted_index(index: dict) -> str:
|
| 9 |
+
if not index:
|
| 10 |
+
return ""
|
| 11 |
+
word_positions = []
|
| 12 |
+
for word, positions in index.items():
|
| 13 |
+
for pos in positions:
|
| 14 |
+
word_positions.append((pos, word))
|
| 15 |
+
word_positions.sort(key=lambda x: x[0])
|
| 16 |
+
return " ".join(w for _, w in word_positions)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def search_openalex(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 20 |
+
try:
|
| 21 |
+
params = {"search": query, "per-page": limit}
|
| 22 |
+
data = await fetch_json(API_BASE, params=params)
|
| 23 |
+
if "error" in data:
|
| 24 |
+
return []
|
| 25 |
+
|
| 26 |
+
results = []
|
| 27 |
+
for work in data.get("results", []):
|
| 28 |
+
title = work.get("title", "")
|
| 29 |
+
year = work.get("publication_year")
|
| 30 |
+
|
| 31 |
+
authors = []
|
| 32 |
+
university = ""
|
| 33 |
+
for authorship in work.get("authorships", []):
|
| 34 |
+
name = authorship.get("author", {}).get("display_name", "")
|
| 35 |
+
if name:
|
| 36 |
+
authors.append(name)
|
| 37 |
+
inst = authorship.get("institutions", [])
|
| 38 |
+
if inst and not university:
|
| 39 |
+
university = inst[0].get("display_name", "")
|
| 40 |
+
|
| 41 |
+
abstract = _decode_abstract_inverted_index(
|
| 42 |
+
work.get("abstract_inverted_index")
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
doi = (work.get("doi") or "").replace("https://doi.org/", "")
|
| 46 |
+
|
| 47 |
+
pdf_url = ""
|
| 48 |
+
for loc in work.get("locations", []):
|
| 49 |
+
if loc.get("pdf_url"):
|
| 50 |
+
pdf_url = loc["pdf_url"]
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
citation_count = work.get("cited_by_count")
|
| 54 |
+
|
| 55 |
+
results.append(
|
| 56 |
+
normalize_result(
|
| 57 |
+
title=title,
|
| 58 |
+
authors=authors,
|
| 59 |
+
year=year,
|
| 60 |
+
abstract=abstract,
|
| 61 |
+
doi=doi,
|
| 62 |
+
pdf_url=pdf_url,
|
| 63 |
+
source=SOURCE,
|
| 64 |
+
university=university,
|
| 65 |
+
citation_count=citation_count,
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
return results
|
| 69 |
+
except Exception:
|
| 70 |
+
return []
|
backend/providers/pubmed.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import List
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
SOURCE = "PubMed"
|
| 6 |
+
ESEARCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
|
| 7 |
+
ESUMMARY = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def _fetch_summaries(pmids: List[str]) -> dict:
|
| 11 |
+
if not pmids:
|
| 12 |
+
return {}
|
| 13 |
+
ids_str = ",".join(pmids)
|
| 14 |
+
data = await fetch_json(ESUMMARY, params={"db": "pubmed", "id": ids_str, "retmode": "json"})
|
| 15 |
+
if "error" in data:
|
| 16 |
+
return {}
|
| 17 |
+
return data.get("result", {})
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def search_pubmed(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 21 |
+
try:
|
| 22 |
+
search_data = await fetch_json(
|
| 23 |
+
ESEARCH,
|
| 24 |
+
params={"db": "pubmed", "term": query, "retmode": "json", "retmax": limit},
|
| 25 |
+
)
|
| 26 |
+
if "error" in search_data:
|
| 27 |
+
return []
|
| 28 |
+
|
| 29 |
+
pmids = search_data.get("esearchresult", {}).get("idlist", [])
|
| 30 |
+
if not pmids:
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
summaries = await _fetch_summaries(pmids)
|
| 34 |
+
|
| 35 |
+
results = []
|
| 36 |
+
for pmid in pmids:
|
| 37 |
+
info = summaries.get(pmid, {})
|
| 38 |
+
if not info:
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
title = info.get("title", "")
|
| 42 |
+
|
| 43 |
+
authors = [
|
| 44 |
+
a.get("name", "") for a in info.get("authors", []) if a.get("name")
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
pubdate = info.get("pubdate", "")
|
| 48 |
+
year = None
|
| 49 |
+
if pubdate:
|
| 50 |
+
try:
|
| 51 |
+
year = int(pubdate.split()[0][:4])
|
| 52 |
+
except (ValueError, IndexError):
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
doi = ""
|
| 56 |
+
for aid in info.get("articleids", []):
|
| 57 |
+
if aid.get("idtype") == "doi":
|
| 58 |
+
doi = aid.get("value", "")
|
| 59 |
+
break
|
| 60 |
+
|
| 61 |
+
abstract = ""
|
| 62 |
+
# ESummary doesn't always include abstract; fetch via EFetch if needed
|
| 63 |
+
# For now leave blank to keep single-request
|
| 64 |
+
|
| 65 |
+
pdf_url = ""
|
| 66 |
+
eloc = info.get("elocationid", "")
|
| 67 |
+
if eloc:
|
| 68 |
+
for eid in eloc if isinstance(eloc, list) else [eloc]:
|
| 69 |
+
if isinstance(eid, dict) and eid.get("elocationidtype") == "doi":
|
| 70 |
+
doi = doi or eid.get("elocationid", "").replace("doi: ", "")
|
| 71 |
+
|
| 72 |
+
results.append(
|
| 73 |
+
normalize_result(
|
| 74 |
+
title=title,
|
| 75 |
+
authors=authors,
|
| 76 |
+
year=year,
|
| 77 |
+
abstract=abstract,
|
| 78 |
+
doi=doi,
|
| 79 |
+
pdf_url=pdf_url,
|
| 80 |
+
source=SOURCE,
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
return results
|
| 84 |
+
except Exception:
|
| 85 |
+
return []
|
backend/providers/redalyc.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_redalyc(query: str, limit: int = 30, **kwargs) -> List[dict]:
|
| 7 |
+
try:
|
| 8 |
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 9 |
+
data = await fetch_json(
|
| 10 |
+
f"https://www.redalyc.org/busquedaArticuloFiltros.oa?q={query}&numItems={limit}",
|
| 11 |
+
headers=headers
|
| 12 |
+
)
|
| 13 |
+
if "error" in data:
|
| 14 |
+
return []
|
| 15 |
+
articles = data if isinstance(data, list) else data.get("articles", [])
|
| 16 |
+
results = []
|
| 17 |
+
for a in articles[:limit]:
|
| 18 |
+
results.append(normalize_result(
|
| 19 |
+
title=a.get("titulo", "") or a.get("title", ""),
|
| 20 |
+
authors=[a.get("autores", "")] if a.get("autores") else [],
|
| 21 |
+
year=int(a.get("anio", "0")) if a.get("anio") else None,
|
| 22 |
+
abstract=a.get("resumen", "") or a.get("abstract", ""),
|
| 23 |
+
pdf_url=a.get("urlPdf", "") or a.get("pdfUrl", ""),
|
| 24 |
+
source="Redalyc",
|
| 25 |
+
))
|
| 26 |
+
return results
|
| 27 |
+
except Exception:
|
| 28 |
+
return []
|
backend/providers/scopus.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_scopus(query: str, limit: int = 25, api_key: str = "", **kwargs) -> List[dict]:
|
| 7 |
+
if not api_key:
|
| 8 |
+
return []
|
| 9 |
+
try:
|
| 10 |
+
headers = {"X-ELS-APIKey": api_key, "Accept": "application/json"}
|
| 11 |
+
params = {"query": f"TITLE-ABS-KEY({query})", "count": limit, "sort": "relevance"}
|
| 12 |
+
data = await fetch_json("https://api.elsevier.com/content/search/scopus", params=params, headers=headers)
|
| 13 |
+
if "error" in data:
|
| 14 |
+
return []
|
| 15 |
+
entries = data.get("search-results", {}).get("entry", [])
|
| 16 |
+
results = []
|
| 17 |
+
for e in entries:
|
| 18 |
+
if e.get("error"):
|
| 19 |
+
continue
|
| 20 |
+
results.append(normalize_result(
|
| 21 |
+
title=e.get("dc:title", ""),
|
| 22 |
+
authors=[a.strip() for a in (e.get("dc:creator", "") or "").split(";") if a.strip()],
|
| 23 |
+
year=int(e.get("prism:coverDate", "0000")[:4]) if e.get("prism:coverDate") else None,
|
| 24 |
+
abstract=e.get("dc:description", ""),
|
| 25 |
+
doi=e.get("prism:doi", ""),
|
| 26 |
+
pdf_url="",
|
| 27 |
+
source="Scopus",
|
| 28 |
+
university=e.get("affilname", ""),
|
| 29 |
+
citation_count=int(e.get("citedby-count", 0)) if e.get("citedby-count") else None,
|
| 30 |
+
))
|
| 31 |
+
return results[:limit]
|
| 32 |
+
except Exception:
|
| 33 |
+
return []
|
backend/providers/semantic_scholar.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
SOURCE = "Semantic Scholar"
|
| 6 |
+
API_BASE = "https://api.semanticscholar.org/graph/v1/paper/search"
|
| 7 |
+
FIELDS = "title,authors,year,abstract,openAccessPdf,url,venue,externalIds"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def search_semantic_scholar(query: str, limit: int = 50, **kwargs) -> List[dict]:
|
| 11 |
+
try:
|
| 12 |
+
headers = {}
|
| 13 |
+
api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY", "")
|
| 14 |
+
if api_key:
|
| 15 |
+
headers["x-api-key"] = api_key
|
| 16 |
+
|
| 17 |
+
params = {
|
| 18 |
+
"query": query,
|
| 19 |
+
"limit": limit,
|
| 20 |
+
"fields": FIELDS,
|
| 21 |
+
}
|
| 22 |
+
data = await fetch_json(API_BASE, params=params, headers=headers)
|
| 23 |
+
if "error" in data:
|
| 24 |
+
return []
|
| 25 |
+
|
| 26 |
+
results = []
|
| 27 |
+
for paper in data.get("data", []):
|
| 28 |
+
title = paper.get("title", "")
|
| 29 |
+
year = paper.get("year")
|
| 30 |
+
|
| 31 |
+
authors = [a.get("name", "") for a in paper.get("authors", [])]
|
| 32 |
+
|
| 33 |
+
abstract = paper.get("abstract", "")
|
| 34 |
+
|
| 35 |
+
ext_ids = paper.get("externalIds", {}) or {}
|
| 36 |
+
doi = ext_ids.get("DOI", "")
|
| 37 |
+
|
| 38 |
+
pdf_url = ""
|
| 39 |
+
oap = paper.get("openAccessPdf")
|
| 40 |
+
if oap and isinstance(oap, dict):
|
| 41 |
+
pdf_url = oap.get("url", "")
|
| 42 |
+
|
| 43 |
+
results.append(
|
| 44 |
+
normalize_result(
|
| 45 |
+
title=title,
|
| 46 |
+
authors=authors,
|
| 47 |
+
year=year,
|
| 48 |
+
abstract=abstract,
|
| 49 |
+
doi=doi,
|
| 50 |
+
pdf_url=pdf_url,
|
| 51 |
+
source=SOURCE,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
return results
|
| 55 |
+
except Exception:
|
| 56 |
+
return []
|
backend/providers/serpapi.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import re
|
| 3 |
+
import httpx
|
| 4 |
+
from .base import fetch_json, normalize_result
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def search_serpapi(query: str, limit: int = 20, api_key: str = "", **kwargs) -> List[dict]:
|
| 8 |
+
if not api_key:
|
| 9 |
+
return []
|
| 10 |
+
try:
|
| 11 |
+
params = {"engine": "google_scholar", "q": query, "num": min(limit, 20), "api_key": api_key}
|
| 12 |
+
data = await fetch_json("https://serpapi.com/search.json", params=params)
|
| 13 |
+
if "error" in data:
|
| 14 |
+
return []
|
| 15 |
+
results = []
|
| 16 |
+
for item in data.get("organic_results", []):
|
| 17 |
+
title = item.get("title", "")
|
| 18 |
+
snippet = item.get("snippet", "")
|
| 19 |
+
link = item.get("link", "")
|
| 20 |
+
year = None
|
| 21 |
+
if item.get("publication_info", {}).get("summary"):
|
| 22 |
+
year_match = item["publication_info"]["summary"]
|
| 23 |
+
y = re.search(r'(\d{4})', year_match)
|
| 24 |
+
if y:
|
| 25 |
+
year = int(y.group(1))
|
| 26 |
+
results.append(normalize_result(
|
| 27 |
+
title=title,
|
| 28 |
+
authors=[],
|
| 29 |
+
year=year,
|
| 30 |
+
abstract=snippet,
|
| 31 |
+
pdf_url=link if "pdf" in link.lower() else "",
|
| 32 |
+
source="Google Scholar",
|
| 33 |
+
))
|
| 34 |
+
return results[:limit]
|
| 35 |
+
except Exception:
|
| 36 |
+
return []
|
backend/providers/sources.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SOURCE_GROUPS = {
|
| 2 |
+
"all": ["openalex", "semantic", "pubmed", "arxiv", "crossref", "dblp", "alicia", "lareferencia", "bdtd", "rraae",
|
| 3 |
+
"scopus", "zenodo", "openaire", "doaj", "core", "redalyc", "serpapi"],
|
| 4 |
+
"latam": ["alicia", "lareferencia", "bdtd", "rraae", "redalyc"],
|
| 5 |
+
"global": ["openalex", "semantic", "pubmed", "arxiv", "crossref", "scopus", "zenodo", "openaire", "doaj", "core"],
|
| 6 |
+
"tesis": ["alicia", "lareferencia", "bdtd", "rraae"],
|
| 7 |
+
"iberoamerica": ["alicia", "lareferencia", "bdtd", "rraae", "redalyc"],
|
| 8 |
+
"peru": ["alicia"],
|
| 9 |
+
"brasil": ["bdtd"],
|
| 10 |
+
"ecuador": ["rraae"],
|
| 11 |
+
"ai_ml": ["arxiv", "dblp"],
|
| 12 |
+
"free": ["openalex", "semantic", "pubmed", "arxiv", "crossref", "dblp", "alicia", "lareferencia", "bdtd", "rraae",
|
| 13 |
+
"zenodo", "openaire", "doaj", "redalyc"],
|
| 14 |
+
"premium": ["scopus", "core", "serpapi"],
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
SOURCE_ALIASES = {
|
| 18 |
+
"semanticscholar": "semantic",
|
| 19 |
+
"semantic_scholar": "semantic",
|
| 20 |
+
"semantic-scholar": "semantic",
|
| 21 |
+
"openalex": "openalex",
|
| 22 |
+
"la_referencia": "lareferencia",
|
| 23 |
+
"la-referencia": "lareferencia",
|
| 24 |
+
"bdtdbr": "bdtd",
|
| 25 |
+
"bdtd-br": "bdtd",
|
| 26 |
+
"rraae_ecuador": "rraae",
|
| 27 |
+
"rraae-ecuador": "rraae",
|
| 28 |
+
"recolecta": "lareferencia",
|
| 29 |
+
"kimuk": "lareferencia",
|
| 30 |
+
"timbo": "lareferencia",
|
| 31 |
+
"redicces": "lareferencia",
|
| 32 |
+
"openaire": "openaire",
|
| 33 |
+
"open-air": "openaire",
|
| 34 |
+
"doaj": "doaj",
|
| 35 |
+
"directoryofopenaccessjournals": "doaj",
|
| 36 |
+
"zenodo": "zenodo",
|
| 37 |
+
"core.ac.uk": "core",
|
| 38 |
+
"coreac": "core",
|
| 39 |
+
"redalyc": "redalyc",
|
| 40 |
+
"redalycorg": "redalyc",
|
| 41 |
+
"serpapi": "serpapi",
|
| 42 |
+
"google_scholar": "serpapi",
|
| 43 |
+
"google-scholar": "serpapi",
|
| 44 |
+
"scopus": "scopus",
|
| 45 |
+
}
|
backend/providers/zenodo.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import httpx
|
| 3 |
+
from .base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def search_zenodo(query: str, limit: int = 25, **kwargs) -> List[dict]:
|
| 7 |
+
try:
|
| 8 |
+
data = await fetch_json(f"https://zenodo.org/api/records?q={query}&size={limit}&type=publication")
|
| 9 |
+
if "error" in data:
|
| 10 |
+
return []
|
| 11 |
+
hits = data.get("hits", {}).get("hits", [])
|
| 12 |
+
results = []
|
| 13 |
+
for h in hits:
|
| 14 |
+
md = h.get("metadata", {})
|
| 15 |
+
creators = md.get("creators", [])
|
| 16 |
+
results.append(normalize_result(
|
| 17 |
+
title=md.get("title", ""),
|
| 18 |
+
authors=[c.get("name", "") for c in creators if c.get("name")],
|
| 19 |
+
year=int(md.get("publication_date", "0000")[:4]) if md.get("publication_date") else None,
|
| 20 |
+
abstract=md.get("description", ""),
|
| 21 |
+
doi=h.get("doi", ""),
|
| 22 |
+
pdf_url=h.get("links", {}).get("pdf", "") if h.get("links") else "",
|
| 23 |
+
source="Zenodo",
|
| 24 |
+
))
|
| 25 |
+
return results[:limit]
|
| 26 |
+
except Exception:
|
| 27 |
+
return []
|
backend/smart_fusion.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smart Fusion - Document relevance scoring and ranking
|
| 3 |
+
Fiel al app original Next.js
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
|
| 9 |
+
# Default weights (from original)
|
| 10 |
+
DEFAULT_SESSION_WEIGHT = 1000
|
| 11 |
+
DEFAULT_PREVIOUS_QUERY_WEIGHT = 100
|
| 12 |
+
DEFAULT_TITLE_MATCH_WEIGHT = 50
|
| 13 |
+
DEFAULT_SNIPPET_MATCH_WEIGHT = 10
|
| 14 |
+
DEFAULT_MIN_THRESHOLD = 30
|
| 15 |
+
DEFAULT_TOP_N = 150
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_text(text: str) -> str:
|
| 19 |
+
"""Remove diacritics for cross-language matching."""
|
| 20 |
+
text = text.lower()
|
| 21 |
+
accents = {'á':'a','é':'e','í':'i','ó':'o','ú':'u','ñ':'n','Á':'A','É':'E','Í':'I','Ó':'O','Ú':'U','Ñ':'N'}
|
| 22 |
+
return re.sub(r'[áéíóúñÁÉÍÓÚÑ]', lambda m: accents.get(m.group(), m.group()), text)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_tokens(text: str) -> set:
|
| 26 |
+
"""Extract meaningful tokens from text."""
|
| 27 |
+
text = normalize_text(text)
|
| 28 |
+
tokens = set(re.findall(r'[a-záéíóúñ]{4,}', text))
|
| 29 |
+
stopwords = {'para', 'como', 'más', 'pero', 'desde', 'hasta', 'sobre', 'entre', 'the', 'and', 'with', 'from', 'that', 'this', 'have', 'been', 'were', 'they'}
|
| 30 |
+
return tokens - stopwords
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def score_document(doc: dict, query: str, is_session: bool = True,
|
| 34 |
+
session_weight: int = DEFAULT_SESSION_WEIGHT,
|
| 35 |
+
previous_query_weight: int = DEFAULT_PREVIOUS_QUERY_WEIGHT,
|
| 36 |
+
title_weight: int = DEFAULT_TITLE_MATCH_WEIGHT,
|
| 37 |
+
snippet_weight: int = DEFAULT_SNIPPET_MATCH_WEIGHT) -> int:
|
| 38 |
+
"""Score a document against a query."""
|
| 39 |
+
score = 0
|
| 40 |
+
|
| 41 |
+
if is_session:
|
| 42 |
+
score += session_weight
|
| 43 |
+
|
| 44 |
+
# Title token matching
|
| 45 |
+
title_tokens = get_tokens(doc.get("title", ""))
|
| 46 |
+
query_tokens = get_tokens(query)
|
| 47 |
+
title_matches = len(title_tokens & query_tokens)
|
| 48 |
+
score += title_matches * title_weight
|
| 49 |
+
|
| 50 |
+
# Snippet matching
|
| 51 |
+
snippet_tokens = get_tokens(doc.get("snippet", "") or doc.get("abstract", ""))
|
| 52 |
+
snippet_matches = len(snippet_tokens & query_tokens)
|
| 53 |
+
score += snippet_matches * snippet_weight
|
| 54 |
+
|
| 55 |
+
# Query match in stored queries
|
| 56 |
+
stored_queries = doc.get("metadata", {}).get("queries", []) if doc.get("metadata") else []
|
| 57 |
+
for sq in stored_queries:
|
| 58 |
+
if query.lower() in sq.lower():
|
| 59 |
+
score += previous_query_weight
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
# Non-session docs need minimum matches
|
| 63 |
+
if not is_session and title_matches < 1 and snippet_matches < 4:
|
| 64 |
+
score = 0
|
| 65 |
+
|
| 66 |
+
return score
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def smart_fusion_rank(docs: list, query: str, weights: dict = None) -> list:
|
| 70 |
+
"""Rank and filter documents using smart fusion scoring."""
|
| 71 |
+
w = weights or {}
|
| 72 |
+
|
| 73 |
+
scored = []
|
| 74 |
+
for doc in docs:
|
| 75 |
+
is_session = doc.get("_isSession", True)
|
| 76 |
+
score = score_document(
|
| 77 |
+
doc, query, is_session,
|
| 78 |
+
session_weight=w.get("sessionWeight", DEFAULT_SESSION_WEIGHT),
|
| 79 |
+
previous_query_weight=w.get("previousQueryWeight", DEFAULT_PREVIOUS_QUERY_WEIGHT),
|
| 80 |
+
title_weight=w.get("titleMatchWeight", DEFAULT_TITLE_MATCH_WEIGHT),
|
| 81 |
+
snippet_weight=w.get("snippetMatchWeight", DEFAULT_SNIPPET_MATCH_WEIGHT)
|
| 82 |
+
)
|
| 83 |
+
doc["smartFusionScore"] = score
|
| 84 |
+
scored.append(doc)
|
| 85 |
+
|
| 86 |
+
# Filter by threshold
|
| 87 |
+
threshold = w.get("minThreshold", DEFAULT_MIN_THRESHOLD)
|
| 88 |
+
filtered = [d for d in scored if d["smartFusionScore"] >= threshold or d.get("_isSession")]
|
| 89 |
+
|
| 90 |
+
# Sort by score descending
|
| 91 |
+
filtered.sort(key=lambda x: x["smartFusionScore"], reverse=True)
|
| 92 |
+
|
| 93 |
+
# Cap at top N
|
| 94 |
+
top_n = w.get("topN", DEFAULT_TOP_N)
|
| 95 |
+
return filtered[:top_n]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def merge_with_memory(new_docs: list, stored_records: list, query: str) -> list:
|
| 99 |
+
"""Merge new search results with existing memory records."""
|
| 100 |
+
record_map = {r.get("title", "").lower().strip(): r for r in stored_records}
|
| 101 |
+
title_map = {r.get("title", "").lower().strip(): r for r in stored_records}
|
| 102 |
+
|
| 103 |
+
merged = list(stored_records)
|
| 104 |
+
|
| 105 |
+
for doc in new_docs:
|
| 106 |
+
title_key = doc.get("title", "").lower().strip()
|
| 107 |
+
if title_key in title_map:
|
| 108 |
+
# Update existing record
|
| 109 |
+
existing = title_map[title_key]
|
| 110 |
+
if doc.get("snippet") and not existing.get("snippet"):
|
| 111 |
+
existing["snippet"] = doc["snippet"]
|
| 112 |
+
if doc.get("pdfUrl") and not existing.get("pdfUrl"):
|
| 113 |
+
existing["pdfUrl"] = doc["pdfUrl"]
|
| 114 |
+
else:
|
| 115 |
+
# Add new record
|
| 116 |
+
merged.append(doc)
|
| 117 |
+
title_map[title_key] = doc
|
| 118 |
+
|
| 119 |
+
return merged
|
backend/synthesis.py
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Synthesis Engine - AI-powered research synthesis pipeline
|
| 3 |
+
Enhanced with hierarchical synthesis, GRADE classification, gap detection, and rescue search.
|
| 4 |
+
Faithful to the original Next.js research-agent prompts.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import httpx
|
| 9 |
+
import re
|
| 10 |
+
from typing import Dict, Any, List, Optional
|
| 11 |
+
from .prompts.profiles import AGENT_PROFILES
|
| 12 |
+
from .prompts.synthesis import (
|
| 13 |
+
MASTER_SYNTHESIS_PROMPT,
|
| 14 |
+
WRITING_PROMPT,
|
| 15 |
+
VALIDATION_PROMPT,
|
| 16 |
+
AUDIT_PROMPT,
|
| 17 |
+
ARA_PROMPT,
|
| 18 |
+
)
|
| 19 |
+
from .prompts.planning import SEARCH_PLANNING_PROMPT, GAP_DETECTION_PROMPT
|
| 20 |
+
from .utils import robust_json_parse, extract_research_plan
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Provider configs
|
| 24 |
+
PROVIDERS = {
|
| 25 |
+
"groq": {
|
| 26 |
+
"base_url": "https://api.groq.com/openai/v1",
|
| 27 |
+
"env_key": "GROQ_API_KEY",
|
| 28 |
+
"models": [
|
| 29 |
+
"llama-3.3-70b-versatile",
|
| 30 |
+
"llama-3.1-8b-instant",
|
| 31 |
+
"deepseek-r1-distill-llama-70b",
|
| 32 |
+
"mixtral-8x7b-32768",
|
| 33 |
+
"gemma2-9b-it",
|
| 34 |
+
"llama3-70b-8192",
|
| 35 |
+
"llama3-8b-8192",
|
| 36 |
+
"llama-guard-3-8b",
|
| 37 |
+
],
|
| 38 |
+
},
|
| 39 |
+
"openrouter": {
|
| 40 |
+
"base_url": "https://openrouter.ai/api/v1",
|
| 41 |
+
"env_key": "OPENROUTER_API_KEY",
|
| 42 |
+
"models": [
|
| 43 |
+
"meta-llama/llama-3.3-70b-instruct:free",
|
| 44 |
+
"google/gemma-4-26b-a4b-it:free",
|
| 45 |
+
"google/gemma-4-31b-it:free",
|
| 46 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 47 |
+
"deepseek/deepseek-v4-flash:free",
|
| 48 |
+
"deepseek/deepseek-r1-0528:free",
|
| 49 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 50 |
+
"minimax/minimax-m2.5:free",
|
| 51 |
+
"openai/gpt-oss-120b:free",
|
| 52 |
+
"openai/gpt-oss-20b:free",
|
| 53 |
+
"arcee-ai/trinity-large-thinking:free",
|
| 54 |
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
| 55 |
+
"google/gemma-3-27b-it:free",
|
| 56 |
+
"google/gemma-3-12b-it:free",
|
| 57 |
+
"qwen/qwen3-coder:free",
|
| 58 |
+
"stepfun/step-3.5-flash:free",
|
| 59 |
+
"z-ai/glm-4.5-air:free",
|
| 60 |
+
"anthropic/claude-sonnet-4.5",
|
| 61 |
+
"anthropic/claude-haiku-4.5",
|
| 62 |
+
"openai/gpt-5.4",
|
| 63 |
+
"openai/gpt-5.4-mini",
|
| 64 |
+
"openai/gpt-5",
|
| 65 |
+
"deepseek/deepseek-v4-pro",
|
| 66 |
+
"deepseek/deepseek-v3.2",
|
| 67 |
+
"qwen/qwen3.6-flash",
|
| 68 |
+
"qwen/qwen3.5-plus-20260420",
|
| 69 |
+
"mistralai/mistral-small-2603",
|
| 70 |
+
"mistralai/mistral-medium-3-5",
|
| 71 |
+
],
|
| 72 |
+
},
|
| 73 |
+
"mistral": {
|
| 74 |
+
"base_url": "https://api.mistral.ai/v1",
|
| 75 |
+
"env_key": "MISTRAL_API_KEY",
|
| 76 |
+
"models": [
|
| 77 |
+
"mistral-small-2506",
|
| 78 |
+
"mistral-small-2603",
|
| 79 |
+
"mistral-medium-2508",
|
| 80 |
+
"mistral-medium-3-5",
|
| 81 |
+
"mistral-large-2512",
|
| 82 |
+
"magistral-medium-2509",
|
| 83 |
+
"magistral-small-2509",
|
| 84 |
+
"ministral-3b-2512",
|
| 85 |
+
"ministral-8b-2512",
|
| 86 |
+
"ministral-14b-2512",
|
| 87 |
+
"codestral-2508",
|
| 88 |
+
"devstral-2512",
|
| 89 |
+
"open-mistral-nemo",
|
| 90 |
+
],
|
| 91 |
+
},
|
| 92 |
+
"gemini": {
|
| 93 |
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
| 94 |
+
"env_key": "GEMINI_API_KEY",
|
| 95 |
+
"models": [
|
| 96 |
+
"gemini-2.5-flash",
|
| 97 |
+
"gemini-2.5-pro",
|
| 98 |
+
"gemini-2.0-flash",
|
| 99 |
+
"gemini-2.0-flash-lite",
|
| 100 |
+
"gemini-3-flash-preview",
|
| 101 |
+
"gemini-3-pro-preview",
|
| 102 |
+
"gemini-3.1-flash-lite",
|
| 103 |
+
"gemma-4-26b-a4b-it",
|
| 104 |
+
"gemma-4-31b-it",
|
| 105 |
+
],
|
| 106 |
+
},
|
| 107 |
+
"deepseek": {
|
| 108 |
+
"base_url": "https://api.deepseek.com/v1",
|
| 109 |
+
"env_key": "DEEPSEEK_API_KEY",
|
| 110 |
+
"models": [
|
| 111 |
+
"deepseek-chat",
|
| 112 |
+
"deepseek-reasoner",
|
| 113 |
+
"deepseek-v4-flash",
|
| 114 |
+
"deepseek-v4-pro",
|
| 115 |
+
],
|
| 116 |
+
},
|
| 117 |
+
"nebius": {
|
| 118 |
+
"base_url": "https://api.tokenfactory.nebius.com/v1",
|
| 119 |
+
"env_key": "NEBIUS_API_KEY",
|
| 120 |
+
"models": [
|
| 121 |
+
"deepseek-ai/DeepSeek-V3.2",
|
| 122 |
+
"deepseek-ai/DeepSeek-V4-Pro",
|
| 123 |
+
"meta-llama/Llama-3.3-70B-Instruct",
|
| 124 |
+
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
| 125 |
+
"Qwen/Qwen3-32B",
|
| 126 |
+
"Qwen/Qwen3.5-397B-A17B",
|
| 127 |
+
"nvidia/Llama-3_1-Nemotron-Ultra-253B-v1",
|
| 128 |
+
"google/gemma-3-27b-it",
|
| 129 |
+
"NousResearch/Hermes-4-405B",
|
| 130 |
+
"moonshotai/Kimi-K2.5",
|
| 131 |
+
"MiniMaxAI/MiniMax-M2.5",
|
| 132 |
+
],
|
| 133 |
+
},
|
| 134 |
+
"azure": {
|
| 135 |
+
"base_url": "https://letxinet.openai.azure.com/openai/deployments",
|
| 136 |
+
"env_key": "AZURE_API_KEY",
|
| 137 |
+
"models": [
|
| 138 |
+
"gpt-4o-mini",
|
| 139 |
+
"gpt-4o",
|
| 140 |
+
"o3-mini",
|
| 141 |
+
"o4-mini",
|
| 142 |
+
"gpt-4.1-mini",
|
| 143 |
+
],
|
| 144 |
+
},
|
| 145 |
+
"huggingface": {
|
| 146 |
+
"base_url": "https://api-inference.huggingface.co/v1",
|
| 147 |
+
"env_key": "HF_TOKEN",
|
| 148 |
+
"models": [
|
| 149 |
+
"deepseek-ai/DeepSeek-V3.2",
|
| 150 |
+
"deepseek-ai/DeepSeek-R1",
|
| 151 |
+
"meta-llama/Llama-3.3-70B-Instruct",
|
| 152 |
+
"meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
| 153 |
+
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
| 154 |
+
"Qwen/Qwen3-Next-80B-A3B-Instruct",
|
| 155 |
+
"google/gemma-3-27b-it",
|
| 156 |
+
"MiniMaxAI/MiniMax-M2.1",
|
| 157 |
+
"moonshotai/Kimi-K2.5",
|
| 158 |
+
],
|
| 159 |
+
},
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
GRADE_LEVELS = {
|
| 163 |
+
"1a": {"label": "Meta-análisis", "weight": 10, "desc": "Revisión sistemática cuantitativa con pooling estadístico"},
|
| 164 |
+
"1b": {"label": "Revisión sistemática", "weight": 9, "desc": "Búsqueda exhaustiva y replicable con criterios de inclusión/exclusión"},
|
| 165 |
+
"2a": {"label": "Ensayo controlado aleatorizado", "weight": 8, "desc": "Experimento con aleatorización y grupo control"},
|
| 166 |
+
"2b": {"label": "Ensayo cuasi-experimental", "weight": 7, "desc": "Experimento sin aleatorización completa"},
|
| 167 |
+
"3a": {"label": "Estudio de cohorte", "weight": 6, "desc": "Seguimiento longitudinal de grupos expuestos/no expuestos"},
|
| 168 |
+
"3b": {"label": "Estudio caso-control", "weight": 5, "desc": "Comparación retrospectiva de casos y controles"},
|
| 169 |
+
"4": {"label": "Corte transversal", "weight": 4, "desc": "Medición en un punto único del tiempo"},
|
| 170 |
+
"5": {"label": "Serie de casos", "weight": 3, "desc": "Descripción de grupos sin grupo control"},
|
| 171 |
+
"6": {"label": "Opinión de expertos", "weight": 2, "desc": "Juicio clínico o consenso de especialistas"},
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
OXFORD_LEVELS = {
|
| 175 |
+
"1a": {"label": "RS de ensayos aleatorizados", "weight": 10, "desc": "Revisión Sistemática de RCTs"},
|
| 176 |
+
"1b": {"label": "Ensayo controlado aleatorizado", "weight": 9, "desc": "RCT individual con intervalo de confianza estrecho"},
|
| 177 |
+
"1c": {"label": "Todo o nada", "weight": 8, "desc": "Todos los pacientes murieron antes que estuviera disponible el tratamiento, y ahora algunos sobreviven; o cuando algunos pacientes morían antes de que estuviera disponible el tratamiento, y ahora ninguno muere"},
|
| 178 |
+
"2a": {"label": "RS de estudios de cohorte", "weight": 7, "desc": "Revisión Sistemática de estudios de cohorte"},
|
| 179 |
+
"2b": {"label": "Estudio de cohorte", "weight": 6, "desc": "Estudio de cohorte individual o RCT de baja calidad"},
|
| 180 |
+
"2c": {"label": "Investigación de resultados", "weight": 5, "desc": "Investigación de resultados, estudios ecológicos"},
|
| 181 |
+
"3a": {"label": "RS de estudios caso-control", "weight": 4, "desc": "Revisión Sistemática de estudios caso-control"},
|
| 182 |
+
"3b": {"label": "Estudio caso-control", "weight": 3, "desc": "Estudio caso-control individual"},
|
| 183 |
+
"4": {"label": "Serie de casos", "weight": 2, "desc": "Serie de casos, o estudios de cohorte o de caso-control de baja calidad"},
|
| 184 |
+
"5": {"label": "Opinión de expertos", "weight": 1, "desc": "Opinión de expertos sin evaluación crítica explícita"},
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
ORIGINAL_GRADE_LEVELS = {
|
| 188 |
+
"ALTA": {
|
| 189 |
+
"label": "ALTA",
|
| 190 |
+
"weight": 4,
|
| 191 |
+
"desc": "Meta-analisis, revisiones sistematicas o ensayos controlados aleatorizados.",
|
| 192 |
+
},
|
| 193 |
+
"MODERADA": {
|
| 194 |
+
"label": "MODERADA",
|
| 195 |
+
"weight": 3,
|
| 196 |
+
"desc": "Ensayos clinicos, estudios experimentales controlados, cohortes o casos y controles bien disenados.",
|
| 197 |
+
},
|
| 198 |
+
"BAJA": {
|
| 199 |
+
"label": "BAJA",
|
| 200 |
+
"weight": 2,
|
| 201 |
+
"desc": "Estudios observacionales, descriptivos o transversales.",
|
| 202 |
+
},
|
| 203 |
+
"MUY BAJA": {
|
| 204 |
+
"label": "MUY BAJA",
|
| 205 |
+
"weight": 1,
|
| 206 |
+
"desc": "Reportes de caso, opiniones, editoriales o evidencia no revisada.",
|
| 207 |
+
},
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
ORIGINAL_GRADE_ALIASES = {
|
| 211 |
+
"ALTO": "ALTA",
|
| 212 |
+
"HIGH": "ALTA",
|
| 213 |
+
"ALTA": "ALTA",
|
| 214 |
+
"MODERADO": "MODERADA",
|
| 215 |
+
"MODERADA": "MODERADA",
|
| 216 |
+
"MODERATE": "MODERADA",
|
| 217 |
+
"MEDIUM": "MODERADA",
|
| 218 |
+
"BAJO": "BAJA",
|
| 219 |
+
"BAJA": "BAJA",
|
| 220 |
+
"LOW": "BAJA",
|
| 221 |
+
"MUY BAJO": "MUY BAJA",
|
| 222 |
+
"MUY BAJA": "MUY BAJA",
|
| 223 |
+
"VERY LOW": "MUY BAJA",
|
| 224 |
+
"VERY_LOW": "MUY BAJA",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def normalize_original_grade_level(level: Any) -> str:
|
| 229 |
+
"""Normalize original beta GRADE labels to ALTA/MODERADA/BAJA/MUY BAJA."""
|
| 230 |
+
raw = str(level or "").strip().upper().replace("_", " ")
|
| 231 |
+
raw = re.sub(r"\s+", " ", raw)
|
| 232 |
+
return ORIGINAL_GRADE_ALIASES.get(raw, "BAJA")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def classify_grade_original(study_type: str) -> str:
|
| 236 |
+
"""Fast fallback that maps study design keywords to the original beta GRADE labels."""
|
| 237 |
+
numeric = classify_grade(study_type)
|
| 238 |
+
if numeric in {"1a", "1b", "2a"}:
|
| 239 |
+
return "ALTA"
|
| 240 |
+
if numeric in {"2b", "3a", "3b"}:
|
| 241 |
+
return "MODERADA"
|
| 242 |
+
if numeric in {"4", "5"}:
|
| 243 |
+
return "BAJA"
|
| 244 |
+
return "MUY BAJA"
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def def_document_has_grade(doc: Dict[str, Any]) -> bool:
|
| 248 |
+
return bool(doc.get("grade_level") or doc.get("evidenceLevel"))
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def classify_grade_oxford(study_type: str) -> str:
|
| 252 |
+
"""Classify a study type string into Oxford CEBM evidence level."""
|
| 253 |
+
t = study_type.lower()
|
| 254 |
+
if "revisión sistemática" in t and ("aleatorizado" in t or "rct" in t):
|
| 255 |
+
return "1a"
|
| 256 |
+
if "meta-análisis" in t or "meta-analisis" in t or "meta analysis" in t:
|
| 257 |
+
return "1a"
|
| 258 |
+
if "ensayo" in t and ("aleatorizado" in t or "randomized" in t or "rct" in t):
|
| 259 |
+
return "1b"
|
| 260 |
+
if "revisión sistemática" in t and ("cohorte" in t or "cohort" in t):
|
| 261 |
+
return "2a"
|
| 262 |
+
if "cohorte" in t or "cohort" in t or "longitudinal" in t:
|
| 263 |
+
return "2b"
|
| 264 |
+
if "ecológico" in t or "ecological" in t:
|
| 265 |
+
return "2c"
|
| 266 |
+
if "revisión sistemática" in t and ("caso-control" in t or "case-control" in t):
|
| 267 |
+
return "3a"
|
| 268 |
+
if "caso-control" in t or "case-control" in t:
|
| 269 |
+
return "3b"
|
| 270 |
+
if "serie de casos" in t or "case series" in t or "transversal" in t or "cross-sectional" in t or "encuesta" in t:
|
| 271 |
+
return "4"
|
| 272 |
+
if "experto" in t or "opinión" in t or "expert" in t:
|
| 273 |
+
return "5"
|
| 274 |
+
return "4" # Default
|
| 275 |
+
|
| 276 |
+
def classify_grade(study_type: str) -> str:
|
| 277 |
+
"""Classify a study type string into GRADE evidence level."""
|
| 278 |
+
t = study_type.lower()
|
| 279 |
+
if "meta-análisis" in t or "meta-analisis" in t or "meta analysis" in t:
|
| 280 |
+
return "1a"
|
| 281 |
+
if "revisión sistemática" in t or "revision sistematica" in t or "systematic review" in t:
|
| 282 |
+
return "1b"
|
| 283 |
+
if "ensayo" in t and ("aleatorizado" in t or "randomized" in t or "rct" in t):
|
| 284 |
+
return "2a"
|
| 285 |
+
if "ensayo" in t or "quasi" in t or "quasi-experimental" in t:
|
| 286 |
+
return "2b"
|
| 287 |
+
if "cohorte" in t or "cohort" in t or "longitudinal" in t:
|
| 288 |
+
return "3a"
|
| 289 |
+
if "caso-control" in t or "case-control" in t:
|
| 290 |
+
return "3b"
|
| 291 |
+
if "transversal" in t or "cross-sectional" in t or "encuesta" in t:
|
| 292 |
+
return "4"
|
| 293 |
+
if "serie de casos" in t or "case series" in t:
|
| 294 |
+
return "5"
|
| 295 |
+
if "experto" in t or "opinión" in t or "expert" in t:
|
| 296 |
+
return "6"
|
| 297 |
+
return "4"
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def grade_label(level: str) -> str:
|
| 301 |
+
entry = GRADE_LEVELS.get(level, GRADE_LEVELS["4"])
|
| 302 |
+
return f"[{level.upper()}] {entry['label']}"
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def grade_weight(level: str) -> int:
|
| 306 |
+
return GRADE_LEVELS.get(level, GRADE_LEVELS["4"])["weight"]
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
class SynthesisEngine:
|
| 310 |
+
def __init__(self, provider: str = "mistral", model: str = None, api_key: str = None,
|
| 311 |
+
search_model: str = None, translation_model: str = None):
|
| 312 |
+
config = PROVIDERS.get(provider, PROVIDERS["mistral"])
|
| 313 |
+
self.base_url = config["base_url"]
|
| 314 |
+
self.model = model or "mistral-small-2506"
|
| 315 |
+
self.search_model = search_model or self.model
|
| 316 |
+
self.translation_model = translation_model or self.model
|
| 317 |
+
self.api_key = api_key or ""
|
| 318 |
+
self.client = httpx.AsyncClient(timeout=180.0)
|
| 319 |
+
|
| 320 |
+
async def _call_llm(self, system_prompt: str, user_prompt: str, temperature: float = 0.0, role: str = "synthesis") -> str:
|
| 321 |
+
# Select model based on role
|
| 322 |
+
model_map = {
|
| 323 |
+
"search": self.search_model,
|
| 324 |
+
"synthesis": self.model,
|
| 325 |
+
"translation": self.translation_model,
|
| 326 |
+
}
|
| 327 |
+
active_model = model_map.get(role, self.model)
|
| 328 |
+
|
| 329 |
+
headers = {
|
| 330 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 331 |
+
"Content-Type": "application/json",
|
| 332 |
+
}
|
| 333 |
+
payload = {
|
| 334 |
+
"model": active_model,
|
| 335 |
+
"messages": [
|
| 336 |
+
{"role": "system", "content": system_prompt},
|
| 337 |
+
{"role": "user", "content": user_prompt},
|
| 338 |
+
],
|
| 339 |
+
"temperature": temperature,
|
| 340 |
+
"max_tokens": 8192,
|
| 341 |
+
}
|
| 342 |
+
# Clamp max_tokens based on model capabilities
|
| 343 |
+
MODEL_MAX_TOKENS = {
|
| 344 |
+
"mistral": 8192, "groq": 32768, "openrouter": 8192,
|
| 345 |
+
"gemini": 65536, "deepseek": 8192, "nebius": 8192,
|
| 346 |
+
"azure": 16384, "huggingface": 8192,
|
| 347 |
+
}
|
| 348 |
+
provider_key = self.base_url.split("//")[-1].split(".")[0] if "//" in self.base_url else "default"
|
| 349 |
+
max_allowed = MODEL_MAX_TOKENS.get(provider_key, 8192)
|
| 350 |
+
# Special cap for DeepSeek models
|
| 351 |
+
if "deepseek" in active_model.lower():
|
| 352 |
+
max_allowed = min(max_allowed, 8192)
|
| 353 |
+
requested_tokens = min(payload.get("max_tokens", 8192), max_allowed)
|
| 354 |
+
payload["max_tokens"] = requested_tokens
|
| 355 |
+
try:
|
| 356 |
+
r = await self.client.post(
|
| 357 |
+
f"{self.base_url}/chat/completions", json=payload, headers=headers
|
| 358 |
+
)
|
| 359 |
+
r.raise_for_status()
|
| 360 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 361 |
+
except Exception as e:
|
| 362 |
+
raise RuntimeError(f"Error calling LLM: {str(e)}") from e
|
| 363 |
+
|
| 364 |
+
def _parse_json(self, text: str) -> dict:
|
| 365 |
+
result = robust_json_parse(text)
|
| 366 |
+
if result is not None:
|
| 367 |
+
return result
|
| 368 |
+
return {"error": "Could not parse JSON", "raw": text[:500]}
|
| 369 |
+
|
| 370 |
+
# ── Core pipeline phases (faithful to original prompts) ──────────────
|
| 371 |
+
|
| 372 |
+
async def orchestrate(self, query: str) -> dict:
|
| 373 |
+
"""Phase 1: Analyze query and extract variables."""
|
| 374 |
+
system = "Eres un orquestador de investigación académica. Analiza la consulta y extrae variables."
|
| 375 |
+
user = f"""Analiza esta consulta de investigación y extrae:
|
| 376 |
+
1. Sujeto de estudio
|
| 377 |
+
2. Variable Independiente (V.I.) con dimensiones
|
| 378 |
+
3. Variable Dependiente (V.D.) con dimensiones
|
| 379 |
+
4. Tipo de estudio sugerido
|
| 380 |
+
5. País/Contexto geográfico
|
| 381 |
+
6. Keywords en español e inglés
|
| 382 |
+
|
| 383 |
+
CONSULTA: "{query}"
|
| 384 |
+
|
| 385 |
+
RESPONDE EN JSON:
|
| 386 |
+
{{
|
| 387 |
+
"subject": "...",
|
| 388 |
+
"variable_independiente": {{"nombre": "...", "dimensiones": [...], "indicadores": [...]}},
|
| 389 |
+
"variable_dependiente": {{"nombre": "...", "dimensiones": [...], "indicadores": [...]}},
|
| 390 |
+
"tipo_estudio": "...",
|
| 391 |
+
"country": "...",
|
| 392 |
+
"keywords_es": [...],
|
| 393 |
+
"keywords_en": [...]
|
| 394 |
+
}}"""
|
| 395 |
+
response = await self._call_llm(system, user, role="search")
|
| 396 |
+
return self._parse_json(response)
|
| 397 |
+
|
| 398 |
+
async def plan_search(self, query: str, profile: str = "general", orchestrator_ctx: dict = None) -> dict:
|
| 399 |
+
"""Phase 2: Plan search queries."""
|
| 400 |
+
profile_data = AGENT_PROFILES.get(profile, AGENT_PROFILES["general"])
|
| 401 |
+
system = f"Eres un estratega de búsqueda académica. {profile_data['title']}."
|
| 402 |
+
user = SEARCH_PLANNING_PROMPT.format(query=query, agent_role=profile)
|
| 403 |
+
response = await self._call_llm(system, user, role="search")
|
| 404 |
+
return self._parse_json(response)
|
| 405 |
+
|
| 406 |
+
async def generate_master_plan(
|
| 407 |
+
self,
|
| 408 |
+
query: str,
|
| 409 |
+
docs_context: str,
|
| 410 |
+
profile: str = "general",
|
| 411 |
+
template_structure: str = None,
|
| 412 |
+
geo_context: str = "Automático",
|
| 413 |
+
) -> dict:
|
| 414 |
+
"""Phase 3: Generate master synthesis plan (linear path)."""
|
| 415 |
+
profile_data = AGENT_PROFILES.get(profile, AGENT_PROFILES["general"])
|
| 416 |
+
system = f"Eres un {profile_data['title']}. Genera un plan maestro de investigación. Contexto Geográfico: {geo_context}"
|
| 417 |
+
user = MASTER_SYNTHESIS_PROMPT.format(
|
| 418 |
+
query=query,
|
| 419 |
+
agent_title=profile_data["title"],
|
| 420 |
+
agent_title_upper=profile_data["title"].upper(),
|
| 421 |
+
profile_instruction=profile_data["instruction"],
|
| 422 |
+
template_structure=template_structure or "Genera la estructura que consideres adecuada.",
|
| 423 |
+
)
|
| 424 |
+
user += f"\n\nCONTEXTO GEOGRÁFICO ASIGNADO: {geo_context}"
|
| 425 |
+
user += f"\n\nDOCUMENTOS ENCONTRADOS:\n{docs_context}"
|
| 426 |
+
response = await self._call_llm(system, user, temperature=0.0, role="synthesis")
|
| 427 |
+
return extract_research_plan(response)
|
| 428 |
+
|
| 429 |
+
async def write_section(self, section_name: str, section_prompt: str, context_text: str, geo_context: str = "Automático") -> str:
|
| 430 |
+
"""Phase 4: Write individual section content."""
|
| 431 |
+
system = f"Eres un Redactor Científico Experto. Contexto Geográfico a priorizar: {geo_context}"
|
| 432 |
+
user = WRITING_PROMPT.replace("{section}", section_name).replace("{section_prompt}", section_prompt).replace("{context_text}", context_text)
|
| 433 |
+
user += f"\n\nCONTEXTO GEOGRÁFICO A PRIORIZAR: {geo_context}"
|
| 434 |
+
return await self._call_llm(system, user, temperature=0.0, role="synthesis")
|
| 435 |
+
|
| 436 |
+
async def validate_citations(self, docs_context: str, content: str) -> dict:
|
| 437 |
+
"""Phase 5a: Validate citations."""
|
| 438 |
+
system = "Eres un Agente de Validación Bibliográfica ESTRICTO."
|
| 439 |
+
user = VALIDATION_PROMPT.replace("{docs_context}", docs_context).replace("{content_to_validate}", content)
|
| 440 |
+
response = await self._call_llm(system, user, temperature=0.0, role="synthesis")
|
| 441 |
+
return self._parse_json(response)
|
| 442 |
+
|
| 443 |
+
async def audit_content(self, docs_context: str, content: str) -> dict:
|
| 444 |
+
"""Phase 5b: Audit content quality."""
|
| 445 |
+
system = "Eres un Auditor Técnico de Calidad Académica."
|
| 446 |
+
user = AUDIT_PROMPT.replace("{docs_context}", docs_context).replace("{content_to_audit}", content)
|
| 447 |
+
response = await self._call_llm(system, user, temperature=0.0, role="synthesis")
|
| 448 |
+
return self._parse_json(response)
|
| 449 |
+
|
| 450 |
+
async def refine_section(self, section_content: str, findings: str) -> str:
|
| 451 |
+
"""Phase 5c: Refine section with ARA+."""
|
| 452 |
+
system = "Eres el Agente de Refinamiento Académico Avanzado (ARA+)."
|
| 453 |
+
user = ARA_PROMPT.replace("{section_content}", section_content).replace("{section_findings}", findings)
|
| 454 |
+
return await self._call_llm(system, user, temperature=0.0, role="synthesis")
|
| 455 |
+
|
| 456 |
+
async def detect_gaps(self, query: str, plan_sections: list) -> dict:
|
| 457 |
+
"""Detect gaps in the research plan."""
|
| 458 |
+
system = "Eres un Auditor de Cobertura Científica."
|
| 459 |
+
sections_json = json.dumps(plan_sections)
|
| 460 |
+
user = GAP_DETECTION_PROMPT.replace("{query}", query).replace("{plan_sections}", sections_json)
|
| 461 |
+
response = await self._call_llm(system, user, temperature=0.0, role="search")
|
| 462 |
+
return self._parse_json(response)
|
| 463 |
+
|
| 464 |
+
# ── GRADE evidence classification ───────────────────────────────────
|
| 465 |
+
|
| 466 |
+
def _enrich_with_grade(
|
| 467 |
+
self,
|
| 468 |
+
doc: Dict[str, Any],
|
| 469 |
+
level: str,
|
| 470 |
+
system: str = "grade",
|
| 471 |
+
evidence_type: str = "",
|
| 472 |
+
justification: str = "",
|
| 473 |
+
) -> Dict[str, Any]:
|
| 474 |
+
"""Helper to attach grade metadata to a document based on system ('grade' or 'oxford')."""
|
| 475 |
+
if system == "original":
|
| 476 |
+
normalized = normalize_original_grade_level(level)
|
| 477 |
+
entry = ORIGINAL_GRADE_LEVELS[normalized]
|
| 478 |
+
return {
|
| 479 |
+
**doc,
|
| 480 |
+
"grade_level": normalized,
|
| 481 |
+
"grade_label": entry["label"],
|
| 482 |
+
"grade_weight": entry["weight"],
|
| 483 |
+
"grade_desc": entry["desc"],
|
| 484 |
+
"grade_system": "original",
|
| 485 |
+
"evidenceLevel": entry["label"],
|
| 486 |
+
"type": evidence_type or doc.get("type") or doc.get("study_type") or "",
|
| 487 |
+
"grade_justification": justification or doc.get("grade_justification", ""),
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
if system == "oxford":
|
| 491 |
+
entry = OXFORD_LEVELS.get(level, OXFORD_LEVELS["4"])
|
| 492 |
+
label = f"[{level.upper()}] {entry['label']}"
|
| 493 |
+
weight = entry["weight"]
|
| 494 |
+
desc = entry["desc"]
|
| 495 |
+
else:
|
| 496 |
+
entry = GRADE_LEVELS.get(level, GRADE_LEVELS["4"])
|
| 497 |
+
label = f"[{level.upper()}] {entry['label']}"
|
| 498 |
+
weight = entry["weight"]
|
| 499 |
+
desc = entry["desc"]
|
| 500 |
+
|
| 501 |
+
return {
|
| 502 |
+
**doc,
|
| 503 |
+
"grade_level": level,
|
| 504 |
+
"grade_label": label,
|
| 505 |
+
"grade_weight": weight,
|
| 506 |
+
"grade_desc": desc,
|
| 507 |
+
"grade_system": system,
|
| 508 |
+
"evidenceLevel": doc.get("evidenceLevel") or label,
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
def _extract_grade_classifications(self, parsed: Any) -> List[Dict[str, Any]]:
|
| 512 |
+
"""Recover classifications from the original beta response shape and common variants."""
|
| 513 |
+
if isinstance(parsed, list):
|
| 514 |
+
return [x for x in parsed if isinstance(x, dict)]
|
| 515 |
+
if not isinstance(parsed, dict):
|
| 516 |
+
return []
|
| 517 |
+
|
| 518 |
+
for key in (
|
| 519 |
+
"classifications",
|
| 520 |
+
"grades",
|
| 521 |
+
"results",
|
| 522 |
+
"documents",
|
| 523 |
+
"analysis",
|
| 524 |
+
"items",
|
| 525 |
+
"data",
|
| 526 |
+
"evaluations",
|
| 527 |
+
"plan",
|
| 528 |
+
):
|
| 529 |
+
value = parsed.get(key)
|
| 530 |
+
if isinstance(value, list):
|
| 531 |
+
return [x for x in value if isinstance(x, dict)]
|
| 532 |
+
if isinstance(value, dict):
|
| 533 |
+
return [value]
|
| 534 |
+
|
| 535 |
+
if any(k in parsed for k in ("index", "level", "type")):
|
| 536 |
+
return [parsed]
|
| 537 |
+
return []
|
| 538 |
+
|
| 539 |
+
def _grade_docs_context(self, documents: List[Dict[str, Any]], limit: int) -> str:
|
| 540 |
+
lines = []
|
| 541 |
+
for i, doc in enumerate(documents[:limit], 1):
|
| 542 |
+
authors = doc.get("authors", [])
|
| 543 |
+
if isinstance(authors, list):
|
| 544 |
+
authors = ", ".join(str(a) for a in authors if a)
|
| 545 |
+
snippet = doc.get("abstract") or doc.get("snippet") or doc.get("summary") or ""
|
| 546 |
+
lines.append(
|
| 547 |
+
f"[{i}] Titulo: {doc.get('title', 'Sin titulo')} | "
|
| 548 |
+
f"Autores: {authors or 'No especificados'} | "
|
| 549 |
+
f"Resumen: {str(snippet)[:500]}"
|
| 550 |
+
)
|
| 551 |
+
return "\n\n".join(lines)
|
| 552 |
+
|
| 553 |
+
async def classify_documents(self, documents: List[Dict[str, Any]], mode: str = "keywords") -> List[Dict[str, Any]]:
|
| 554 |
+
"""
|
| 555 |
+
Classify documents using the specified strategy.
|
| 556 |
+
Modes: 'keywords' (default, fast), 'llm' (accurate but slow), 'oxford' (CEBM fast), 'hybrid' (keywords + llm for unknown).
|
| 557 |
+
"""
|
| 558 |
+
from backend.prompts.synthesis import GRADE_PROMPT, GRADE_ORIGINAL_PROMPT
|
| 559 |
+
import json
|
| 560 |
+
|
| 561 |
+
enriched = []
|
| 562 |
+
mode = mode.lower()
|
| 563 |
+
|
| 564 |
+
if mode == "original":
|
| 565 |
+
limit = min(50, len(documents))
|
| 566 |
+
parsed: Any = {}
|
| 567 |
+
try:
|
| 568 |
+
user = GRADE_ORIGINAL_PROMPT.format(
|
| 569 |
+
documents_text=self._grade_docs_context(documents, limit)
|
| 570 |
+
)
|
| 571 |
+
response = await self._call_llm(
|
| 572 |
+
"Eres un Agente de Evaluacion Metodologica. Tu salida debe ser exclusivamente JSON valido.",
|
| 573 |
+
user,
|
| 574 |
+
temperature=0.0,
|
| 575 |
+
role="synthesis",
|
| 576 |
+
)
|
| 577 |
+
parsed = self._parse_json(response)
|
| 578 |
+
except Exception as e:
|
| 579 |
+
print(f"[GRADE ORIGINAL] Error classifying docs: {e}. Falling back to keywords.")
|
| 580 |
+
|
| 581 |
+
classifications = self._extract_grade_classifications(parsed)
|
| 582 |
+
by_index = {}
|
| 583 |
+
for i, item in enumerate(classifications, 1):
|
| 584 |
+
try:
|
| 585 |
+
idx = int(item.get("index", i)) - 1
|
| 586 |
+
except (TypeError, ValueError):
|
| 587 |
+
idx = i - 1
|
| 588 |
+
by_index[idx] = item
|
| 589 |
+
|
| 590 |
+
for i, doc in enumerate(documents):
|
| 591 |
+
item = by_index.get(i)
|
| 592 |
+
if item and i < limit:
|
| 593 |
+
enriched.append(
|
| 594 |
+
self._enrich_with_grade(
|
| 595 |
+
doc,
|
| 596 |
+
item.get("level", "BAJA"),
|
| 597 |
+
"original",
|
| 598 |
+
evidence_type=item.get("type", ""),
|
| 599 |
+
justification=item.get("justification", item.get("reason", "")),
|
| 600 |
+
)
|
| 601 |
+
)
|
| 602 |
+
else:
|
| 603 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 604 |
+
enriched.append(
|
| 605 |
+
self._enrich_with_grade(
|
| 606 |
+
doc,
|
| 607 |
+
classify_grade_original(study_type),
|
| 608 |
+
"original",
|
| 609 |
+
evidence_type=study_type,
|
| 610 |
+
)
|
| 611 |
+
)
|
| 612 |
+
return enriched
|
| 613 |
+
|
| 614 |
+
if mode == "keywords":
|
| 615 |
+
for doc in documents:
|
| 616 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 617 |
+
level = classify_grade(study_type)
|
| 618 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 619 |
+
|
| 620 |
+
elif mode == "oxford":
|
| 621 |
+
for doc in documents:
|
| 622 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 623 |
+
level = classify_grade_oxford(study_type)
|
| 624 |
+
enriched.append(self._enrich_with_grade(doc, level, "oxford"))
|
| 625 |
+
|
| 626 |
+
elif mode in ["llm", "hybrid"]:
|
| 627 |
+
# For hybrid, pre-filter with keywords to save tokens
|
| 628 |
+
docs_to_llm = []
|
| 629 |
+
if mode == "hybrid":
|
| 630 |
+
for doc in documents:
|
| 631 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 632 |
+
level = classify_grade(study_type)
|
| 633 |
+
if level != "4": # Confident classification
|
| 634 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 635 |
+
else:
|
| 636 |
+
docs_to_llm.append(doc)
|
| 637 |
+
else:
|
| 638 |
+
docs_to_llm = documents
|
| 639 |
+
|
| 640 |
+
if docs_to_llm:
|
| 641 |
+
# Prepare content for LLM (up to 30 docs to avoid context window issues)
|
| 642 |
+
limit = 30
|
| 643 |
+
content_to_grade = ""
|
| 644 |
+
for i, doc in enumerate(docs_to_llm[:limit]):
|
| 645 |
+
authors_str = ", ".join(doc.get("authors", []))
|
| 646 |
+
snippet = doc.get("snippet", doc.get("abstract", ""))
|
| 647 |
+
content_to_grade += f"[{i+1}] ID: {doc.get('id', i)} | Autores: {authors_str} | Resumen: {snippet}\n\n"
|
| 648 |
+
|
| 649 |
+
system = "Eres un experto en clasificación de evidencia científica y medicina basada en evidencia."
|
| 650 |
+
user = GRADE_PROMPT.format(documents_text=content_to_grade)
|
| 651 |
+
|
| 652 |
+
try:
|
| 653 |
+
response = await self._call_llm(system, user, temperature=0.1, role="synthesis")
|
| 654 |
+
results = self._parse_json(response)
|
| 655 |
+
if not isinstance(results, list):
|
| 656 |
+
if isinstance(results, dict) and "classifications" in results:
|
| 657 |
+
results = results["classifications"]
|
| 658 |
+
else:
|
| 659 |
+
results = [results]
|
| 660 |
+
|
| 661 |
+
# Map results back to documents
|
| 662 |
+
for i, doc in enumerate(docs_to_llm):
|
| 663 |
+
if i < limit and i < len(results):
|
| 664 |
+
res = results[i]
|
| 665 |
+
# Handle different response structures
|
| 666 |
+
level = res.get("level", "4")
|
| 667 |
+
if not level: level = "4"
|
| 668 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 669 |
+
else:
|
| 670 |
+
# Fallback for remaining docs
|
| 671 |
+
enriched.append(self._enrich_with_grade(doc, "4", "grade"))
|
| 672 |
+
except Exception as e:
|
| 673 |
+
print(f"[GRADE LLM] Error classifying docs: {e}. Falling back to keywords.")
|
| 674 |
+
for doc in docs_to_llm:
|
| 675 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 676 |
+
level = classify_grade(study_type)
|
| 677 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 678 |
+
else:
|
| 679 |
+
# Fallback
|
| 680 |
+
for doc in documents:
|
| 681 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 682 |
+
level = classify_grade(study_type)
|
| 683 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 684 |
+
|
| 685 |
+
return enriched
|
| 686 |
+
|
| 687 |
+
def classify_evidence(self, documents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 688 |
+
"""Legacy synchronous wrapper. Use await classify_documents() instead."""
|
| 689 |
+
if any(def_document_has_grade(doc) for doc in documents):
|
| 690 |
+
return documents
|
| 691 |
+
import asyncio
|
| 692 |
+
try:
|
| 693 |
+
loop = asyncio.get_event_loop()
|
| 694 |
+
return loop.run_until_complete(self.classify_documents(documents, "keywords"))
|
| 695 |
+
except RuntimeError:
|
| 696 |
+
# If event loop is already running, we have to fall back to simple keyword matching
|
| 697 |
+
enriched = []
|
| 698 |
+
for doc in documents:
|
| 699 |
+
study_type = doc.get("study_type", doc.get("type", "transversal"))
|
| 700 |
+
level = classify_grade(study_type)
|
| 701 |
+
enriched.append(self._enrich_with_grade(doc, level, "grade"))
|
| 702 |
+
return enriched
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def sort_by_evidence(self, documents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 706 |
+
"""Sort documents by GRADE weight descending (strongest evidence first)."""
|
| 707 |
+
return sorted(documents, key=lambda d: d.get("grade_weight", 0), reverse=True)
|
| 708 |
+
|
| 709 |
+
def evidence_summary(self, documents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 710 |
+
"""Produce a GRADE distribution summary."""
|
| 711 |
+
counts: Dict[str, int] = {}
|
| 712 |
+
for doc in documents:
|
| 713 |
+
lvl = doc.get("grade_level", "4")
|
| 714 |
+
counts[lvl] = counts.get(lvl, 0) + 1
|
| 715 |
+
|
| 716 |
+
if any(
|
| 717 |
+
doc.get("grade_system") == "original" or doc.get("grade_level") in ORIGINAL_GRADE_LEVELS
|
| 718 |
+
for doc in documents
|
| 719 |
+
):
|
| 720 |
+
return {
|
| 721 |
+
"distribution": [
|
| 722 |
+
{"level": l, "label": ORIGINAL_GRADE_LEVELS[l]["label"], "count": counts.get(l, 0)}
|
| 723 |
+
for l in ["ALTA", "MODERADA", "BAJA", "MUY BAJA"] if counts.get(l, 0) > 0
|
| 724 |
+
],
|
| 725 |
+
"total": len(documents),
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
levels_desc = ["1a", "1b", "2a", "2b", "3a", "3b", "4", "5", "6"]
|
| 729 |
+
return {
|
| 730 |
+
"distribution": [
|
| 731 |
+
{"level": l, "label": GRADE_LEVELS[l]["label"], "count": counts.get(l, 0)}
|
| 732 |
+
for l in levels_desc if counts.get(l, 0) > 0
|
| 733 |
+
],
|
| 734 |
+
"total": len(documents),
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
# ── Full-text retrieval helpers ─────────────────────────────────────
|
| 738 |
+
|
| 739 |
+
def extract_full_text(self, doc: Dict[str, Any]) -> str:
|
| 740 |
+
"""Extract the best available full text from a document entry."""
|
| 741 |
+
for key in ("full_text", "text", "content", "body", "extracted_text"):
|
| 742 |
+
val = doc.get(key)
|
| 743 |
+
if val and isinstance(val, str) and len(val.strip()) > 50:
|
| 744 |
+
return val.strip()
|
| 745 |
+
abstract = doc.get("abstract", doc.get("summary", ""))
|
| 746 |
+
if abstract:
|
| 747 |
+
return f"[Solo disponible resumen/abstract]\n{abstract.strip()}"
|
| 748 |
+
return "[No se encontró texto completo ni abstract para este documento]"
|
| 749 |
+
|
| 750 |
+
def build_full_text_context(self, documents: List[Dict[str, Any]], max_chars: int = 120000) -> str:
|
| 751 |
+
"""Build a concatenated full-text context from documents respecting char limit."""
|
| 752 |
+
sorted_docs = self.sort_by_evidence(documents)
|
| 753 |
+
parts: List[str] = []
|
| 754 |
+
total = 0
|
| 755 |
+
for i, doc in enumerate(sorted_docs, 1):
|
| 756 |
+
ref_id = doc.get("id", i)
|
| 757 |
+
title = doc.get("title", "Sin título")
|
| 758 |
+
authors = doc.get("authors", "Autor desconocido")
|
| 759 |
+
year = doc.get("year", "?")
|
| 760 |
+
grade = doc.get("evidenceLevel") or doc.get("grade_label", "")
|
| 761 |
+
text = self.extract_full_text(doc)
|
| 762 |
+
header = f"[{i}] (BIB:{ref_id}) {title} - {authors} ({year}) [{grade}]"
|
| 763 |
+
chunk = f"{header}\n{text}\n"
|
| 764 |
+
if total + len(chunk) > max_chars:
|
| 765 |
+
remaining = max_chars - total
|
| 766 |
+
if remaining > 200:
|
| 767 |
+
parts.append(chunk[:remaining] + "\n... [truncado por límite de tokens]")
|
| 768 |
+
break
|
| 769 |
+
parts.append(chunk)
|
| 770 |
+
total += len(chunk)
|
| 771 |
+
return "\n---\n".join(parts)
|
| 772 |
+
|
| 773 |
+
# ── Hierarchical (Map-Reduce) synthesis ─────────────────────────────
|
| 774 |
+
|
| 775 |
+
async def _map_chunk(
|
| 776 |
+
self,
|
| 777 |
+
chunk_docs: List[Dict[str, Any]],
|
| 778 |
+
chunk_idx: int,
|
| 779 |
+
query: str,
|
| 780 |
+
profile: str,
|
| 781 |
+
geo_context: str = "Automático",
|
| 782 |
+
) -> str:
|
| 783 |
+
"""Map step: synthesize a single chunk of documents."""
|
| 784 |
+
profile_data = AGENT_PROFILES.get(profile, AGENT_PROFILES["general"])
|
| 785 |
+
context = self.build_full_text_context(chunk_docs, max_chars=40000)
|
| 786 |
+
system = f"Eres un {profile_data['title']}. Sintetiza este bloque de documentos."
|
| 787 |
+
user = f"""CONSULTA ORIGINAL: "{query}"
|
| 788 |
+
DOCUMENTOS DEL BLOQUE {chunk_idx}:
|
| 789 |
+
{context}
|
| 790 |
+
|
| 791 |
+
TAREA: Sintetiza los hallazgos clave de este bloque.
|
| 792 |
+
- Menciona autores, años y datos específicos.
|
| 793 |
+
- Usa formato [[n]] {{BIB:ID}} para cada cita.
|
| 794 |
+
- Sé conciso pero técnico.
|
| 795 |
+
- SOLO texto, NO JSON."""
|
| 796 |
+
user += f"\n\nCONTEXTO GEOGRÁFICO A PRIORIZAR: {geo_context}"
|
| 797 |
+
return await self._call_llm(system, user, temperature=0.0)
|
| 798 |
+
|
| 799 |
+
async def _reduce_summaries(self, summaries: List[str], query: str, profile: str, geo_context: str = "Automático") -> dict:
|
| 800 |
+
"""Reduce step: merge chunk summaries into a single master plan."""
|
| 801 |
+
profile_data = AGENT_PROFILES.get(profile, AGENT_PROFILES["general"])
|
| 802 |
+
combined = "\n\n---\n\n".join(summaries)
|
| 803 |
+
system = f"Eres un {profile_data['title']}. Fusiona múltiples síntesis parciales en un plan coherente."
|
| 804 |
+
user = MASTER_SYNTHESIS_PROMPT.format(
|
| 805 |
+
query=query,
|
| 806 |
+
agent_title=profile_data["title"],
|
| 807 |
+
agent_title_upper=profile_data["title"].upper(),
|
| 808 |
+
profile_instruction=profile_data["instruction"],
|
| 809 |
+
template_structure="Integra las secciones de las síntesis parciales en un plan maestro unificado.",
|
| 810 |
+
)
|
| 811 |
+
user += f"\n\nSÍNTESIS PARCIALES:\n{combined}"
|
| 812 |
+
user += f"\n\nCONTEXTO GEOGRÁFICO A PRIORIZAR: {geo_context}"
|
| 813 |
+
response = await self._call_llm(system, user, temperature=0.0)
|
| 814 |
+
return extract_research_plan(response)
|
| 815 |
+
|
| 816 |
+
async def hierarchical_synthesis(
|
| 817 |
+
self,
|
| 818 |
+
query: str,
|
| 819 |
+
documents: List[Dict[str, Any]],
|
| 820 |
+
profile: str = "general",
|
| 821 |
+
chunk_size: int = 10,
|
| 822 |
+
geo_context: str = "Automático",
|
| 823 |
+
) -> dict:
|
| 824 |
+
"""
|
| 825 |
+
Map-Reduce hierarchical synthesis.
|
| 826 |
+
1. Split docs into chunks.
|
| 827 |
+
2. Map: synthesize each chunk independently.
|
| 828 |
+
3. Reduce: merge all chunk summaries into a master plan.
|
| 829 |
+
4. Detect gaps and optionally rescue.
|
| 830 |
+
"""
|
| 831 |
+
enriched = self.classify_evidence(documents)
|
| 832 |
+
sorted_docs = self.sort_by_evidence(enriched)
|
| 833 |
+
|
| 834 |
+
chunks = [
|
| 835 |
+
sorted_docs[i:i + chunk_size]
|
| 836 |
+
for i in range(0, len(sorted_docs), chunk_size)
|
| 837 |
+
]
|
| 838 |
+
|
| 839 |
+
summaries: List[str] = []
|
| 840 |
+
for idx, chunk in enumerate(chunks, 1):
|
| 841 |
+
summary = await self._map_chunk(chunk, idx, query, profile, geo_context=geo_context)
|
| 842 |
+
summaries.append(summary)
|
| 843 |
+
|
| 844 |
+
master_plan = await self._reduce_summaries(summaries, query, profile, geo_context=geo_context)
|
| 845 |
+
|
| 846 |
+
plan_sections = master_plan.get("plan", [])
|
| 847 |
+
gap_result = await self.detect_gaps(query, plan_sections)
|
| 848 |
+
master_plan["gap_analysis"] = gap_result
|
| 849 |
+
master_plan["evidence_summary"] = self.evidence_summary(enriched)
|
| 850 |
+
|
| 851 |
+
if gap_result.get("requires_rescue"):
|
| 852 |
+
rescue_result = await self._rescue_search(query, gap_result.get("missing_aspects", []))
|
| 853 |
+
master_plan["rescue_results"] = rescue_result
|
| 854 |
+
|
| 855 |
+
return master_plan
|
| 856 |
+
|
| 857 |
+
# ── Linear synthesis (original approach, kept for compatibility) ────
|
| 858 |
+
|
| 859 |
+
async def linear_synthesis(
|
| 860 |
+
self,
|
| 861 |
+
query: str,
|
| 862 |
+
documents: List[Dict[str, Any]],
|
| 863 |
+
profile: str = "general",
|
| 864 |
+
) -> dict:
|
| 865 |
+
"""Original linear pipeline: orchestrate → plan → master plan → gap detection."""
|
| 866 |
+
enriched = self.classify_evidence(documents)
|
| 867 |
+
sorted_docs = self.sort_by_evidence(enriched)
|
| 868 |
+
docs_context = self.build_full_text_context(sorted_docs)
|
| 869 |
+
|
| 870 |
+
master_plan = await self.generate_master_plan(query, docs_context, profile)
|
| 871 |
+
plan_sections = master_plan.get("plan", [])
|
| 872 |
+
gap_result = await self.detect_gaps(query, plan_sections)
|
| 873 |
+
|
| 874 |
+
master_plan["gap_analysis"] = gap_result
|
| 875 |
+
master_plan["evidence_summary"] = self.evidence_summary(enriched)
|
| 876 |
+
|
| 877 |
+
if gap_result.get("requires_rescue"):
|
| 878 |
+
rescue_result = await self._rescue_search(query, gap_result.get("missing_aspects", []))
|
| 879 |
+
master_plan["rescue_results"] = rescue_result
|
| 880 |
+
|
| 881 |
+
return master_plan
|
| 882 |
+
|
| 883 |
+
# ── Gap detection + rescue search ───────────────────────────────────
|
| 884 |
+
|
| 885 |
+
async def _rescue_search(self, query: str, missing_aspects: List[str]) -> Dict[str, Any]:
|
| 886 |
+
"""Generate supplementary search queries for detected gaps."""
|
| 887 |
+
system = "Eres un Estratega de Búsqueda de Rescate. Genera queries de búsqueda para cubrir faltas."
|
| 888 |
+
aspects_text = "\n".join(f"- {a}" for a in missing_aspects)
|
| 889 |
+
user = f"""CONSULTA ORIGINAL: "{query}"
|
| 890 |
+
ASPECTOS FALTANTES:
|
| 891 |
+
{aspects_text}
|
| 892 |
+
|
| 893 |
+
Genera queries de búsqueda de rescate optimizados para cubrir cada aspecto faltante.
|
| 894 |
+
RESPONDE EN JSON:
|
| 895 |
+
{{
|
| 896 |
+
"rescue_queries": [
|
| 897 |
+
{{"aspect": "...", "english_query": "...", "spanish_query": "..."}}
|
| 898 |
+
]
|
| 899 |
+
}}"""
|
| 900 |
+
response = await self._call_llm(system, user, temperature=0.0)
|
| 901 |
+
return self._parse_json(response)
|
| 902 |
+
|
| 903 |
+
async def run_full_pipeline(
|
| 904 |
+
self,
|
| 905 |
+
query: str,
|
| 906 |
+
documents: List[Dict[str, Any]],
|
| 907 |
+
profile: str = "general",
|
| 908 |
+
mode: str = "linear",
|
| 909 |
+
chunk_size: int = 10,
|
| 910 |
+
geo_context: str = "Automático",
|
| 911 |
+
) -> dict:
|
| 912 |
+
"""
|
| 913 |
+
Unified entry point for the full synthesis pipeline.
|
| 914 |
+
mode: "linear" | "hierarchical"
|
| 915 |
+
"""
|
| 916 |
+
if mode == "hierarchical":
|
| 917 |
+
return await self.hierarchical_synthesis(query, documents, profile, chunk_size, geo_context=geo_context)
|
| 918 |
+
return await self.linear_synthesis(query, documents, profile)
|
| 919 |
+
|
| 920 |
+
# ── Cleanup ─────────────────────────────────────────────────────────
|
| 921 |
+
|
| 922 |
+
async def close(self):
|
| 923 |
+
await self.client.aclose()
|
backend/tools/__init__.py
ADDED
|
File without changes
|
backend/tools/dme_extractor.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import re
|
| 3 |
+
import json
|
| 4 |
+
import asyncio
|
| 5 |
+
|
| 6 |
+
async def solve_anubis_challenge(url: str, client: httpx.AsyncClient) -> str:
|
| 7 |
+
"""Bypass Anubis (Techaro) challenge used by some LATAM repositories"""
|
| 8 |
+
# Simply rotating the User-Agent and setting an Accept-Language often bypasses basic Anubis
|
| 9 |
+
headers = {
|
| 10 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
| 11 |
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
| 12 |
+
'Accept-Language': 'es-PE,es;q=0.9,en-US;q=0.8,en;q=0.7',
|
| 13 |
+
'Sec-Ch-Ua': '"Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"',
|
| 14 |
+
'Sec-Ch-Ua-Mobile': '?0',
|
| 15 |
+
'Sec-Ch-Ua-Platform': '"Windows"'
|
| 16 |
+
}
|
| 17 |
+
res = await client.get(url, headers=headers, follow_redirects=True, timeout=15.0)
|
| 18 |
+
return res.text
|
| 19 |
+
|
| 20 |
+
async def extract_dspace_metadata(url: str) -> dict:
|
| 21 |
+
"""Detect DSpace 7 SPA and extract hidden API metadata"""
|
| 22 |
+
async with httpx.AsyncClient(verify=False) as client:
|
| 23 |
+
try:
|
| 24 |
+
html = await solve_anubis_challenge(url, client)
|
| 25 |
+
|
| 26 |
+
# Detect if it's DSpace 7 Angular
|
| 27 |
+
if 'dspace-angular' in html or 'server/api' in html:
|
| 28 |
+
# Find the handle
|
| 29 |
+
handle_match = re.search(r'handle/(\d+/\d+)', url)
|
| 30 |
+
if handle_match:
|
| 31 |
+
handle = handle_match.group(1)
|
| 32 |
+
# Extract the base URL
|
| 33 |
+
base_url = re.match(r'(https?://[^/]+)', url).group(1)
|
| 34 |
+
api_url = f"{base_url}/server/api/core/items/search/findByHandle?handle={handle}"
|
| 35 |
+
|
| 36 |
+
api_res = await client.get(api_url, timeout=10.0)
|
| 37 |
+
if api_res.status_code == 200:
|
| 38 |
+
data = api_res.json()
|
| 39 |
+
metadata = data.get('_embedded', {}).get('metadata', {})
|
| 40 |
+
|
| 41 |
+
abstract = ""
|
| 42 |
+
if 'dc.description.abstract' in metadata:
|
| 43 |
+
abstract = metadata['dc.description.abstract'][0]['value']
|
| 44 |
+
|
| 45 |
+
# Try to find the bitstream (PDF)
|
| 46 |
+
pdf_url = ""
|
| 47 |
+
bundles_link = data.get('_links', {}).get('bundles', {}).get('href')
|
| 48 |
+
if bundles_link:
|
| 49 |
+
bundle_res = await client.get(bundles_link)
|
| 50 |
+
if bundle_res.status_code == 200:
|
| 51 |
+
bundles = bundle_res.json().get('_embedded', {}).get('bundles', [])
|
| 52 |
+
for b in bundles:
|
| 53 |
+
if b.get('name') == 'ORIGINAL':
|
| 54 |
+
bitstreams_link = b.get('_links', {}).get('bitstreams', {}).get('href')
|
| 55 |
+
if bitstreams_link:
|
| 56 |
+
bits_res = await client.get(bitstreams_link)
|
| 57 |
+
if bits_res.status_code == 200:
|
| 58 |
+
bits = bits_res.json().get('_embedded', {}).get('bitstreams', [])
|
| 59 |
+
for bit in bits:
|
| 60 |
+
if bit.get('bundleName') == 'ORIGINAL' and 'pdf' in bit.get('format', '').lower():
|
| 61 |
+
pdf_url = bit.get('_links', {}).get('content', {}).get('href')
|
| 62 |
+
break
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"abstract": abstract,
|
| 67 |
+
"pdf_url": pdf_url,
|
| 68 |
+
"enhanced": True
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# Fallback to basic HTML meta tags for older DSpace (xmlui/jspui)
|
| 72 |
+
abstract_match = re.search(r'<meta\s+name="DC\.description\.abstract"\s+content="([^"]+)"', html)
|
| 73 |
+
pdf_match = re.search(r'<meta\s+name="citation_pdf_url"\s+content="([^"]+)"', html)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"abstract": abstract_match.group(1) if abstract_match else "",
|
| 77 |
+
"pdf_url": pdf_match.group(1) if pdf_match else "",
|
| 78 |
+
"enhanced": bool(abstract_match or pdf_match)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"[DME] Error extracting metadata from {url}: {e}")
|
| 83 |
+
return {"abstract": "", "pdf_url": "", "enhanced": False}
|
| 84 |
+
|
| 85 |
+
async def enhance_results(results: list) -> list:
|
| 86 |
+
"""Run DME on a list of results"""
|
| 87 |
+
tasks = []
|
| 88 |
+
|
| 89 |
+
async def process_item(r):
|
| 90 |
+
if not r.get("abstract") or len(r["abstract"]) < 50 or "[...]" in r["abstract"] or not r.get("pdfUrl"):
|
| 91 |
+
if r.get("source") in ["ALICIA", "RENATI", "La Referencia", "Bases LATAM"]:
|
| 92 |
+
url = r.get("doi") or "" # Fallback URL is often stored in doi if it's a URI
|
| 93 |
+
if "http" in url:
|
| 94 |
+
dme_data = await extract_dspace_metadata(url)
|
| 95 |
+
if dme_data["enhanced"]:
|
| 96 |
+
if dme_data["abstract"]:
|
| 97 |
+
r["abstract"] = dme_data["abstract"]
|
| 98 |
+
if dme_data["pdf_url"]:
|
| 99 |
+
r["pdfUrl"] = dme_data["pdf_url"]
|
| 100 |
+
return r
|
| 101 |
+
|
| 102 |
+
for r in results:
|
| 103 |
+
tasks.append(process_item(r))
|
| 104 |
+
|
| 105 |
+
return await asyncio.gather(*tasks)
|
backend/tools/export_utils.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Export utilities for research reports.
|
| 3 |
+
Supports: DOCX, PDF, Markdown, BibTeX, ZIP (full workspace).
|
| 4 |
+
Ported from the Next.js original.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import zipfile
|
| 10 |
+
import tempfile
|
| 11 |
+
import re
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from typing import Optional, List, Dict, Any
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _project_root() -> str:
|
| 18 |
+
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _sanitize_key(value: str, fallback: str) -> str:
|
| 22 |
+
key = re.sub(r'[^a-zA-Z0-9_]', '_', value or "")
|
| 23 |
+
key = re.sub(r'_+', '_', key).strip('_')
|
| 24 |
+
return key or fallback
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _escape_bibtex(value: Any) -> str:
|
| 28 |
+
text = "" if value is None else str(value)
|
| 29 |
+
text = text.replace('\u2028', ' ').replace('\u2029', ' ')
|
| 30 |
+
return text.replace('&', r'\&').replace('%', r'\%').replace('_', r'\_')
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _doc_authors(doc: Dict[str, Any]) -> str:
|
| 34 |
+
authors = doc.get("authors", [])
|
| 35 |
+
if isinstance(authors, list):
|
| 36 |
+
return " and ".join(str(a) for a in authors if a) or "Unknown"
|
| 37 |
+
return str(authors or "Unknown")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _markdown_to_latex_body(report_md: str) -> str:
|
| 41 |
+
body = report_md or ""
|
| 42 |
+
body = re.sub(r'^###\s+(.+)$', r'\\subsection{\1}', body, flags=re.MULTILINE)
|
| 43 |
+
body = re.sub(r'^##\s+(.+)$', r'\\section{\1}', body, flags=re.MULTILINE)
|
| 44 |
+
body = re.sub(r'^#\s+(.+)$', r'\\section{\1}', body, flags=re.MULTILINE)
|
| 45 |
+
body = body.replace('**', '')
|
| 46 |
+
return body
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def generate_bibtex_from_docs(docs: List[Dict[str, Any]]) -> str:
|
| 50 |
+
"""Generate BibTeX entries from pipeline documents, preserving original GRADE evidence."""
|
| 51 |
+
entries = []
|
| 52 |
+
seen = set()
|
| 53 |
+
|
| 54 |
+
for idx, doc in enumerate(docs, 1):
|
| 55 |
+
title = doc.get("title") or "Untitled"
|
| 56 |
+
raw_id = doc.get("id") or doc.get("doi") or title
|
| 57 |
+
cite_key = _sanitize_key(str(raw_id), f"ref{idx}")
|
| 58 |
+
if cite_key in seen:
|
| 59 |
+
cite_key = f"{cite_key}_{idx}"
|
| 60 |
+
seen.add(cite_key)
|
| 61 |
+
|
| 62 |
+
authors = _doc_authors(doc)
|
| 63 |
+
year = doc.get("year") or "n.d."
|
| 64 |
+
doi = doc.get("doi") or doc.get("metadata", {}).get("doi") or ""
|
| 65 |
+
source = doc.get("source") or doc.get("metadata", {}).get("journal") or "Repository"
|
| 66 |
+
url = doc.get("url") or doc.get("pdfUrl") or doc.get("handleUrl") or ""
|
| 67 |
+
evidence = doc.get("evidenceLevel") or doc.get("grade_label") or doc.get("grade_level") or "PENDIENTE"
|
| 68 |
+
|
| 69 |
+
type_text = str(doc.get("type") or "").lower()
|
| 70 |
+
title_text = str(title).lower()
|
| 71 |
+
source_text = str(source).lower()
|
| 72 |
+
is_thesis = any(k in f"{type_text} {title_text}" for k in [
|
| 73 |
+
"tesis", "thesis", "dissertation", "grado", "maestria", "doctorado", "licenciatura",
|
| 74 |
+
"bachelor", "master", "phd",
|
| 75 |
+
])
|
| 76 |
+
has_journal_hint = any(k in source_text for k in [
|
| 77 |
+
"journal", "revista", "review", "proceedings", "conference", "transactions",
|
| 78 |
+
])
|
| 79 |
+
|
| 80 |
+
bib_type = "mastersthesis" if is_thesis and not has_journal_hint and not doi else "article"
|
| 81 |
+
venue_field = "school" if bib_type == "mastersthesis" else "journal"
|
| 82 |
+
|
| 83 |
+
url_field = f" url = {{{url}}},\n" if url else ""
|
| 84 |
+
entry = (
|
| 85 |
+
f"@{bib_type}{{{cite_key},\n"
|
| 86 |
+
f" author = {{{_escape_bibtex(authors)}}},\n"
|
| 87 |
+
f" title = {{{_escape_bibtex(title)}}},\n"
|
| 88 |
+
f" {venue_field} = {{{_escape_bibtex(source)}}},\n"
|
| 89 |
+
f" year = {{{_escape_bibtex(year)}}},\n"
|
| 90 |
+
f"{url_field}"
|
| 91 |
+
f" doi = {{{_escape_bibtex(doi)}}},\n"
|
| 92 |
+
f" note = {{Calidad de evidencia GRADE: {_escape_bibtex(evidence)}}}\n"
|
| 93 |
+
f"}}"
|
| 94 |
+
)
|
| 95 |
+
entries.append(entry)
|
| 96 |
+
|
| 97 |
+
return "\n\n".join(entries)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def persist_research_output(
|
| 101 |
+
report_md: str,
|
| 102 |
+
docs: List[Dict[str, Any]],
|
| 103 |
+
query: str,
|
| 104 |
+
agent_role: str = "general",
|
| 105 |
+
model: str = "unknown",
|
| 106 |
+
output_root: Optional[str] = None,
|
| 107 |
+
) -> Dict[str, str]:
|
| 108 |
+
"""Persist final pipeline artifacts following the original beta data-mining layout."""
|
| 109 |
+
root = output_root or os.path.join(_project_root(), "latex_output")
|
| 110 |
+
scraping_dir = os.path.join(root, "data", "json1_scraping")
|
| 111 |
+
outputs_dir = os.path.join(root, "data", "json2_outputs")
|
| 112 |
+
os.makedirs(scraping_dir, exist_ok=True)
|
| 113 |
+
os.makedirs(outputs_dir, exist_ok=True)
|
| 114 |
+
os.makedirs(root, exist_ok=True)
|
| 115 |
+
|
| 116 |
+
timestamp = datetime.utcnow().isoformat() + "Z"
|
| 117 |
+
role_name = _sanitize_key((agent_role or "consolidado_investigacion").lower(), "consolidado_investigacion")
|
| 118 |
+
tex_path = os.path.join(root, f"{role_name}.tex")
|
| 119 |
+
md_path = os.path.join(root, f"{role_name}.md")
|
| 120 |
+
bib_path = os.path.join(root, "referencias.bib")
|
| 121 |
+
scraping_path = os.path.join(scraping_dir, "scraping_data.json")
|
| 122 |
+
outputs_path = os.path.join(outputs_dir, "llm_outputs.json")
|
| 123 |
+
|
| 124 |
+
bib = generate_bibtex_from_docs(docs)
|
| 125 |
+
tex = _markdown_to_latex_body(report_md)
|
| 126 |
+
|
| 127 |
+
with open(tex_path, "w", encoding="utf-8") as f:
|
| 128 |
+
f.write(tex)
|
| 129 |
+
with open(md_path, "w", encoding="utf-8") as f:
|
| 130 |
+
f.write(report_md or "")
|
| 131 |
+
with open(bib_path, "w", encoding="utf-8") as f:
|
| 132 |
+
f.write(bib)
|
| 133 |
+
|
| 134 |
+
scraping_data = {
|
| 135 |
+
"version": "1.0.0",
|
| 136 |
+
"createdAt": timestamp,
|
| 137 |
+
"lastModifiedAt": timestamp,
|
| 138 |
+
"projectId": "LETXIPU-GRADIO",
|
| 139 |
+
"totalRecords": len(docs),
|
| 140 |
+
"records": [
|
| 141 |
+
{
|
| 142 |
+
"id": doc.get("id") or f"doc_{i}",
|
| 143 |
+
"url": doc.get("url") or doc.get("pdfUrl") or doc.get("handleUrl") or "",
|
| 144 |
+
"title": doc.get("title") or "Sin titulo",
|
| 145 |
+
"snippet": doc.get("snippet") or doc.get("abstract") or "",
|
| 146 |
+
"source": doc.get("source") or "Desconocido",
|
| 147 |
+
"scrapedAt": timestamp,
|
| 148 |
+
"metadata": {
|
| 149 |
+
"authors": doc.get("authors") or [],
|
| 150 |
+
"year": int(doc["year"]) if str(doc.get("year", "")).isdigit() else None,
|
| 151 |
+
"abstract": doc.get("abstract"),
|
| 152 |
+
"doi": doc.get("doi"),
|
| 153 |
+
"pdfUrl": doc.get("pdfUrl"),
|
| 154 |
+
"university": doc.get("university") or doc.get("institution"),
|
| 155 |
+
"queries": [query],
|
| 156 |
+
"evidenceLevel": doc.get("evidenceLevel") or doc.get("grade_label") or doc.get("grade_level"),
|
| 157 |
+
},
|
| 158 |
+
}
|
| 159 |
+
for i, doc in enumerate(docs, 1)
|
| 160 |
+
],
|
| 161 |
+
"changelog": [
|
| 162 |
+
{
|
| 163 |
+
"timestamp": timestamp,
|
| 164 |
+
"action": "added",
|
| 165 |
+
"recordCount": len(docs),
|
| 166 |
+
"description": "Generado automaticamente por el pipeline Python Gradio.",
|
| 167 |
+
}
|
| 168 |
+
],
|
| 169 |
+
"metadata": {
|
| 170 |
+
"queryUsed": query,
|
| 171 |
+
"sourcesEnabled": [],
|
| 172 |
+
"iterationsCompleted": 1,
|
| 173 |
+
"totalIterationsPlanned": 1,
|
| 174 |
+
},
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
output_record = {
|
| 178 |
+
"id": f"out_{int(datetime.utcnow().timestamp())}",
|
| 179 |
+
"timestamp": timestamp,
|
| 180 |
+
"promptUsed": query,
|
| 181 |
+
"modelUsed": model or "unknown",
|
| 182 |
+
"agentRole": agent_role,
|
| 183 |
+
"inputRecordCount": len(docs),
|
| 184 |
+
"output": {"plainText": report_md or "", "latex": tex},
|
| 185 |
+
"sourceScrapingVersion": "1.0.0",
|
| 186 |
+
}
|
| 187 |
+
outputs_data = {
|
| 188 |
+
"version": "1.0.0",
|
| 189 |
+
"createdAt": timestamp,
|
| 190 |
+
"lastModifiedAt": timestamp,
|
| 191 |
+
"projectId": "LETXIPU-GRADIO",
|
| 192 |
+
"outputs": [output_record],
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
with open(scraping_path, "w", encoding="utf-8") as f:
|
| 196 |
+
json.dump(scraping_data, f, ensure_ascii=False, indent=2)
|
| 197 |
+
with open(outputs_path, "w", encoding="utf-8") as f:
|
| 198 |
+
json.dump(outputs_data, f, ensure_ascii=False, indent=2)
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"tex": tex_path,
|
| 202 |
+
"markdown": md_path,
|
| 203 |
+
"bib": bib_path,
|
| 204 |
+
"scraping_json": scraping_path,
|
| 205 |
+
"outputs_json": outputs_path,
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def export_markdown(report_md: str, query: str = "") -> str:
|
| 210 |
+
"""Export report as clean Markdown file."""
|
| 211 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 212 |
+
safe_name = re.sub(r'[^\w\s-]', '', query[:40]).strip().replace(' ', '_') or "research"
|
| 213 |
+
filename = f"{safe_name}_{timestamp}.md"
|
| 214 |
+
|
| 215 |
+
path = os.path.join(tempfile.gettempdir(), filename)
|
| 216 |
+
|
| 217 |
+
header = f"""---
|
| 218 |
+
title: "{query}"
|
| 219 |
+
date: "{datetime.now().isoformat()}"
|
| 220 |
+
generator: "LETXIPU Research Platform"
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
"""
|
| 224 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 225 |
+
f.write(header + report_md)
|
| 226 |
+
|
| 227 |
+
return path
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def export_bibtex(docs_df: pd.DataFrame, query: str = "") -> str:
|
| 231 |
+
"""Export documents as BibTeX references."""
|
| 232 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 233 |
+
safe_name = re.sub(r'[^\w\s-]', '', query[:40]).strip().replace(' ', '_') or "references"
|
| 234 |
+
filename = f"{safe_name}_{timestamp}.bib"
|
| 235 |
+
path = os.path.join(tempfile.gettempdir(), filename)
|
| 236 |
+
|
| 237 |
+
entries = []
|
| 238 |
+
for idx, row in docs_df.iterrows():
|
| 239 |
+
title = row.get("Título", "N/A")
|
| 240 |
+
authors = row.get("Autores", "N/A")
|
| 241 |
+
year = str(row.get("Año", ""))
|
| 242 |
+
doi = row.get("DOI", "")
|
| 243 |
+
source = row.get("Fuente", "")
|
| 244 |
+
|
| 245 |
+
# Generate citation key
|
| 246 |
+
first_author = authors.split(",")[0].strip().split()[-1] if authors else "unknown"
|
| 247 |
+
cite_key = re.sub(r'[^a-zA-Z0-9]', '', f"{first_author}{year}")
|
| 248 |
+
if not cite_key:
|
| 249 |
+
cite_key = f"ref{idx}"
|
| 250 |
+
|
| 251 |
+
entry = f"""@article{{{cite_key},
|
| 252 |
+
title = {{{title}}},
|
| 253 |
+
author = {{{authors}}},
|
| 254 |
+
year = {{{year}}},
|
| 255 |
+
doi = {{{doi}}},
|
| 256 |
+
journal = {{{source}}},
|
| 257 |
+
}}"""
|
| 258 |
+
entries.append(entry)
|
| 259 |
+
|
| 260 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 261 |
+
f.write("\n\n".join(entries))
|
| 262 |
+
|
| 263 |
+
return path
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def export_zip(report_md: str, docs_df: pd.DataFrame, query: str = "",
|
| 267 |
+
settings: dict = None) -> str:
|
| 268 |
+
"""Export full workspace as ZIP: report.md + references.bib + documents.csv + settings.json"""
|
| 269 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 270 |
+
safe_name = re.sub(r'[^\w\s-]', '', query[:40]).strip().replace(' ', '_') or "research"
|
| 271 |
+
filename = f"{safe_name}_workspace_{timestamp}.zip"
|
| 272 |
+
path = os.path.join(tempfile.gettempdir(), filename)
|
| 273 |
+
|
| 274 |
+
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
| 275 |
+
# 1. Report markdown
|
| 276 |
+
header = f"---\ntitle: \"{query}\"\ndate: \"{datetime.now().isoformat()}\"\n---\n\n"
|
| 277 |
+
zf.writestr("report.md", header + report_md)
|
| 278 |
+
|
| 279 |
+
# 2. BibTeX
|
| 280 |
+
bib_path = export_bibtex(docs_df, query)
|
| 281 |
+
zf.write(bib_path, "references.bib")
|
| 282 |
+
|
| 283 |
+
# 3. Documents CSV
|
| 284 |
+
csv_content = docs_df.to_csv(index=False, encoding='utf-8')
|
| 285 |
+
zf.writestr("documents.csv", csv_content)
|
| 286 |
+
|
| 287 |
+
# 4. Documents JSON (machine-readable)
|
| 288 |
+
docs_json = docs_df.to_json(orient='records', force_ascii=False, indent=2)
|
| 289 |
+
zf.writestr("documents.json", docs_json)
|
| 290 |
+
|
| 291 |
+
# 5. Settings/metadata
|
| 292 |
+
meta = {
|
| 293 |
+
"query": query,
|
| 294 |
+
"timestamp": datetime.now().isoformat(),
|
| 295 |
+
"total_documents": len(docs_df),
|
| 296 |
+
"platform": "LETXIPU Research Platform",
|
| 297 |
+
"settings": settings or {},
|
| 298 |
+
}
|
| 299 |
+
zf.writestr("metadata.json", json.dumps(meta, indent=2, ensure_ascii=False))
|
| 300 |
+
|
| 301 |
+
return path
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def export_docx(report_md: str, query: str = "") -> Optional[str]:
|
| 305 |
+
"""Export report as DOCX using python-docx if available."""
|
| 306 |
+
try:
|
| 307 |
+
from docx import Document
|
| 308 |
+
from docx.shared import Pt, Inches
|
| 309 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 310 |
+
except ImportError:
|
| 311 |
+
return None # python-docx not installed
|
| 312 |
+
|
| 313 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 314 |
+
safe_name = re.sub(r'[^\w\s-]', '', query[:40]).strip().replace(' ', '_') or "research"
|
| 315 |
+
filename = f"{safe_name}_{timestamp}.docx"
|
| 316 |
+
path = os.path.join(tempfile.gettempdir(), filename)
|
| 317 |
+
|
| 318 |
+
doc = Document()
|
| 319 |
+
|
| 320 |
+
# Title
|
| 321 |
+
title_para = doc.add_heading(query or "Informe de Investigación", level=0)
|
| 322 |
+
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 323 |
+
|
| 324 |
+
doc.add_paragraph(
|
| 325 |
+
f"Generado: {datetime.now().strftime('%d/%m/%Y %H:%M')} | LETXIPU Research Platform",
|
| 326 |
+
style='Subtitle'
|
| 327 |
+
)
|
| 328 |
+
doc.add_paragraph("") # spacer
|
| 329 |
+
|
| 330 |
+
# Parse markdown sections
|
| 331 |
+
lines = report_md.split('\n')
|
| 332 |
+
for line in lines:
|
| 333 |
+
stripped = line.strip()
|
| 334 |
+
if not stripped:
|
| 335 |
+
doc.add_paragraph("")
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
if stripped.startswith('#### '):
|
| 339 |
+
doc.add_heading(stripped[5:], level=4)
|
| 340 |
+
elif stripped.startswith('### '):
|
| 341 |
+
doc.add_heading(stripped[4:], level=3)
|
| 342 |
+
elif stripped.startswith('## '):
|
| 343 |
+
doc.add_heading(stripped[3:], level=2)
|
| 344 |
+
elif stripped.startswith('# '):
|
| 345 |
+
doc.add_heading(stripped[2:], level=1)
|
| 346 |
+
elif stripped.startswith('- ') or stripped.startswith('* '):
|
| 347 |
+
doc.add_paragraph(stripped[2:], style='List Bullet')
|
| 348 |
+
elif re.match(r'^\d+\.\s', stripped):
|
| 349 |
+
text = re.sub(r'^\d+\.\s', '', stripped)
|
| 350 |
+
doc.add_paragraph(text, style='List Number')
|
| 351 |
+
elif stripped.startswith('> '):
|
| 352 |
+
p = doc.add_paragraph(stripped[2:])
|
| 353 |
+
p.style = 'Intense Quote'
|
| 354 |
+
else:
|
| 355 |
+
# Handle bold and italic in regular text
|
| 356 |
+
p = doc.add_paragraph()
|
| 357 |
+
# Simple bold/italic parsing
|
| 358 |
+
parts = re.split(r'(\*\*.*?\*\*|\*.*?\*)', stripped)
|
| 359 |
+
for part in parts:
|
| 360 |
+
if part.startswith('**') and part.endswith('**'):
|
| 361 |
+
run = p.add_run(part[2:-2])
|
| 362 |
+
run.bold = True
|
| 363 |
+
elif part.startswith('*') and part.endswith('*'):
|
| 364 |
+
run = p.add_run(part[1:-1])
|
| 365 |
+
run.italic = True
|
| 366 |
+
else:
|
| 367 |
+
p.add_run(part)
|
| 368 |
+
|
| 369 |
+
doc.save(path)
|
| 370 |
+
return path
|
backend/tools/graph_generator.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import networkx as nx
|
| 2 |
+
from pyvis.network import Network
|
| 3 |
+
import os
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
# Directorio para guardar grafos temporales
|
| 7 |
+
GRAPH_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "assets", "graphs")
|
| 8 |
+
os.makedirs(GRAPH_DIR, exist_ok=True)
|
| 9 |
+
|
| 10 |
+
class GraphGenerator:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
def generate_research_graph(self, docs: list) -> str:
|
| 15 |
+
"""
|
| 16 |
+
Toma una lista de documentos y genera un grafo interactivo de relaciones.
|
| 17 |
+
Devuelve el código HTML del grafo (o la ruta al archivo generado).
|
| 18 |
+
"""
|
| 19 |
+
if not docs:
|
| 20 |
+
return "<div style='padding:20px;'>No hay documentos suficientes para generar el grafo.</div>"
|
| 21 |
+
|
| 22 |
+
G = nx.Graph()
|
| 23 |
+
|
| 24 |
+
# Añadir un nodo central para la consulta (opcional, en este caso los agruparemos por autores y fuentes)
|
| 25 |
+
# Recorrer documentos
|
| 26 |
+
for doc in docs:
|
| 27 |
+
title = doc.get("title", "Desconocido")[:30] + "..."
|
| 28 |
+
doc_id = doc.get("doi") or doc.get("pdfUrl") or title
|
| 29 |
+
source = doc.get("source", "Fuente Desconocida")
|
| 30 |
+
|
| 31 |
+
# Añadir nodo del documento principal
|
| 32 |
+
G.add_node(doc_id, label=title, title=doc.get("title", ""), color="#8b5cf6", shape="dot", size=20)
|
| 33 |
+
|
| 34 |
+
# Nodo de la fuente
|
| 35 |
+
G.add_node(source, label=source, color="#10b981", shape="square", size=25)
|
| 36 |
+
G.add_edge(doc_id, source)
|
| 37 |
+
|
| 38 |
+
# Nodos de autores
|
| 39 |
+
authors = doc.get("authors", [])
|
| 40 |
+
if isinstance(authors, list):
|
| 41 |
+
for author in authors[:3]: # Solo los primeros 3 autores para no saturar
|
| 42 |
+
author_name = str(author).strip()
|
| 43 |
+
if author_name:
|
| 44 |
+
G.add_node(author_name, label=author_name, color="#f59e0b", shape="triangle", size=15)
|
| 45 |
+
G.add_edge(doc_id, author_name)
|
| 46 |
+
|
| 47 |
+
# Generar con pyvis
|
| 48 |
+
net = Network(height="600px", width="100%", bgcolor="#0f172a", font_color="white", select_menu=True)
|
| 49 |
+
# Opciones físicas para un buen layout
|
| 50 |
+
net.force_atlas_2based(gravity=-50, central_gravity=0.01, spring_length=100, spring_strength=0.08, damping=0.4, overlap=0)
|
| 51 |
+
|
| 52 |
+
net.from_nx(G)
|
| 53 |
+
|
| 54 |
+
filename = f"graph_{uuid.uuid4().hex[:8]}.html"
|
| 55 |
+
filepath = os.path.join(GRAPH_DIR, filename)
|
| 56 |
+
|
| 57 |
+
net.write_html(filepath)
|
| 58 |
+
|
| 59 |
+
# Leer el contenido HTML generado
|
| 60 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 61 |
+
html_content = f.read()
|
| 62 |
+
|
| 63 |
+
return html_content
|
| 64 |
+
|
| 65 |
+
generator = GraphGenerator()
|
backend/tools/latex_compiler.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
import tempfile
|
| 4 |
+
import shutil
|
| 5 |
+
|
| 6 |
+
class LatexCompiler:
|
| 7 |
+
def __init__(self, engine="pdflatex"):
|
| 8 |
+
self.engine = engine
|
| 9 |
+
self.output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "latex_output")
|
| 10 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
def compile(self, tex_content: str, filename: str = "document") -> dict:
|
| 13 |
+
"""
|
| 14 |
+
Compila código LaTeX y devuelve la ruta del PDF generado o los errores.
|
| 15 |
+
"""
|
| 16 |
+
if not tex_content.strip():
|
| 17 |
+
return {"success": False, "error": "El código fuente está vacío."}
|
| 18 |
+
|
| 19 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 20 |
+
tex_file = os.path.join(temp_dir, f"{filename}.tex")
|
| 21 |
+
|
| 22 |
+
# Escribir el código en el archivo .tex
|
| 23 |
+
with open(tex_file, "w", encoding="utf-8") as f:
|
| 24 |
+
f.write(tex_content)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Ejecutar compilador en modo interactivo=false para que no se quede bloqueado si hay error
|
| 28 |
+
# Y compilar dos veces para asegurar referencias y tabla de contenidos
|
| 29 |
+
for _ in range(2):
|
| 30 |
+
result = subprocess.run(
|
| 31 |
+
[self.engine, "-interaction=nonstopmode", f"{filename}.tex"],
|
| 32 |
+
cwd=temp_dir,
|
| 33 |
+
capture_output=True,
|
| 34 |
+
text=True,
|
| 35 |
+
timeout=30 # Timeout de 30 segundos
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
pdf_file = os.path.join(temp_dir, f"{filename}.pdf")
|
| 39 |
+
|
| 40 |
+
if os.path.exists(pdf_file):
|
| 41 |
+
# Copiar el PDF resultante a nuestra carpeta de salida
|
| 42 |
+
final_pdf_path = os.path.join(self.output_dir, f"{filename}.pdf")
|
| 43 |
+
shutil.copy2(pdf_file, final_pdf_path)
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"success": True,
|
| 47 |
+
"pdf_path": final_pdf_path,
|
| 48 |
+
"logs": result.stdout
|
| 49 |
+
}
|
| 50 |
+
else:
|
| 51 |
+
return {
|
| 52 |
+
"success": False,
|
| 53 |
+
"error": "Error de compilación LaTeX",
|
| 54 |
+
"logs": result.stdout
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
except subprocess.TimeoutExpired:
|
| 58 |
+
return {
|
| 59 |
+
"success": False,
|
| 60 |
+
"error": "Tiempo de compilación agotado (30s). Verifica si hay bucles o paquetes conflictivos."
|
| 61 |
+
}
|
| 62 |
+
except FileNotFoundError:
|
| 63 |
+
return {
|
| 64 |
+
"success": False,
|
| 65 |
+
"error": f"Compilador '{self.engine}' no encontrado. Verifica que TeX Live / MiKTeX esté instalado en el sistema y en el PATH."
|
| 66 |
+
}
|
| 67 |
+
except Exception as e:
|
| 68 |
+
return {
|
| 69 |
+
"success": False,
|
| 70 |
+
"error": str(e)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# Instancia global para facilitar su uso
|
| 74 |
+
compiler = LatexCompiler()
|
backend/tools/metadata.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from backend.providers.base import fetch_json, normalize_result
|
| 4 |
+
|
| 5 |
+
async def fetch_metadata(doi: str = None, url: str = None, title: str = None) -> dict:
|
| 6 |
+
"""Fetch metadata for a single paper."""
|
| 7 |
+
if doi:
|
| 8 |
+
# Try OpenAlex by DOI
|
| 9 |
+
data = await fetch_json(f"https://api.openalex.org/works/https://doi.org/{doi}")
|
| 10 |
+
if "error" not in data:
|
| 11 |
+
return normalize_result(
|
| 12 |
+
title=data.get("title"),
|
| 13 |
+
authors=[a.get("author", {}).get("display_name", "") for a in data.get("authorships", [])],
|
| 14 |
+
year=data.get("publication_year"),
|
| 15 |
+
abstract=None, # Need to decode inverted index
|
| 16 |
+
doi=doi,
|
| 17 |
+
pdf_url=data.get("open_access", {}).get("oa_url"),
|
| 18 |
+
source="openalex",
|
| 19 |
+
university=data.get("authorships", [{}])[0].get("institutions", [{}])[0].get("display_name") if data.get("authorships") else None,
|
| 20 |
+
citation_count=data.get("cited_by_count"),
|
| 21 |
+
)
|
| 22 |
+
if title:
|
| 23 |
+
data = await fetch_json("https://api.openalex.org/works", params={"search": title, "per-page": 1})
|
| 24 |
+
if "error" not in data and data.get("results"):
|
| 25 |
+
work = data["results"][0]
|
| 26 |
+
return normalize_result(
|
| 27 |
+
title=work.get("title"),
|
| 28 |
+
authors=[a.get("author", {}).get("display_name", "") for a in work.get("authorships", [])],
|
| 29 |
+
year=work.get("publication_year"),
|
| 30 |
+
abstract=None,
|
| 31 |
+
doi=work.get("ids", {}).get("doi", "").replace("https://doi.org/", ""),
|
| 32 |
+
pdf_url=work.get("open_access", {}).get("oa_url"),
|
| 33 |
+
source="openalex",
|
| 34 |
+
)
|
| 35 |
+
return {"error": "No metadata found"}
|
| 36 |
+
|
| 37 |
+
async def recover_metadata(doi: str = None, url: str = None, title: str = None) -> dict:
|
| 38 |
+
"""Deep metadata recovery from multiple sources."""
|
| 39 |
+
result = {}
|
| 40 |
+
sources_tried = []
|
| 41 |
+
|
| 42 |
+
# Try OpenAlex
|
| 43 |
+
if doi:
|
| 44 |
+
sources_tried.append("OpenAlex")
|
| 45 |
+
data = await fetch_json(f"https://api.openalex.org/works/https://doi.org/{doi}")
|
| 46 |
+
if "error" not in data:
|
| 47 |
+
result["title"] = data.get("title")
|
| 48 |
+
result["year"] = data.get("publication_year")
|
| 49 |
+
result["doi"] = doi
|
| 50 |
+
result["authors"] = [a.get("author", {}).get("display_name", "") for a in data.get("authorships", [])]
|
| 51 |
+
|
| 52 |
+
# Try Crossref for abstract
|
| 53 |
+
if doi and not result.get("abstract"):
|
| 54 |
+
sources_tried.append("Crossref")
|
| 55 |
+
data = await fetch_json(f"https://api.crossref.org/works/{doi}")
|
| 56 |
+
if "error" not in data:
|
| 57 |
+
item = data.get("message", {})
|
| 58 |
+
if item.get("abstract"):
|
| 59 |
+
result["abstract"] = item["abstract"][:500]
|
| 60 |
+
|
| 61 |
+
# Try Semantic Scholar for PDF
|
| 62 |
+
if doi and not result.get("pdfUrl"):
|
| 63 |
+
sources_tried.append("Semantic Scholar")
|
| 64 |
+
data = await fetch_json(f"https://api.semanticscholar.org/graph/v1/paper/DOI:{doi}?fields=openAccessPdf")
|
| 65 |
+
if "error" not in data:
|
| 66 |
+
result["pdfUrl"] = data.get("openAccessPdf", {}).get("url")
|
| 67 |
+
|
| 68 |
+
result["sourcesTried"] = sources_tried
|
| 69 |
+
return result
|