Spaces:
Sleeping
Sleeping
Update tools.py
Browse files
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 |
+
}
|