scimapper 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
core/doc_builder.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ AutoSMS Framework - Módulo Generador de Documentos Académicos (DocBuilder)
3
+ ========================================================================
4
+ Construye automáticamente un borrador formal en Microsoft Word (.docx)
5
+ con tipografía académica estandarizada (Times New Roman 10pt, justificado),
6
+ tablas unificadas (#666666 / #F0F4F8), diagrama PRISMA tabulado, mapeo
7
+ taxonómico y la estructura de Discusión Cualitativa con los papers seleccionados.
8
+ """
9
+
10
+ import os
11
+ import json
12
+ import docx
13
+ from docx.shared import Inches, Pt, RGBColor
14
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
15
+ from docx.enum.table import WD_TABLE_ALIGNMENT
16
+ from docx.oxml import OxmlElement, parse_xml
17
+ from docx.oxml.ns import nsdecls, qn
18
+
19
+ def set_cell_background(cell, color_hex: str):
20
+ """Asigna color de fondo a una celda de tabla."""
21
+ tcPr = cell._tc.get_or_add_tcPr()
22
+ for child in list(tcPr):
23
+ if child.tag.endswith('shd'):
24
+ tcPr.remove(child)
25
+ shading_elm = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color_hex}"/>')
26
+ tcPr.append(shading_elm)
27
+
28
+ def set_cell_margins(cell, top=80, bottom=80, left=120, right=120):
29
+ """Establece márgenes internos (padding) de la celda en dxa."""
30
+ tcPr = cell._tc.get_or_add_tcPr()
31
+ for child in list(tcPr):
32
+ if child.tag.endswith('tcMar'):
33
+ tcPr.remove(child)
34
+ tcMar = OxmlElement('w:tcMar')
35
+ for m, val in [('top', top), ('bottom', bottom), ('left', left), ('right', right)]:
36
+ node = OxmlElement(f'w:{m}')
37
+ node.set(qn('w:w'), str(val))
38
+ node.set(qn('w:type'), 'dxa')
39
+ tcMar.append(node)
40
+ tcPr.append(tcMar)
41
+
42
+ def style_academic_table(table, col_widths=None):
43
+ """Aplica formato visual académico uniforme a cualquier tabla."""
44
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
45
+ table.autofit = False
46
+
47
+ for row_idx, row in enumerate(table.rows):
48
+ is_header = (row_idx == 0)
49
+ bg_color = "666666" if is_header else ("F0F4F8" if row_idx % 2 == 1 else "FFFFFF")
50
+
51
+ for col_idx, cell in enumerate(row.cells):
52
+ set_cell_background(cell, bg_color)
53
+ set_cell_margins(cell, top=70, bottom=70, left=110, right=110)
54
+
55
+ if col_widths and col_idx < len(col_widths):
56
+ cell.width = col_widths[col_idx]
57
+
58
+ for p in cell.paragraphs:
59
+ p.paragraph_format.line_spacing = 1.05
60
+ p.paragraph_format.space_before = Pt(2)
61
+ p.paragraph_format.space_after = Pt(2)
62
+ for run in p.runs:
63
+ run.font.name = "Times New Roman"
64
+ run.font.size = Pt(9)
65
+ if is_header:
66
+ run.font.bold = True
67
+ run.font.color.rgb = RGBColor(255, 255, 255)
68
+ else:
69
+ run.font.color.rgb = RGBColor(30, 30, 30)
70
+
71
+ class AutoDocBuilder:
72
+ def __init__(self, config: dict):
73
+ self.config = config
74
+ self.meta = config.get("project_metadata", {})
75
+ self.rqs = config.get("research_questions", {})
76
+ self.output_dir = self.meta.get("output_dir", "./output")
77
+ self.title = self.meta.get("title", "Mapeo Sistemático de la Literatura")
78
+ self.authors = ", ".join(self.meta.get("authors", ["Investigador Principal"]))
79
+ self.years = self.meta.get("temporal_window", [2018, 2026])
80
+
81
+ def add_p(self, doc, text="", bold_prefix="", italic=False, space_after=4, align=WD_ALIGN_PARAGRAPH.JUSTIFY):
82
+ p = doc.add_paragraph()
83
+ p.alignment = align
84
+ p.paragraph_format.line_spacing = 1.15
85
+ p.paragraph_format.space_after = Pt(space_after)
86
+ p.paragraph_format.space_before = Pt(0)
87
+
88
+ if bold_prefix:
89
+ r_pre = p.add_run(bold_prefix)
90
+ r_pre.font.name = "Times New Roman"
91
+ r_pre.font.size = Pt(10)
92
+ r_pre.font.bold = True
93
+
94
+ if text:
95
+ r = p.add_run(text)
96
+ r.font.name = "Times New Roman"
97
+ r.font.size = Pt(10)
98
+ r.font.italic = italic
99
+ return p
100
+
101
+ def add_heading_1(self, doc, title_text):
102
+ p = doc.add_paragraph()
103
+ p.paragraph_format.space_before = Pt(14)
104
+ p.paragraph_format.space_after = Pt(6)
105
+ p.paragraph_format.keep_with_next = True
106
+ r = p.add_run(title_text)
107
+ r.font.name = "Times New Roman"
108
+ r.font.size = Pt(12)
109
+ r.font.bold = True
110
+ r.font.color.rgb = RGBColor(0, 51, 102)
111
+ return p
112
+
113
+ def add_heading_2(self, doc, title_text):
114
+ p = doc.add_paragraph()
115
+ p.paragraph_format.space_before = Pt(10)
116
+ p.paragraph_format.space_after = Pt(4)
117
+ p.paragraph_format.keep_with_next = True
118
+ r = p.add_run(title_text)
119
+ r.font.name = "Times New Roman"
120
+ r.font.size = Pt(10.5)
121
+ r.font.bold = True
122
+ r.font.italic = True
123
+ r.font.color.rgb = RGBColor(50, 50, 50)
124
+ return p
125
+
126
+ def generate_draft(self) -> str:
127
+ print("\n📄 GENERANDO BORRADOR FORMAL EN MICROSOFT WORD (.docx)...")
128
+ doc = docx.Document()
129
+
130
+ # Configuración de márgenes estándar (2.5 cm)
131
+ sections = doc.sections
132
+ for s in sections:
133
+ s.top_margin = Inches(1.0)
134
+ s.bottom_margin = Inches(1.0)
135
+ s.left_margin = Inches(1.0)
136
+ s.right_margin = Inches(1.0)
137
+
138
+ # 1. Título y Metadatos
139
+ p_title = doc.add_paragraph()
140
+ p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
141
+ p_title.paragraph_format.space_after = Pt(8)
142
+ r_title = p_title.add_run(self.title)
143
+ r_title.font.name = "Times New Roman"
144
+ r_title.font.size = Pt(16)
145
+ r_title.font.bold = True
146
+
147
+ p_sub = doc.add_paragraph()
148
+ p_sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
149
+ p_sub.paragraph_format.space_after = Pt(18)
150
+ r_sub = p_sub.add_run(f"{self.authors}\nVentana de Estudio: {self.years[0]} – {self.years[1]}")
151
+ r_sub.font.name = "Times New Roman"
152
+ r_sub.font.size = Pt(10.5)
153
+ r_sub.font.italic = True
154
+
155
+ # Resumen / Abstract
156
+ self.add_p(doc,
157
+ f"El presente estudio reporta un Mapeo Sistemático de la Literatura (SMS) conducido bajo las directrices PRISMA 2020. "
158
+ f"Se aplicó un pipeline computacional no supervisado basado en modelos de lenguaje denso (Sentence-Transformers) y "
159
+ f"extracción léxica diferencial (c-TF-IDF), acoplado con supervisión experta continua (Human-in-the-Loop) para categorizar "
160
+ f"el estado del arte, identificar vacíos de investigación y sintetizar evidencias empíricas.",
161
+ bold_prefix="Resumen— ")
162
+
163
+ # 2. SECCIÓN I: INTRODUCCIÓN
164
+ self.add_heading_1(doc, "I. INTRODUCCIÓN")
165
+ self.add_p(doc,
166
+ "La creciente proliferación de literatura científica en esta disciplina exige metodologías sistemáticas y reproducibles "
167
+ "capaces de caracterizar el panorama bibliográfico sin sesgos de selección manual. "
168
+ "Para guiar esta investigación, se formularon las siguientes Preguntas de Investigación (RQs):")
169
+
170
+ for rq_id, rq_desc in self.rqs.items():
171
+ self.add_p(doc, rq_desc, bold_prefix=f"• {rq_id}: ")
172
+
173
+ # 3. SECCIÓN II: METODOLOGÍA Y FLUJO PRISMA 2020
174
+ self.add_heading_1(doc, "II. METODOLOGÍA Y PROTOCOLO PRISMA")
175
+ self.add_p(doc,
176
+ "El proceso de selección bibliográfica siguió el estándar internacional PRISMA 2020 (Preferred Reporting Items for Systematic "
177
+ "Reviews and Meta-Analyses), combinando recolección automatizada de metadatos, deduplicación algorítmica y filtrado temático experto.")
178
+
179
+ # Cargar datos de tópicos y candidatos
180
+ topics_json_path = os.path.join(self.output_dir, "discovered_topics.json")
181
+ candidates_json_path = os.path.join(self.output_dir, "candidatos_discusion.json")
182
+
183
+ topics_info = {}
184
+ if os.path.exists(topics_json_path):
185
+ with open(topics_json_path, "r", encoding="utf-8") as f:
186
+ topics_info = json.load(f)
187
+
188
+ candidates = []
189
+ if os.path.exists(candidates_json_path):
190
+ with open(candidates_json_path, "r", encoding="utf-8") as f:
191
+ candidates = json.load(f)
192
+
193
+ total_mapped = sum(t.get("count", 0) for t in topics_info.values() if not t.get("exclude_prisma", False))
194
+ total_excluded = sum(t.get("count", 0) for t in topics_info.values() if t.get("exclude_prisma", False))
195
+ total_raw = total_mapped + total_excluded
196
+
197
+ # Tabla PRISMA
198
+ t_prisma = doc.add_table(rows=5, cols=4)
199
+ t_prisma.rows[0].cells[0].paragraphs[0].text = "Fase PRISMA"
200
+ t_prisma.rows[0].cells[1].paragraphs[0].text = "Descripción del Proceso"
201
+ t_prisma.rows[0].cells[2].paragraphs[0].text = "Registros"
202
+ t_prisma.rows[0].cells[3].paragraphs[0].text = "Criterio Aplicado"
203
+
204
+ datos_prisma = [
205
+ ("1. Identificación", "Búsqueda automatizada en fuentes académicas abiertas (OpenAlex)", f"N = {total_raw}", "Consultas booleanas estructuradas"),
206
+ ("2. Deduplicación", "Fusión de registros redundantes mediante DOI y hashes alfanuméricos", f"Filtrado", "Normalización estricta de títulos y DOIs"),
207
+ ("3. Screening Temático", "Agrupamiento vectorial y evaluación humana de relevancia", f"Excluidos: {total_excluded}", "Criterios de inclusión/exclusión del protocolo"),
208
+ ("4. Inclusión Final", "Corpus consolidado analizado en el mapeo conceptual y discusión", f"N = {total_mapped}", "Aptos para síntesis cuantitativa y cualitativa")
209
+ ]
210
+
211
+ for i, row in enumerate(datos_prisma):
212
+ for c_idx in range(4):
213
+ t_prisma.rows[i+1].cells[c_idx].paragraphs[0].text = row[c_idx]
214
+
215
+ style_academic_table(t_prisma, [Inches(1.3), Inches(2.5), Inches(1.1), Inches(1.6)])
216
+
217
+ # 4. SECCIÓN III: RESULTADOS Y TAXONOMÍA DEL MAPEO
218
+ self.add_heading_1(doc, "III. RESULTADOS: TAXONOMÍA Y DINÁMICA TEMPORAL")
219
+ self.add_p(doc,
220
+ "El agrupamiento vectorial no supervisado acoplado a c-TF-IDF y el análisis de trayectorias temporales "
221
+ "permitieron caracterizar la estructura temática del corpus y su tasa de crecimiento anual compuesta (CAGR).")
222
+
223
+ # Tabla de Taxonomías con Dinámica Temporal
224
+ t_tax = doc.add_table(rows=1 + len(topics_info), cols=6)
225
+ t_tax.rows[0].cells[0].paragraphs[0].text = "ID"
226
+ t_tax.rows[0].cells[1].paragraphs[0].text = "Taxonomía / Tópico Descubierto"
227
+ t_tax.rows[0].cells[2].paragraphs[0].text = "Artículos"
228
+ t_tax.rows[0].cells[3].paragraphs[0].text = "%"
229
+ t_tax.rows[0].cells[4].paragraphs[0].text = "Dinámica y CAGR"
230
+ t_tax.rows[0].cells[5].paragraphs[0].text = "Descriptores Léxicos (c-TF-IDF)"
231
+
232
+ for r_idx, (tid, info) in enumerate(sorted(topics_info.items(), key=lambda x: int(x[0]))):
233
+ status = " [Excluido]" if info.get("exclude_prisma", False) else ""
234
+ t_dyn = info.get("temporal_dynamics", {})
235
+ badge = t_dyn.get("badge", "⚖️ Maduro")
236
+ cagr = t_dyn.get("cagr_percent", 0.0)
237
+
238
+ t_tax.rows[r_idx+1].cells[0].paragraphs[0].text = str(tid)
239
+ t_tax.rows[r_idx+1].cells[1].paragraphs[0].text = f"{info.get('assigned_name')}{status}"
240
+ t_tax.rows[r_idx+1].cells[2].paragraphs[0].text = str(info.get("count", 0))
241
+ t_tax.rows[r_idx+1].cells[3].paragraphs[0].text = f"{info.get('percentage', 0)}%"
242
+ t_tax.rows[r_idx+1].cells[4].paragraphs[0].text = f"{badge} ({cagr:+.1f}%)"
243
+ t_tax.rows[r_idx+1].cells[5].paragraphs[0].text = ", ".join(info.get("keywords", [])[:5])
244
+
245
+ style_academic_table(t_tax, [Inches(0.4), Inches(2.0), Inches(0.7), Inches(0.5), Inches(1.4), Inches(1.5)])
246
+
247
+ self.add_p(doc,
248
+ "Como se observa en la tabla precedente, los tópicos catalogados como 'Emergentes' (🚀) exhiben una marcada aceleración "
249
+ "en su volumen de publicaciones en los últimos 3 años (CAGR > 12%), reflejando las líneas de frontera investigativa. "
250
+ "Por su parte, los tópicos 'Maduros' (⚖️) representan los fundamentos metodológicos consolidados de la disciplina.",
251
+ italic=True)
252
+
253
+ # 5. SECCIÓN IV: DISCUSIÓN CUALITATIVA
254
+ self.add_heading_1(doc, "IV. DISCUSIÓN Y SÍNTESIS CUALITATIVA")
255
+ self.add_p(doc,
256
+ "Para complementar la visión macroscópica del mapeo, se realizó un análisis cualitativo a texto completo "
257
+ "sobre una muestra intencional estratificada de artículos representativos (medoides y publicaciones de alto impacto). "
258
+ "Esta síntesis responde a las metodologías empíricas y vacíos planteados en las RQs.")
259
+
260
+ selected_papers = [c for c in candidates if c.get("selected_for_deep_review", False)]
261
+ if not selected_papers and candidates:
262
+ # Si el usuario no marcó ninguno aún, tomar el top 1 de cada tópico
263
+ selected_papers = [c for c in candidates if c.get("rank_in_topic", 1) == 1]
264
+
265
+ # Tabla de Artículos Seleccionados
266
+ self.add_heading_2(doc, "Matriz de Artículos Analizados a Texto Completo")
267
+ t_sel = doc.add_table(rows=1 + len(selected_papers), cols=5)
268
+ t_sel.rows[0].cells[0].paragraphs[0].text = "Ref"
269
+ t_sel.rows[0].cells[1].paragraphs[0].text = "Artículo / Autores"
270
+ t_sel.rows[0].cells[2].paragraphs[0].text = "Año | Citas"
271
+ t_sel.rows[0].cells[3].paragraphs[0].text = "Tópico"
272
+ t_sel.rows[0].cells[4].paragraphs[0].text = "Técnica / Aporte Principal"
273
+
274
+ for p_idx, p_data in enumerate(selected_papers):
275
+ t_sel.rows[p_idx+1].cells[0].paragraphs[0].text = f"P{p_idx+1}"
276
+ t_sel.rows[p_idx+1].cells[1].paragraphs[0].text = f"{p_data.get('title')}\n({p_data.get('authors')[:40]}...)"
277
+ t_sel.rows[p_idx+1].cells[2].paragraphs[0].text = f"{p_data.get('publication_year')} | {p_data.get('cited_by_count')} citas"
278
+ t_sel.rows[p_idx+1].cells[3].paragraphs[0].text = str(p_data.get("topic_name"))[:25]
279
+ t_sel.rows[p_idx+1].cells[4].paragraphs[0].text = "[Completar técnica y aporte específico]"
280
+
281
+ style_academic_table(t_sel, [Inches(0.5), Inches(2.6), Inches(1.0), Inches(1.1), Inches(1.3)])
282
+
283
+ # Subsecciones por artículo para redactar hallazgos
284
+ for p_idx, p_data in enumerate(selected_papers):
285
+ self.add_heading_2(doc, f"Análisis de P{p_idx+1}: {p_data.get('title')}")
286
+ self.add_p(doc, f"Autores: {p_data.get('authors')} ({p_data.get('publication_year')}). Publicado en: {p_data.get('venue')}. DOI: {p_data.get('doi')}")
287
+ self.add_p(doc, p_data.get('abstract', ''), bold_prefix="Síntesis del Abstract: ", italic=True)
288
+ self.add_p(doc,
289
+ f"[Espacio para el investigador]: Describir aquí los hallazgos empíricos clave de este estudio en relación con {list(self.rqs.keys())[0] if self.rqs else 'las RQs'}. "
290
+ f"Evaluar el rigor de su validación experimental y las limitaciones identificadas por los autores.",
291
+ bold_prefix="Hallazgos Empíricos y Limitaciones: ")
292
+
293
+ # 6. SECCIÓN V: AMENAZAS A LA VALIDEZ
294
+ self.add_heading_1(doc, "V. AMENAZAS A LA VALIDEZ")
295
+ self.add_p(doc, "Sesgo de cobertura en fuentes bibliográficas y sensibilidad de las consultas de búsqueda.", bold_prefix="• Validez de Construcción: ")
296
+ self.add_p(doc, "Posibles sesgos en la interpretación humana durante el nombramiento de clusters y selección de papers para la discusión profunda.", bold_prefix="• Validez Interna: ")
297
+ self.add_p(doc, "Generalizabilidad de los patrones taxonómicos a entornos industriales no documentados formalmente en la literatura indexada.", bold_prefix="• Validez Externa: ")
298
+
299
+ # 7. SECCIÓN VI: CONCLUSIONES
300
+ self.add_heading_1(doc, "VI. CONCLUSIONES Y TRABAJO FUTURO")
301
+ self.add_p(doc,
302
+ "El mapeo sistemático ha permitido sintetizar de manera estructurada y auditable el estado del arte en el dominio. "
303
+ "La integración de modelos de lenguaje densos con supervisión humana experta ofreció un balance óptimo entre "
304
+ "exhaustividad de cobertura y precisión interpretativa, identificando oportunidades prometedoras para futuras investigaciones.")
305
+
306
+ # Guardar archivo con manejo de fallback
307
+ out_docx = os.path.join(self.output_dir, f"{self.meta.get('short_name', 'Mapeo_Sistematico')}_BORRADOR.docx")
308
+ try:
309
+ doc.save(out_docx)
310
+ print(f"✔ Documento Word formal guardado exitosamente en: {out_docx}")
311
+ return out_docx
312
+ except PermissionError:
313
+ fallback = os.path.join(self.output_dir, f"{self.meta.get('short_name', 'Mapeo_Sistematico')}_BORRADOR_NUEVO.docx")
314
+ doc.save(fallback)
315
+ print(f"⚠️ El archivo original estaba abierto en Word. Guardado en fallback: {fallback}")
316
+ return fallback
core/embedder.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ AutoSMS Framework - Módulo de Embeddings (Embedder)
3
+ Gestiona la selección de modelos de lenguaje por dominio,
4
+ vectorización semántica densa y caché de vectores binarios.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import hashlib
10
+ import numpy as np
11
+ import pandas as pd
12
+ import logging
13
+ from sentence_transformers import SentenceTransformer
14
+
15
+ logger = logging.getLogger("scimapper.embedder")
16
+
17
+ class AutoEmbedder:
18
+ def __init__(self, config: dict):
19
+ self.config = config
20
+ self.nlp_cfg = config.get("nlp_tuning", {})
21
+ self.output_dir = config.get("project_metadata", {}).get("output_dir", "./output")
22
+ os.makedirs(self.output_dir, exist_ok=True)
23
+
24
+ # Determinar modelo según preset o selección personalizada
25
+ preset = self.nlp_cfg.get("domain_preset", "software_engineering")
26
+ catalog = self.nlp_cfg.get("domain_presets_catalog", {})
27
+ self.model_name = catalog.get(preset, self.nlp_cfg.get("embedding_model", "sentence-transformers/all-MiniLM-L6-v2"))
28
+ model_slug = hashlib.sha256(self.model_name.encode()).hexdigest()[:8]
29
+ self.cache_file = os.path.join(self.output_dir, f"embeddings_cache_{model_slug}.npy")
30
+ self.meta_file = os.path.join(self.output_dir, f"embeddings_cache_{model_slug}.json")
31
+
32
+ def _cache_valid(self, n_texts: int) -> bool:
33
+ if not (os.path.exists(self.cache_file) and os.path.exists(self.meta_file)):
34
+ return False
35
+ try:
36
+ with open(self.meta_file, "r", encoding="utf-8") as f:
37
+ meta = json.load(f)
38
+ if meta.get("model_name") != self.model_name:
39
+ return False
40
+ if meta.get("n_texts") != n_texts:
41
+ return False
42
+ arr = np.load(self.cache_file)
43
+ if arr.shape[0] != n_texts:
44
+ return False
45
+ return True
46
+ except Exception:
47
+ return False
48
+
49
+ def encode(self, df: pd.DataFrame) -> np.ndarray:
50
+ logger.info("GENERACIÓN DE EMBEDDINGS DENSOS...")
51
+ logger.info("Modelo seleccionado: %s", self.model_name)
52
+
53
+ textos = (df["title"] + ". " + df["abstract"]).tolist()
54
+
55
+ if self._cache_valid(len(textos)):
56
+ logger.info("Cargando vectores precalculados desde caché: %s", self.cache_file)
57
+ return np.load(self.cache_file)
58
+
59
+ logger.info("Iniciando inferencia semántica sobre %d documentos...", len(textos))
60
+ model = SentenceTransformer(self.model_name)
61
+ embeddings = model.encode(textos, show_progress_bar=True, batch_size=32, normalize_embeddings=True)
62
+
63
+ np.save(self.cache_file, embeddings)
64
+ with open(self.meta_file, "w", encoding="utf-8") as f:
65
+ json.dump(
66
+ {"model_name": self.model_name, "n_texts": len(textos), "shape": list(embeddings.shape)},
67
+ f, indent=2, ensure_ascii=False,
68
+ )
69
+ logger.info("Vectores guardados (%d docs, %d dims).", embeddings.shape[0], embeddings.shape[1])
70
+ return embeddings
core/selector.py ADDED
@@ -0,0 +1,129 @@
1
+ """
2
+ AutoSMS Framework - Módulo de Selección Estratificada (Selector)
3
+ ==============================================================
4
+ Identifica de forma algorítmica los artículos candidatos más representativos
5
+ para la Discusión Cualitativa Profunda mediante:
6
+ 1. Umbral inteligente de citas mínimas (prioriza papers con validación bibliométrica).
7
+ 2. Centralidad semántica respecto al centroide del cluster (medoide).
8
+ 3. Impacto de citas normalizado logarítmicamente.
9
+ 4. Cobertura proporcional balanceada por cada una de las taxonomías activas.
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ class AutoSelector:
18
+ def __init__(self, config: dict):
19
+ self.config = config
20
+ self.output_dir = config.get("project_metadata", {}).get("output_dir", "./output")
21
+ self.synth_cfg = config.get("qualitative_synthesis", {})
22
+ self.top_per_topic = self.synth_cfg.get("candidates_per_topic", 3)
23
+ self.min_citations = self.synth_cfg.get("min_citations", 1)
24
+
25
+ def select_candidates(self, df: pd.DataFrame, embeddings: np.ndarray, topics_info: dict) -> list:
26
+ print("\n🎯 SELECCIÓN ESTRATIFICADA DE ARTÍCULOS PARA LA DISCUSIÓN...")
27
+ print(f" Estrategia: Centralidad Semántica (60%) + Citas (40%) | Umbral preferente: >= {self.min_citations} citas")
28
+
29
+ df = df.reset_index(drop=True).copy()
30
+ if len(df) != len(embeddings):
31
+ raise ValueError(f"Desalineación df/embeddings: {len(df)} vs {len(embeddings)}")
32
+
33
+ candidates_list = []
34
+
35
+ # Asegurar tipos y columnas
36
+ df["topic_id"] = df["topic_id"].astype(int)
37
+ df["cited_by_count"] = pd.to_numeric(df.get("cited_by_count", 0), errors="coerce").fillna(0).astype(int)
38
+
39
+ for tid_str, t_info in topics_info.items():
40
+ tid = int(tid_str)
41
+ if t_info.get("exclude_prisma", False):
42
+ print(f" [!] Omitiendo Tópico {tid} (marcado como excluido en PRISMA).")
43
+ continue
44
+
45
+ indices = df.index[df["topic_id"] == tid].tolist()
46
+ if not indices:
47
+ continue
48
+
49
+ sub_embs = embeddings[indices]
50
+ sub_df = df.loc[indices].copy()
51
+
52
+ # 1. Calcular centroide semántico del cluster
53
+ centroid = np.mean(sub_embs, axis=0)
54
+ centroid_norm = np.linalg.norm(centroid) + 1e-9
55
+
56
+ # 2. Similaridad Coseno con el centroide (Centralidad)
57
+ sub_norms = np.linalg.norm(sub_embs, axis=1) + 1e-9
58
+ cos_sim = np.dot(sub_embs, centroid) / (sub_norms * centroid_norm)
59
+ sim_min, sim_max = cos_sim.min(), cos_sim.max()
60
+ sim_scaled = (cos_sim - sim_min) / (sim_max - sim_min + 1e-9)
61
+
62
+ # 3. Impacto de Citas normalizado logarítmicamente
63
+ cites = sub_df["cited_by_count"].values
64
+ cite_log = np.log1p(cites)
65
+ cite_min, cite_max = cite_log.min(), cite_log.max()
66
+ cite_scaled = (cite_log - cite_min) / (cite_max - cite_min + 1e-9)
67
+
68
+ # 4. Puntuación combinada multiobjetivo
69
+ composite_score = (0.60 * sim_scaled) + (0.40 * cite_scaled)
70
+ sub_df["score_representatividad"] = np.round(composite_score, 4)
71
+ sub_df["centralidad_semantica"] = np.round(cos_sim, 4)
72
+
73
+ # 5. Filtro inteligente de citas mínimas:
74
+ # Si existen papers en este cluster con >= min_citations, se les da prioridad
75
+ has_cited_papers = (sub_df["cited_by_count"] >= self.min_citations).any()
76
+ sub_df["meets_citation_threshold"] = sub_df["cited_by_count"] >= self.min_citations
77
+
78
+ if has_cited_papers:
79
+ # Priorizar primero los que superan el umbral de citas, luego por score
80
+ sorted_candidates = sub_df.sort_values(
81
+ by=["meets_citation_threshold", "score_representatividad"],
82
+ ascending=[False, False]
83
+ ).head(self.top_per_topic)
84
+ else:
85
+ # Si todos tienen 0 citas (ej. cluster 100% de 2025/2026), ordenar puramente por score
86
+ sorted_candidates = sub_df.sort_values(by="score_representatividad", ascending=False).head(self.top_per_topic)
87
+
88
+ for rank, (_, row) in enumerate(sorted_candidates.iterrows()):
89
+ is_preselected = (rank == 0)
90
+ n_cites = int(row["cited_by_count"])
91
+
92
+ if n_cites >= self.min_citations:
93
+ notes = f"✔ Validación bibliométrica confirmada ({n_cites} citas)."
94
+ else:
95
+ notes = "⚠️ Sin citas acumuladas (publicación reciente/early access). Validar aporte cualitativo."
96
+
97
+ candidates_list.append({
98
+ "topic_id": tid,
99
+ "topic_name": t_info.get("assigned_name", f"Tópico {tid}"),
100
+ "rank_in_topic": rank + 1,
101
+ "selected_for_deep_review": is_preselected,
102
+ "title": str(row["title"]),
103
+ "authors": str(row["authors"]),
104
+ "publication_year": int(row["publication_year"]) if str(row["publication_year"]).isdigit() else 2024,
105
+ "venue": str(row["venue"]),
106
+ "cited_by_count": n_cites,
107
+ "doi": str(row["doi"]),
108
+ "centrality_score": float(row["centralidad_semantica"]),
109
+ "composite_score": float(row["score_representatividad"]),
110
+ "meets_citation_threshold": bool(row["meets_citation_threshold"]),
111
+ "abstract": str(row["abstract"]),
112
+ "human_notes": notes
113
+ })
114
+
115
+ # Guardar en JSON (para edición humana)
116
+ json_out = os.path.join(self.output_dir, "candidatos_discusion.json")
117
+ with open(json_out, "w", encoding="utf-8") as f:
118
+ json.dump(candidates_list, f, indent=2, ensure_ascii=False)
119
+
120
+ # Guardar también en CSV (para lectura en Excel)
121
+ csv_out = os.path.join(self.output_dir, "candidatos_discusion.csv")
122
+ export_df = pd.DataFrame(candidates_list).drop(columns=["abstract"])
123
+ export_df.to_csv(csv_out, index=False, encoding="utf-8-sig")
124
+
125
+ print(f"✔ Lista de candidatos sugeridos generada ({len(candidates_list)} artículos evaluados):")
126
+ print(f" JSON editable: {json_out}")
127
+ print(f" CSV resumido: {csv_out}")
128
+ print("💡 Abre 'candidatos_discusion.json' y ajusta 'selected_for_deep_review': true/false según tu criterio.")
129
+ return candidates_list
core/utils.py ADDED
@@ -0,0 +1,79 @@
1
+ """Utility helpers for SciMapper: logging, validation and reproducibility."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import hashlib
7
+ from datetime import datetime
8
+
9
+
10
+ def setup_logging(verbose: bool = False) -> logging.Logger:
11
+ level = logging.DEBUG if verbose else logging.INFO
12
+ logging.basicConfig(
13
+ level=level,
14
+ format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
15
+ datefmt="%H:%M:%S",
16
+ )
17
+ return logging.getLogger("scimapper")
18
+
19
+
20
+ def validate_config(config: dict) -> list:
21
+ """Return a list of validation errors (empty if valid)."""
22
+ errors = []
23
+ meta = config.get("project_metadata", {})
24
+ if not isinstance(meta, dict):
25
+ errors.append("project_metadata debe ser un objeto.")
26
+ return errors
27
+
28
+ if not meta.get("title"):
29
+ errors.append("project_metadata.title es obligatorio.")
30
+ if not meta.get("short_name"):
31
+ errors.append("project_metadata.short_name es obligatorio.")
32
+
33
+ years = meta.get("temporal_window", [])
34
+ if not (isinstance(years, list) and len(years) == 2):
35
+ errors.append("project_metadata.temporal_window debe ser [año_inicio, año_fin].")
36
+ else:
37
+ try:
38
+ y0, y1 = int(years[0]), int(years[1])
39
+ if y0 > y1:
40
+ errors.append("temporal_window: el año inicial no puede ser mayor al final.")
41
+ if y0 < 1900 or y1 > 2100:
42
+ errors.append("temporal_window: rango de años fuera de límites razonables.")
43
+ except (TypeError, ValueError):
44
+ errors.append("temporal_window debe contener enteros.")
45
+
46
+ strategy = config.get("search_strategy", {})
47
+ queries = strategy.get("queries", [])
48
+ if not isinstance(queries, list) or not queries:
49
+ errors.append("search_strategy.queries debe ser una lista no vacía.")
50
+ if not strategy.get("openalex_email"):
51
+ errors.append("search_strategy.openalex_email es obligatorio.")
52
+
53
+ nlp = config.get("nlp_tuning", {})
54
+ k = nlp.get("n_clusters", 6)
55
+ try:
56
+ if int(k) < 2 or int(k) > 50:
57
+ errors.append("nlp_tuning.n_clusters debe estar entre 2 y 50.")
58
+ except (TypeError, ValueError):
59
+ errors.append("nlp_tuning.n_clusters debe ser entero.")
60
+
61
+ return errors
62
+
63
+
64
+ def config_hash(config: dict) -> str:
65
+ blob = json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")
66
+ return hashlib.sha256(blob).hexdigest()[:12]
67
+
68
+
69
+ def write_manifest(output_dir: str, config: dict, counts: dict) -> str:
70
+ os.makedirs(output_dir, exist_ok=True)
71
+ manifest = {
72
+ "timestamp": datetime.utcnow().isoformat() + "Z",
73
+ "config_hash": config_hash(config),
74
+ "counts": counts,
75
+ }
76
+ path = os.path.join(output_dir, "run_manifest.json")
77
+ with open(path, "w", encoding="utf-8") as f:
78
+ json.dump(manifest, f, indent=2, ensure_ascii=False)
79
+ return path