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/__init__.py +11 -0
- core/clusterer.py +178 -0
- core/collector.py +153 -0
- core/config_template.json +48 -0
- core/doc_builder.py +316 -0
- core/embedder.py +70 -0
- core/selector.py +129 -0
- core/utils.py +79 -0
- core/visualizer.py +271 -0
- scimapper-1.0.1.dist-info/METADATA +252 -0
- scimapper-1.0.1.dist-info/RECORD +15 -0
- scimapper-1.0.1.dist-info/WHEEL +5 -0
- scimapper-1.0.1.dist-info/entry_points.txt +2 -0
- scimapper-1.0.1.dist-info/licenses/LICENSE +21 -0
- scimapper-1.0.1.dist-info/top_level.txt +1 -0
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
|