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/__init__.py
ADDED
core/clusterer.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutoSMS Framework - Módulo de Clustering, c-TF-IDF y Dinámica Temporal
|
|
3
|
+
=====================================================================
|
|
4
|
+
Ejecuta la reducción bidimensional (PCA + t-SNE), agrupamiento semántico,
|
|
5
|
+
extracción formal de palabras clave diferenciales (c-TF-IDF) y el cálculo
|
|
6
|
+
del Radar de Madurez Temporal (CAGR y clasificación de tendencias).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from sklearn.decomposition import PCA
|
|
15
|
+
from sklearn.manifold import TSNE
|
|
16
|
+
from sklearn.cluster import KMeans
|
|
17
|
+
from sklearn.feature_extraction.text import CountVectorizer
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("scimapper.clusterer")
|
|
20
|
+
|
|
21
|
+
class AutoClusterer:
|
|
22
|
+
def __init__(self, config: dict):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.nlp_cfg = config.get("nlp_tuning", {})
|
|
25
|
+
self.output_dir = config.get("project_metadata", {}).get("output_dir", "./output")
|
|
26
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
27
|
+
self.k = self.nlp_cfg.get("n_clusters", 6)
|
|
28
|
+
|
|
29
|
+
def reduce_2d(self, embeddings: np.ndarray) -> np.ndarray:
|
|
30
|
+
logger.info("Reduciendo dimensionalidad a espacio bidimensional (PCA + t-SNE)...")
|
|
31
|
+
n_samples = embeddings.shape[0]
|
|
32
|
+
if n_samples <= 2:
|
|
33
|
+
return np.zeros((n_samples, 2))
|
|
34
|
+
|
|
35
|
+
pca_dims = min(50, embeddings.shape[1], n_samples - 1)
|
|
36
|
+
pca = PCA(n_components=pca_dims, random_state=42)
|
|
37
|
+
pca_feats = pca.fit_transform(embeddings)
|
|
38
|
+
|
|
39
|
+
perp = min(30, max(2, (n_samples - 1) // 3))
|
|
40
|
+
tsne = TSNE(n_components=2, perplexity=perp, learning_rate="auto", init="pca", random_state=42)
|
|
41
|
+
coords = tsne.fit_transform(pca_feats)
|
|
42
|
+
return coords
|
|
43
|
+
|
|
44
|
+
def cluster(self, embeddings: np.ndarray) -> np.ndarray:
|
|
45
|
+
n_samples = embeddings.shape[0]
|
|
46
|
+
actual_k = min(self.k, max(1, n_samples // 2))
|
|
47
|
+
logger.info("Ejecutando agrupamiento no supervisado en k=%d clusters...", actual_k)
|
|
48
|
+
if actual_k <= 1:
|
|
49
|
+
return np.zeros(n_samples, dtype=int)
|
|
50
|
+
kmeans = KMeans(n_clusters=actual_k, random_state=42, n_init=10)
|
|
51
|
+
labels = kmeans.fit_predict(embeddings)
|
|
52
|
+
return labels
|
|
53
|
+
|
|
54
|
+
def extract_c_tf_idf(self, texts: list, labels: np.ndarray, top_n: int = 8) -> dict:
|
|
55
|
+
logger.info("Calculando Class-based TF-IDF (c-TF-IDF) para extraccion lexica...")
|
|
56
|
+
unique_labels = sorted(list(set(labels)))
|
|
57
|
+
docs_per_cluster = []
|
|
58
|
+
for l in unique_labels:
|
|
59
|
+
cluster_docs = [texts[i] for i in range(len(texts)) if labels[i] == l]
|
|
60
|
+
docs_per_cluster.append(" ".join(cluster_docs))
|
|
61
|
+
|
|
62
|
+
stop_words = self.nlp_cfg.get("stop_words", "english")
|
|
63
|
+
if isinstance(stop_words, str) and stop_words.lower() in ("none", "null", ""):
|
|
64
|
+
stop_words = None
|
|
65
|
+
|
|
66
|
+
vec = CountVectorizer(ngram_range=(1, 2), stop_words=stop_words, min_df=1, max_df=1.0)
|
|
67
|
+
X = vec.fit_transform(docs_per_cluster)
|
|
68
|
+
words = vec.get_feature_names_out()
|
|
69
|
+
|
|
70
|
+
tf = X.toarray().astype(float)
|
|
71
|
+
w_sum = tf.sum(axis=1, keepdims=True)
|
|
72
|
+
tf_norm = tf / np.maximum(w_sum, 1)
|
|
73
|
+
|
|
74
|
+
# IDF clásico por documento (cluster): log(1 + N / (df + 1))
|
|
75
|
+
doc_freq = (tf > 0).sum(axis=0).astype(float)
|
|
76
|
+
n_clusters = max(1, len(docs_per_cluster))
|
|
77
|
+
idf = np.log(1.0 + (n_clusters / (doc_freq + 1.0)))
|
|
78
|
+
c_tf_idf = tf_norm * idf
|
|
79
|
+
|
|
80
|
+
keywords_dict = {}
|
|
81
|
+
for idx, l in enumerate(unique_labels):
|
|
82
|
+
top_idx = np.argsort(c_tf_idf[idx])[::-1][:top_n]
|
|
83
|
+
top_words = [words[i] for i in top_idx if len(words[i]) > 2]
|
|
84
|
+
keywords_dict[int(l)] = top_words
|
|
85
|
+
|
|
86
|
+
return keywords_dict
|
|
87
|
+
|
|
88
|
+
def compute_temporal_dynamics(self, df: pd.DataFrame, labels: np.ndarray) -> dict:
|
|
89
|
+
logger.info("Calculando Radar de Madurez Temporal y CAGR por Topico...")
|
|
90
|
+
dynamics = {}
|
|
91
|
+
unique_labels = sorted(list(set(labels)))
|
|
92
|
+
|
|
93
|
+
# Convertir años a enteros válidos
|
|
94
|
+
years_series = pd.to_numeric(df["publication_year"], errors="coerce").dropna()
|
|
95
|
+
if years_series.empty:
|
|
96
|
+
min_year, max_year = 2018, 2026
|
|
97
|
+
else:
|
|
98
|
+
min_year, max_year = int(years_series.min()), int(years_series.max())
|
|
99
|
+
|
|
100
|
+
span = max(1, max_year - min_year)
|
|
101
|
+
|
|
102
|
+
for l in unique_labels:
|
|
103
|
+
sub = df[df["topic_id"] == l]
|
|
104
|
+
sub_years = pd.to_numeric(sub["publication_year"], errors="coerce").dropna().astype(int)
|
|
105
|
+
|
|
106
|
+
yearly_counts = {}
|
|
107
|
+
for y in range(min_year, max_year + 1):
|
|
108
|
+
yearly_counts[y] = int((sub_years == y).sum())
|
|
109
|
+
|
|
110
|
+
# Cálculo de CAGR suavizado (Laplace +1 para evitar ceros)
|
|
111
|
+
start_val = yearly_counts.get(min_year, 0) + yearly_counts.get(min_year + 1, 0)
|
|
112
|
+
end_val = yearly_counts.get(max_year, 0) + yearly_counts.get(max_year - 1, 0)
|
|
113
|
+
|
|
114
|
+
cagr = ((end_val + 1.0) / (start_val + 1.0)) ** (1.0 / max(1, span - 1)) - 1.0
|
|
115
|
+
cagr_pct = round(cagr * 100, 1)
|
|
116
|
+
|
|
117
|
+
# Proporción de producción en los últimos 3 años
|
|
118
|
+
recent_years = [max_year, max_year - 1, max_year - 2]
|
|
119
|
+
recent_count = sum(yearly_counts.get(y, 0) for y in recent_years)
|
|
120
|
+
recent_ratio = round((recent_count / max(1, len(sub))) * 100, 1)
|
|
121
|
+
|
|
122
|
+
# Clasificación de la dinámica
|
|
123
|
+
if cagr_pct >= 12.0 or recent_ratio >= 45.0:
|
|
124
|
+
category = "Emergente (Hot Topic)"
|
|
125
|
+
badge = "🚀 Emergente"
|
|
126
|
+
elif cagr_pct <= -8.0 and recent_ratio <= 22.0:
|
|
127
|
+
category = "En Declive"
|
|
128
|
+
badge = "📉 En Declive"
|
|
129
|
+
else:
|
|
130
|
+
category = "Maduro / Consolidado"
|
|
131
|
+
badge = "⚖️ Maduro"
|
|
132
|
+
|
|
133
|
+
dynamics[int(l)] = {
|
|
134
|
+
"cagr_percent": cagr_pct,
|
|
135
|
+
"recent_ratio_percent": recent_ratio,
|
|
136
|
+
"trend_category": category,
|
|
137
|
+
"badge": badge,
|
|
138
|
+
"yearly_distribution": yearly_counts
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return dynamics
|
|
142
|
+
|
|
143
|
+
def run(self, df: pd.DataFrame, embeddings: np.ndarray) -> tuple:
|
|
144
|
+
df = df.reset_index(drop=True).copy()
|
|
145
|
+
coords = self.reduce_2d(embeddings)
|
|
146
|
+
df["coord_x"] = coords[:, 0]
|
|
147
|
+
df["coord_y"] = coords[:, 1]
|
|
148
|
+
|
|
149
|
+
labels = self.cluster(embeddings)
|
|
150
|
+
df["topic_id"] = labels
|
|
151
|
+
|
|
152
|
+
texts = (df["title"] + ". " + df["abstract"]).tolist()
|
|
153
|
+
keywords = self.extract_c_tf_idf(texts, labels, top_n=8)
|
|
154
|
+
temporal_info = self.compute_temporal_dynamics(df, labels)
|
|
155
|
+
|
|
156
|
+
# Generar JSON de tópicos descubiertos enriquecido
|
|
157
|
+
topics_summary = {}
|
|
158
|
+
for l in sorted(keywords.keys()):
|
|
159
|
+
sub = df[df["topic_id"] == l]
|
|
160
|
+
sample_titles = sub["title"].head(4).tolist()
|
|
161
|
+
t_dyn = temporal_info.get(l, {})
|
|
162
|
+
|
|
163
|
+
topics_summary[l] = {
|
|
164
|
+
"count": len(sub),
|
|
165
|
+
"percentage": round(len(sub) / len(df) * 100, 1),
|
|
166
|
+
"keywords": keywords[l],
|
|
167
|
+
"sample_titles": sample_titles,
|
|
168
|
+
"assigned_name": f"Tópico {l}: {', '.join(keywords[l][:3]).title()}",
|
|
169
|
+
"exclude_prisma": False,
|
|
170
|
+
"temporal_dynamics": t_dyn
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
topics_json_path = os.path.join(self.output_dir, "discovered_topics.json")
|
|
174
|
+
with open(topics_json_path, "w", encoding="utf-8") as f:
|
|
175
|
+
json.dump(topics_summary, f, indent=2, ensure_ascii=False)
|
|
176
|
+
|
|
177
|
+
logger.info("Archivo de ajuste humano generado en: %s", topics_json_path)
|
|
178
|
+
return df, topics_summary
|
core/collector.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutoSMS Framework - Módulo de Recolección (Collector)
|
|
3
|
+
Maneja la ingesta agnóstica de literatura académica desde OpenAlex API,
|
|
4
|
+
reconstrucción de abstracts y deduplicación basada en DOI y hashes de títulos.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import re
|
|
12
|
+
import logging
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
import urllib.error
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("scimapper.collector")
|
|
19
|
+
|
|
20
|
+
def clean_text(text: str) -> str:
|
|
21
|
+
if not text:
|
|
22
|
+
return ""
|
|
23
|
+
return re.sub(r'\s+', ' ', text).strip()
|
|
24
|
+
|
|
25
|
+
def reconstruct_abstract(inverted_index: dict) -> str:
|
|
26
|
+
if not inverted_index:
|
|
27
|
+
return ""
|
|
28
|
+
word_positions = []
|
|
29
|
+
for word, positions in inverted_index.items():
|
|
30
|
+
for pos in positions:
|
|
31
|
+
word_positions.append((pos, word))
|
|
32
|
+
word_positions.sort(key=lambda x: x[0])
|
|
33
|
+
return clean_text(" ".join(word for _, word in word_positions))
|
|
34
|
+
|
|
35
|
+
def normalize_title(t: str) -> str:
|
|
36
|
+
t = re.sub(r'[^a-zA-Z0-9]', '', str(t).lower())
|
|
37
|
+
return t[:80]
|
|
38
|
+
|
|
39
|
+
class AutoCollector:
|
|
40
|
+
def __init__(self, config: dict):
|
|
41
|
+
self.config = config
|
|
42
|
+
self.meta = config.get("project_metadata", {})
|
|
43
|
+
self.strategy = config.get("search_strategy", {})
|
|
44
|
+
self.email = self.strategy.get("openalex_email", "researcher@example.com")
|
|
45
|
+
self.year_min, self.year_max = self.meta.get("temporal_window", [2018, 2026])
|
|
46
|
+
self.max_per_query = self.strategy.get("max_results_per_query", 100)
|
|
47
|
+
self.output_dir = self.meta.get("output_dir", "./output")
|
|
48
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
def fetch_from_openalex(self, query: str, max_retries: int = 3) -> list:
|
|
51
|
+
results = []
|
|
52
|
+
base_url = "https://api.openalex.org/works"
|
|
53
|
+
params = {
|
|
54
|
+
"search": query,
|
|
55
|
+
"filter": f"from_publication_date:{self.year_min}-01-01,to_publication_date:{self.year_max}-12-31,has_abstract:true",
|
|
56
|
+
"per_page": min(50, self.max_per_query),
|
|
57
|
+
"mailto": self.email
|
|
58
|
+
}
|
|
59
|
+
url = f"{base_url}?{urllib.parse.urlencode(params)}"
|
|
60
|
+
logger.info("Consultando: %s", query[:60])
|
|
61
|
+
|
|
62
|
+
obtained = 0
|
|
63
|
+
page = 1
|
|
64
|
+
while obtained < self.max_per_query:
|
|
65
|
+
page_url = f"{url}&page={page}"
|
|
66
|
+
data = None
|
|
67
|
+
for attempt in range(1, max_retries + 1):
|
|
68
|
+
try:
|
|
69
|
+
req = urllib.request.Request(page_url, headers={"User-Agent": f"AutoSMS-Framework ({self.email})"})
|
|
70
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
71
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
72
|
+
break
|
|
73
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as e:
|
|
74
|
+
wait = 2 ** attempt
|
|
75
|
+
logger.warning("Error página %d (intento %d/%d): %s. Reintentando en %ds", page, attempt, max_retries, e, wait)
|
|
76
|
+
time.sleep(wait)
|
|
77
|
+
if data is None:
|
|
78
|
+
logger.error("No se pudo obtener la página %d tras %d intentos. Abortando consulta.", page, max_retries)
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
items = data.get("results", [])
|
|
82
|
+
if not items:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
for it in items:
|
|
86
|
+
title = clean_text(it.get("title") or "")
|
|
87
|
+
abstract = reconstruct_abstract(it.get("abstract_inverted_index"))
|
|
88
|
+
if len(title) < 10 or len(abstract) < 80:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
authors_list = []
|
|
92
|
+
for a in (it.get("authorships") or []):
|
|
93
|
+
auth = a.get("author") or {}
|
|
94
|
+
name = auth.get("display_name")
|
|
95
|
+
if name:
|
|
96
|
+
authors_list.append(name)
|
|
97
|
+
authors = "; ".join(authors_list) or "Desconocido"
|
|
98
|
+
|
|
99
|
+
loc = it.get("primary_location") or {}
|
|
100
|
+
src = loc.get("source") or {}
|
|
101
|
+
venue = src.get("display_name") or "No especificado"
|
|
102
|
+
|
|
103
|
+
results.append({
|
|
104
|
+
"id": it.get("id", ""),
|
|
105
|
+
"doi": it.get("doi", "") or "",
|
|
106
|
+
"title": title,
|
|
107
|
+
"authors": authors,
|
|
108
|
+
"publication_year": it.get("publication_year", ""),
|
|
109
|
+
"venue": venue,
|
|
110
|
+
"abstract": abstract,
|
|
111
|
+
"cited_by_count": it.get("cited_by_count", 0)
|
|
112
|
+
})
|
|
113
|
+
obtained += 1
|
|
114
|
+
if obtained >= self.max_per_query:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
page += 1
|
|
118
|
+
time.sleep(0.3)
|
|
119
|
+
|
|
120
|
+
logger.info("✔ %d registros con abstract obtenidos.", len(results))
|
|
121
|
+
return results
|
|
122
|
+
|
|
123
|
+
def run(self) -> pd.DataFrame:
|
|
124
|
+
print("\n🚀 INICIANDO RECOLECCIÓN AUTOMATIZADA DE LITERATURA...")
|
|
125
|
+
all_records = []
|
|
126
|
+
queries = self.strategy.get("queries", [])
|
|
127
|
+
for q in queries:
|
|
128
|
+
recs = self.fetch_from_openalex(q)
|
|
129
|
+
all_records.extend(recs)
|
|
130
|
+
|
|
131
|
+
# Deduplicación
|
|
132
|
+
seen = set()
|
|
133
|
+
unique_records = []
|
|
134
|
+
for r in all_records:
|
|
135
|
+
doi = r["doi"].strip().lower()
|
|
136
|
+
norm_t = normalize_title(r["title"])
|
|
137
|
+
key = doi if (doi and len(doi) > 5) else norm_t
|
|
138
|
+
if key and key not in seen:
|
|
139
|
+
seen.add(key)
|
|
140
|
+
unique_records.append(r)
|
|
141
|
+
|
|
142
|
+
df = pd.DataFrame(unique_records)
|
|
143
|
+
out_csv = os.path.join(self.output_dir, "corpus_candidatos.csv")
|
|
144
|
+
df.to_csv(out_csv, index=False, encoding="utf-8-sig")
|
|
145
|
+
|
|
146
|
+
print(f"\n=======================================================")
|
|
147
|
+
print(f"📊 REPORTE DE RECOLECCIÓN:")
|
|
148
|
+
print(f" Total registros brutos: {len(all_records)}")
|
|
149
|
+
print(f" Registros únicos filtrados: {len(df)}")
|
|
150
|
+
print(f" Duplicados removidos: {len(all_records) - len(df)}")
|
|
151
|
+
print(f" Archivo guardado en: {out_csv}")
|
|
152
|
+
print(f"=======================================================")
|
|
153
|
+
return df
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project_metadata": {
|
|
3
|
+
"title": "Mapeo Sistemático de la Literatura sobre [Tu Tema Aquí]",
|
|
4
|
+
"short_name": "SMS_Proyecto",
|
|
5
|
+
"authors": ["Tu Nombre", "Coautor 1"],
|
|
6
|
+
"temporal_window": [2018, 2026],
|
|
7
|
+
"output_dir": "./output"
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
"research_questions": {
|
|
11
|
+
"RQ1": "¿Qué enfoques y técnicas principales se aplican actualmente en la disciplina?",
|
|
12
|
+
"RQ2": "¿Qué métricas y metodologías empíricas de evaluación se reportan en la literatura?",
|
|
13
|
+
"RQ3": "¿Cuáles son los principales desafíos abiertos, limitaciones y vacíos de investigación?"
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
"search_strategy": {
|
|
17
|
+
"openalex_email": "tu_email@ejemplo.com",
|
|
18
|
+
"max_results_per_query": 150,
|
|
19
|
+
"queries": [
|
|
20
|
+
"\"primary topic\" AND (\"secondary keyword\" OR \"alternative keyword\")",
|
|
21
|
+
"\"methodology keyword\" AND \"domain keyword\""
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
"nlp_tuning": {
|
|
26
|
+
"domain_preset": "software_engineering",
|
|
27
|
+
"domain_presets_catalog": {
|
|
28
|
+
"software_engineering": "sentence-transformers/all-MiniLM-L6-v2",
|
|
29
|
+
"computer_science_general": "BAAI/bge-small-en-v1.5",
|
|
30
|
+
"biomedical_health": "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract",
|
|
31
|
+
"scientific_general": "allenai/scibert_scivocab_uncased",
|
|
32
|
+
"finance_business": "ProsusAI/finbert",
|
|
33
|
+
"multilingual": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
|
34
|
+
},
|
|
35
|
+
"n_clusters": 6,
|
|
36
|
+
"dimensionality_reduction": "pca_tsne"
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
"qualitative_synthesis": {
|
|
40
|
+
"candidates_per_topic": 3,
|
|
41
|
+
"min_citations": 1
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
"prisma_audit": {
|
|
45
|
+
"deduplication_keys": ["doi", "title_hash"],
|
|
46
|
+
"quality_threshold": 3.5
|
|
47
|
+
}
|
|
48
|
+
}
|