Shivani-Bhat commited on
Commit
722ed5a
Β·
verified Β·
1 Parent(s): f8b1ac8

Update tools.py

Browse files
Files changed (1) hide show
  1. tools.py +863 -1
tools.py CHANGED
@@ -27,6 +27,25 @@ from gensim.models.phrases import Phrases, Phraser
27
 
28
  from tqdm import tqdm
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  pd.options.mode.chained_assignment = None
31
 
32
  # ---------------------------------------------------------------------------
@@ -45,6 +64,45 @@ _RE_WHITESPACE = re.compile(r'\s+')
45
  # NLTK Bootstrap
46
  # ---------------------------------------------------------------------------
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def _bootstrap_nltk() -> None:
49
  """Silently download required NLTK packages if missing."""
50
  packages = [
@@ -1128,4 +1186,808 @@ def export_all_artifacts(
1128
  logger.error(f"Failed to save narrative.txt: {e}")
1129
 
1130
  logger.info(f"Exported {len(artifacts)} artifacts to {output_dir}/")
1131
- return artifacts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  from tqdm import tqdm
29
 
30
+ # Optional heavy deps β€” imported lazily inside functions
31
+ try:
32
+ import torch
33
+ _TORCH_AVAILABLE = True
34
+ except ImportError:
35
+ _TORCH_AVAILABLE = False
36
+
37
+ try:
38
+ import umap as _umap_module
39
+ _UMAP_AVAILABLE = True
40
+ except ImportError:
41
+ _UMAP_AVAILABLE = False
42
+
43
+ try:
44
+ import hdbscan as _hdbscan_module
45
+ _HDBSCAN_AVAILABLE = True
46
+ except ImportError:
47
+ _HDBSCAN_AVAILABLE = False
48
+
49
  pd.options.mode.chained_assignment = None
50
 
51
  # ---------------------------------------------------------------------------
 
64
  # NLTK Bootstrap
65
  # ---------------------------------------------------------------------------
66
 
67
+ # =============================================================================
68
+ # GROUP 0: TITLE + ABSTRACT COMBINED COLUMN
69
+ # =============================================================================
70
+
71
+ def build_title_abstract_column(df: pd.DataFrame) -> pd.DataFrame:
72
+ """Create a 'title_abstract' combined column for SPECTER2 embedding.
73
+
74
+ Concatenates title and abstract with '. ' separator.
75
+ Also adds 'doi_key' column: DOI if available, else 'doc_<index>'.
76
+
77
+ Args:
78
+ df: DataFrame with 'title' and/or 'abstract' columns.
79
+ Returns:
80
+ DataFrame copy with 'title_abstract' and 'doi_key' added.
81
+ """
82
+ df = df.copy()
83
+ title = df.get('title', pd.Series([''] * len(df), index=df.index)).fillna('')
84
+ abstract = df.get('abstract', pd.Series([''] * len(df), index=df.index)).fillna('')
85
+ df['title_abstract'] = (
86
+ title.astype(str).str.strip() + '. ' + abstract.astype(str).str.strip()
87
+ ).str.strip('. ').str.strip()
88
+
89
+ # DOI key: use existing DOI or generate synthetic id
90
+ if 'doi' in df.columns:
91
+ doi_filled = df['doi'].astype(str).replace({'nan': '', 'None': ''})
92
+ df['doi_key'] = [
93
+ doi if doi.strip() else f'doc_{i}'
94
+ for i, doi in enumerate(doi_filled)
95
+ ]
96
+ else:
97
+ df['doi_key'] = [f'doc_{i}' for i in range(len(df))]
98
+
99
+ logger.info(
100
+ f"build_title_abstract_column: {len(df)} rows, "
101
+ f"{(df['title_abstract'].str.len() > 10).sum()} with content."
102
+ )
103
+ return df
104
+
105
+
106
  def _bootstrap_nltk() -> None:
107
  """Silently download required NLTK packages if missing."""
108
  packages = [
 
1186
  logger.error(f"Failed to save narrative.txt: {e}")
1187
 
1188
  logger.info(f"Exported {len(artifacts)} artifacts to {output_dir}/")
1189
+ return artifacts
1190
+
1191
+
1192
+ # =============================================================================
1193
+ # GROUP 8: SPECTER2 DOCUMENT EMBEDDINGS
1194
+ # =============================================================================
1195
+
1196
+ _SPECTER2_MODEL_NAME = "allenai/specter2_base"
1197
+ _specter2_tokenizer = None
1198
+ _specter2_model = None
1199
+
1200
+
1201
+ def _load_specter2(device: str = 'cpu'):
1202
+ """Lazy-load SPECTER2 model and tokenizer (module-level cache)."""
1203
+ global _specter2_tokenizer, _specter2_model
1204
+ if _specter2_model is not None:
1205
+ return _specter2_tokenizer, _specter2_model
1206
+ from transformers import AutoTokenizer, AutoModel
1207
+ logger.info(f"Loading {_SPECTER2_MODEL_NAME} ...")
1208
+ _specter2_tokenizer = AutoTokenizer.from_pretrained(_SPECTER2_MODEL_NAME)
1209
+ _specter2_model = AutoModel.from_pretrained(_SPECTER2_MODEL_NAME)
1210
+ _specter2_model.eval()
1211
+ _specter2_model.to(device)
1212
+ logger.info("SPECTER2 loaded.")
1213
+ return _specter2_tokenizer, _specter2_model
1214
+
1215
+
1216
+ def _texts_hash(texts: List[str]) -> str:
1217
+ import hashlib
1218
+ blob = '||'.join(t[:300] for t in texts)
1219
+ return hashlib.md5(blob.encode('utf-8', errors='ignore')).hexdigest()[:16]
1220
+
1221
+
1222
+ def _embed_batch(texts: List[str], tokenizer, model, device: str = 'cpu', max_length: int = 512) -> np.ndarray:
1223
+ import torch
1224
+ batch = [t if (isinstance(t, str) and t.strip()) else 'information systems' for t in texts]
1225
+ enc = tokenizer(batch, padding=True, truncation=True, max_length=max_length, return_tensors='pt')
1226
+ enc = {k: v.to(device) for k, v in enc.items()}
1227
+ with torch.no_grad():
1228
+ out = model(**enc)
1229
+ return out.last_hidden_state[:, 0, :].cpu().numpy()
1230
+
1231
+
1232
+ def _tfidf_fallback_embed(texts: List[str], n_components: int = 256) -> np.ndarray:
1233
+ """TF-IDF + TruncatedSVD fallback when SPECTER2 unavailable."""
1234
+ from sklearn.feature_extraction.text import TfidfVectorizer
1235
+ from sklearn.decomposition import TruncatedSVD
1236
+ safe = [t if (isinstance(t, str) and t.strip()) else 'information' for t in texts]
1237
+ nc = min(n_components, len(safe) - 1, 7999)
1238
+ nc = max(nc, 2)
1239
+ vec = TfidfVectorizer(max_features=8000, ngram_range=(1, 2), sublinear_tf=True, min_df=1)
1240
+ mat = vec.fit_transform(safe)
1241
+ svd = TruncatedSVD(n_components=nc, random_state=42)
1242
+ dense = svd.fit_transform(mat).astype(np.float32)
1243
+ norms = np.linalg.norm(dense, axis=1, keepdims=True)
1244
+ norms[norms == 0] = 1.0
1245
+ return dense / norms
1246
+
1247
+
1248
+ def embed_with_specter2(
1249
+ texts: List[str],
1250
+ cache_dir: str = 'outputs/specter_cache',
1251
+ batch_size: int = 8,
1252
+ device: str = 'cpu',
1253
+ ) -> np.ndarray:
1254
+ """Generate L2-normalised SPECTER2 (768-dim) embeddings, one per paper.
1255
+
1256
+ Caches results to disk keyed by MD5 of input texts to avoid re-embedding.
1257
+ Falls back to TF-IDF + SVD (256-dim) if transformers/torch not installed.
1258
+
1259
+ Args:
1260
+ texts: List of 'title. abstract' strings (from build_title_abstract_column).
1261
+ cache_dir: Directory for .npy cache files.
1262
+ batch_size: Texts per forward pass (lower if OOM on CPU).
1263
+ device: 'cpu' or 'cuda'.
1264
+ Returns:
1265
+ np.ndarray shape (N, D), L2-normalised float32.
1266
+ """
1267
+ Path(cache_dir).mkdir(parents=True, exist_ok=True)
1268
+ cache_file = Path(cache_dir) / f"{_texts_hash(texts)}.npy"
1269
+
1270
+ if cache_file.exists():
1271
+ logger.info(f"embed_with_specter2: loading from cache {cache_file}")
1272
+ return np.load(str(cache_file))
1273
+
1274
+ if not _TORCH_AVAILABLE:
1275
+ logger.warning("torch not available β€” using TF-IDF fallback embeddings.")
1276
+ embs = _tfidf_fallback_embed(texts)
1277
+ np.save(str(cache_file), embs)
1278
+ return embs
1279
+
1280
+ try:
1281
+ tokenizer, model = _load_specter2(device=device)
1282
+ except Exception as exc:
1283
+ logger.warning(f"SPECTER2 load failed ({exc}) β€” using TF-IDF fallback.")
1284
+ embs = _tfidf_fallback_embed(texts)
1285
+ np.save(str(cache_file), embs)
1286
+ return embs
1287
+
1288
+ all_embs: List[np.ndarray] = []
1289
+ for i in tqdm(range(0, len(texts), batch_size), desc='SPECTER2'):
1290
+ batch = texts[i: i + batch_size]
1291
+ try:
1292
+ all_embs.append(_embed_batch(batch, tokenizer, model, device))
1293
+ except Exception as e:
1294
+ logger.warning(f"Batch {i} failed ({e}); using zeros.")
1295
+ all_embs.append(np.zeros((len(batch), 768), dtype=np.float32))
1296
+
1297
+ embs = np.vstack(all_embs).astype(np.float32)
1298
+ norms = np.linalg.norm(embs, axis=1, keepdims=True)
1299
+ norms[norms == 0] = 1.0
1300
+ embs = embs / norms
1301
+
1302
+ np.save(str(cache_file), embs)
1303
+ logger.info(f"embed_with_specter2: saved {embs.shape} embeddings β†’ {cache_file}")
1304
+ return embs
1305
+
1306
+
1307
+ # =============================================================================
1308
+ # GROUP 9: UMAP + HDBSCAN CLUSTERING (replaces old DBSCAN pipeline)
1309
+ # =============================================================================
1310
+
1311
+ def _run_umap(
1312
+ embeddings: np.ndarray,
1313
+ n_components: int = 50,
1314
+ n_neighbors: int = 15,
1315
+ min_dist: float = 0.0,
1316
+ random_state: int = 42,
1317
+ ) -> np.ndarray:
1318
+ """UMAP dimensionality reduction (cosine metric). Falls back to PCA."""
1319
+ n_comp = min(n_components, embeddings.shape[0] - 2, embeddings.shape[1])
1320
+ n_comp = max(n_comp, 2)
1321
+ n_neigh = min(n_neighbors, embeddings.shape[0] - 1)
1322
+ n_neigh = max(n_neigh, 2)
1323
+
1324
+ if _UMAP_AVAILABLE:
1325
+ try:
1326
+ reducer = _umap_module.UMAP(
1327
+ n_components=n_comp,
1328
+ n_neighbors=n_neigh,
1329
+ min_dist=min_dist,
1330
+ metric='cosine',
1331
+ random_state=random_state,
1332
+ low_memory=True,
1333
+ )
1334
+ return reducer.fit_transform(embeddings)
1335
+ except Exception as e:
1336
+ logger.warning(f"UMAP failed ({e}); falling back to PCA.")
1337
+
1338
+ from sklearn.decomposition import PCA
1339
+ pca = PCA(n_components=n_comp, random_state=random_state)
1340
+ return pca.fit_transform(embeddings)
1341
+
1342
+
1343
+ def _hdbscan_labels(
1344
+ reduced: np.ndarray,
1345
+ min_cluster_size: int,
1346
+ min_samples: int = 3,
1347
+ ) -> np.ndarray:
1348
+ if _HDBSCAN_AVAILABLE:
1349
+ try:
1350
+ clusterer = _hdbscan_module.HDBSCAN(
1351
+ min_cluster_size=min_cluster_size,
1352
+ min_samples=min_samples,
1353
+ cluster_selection_method='eom',
1354
+ metric='euclidean',
1355
+ )
1356
+ return clusterer.fit_predict(reduced)
1357
+ except Exception as e:
1358
+ logger.warning(f"HDBSCAN failed ({e}); falling back to KMeans.")
1359
+
1360
+ from sklearn.cluster import KMeans
1361
+ n_c = max(3, reduced.shape[0] // max(min_cluster_size, 1))
1362
+ n_c = min(n_c, 30)
1363
+ return KMeans(n_clusters=n_c, random_state=42, n_init=10).fit_predict(reduced)
1364
+
1365
+
1366
+ def _sweep_clusters(
1367
+ reduced: np.ndarray,
1368
+ target_min: int = 15,
1369
+ target_max: int = 30,
1370
+ min_samples: int = 3,
1371
+ ) -> Tuple[np.ndarray, int]:
1372
+ """Sweep min_cluster_size until n_clusters lands in [target_min, target_max]."""
1373
+ best_labels: Optional[np.ndarray] = None
1374
+ best_n = 0
1375
+ best_dist = float('inf')
1376
+
1377
+ # Sweep: small mcs β†’ many clusters; large mcs β†’ few clusters
1378
+ for mcs in list(range(5, 51)):
1379
+ labels = _hdbscan_labels(reduced, min_cluster_size=mcs, min_samples=min_samples)
1380
+ n_c = len(set(labels) - {-1})
1381
+ if target_min <= n_c <= target_max:
1382
+ logger.info(f"Cluster sweep: min_cluster_size={mcs} β†’ {n_c} clusters βœ“")
1383
+ return labels, mcs
1384
+ dist = min(abs(n_c - target_min), abs(n_c - target_max))
1385
+ if dist < best_dist:
1386
+ best_dist = dist
1387
+ best_labels = labels
1388
+ best_n = n_c
1389
+
1390
+ logger.warning(f"Cluster sweep: could not hit [{target_min},{target_max}]; got {best_n} clusters.")
1391
+ return best_labels, -1
1392
+
1393
+
1394
+ def _split_large_cluster(
1395
+ indices: List[int],
1396
+ embeddings: np.ndarray,
1397
+ max_size: int,
1398
+ next_id: int,
1399
+ ) -> Dict[int, int]:
1400
+ """Split oversized cluster using KMeans. Returns {old_idx: new_cluster_id}."""
1401
+ from sklearn.cluster import KMeans
1402
+ sub_embs = embeddings[indices]
1403
+ k = max(2, len(indices) // max_size + 1)
1404
+ k = min(k, len(indices))
1405
+ labels = KMeans(n_clusters=k, random_state=42, n_init=5).fit_predict(sub_embs)
1406
+ mapping: Dict[int, int] = {}
1407
+ for local_i, orig_i in enumerate(indices):
1408
+ mapping[orig_i] = next_id + int(labels[local_i])
1409
+ return mapping
1410
+
1411
+
1412
+ def _cosine_sim_filter(
1413
+ labels: np.ndarray,
1414
+ embeddings: np.ndarray,
1415
+ sim_low: float = 0.50,
1416
+ sim_high: float = 0.60,
1417
+ ) -> Tuple[np.ndarray, Dict[int, float]]:
1418
+ """
1419
+ Compute mean intra-cluster cosine similarity.
1420
+ Clusters below sim_low are dissolved to noise (-1).
1421
+ Returns updated labels and per-cluster similarity dict.
1422
+ """
1423
+ unique_ids = sorted(set(labels) - {-1})
1424
+ cluster_sims: Dict[int, float] = {}
1425
+ new_labels = labels.copy()
1426
+
1427
+ for cid in unique_ids:
1428
+ mask = labels == cid
1429
+ sub = embeddings[mask]
1430
+ if len(sub) < 2:
1431
+ cluster_sims[cid] = 1.0
1432
+ continue
1433
+ # Mean pairwise cosine sim = (sum of all dot products - n) / (n*(n-1))
1434
+ # Since embeddings are L2-normalised, dot product = cosine similarity
1435
+ dot = sub @ sub.T
1436
+ n = len(sub)
1437
+ mean_sim = float((dot.sum() - n) / max(n * (n - 1), 1))
1438
+ cluster_sims[cid] = round(mean_sim, 4)
1439
+
1440
+ if mean_sim < sim_low:
1441
+ new_labels[mask] = -1 # dissolve too-diffuse cluster
1442
+ logger.debug(f"Cluster {cid}: sim={mean_sim:.3f} < {sim_low} β†’ dissolved.")
1443
+
1444
+ return new_labels, cluster_sims
1445
+
1446
+
1447
+ def _renumber_labels(labels: np.ndarray) -> np.ndarray:
1448
+ """Renumber cluster ids to 0..K-1 preserving -1 for noise."""
1449
+ unique_ids = sorted(set(labels) - {-1})
1450
+ remap = {old: new for new, old in enumerate(unique_ids)}
1451
+ return np.array([remap.get(x, -1) for x in labels])
1452
+
1453
+
1454
+ def specter2_hdbscan_cluster_topics(
1455
+ df: pd.DataFrame,
1456
+ embeddings: np.ndarray,
1457
+ min_cluster_size: int = 5,
1458
+ max_cluster_size: int = 100,
1459
+ target_min_clusters: int = 15,
1460
+ target_max_clusters: int = 30,
1461
+ cosine_sim_low: float = 0.50,
1462
+ cosine_sim_high: float = 0.60,
1463
+ umap_n_components: int = 50,
1464
+ umap_n_neighbors: int = 15,
1465
+ random_state: int = 42,
1466
+ ) -> pd.DataFrame:
1467
+ """
1468
+ Full SPECTER2 β†’ UMAP β†’ HDBSCAN clustering pipeline.
1469
+
1470
+ Parameters
1471
+ ----------
1472
+ df : DataFrame with 'title', 'abstract', 'doi_key', 'title_abstract'.
1473
+ embeddings : L2-normalised SPECTER2 vectors, shape (N, D).
1474
+ min_cluster_size : Minimum papers per cluster (default 5).
1475
+ max_cluster_size : Maximum papers per cluster (default 100).
1476
+ target_min_clusters: Target minimum cluster count (default 15).
1477
+ target_max_clusters: Target maximum cluster count (default 30).
1478
+ cosine_sim_low / high: Acceptable intra-cluster cosine similarity range.
1479
+ umap_n_components : UMAP output dimensions (default 50).
1480
+ umap_n_neighbors : UMAP neighbourhood size (default 15).
1481
+ random_state : Reproducibility seed.
1482
+
1483
+ Returns
1484
+ -------
1485
+ DataFrame with columns:
1486
+ doc_id, doi_key, title_snippet, cluster_final, is_noise,
1487
+ intra_cluster_sim, top_kws
1488
+ """
1489
+ if df is None or df.empty:
1490
+ return pd.DataFrame()
1491
+ n = len(df)
1492
+ assert embeddings.shape[0] == n, "embeddings row count must match df row count."
1493
+
1494
+ logger.info(f"UMAP: reducing {n} docs from dim {embeddings.shape[1]} β†’ {umap_n_components}...")
1495
+ reduced = _run_umap(embeddings, n_components=umap_n_components,
1496
+ n_neighbors=umap_n_neighbors, min_dist=0.0,
1497
+ random_state=random_state)
1498
+
1499
+ logger.info("HDBSCAN: sweeping for 15-30 clusters...")
1500
+ labels, best_mcs = _sweep_clusters(
1501
+ reduced,
1502
+ target_min=target_min_clusters,
1503
+ target_max=target_max_clusters,
1504
+ min_samples=3,
1505
+ )
1506
+
1507
+ # ── Enforce max cluster size (split oversized) ──
1508
+ next_id = int(labels.max()) + 1
1509
+ changed = True
1510
+ while changed:
1511
+ changed = False
1512
+ unique_ids = sorted(set(labels) - {-1})
1513
+ for cid in unique_ids:
1514
+ mask_idx = [i for i, l in enumerate(labels) if l == cid]
1515
+ if len(mask_idx) > max_cluster_size:
1516
+ mapping = _split_large_cluster(mask_idx, embeddings, max_cluster_size, next_id)
1517
+ for orig_i, new_cid in mapping.items():
1518
+ labels[orig_i] = new_cid
1519
+ next_id += max(mapping.values()) - cid + 1
1520
+ changed = True
1521
+
1522
+ # ── Enforce min cluster size (absorb undersized) ──
1523
+ unique_ids = sorted(set(labels) - {-1})
1524
+ sizes = {cid: int((labels == cid).sum()) for cid in unique_ids}
1525
+ large_ids = [cid for cid, s in sizes.items() if s >= min_cluster_size]
1526
+ for cid, s in sizes.items():
1527
+ if s < min_cluster_size:
1528
+ if not large_ids:
1529
+ labels[labels == cid] = -1
1530
+ continue
1531
+ sub_idx = [i for i, l in enumerate(labels) if l == cid]
1532
+ sub_embs = embeddings[sub_idx]
1533
+ # Find nearest large cluster centroid
1534
+ best_cid, best_sim = -1, -2.0
1535
+ for lcid in large_ids:
1536
+ l_mask = labels == lcid
1537
+ centroid = embeddings[l_mask].mean(axis=0)
1538
+ centroid /= max(np.linalg.norm(centroid), 1e-9)
1539
+ sim = float((sub_embs @ centroid).mean())
1540
+ if sim > best_sim:
1541
+ best_sim, best_cid = sim, lcid
1542
+ for idx in sub_idx:
1543
+ labels[idx] = best_cid
1544
+
1545
+ # ── Cosine similarity quality filter ──
1546
+ labels, cluster_sims = _cosine_sim_filter(labels, embeddings, cosine_sim_low, cosine_sim_high)
1547
+
1548
+ # ── Final renumber ──
1549
+ labels = _renumber_labels(labels)
1550
+ n_final = len(set(labels) - {-1})
1551
+ n_noise = int((labels == -1).sum())
1552
+ logger.info(f"Clustering complete: {n_final} clusters, {n_noise} noise docs.")
1553
+
1554
+ # ── Build result DataFrame ──
1555
+ titles = df.get('title', pd.Series([''] * n)).fillna('').tolist()
1556
+ doi_keys = df.get('doi_key', pd.Series([f'doc_{i}' for i in range(n)])).tolist()
1557
+
1558
+ # TF-IDF keywords per doc for display
1559
+ ta_texts = df.get('title_abstract', pd.Series([''] * n)).fillna('').tolist()
1560
+ try:
1561
+ from sklearn.feature_extraction.text import TfidfVectorizer as _TV
1562
+ _v = _TV(max_features=3000, ngram_range=(1, 2), min_df=1)
1563
+ _m = _v.fit_transform([t or ' ' for t in ta_texts])
1564
+ _feat = _v.get_feature_names_out()
1565
+ top_kws = []
1566
+ for i in range(_m.shape[0]):
1567
+ row = _m[i].toarray().flatten()
1568
+ idx = row.argsort()[::-1][:6]
1569
+ top_kws.append(', '.join(_feat[j] for j in idx if row[j] > 0))
1570
+ except Exception:
1571
+ top_kws = [''] * n
1572
+
1573
+ intra_sims = [cluster_sims.get(int(l), 1.0) if l != -1 else 0.0 for l in labels]
1574
+
1575
+ result = pd.DataFrame({
1576
+ 'doc_id': range(n),
1577
+ 'doi_key': doi_keys,
1578
+ 'title_snippet': [t[:90] + '…' if len(t) > 90 else t for t in titles],
1579
+ 'cluster_final': labels,
1580
+ 'is_noise': labels == -1,
1581
+ 'intra_cluster_sim': intra_sims,
1582
+ 'top_kws': top_kws,
1583
+ })
1584
+ return result
1585
+
1586
+
1587
+ def get_cluster_summary(cluster_df: pd.DataFrame) -> pd.DataFrame:
1588
+ """Aggregate cluster_df into a per-cluster summary DataFrame.
1589
+
1590
+ Returns DataFrame with columns:
1591
+ cluster_id, size, is_noise_cluster, top_kws, avg_sim, label
1592
+ """
1593
+ if cluster_df is None or cluster_df.empty:
1594
+ return pd.DataFrame()
1595
+ rows = []
1596
+ for cid, grp in cluster_df.groupby('cluster_final'):
1597
+ rows.append({
1598
+ 'cluster_id': int(cid),
1599
+ 'size': len(grp),
1600
+ 'is_noise_cluster': cid == -1,
1601
+ 'top_kws': _most_common_kw(grp['top_kws'].tolist()),
1602
+ 'avg_sim': round(float(grp['intra_cluster_sim'].mean()), 4),
1603
+ 'label': 'Unlabeled',
1604
+ })
1605
+ return (
1606
+ pd.DataFrame(rows)
1607
+ .sort_values('size', ascending=False)
1608
+ .reset_index(drop=True)
1609
+ )
1610
+
1611
+
1612
+ def _most_common_kw(kw_lists: List[str], top_n: int = 6) -> str:
1613
+ from collections import Counter
1614
+ counter: Counter = Counter()
1615
+ for s in kw_lists:
1616
+ for kw in str(s).split(','):
1617
+ kw = kw.strip()
1618
+ if kw:
1619
+ counter[kw] += 1
1620
+ return ', '.join(kw for kw, _ in counter.most_common(top_n))
1621
+
1622
+
1623
+ # =============================================================================
1624
+ # GROUP 10: 3-LLM CLUSTER LABELING (Mistral + Gemini + HF Inference β€” all free)
1625
+ # =============================================================================
1626
+
1627
+ import time as _time
1628
+ import httpx as _httpx
1629
+
1630
+
1631
+ def _get_rep_titles(
1632
+ cluster_df: pd.DataFrame,
1633
+ cluster_id: int,
1634
+ embeddings: np.ndarray,
1635
+ top_n: int = 3,
1636
+ ) -> List[str]:
1637
+ """Select top_n papers closest to cluster centroid (highest cosine sim)."""
1638
+ mask = cluster_df['cluster_final'] == cluster_id
1639
+ indices = cluster_df.index[mask].tolist()
1640
+ if not indices:
1641
+ return []
1642
+ sub_embs = embeddings[indices]
1643
+ centroid = sub_embs.mean(axis=0)
1644
+ norm = np.linalg.norm(centroid)
1645
+ if norm > 0:
1646
+ centroid /= norm
1647
+ sims = sub_embs @ centroid
1648
+ top_local = np.argsort(sims)[::-1][:top_n]
1649
+ top_global = [indices[i] for i in top_local]
1650
+ snippets = cluster_df.loc[top_global, 'title_snippet'].tolist()
1651
+ return [s for s in snippets if s and s.strip()]
1652
+
1653
+
1654
+ def _majority_label(labels: List[str]) -> str:
1655
+ """Pick the label with most agreement among non-error strings."""
1656
+ from collections import Counter
1657
+ valid = [l.strip().strip('"\'') for l in labels
1658
+ if l and not l.startswith('[') and len(l.strip()) > 2]
1659
+ if not valid:
1660
+ return labels[0] if labels else 'Research Cluster'
1661
+ c = Counter(valid)
1662
+ top_count = max(c.values())
1663
+ winners = [l for l, cnt in c.items() if cnt == top_count]
1664
+ return max(winners, key=len) # prefer longer label if tied
1665
+
1666
+
1667
+ def _call_mistral_label(prompt: str, api_key: str) -> str:
1668
+ try:
1669
+ r = _httpx.post(
1670
+ 'https://api.mistral.ai/v1/chat/completions',
1671
+ headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
1672
+ json={'model': 'mistral-small-latest', 'messages': [{'role': 'user', 'content': prompt}],
1673
+ 'max_tokens': 20, 'temperature': 0.2},
1674
+ timeout=20.0,
1675
+ )
1676
+ r.raise_for_status()
1677
+ return r.json()['choices'][0]['message']['content'].strip()
1678
+ except Exception as e:
1679
+ return f'[Mistral: {e}]'
1680
+
1681
+
1682
+ def _call_gemini_label(prompt: str, api_key: str) -> str:
1683
+ try:
1684
+ url = (f'https://generativelanguage.googleapis.com/v1beta/models/'
1685
+ f'gemini-2.0-flash:generateContent?key={api_key}')
1686
+ r = _httpx.post(
1687
+ url,
1688
+ headers={'Content-Type': 'application/json'},
1689
+ json={'contents': [{'parts': [{'text': prompt}]}],
1690
+ 'generationConfig': {'maxOutputTokens': 20, 'temperature': 0.2}},
1691
+ timeout=20.0,
1692
+ )
1693
+ r.raise_for_status()
1694
+ cands = r.json().get('candidates', [])
1695
+ return cands[0]['content']['parts'][0]['text'].strip() if cands else '[Gemini: empty]'
1696
+ except Exception as e:
1697
+ return f'[Gemini: {e}]'
1698
+
1699
+
1700
+ def _call_deepseek_label(prompt: str, api_key: str,
1701
+ model: str = 'deepseek-chat') -> str:
1702
+ """DeepSeek API (OpenAI-compatible) β€” free $5 credit on signup."""
1703
+ try:
1704
+ r = _httpx.post(
1705
+ 'https://api.deepseek.com/v1/chat/completions',
1706
+ headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
1707
+ json={'model': model, 'messages': [{'role': 'user', 'content': prompt}],
1708
+ 'max_tokens': 20, 'temperature': 0.2},
1709
+ timeout=25.0,
1710
+ )
1711
+ r.raise_for_status()
1712
+ return r.json()['choices'][0]['message']['content'].strip()
1713
+ except Exception as e:
1714
+ return f'[DeepSeek: {e}]'
1715
+
1716
+
1717
+ def _label_prompt(titles: List[str]) -> str:
1718
+ titles_block = '\n'.join(f'- {t}' for t in titles[:3])
1719
+ return (
1720
+ 'You are an expert Information Systems researcher. '
1721
+ 'Given these 3 representative paper titles from one research cluster, '
1722
+ 'provide a concise 3-5 word thematic label. '
1723
+ 'Return ONLY the label, nothing else.\n\n'
1724
+ f'Titles:\n{titles_block}'
1725
+ )
1726
+
1727
+
1728
+ def label_clusters_3llm(
1729
+ cluster_df: pd.DataFrame,
1730
+ cluster_summary_df: pd.DataFrame,
1731
+ embeddings: np.ndarray,
1732
+ mistral_api_key: str = '',
1733
+ gemini_api_key: str = '',
1734
+ deepseek_api_key: str = '',
1735
+ max_clusters: int = 30,
1736
+ ) -> pd.DataFrame:
1737
+ """Label each cluster using Mistral + Gemini + DeepSeek (all free/affordable APIs).
1738
+
1739
+ For each cluster:
1740
+ 1. Selects 3 most representative paper titles (closest to centroid).
1741
+ 2. Sends the same prompt to all 3 LLMs independently.
1742
+ 3. Majority vote picks the final label; all 3 candidates stored.
1743
+
1744
+ Falls back to keyword-based labeling if no API keys provided.
1745
+
1746
+ Args:
1747
+ cluster_df : Document-level cluster DataFrame.
1748
+ cluster_summary_df: Cluster-level summary from get_cluster_summary().
1749
+ embeddings : SPECTER2 embeddings aligned with cluster_df rows.
1750
+ mistral_api_key : Mistral API key (free tier).
1751
+ gemini_api_key : Google AI Studio API key (free tier).
1752
+ deepseek_api_key : DeepSeek API key (free $5 credit β€” deepseek.com).
1753
+ max_clusters : Cap API calls to this many clusters.
1754
+
1755
+ Returns:
1756
+ cluster_summary_df copy with 'label', 'label_mistral',
1757
+ 'label_gemini', 'label_hf' columns populated.
1758
+ """
1759
+ summary = cluster_summary_df.copy()
1760
+ if 'label_mistral' not in summary.columns:
1761
+ summary['label_mistral'] = ''
1762
+ if 'label_gemini' not in summary.columns:
1763
+ summary['label_gemini'] = ''
1764
+ if 'label_hf' not in summary.columns:
1765
+ summary['label_hf'] = ''
1766
+
1767
+ has_any_key = any([mistral_api_key.strip(), gemini_api_key.strip(), deepseek_api_key.strip()])
1768
+
1769
+ for idx, row in summary.iterrows():
1770
+ if idx >= max_clusters:
1771
+ break
1772
+ cid = row['cluster_id']
1773
+ if cid == -1:
1774
+ summary.at[idx, 'label'] = 'Noise / Outliers'
1775
+ continue
1776
+
1777
+ titles = _get_rep_titles(cluster_df, cid, embeddings, top_n=3)
1778
+ if not titles:
1779
+ summary.at[idx, 'label'] = row.get('top_kws', 'Unknown')[:40]
1780
+ continue
1781
+
1782
+ if not has_any_key:
1783
+ # Fallback: keyword-based label from top_kws
1784
+ kws = str(row.get('top_kws', '')).split(',')
1785
+ label = ' '.join(w.strip().title() for w in kws[:3] if w.strip())
1786
+ summary.at[idx, 'label'] = label or 'Research Cluster'
1787
+ continue
1788
+
1789
+ prompt = _label_prompt(titles)
1790
+ candidates: List[str] = []
1791
+
1792
+ if mistral_api_key.strip():
1793
+ ml = _call_mistral_label(prompt, mistral_api_key)
1794
+ summary.at[idx, 'label_mistral'] = ml
1795
+ candidates.append(ml)
1796
+ _time.sleep(0.2)
1797
+
1798
+ if gemini_api_key.strip():
1799
+ gl = _call_gemini_label(prompt, gemini_api_key)
1800
+ summary.at[idx, 'label_gemini'] = gl
1801
+ candidates.append(gl)
1802
+ _time.sleep(0.2)
1803
+
1804
+ if deepseek_api_key.strip():
1805
+ dl = _call_deepseek_label(prompt, deepseek_api_key)
1806
+ summary.at[idx, 'label_deepseek'] = dl
1807
+ candidates.append(dl)
1808
+ _time.sleep(0.2)
1809
+
1810
+ summary.at[idx, 'label'] = _majority_label(candidates) if candidates else 'Research Cluster'
1811
+ logger.info(f"Cluster {cid} labeled: {summary.at[idx, 'label']!r} "
1812
+ f"(candidates: {candidates})")
1813
+
1814
+ return summary
1815
+
1816
+
1817
+ # =============================================================================
1818
+ # GROUP 11: AGENTIC COUNCIL (Mistral + Gemini + DeepSeek β€” DeepSeek as synthesis judge)
1819
+ # =============================================================================
1820
+
1821
+ COUNCIL_PROMPT_TEMPLATE = """You are a senior Information Systems research analyst.
1822
+ You have been given a PAJAIS research gap analysis report with the following findings:
1823
+ {findings}
1824
+ Based on this analysis, provide your expert assessment covering:
1825
+ 1. The 3 most strategically important research gaps for the field
1826
+ 2. Which novel topics have the highest publication impact potential
1827
+ 3. Recommended methodologies for addressing the top gap
1828
+ 4. Any risks or caveats in the analysis
1829
+ Be specific, cite topic names from the report, and limit your response to 300 words."""
1830
+
1831
+ SYNTHESIS_PROMPT_TEMPLATE = """You are the Chief Research Officer synthesizing advice from three expert panels.
1832
+ Panel A (Mistral) said:
1833
+ {mistral_response}
1834
+ Panel B (Gemini) said:
1835
+ {gemini_response}
1836
+ Panel C (DeepSeek) said:
1837
+ {deepseek_response}
1838
+ Your task:
1839
+ 1. Identify the 2-3 points ALL panels AGREE on (consensus insights)
1840
+ 2. Identify where they DIVERGE and explain which view is most defensible
1841
+ 3. Produce a final 200-word synthesis recommendation
1842
+ Structure your response as:
1843
+ ### Consensus
1844
+ <points>
1845
+ ### Divergence
1846
+ <analysis>
1847
+ ### Final Recommendation
1848
+ <synthesis>"""
1849
+
1850
+
1851
+ def _call_mistral(prompt: str, api_key: str, model: str = 'mistral-large-latest') -> str:
1852
+ try:
1853
+ r = _httpx.post(
1854
+ 'https://api.mistral.ai/v1/chat/completions',
1855
+ headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
1856
+ json={'model': model, 'messages': [{'role': 'user', 'content': prompt}],
1857
+ 'max_tokens': 500, 'temperature': 0.4},
1858
+ timeout=30.0,
1859
+ )
1860
+ r.raise_for_status()
1861
+ return r.json()['choices'][0]['message']['content'].strip()
1862
+ except Exception as e:
1863
+ logger.error(f'Mistral call failed: {e}')
1864
+ return f'[Mistral unavailable: {e}]'
1865
+
1866
+
1867
+ def _call_gemini(prompt: str, api_key: str, model: str = 'gemini-2.0-flash') -> str:
1868
+ try:
1869
+ url = (f'https://generativelanguage.googleapis.com/v1beta/models/'
1870
+ f'{model}:generateContent?key={api_key}')
1871
+ r = _httpx.post(
1872
+ url,
1873
+ headers={'Content-Type': 'application/json'},
1874
+ json={'contents': [{'parts': [{'text': prompt}]}],
1875
+ 'generationConfig': {'maxOutputTokens': 500, 'temperature': 0.4}},
1876
+ timeout=30.0,
1877
+ )
1878
+ r.raise_for_status()
1879
+ cands = r.json().get('candidates', [])
1880
+ return cands[0]['content']['parts'][0]['text'].strip() if cands else '[Gemini: empty]'
1881
+ except Exception as e:
1882
+ logger.error(f'Gemini call failed: {e}')
1883
+ return f'[Gemini unavailable: {e}]'
1884
+
1885
+
1886
+ def _call_deepseek(
1887
+ prompt: str,
1888
+ api_key: str,
1889
+ model: str = 'deepseek-chat',
1890
+ ) -> str:
1891
+ """DeepSeek API (OpenAI-compatible endpoint). Uses deepseek-chat (DeepSeek-V3)."""
1892
+ try:
1893
+ r = _httpx.post(
1894
+ 'https://api.deepseek.com/v1/chat/completions',
1895
+ headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
1896
+ json={'model': model, 'messages': [{'role': 'user', 'content': prompt}],
1897
+ 'max_tokens': 500, 'temperature': 0.4},
1898
+ timeout=30.0,
1899
+ )
1900
+ r.raise_for_status()
1901
+ return r.json()['choices'][0]['message']['content'].strip()
1902
+ except Exception as e:
1903
+ logger.error(f'DeepSeek call failed: {e}')
1904
+ return f'[DeepSeek unavailable: {e}]'
1905
+
1906
+
1907
+ def run_agentic_council(
1908
+ taxonomy_map: Dict[str, Any],
1909
+ topic_df: Optional[pd.DataFrame],
1910
+ mistral_api_key: str = '',
1911
+ gemini_api_key: str = '',
1912
+ deepseek_api_key: str = '',
1913
+ anthropic_api_key: str = '', # kept for backward compat β€” not used
1914
+ ) -> Dict[str, str]:
1915
+ """Run 4-stage agentic council: Mistral β†’ Gemini β†’ DeepSeek panels + DeepSeek synthesis.
1916
+
1917
+ Returns dict with keys:
1918
+ 'findings_summary', 'mistral', 'gemini', 'deepseek', 'synthesis'
1919
+ """
1920
+ gap = taxonomy_map.get('gap_analysis', {})
1921
+ novel_themes = taxonomy_map.get('novel_themes', [])[:5]
1922
+ pub_themes = taxonomy_map.get('publishable_novel_themes', [])[:3]
1923
+
1924
+ novel_str = '\n'.join(
1925
+ f" - {t['label']} (n={t['doc_count']}, coherence={t['coherence']:.2f})"
1926
+ for t in novel_themes
1927
+ )
1928
+ pub_str = '\n'.join(
1929
+ f" - {t['label']} (n={t['doc_count']}, coherence={t['coherence']:.2f})"
1930
+ for t in pub_themes
1931
+ )
1932
+ covered_str = ', '.join(gap.get('covered_themes', [])[:5])
1933
+ uncovered_str = ', '.join(gap.get('uncovered_themes', [])[:5])
1934
+ top_topics_str = ''
1935
+ if topic_df is not None and not topic_df.empty and 'label' in topic_df.columns:
1936
+ top_topics_str = ', '.join(topic_df.head(5)['label'].tolist())
1937
+
1938
+ findings = (
1939
+ f"PAJAIS Coverage: {gap.get('coverage_pct', 0):.1f}% "
1940
+ f"({gap.get('mapped_count', 0)} mapped, {gap.get('novel_count', 0)} novel)\n"
1941
+ f"Covered themes (sample): {covered_str}\n"
1942
+ f"Uncovered themes (sample): {uncovered_str}\n"
1943
+ f"Top discovered topics: {top_topics_str}\n"
1944
+ f"Novel research themes (top 5):\n{novel_str}\n"
1945
+ f"Publishable gap candidates:\n{pub_str}"
1946
+ ).strip()
1947
+
1948
+ council_prompt = COUNCIL_PROMPT_TEMPLATE.format(findings=findings)
1949
+
1950
+ # ── Stage 1: Mistral panel ──
1951
+ logger.info('Council: calling Mistral (Panel A)…')
1952
+ mistral_resp = (
1953
+ _call_mistral(council_prompt, mistral_api_key)
1954
+ if mistral_api_key.strip()
1955
+ else '[Mistral API key not provided]'
1956
+ )
1957
+
1958
+ # ── Stage 2: Gemini panel ──
1959
+ logger.info('Council: calling Gemini (Panel B)…')
1960
+ gemini_resp = (
1961
+ _call_gemini(council_prompt, gemini_api_key)
1962
+ if gemini_api_key.strip()
1963
+ else '[Gemini API key not provided]'
1964
+ )
1965
+
1966
+ # ── Stage 3: DeepSeek panel ──
1967
+ logger.info('Council: calling DeepSeek (Panel C)…')
1968
+ deepseek_resp = (
1969
+ _call_deepseek(council_prompt, deepseek_api_key)
1970
+ if deepseek_api_key.strip()
1971
+ else '[DeepSeek API key not provided]'
1972
+ )
1973
+
1974
+ # ── Stage 4: DeepSeek synthesis judge ──
1975
+ synthesis_prompt = SYNTHESIS_PROMPT_TEMPLATE.format(
1976
+ mistral_response=mistral_resp,
1977
+ gemini_response=gemini_resp,
1978
+ deepseek_response=deepseek_resp,
1979
+ )
1980
+ logger.info('Council: calling DeepSeek (synthesis judge)…')
1981
+ synthesis_resp = (
1982
+ _call_deepseek(synthesis_prompt, deepseek_api_key, model='deepseek-reasoner')
1983
+ if deepseek_api_key.strip()
1984
+ else '[DeepSeek API key not provided β€” synthesis skipped]'
1985
+ )
1986
+
1987
+ return {
1988
+ 'findings_summary': findings,
1989
+ 'mistral': mistral_resp,
1990
+ 'gemini': gemini_resp,
1991
+ 'deepseek': deepseek_resp,
1992
+ 'synthesis': synthesis_resp,
1993
+ }