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 ADDED
@@ -0,0 +1,11 @@
1
+ """Core modules for SciMapper."""
2
+
3
+ import sys
4
+
5
+ try:
6
+ if hasattr(sys.stdout, "reconfigure"):
7
+ sys.stdout.reconfigure(encoding="utf-8")
8
+ if hasattr(sys.stderr, "reconfigure"):
9
+ sys.stderr.reconfigure(encoding="utf-8")
10
+ except Exception:
11
+ pass
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
+ }