C2MV commited on
Commit
68fb5e2
·
verified ·
1 Parent(s): 2a7ce7a

Initial upload for Build Small Hackathon

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .chroma_db/chroma.sqlite3 +3 -0
  2. .env.example +26 -0
  3. .gitattributes +1 -0
  4. .gitignore +9 -0
  5. ANALISIS_BUGS_REFACTORIZACION.md +591 -0
  6. Dockerfile +12 -0
  7. Letxinet_Explicacion.html +62 -0
  8. Letxinet_Explicacion.pdf +99 -0
  9. README.md +104 -9
  10. app.py +224 -0
  11. assets/custom.js +460 -0
  12. assets/graphs/graph_01ea5af0.html +201 -0
  13. assets/graphs/graph_5a169899.html +201 -0
  14. assets/graphs/graph_77739dd7.html +201 -0
  15. assets/styles.css +899 -0
  16. backend/__init__.py +1 -0
  17. backend/database/models.py +58 -0
  18. backend/guardrails.py +33 -0
  19. backend/metadata_recovery.py +211 -0
  20. backend/persistence.py +56 -0
  21. backend/pipeline.py +1050 -0
  22. backend/prompts/__init__.py +30 -0
  23. backend/prompts/planning.py +59 -0
  24. backend/prompts/profiles.py +260 -0
  25. backend/prompts/synthesis.py +203 -0
  26. backend/providers/__init__.py +36 -0
  27. backend/providers/arxiv.py +71 -0
  28. backend/providers/base.py +62 -0
  29. backend/providers/core_.py +27 -0
  30. backend/providers/crossref.py +61 -0
  31. backend/providers/dblp.py +64 -0
  32. backend/providers/doaj.py +38 -0
  33. backend/providers/latam_repositories.py +114 -0
  34. backend/providers/openaire.py +36 -0
  35. backend/providers/openalex.py +70 -0
  36. backend/providers/pubmed.py +85 -0
  37. backend/providers/redalyc.py +28 -0
  38. backend/providers/scopus.py +33 -0
  39. backend/providers/semantic_scholar.py +56 -0
  40. backend/providers/serpapi.py +36 -0
  41. backend/providers/sources.py +45 -0
  42. backend/providers/zenodo.py +27 -0
  43. backend/smart_fusion.py +119 -0
  44. backend/synthesis.py +923 -0
  45. backend/tools/__init__.py +0 -0
  46. backend/tools/dme_extractor.py +105 -0
  47. backend/tools/export_utils.py +370 -0
  48. backend/tools/graph_generator.py +65 -0
  49. backend/tools/latex_compiler.py +74 -0
  50. 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: gray
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(/&quot;/g,'"').replace(/&#39;/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, "&quot;").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