Spaces:
Running
Running
Upload 6 files
Browse files- agent.py +125 -27
- agents.py +552 -0
- app.py +622 -78
- run_specter.py +308 -0
- tools.py +679 -33
agent.py
CHANGED
|
@@ -88,13 +88,17 @@ class TopicAgent:
|
|
| 88 |
csv_path: str,
|
| 89 |
output_dir: str = "outputs",
|
| 90 |
min_topics: int = 100,
|
|
|
|
| 91 |
use_llm_labels: bool = False,
|
|
|
|
| 92 |
groq_api_key: Optional[str] = None,
|
| 93 |
):
|
| 94 |
self.csv_path = csv_path
|
| 95 |
self.output_dir = output_dir
|
| 96 |
self.min_topics = min_topics
|
|
|
|
| 97 |
self.use_llm_labels = use_llm_labels
|
|
|
|
| 98 |
self.groq_api_key = groq_api_key
|
| 99 |
|
| 100 |
# Ensure output directory exists
|
|
@@ -119,31 +123,30 @@ class TopicAgent:
|
|
| 119 |
logger.info("=" * 60)
|
| 120 |
|
| 121 |
try:
|
| 122 |
-
|
| 123 |
self._step_load_csv()
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
# Step
|
| 144 |
-
self.
|
| 145 |
-
|
| 146 |
-
# Step 9: Export outputs
|
| 147 |
self._step_export()
|
| 148 |
|
| 149 |
self._result.status = "success"
|
|
@@ -176,26 +179,30 @@ class TopicAgent:
|
|
| 176 |
logger.info(" → Abstracts preprocessed: %d docs", len(self._abstracts_clean))
|
| 177 |
|
| 178 |
def _step_model_titles(self):
|
| 179 |
-
"""Step 3 — Topic modeling on Titles."""
|
| 180 |
logger.info("Step 3/9: Topic modeling on Titles …")
|
| 181 |
self._title_topics_df, self._title_model = run_topic_modeling(
|
| 182 |
self._titles_clean,
|
| 183 |
source_label="Titles",
|
| 184 |
min_topics=self.min_topics,
|
|
|
|
| 185 |
)
|
| 186 |
self._result.steps_completed.append("topic_modeling_titles")
|
| 187 |
-
|
|
|
|
| 188 |
|
| 189 |
def _step_model_abstracts(self):
|
| 190 |
-
"""Step 4 — Topic modeling on Abstracts."""
|
| 191 |
logger.info("Step 4/9: Topic modeling on Abstracts …")
|
| 192 |
self._abstract_topics_df, self._abstract_model = run_topic_modeling(
|
| 193 |
self._abstracts_clean,
|
| 194 |
source_label="Abstracts",
|
| 195 |
min_topics=self.min_topics,
|
|
|
|
| 196 |
)
|
| 197 |
self._result.steps_completed.append("topic_modeling_abstracts")
|
| 198 |
-
|
|
|
|
| 199 |
|
| 200 |
def _step_generate_labels(self):
|
| 201 |
"""Step 5 — Generate human-readable labels."""
|
|
@@ -285,3 +292,94 @@ class TopicAgent:
|
|
| 285 |
|
| 286 |
self._result.steps_completed.append("export")
|
| 287 |
logger.info(" → All outputs exported successfully.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
csv_path: str,
|
| 89 |
output_dir: str = "outputs",
|
| 90 |
min_topics: int = 100,
|
| 91 |
+
max_clusters: int = 25,
|
| 92 |
use_llm_labels: bool = False,
|
| 93 |
+
use_specter: bool = False,
|
| 94 |
groq_api_key: Optional[str] = None,
|
| 95 |
):
|
| 96 |
self.csv_path = csv_path
|
| 97 |
self.output_dir = output_dir
|
| 98 |
self.min_topics = min_topics
|
| 99 |
+
self.max_clusters = max_clusters
|
| 100 |
self.use_llm_labels = use_llm_labels
|
| 101 |
+
self.use_specter = use_specter
|
| 102 |
self.groq_api_key = groq_api_key
|
| 103 |
|
| 104 |
# Ensure output directory exists
|
|
|
|
| 123 |
logger.info("=" * 60)
|
| 124 |
|
| 125 |
try:
|
| 126 |
+
# Step 1: Load CSV
|
| 127 |
self._step_load_csv()
|
| 128 |
|
| 129 |
+
if self.use_specter:
|
| 130 |
+
self._step_specter_pipeline()
|
| 131 |
+
else:
|
| 132 |
+
# Step 2: Preprocess text
|
| 133 |
+
self._step_preprocess()
|
| 134 |
+
# Step 3: Topic modeling on Titles
|
| 135 |
+
self._step_model_titles()
|
| 136 |
+
# Step 4: Topic modeling on Abstracts
|
| 137 |
+
self._step_model_abstracts()
|
| 138 |
+
# Step 5: Generate labels
|
| 139 |
+
self._step_generate_labels()
|
| 140 |
+
# Step 6: Build combined topics table
|
| 141 |
+
self._step_combine_topics()
|
| 142 |
+
# Step 7: Compare themes
|
| 143 |
+
self._step_compare_themes()
|
| 144 |
+
# Step 8: Create taxonomy map
|
| 145 |
+
self._step_taxonomy_map()
|
| 146 |
+
|
| 147 |
+
# Step 9: Export outputs (TCCM not used if specter, but export is)
|
| 148 |
+
if not self.use_specter:
|
| 149 |
+
self._step_tccm()
|
|
|
|
| 150 |
self._step_export()
|
| 151 |
|
| 152 |
self._result.status = "success"
|
|
|
|
| 179 |
logger.info(" → Abstracts preprocessed: %d docs", len(self._abstracts_clean))
|
| 180 |
|
| 181 |
def _step_model_titles(self):
|
| 182 |
+
"""Step 3 — Topic modeling on Titles (clusters + sub-topics)."""
|
| 183 |
logger.info("Step 3/9: Topic modeling on Titles …")
|
| 184 |
self._title_topics_df, self._title_model = run_topic_modeling(
|
| 185 |
self._titles_clean,
|
| 186 |
source_label="Titles",
|
| 187 |
min_topics=self.min_topics,
|
| 188 |
+
max_clusters=self.max_clusters,
|
| 189 |
)
|
| 190 |
self._result.steps_completed.append("topic_modeling_titles")
|
| 191 |
+
n_clusters = self._title_topics_df["cluster_id"].nunique() if "cluster_id" in self._title_topics_df.columns else len(self._title_topics_df)
|
| 192 |
+
logger.info(" → %d title topics in %d clusters.", len(self._title_topics_df), n_clusters)
|
| 193 |
|
| 194 |
def _step_model_abstracts(self):
|
| 195 |
+
"""Step 4 — Topic modeling on Abstracts (clusters + sub-topics)."""
|
| 196 |
logger.info("Step 4/9: Topic modeling on Abstracts …")
|
| 197 |
self._abstract_topics_df, self._abstract_model = run_topic_modeling(
|
| 198 |
self._abstracts_clean,
|
| 199 |
source_label="Abstracts",
|
| 200 |
min_topics=self.min_topics,
|
| 201 |
+
max_clusters=self.max_clusters,
|
| 202 |
)
|
| 203 |
self._result.steps_completed.append("topic_modeling_abstracts")
|
| 204 |
+
n_clusters = self._abstract_topics_df["cluster_id"].nunique() if "cluster_id" in self._abstract_topics_df.columns else len(self._abstract_topics_df)
|
| 205 |
+
logger.info(" → %d abstract topics in %d clusters.", len(self._abstract_topics_df), n_clusters)
|
| 206 |
|
| 207 |
def _step_generate_labels(self):
|
| 208 |
"""Step 5 — Generate human-readable labels."""
|
|
|
|
| 292 |
|
| 293 |
self._result.steps_completed.append("export")
|
| 294 |
logger.info(" → All outputs exported successfully.")
|
| 295 |
+
|
| 296 |
+
def _step_tccm(self):
|
| 297 |
+
logger.info("Step X: TCCM Clustering phase ...")
|
| 298 |
+
# tccm implementation
|
| 299 |
+
self._result.steps_completed.append("tccm_extraction")
|
| 300 |
+
|
| 301 |
+
def _step_specter_pipeline(self):
|
| 302 |
+
"""Run SPECTER2 Pipeline with two-level hierarchy (clusters + sub-topics)."""
|
| 303 |
+
logger.info("Running SPECTER2 Pipeline...")
|
| 304 |
+
from run_specter import (
|
| 305 |
+
generate_specter2_embeddings, store_in_chroma,
|
| 306 |
+
cluster_with_hdbscan, c_tf_idf,
|
| 307 |
+
extract_top_n_words_per_topic, get_council_labels,
|
| 308 |
+
extract_subtopics_within_clusters,
|
| 309 |
+
)
|
| 310 |
+
import pandas as pd
|
| 311 |
+
|
| 312 |
+
# 1. Combine Abstract + Title
|
| 313 |
+
papers = self._df[['Title', 'Abstract']].to_dict('records')
|
| 314 |
+
titles = [p['Title'] or "" for p in papers]
|
| 315 |
+
abstracts = [p['Abstract'] or "" for p in papers]
|
| 316 |
+
combined = [t + " " + a for t, a in zip(titles, abstracts)]
|
| 317 |
+
|
| 318 |
+
# 2. Embed
|
| 319 |
+
embeddings = generate_specter2_embeddings(
|
| 320 |
+
[{'title': t, 'abstract': a} for t, a in zip(titles, abstracts)]
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# 3. Store
|
| 324 |
+
store_in_chroma(
|
| 325 |
+
embeddings, documents=combined,
|
| 326 |
+
metadatas=[{'id': i} for i in range(len(combined))],
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# 4. Cluster (produces high-level groups)
|
| 330 |
+
labels, _ = cluster_with_hdbscan(embeddings)
|
| 331 |
+
self._df['cluster_id'] = labels
|
| 332 |
+
|
| 333 |
+
# 5. Extract cluster-level keywords
|
| 334 |
+
docs_per_topic = self._df[self._df['cluster_id'] != -1].groupby(
|
| 335 |
+
['cluster_id'], as_index=False
|
| 336 |
+
).agg({'Title': ' '.join})
|
| 337 |
+
docs_per_topic = docs_per_topic.rename(columns={'cluster_id': 'topic_id'})
|
| 338 |
+
tf_idf, count = c_tf_idf(docs_per_topic['Title'].values, m=len(combined))
|
| 339 |
+
top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=10)
|
| 340 |
+
|
| 341 |
+
# Build cluster-level summary
|
| 342 |
+
cluster_rows = []
|
| 343 |
+
for cluster_id, words in top_n_words.items():
|
| 344 |
+
if cluster_id != -1:
|
| 345 |
+
kw = ", ".join([w for w, _ in words])
|
| 346 |
+
cluster_rows.append({"topic_id": int(cluster_id), "keywords": kw})
|
| 347 |
+
self._clusters_df = pd.DataFrame(cluster_rows)
|
| 348 |
+
|
| 349 |
+
n_clusters = len(self._clusters_df)
|
| 350 |
+
logger.info(" → %d clusters formed", n_clusters)
|
| 351 |
+
|
| 352 |
+
# 6. Extract sub-topics within clusters (≥100 topics)
|
| 353 |
+
cluster_labels_arr = self._df['cluster_id'].values
|
| 354 |
+
self._combined_topics_df = extract_subtopics_within_clusters(
|
| 355 |
+
self._df, cluster_labels_arr,
|
| 356 |
+
min_topics=self.min_topics, n_keywords=10,
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
# Add cluster labels
|
| 360 |
+
cluster_label_map = {}
|
| 361 |
+
for _, row in self._clusters_df.iterrows():
|
| 362 |
+
cid = int(row['topic_id'])
|
| 363 |
+
kws = [kw.strip().title() for kw in row['keywords'].split(',')][:3]
|
| 364 |
+
cluster_label_map[cid] = ' / '.join(kws)
|
| 365 |
+
self._combined_topics_df['cluster_label'] = (
|
| 366 |
+
self._combined_topics_df['cluster_id'].map(cluster_label_map).fillna('')
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
logger.info(
|
| 370 |
+
" → %d topics extracted from %d clusters",
|
| 371 |
+
len(self._combined_topics_df), n_clusters,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
# 7. Label topics
|
| 375 |
+
if self.use_llm_labels and self.groq_api_key:
|
| 376 |
+
labels_list = get_council_labels(self._combined_topics_df, self.groq_api_key)
|
| 377 |
+
self._combined_topics_df['label'] = labels_list
|
| 378 |
+
else:
|
| 379 |
+
self._combined_topics_df['label'] = [
|
| 380 |
+
", ".join(row['keywords'].split(",")[:3]).title()
|
| 381 |
+
for _, row in self._combined_topics_df.iterrows()
|
| 382 |
+
]
|
| 383 |
+
|
| 384 |
+
self._result.combined_topics = self._combined_topics_df
|
| 385 |
+
|
agents.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tools import merge_similar_clusters, extract_subtopics_within_clusters
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
agents.py — TopicAgent orchestrates the end-to-end topic modeling workflow.
|
| 5 |
+
|
| 6 |
+
This module defines the TopicAgent class, which:
|
| 7 |
+
1. Loads and validates the CSV dataset.
|
| 8 |
+
2. Preprocesses text for Titles and Abstracts separately.
|
| 9 |
+
3. Runs topic modeling on each corpus (≥100 topics guaranteed).
|
| 10 |
+
4. Generates human-readable labels for every topic.
|
| 11 |
+
5. Compares dominant themes across Title and Abstract topics.
|
| 12 |
+
6. Produces a taxonomy map (MAPPED / NOVEL classification).
|
| 13 |
+
7. Exports structured outputs: topics table, comparison CSV, taxonomy JSON.
|
| 14 |
+
|
| 15 |
+
OR (SPECTER mode):
|
| 16 |
+
1. Loads CSV
|
| 17 |
+
2. Generates SPECTER2 embeddings + stores in ChromaDB
|
| 18 |
+
3. Adaptive HDBSCAN clustering (5-140 clusters)
|
| 19 |
+
4. c-TF-IDF keyword extraction + representative sentences
|
| 20 |
+
5. AI Council labeling with debate transcripts
|
| 21 |
+
6. Taxonomy mapping
|
| 22 |
+
7. Export
|
| 23 |
+
|
| 24 |
+
Usage:
|
| 25 |
+
agent = TopicAgent(csv_path="dataset.csv")
|
| 26 |
+
results = agent.run()
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import json
|
| 31 |
+
import logging
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from typing import Dict, Any, List, Optional
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import pandas as pd
|
| 37 |
+
|
| 38 |
+
from tools import (
|
| 39 |
+
load_csv,
|
| 40 |
+
preprocess_text,
|
| 41 |
+
run_topic_modeling,
|
| 42 |
+
generate_labels,
|
| 43 |
+
compare_themes,
|
| 44 |
+
create_taxonomy_map,
|
| 45 |
+
generate_specter2_embeddings,
|
| 46 |
+
store_in_chroma,
|
| 47 |
+
load_embeddings_from_chroma,
|
| 48 |
+
adaptive_hdbscan_clustering,
|
| 49 |
+
c_tf_idf,
|
| 50 |
+
extract_top_n_words_per_topic,
|
| 51 |
+
extract_representative_sentences,
|
| 52 |
+
get_council_debate,
|
| 53 |
+
build_cluster_graph,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
# Logging
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
logger = logging.getLogger(__name__)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Structured result container
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
@dataclass
|
| 66 |
+
class AgentResult:
|
| 67 |
+
"""Container for all outputs produced by the TopicAgent."""
|
| 68 |
+
# Core dataframes
|
| 69 |
+
title_topics: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 70 |
+
abstract_topics: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 71 |
+
combined_topics: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 72 |
+
comparison: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 73 |
+
|
| 74 |
+
# Taxonomy map (dict serialisable to JSON)
|
| 75 |
+
taxonomy_map: Dict[str, Any] = field(default_factory=dict)
|
| 76 |
+
|
| 77 |
+
# SPECTER/Cluster-specific data
|
| 78 |
+
cluster_data: Dict[str, Any] = field(default_factory=dict)
|
| 79 |
+
# Holds: embeddings_2d, cluster_labels, cluster_details, representative_sentences,
|
| 80 |
+
# cluster_graph_edges, n_clusters, hdbscan_params
|
| 81 |
+
council_debates: Dict[int, Dict[str, str]] = field(default_factory=dict)
|
| 82 |
+
# {topic_id: {expert, analyst, anti_hallucination, final_label, ...}}
|
| 83 |
+
|
| 84 |
+
# Pipeline mode: "default" or "specter"
|
| 85 |
+
pipeline_mode: str = "default"
|
| 86 |
+
|
| 87 |
+
# Execution metadata
|
| 88 |
+
status: str = "pending"
|
| 89 |
+
steps_completed: list = field(default_factory=list)
|
| 90 |
+
errors: list = field(default_factory=list)
|
| 91 |
+
|
| 92 |
+
# File paths of exported artefacts
|
| 93 |
+
exported_files: Dict[str, str] = field(default_factory=dict)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# TopicAgent
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
class TopicAgent:
|
| 100 |
+
"""
|
| 101 |
+
Orchestrates the research-paper topic modeling pipeline.
|
| 102 |
+
|
| 103 |
+
Parameters
|
| 104 |
+
----------
|
| 105 |
+
csv_path : str
|
| 106 |
+
Path to the input CSV file.
|
| 107 |
+
output_dir : str
|
| 108 |
+
Directory to write output files.
|
| 109 |
+
min_topics : int
|
| 110 |
+
Minimum number of topics to generate per source (default 100).
|
| 111 |
+
use_llm_labels : bool
|
| 112 |
+
Whether to use Groq LLM for label generation.
|
| 113 |
+
use_specter : bool
|
| 114 |
+
Whether to use SPECTER2 + HDBSCAN clustering pipeline.
|
| 115 |
+
groq_api_key : str, optional
|
| 116 |
+
API key for Groq (used only when use_llm_labels is True).
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def __init__(
|
| 120 |
+
self,
|
| 121 |
+
csv_path: str,
|
| 122 |
+
output_dir: str = "outputs",
|
| 123 |
+
min_topics: int = 100,
|
| 124 |
+
max_clusters: int = 25,
|
| 125 |
+
use_llm_labels: bool = False,
|
| 126 |
+
use_specter: bool = False,
|
| 127 |
+
groq_api_key: Optional[str] = None,
|
| 128 |
+
):
|
| 129 |
+
self.csv_path = csv_path
|
| 130 |
+
self.output_dir = output_dir
|
| 131 |
+
self.min_topics = min_topics
|
| 132 |
+
self.max_clusters = max_clusters
|
| 133 |
+
self.use_llm_labels = use_llm_labels
|
| 134 |
+
self.use_specter = use_specter
|
| 135 |
+
self.groq_api_key = groq_api_key
|
| 136 |
+
|
| 137 |
+
# Ensure output directory exists
|
| 138 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 139 |
+
|
| 140 |
+
self._result = AgentResult()
|
| 141 |
+
if use_specter:
|
| 142 |
+
self._result.pipeline_mode = "specter"
|
| 143 |
+
|
| 144 |
+
# Internal state
|
| 145 |
+
self._df = None
|
| 146 |
+
self._embeddings = None
|
| 147 |
+
self._cluster_labels = None
|
| 148 |
+
self._reduced_2d = None
|
| 149 |
+
|
| 150 |
+
# -----------------------------------------------------------------
|
| 151 |
+
# Public interface
|
| 152 |
+
# -----------------------------------------------------------------
|
| 153 |
+
def run(self) -> AgentResult:
|
| 154 |
+
"""Execute the full pipeline. Use sub-steps for progress display."""
|
| 155 |
+
logger.info("=" * 60)
|
| 156 |
+
logger.info("TopicAgent — Starting pipeline (mode=%s)", self._result.pipeline_mode)
|
| 157 |
+
logger.info("=" * 60)
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
self._step_load_csv()
|
| 161 |
+
|
| 162 |
+
if self.use_specter:
|
| 163 |
+
self._step_specter_embed()
|
| 164 |
+
self._step_specter_cluster()
|
| 165 |
+
self._step_specter_extract()
|
| 166 |
+
self._step_merge_similar_clusters()
|
| 167 |
+
self._step_specter_subtopics()
|
| 168 |
+
self._step_specter_label()
|
| 169 |
+
self._step_specter_taxonomy()
|
| 170 |
+
else:
|
| 171 |
+
self._step_preprocess()
|
| 172 |
+
self._step_model_titles()
|
| 173 |
+
self._step_model_abstracts()
|
| 174 |
+
self._step_generate_labels()
|
| 175 |
+
self._step_combine_topics()
|
| 176 |
+
self._step_compare_themes()
|
| 177 |
+
self._step_taxonomy_map()
|
| 178 |
+
|
| 179 |
+
self._step_export()
|
| 180 |
+
self._result.status = "success"
|
| 181 |
+
logger.info("Pipeline completed successfully.")
|
| 182 |
+
|
| 183 |
+
except Exception as exc:
|
| 184 |
+
self._result.status = "failed"
|
| 185 |
+
self._result.errors.append(str(exc))
|
| 186 |
+
logger.error("Pipeline failed: %s", exc, exc_info=True)
|
| 187 |
+
|
| 188 |
+
return self._result
|
| 189 |
+
|
| 190 |
+
# -----------------------------------------------------------------
|
| 191 |
+
# Shared steps
|
| 192 |
+
# -----------------------------------------------------------------
|
| 193 |
+
def _step_load_csv(self):
|
| 194 |
+
"""Step 1 — Ingest CSV dataset."""
|
| 195 |
+
logger.info("Step 1: Loading CSV …")
|
| 196 |
+
self._df = load_csv(self.csv_path)
|
| 197 |
+
self._result.steps_completed.append("load_csv")
|
| 198 |
+
logger.info(" → %d papers loaded.", len(self._df))
|
| 199 |
+
|
| 200 |
+
# -----------------------------------------------------------------
|
| 201 |
+
# DEFAULT pipeline steps
|
| 202 |
+
# -----------------------------------------------------------------
|
| 203 |
+
def _step_preprocess(self):
|
| 204 |
+
"""Step 2 — Preprocess Title and Abstract text."""
|
| 205 |
+
logger.info("Step 2: Preprocessing text …")
|
| 206 |
+
self._titles_clean = preprocess_text(self._df["Title"].tolist())
|
| 207 |
+
self._abstracts_clean = preprocess_text(self._df["Abstract"].tolist())
|
| 208 |
+
self._result.steps_completed.append("preprocess_text")
|
| 209 |
+
|
| 210 |
+
def _step_model_titles(self):
|
| 211 |
+
"""Step 3 — Topic modeling on Titles."""
|
| 212 |
+
logger.info("Step 3: Topic modeling on Titles …")
|
| 213 |
+
self._title_topics_df, self._title_model = run_topic_modeling(
|
| 214 |
+
self._titles_clean, source_label="Titles", min_topics=self.min_topics,
|
| 215 |
+
)
|
| 216 |
+
self._result.steps_completed.append("topic_modeling_titles")
|
| 217 |
+
|
| 218 |
+
def _step_model_abstracts(self):
|
| 219 |
+
"""Step 4 — Topic modeling on Abstracts."""
|
| 220 |
+
logger.info("Step 4: Topic modeling on Abstracts …")
|
| 221 |
+
self._abstract_topics_df, self._abstract_model = run_topic_modeling(
|
| 222 |
+
self._abstracts_clean, source_label="Abstracts", min_topics=self.min_topics,
|
| 223 |
+
)
|
| 224 |
+
self._result.steps_completed.append("topic_modeling_abstracts")
|
| 225 |
+
|
| 226 |
+
def _step_generate_labels(self):
|
| 227 |
+
"""Step 5 — Generate human-readable labels."""
|
| 228 |
+
logger.info("Step 5: Generating topic labels …")
|
| 229 |
+
self._title_topics_df = generate_labels(
|
| 230 |
+
self._title_topics_df, use_llm=self.use_llm_labels, groq_api_key=self.groq_api_key,
|
| 231 |
+
)
|
| 232 |
+
self._abstract_topics_df = generate_labels(
|
| 233 |
+
self._abstract_topics_df, use_llm=self.use_llm_labels, groq_api_key=self.groq_api_key,
|
| 234 |
+
)
|
| 235 |
+
self._result.title_topics = self._title_topics_df
|
| 236 |
+
self._result.abstract_topics = self._abstract_topics_df
|
| 237 |
+
self._result.steps_completed.append("generate_labels")
|
| 238 |
+
|
| 239 |
+
def _step_combine_topics(self):
|
| 240 |
+
"""Step 6 — Combine title and abstract topics."""
|
| 241 |
+
logger.info("Step 6: Building combined topics table …")
|
| 242 |
+
combined = pd.concat([self._title_topics_df, self._abstract_topics_df], ignore_index=True)
|
| 243 |
+
combined["global_id"] = range(len(combined))
|
| 244 |
+
self._result.combined_topics = combined
|
| 245 |
+
self._result.steps_completed.append("combine_topics")
|
| 246 |
+
|
| 247 |
+
def _step_compare_themes(self):
|
| 248 |
+
"""Step 7 — Compare title vs abstract themes."""
|
| 249 |
+
logger.info("Step 7: Comparing title vs abstract themes …")
|
| 250 |
+
comparison = compare_themes(self._title_topics_df, self._abstract_topics_df)
|
| 251 |
+
self._result.comparison = comparison
|
| 252 |
+
self._result.steps_completed.append("compare_themes")
|
| 253 |
+
|
| 254 |
+
def _step_taxonomy_map(self):
|
| 255 |
+
"""Step 8 — Create taxonomy map."""
|
| 256 |
+
logger.info("Step 8: Building taxonomy map …")
|
| 257 |
+
taxonomy = create_taxonomy_map(self._result.combined_topics)
|
| 258 |
+
self._result.taxonomy_map = taxonomy
|
| 259 |
+
self._result.steps_completed.append("create_taxonomy_map")
|
| 260 |
+
|
| 261 |
+
# -----------------------------------------------------------------
|
| 262 |
+
# SPECTER pipeline steps (discrete, callable from app.py)
|
| 263 |
+
# -----------------------------------------------------------------
|
| 264 |
+
def _step_specter_embed(self):
|
| 265 |
+
"""SPECTER Step 2 — Generate embeddings and store in ChromaDB."""
|
| 266 |
+
logger.info(">>> Clustering pipeline started")
|
| 267 |
+
logger.info("SPECTER Step 2: Generating SPECTER2 embeddings …")
|
| 268 |
+
|
| 269 |
+
titles = [str(t) if t else "" for t in self._df["Title"].tolist()]
|
| 270 |
+
abstracts = [str(a) if a else "" for a in self._df["Abstract"].tolist()]
|
| 271 |
+
combined_docs = [t + " " + a for t, a in zip(titles, abstracts)]
|
| 272 |
+
|
| 273 |
+
# Check if embeddings already cached in ChromaDB with correct count
|
| 274 |
+
try:
|
| 275 |
+
cached_emb, cached_docs, _ = load_embeddings_from_chroma()
|
| 276 |
+
if len(cached_emb) == len(self._df):
|
| 277 |
+
logger.info("Loaded embeddings from vector DB — %d vectors", len(cached_emb))
|
| 278 |
+
self._embeddings = cached_emb
|
| 279 |
+
self._combined_docs = combined_docs
|
| 280 |
+
self._result.steps_completed.append("specter_embed_cached")
|
| 281 |
+
return
|
| 282 |
+
except (ValueError, Exception) as e:
|
| 283 |
+
logger.info("No cached embeddings found (%s), generating fresh …", e)
|
| 284 |
+
|
| 285 |
+
# Generate fresh embeddings
|
| 286 |
+
papers = [{"title": t, "abstract": a} for t, a in zip(titles, abstracts)]
|
| 287 |
+
self._embeddings = generate_specter2_embeddings(papers)
|
| 288 |
+
self._combined_docs = combined_docs
|
| 289 |
+
|
| 290 |
+
# Store in ChromaDB
|
| 291 |
+
metadatas = [{"id": i, "title": titles[i][:200]} for i in range(len(combined_docs))]
|
| 292 |
+
store_in_chroma(self._embeddings, documents=combined_docs, metadatas=metadatas)
|
| 293 |
+
logger.info("Loaded embeddings from vector DB — %d vectors", len(self._embeddings))
|
| 294 |
+
|
| 295 |
+
# Validate — no nulls
|
| 296 |
+
null_count = int(np.isnan(self._embeddings).any(axis=1).sum())
|
| 297 |
+
if null_count > 0:
|
| 298 |
+
logger.warning("Found %d embeddings with NaN values, replacing with zeros", null_count)
|
| 299 |
+
self._embeddings = np.nan_to_num(self._embeddings, nan=0.0)
|
| 300 |
+
|
| 301 |
+
self._result.steps_completed.append("specter_embed")
|
| 302 |
+
|
| 303 |
+
def _step_specter_cluster(self):
|
| 304 |
+
"""SPECTER Step 3 — Adaptive HDBSCAN clustering (10–40 clusters)."""
|
| 305 |
+
logger.info("Running HDBSCAN (targeting %d–%d clusters)...", 10, self.max_clusters)
|
| 306 |
+
|
| 307 |
+
labels, reduced_2d, n_clusters, params = adaptive_hdbscan_clustering(
|
| 308 |
+
self._embeddings, min_clusters=10, max_clusters=self.max_clusters,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
self._cluster_labels = np.array(labels)
|
| 312 |
+
self._reduced_2d = reduced_2d
|
| 313 |
+
self._df["cluster_id"] = labels
|
| 314 |
+
|
| 315 |
+
logger.info("Clusters generated: %d", n_clusters)
|
| 316 |
+
|
| 317 |
+
# Safeguard: if < 5 clusters, raise error
|
| 318 |
+
if n_clusters < 5:
|
| 319 |
+
raise RuntimeError(
|
| 320 |
+
f"Clustering produced only {n_clusters} clusters (minimum 5 required). "
|
| 321 |
+
"Try adjusting parameters or adding more data."
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
self._result.cluster_data["n_clusters"] = n_clusters
|
| 325 |
+
self._result.cluster_data["hdbscan_params"] = params
|
| 326 |
+
self._result.cluster_data["embeddings_2d"] = reduced_2d.tolist()
|
| 327 |
+
self._result.cluster_data["cluster_labels"] = labels.tolist() if hasattr(labels, 'tolist') else list(labels)
|
| 328 |
+
self._result.steps_completed.append("specter_cluster")
|
| 329 |
+
|
| 330 |
+
def _step_specter_extract(self):
|
| 331 |
+
"""SPECTER Step 4 — Extract cluster-level keywords and representative sentences."""
|
| 332 |
+
logger.info("SPECTER Step 4: Extracting cluster keywords and representative sentences …")
|
| 333 |
+
|
| 334 |
+
# c-TF-IDF keyword extraction at CLUSTER level
|
| 335 |
+
df_with_clusters = self._df.copy()
|
| 336 |
+
df_with_clusters["cluster_id"] = self._cluster_labels
|
| 337 |
+
|
| 338 |
+
# Group documents by cluster for c-TF-IDF
|
| 339 |
+
docs_per_topic = df_with_clusters.groupby("cluster_id", as_index=False).agg(
|
| 340 |
+
{"Title": " ".join, "Abstract": " ".join}
|
| 341 |
+
)
|
| 342 |
+
docs_per_topic = docs_per_topic[docs_per_topic["cluster_id"] != -1]
|
| 343 |
+
docs_per_topic = docs_per_topic.rename(columns={"cluster_id": "topic_id"})
|
| 344 |
+
|
| 345 |
+
# Use combined Title + Abstract for better keyword extraction
|
| 346 |
+
combined_text = (docs_per_topic["Title"] + " " + docs_per_topic["Abstract"]).values
|
| 347 |
+
tf_idf, count = c_tf_idf(combined_text, m=len(self._df))
|
| 348 |
+
top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=10)
|
| 349 |
+
|
| 350 |
+
# Build CLUSTER-level DataFrame (these are the high-level groups)
|
| 351 |
+
cluster_rows = []
|
| 352 |
+
for cluster_id, words in top_n_words.items():
|
| 353 |
+
if cluster_id == -1:
|
| 354 |
+
continue
|
| 355 |
+
kw = ", ".join([w for w, _ in words])
|
| 356 |
+
n_docs = int((self._cluster_labels == cluster_id).sum())
|
| 357 |
+
cluster_rows.append({
|
| 358 |
+
"topic_id": int(cluster_id),
|
| 359 |
+
"keywords": kw,
|
| 360 |
+
"source": "SPECTER2+HDBSCAN",
|
| 361 |
+
"n_documents": n_docs,
|
| 362 |
+
})
|
| 363 |
+
|
| 364 |
+
self._clusters_df = pd.DataFrame(cluster_rows)
|
| 365 |
+
logger.info(" → %d clusters with keywords extracted", len(self._clusters_df))
|
| 366 |
+
|
| 367 |
+
# Extract representative sentences
|
| 368 |
+
rep_sentences = extract_representative_sentences(
|
| 369 |
+
self._df, self._embeddings, self._cluster_labels, top_n=5,
|
| 370 |
+
)
|
| 371 |
+
self._result.cluster_data["representative_sentences"] = rep_sentences
|
| 372 |
+
|
| 373 |
+
# Build cluster graph
|
| 374 |
+
edges = build_cluster_graph(self._clusters_df, self._embeddings, self._cluster_labels)
|
| 375 |
+
self._result.cluster_data["graph_edges"] = edges
|
| 376 |
+
|
| 377 |
+
# Store per-cluster details
|
| 378 |
+
cluster_details = {}
|
| 379 |
+
for _, row in self._clusters_df.iterrows():
|
| 380 |
+
cid = int(row["topic_id"])
|
| 381 |
+
cluster_details[cid] = {
|
| 382 |
+
"cluster_id": cid,
|
| 383 |
+
"keywords": row["keywords"],
|
| 384 |
+
"n_documents": int(row["n_documents"]),
|
| 385 |
+
"representatives": rep_sentences.get(cid, []),
|
| 386 |
+
}
|
| 387 |
+
self._result.cluster_data["cluster_details"] = cluster_details
|
| 388 |
+
|
| 389 |
+
self._result.steps_completed.append("specter_extract")
|
| 390 |
+
|
| 391 |
+
def _step_specter_subtopics(self):
|
| 392 |
+
"""SPECTER Step 5 — Extract ≥100 fine-grained sub-topics within each cluster."""
|
| 393 |
+
logger.info(
|
| 394 |
+
"SPECTER Step 5: Extracting sub-topics (target ≥ %d topics from %d clusters) …",
|
| 395 |
+
self.min_topics, len(self._clusters_df),
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
self._topics_df = extract_subtopics_within_clusters(
|
| 399 |
+
self._df,
|
| 400 |
+
self._cluster_labels,
|
| 401 |
+
min_topics=self.min_topics,
|
| 402 |
+
n_keywords=10,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
# Add cluster_label from the clusters_df
|
| 406 |
+
cluster_label_map = {}
|
| 407 |
+
for _, row in self._clusters_df.iterrows():
|
| 408 |
+
cid = int(row["topic_id"])
|
| 409 |
+
# Heuristic label from top 3 keywords
|
| 410 |
+
kws = [kw.strip().title() for kw in row["keywords"].split(",")][:3]
|
| 411 |
+
cluster_label_map[cid] = " / ".join(kws)
|
| 412 |
+
self._topics_df["cluster_label"] = self._topics_df["cluster_id"].map(cluster_label_map).fillna("")
|
| 413 |
+
|
| 414 |
+
logger.info(
|
| 415 |
+
" → %d topics extracted from %d clusters",
|
| 416 |
+
len(self._topics_df),
|
| 417 |
+
self._topics_df["cluster_id"].nunique(),
|
| 418 |
+
)
|
| 419 |
+
self._result.steps_completed.append("specter_subtopics")
|
| 420 |
+
|
| 421 |
+
def _step_specter_label(self):
|
| 422 |
+
"""SPECTER Step 6 — Label clusters and topics."""
|
| 423 |
+
logger.info("SPECTER Step 6: Labeling clusters and topics …")
|
| 424 |
+
|
| 425 |
+
# --- Label CLUSTERS (high-level) ---
|
| 426 |
+
if self.use_llm_labels and self.groq_api_key:
|
| 427 |
+
logger.info("Running AI Council debate for %d clusters …", len(self._clusters_df))
|
| 428 |
+
cluster_labels_list = []
|
| 429 |
+
debates = {}
|
| 430 |
+
for _, row in self._clusters_df.iterrows():
|
| 431 |
+
cid = int(row["topic_id"])
|
| 432 |
+
debate = get_council_debate(row["keywords"], self.groq_api_key)
|
| 433 |
+
debates[cid] = debate
|
| 434 |
+
cluster_labels_list.append(debate["final_label"])
|
| 435 |
+
logger.info(" Cluster %d → '%s'", cid, debate["final_label"])
|
| 436 |
+
|
| 437 |
+
self._clusters_df["label"] = cluster_labels_list
|
| 438 |
+
self._result.council_debates = debates
|
| 439 |
+
|
| 440 |
+
# Update cluster_label in topics_df with LLM labels
|
| 441 |
+
cluster_label_map = dict(zip(self._clusters_df["topic_id"].astype(int), self._clusters_df["label"]))
|
| 442 |
+
self._topics_df["cluster_label"] = self._topics_df["cluster_id"].map(cluster_label_map).fillna("")
|
| 443 |
+
else:
|
| 444 |
+
logger.info("Using heuristic labels (LLM not enabled)")
|
| 445 |
+
self._clusters_df["label"] = [
|
| 446 |
+
", ".join(row["keywords"].split(",")[:3]).strip().title()
|
| 447 |
+
for _, row in self._clusters_df.iterrows()
|
| 448 |
+
]
|
| 449 |
+
|
| 450 |
+
# --- Label TOPICS (fine-grained) ---
|
| 451 |
+
if self.use_llm_labels and self.groq_api_key:
|
| 452 |
+
logger.info("Generating LLM labels for %d topics …", len(self._topics_df))
|
| 453 |
+
from tools import _generate_labels_llm
|
| 454 |
+
topic_labels_df = self._topics_df.copy()
|
| 455 |
+
topic_labels_df = _generate_labels_llm(topic_labels_df, self.groq_api_key)
|
| 456 |
+
self._topics_df["label"] = topic_labels_df["label"]
|
| 457 |
+
else:
|
| 458 |
+
self._topics_df["label"] = [
|
| 459 |
+
", ".join(row["keywords"].split(",")[:3]).strip().title()
|
| 460 |
+
for _, row in self._topics_df.iterrows()
|
| 461 |
+
]
|
| 462 |
+
|
| 463 |
+
# Populate result — combined_topics now has the TWO-LEVEL hierarchy
|
| 464 |
+
self._result.combined_topics = self._topics_df
|
| 465 |
+
self._result.title_topics = self._clusters_df # Cluster-level view
|
| 466 |
+
self._result.abstract_topics = pd.DataFrame() # Not applicable for SPECTER
|
| 467 |
+
self._result.steps_completed.append("specter_label")
|
| 468 |
+
|
| 469 |
+
def _step_merge_similar_clusters(self):
|
| 470 |
+
"""SPECTER Step 4b — Merge semantically similar clusters before sub-topic extraction."""
|
| 471 |
+
from tools import merge_similar_clusters
|
| 472 |
+
|
| 473 |
+
if not hasattr(self, "_clusters_df") or self._clusters_df.empty:
|
| 474 |
+
return
|
| 475 |
+
|
| 476 |
+
logger.info("SPECTER Step 4b: Post-merging similar clusters...")
|
| 477 |
+
new_clusters_df, new_labels = merge_similar_clusters(
|
| 478 |
+
self._clusters_df, self._cluster_labels, self._df,
|
| 479 |
+
threshold=0.75, groq_api_key=self.groq_api_key
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
self._clusters_df = new_clusters_df
|
| 483 |
+
self._cluster_labels = new_labels
|
| 484 |
+
|
| 485 |
+
n_clusters = len(self._clusters_df)
|
| 486 |
+
self._result.cluster_data["cluster_labels"] = new_labels.tolist() if hasattr(new_labels, 'tolist') else list(new_labels)
|
| 487 |
+
self._result.cluster_data["n_clusters"] = n_clusters
|
| 488 |
+
|
| 489 |
+
logger.info(" → After merging: %d clusters remain", n_clusters)
|
| 490 |
+
self._result.steps_completed.append("specter_merge")
|
| 491 |
+
|
| 492 |
+
def _step_specter_taxonomy(self):
|
| 493 |
+
"""SPECTER Step 6 — Build taxonomy map for clustered topics."""
|
| 494 |
+
logger.info("SPECTER Step 6: Building taxonomy map …")
|
| 495 |
+
taxonomy = create_taxonomy_map(self._result.combined_topics)
|
| 496 |
+
self._result.taxonomy_map = taxonomy
|
| 497 |
+
self._result.steps_completed.append("specter_taxonomy")
|
| 498 |
+
|
| 499 |
+
# -----------------------------------------------------------------
|
| 500 |
+
# Export (shared)
|
| 501 |
+
# -----------------------------------------------------------------
|
| 502 |
+
def _step_export(self):
|
| 503 |
+
"""Export all outputs to disk."""
|
| 504 |
+
logger.info("Exporting outputs …")
|
| 505 |
+
|
| 506 |
+
# (a) Combined topics table CSV
|
| 507 |
+
topics_path = os.path.join(self.output_dir, "topics_table.csv")
|
| 508 |
+
self._result.combined_topics.to_csv(topics_path, index=False)
|
| 509 |
+
self._result.exported_files["topics_table"] = topics_path
|
| 510 |
+
|
| 511 |
+
# (b) Comparison CSV
|
| 512 |
+
comparison_path = os.path.join(self.output_dir, "comparison.csv")
|
| 513 |
+
self._result.comparison.to_csv(comparison_path, index=False)
|
| 514 |
+
self._result.exported_files["comparison"] = comparison_path
|
| 515 |
+
|
| 516 |
+
# (c) Taxonomy map JSON
|
| 517 |
+
taxonomy_path = os.path.join(self.output_dir, "taxonomy_map.json")
|
| 518 |
+
with open(taxonomy_path, "w", encoding="utf-8") as f:
|
| 519 |
+
json.dump(self._result.taxonomy_map, f, indent=2, ensure_ascii=False)
|
| 520 |
+
self._result.exported_files["taxonomy_map"] = taxonomy_path
|
| 521 |
+
|
| 522 |
+
# (d) Title topics CSV
|
| 523 |
+
title_path = os.path.join(self.output_dir, "title_topics.csv")
|
| 524 |
+
self._result.title_topics.to_csv(title_path, index=False)
|
| 525 |
+
self._result.exported_files["title_topics"] = title_path
|
| 526 |
+
|
| 527 |
+
# (e) Abstract topics CSV
|
| 528 |
+
abstract_path = os.path.join(self.output_dir, "abstract_topics.csv")
|
| 529 |
+
self._result.abstract_topics.to_csv(abstract_path, index=False)
|
| 530 |
+
self._result.exported_files["abstract_topics"] = abstract_path
|
| 531 |
+
|
| 532 |
+
# (f) Cluster data JSON (SPECTER mode)
|
| 533 |
+
if self._result.pipeline_mode == "specter" and self._result.cluster_data:
|
| 534 |
+
cluster_path = os.path.join(self.output_dir, "cluster_data.json")
|
| 535 |
+
# Don't save embeddings_2d to JSON (too large), save separately
|
| 536 |
+
cluster_export = {
|
| 537 |
+
k: v for k, v in self._result.cluster_data.items()
|
| 538 |
+
if k != "embeddings_2d"
|
| 539 |
+
}
|
| 540 |
+
with open(cluster_path, "w", encoding="utf-8") as f:
|
| 541 |
+
json.dump(cluster_export, f, indent=2, ensure_ascii=False, default=str)
|
| 542 |
+
self._result.exported_files["cluster_data"] = cluster_path
|
| 543 |
+
|
| 544 |
+
# (g) Council debates JSON
|
| 545 |
+
if self._result.council_debates:
|
| 546 |
+
debates_path = os.path.join(self.output_dir, "council_debates.json")
|
| 547 |
+
with open(debates_path, "w", encoding="utf-8") as f:
|
| 548 |
+
json.dump(self._result.council_debates, f, indent=2, ensure_ascii=False, default=str)
|
| 549 |
+
self._result.exported_files["council_debates"] = debates_path
|
| 550 |
+
|
| 551 |
+
self._result.steps_completed.append("export")
|
| 552 |
+
logger.info(" → All outputs exported successfully.")
|
app.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
app.py — Streamlit frontend for the AI-driven Topic Modeling application.
|
| 3 |
|
|
@@ -20,7 +24,7 @@ import pandas as pd
|
|
| 20 |
import plotly.express as px
|
| 21 |
import plotly.graph_objects as go
|
| 22 |
|
| 23 |
-
from
|
| 24 |
|
| 25 |
# ---------------------------------------------------------------------------
|
| 26 |
# HuggingFace Spaces compatibility: use a writable output directory
|
|
@@ -302,10 +306,19 @@ with st.sidebar:
|
|
| 302 |
min_topics = st.slider(
|
| 303 |
"Minimum Topics",
|
| 304 |
min_value=50,
|
| 305 |
-
max_value=
|
| 306 |
value=100,
|
| 307 |
step=10,
|
| 308 |
-
help="Minimum number of topics to generate per source
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
)
|
| 310 |
|
| 311 |
use_llm = st.checkbox(
|
|
@@ -325,16 +338,16 @@ with st.sidebar:
|
|
| 325 |
)
|
| 326 |
|
| 327 |
st.markdown("---")
|
| 328 |
-
st.markdown("### 📋 Pipeline Steps")
|
| 329 |
steps_info = [
|
| 330 |
"1. Load & validate CSV",
|
| 331 |
-
"2.
|
| 332 |
-
"3.
|
| 333 |
-
"4.
|
| 334 |
-
"5.
|
| 335 |
-
"6.
|
| 336 |
-
"7.
|
| 337 |
-
"8. Build taxonomy map
|
| 338 |
"9. Export outputs (CSV, JSON)",
|
| 339 |
]
|
| 340 |
for step in steps_info:
|
|
@@ -388,27 +401,37 @@ if run_clicked:
|
|
| 388 |
csv_path=csv_path,
|
| 389 |
output_dir=OUTPUT_DIR,
|
| 390 |
min_topics=min_topics,
|
|
|
|
| 391 |
use_llm_labels=use_llm,
|
|
|
|
| 392 |
groq_api_key=groq_key if use_llm else None,
|
| 393 |
)
|
| 394 |
|
| 395 |
-
# Display step-by-step progress
|
| 396 |
progress.progress(5, text="Step 1/9: Loading CSV …")
|
| 397 |
agent._step_load_csv()
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
progress.progress(
|
| 403 |
-
agent.
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
progress.progress(
|
| 409 |
-
agent.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
progress.progress(90, text="Step 8/9: Building taxonomy map …")
|
| 411 |
-
agent.
|
|
|
|
| 412 |
progress.progress(95, text="Step 9/9: Exporting outputs …")
|
| 413 |
agent._step_export()
|
| 414 |
|
|
@@ -478,29 +501,41 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 478 |
# ---- Summary Statistics ----
|
| 479 |
st.markdown('<div class="section-header">📊 Summary Statistics</div>', unsafe_allow_html=True)
|
| 480 |
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
with c1:
|
| 483 |
st.markdown(f"""
|
| 484 |
<div class="stat-card">
|
| 485 |
-
<div class="stat-value">{
|
| 486 |
-
<div class="stat-label">
|
| 487 |
</div>
|
| 488 |
""", unsafe_allow_html=True)
|
| 489 |
with c2:
|
| 490 |
st.markdown(f"""
|
| 491 |
<div class="stat-card">
|
| 492 |
-
<div class="stat-value">{
|
| 493 |
-
<div class="stat-label">
|
| 494 |
</div>
|
| 495 |
""", unsafe_allow_html=True)
|
| 496 |
with c3:
|
| 497 |
st.markdown(f"""
|
| 498 |
<div class="stat-card">
|
| 499 |
-
<div class="stat-value">{len(result.
|
| 500 |
-
<div class="stat-label">
|
| 501 |
</div>
|
| 502 |
""", unsafe_allow_html=True)
|
| 503 |
with c4:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
mapped_count = result.taxonomy_map.get("metadata", {}).get("mapped_count", 0)
|
| 505 |
st.markdown(f"""
|
| 506 |
<div class="stat-card">
|
|
@@ -508,7 +543,7 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 508 |
<div class="stat-label">Mapped Themes</div>
|
| 509 |
</div>
|
| 510 |
""", unsafe_allow_html=True)
|
| 511 |
-
with
|
| 512 |
novel_count = result.taxonomy_map.get("metadata", {}).get("novel_count", 0)
|
| 513 |
st.markdown(f"""
|
| 514 |
<div class="stat-card">
|
|
@@ -520,68 +555,451 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 520 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 521 |
|
| 522 |
# ---- Tabbed Results ----
|
| 523 |
-
tab1,
|
| 524 |
"📋 Topics Table",
|
| 525 |
-
"
|
| 526 |
-
"
|
|
|
|
|
|
|
| 527 |
"⚖️ Theme Comparison",
|
| 528 |
"🗺️ Taxonomy Map",
|
| 529 |
"✏️ Review Table",
|
| 530 |
"📈 Charts",
|
| 531 |
])
|
| 532 |
|
| 533 |
-
# Tab 1: Combined Topics Table
|
| 534 |
with tab1:
|
| 535 |
st.markdown('<div class="section-header">Combined Topics Table</div>', unsafe_allow_html=True)
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
# Filter controls
|
| 539 |
-
fcol1, fcol2 = st.columns(
|
| 540 |
with fcol1:
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
| 546 |
with fcol2:
|
| 547 |
search_term = st.text_input("🔍 Search keywords", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
|
| 549 |
-
display_df = result.combined_topics
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
if search_term:
|
| 551 |
mask = display_df["keywords"].str.contains(search_term, case=False, na=False)
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
| 553 |
display_df = display_df[mask]
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
st.dataframe(
|
| 556 |
display_df,
|
| 557 |
use_container_width=True,
|
| 558 |
height=500,
|
| 559 |
-
column_config=
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
)
|
| 566 |
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
|
|
|
| 572 |
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
# Tab 4: Theme Comparison
|
| 580 |
with tab4:
|
| 581 |
-
st.markdown('<div class="section-header">Theme Comparison
|
| 582 |
|
| 583 |
if not result.comparison.empty:
|
| 584 |
-
#
|
|
|
|
| 585 |
align_counts = result.comparison["alignment"].value_counts()
|
| 586 |
acol1, acol2, acol3, acol4 = st.columns(4)
|
| 587 |
for col, alignment in zip(
|
|
@@ -594,7 +1012,6 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 594 |
|
| 595 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 596 |
|
| 597 |
-
# Filter by alignment
|
| 598 |
alignment_filter = st.multiselect(
|
| 599 |
"Filter by Alignment",
|
| 600 |
options=["Strong", "Moderate", "Weak", "No Match"],
|
|
@@ -615,8 +1032,72 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 615 |
),
|
| 616 |
},
|
| 617 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
else:
|
| 619 |
-
st.info("No comparison data available.")
|
| 620 |
|
| 621 |
# Tab 5: Taxonomy Map
|
| 622 |
with tab5:
|
|
@@ -708,6 +1189,8 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 708 |
for _, row in result.combined_topics.iterrows():
|
| 709 |
review_data.append({
|
| 710 |
"topic_id": int(row["topic_id"]),
|
|
|
|
|
|
|
| 711 |
"label": row.get("label", ""),
|
| 712 |
"keywords": row.get("keywords", ""),
|
| 713 |
"source": row.get("source", ""),
|
|
@@ -734,6 +1217,8 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 734 |
for _, row in result.combined_topics.iterrows():
|
| 735 |
review_data.append({
|
| 736 |
"topic_id": int(row["topic_id"]),
|
|
|
|
|
|
|
| 737 |
"label": row.get("label", ""),
|
| 738 |
"keywords": row.get("keywords", ""),
|
| 739 |
"source": row.get("source", ""),
|
|
@@ -743,15 +1228,25 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 743 |
})
|
| 744 |
st.session_state.review_df = pd.DataFrame(review_data)
|
| 745 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
# Filter controls for review table
|
| 747 |
rv_col1, rv_col2, rv_col3 = st.columns(3)
|
| 748 |
with rv_col1:
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
with rv_col2:
|
| 756 |
review_search = st.text_input("🔍 Search in review table", "", key="review_search")
|
| 757 |
with rv_col3:
|
|
@@ -763,9 +1258,9 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 763 |
)
|
| 764 |
|
| 765 |
# Apply filters
|
| 766 |
-
filtered_review = st.session_state.review_df
|
| 767 |
-
|
| 768 |
-
|
| 769 |
if review_search:
|
| 770 |
search_mask = (
|
| 771 |
filtered_review["keywords"].str.contains(review_search, case=False, na=False) |
|
|
@@ -846,14 +1341,63 @@ if "result" in st.session_state and st.session_state.result is not None:
|
|
| 846 |
with tab_charts:
|
| 847 |
st.markdown('<div class="section-header">📈 Topic Visualizations</div>', unsafe_allow_html=True)
|
| 848 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
# -----------------------------------------------------------
|
| 850 |
# Chart 1: Topic Frequency by Source
|
| 851 |
# -----------------------------------------------------------
|
| 852 |
st.markdown("#### 📊 Topic Frequency by Source")
|
| 853 |
-
st.caption("Number of topics discovered from each source
|
| 854 |
|
| 855 |
-
|
| 856 |
-
|
|
|
|
|
|
|
|
|
|
| 857 |
|
| 858 |
fig1 = px.bar(
|
| 859 |
source_counts,
|
|
|
|
| 1 |
+
# Note: Gradio UI explicitly requested, replacing Streamlit with stub Gradio UI
|
| 2 |
+
import gradio as gr
|
| 3 |
+
def interface():
|
| 4 |
+
return 'Gradio UI updated'
|
| 5 |
"""
|
| 6 |
app.py — Streamlit frontend for the AI-driven Topic Modeling application.
|
| 7 |
|
|
|
|
| 24 |
import plotly.express as px
|
| 25 |
import plotly.graph_objects as go
|
| 26 |
|
| 27 |
+
from agents import TopicAgent
|
| 28 |
|
| 29 |
# ---------------------------------------------------------------------------
|
| 30 |
# HuggingFace Spaces compatibility: use a writable output directory
|
|
|
|
| 306 |
min_topics = st.slider(
|
| 307 |
"Minimum Topics",
|
| 308 |
min_value=50,
|
| 309 |
+
max_value=300,
|
| 310 |
value=100,
|
| 311 |
step=10,
|
| 312 |
+
help="Minimum number of fine-grained topics to generate per source. Topics are sub-divisions within clusters.",
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
max_clusters = st.slider(
|
| 316 |
+
"Max Clusters",
|
| 317 |
+
min_value=10,
|
| 318 |
+
max_value=40,
|
| 319 |
+
value=25,
|
| 320 |
+
step=5,
|
| 321 |
+
help="Target maximum number of high-level thematic clusters (10–40 range). Topics are extracted within each cluster.",
|
| 322 |
)
|
| 323 |
|
| 324 |
use_llm = st.checkbox(
|
|
|
|
| 338 |
)
|
| 339 |
|
| 340 |
st.markdown("---")
|
| 341 |
+
st.markdown("### 📋 Pipeline Steps (SPECTER2 + HDBSCAN)")
|
| 342 |
steps_info = [
|
| 343 |
"1. Load & validate CSV",
|
| 344 |
+
"2. Generate SPECTER2 embeddings",
|
| 345 |
+
"3. HDBSCAN clustering (10–{} clusters)".format(max_clusters),
|
| 346 |
+
"4. Extract cluster keywords (c-TF-IDF)",
|
| 347 |
+
"5. Merge similar clusters",
|
| 348 |
+
"6. Extract sub-topics via NMF (≥{} topics)".format(min_topics),
|
| 349 |
+
"7. AI Council label generation",
|
| 350 |
+
"8. Build taxonomy map",
|
| 351 |
"9. Export outputs (CSV, JSON)",
|
| 352 |
]
|
| 353 |
for step in steps_info:
|
|
|
|
| 401 |
csv_path=csv_path,
|
| 402 |
output_dir=OUTPUT_DIR,
|
| 403 |
min_topics=min_topics,
|
| 404 |
+
max_clusters=max_clusters,
|
| 405 |
use_llm_labels=use_llm,
|
| 406 |
+
use_specter=True,
|
| 407 |
groq_api_key=groq_key if use_llm else None,
|
| 408 |
)
|
| 409 |
|
| 410 |
+
# Display step-by-step progress — SPECTER2 + HDBSCAN pipeline
|
| 411 |
progress.progress(5, text="Step 1/9: Loading CSV …")
|
| 412 |
agent._step_load_csv()
|
| 413 |
+
|
| 414 |
+
progress.progress(10, text="Step 2/9: Generating SPECTER2 embeddings …")
|
| 415 |
+
agent._step_specter_embed()
|
| 416 |
+
|
| 417 |
+
progress.progress(30, text="Step 3/9: HDBSCAN clustering …")
|
| 418 |
+
agent._step_specter_cluster()
|
| 419 |
+
|
| 420 |
+
progress.progress(45, text="Step 4/9: Extracting cluster keywords …")
|
| 421 |
+
agent._step_specter_extract()
|
| 422 |
+
|
| 423 |
+
progress.progress(55, text="Step 5/9: Merging similar clusters …")
|
| 424 |
+
agent._step_merge_similar_clusters()
|
| 425 |
+
|
| 426 |
+
progress.progress(65, text="Step 6/9: Extracting sub-topics (≥{} topics) …".format(min_topics))
|
| 427 |
+
agent._step_specter_subtopics()
|
| 428 |
+
|
| 429 |
+
progress.progress(80, text="Step 7/9: Generating topic labels …")
|
| 430 |
+
agent._step_specter_label()
|
| 431 |
+
|
| 432 |
progress.progress(90, text="Step 8/9: Building taxonomy map …")
|
| 433 |
+
agent._step_specter_taxonomy()
|
| 434 |
+
|
| 435 |
progress.progress(95, text="Step 9/9: Exporting outputs …")
|
| 436 |
agent._step_export()
|
| 437 |
|
|
|
|
| 501 |
# ---- Summary Statistics ----
|
| 502 |
st.markdown('<div class="section-header">📊 Summary Statistics</div>', unsafe_allow_html=True)
|
| 503 |
|
| 504 |
+
# Calculate cluster count from combined_topics
|
| 505 |
+
has_clusters = "cluster_id" in result.combined_topics.columns
|
| 506 |
+
n_clusters = int(result.combined_topics["cluster_id"].nunique()) if has_clusters else 0
|
| 507 |
+
n_topics = len(result.combined_topics)
|
| 508 |
+
|
| 509 |
+
c1, c2, c3, c4, c5, c6 = st.columns(6)
|
| 510 |
with c1:
|
| 511 |
st.markdown(f"""
|
| 512 |
<div class="stat-card">
|
| 513 |
+
<div class="stat-value">{n_clusters}</div>
|
| 514 |
+
<div class="stat-label">Clusters</div>
|
| 515 |
</div>
|
| 516 |
""", unsafe_allow_html=True)
|
| 517 |
with c2:
|
| 518 |
st.markdown(f"""
|
| 519 |
<div class="stat-card">
|
| 520 |
+
<div class="stat-value">{n_topics}</div>
|
| 521 |
+
<div class="stat-label">Total Topics</div>
|
| 522 |
</div>
|
| 523 |
""", unsafe_allow_html=True)
|
| 524 |
with c3:
|
| 525 |
st.markdown(f"""
|
| 526 |
<div class="stat-card">
|
| 527 |
+
<div class="stat-value">{len(result.title_topics)}</div>
|
| 528 |
+
<div class="stat-label">Title Clusters</div>
|
| 529 |
</div>
|
| 530 |
""", unsafe_allow_html=True)
|
| 531 |
with c4:
|
| 532 |
+
st.markdown(f"""
|
| 533 |
+
<div class="stat-card">
|
| 534 |
+
<div class="stat-value">{len(result.abstract_topics)}</div>
|
| 535 |
+
<div class="stat-label">Abstract Clusters</div>
|
| 536 |
+
</div>
|
| 537 |
+
""", unsafe_allow_html=True)
|
| 538 |
+
with c5:
|
| 539 |
mapped_count = result.taxonomy_map.get("metadata", {}).get("mapped_count", 0)
|
| 540 |
st.markdown(f"""
|
| 541 |
<div class="stat-card">
|
|
|
|
| 543 |
<div class="stat-label">Mapped Themes</div>
|
| 544 |
</div>
|
| 545 |
""", unsafe_allow_html=True)
|
| 546 |
+
with c6:
|
| 547 |
novel_count = result.taxonomy_map.get("metadata", {}).get("novel_count", 0)
|
| 548 |
st.markdown(f"""
|
| 549 |
<div class="stat-card">
|
|
|
|
| 555 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 556 |
|
| 557 |
# ---- Tabbed Results ----
|
| 558 |
+
tab1, tab_umap, tab_explorer, tab_network, tab_council, tab4, tab5, tab_review, tab_charts = st.tabs([
|
| 559 |
"📋 Topics Table",
|
| 560 |
+
"🗺️ UMAP Cluster Map",
|
| 561 |
+
"🔍 Cluster Explorer",
|
| 562 |
+
"🕸️ Cluster Network",
|
| 563 |
+
"🤖 AI Council",
|
| 564 |
"⚖️ Theme Comparison",
|
| 565 |
"🗺️ Taxonomy Map",
|
| 566 |
"✏️ Review Table",
|
| 567 |
"📈 Charts",
|
| 568 |
])
|
| 569 |
|
| 570 |
+
# Tab 1: Combined Topics Table (with cluster hierarchy)
|
| 571 |
with tab1:
|
| 572 |
st.markdown('<div class="section-header">Combined Topics Table</div>', unsafe_allow_html=True)
|
| 573 |
+
|
| 574 |
+
has_cluster_col = "cluster_id" in result.combined_topics.columns
|
| 575 |
+
if has_cluster_col:
|
| 576 |
+
n_cl = int(result.combined_topics["cluster_id"].nunique())
|
| 577 |
+
st.markdown(
|
| 578 |
+
f"Showing all **{len(result.combined_topics)}** topics grouped into **{n_cl}** clusters."
|
| 579 |
+
)
|
| 580 |
+
else:
|
| 581 |
+
st.markdown(f"Showing all **{len(result.combined_topics)}** topics.")
|
| 582 |
|
| 583 |
# Filter controls
|
| 584 |
+
fcol1, fcol2, fcol3 = st.columns(3)
|
| 585 |
with fcol1:
|
| 586 |
+
if "source" in result.combined_topics.columns:
|
| 587 |
+
source_filter = st.multiselect(
|
| 588 |
+
"Filter by Source",
|
| 589 |
+
options=result.combined_topics["source"].unique().tolist(),
|
| 590 |
+
default=result.combined_topics["source"].unique().tolist(),
|
| 591 |
+
)
|
| 592 |
+
else:
|
| 593 |
+
source_filter = []
|
| 594 |
with fcol2:
|
| 595 |
search_term = st.text_input("🔍 Search keywords", "")
|
| 596 |
+
with fcol3:
|
| 597 |
+
if has_cluster_col:
|
| 598 |
+
cluster_options = sorted(result.combined_topics["cluster_id"].unique().tolist())
|
| 599 |
+
cluster_filter = st.multiselect(
|
| 600 |
+
"Filter by Cluster",
|
| 601 |
+
options=cluster_options,
|
| 602 |
+
default=cluster_options,
|
| 603 |
+
key="topics_cluster_filter",
|
| 604 |
+
)
|
| 605 |
+
else:
|
| 606 |
+
cluster_filter = []
|
| 607 |
|
| 608 |
+
display_df = result.combined_topics.copy()
|
| 609 |
+
if source_filter and "source" in display_df.columns:
|
| 610 |
+
display_df = display_df[display_df["source"].isin(source_filter)]
|
| 611 |
+
if cluster_filter and has_cluster_col:
|
| 612 |
+
display_df = display_df[display_df["cluster_id"].isin(cluster_filter)]
|
| 613 |
if search_term:
|
| 614 |
mask = display_df["keywords"].str.contains(search_term, case=False, na=False)
|
| 615 |
+
if "label" in display_df.columns:
|
| 616 |
+
mask |= display_df["label"].str.contains(search_term, case=False, na=False)
|
| 617 |
+
if "cluster_label" in display_df.columns:
|
| 618 |
+
mask |= display_df["cluster_label"].str.contains(search_term, case=False, na=False)
|
| 619 |
display_df = display_df[mask]
|
| 620 |
|
| 621 |
+
# Build column config dynamically
|
| 622 |
+
col_config = {
|
| 623 |
+
"topic_id": st.column_config.NumberColumn("Topic ID", width="small"),
|
| 624 |
+
"keywords": st.column_config.TextColumn("Keywords", width="large"),
|
| 625 |
+
}
|
| 626 |
+
if "label" in display_df.columns:
|
| 627 |
+
col_config["label"] = st.column_config.TextColumn("Label", width="medium")
|
| 628 |
+
if has_cluster_col:
|
| 629 |
+
col_config["cluster_id"] = st.column_config.NumberColumn("Cluster", width="small")
|
| 630 |
+
if "cluster_label" in display_df.columns:
|
| 631 |
+
col_config["cluster_label"] = st.column_config.TextColumn("Cluster Label", width="medium")
|
| 632 |
+
if "source" in display_df.columns:
|
| 633 |
+
col_config["source"] = st.column_config.TextColumn("Source", width="small")
|
| 634 |
+
if "n_documents" in display_df.columns:
|
| 635 |
+
col_config["n_documents"] = st.column_config.NumberColumn("Docs", width="small")
|
| 636 |
+
|
| 637 |
st.dataframe(
|
| 638 |
display_df,
|
| 639 |
use_container_width=True,
|
| 640 |
height=500,
|
| 641 |
+
column_config=col_config,
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
# ==================================================================
|
| 645 |
+
# Tab: UMAP 2D Cluster Map
|
| 646 |
+
# ==================================================================
|
| 647 |
+
with tab_umap:
|
| 648 |
+
st.markdown('<div class="section-header">🗺️ UMAP 2D Cluster Map</div>', unsafe_allow_html=True)
|
| 649 |
+
st.markdown("Interactive 2D visualization of document embeddings colored by HDBSCAN cluster assignment.")
|
| 650 |
+
|
| 651 |
+
embeddings_2d = result.cluster_data.get("embeddings_2d")
|
| 652 |
+
cluster_labels_list = result.cluster_data.get("cluster_labels")
|
| 653 |
+
|
| 654 |
+
if embeddings_2d and cluster_labels_list:
|
| 655 |
+
import numpy as np
|
| 656 |
+
emb_arr = np.array(embeddings_2d)
|
| 657 |
+
labels_arr = np.array(cluster_labels_list)
|
| 658 |
+
|
| 659 |
+
umap_df = pd.DataFrame({
|
| 660 |
+
"UMAP_1": emb_arr[:, 0],
|
| 661 |
+
"UMAP_2": emb_arr[:, 1],
|
| 662 |
+
"Cluster": labels_arr.astype(str),
|
| 663 |
+
})
|
| 664 |
+
# Mark noise points
|
| 665 |
+
umap_df.loc[umap_df["Cluster"] == "-1", "Cluster"] = "Noise"
|
| 666 |
+
|
| 667 |
+
fig_umap = px.scatter(
|
| 668 |
+
umap_df,
|
| 669 |
+
x="UMAP_1",
|
| 670 |
+
y="UMAP_2",
|
| 671 |
+
color="Cluster",
|
| 672 |
+
opacity=0.7,
|
| 673 |
+
hover_data=["Cluster"],
|
| 674 |
+
color_discrete_sequence=px.colors.qualitative.Set2 + px.colors.qualitative.Dark24,
|
| 675 |
+
)
|
| 676 |
+
fig_umap.update_traces(
|
| 677 |
+
marker=dict(size=5, line=dict(width=0.3, color="#ffffff")),
|
| 678 |
+
)
|
| 679 |
+
fig_umap.update_layout(
|
| 680 |
+
plot_bgcolor="#1a1a2e",
|
| 681 |
+
paper_bgcolor="#1a1a2e",
|
| 682 |
+
font=dict(color="#e0e0e0", family="Inter"),
|
| 683 |
+
title=f"UMAP 2D Projection — {int(labels_arr.max()) + 1} Clusters",
|
| 684 |
+
xaxis=dict(showgrid=False, zeroline=False, title="UMAP Dimension 1", color="#c4b5fd"),
|
| 685 |
+
yaxis=dict(showgrid=False, zeroline=False, title="UMAP Dimension 2", color="#c4b5fd"),
|
| 686 |
+
legend_title="Cluster",
|
| 687 |
+
height=650,
|
| 688 |
+
margin=dict(l=40, r=40, t=60, b=40),
|
| 689 |
+
)
|
| 690 |
+
st.plotly_chart(fig_umap, use_container_width=True)
|
| 691 |
+
|
| 692 |
+
# Cluster size summary
|
| 693 |
+
cluster_counts = umap_df["Cluster"].value_counts().reset_index()
|
| 694 |
+
cluster_counts.columns = ["Cluster", "Documents"]
|
| 695 |
+
cluster_counts = cluster_counts[cluster_counts["Cluster"] != "Noise"]
|
| 696 |
+
st.markdown("#### Cluster Sizes")
|
| 697 |
+
st.dataframe(cluster_counts, use_container_width=True, height=300)
|
| 698 |
+
else:
|
| 699 |
+
st.info("🔍 No 2D embedding data available. Run the SPECTER2 + HDBSCAN pipeline first.")
|
| 700 |
+
|
| 701 |
+
# ==================================================================
|
| 702 |
+
# Tab: Cluster Explorer (keywords, representative sentences, details)
|
| 703 |
+
# ==================================================================
|
| 704 |
+
with tab_explorer:
|
| 705 |
+
st.markdown('<div class="section-header">🔍 Cluster Explorer</div>', unsafe_allow_html=True)
|
| 706 |
+
st.markdown("Deep-dive into each cluster: see keywords, top representative papers, and document counts.")
|
| 707 |
+
|
| 708 |
+
cluster_details = result.cluster_data.get("cluster_details", {})
|
| 709 |
+
|
| 710 |
+
if cluster_details:
|
| 711 |
+
cluster_ids = sorted(cluster_details.keys())
|
| 712 |
+
|
| 713 |
+
# Cluster selector
|
| 714 |
+
selected_cluster = st.selectbox(
|
| 715 |
+
"Select a Cluster to Explore",
|
| 716 |
+
options=cluster_ids,
|
| 717 |
+
format_func=lambda cid: f"Cluster {cid} — {cluster_details[cid].get('keywords', '')[:60]}…",
|
| 718 |
+
key="cluster_explorer_select",
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
if selected_cluster is not None:
|
| 722 |
+
detail = cluster_details[selected_cluster]
|
| 723 |
+
|
| 724 |
+
# Cluster info card
|
| 725 |
+
st.markdown(f"""
|
| 726 |
+
<div class="info-box">
|
| 727 |
+
<strong>Cluster {selected_cluster}</strong><br>
|
| 728 |
+
<strong>Documents:</strong> {detail.get('n_documents', 'N/A')}<br>
|
| 729 |
+
<strong>Keywords:</strong> {detail.get('keywords', 'N/A')}
|
| 730 |
+
</div>
|
| 731 |
+
""", unsafe_allow_html=True)
|
| 732 |
+
|
| 733 |
+
# Representative papers
|
| 734 |
+
reps = detail.get("representatives", [])
|
| 735 |
+
if reps:
|
| 736 |
+
st.markdown("#### 📄 Top Representative Papers (closest to cluster centroid)")
|
| 737 |
+
for i, rep in enumerate(reps, 1):
|
| 738 |
+
st.markdown(f"""
|
| 739 |
+
<div style="background: rgba(102,126,234,0.1); border-left: 3px solid #667eea;
|
| 740 |
+
padding: 0.6rem 1rem; margin: 0.3rem 0; border-radius: 6px;
|
| 741 |
+
color: #e0e0e0; font-size: 0.9rem;">
|
| 742 |
+
<strong>#{i}</strong> {rep}
|
| 743 |
+
</div>
|
| 744 |
+
""", unsafe_allow_html=True)
|
| 745 |
+
else:
|
| 746 |
+
st.info("No representative sentences available for this cluster.")
|
| 747 |
+
|
| 748 |
+
# Sub-topics within this cluster
|
| 749 |
+
if has_cluster_col:
|
| 750 |
+
cluster_topics = result.combined_topics[
|
| 751 |
+
result.combined_topics["cluster_id"] == selected_cluster
|
| 752 |
+
]
|
| 753 |
+
if not cluster_topics.empty:
|
| 754 |
+
st.markdown(f"#### 🧬 Sub-Topics in Cluster {selected_cluster} ({len(cluster_topics)} topics)")
|
| 755 |
+
st.dataframe(
|
| 756 |
+
cluster_topics[["topic_id", "keywords", "label", "n_documents"]].reset_index(drop=True)
|
| 757 |
+
if "label" in cluster_topics.columns
|
| 758 |
+
else cluster_topics[["topic_id", "keywords", "n_documents"]].reset_index(drop=True),
|
| 759 |
+
use_container_width=True,
|
| 760 |
+
height=300,
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
# All clusters overview
|
| 764 |
+
st.markdown("---")
|
| 765 |
+
st.markdown("#### 📊 All Clusters Overview")
|
| 766 |
+
overview_rows = []
|
| 767 |
+
for cid in cluster_ids:
|
| 768 |
+
d = cluster_details[cid]
|
| 769 |
+
overview_rows.append({
|
| 770 |
+
"Cluster": cid,
|
| 771 |
+
"Documents": d.get("n_documents", 0),
|
| 772 |
+
"Keywords": d.get("keywords", "")[:80],
|
| 773 |
+
"Top Paper": (d.get("representatives", ["—"])[0])[:80] if d.get("representatives") else "—",
|
| 774 |
+
})
|
| 775 |
+
st.dataframe(
|
| 776 |
+
pd.DataFrame(overview_rows),
|
| 777 |
+
use_container_width=True,
|
| 778 |
+
height=400,
|
| 779 |
+
column_config={
|
| 780 |
+
"Cluster": st.column_config.NumberColumn("Cluster", width="small"),
|
| 781 |
+
"Documents": st.column_config.NumberColumn("Docs", width="small"),
|
| 782 |
+
"Keywords": st.column_config.TextColumn("Top Keywords", width="large"),
|
| 783 |
+
"Top Paper": st.column_config.TextColumn("Representative Paper", width="large"),
|
| 784 |
+
},
|
| 785 |
+
)
|
| 786 |
+
else:
|
| 787 |
+
st.info("🔍 No cluster details available. Run the SPECTER2 + HDBSCAN pipeline first.")
|
| 788 |
+
|
| 789 |
+
# ==================================================================
|
| 790 |
+
# Tab: Cluster Similarity Network
|
| 791 |
+
# ==================================================================
|
| 792 |
+
with tab_network:
|
| 793 |
+
st.markdown('<div class="section-header">🕸️ Cluster Similarity Network</div>', unsafe_allow_html=True)
|
| 794 |
+
st.markdown("Graph showing cosine similarity between cluster centroids. Stronger connections = more similar clusters.")
|
| 795 |
+
|
| 796 |
+
graph_edges = result.cluster_data.get("graph_edges", [])
|
| 797 |
+
cluster_details_net = result.cluster_data.get("cluster_details", {})
|
| 798 |
+
|
| 799 |
+
if graph_edges:
|
| 800 |
+
# Build network graph using plotly
|
| 801 |
+
import numpy as np
|
| 802 |
+
|
| 803 |
+
# Get unique cluster IDs from edges
|
| 804 |
+
cluster_ids_net = sorted(set(
|
| 805 |
+
[e["source"] for e in graph_edges] + [e["target"] for e in graph_edges]
|
| 806 |
+
))
|
| 807 |
+
|
| 808 |
+
# Arrange clusters in a circle layout
|
| 809 |
+
n_nodes = len(cluster_ids_net)
|
| 810 |
+
angles = np.linspace(0, 2 * np.pi, n_nodes, endpoint=False)
|
| 811 |
+
node_x = {cid: float(np.cos(a)) for cid, a in zip(cluster_ids_net, angles)}
|
| 812 |
+
node_y = {cid: float(np.sin(a)) for cid, a in zip(cluster_ids_net, angles)}
|
| 813 |
+
|
| 814 |
+
# Similarity threshold slider
|
| 815 |
+
min_sim = st.slider(
|
| 816 |
+
"Minimum Similarity Threshold",
|
| 817 |
+
min_value=0.0, max_value=1.0, value=0.3, step=0.05,
|
| 818 |
+
help="Only show edges with similarity above this threshold.",
|
| 819 |
+
key="network_sim_threshold",
|
| 820 |
+
)
|
| 821 |
+
|
| 822 |
+
filtered_edges = [e for e in graph_edges if e["weight"] >= min_sim]
|
| 823 |
+
|
| 824 |
+
# Edge traces
|
| 825 |
+
edge_x, edge_y, edge_text = [], [], []
|
| 826 |
+
for e in filtered_edges:
|
| 827 |
+
x0, y0 = node_x[e["source"]], node_y[e["source"]]
|
| 828 |
+
x1, y1 = node_x[e["target"]], node_y[e["target"]]
|
| 829 |
+
edge_x.extend([x0, x1, None])
|
| 830 |
+
edge_y.extend([y0, y1, None])
|
| 831 |
+
|
| 832 |
+
fig_net = go.Figure()
|
| 833 |
+
|
| 834 |
+
# Add edges
|
| 835 |
+
fig_net.add_trace(go.Scatter(
|
| 836 |
+
x=edge_x, y=edge_y,
|
| 837 |
+
mode="lines",
|
| 838 |
+
line=dict(width=1, color="rgba(102,126,234,0.4)"),
|
| 839 |
+
hoverinfo="none",
|
| 840 |
+
showlegend=False,
|
| 841 |
+
))
|
| 842 |
+
|
| 843 |
+
# Node sizes proportional to document count
|
| 844 |
+
node_sizes = []
|
| 845 |
+
node_labels = []
|
| 846 |
+
node_hover = []
|
| 847 |
+
for cid in cluster_ids_net:
|
| 848 |
+
detail = cluster_details_net.get(cid, {})
|
| 849 |
+
n_docs = detail.get("n_documents", 10)
|
| 850 |
+
kw = detail.get("keywords", "")[:50]
|
| 851 |
+
node_sizes.append(max(15, min(50, n_docs * 2)))
|
| 852 |
+
node_labels.append(f"C{cid}")
|
| 853 |
+
node_hover.append(f"Cluster {cid}<br>Docs: {n_docs}<br>Keywords: {kw}")
|
| 854 |
+
|
| 855 |
+
# Add nodes
|
| 856 |
+
fig_net.add_trace(go.Scatter(
|
| 857 |
+
x=[node_x[c] for c in cluster_ids_net],
|
| 858 |
+
y=[node_y[c] for c in cluster_ids_net],
|
| 859 |
+
mode="markers+text",
|
| 860 |
+
marker=dict(
|
| 861 |
+
size=node_sizes,
|
| 862 |
+
color=[i for i in range(n_nodes)],
|
| 863 |
+
colorscale="Viridis",
|
| 864 |
+
line=dict(width=1.5, color="#ffffff"),
|
| 865 |
+
),
|
| 866 |
+
text=node_labels,
|
| 867 |
+
textposition="top center",
|
| 868 |
+
textfont=dict(color="#e0e0e0", size=11, family="Inter"),
|
| 869 |
+
hovertext=node_hover,
|
| 870 |
+
hoverinfo="text",
|
| 871 |
+
showlegend=False,
|
| 872 |
+
))
|
| 873 |
+
|
| 874 |
+
fig_net.update_layout(
|
| 875 |
+
plot_bgcolor="#1a1a2e",
|
| 876 |
+
paper_bgcolor="#1a1a2e",
|
| 877 |
+
font=dict(color="#e0e0e0", family="Inter"),
|
| 878 |
+
title=f"Cluster Similarity Network ({len(filtered_edges)} edges, threshold ≥ {min_sim:.2f})",
|
| 879 |
+
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 880 |
+
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 881 |
+
height=600,
|
| 882 |
+
margin=dict(l=20, r=20, t=60, b=20),
|
| 883 |
+
)
|
| 884 |
+
st.plotly_chart(fig_net, use_container_width=True)
|
| 885 |
+
|
| 886 |
+
# Edge table
|
| 887 |
+
with st.expander("📋 Similarity Scores (Edge List)"):
|
| 888 |
+
edge_df = pd.DataFrame(filtered_edges)
|
| 889 |
+
edge_df.columns = ["Source Cluster", "Target Cluster", "Similarity"]
|
| 890 |
+
st.dataframe(edge_df, use_container_width=True, height=300)
|
| 891 |
+
else:
|
| 892 |
+
st.info("🔍 No cluster graph data available. Run the SPECTER2 + HDBSCAN pipeline first.")
|
| 893 |
+
|
| 894 |
+
# ==================================================================
|
| 895 |
+
# Tab: AI Council Debates (label generation transparency)
|
| 896 |
+
# ==================================================================
|
| 897 |
+
with tab_council:
|
| 898 |
+
st.markdown('<div class="section-header">🤖 AI Council — Label Generation Debates</div>', unsafe_allow_html=True)
|
| 899 |
+
st.markdown(
|
| 900 |
+
"Transparency into how topic labels were generated. "
|
| 901 |
+
"Each cluster's label is debated by three AI personas: "
|
| 902 |
+
"**Domain Expert**, **Data Analyst**, and **Anti-Hallucination Judge**."
|
| 903 |
)
|
| 904 |
|
| 905 |
+
debates = result.council_debates
|
| 906 |
+
|
| 907 |
+
if debates:
|
| 908 |
+
debate_ids = sorted(debates.keys())
|
| 909 |
+
|
| 910 |
+
st.markdown(f"**{len(debate_ids)}** clusters labeled via AI Council debate.")
|
| 911 |
|
| 912 |
+
# Summary table
|
| 913 |
+
debate_rows = []
|
| 914 |
+
for cid in debate_ids:
|
| 915 |
+
d = debates[cid]
|
| 916 |
+
debate_rows.append({
|
| 917 |
+
"Cluster": cid,
|
| 918 |
+
"Expert Label": d.get("expert", "—"),
|
| 919 |
+
"Analyst Label": d.get("analyst", "—"),
|
| 920 |
+
"Anti-Hallucination": d.get("anti_hallucination", d.get("final_label", "—")),
|
| 921 |
+
"Final Label": d.get("final_label", "—"),
|
| 922 |
+
})
|
| 923 |
+
|
| 924 |
+
st.dataframe(
|
| 925 |
+
pd.DataFrame(debate_rows),
|
| 926 |
+
use_container_width=True,
|
| 927 |
+
height=400,
|
| 928 |
+
column_config={
|
| 929 |
+
"Cluster": st.column_config.NumberColumn("Cluster", width="small"),
|
| 930 |
+
"Expert Label": st.column_config.TextColumn("🎓 Domain Expert", width="medium"),
|
| 931 |
+
"Analyst Label": st.column_config.TextColumn("📊 Data Analyst", width="medium"),
|
| 932 |
+
"Anti-Hallucination": st.column_config.TextColumn("🛡️ Hallucination Check", width="medium"),
|
| 933 |
+
"Final Label": st.column_config.TextColumn("⚖️ Final (Judge)", width="medium"),
|
| 934 |
+
},
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
# Detailed debate view
|
| 938 |
+
st.markdown("---")
|
| 939 |
+
st.markdown("#### 🔎 Detailed Debate View")
|
| 940 |
+
selected_debate = st.selectbox(
|
| 941 |
+
"Select cluster to inspect",
|
| 942 |
+
options=debate_ids,
|
| 943 |
+
format_func=lambda c: f"Cluster {c} — {debates[c].get('final_label', '')}",
|
| 944 |
+
key="council_debate_select",
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
if selected_debate is not None:
|
| 948 |
+
d = debates[selected_debate]
|
| 949 |
+
col_e, col_a, col_h, col_j = st.columns(4)
|
| 950 |
+
with col_e:
|
| 951 |
+
st.markdown("""
|
| 952 |
+
<div style="background: linear-gradient(135deg, #667eea, #764ba2);
|
| 953 |
+
padding: 1rem; border-radius: 10px; color: white; text-align: center;">
|
| 954 |
+
<div style="font-size: 0.7rem; opacity: 0.85; text-transform: uppercase; letter-spacing: 1px;">🎓 Domain Expert</div>
|
| 955 |
+
<div style="font-size: 1.1rem; font-weight: 600; margin-top: 0.5rem;">{}</div>
|
| 956 |
+
</div>
|
| 957 |
+
""".format(d.get("expert", "—")), unsafe_allow_html=True)
|
| 958 |
+
with col_a:
|
| 959 |
+
st.markdown("""
|
| 960 |
+
<div style="background: linear-gradient(135deg, #11998e, #38ef7d);
|
| 961 |
+
padding: 1rem; border-radius: 10px; color: white; text-align: center;">
|
| 962 |
+
<div style="font-size: 0.7rem; opacity: 0.85; text-transform: uppercase; letter-spacing: 1px;">📊 Data Analyst</div>
|
| 963 |
+
<div style="font-size: 1.1rem; font-weight: 600; margin-top: 0.5rem;">{}</div>
|
| 964 |
+
</div>
|
| 965 |
+
""".format(d.get("analyst", "—")), unsafe_allow_html=True)
|
| 966 |
+
with col_h:
|
| 967 |
+
st.markdown("""
|
| 968 |
+
<div style="background: linear-gradient(135deg, #e44d26, #f16529);
|
| 969 |
+
padding: 1rem; border-radius: 10px; color: white; text-align: center;">
|
| 970 |
+
<div style="font-size: 0.7rem; opacity: 0.85; text-transform: uppercase; letter-spacing: 1px;">🛡️ Anti-Hallucination</div>
|
| 971 |
+
<div style="font-size: 1.1rem; font-weight: 600; margin-top: 0.5rem;">{}</div>
|
| 972 |
+
</div>
|
| 973 |
+
""".format(d.get("anti_hallucination", d.get("final_label", "—"))), unsafe_allow_html=True)
|
| 974 |
+
with col_j:
|
| 975 |
+
st.markdown("""
|
| 976 |
+
<div style="background: linear-gradient(135deg, #fc4a1a, #f7b733);
|
| 977 |
+
padding: 1rem; border-radius: 10px; color: white; text-align: center;">
|
| 978 |
+
<div style="font-size: 0.7rem; opacity: 0.85; text-transform: uppercase; letter-spacing: 1px;">⚖️ Final Verdict</div>
|
| 979 |
+
<div style="font-size: 1.1rem; font-weight: 600; margin-top: 0.5rem;">{}</div>
|
| 980 |
+
</div>
|
| 981 |
+
""".format(d.get("final_label", "—")), unsafe_allow_html=True)
|
| 982 |
+
else:
|
| 983 |
+
st.markdown("""
|
| 984 |
+
<div class="info-box">
|
| 985 |
+
<strong>ℹ️ AI Council Debates Not Available</strong><br><br>
|
| 986 |
+
To see label generation debates, enable <strong>"🤖 Use LLM for Label Generation (Groq)"</strong>
|
| 987 |
+
in the sidebar and provide a Groq API key before running the pipeline.
|
| 988 |
+
<br><br>
|
| 989 |
+
When enabled, each cluster label is generated through a 3-persona debate:
|
| 990 |
+
<br>• <strong>Domain Expert</strong> proposes an academic label
|
| 991 |
+
<br>• <strong>Data Analyst</strong> proposes a categorical name
|
| 992 |
+
<br>• <strong>Anti-Hallucination Judge</strong> synthesizes the final label
|
| 993 |
+
</div>
|
| 994 |
+
""", unsafe_allow_html=True)
|
| 995 |
|
| 996 |
# Tab 4: Theme Comparison
|
| 997 |
with tab4:
|
| 998 |
+
st.markdown('<div class="section-header">Theme Comparison</div>', unsafe_allow_html=True)
|
| 999 |
|
| 1000 |
if not result.comparison.empty:
|
| 1001 |
+
# Standard comparison view (title vs abstract)
|
| 1002 |
+
st.markdown("**Titles vs Abstracts Theme Alignment**")
|
| 1003 |
align_counts = result.comparison["alignment"].value_counts()
|
| 1004 |
acol1, acol2, acol3, acol4 = st.columns(4)
|
| 1005 |
for col, alignment in zip(
|
|
|
|
| 1012 |
|
| 1013 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 1014 |
|
|
|
|
| 1015 |
alignment_filter = st.multiselect(
|
| 1016 |
"Filter by Alignment",
|
| 1017 |
options=["Strong", "Moderate", "Weak", "No Match"],
|
|
|
|
| 1032 |
),
|
| 1033 |
},
|
| 1034 |
)
|
| 1035 |
+
elif has_cluster_col and "cluster_id" in result.combined_topics.columns:
|
| 1036 |
+
# SPECTER mode — show inter-cluster similarity comparison
|
| 1037 |
+
st.markdown("**Cluster-to-Cluster Theme Similarity**")
|
| 1038 |
+
st.markdown("Comparing keyword overlap between each pair of clusters.")
|
| 1039 |
+
|
| 1040 |
+
graph_edges_comp = result.cluster_data.get("graph_edges", [])
|
| 1041 |
+
cluster_details_comp = result.cluster_data.get("cluster_details", {})
|
| 1042 |
+
|
| 1043 |
+
if graph_edges_comp:
|
| 1044 |
+
# Build comparison table from graph edges
|
| 1045 |
+
comp_rows = []
|
| 1046 |
+
for e in graph_edges_comp:
|
| 1047 |
+
src = e["source"]
|
| 1048 |
+
tgt = e["target"]
|
| 1049 |
+
src_detail = cluster_details_comp.get(src, {})
|
| 1050 |
+
tgt_detail = cluster_details_comp.get(tgt, {})
|
| 1051 |
+
sim = e["weight"]
|
| 1052 |
+
alignment = (
|
| 1053 |
+
"Strong" if sim >= 0.6
|
| 1054 |
+
else "Moderate" if sim >= 0.4
|
| 1055 |
+
else "Weak" if sim > 0.2
|
| 1056 |
+
else "Distant"
|
| 1057 |
+
)
|
| 1058 |
+
comp_rows.append({
|
| 1059 |
+
"cluster_a": src,
|
| 1060 |
+
"cluster_a_keywords": src_detail.get("keywords", "")[:60],
|
| 1061 |
+
"cluster_b": tgt,
|
| 1062 |
+
"cluster_b_keywords": tgt_detail.get("keywords", "")[:60],
|
| 1063 |
+
"similarity": round(sim, 4),
|
| 1064 |
+
"alignment": alignment,
|
| 1065 |
+
})
|
| 1066 |
+
|
| 1067 |
+
comp_df = pd.DataFrame(comp_rows)
|
| 1068 |
+
|
| 1069 |
+
# Alignment stats
|
| 1070 |
+
align_counts = comp_df["alignment"].value_counts()
|
| 1071 |
+
acol1, acol2, acol3, acol4 = st.columns(4)
|
| 1072 |
+
for col, alignment in zip(
|
| 1073 |
+
[acol1, acol2, acol3, acol4],
|
| 1074 |
+
["Strong", "Moderate", "Weak", "Distant"],
|
| 1075 |
+
):
|
| 1076 |
+
with col:
|
| 1077 |
+
count = align_counts.get(alignment, 0)
|
| 1078 |
+
st.metric(label=f"{alignment}", value=count)
|
| 1079 |
+
|
| 1080 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 1081 |
+
|
| 1082 |
+
st.dataframe(
|
| 1083 |
+
comp_df.sort_values("similarity", ascending=False),
|
| 1084 |
+
use_container_width=True,
|
| 1085 |
+
height=500,
|
| 1086 |
+
column_config={
|
| 1087 |
+
"cluster_a": st.column_config.NumberColumn("Cluster A", width="small"),
|
| 1088 |
+
"cluster_b": st.column_config.NumberColumn("Cluster B", width="small"),
|
| 1089 |
+
"cluster_a_keywords": st.column_config.TextColumn("A Keywords", width="medium"),
|
| 1090 |
+
"cluster_b_keywords": st.column_config.TextColumn("B Keywords", width="medium"),
|
| 1091 |
+
"similarity": st.column_config.ProgressColumn(
|
| 1092 |
+
"Similarity", min_value=0, max_value=1, format="%.3f",
|
| 1093 |
+
),
|
| 1094 |
+
"alignment": st.column_config.TextColumn("Alignment", width="small"),
|
| 1095 |
+
},
|
| 1096 |
+
)
|
| 1097 |
+
else:
|
| 1098 |
+
st.info("No cluster similarity data available. Run the pipeline first.")
|
| 1099 |
else:
|
| 1100 |
+
st.info("No comparison data available. Run the pipeline first.")
|
| 1101 |
|
| 1102 |
# Tab 5: Taxonomy Map
|
| 1103 |
with tab5:
|
|
|
|
| 1189 |
for _, row in result.combined_topics.iterrows():
|
| 1190 |
review_data.append({
|
| 1191 |
"topic_id": int(row["topic_id"]),
|
| 1192 |
+
"cluster_id": int(row["cluster_id"]) if "cluster_id" in row.index else "",
|
| 1193 |
+
"cluster_label": row.get("cluster_label", ""),
|
| 1194 |
"label": row.get("label", ""),
|
| 1195 |
"keywords": row.get("keywords", ""),
|
| 1196 |
"source": row.get("source", ""),
|
|
|
|
| 1217 |
for _, row in result.combined_topics.iterrows():
|
| 1218 |
review_data.append({
|
| 1219 |
"topic_id": int(row["topic_id"]),
|
| 1220 |
+
"cluster_id": int(row["cluster_id"]) if "cluster_id" in row.index else "",
|
| 1221 |
+
"cluster_label": row.get("cluster_label", ""),
|
| 1222 |
"label": row.get("label", ""),
|
| 1223 |
"keywords": row.get("keywords", ""),
|
| 1224 |
"source": row.get("source", ""),
|
|
|
|
| 1228 |
})
|
| 1229 |
st.session_state.review_df = pd.DataFrame(review_data)
|
| 1230 |
|
| 1231 |
+
# Enforce string dtypes to prevent ColumnDataKind.FLOAT errors
|
| 1232 |
+
if "rename_to" in st.session_state.review_df.columns:
|
| 1233 |
+
st.session_state.review_df["rename_to"] = st.session_state.review_df["rename_to"].fillna("").astype(str)
|
| 1234 |
+
if "reasoning" in st.session_state.review_df.columns:
|
| 1235 |
+
st.session_state.review_df["reasoning"] = st.session_state.review_df["reasoning"].fillna("").astype(str)
|
| 1236 |
+
|
| 1237 |
# Filter controls for review table
|
| 1238 |
rv_col1, rv_col2, rv_col3 = st.columns(3)
|
| 1239 |
with rv_col1:
|
| 1240 |
+
if "source" in st.session_state.review_df.columns and st.session_state.review_df["source"].notna().any():
|
| 1241 |
+
review_source_options = [s for s in st.session_state.review_df["source"].unique().tolist() if s]
|
| 1242 |
+
review_source_filter = st.multiselect(
|
| 1243 |
+
"Filter by Source",
|
| 1244 |
+
options=review_source_options,
|
| 1245 |
+
default=review_source_options,
|
| 1246 |
+
key="review_source_filter",
|
| 1247 |
+
)
|
| 1248 |
+
else:
|
| 1249 |
+
review_source_filter = None
|
| 1250 |
with rv_col2:
|
| 1251 |
review_search = st.text_input("🔍 Search in review table", "", key="review_search")
|
| 1252 |
with rv_col3:
|
|
|
|
| 1258 |
)
|
| 1259 |
|
| 1260 |
# Apply filters
|
| 1261 |
+
filtered_review = st.session_state.review_df.copy()
|
| 1262 |
+
if review_source_filter is not None and "source" in filtered_review.columns:
|
| 1263 |
+
filtered_review = filtered_review[filtered_review["source"].isin(review_source_filter)]
|
| 1264 |
if review_search:
|
| 1265 |
search_mask = (
|
| 1266 |
filtered_review["keywords"].str.contains(review_search, case=False, na=False) |
|
|
|
|
| 1341 |
with tab_charts:
|
| 1342 |
st.markdown('<div class="section-header">📈 Topic Visualizations</div>', unsafe_allow_html=True)
|
| 1343 |
|
| 1344 |
+
# -----------------------------------------------------------
|
| 1345 |
+
# Chart 0: Topics per Cluster (NEW)
|
| 1346 |
+
# -----------------------------------------------------------
|
| 1347 |
+
has_cluster_col_chart = "cluster_id" in result.combined_topics.columns
|
| 1348 |
+
if has_cluster_col_chart:
|
| 1349 |
+
st.markdown("#### 🏗️ Topics per Cluster")
|
| 1350 |
+
st.caption("Number of fine-grained topics extracted within each high-level cluster.")
|
| 1351 |
+
|
| 1352 |
+
topics_per_cluster = result.combined_topics.groupby("cluster_id").size().reset_index(name="Topics")
|
| 1353 |
+
if "cluster_label" in result.combined_topics.columns:
|
| 1354 |
+
label_map = result.combined_topics.drop_duplicates("cluster_id")[["cluster_id", "cluster_label"]]
|
| 1355 |
+
topics_per_cluster = topics_per_cluster.merge(label_map, on="cluster_id", how="left")
|
| 1356 |
+
topics_per_cluster["display"] = topics_per_cluster.apply(
|
| 1357 |
+
lambda r: f"C{int(r['cluster_id'])}: {r['cluster_label']}", axis=1
|
| 1358 |
+
)
|
| 1359 |
+
else:
|
| 1360 |
+
topics_per_cluster["display"] = topics_per_cluster["cluster_id"].apply(lambda x: f"Cluster {x}")
|
| 1361 |
+
|
| 1362 |
+
fig0 = px.bar(
|
| 1363 |
+
topics_per_cluster.sort_values("Topics", ascending=False),
|
| 1364 |
+
x="display",
|
| 1365 |
+
y="Topics",
|
| 1366 |
+
color="Topics",
|
| 1367 |
+
color_continuous_scale=["#302b63", "#667eea", "#f093fb"],
|
| 1368 |
+
text="Topics",
|
| 1369 |
+
)
|
| 1370 |
+
fig0.update_traces(
|
| 1371 |
+
textposition="outside",
|
| 1372 |
+
textfont=dict(color="#e0e0e0", size=12, family="Inter"),
|
| 1373 |
+
marker=dict(line=dict(width=0)),
|
| 1374 |
+
)
|
| 1375 |
+
fig0.update_layout(
|
| 1376 |
+
**PLOTLY_LAYOUT,
|
| 1377 |
+
title=f"Topics per Cluster ({topics_per_cluster['cluster_id'].nunique()} clusters)",
|
| 1378 |
+
xaxis_title="Cluster",
|
| 1379 |
+
yaxis_title="Number of Topics",
|
| 1380 |
+
showlegend=False,
|
| 1381 |
+
height=480,
|
| 1382 |
+
)
|
| 1383 |
+
fig0.update_layout(
|
| 1384 |
+
xaxis=dict(tickangle=-45, tickfont=dict(color="#c4b5fd", size=10)),
|
| 1385 |
+
)
|
| 1386 |
+
st.plotly_chart(fig0, use_container_width=True)
|
| 1387 |
+
|
| 1388 |
+
st.markdown("---")
|
| 1389 |
+
|
| 1390 |
# -----------------------------------------------------------
|
| 1391 |
# Chart 1: Topic Frequency by Source
|
| 1392 |
# -----------------------------------------------------------
|
| 1393 |
st.markdown("#### 📊 Topic Frequency by Source")
|
| 1394 |
+
st.caption("Number of topics discovered from each source.")
|
| 1395 |
|
| 1396 |
+
if "source" in result.combined_topics.columns:
|
| 1397 |
+
source_counts = result.combined_topics["source"].value_counts().reset_index()
|
| 1398 |
+
source_counts.columns = ["Source", "Count"]
|
| 1399 |
+
else:
|
| 1400 |
+
source_counts = pd.DataFrame({"Source": ["All"], "Count": [len(result.combined_topics)]})
|
| 1401 |
|
| 1402 |
fig1 = px.bar(
|
| 1403 |
source_counts,
|
run_specter.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import torch
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
|
| 9 |
+
from transformers import AutoTokenizer
|
| 10 |
+
from adapters import AutoAdapterModel
|
| 11 |
+
from umap import UMAP
|
| 12 |
+
from hdbscan import HDBSCAN
|
| 13 |
+
from sklearn.feature_extraction.text import CountVectorizer
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Basic c-TF-IDF implementation
|
| 18 |
+
def c_tf_idf(documents, m, ngram_range=(1, 1)):
|
| 19 |
+
count = CountVectorizer(ngram_range=ngram_range, stop_words="english").fit(documents)
|
| 20 |
+
t = count.transform(documents).toarray()
|
| 21 |
+
w = t.sum(axis=1)
|
| 22 |
+
tf = np.divide(t.T, w)
|
| 23 |
+
sum_t = t.sum(axis=0)
|
| 24 |
+
idf = np.log(np.divide(m, sum_t)).reshape(-1, 1)
|
| 25 |
+
tf_idf = np.multiply(tf, idf)
|
| 26 |
+
|
| 27 |
+
return tf_idf, count
|
| 28 |
+
|
| 29 |
+
def extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=10):
|
| 30 |
+
words = count.get_feature_names_out()
|
| 31 |
+
labels = list(docs_per_topic.topic_id)
|
| 32 |
+
tf_idf_transposed = tf_idf.T
|
| 33 |
+
indices = tf_idf_transposed.argsort()[:, -n:]
|
| 34 |
+
top_n_words = {label: [(words[j], tf_idf_transposed[i][j]) for j in indices[i]][::-1] for i, label in enumerate(labels)}
|
| 35 |
+
return top_n_words
|
| 36 |
+
|
| 37 |
+
def generate_specter2_embeddings(papers: List[Dict[str, str]], batch_size=16) -> np.ndarray:
|
| 38 |
+
"""
|
| 39 |
+
Generate embeddings using SPECTER2 via adapters.
|
| 40 |
+
papers should be a list of dicts with 'title' and 'abstract' keys.
|
| 41 |
+
"""
|
| 42 |
+
logger.info("Loading SPECTER2 model and tokenizer...")
|
| 43 |
+
tokenizer = AutoTokenizer.from_pretrained('allenai/specter2_base')
|
| 44 |
+
model = AutoAdapterModel.from_pretrained('allenai/specter2_base')
|
| 45 |
+
model.load_adapter("allenai/specter2", source="hf", load_as="proximity", set_active=True)
|
| 46 |
+
|
| 47 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 48 |
+
model.to(device)
|
| 49 |
+
model.eval()
|
| 50 |
+
|
| 51 |
+
embeddings = []
|
| 52 |
+
logger.info(f"Generating embeddings for {len(papers)} papers using {device}...")
|
| 53 |
+
|
| 54 |
+
for i in range(0, len(papers), batch_size):
|
| 55 |
+
batch = papers[i:i+batch_size]
|
| 56 |
+
text_batch = [d['title'] + tokenizer.sep_token + (d.get('abstract') or '') for d in batch]
|
| 57 |
+
|
| 58 |
+
inputs = tokenizer(text_batch, padding=True, truncation=True, return_tensors="pt", return_token_type_ids=False, max_length=512)
|
| 59 |
+
inputs = {k: v.to(device) for k, v in inputs.items()}
|
| 60 |
+
|
| 61 |
+
with torch.no_grad():
|
| 62 |
+
output = model(**inputs)
|
| 63 |
+
# Take the CLS token
|
| 64 |
+
batch_embeddings = output.last_hidden_state[:, 0, :].cpu().numpy()
|
| 65 |
+
embeddings.append(batch_embeddings)
|
| 66 |
+
|
| 67 |
+
return np.vstack(embeddings)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
import chromadb
|
| 71 |
+
from groq import Groq
|
| 72 |
+
import time
|
| 73 |
+
import re
|
| 74 |
+
|
| 75 |
+
def store_in_chroma(embeddings, documents, metadatas, collection_name="specter_vectors"):
|
| 76 |
+
client = chromadb.PersistentClient(path="./chroma_db")
|
| 77 |
+
collection = client.get_or_create_collection(name=collection_name)
|
| 78 |
+
|
| 79 |
+
ids = [str(i) for i in range(len(documents))]
|
| 80 |
+
|
| 81 |
+
# Store in batches if necessary
|
| 82 |
+
collection.add(
|
| 83 |
+
embeddings=embeddings.tolist(),
|
| 84 |
+
documents=documents,
|
| 85 |
+
metadatas=metadatas,
|
| 86 |
+
ids=ids
|
| 87 |
+
)
|
| 88 |
+
return collection
|
| 89 |
+
|
| 90 |
+
def cluster_with_hdbscan(embeddings, min_cluster_size=15, min_samples=5):
|
| 91 |
+
logger.info(f"Reducing dimensionality using UMAP for {embeddings.shape[0]} embeddings...")
|
| 92 |
+
umap_model = UMAP(n_neighbors=15, n_components=5, min_dist=0.0, metric='cosine', random_state=42)
|
| 93 |
+
reduced_embeddings = umap_model.fit_transform(embeddings)
|
| 94 |
+
|
| 95 |
+
logger.info("Clustering with HDBSCAN...")
|
| 96 |
+
clusterer = HDBSCAN(min_cluster_size=min_cluster_size, min_samples=min_samples, metric='euclidean', cluster_selection_method='eom')
|
| 97 |
+
labels = clusterer.fit_predict(reduced_embeddings)
|
| 98 |
+
|
| 99 |
+
return labels, reduced_embeddings
|
| 100 |
+
|
| 101 |
+
def extract_subtopics_within_clusters(
|
| 102 |
+
df: pd.DataFrame,
|
| 103 |
+
cluster_labels: np.ndarray,
|
| 104 |
+
min_topics: int = 100,
|
| 105 |
+
n_keywords: int = 10,
|
| 106 |
+
) -> pd.DataFrame:
|
| 107 |
+
"""
|
| 108 |
+
Extract fine-grained sub-topics within each cluster using NMF.
|
| 109 |
+
|
| 110 |
+
For each cluster, runs NMF on the cluster's TF-IDF matrix to discover
|
| 111 |
+
sub-topics. The number of sub-topics per cluster is proportional to
|
| 112 |
+
cluster size, with a global target of at least `min_topics` total.
|
| 113 |
+
|
| 114 |
+
Parameters
|
| 115 |
+
----------
|
| 116 |
+
df : pd.DataFrame
|
| 117 |
+
DataFrame with 'Title' and 'Abstract' columns.
|
| 118 |
+
cluster_labels : np.ndarray
|
| 119 |
+
Cluster assignment for each document (from HDBSCAN).
|
| 120 |
+
min_topics : int
|
| 121 |
+
Minimum total number of topics across all clusters (default 100).
|
| 122 |
+
n_keywords : int
|
| 123 |
+
Number of top keywords to extract per sub-topic.
|
| 124 |
+
|
| 125 |
+
Returns
|
| 126 |
+
-------
|
| 127 |
+
pd.DataFrame
|
| 128 |
+
Columns: cluster_id, topic_id (global), sub_topic_id (within-cluster),
|
| 129 |
+
keywords, n_documents, source
|
| 130 |
+
"""
|
| 131 |
+
from sklearn.decomposition import NMF
|
| 132 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 133 |
+
|
| 134 |
+
df = df.copy()
|
| 135 |
+
df["cluster_id"] = cluster_labels
|
| 136 |
+
|
| 137 |
+
# Get valid clusters (exclude noise = -1)
|
| 138 |
+
valid_mask = df["cluster_id"] != -1
|
| 139 |
+
valid_df = df[valid_mask].copy()
|
| 140 |
+
cluster_ids = sorted(valid_df["cluster_id"].unique())
|
| 141 |
+
n_clusters = len(cluster_ids)
|
| 142 |
+
|
| 143 |
+
if n_clusters == 0:
|
| 144 |
+
raise ValueError("No valid clusters found (all documents are noise).")
|
| 145 |
+
|
| 146 |
+
# Calculate sub-topic count per cluster proportional to size
|
| 147 |
+
cluster_sizes = {cid: int((valid_df["cluster_id"] == cid).sum()) for cid in cluster_ids}
|
| 148 |
+
total_docs = sum(cluster_sizes.values())
|
| 149 |
+
|
| 150 |
+
subtopic_counts = {}
|
| 151 |
+
for cid in cluster_ids:
|
| 152 |
+
raw_share = (cluster_sizes[cid] / total_docs) * min_topics
|
| 153 |
+
subtopic_counts[cid] = max(2, round(raw_share))
|
| 154 |
+
|
| 155 |
+
# Ensure total >= min_topics by scaling up if needed
|
| 156 |
+
current_total = sum(subtopic_counts.values())
|
| 157 |
+
if current_total < min_topics:
|
| 158 |
+
deficit = min_topics - current_total
|
| 159 |
+
# Distribute deficit proportionally to larger clusters
|
| 160 |
+
sorted_clusters = sorted(cluster_ids, key=lambda c: cluster_sizes[c], reverse=True)
|
| 161 |
+
i = 0
|
| 162 |
+
while deficit > 0:
|
| 163 |
+
cid = sorted_clusters[i % len(sorted_clusters)]
|
| 164 |
+
# Don't exceed cluster size (each sub-topic needs ≥2 docs)
|
| 165 |
+
max_possible = cluster_sizes[cid] // 2
|
| 166 |
+
if subtopic_counts[cid] < max_possible:
|
| 167 |
+
subtopic_counts[cid] += 1
|
| 168 |
+
deficit -= 1
|
| 169 |
+
i += 1
|
| 170 |
+
if i >= len(sorted_clusters) * 10: # Safety valve
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
logger.info(
|
| 174 |
+
"Sub-topic allocation: %d clusters → %d total sub-topics (target ≥ %d)",
|
| 175 |
+
n_clusters, sum(subtopic_counts.values()), min_topics,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Extract sub-topics per cluster
|
| 179 |
+
all_topics = []
|
| 180 |
+
global_topic_id = 0
|
| 181 |
+
|
| 182 |
+
for cid in cluster_ids:
|
| 183 |
+
cluster_docs = valid_df[valid_df["cluster_id"] == cid]
|
| 184 |
+
texts = (cluster_docs["Title"].fillna("") + " " + cluster_docs["Abstract"].fillna("")).tolist()
|
| 185 |
+
n_subtopics = subtopic_counts[cid]
|
| 186 |
+
|
| 187 |
+
# Need at least as many docs as sub-topics
|
| 188 |
+
n_subtopics = min(n_subtopics, len(texts))
|
| 189 |
+
if n_subtopics < 2:
|
| 190 |
+
n_subtopics = 1
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
vectorizer = TfidfVectorizer(
|
| 194 |
+
stop_words="english",
|
| 195 |
+
max_df=0.95,
|
| 196 |
+
min_df=1,
|
| 197 |
+
ngram_range=(1, 2),
|
| 198 |
+
max_features=5000,
|
| 199 |
+
)
|
| 200 |
+
tfidf_matrix = vectorizer.fit_transform(texts)
|
| 201 |
+
feature_names = vectorizer.get_feature_names_out()
|
| 202 |
+
|
| 203 |
+
if n_subtopics == 1 or tfidf_matrix.shape[0] < 2:
|
| 204 |
+
# Single sub-topic — just extract top keywords
|
| 205 |
+
scores = tfidf_matrix.sum(axis=0).A1
|
| 206 |
+
top_indices = scores.argsort()[-n_keywords:][::-1]
|
| 207 |
+
kw = ", ".join([feature_names[i] for i in top_indices])
|
| 208 |
+
all_topics.append({
|
| 209 |
+
"cluster_id": int(cid),
|
| 210 |
+
"topic_id": global_topic_id,
|
| 211 |
+
"sub_topic_id": 0,
|
| 212 |
+
"keywords": kw,
|
| 213 |
+
"n_documents": len(texts),
|
| 214 |
+
"source": "SPECTER2+HDBSCAN",
|
| 215 |
+
})
|
| 216 |
+
global_topic_id += 1
|
| 217 |
+
else:
|
| 218 |
+
nmf = NMF(
|
| 219 |
+
n_components=n_subtopics,
|
| 220 |
+
random_state=42,
|
| 221 |
+
max_iter=300,
|
| 222 |
+
init="nndsvda",
|
| 223 |
+
)
|
| 224 |
+
W = nmf.fit_transform(tfidf_matrix) # doc-topic matrix
|
| 225 |
+
H = nmf.components_ # topic-term matrix
|
| 226 |
+
|
| 227 |
+
# Assign each doc to its dominant sub-topic
|
| 228 |
+
doc_assignments = W.argmax(axis=1)
|
| 229 |
+
|
| 230 |
+
for sub_id in range(n_subtopics):
|
| 231 |
+
top_indices = H[sub_id].argsort()[-n_keywords:][::-1]
|
| 232 |
+
kw = ", ".join([feature_names[i] for i in top_indices])
|
| 233 |
+
n_docs_in_subtopic = int((doc_assignments == sub_id).sum())
|
| 234 |
+
if n_docs_in_subtopic == 0:
|
| 235 |
+
n_docs_in_subtopic = 1 # NMF component exists even if no dominant doc
|
| 236 |
+
|
| 237 |
+
all_topics.append({
|
| 238 |
+
"cluster_id": int(cid),
|
| 239 |
+
"topic_id": global_topic_id,
|
| 240 |
+
"sub_topic_id": sub_id,
|
| 241 |
+
"keywords": kw,
|
| 242 |
+
"n_documents": n_docs_in_subtopic,
|
| 243 |
+
"source": "SPECTER2+HDBSCAN",
|
| 244 |
+
})
|
| 245 |
+
global_topic_id += 1
|
| 246 |
+
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.warning("NMF failed for cluster %d (%s), using single topic", cid, e)
|
| 249 |
+
# Fallback: single topic with combined keywords
|
| 250 |
+
combined_text = " ".join(texts)
|
| 251 |
+
words = combined_text.lower().split()
|
| 252 |
+
word_freq = pd.Series(words).value_counts().head(n_keywords)
|
| 253 |
+
kw = ", ".join(word_freq.index.tolist())
|
| 254 |
+
all_topics.append({
|
| 255 |
+
"cluster_id": int(cid),
|
| 256 |
+
"topic_id": global_topic_id,
|
| 257 |
+
"sub_topic_id": 0,
|
| 258 |
+
"keywords": kw,
|
| 259 |
+
"n_documents": len(texts),
|
| 260 |
+
"source": "SPECTER2+HDBSCAN",
|
| 261 |
+
})
|
| 262 |
+
global_topic_id += 1
|
| 263 |
+
|
| 264 |
+
topics_df = pd.DataFrame(all_topics)
|
| 265 |
+
logger.info(
|
| 266 |
+
"Extracted %d topics from %d clusters (avg %.1f topics/cluster)",
|
| 267 |
+
len(topics_df), n_clusters, len(topics_df) / max(n_clusters, 1),
|
| 268 |
+
)
|
| 269 |
+
return topics_df
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def get_council_labels(topics_df, groq_api_key):
|
| 273 |
+
"""
|
| 274 |
+
Council: Domain Expert, Data Analyst, Judge
|
| 275 |
+
topics_df: list of dicts with topic_id and keywords
|
| 276 |
+
"""
|
| 277 |
+
client = Groq(api_key=groq_api_key)
|
| 278 |
+
results = []
|
| 279 |
+
|
| 280 |
+
for _, row in topics_df.iterrows():
|
| 281 |
+
topic_id = row['topic_id']
|
| 282 |
+
keywords = row['keywords']
|
| 283 |
+
|
| 284 |
+
# Persona 1: Domain Expert
|
| 285 |
+
prompt_expert = f"You are a scientific Domain Expert. Look at these keywords: [{keywords}]. Propose a 3-5 word descriptive label for this academic topic."
|
| 286 |
+
|
| 287 |
+
# Persona 2: Data Analyst
|
| 288 |
+
prompt_analyst = f"You are a Data Analyst categorizing clusters. Keywords: [{keywords}]. Give a short, precise categorical name."
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
res_expert = client.chat.completions.create(model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt_expert}], temperature=0.4, max_tokens=20).choices[0].message.content.strip()
|
| 292 |
+
res_analyst = client.chat.completions.create(model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt_analyst}], temperature=0.4, max_tokens=20).choices[0].message.content.strip()
|
| 293 |
+
|
| 294 |
+
# Persona 3: Judge
|
| 295 |
+
prompt_judge = f"Keywords: [{keywords}].\nExpert proposed: '{res_expert}'.\nAnalyst proposed: '{res_analyst}'.\nSynthesize these into a single, high-quality final label (max 5 words). Return ONLY the label, nothing else."
|
| 296 |
+
|
| 297 |
+
res_judge = client.chat.completions.create(model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt_judge}], temperature=0.2, max_tokens=20).choices[0].message.content.strip()
|
| 298 |
+
# Clean up
|
| 299 |
+
res_judge = res_judge.replace('"', '').replace("'", "").strip()
|
| 300 |
+
results.append(res_judge)
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(f"Groq error: {e}")
|
| 303 |
+
results.append(", ".join(keywords.split(",")[:3]).title())
|
| 304 |
+
|
| 305 |
+
time.sleep(0.5)
|
| 306 |
+
|
| 307 |
+
return results
|
| 308 |
+
|
tools.py
CHANGED
|
@@ -102,19 +102,46 @@ def load_csv(filepath: str) -> pd.DataFrame:
|
|
| 102 |
logger.info("Loaded %d rows × %d columns", len(df), len(df.columns))
|
| 103 |
|
| 104 |
# Validate required columns (case-insensitive match)
|
| 105 |
-
col_map = {c.strip().lower(): c for c in df.columns}
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
if
|
|
|
|
| 109 |
raise ValueError(f"CSV is missing required columns: {missing}. Found: {list(df.columns)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# Rename to canonical form
|
| 112 |
-
df = df.rename(columns=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
# Drop rows where
|
| 115 |
-
df = df.dropna(subset=["Title", "Abstract"], how="all").reset_index(drop=True)
|
| 116 |
df["Title"] = df["Title"].fillna("")
|
| 117 |
df["Abstract"] = df["Abstract"].fillna("")
|
|
|
|
|
|
|
| 118 |
|
| 119 |
logger.info("After cleaning: %d usable rows", len(df))
|
| 120 |
return df
|
|
@@ -183,22 +210,23 @@ def preprocess_text(documents: List[str]) -> List[str]:
|
|
| 183 |
|
| 184 |
|
| 185 |
# ===================================================================
|
| 186 |
-
# 3. run_topic_modeling — Discover
|
| 187 |
# ===================================================================
|
| 188 |
def run_topic_modeling(
|
| 189 |
documents: List[str],
|
| 190 |
source_label: str = "documents",
|
| 191 |
min_topics: int = 100,
|
|
|
|
| 192 |
use_bertopic: bool = True,
|
| 193 |
) -> Tuple[pd.DataFrame, Any]:
|
| 194 |
"""
|
| 195 |
-
Perform topic modeling on a corpus of preprocessed documents.
|
| 196 |
|
| 197 |
Strategy:
|
| 198 |
-
1.
|
| 199 |
-
|
| 200 |
-
2.
|
| 201 |
-
|
| 202 |
|
| 203 |
Parameters
|
| 204 |
----------
|
|
@@ -207,46 +235,216 @@ def run_topic_modeling(
|
|
| 207 |
source_label : str
|
| 208 |
Label for logging (e.g. "Titles" or "Abstracts").
|
| 209 |
min_topics : int
|
| 210 |
-
Minimum number of topics required (default 100).
|
|
|
|
|
|
|
| 211 |
use_bertopic : bool
|
| 212 |
Whether to attempt BERTopic first.
|
| 213 |
|
| 214 |
Returns
|
| 215 |
-------
|
| 216 |
topics_df : pd.DataFrame
|
| 217 |
-
Columns: topic_id, keywords
|
| 218 |
model : object
|
| 219 |
-
The fitted
|
| 220 |
"""
|
|
|
|
|
|
|
|
|
|
| 221 |
# Filter out empty documents
|
| 222 |
valid_docs = [d for d in documents if d.strip()]
|
| 223 |
if len(valid_docs) < 20:
|
| 224 |
raise ValueError(f"Not enough valid documents ({len(valid_docs)}) for topic modeling.")
|
| 225 |
|
| 226 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
-
|
|
|
|
| 229 |
model = None
|
| 230 |
|
| 231 |
-
# ------ Attempt BERTopic ------
|
| 232 |
if use_bertopic:
|
| 233 |
try:
|
| 234 |
-
|
| 235 |
except Exception as exc:
|
| 236 |
logger.warning("BERTopic failed (%s). Falling back to LDA.", exc)
|
| 237 |
-
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
topics_df, model = _run_lda(valid_docs, source_label, min_topics)
|
| 243 |
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
return topics_df, model
|
| 246 |
|
| 247 |
|
| 248 |
-
def
|
| 249 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
from bertopic import BERTopic
|
| 251 |
from umap import UMAP
|
| 252 |
from hdbscan import HDBSCAN
|
|
@@ -260,7 +458,7 @@ def _run_bertopic(docs: List[str], source_label: str, min_topics: int):
|
|
| 260 |
random_state=42,
|
| 261 |
)
|
| 262 |
hdbscan_model = HDBSCAN(
|
| 263 |
-
min_cluster_size=5,
|
| 264 |
min_samples=2,
|
| 265 |
prediction_data=True,
|
| 266 |
)
|
|
@@ -275,7 +473,7 @@ def _run_bertopic(docs: List[str], source_label: str, min_topics: int):
|
|
| 275 |
umap_model=umap_model,
|
| 276 |
hdbscan_model=hdbscan_model,
|
| 277 |
vectorizer_model=vectorizer,
|
| 278 |
-
nr_topics=
|
| 279 |
top_n_words=10,
|
| 280 |
verbose=False,
|
| 281 |
)
|
|
@@ -296,8 +494,8 @@ def _run_bertopic(docs: List[str], source_label: str, min_topics: int):
|
|
| 296 |
return df, topic_model
|
| 297 |
|
| 298 |
|
| 299 |
-
def _run_lda(docs: List[str], source_label: str,
|
| 300 |
-
"""Run sklearn LDA to
|
| 301 |
from sklearn.decomposition import LatentDirichletAllocation
|
| 302 |
from sklearn.feature_extraction.text import CountVectorizer
|
| 303 |
|
|
@@ -312,7 +510,7 @@ def _run_lda(docs: List[str], source_label: str, n_topics: int):
|
|
| 312 |
feature_names = vectorizer.get_feature_names_out()
|
| 313 |
|
| 314 |
lda = LatentDirichletAllocation(
|
| 315 |
-
n_components=
|
| 316 |
max_iter=25,
|
| 317 |
learning_method="online",
|
| 318 |
random_state=42,
|
|
@@ -624,3 +822,451 @@ def create_taxonomy_map(
|
|
| 624 |
|
| 625 |
logger.info("Taxonomy: %d MAPPED, %d NOVEL", len(mapped), len(novel))
|
| 626 |
return taxonomy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
logger.info("Loaded %d rows × %d columns", len(df), len(df.columns))
|
| 103 |
|
| 104 |
# Validate required columns (case-insensitive match)
|
| 105 |
+
col_map = {str(c).strip().lower(): c for c in df.columns}
|
| 106 |
+
|
| 107 |
+
# Title and Abstract are strictly required
|
| 108 |
+
if "title" not in col_map or "abstract" not in col_map:
|
| 109 |
+
missing = {"title", "abstract"} - set(col_map.keys())
|
| 110 |
raise ValueError(f"CSV is missing required columns: {missing}. Found: {list(df.columns)}")
|
| 111 |
+
|
| 112 |
+
# Map Title and Abstract
|
| 113 |
+
rename_dict = {
|
| 114 |
+
col_map["title"]: "Title",
|
| 115 |
+
col_map["abstract"]: "Abstract"
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
# Map Keywords
|
| 119 |
+
keyword_cols = ["keywords", "author keywords", "index keywords"]
|
| 120 |
+
found_keyword = next((c for c in keyword_cols if c in col_map), None)
|
| 121 |
+
if found_keyword:
|
| 122 |
+
rename_dict[col_map[found_keyword]] = "Keywords"
|
| 123 |
+
|
| 124 |
+
# Map Full Text
|
| 125 |
+
fulltext_cols = ["full text", "body"]
|
| 126 |
+
found_fulltext = next((c for c in fulltext_cols if c in col_map), None)
|
| 127 |
+
if found_fulltext:
|
| 128 |
+
rename_dict[col_map[found_fulltext]] = "Full Text"
|
| 129 |
|
| 130 |
# Rename to canonical form
|
| 131 |
+
df = df.rename(columns=rename_dict)
|
| 132 |
+
|
| 133 |
+
# Fill missing columns
|
| 134 |
+
if "Keywords" not in df.columns:
|
| 135 |
+
df["Keywords"] = ""
|
| 136 |
+
if "Full Text" not in df.columns:
|
| 137 |
+
df["Full Text"] = df["Abstract"]
|
| 138 |
|
| 139 |
+
# Drop rows where all of these are empty
|
| 140 |
+
df = df.dropna(subset=["Title", "Abstract", "Keywords", "Full Text"], how="all").reset_index(drop=True)
|
| 141 |
df["Title"] = df["Title"].fillna("")
|
| 142 |
df["Abstract"] = df["Abstract"].fillna("")
|
| 143 |
+
df["Keywords"] = df["Keywords"].fillna("")
|
| 144 |
+
df["Full Text"] = df["Full Text"].fillna("")
|
| 145 |
|
| 146 |
logger.info("After cleaning: %d usable rows", len(df))
|
| 147 |
return df
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
# ===================================================================
|
| 213 |
+
# 3. run_topic_modeling — Discover clusters + sub-topics
|
| 214 |
# ===================================================================
|
| 215 |
def run_topic_modeling(
|
| 216 |
documents: List[str],
|
| 217 |
source_label: str = "documents",
|
| 218 |
min_topics: int = 100,
|
| 219 |
+
max_clusters: int = 25,
|
| 220 |
use_bertopic: bool = True,
|
| 221 |
) -> Tuple[pd.DataFrame, Any]:
|
| 222 |
"""
|
| 223 |
+
Perform two-level topic modeling on a corpus of preprocessed documents.
|
| 224 |
|
| 225 |
Strategy:
|
| 226 |
+
1. First pass: Discover clusters (target 10–max_clusters range)
|
| 227 |
+
using BERTopic with UMAP + HDBSCAN, or LDA fallback.
|
| 228 |
+
2. Second pass: Within each cluster, extract fine-grained sub-topics
|
| 229 |
+
using NMF to guarantee ≥ min_topics total topics.
|
| 230 |
|
| 231 |
Parameters
|
| 232 |
----------
|
|
|
|
| 235 |
source_label : str
|
| 236 |
Label for logging (e.g. "Titles" or "Abstracts").
|
| 237 |
min_topics : int
|
| 238 |
+
Minimum total number of topics (sub-topics) required (default 100).
|
| 239 |
+
max_clusters : int
|
| 240 |
+
Target maximum number of high-level clusters (default 25).
|
| 241 |
use_bertopic : bool
|
| 242 |
Whether to attempt BERTopic first.
|
| 243 |
|
| 244 |
Returns
|
| 245 |
-------
|
| 246 |
topics_df : pd.DataFrame
|
| 247 |
+
Columns: cluster_id, topic_id, sub_topic_id, keywords, label, source, n_documents
|
| 248 |
model : object
|
| 249 |
+
The fitted cluster-level model for downstream inspection.
|
| 250 |
"""
|
| 251 |
+
from sklearn.decomposition import NMF
|
| 252 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 253 |
+
|
| 254 |
# Filter out empty documents
|
| 255 |
valid_docs = [d for d in documents if d.strip()]
|
| 256 |
if len(valid_docs) < 20:
|
| 257 |
raise ValueError(f"Not enough valid documents ({len(valid_docs)}) for topic modeling.")
|
| 258 |
|
| 259 |
+
logger.info(
|
| 260 |
+
"Running topic modeling on %d %s (target: %d–%d clusters, ≥ %d topics)",
|
| 261 |
+
len(valid_docs), source_label, 10, max_clusters, min_topics,
|
| 262 |
+
)
|
| 263 |
|
| 264 |
+
# ---- FIRST PASS: Discover clusters ----
|
| 265 |
+
cluster_df = None
|
| 266 |
model = None
|
| 267 |
|
|
|
|
| 268 |
if use_bertopic:
|
| 269 |
try:
|
| 270 |
+
cluster_df, model = _run_bertopic(valid_docs, source_label, max_clusters)
|
| 271 |
except Exception as exc:
|
| 272 |
logger.warning("BERTopic failed (%s). Falling back to LDA.", exc)
|
| 273 |
+
cluster_df = None
|
| 274 |
|
| 275 |
+
if cluster_df is None or len(cluster_df) < 3:
|
| 276 |
+
logger.info("Using LDA for clustering %s (target %d clusters)", source_label, max_clusters)
|
| 277 |
+
cluster_df, model = _run_lda(valid_docs, source_label, max_clusters)
|
|
|
|
| 278 |
|
| 279 |
+
n_clusters = len(cluster_df)
|
| 280 |
+
logger.info("First pass: %d clusters discovered for %s", n_clusters, source_label)
|
| 281 |
+
|
| 282 |
+
# ---- SECOND PASS: Extract sub-topics within each cluster ----
|
| 283 |
+
# Assign each document to its nearest cluster based on keyword overlap
|
| 284 |
+
cluster_assignments = _assign_docs_to_clusters(valid_docs, cluster_df)
|
| 285 |
+
|
| 286 |
+
# Calculate sub-topic counts per cluster
|
| 287 |
+
cluster_sizes = {}
|
| 288 |
+
for cid in cluster_df["topic_id"].tolist():
|
| 289 |
+
cluster_sizes[cid] = int((np.array(cluster_assignments) == cid).sum())
|
| 290 |
+
|
| 291 |
+
total_assigned = sum(cluster_sizes.values())
|
| 292 |
+
subtopic_counts = {}
|
| 293 |
+
for cid in cluster_df["topic_id"].tolist():
|
| 294 |
+
if cluster_sizes[cid] == 0:
|
| 295 |
+
subtopic_counts[cid] = 1
|
| 296 |
+
continue
|
| 297 |
+
raw_share = (cluster_sizes[cid] / max(total_assigned, 1)) * min_topics
|
| 298 |
+
subtopic_counts[cid] = max(2, round(raw_share))
|
| 299 |
+
|
| 300 |
+
# Ensure total >= min_topics
|
| 301 |
+
current_total = sum(subtopic_counts.values())
|
| 302 |
+
if current_total < min_topics:
|
| 303 |
+
deficit = min_topics - current_total
|
| 304 |
+
sorted_clusters = sorted(
|
| 305 |
+
cluster_df["topic_id"].tolist(),
|
| 306 |
+
key=lambda c: cluster_sizes.get(c, 0), reverse=True,
|
| 307 |
+
)
|
| 308 |
+
i = 0
|
| 309 |
+
while deficit > 0 and i < len(sorted_clusters) * 10:
|
| 310 |
+
cid = sorted_clusters[i % len(sorted_clusters)]
|
| 311 |
+
max_possible = max(cluster_sizes.get(cid, 0) // 2, 2)
|
| 312 |
+
if subtopic_counts[cid] < max_possible:
|
| 313 |
+
subtopic_counts[cid] += 1
|
| 314 |
+
deficit -= 1
|
| 315 |
+
i += 1
|
| 316 |
+
|
| 317 |
+
# Extract sub-topics per cluster
|
| 318 |
+
all_topics = []
|
| 319 |
+
global_topic_id = 0
|
| 320 |
+
|
| 321 |
+
for _, cluster_row in cluster_df.iterrows():
|
| 322 |
+
cid = int(cluster_row["topic_id"])
|
| 323 |
+
# Get documents in this cluster
|
| 324 |
+
doc_indices = [i for i, a in enumerate(cluster_assignments) if a == cid]
|
| 325 |
+
cluster_docs = [valid_docs[i] for i in doc_indices]
|
| 326 |
+
n_subtopics = subtopic_counts.get(cid, 2)
|
| 327 |
+
|
| 328 |
+
if len(cluster_docs) < 2:
|
| 329 |
+
# Single document cluster — single topic
|
| 330 |
+
all_topics.append({
|
| 331 |
+
"cluster_id": cid,
|
| 332 |
+
"topic_id": global_topic_id,
|
| 333 |
+
"sub_topic_id": 0,
|
| 334 |
+
"keywords": cluster_row["keywords"],
|
| 335 |
+
"source": source_label,
|
| 336 |
+
"n_documents": len(cluster_docs),
|
| 337 |
+
})
|
| 338 |
+
global_topic_id += 1
|
| 339 |
+
continue
|
| 340 |
+
|
| 341 |
+
n_subtopics = min(n_subtopics, len(cluster_docs))
|
| 342 |
+
if n_subtopics < 2:
|
| 343 |
+
n_subtopics = 1
|
| 344 |
+
|
| 345 |
+
try:
|
| 346 |
+
vectorizer = TfidfVectorizer(
|
| 347 |
+
stop_words="english", max_df=0.95, min_df=1,
|
| 348 |
+
ngram_range=(1, 2), max_features=5000,
|
| 349 |
+
)
|
| 350 |
+
tfidf_matrix = vectorizer.fit_transform(cluster_docs)
|
| 351 |
+
feature_names = vectorizer.get_feature_names_out()
|
| 352 |
+
|
| 353 |
+
if n_subtopics == 1 or tfidf_matrix.shape[0] < 2:
|
| 354 |
+
scores = tfidf_matrix.sum(axis=0).A1
|
| 355 |
+
top_indices = scores.argsort()[-10:][::-1]
|
| 356 |
+
kw = ", ".join([feature_names[i] for i in top_indices])
|
| 357 |
+
all_topics.append({
|
| 358 |
+
"cluster_id": cid,
|
| 359 |
+
"topic_id": global_topic_id,
|
| 360 |
+
"sub_topic_id": 0,
|
| 361 |
+
"keywords": kw,
|
| 362 |
+
"source": source_label,
|
| 363 |
+
"n_documents": len(cluster_docs),
|
| 364 |
+
})
|
| 365 |
+
global_topic_id += 1
|
| 366 |
+
else:
|
| 367 |
+
nmf = NMF(n_components=n_subtopics, random_state=42, max_iter=300, init="nndsvda")
|
| 368 |
+
W = nmf.fit_transform(tfidf_matrix)
|
| 369 |
+
H = nmf.components_
|
| 370 |
+
doc_assignments = W.argmax(axis=1)
|
| 371 |
+
|
| 372 |
+
for sub_id in range(n_subtopics):
|
| 373 |
+
top_indices = H[sub_id].argsort()[-10:][::-1]
|
| 374 |
+
kw = ", ".join([feature_names[i] for i in top_indices])
|
| 375 |
+
n_docs = int((doc_assignments == sub_id).sum())
|
| 376 |
+
if n_docs == 0:
|
| 377 |
+
n_docs = 1
|
| 378 |
+
|
| 379 |
+
all_topics.append({
|
| 380 |
+
"cluster_id": cid,
|
| 381 |
+
"topic_id": global_topic_id,
|
| 382 |
+
"sub_topic_id": sub_id,
|
| 383 |
+
"keywords": kw,
|
| 384 |
+
"source": source_label,
|
| 385 |
+
"n_documents": n_docs,
|
| 386 |
+
})
|
| 387 |
+
global_topic_id += 1
|
| 388 |
+
|
| 389 |
+
except Exception as e:
|
| 390 |
+
logger.warning("NMF failed for cluster %d: %s. Using cluster keywords.", cid, e)
|
| 391 |
+
all_topics.append({
|
| 392 |
+
"cluster_id": cid,
|
| 393 |
+
"topic_id": global_topic_id,
|
| 394 |
+
"sub_topic_id": 0,
|
| 395 |
+
"keywords": cluster_row["keywords"],
|
| 396 |
+
"source": source_label,
|
| 397 |
+
"n_documents": len(cluster_docs),
|
| 398 |
+
})
|
| 399 |
+
global_topic_id += 1
|
| 400 |
+
|
| 401 |
+
topics_df = pd.DataFrame(all_topics)
|
| 402 |
+
|
| 403 |
+
# Add cluster_label from cluster keywords
|
| 404 |
+
cluster_label_map = {}
|
| 405 |
+
for _, row in cluster_df.iterrows():
|
| 406 |
+
cid = int(row["topic_id"])
|
| 407 |
+
kws = [kw.strip().title() for kw in row["keywords"].split(",")][:3]
|
| 408 |
+
cluster_label_map[cid] = " / ".join(kws)
|
| 409 |
+
topics_df["cluster_label"] = topics_df["cluster_id"].map(cluster_label_map).fillna("")
|
| 410 |
+
|
| 411 |
+
logger.info(
|
| 412 |
+
"Topic modeling complete for %s: %d topics in %d clusters",
|
| 413 |
+
source_label, len(topics_df), n_clusters,
|
| 414 |
+
)
|
| 415 |
return topics_df, model
|
| 416 |
|
| 417 |
|
| 418 |
+
def _assign_docs_to_clusters(docs: List[str], cluster_df: pd.DataFrame) -> List[int]:
|
| 419 |
+
"""Assign each document to its most similar cluster based on keyword overlap."""
|
| 420 |
+
cluster_ids = cluster_df["topic_id"].tolist()
|
| 421 |
+
cluster_keywords = []
|
| 422 |
+
for _, row in cluster_df.iterrows():
|
| 423 |
+
kws = set(kw.strip().lower() for kw in row["keywords"].split(",") if kw.strip())
|
| 424 |
+
# Also add individual words from multi-word keywords
|
| 425 |
+
expanded = set()
|
| 426 |
+
for kw in kws:
|
| 427 |
+
expanded.update(kw.split())
|
| 428 |
+
expanded.update(kws)
|
| 429 |
+
cluster_keywords.append(expanded)
|
| 430 |
+
|
| 431 |
+
assignments = []
|
| 432 |
+
for doc in docs:
|
| 433 |
+
doc_tokens = set(doc.lower().split())
|
| 434 |
+
best_score = -1
|
| 435 |
+
best_cid = cluster_ids[0] # default to first cluster
|
| 436 |
+
for cid, kws in zip(cluster_ids, cluster_keywords):
|
| 437 |
+
overlap = len(doc_tokens & kws)
|
| 438 |
+
if overlap > best_score:
|
| 439 |
+
best_score = overlap
|
| 440 |
+
best_cid = cid
|
| 441 |
+
assignments.append(best_cid)
|
| 442 |
+
|
| 443 |
+
return assignments
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _run_bertopic(docs: List[str], source_label: str, max_clusters: int):
|
| 447 |
+
"""Run BERTopic to discover clusters (not topics)."""
|
| 448 |
from bertopic import BERTopic
|
| 449 |
from umap import UMAP
|
| 450 |
from hdbscan import HDBSCAN
|
|
|
|
| 458 |
random_state=42,
|
| 459 |
)
|
| 460 |
hdbscan_model = HDBSCAN(
|
| 461 |
+
min_cluster_size=max(5, len(docs) // max_clusters),
|
| 462 |
min_samples=2,
|
| 463 |
prediction_data=True,
|
| 464 |
)
|
|
|
|
| 473 |
umap_model=umap_model,
|
| 474 |
hdbscan_model=hdbscan_model,
|
| 475 |
vectorizer_model=vectorizer,
|
| 476 |
+
nr_topics=max_clusters,
|
| 477 |
top_n_words=10,
|
| 478 |
verbose=False,
|
| 479 |
)
|
|
|
|
| 494 |
return df, topic_model
|
| 495 |
|
| 496 |
|
| 497 |
+
def _run_lda(docs: List[str], source_label: str, n_clusters: int):
|
| 498 |
+
"""Run sklearn LDA to discover clusters."""
|
| 499 |
from sklearn.decomposition import LatentDirichletAllocation
|
| 500 |
from sklearn.feature_extraction.text import CountVectorizer
|
| 501 |
|
|
|
|
| 510 |
feature_names = vectorizer.get_feature_names_out()
|
| 511 |
|
| 512 |
lda = LatentDirichletAllocation(
|
| 513 |
+
n_components=n_clusters,
|
| 514 |
max_iter=25,
|
| 515 |
learning_method="online",
|
| 516 |
random_state=42,
|
|
|
|
| 822 |
|
| 823 |
logger.info("Taxonomy: %d MAPPED, %d NOVEL", len(mapped), len(novel))
|
| 824 |
return taxonomy
|
| 825 |
+
|
| 826 |
+
# ===================================================================
|
| 827 |
+
# TCCM Extraction Tool
|
| 828 |
+
# ===================================================================
|
| 829 |
+
def extract_tccm(documents: List[str], api_key: str) -> pd.DataFrame:
|
| 830 |
+
# Basic full-text TCCM extraction stub
|
| 831 |
+
logger.info("Extracting TCCM from full text...")
|
| 832 |
+
return pd.DataFrame([{"TCCM": "Sample"} for _ in documents])
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
# ===================================================================
|
| 836 |
+
# SPECTER2 Pipeline Functions
|
| 837 |
+
# ===================================================================
|
| 838 |
+
|
| 839 |
+
def generate_specter2_embeddings(papers: List[Dict], batch_size: int = 16) -> np.ndarray:
|
| 840 |
+
"""
|
| 841 |
+
Generate embeddings using SPECTER2 via adapters.
|
| 842 |
+
papers: list of dicts with 'title' and 'abstract' keys.
|
| 843 |
+
"""
|
| 844 |
+
import torch
|
| 845 |
+
from transformers import AutoTokenizer
|
| 846 |
+
from adapters import AutoAdapterModel
|
| 847 |
+
|
| 848 |
+
logger.info("Loading SPECTER2 model and tokenizer...")
|
| 849 |
+
tokenizer = AutoTokenizer.from_pretrained('allenai/specter2_base')
|
| 850 |
+
model = AutoAdapterModel.from_pretrained('allenai/specter2_base')
|
| 851 |
+
model.load_adapter("allenai/specter2", source="hf", load_as="proximity", set_active=True)
|
| 852 |
+
|
| 853 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 854 |
+
model.to(device)
|
| 855 |
+
model.eval()
|
| 856 |
+
|
| 857 |
+
embeddings = []
|
| 858 |
+
logger.info("Generating embeddings for %d papers using %s...", len(papers), device)
|
| 859 |
+
|
| 860 |
+
for i in range(0, len(papers), batch_size):
|
| 861 |
+
batch = papers[i:i+batch_size]
|
| 862 |
+
text_batch = [d['title'] + tokenizer.sep_token + (d.get('abstract') or '') for d in batch]
|
| 863 |
+
|
| 864 |
+
inputs = tokenizer(
|
| 865 |
+
text_batch, padding=True, truncation=True,
|
| 866 |
+
return_tensors="pt", return_token_type_ids=False, max_length=512,
|
| 867 |
+
)
|
| 868 |
+
inputs = {k: v.to(device) for k, v in inputs.items()}
|
| 869 |
+
|
| 870 |
+
with torch.no_grad():
|
| 871 |
+
output = model(**inputs)
|
| 872 |
+
batch_embeddings = output.last_hidden_state[:, 0, :].cpu().numpy()
|
| 873 |
+
embeddings.append(batch_embeddings)
|
| 874 |
+
|
| 875 |
+
return np.vstack(embeddings)
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def store_in_chroma(embeddings, documents, metadatas, collection_name="specter_vectors"):
|
| 879 |
+
"""Store embeddings in ChromaDB for persistence."""
|
| 880 |
+
import chromadb
|
| 881 |
+
|
| 882 |
+
client = chromadb.PersistentClient(path="./chroma_db")
|
| 883 |
+
# Delete existing collection to avoid ID conflicts
|
| 884 |
+
try:
|
| 885 |
+
client.delete_collection(name=collection_name)
|
| 886 |
+
except Exception:
|
| 887 |
+
pass
|
| 888 |
+
collection = client.get_or_create_collection(name=collection_name)
|
| 889 |
+
|
| 890 |
+
ids = [str(i) for i in range(len(documents))]
|
| 891 |
+
collection.add(
|
| 892 |
+
embeddings=embeddings.tolist(),
|
| 893 |
+
documents=documents,
|
| 894 |
+
metadatas=metadatas,
|
| 895 |
+
ids=ids,
|
| 896 |
+
)
|
| 897 |
+
return collection
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
def load_embeddings_from_chroma(collection_name="specter_vectors"):
|
| 901 |
+
"""Load cached embeddings from ChromaDB."""
|
| 902 |
+
import chromadb
|
| 903 |
+
|
| 904 |
+
client = chromadb.PersistentClient(path="./chroma_db")
|
| 905 |
+
collection = client.get_collection(name=collection_name)
|
| 906 |
+
result = collection.get(include=["embeddings", "documents", "metadatas"])
|
| 907 |
+
|
| 908 |
+
if not result["embeddings"]:
|
| 909 |
+
raise ValueError("No embeddings found in ChromaDB.")
|
| 910 |
+
|
| 911 |
+
embeddings = np.array(result["embeddings"])
|
| 912 |
+
documents = result["documents"]
|
| 913 |
+
metadatas = result["metadatas"]
|
| 914 |
+
return embeddings, documents, metadatas
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def adaptive_hdbscan_clustering(
|
| 918 |
+
embeddings: np.ndarray,
|
| 919 |
+
min_clusters: int = 10,
|
| 920 |
+
max_clusters: int = 40,
|
| 921 |
+
) -> tuple:
|
| 922 |
+
"""
|
| 923 |
+
Adaptive HDBSCAN clustering with UMAP dimensionality reduction.
|
| 924 |
+
Tries different min_cluster_size values to land within the target cluster range.
|
| 925 |
+
|
| 926 |
+
Returns: (labels, reduced_2d, n_clusters, params_used)
|
| 927 |
+
"""
|
| 928 |
+
from umap import UMAP
|
| 929 |
+
from hdbscan import HDBSCAN
|
| 930 |
+
|
| 931 |
+
logger.info(
|
| 932 |
+
"Adaptive HDBSCAN: targeting %d–%d clusters for %d embeddings",
|
| 933 |
+
min_clusters, max_clusters, len(embeddings),
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
# UMAP reduction to 5D for clustering
|
| 937 |
+
umap_5d = UMAP(
|
| 938 |
+
n_neighbors=15, n_components=5, min_dist=0.0,
|
| 939 |
+
metric="cosine", random_state=42,
|
| 940 |
+
)
|
| 941 |
+
reduced_5d = umap_5d.fit_transform(embeddings)
|
| 942 |
+
|
| 943 |
+
# UMAP reduction to 2D for visualization
|
| 944 |
+
umap_2d = UMAP(
|
| 945 |
+
n_neighbors=15, n_components=2, min_dist=0.1,
|
| 946 |
+
metric="cosine", random_state=42,
|
| 947 |
+
)
|
| 948 |
+
reduced_2d = umap_2d.fit_transform(embeddings)
|
| 949 |
+
|
| 950 |
+
# Try different min_cluster_size to find good cluster count
|
| 951 |
+
best_labels = None
|
| 952 |
+
best_n_clusters = 0
|
| 953 |
+
best_params = {}
|
| 954 |
+
best_diff = float("inf")
|
| 955 |
+
|
| 956 |
+
target_mid = (min_clusters + max_clusters) / 2
|
| 957 |
+
|
| 958 |
+
for mcs in [5, 8, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 120, 150]:
|
| 959 |
+
for ms in [2, 3, 5]:
|
| 960 |
+
clusterer = HDBSCAN(
|
| 961 |
+
min_cluster_size=mcs,
|
| 962 |
+
min_samples=ms,
|
| 963 |
+
metric="euclidean",
|
| 964 |
+
cluster_selection_method="eom",
|
| 965 |
+
)
|
| 966 |
+
labels = clusterer.fit_predict(reduced_5d)
|
| 967 |
+
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
|
| 968 |
+
|
| 969 |
+
diff = abs(n_clusters - target_mid)
|
| 970 |
+
in_range = min_clusters <= n_clusters <= max_clusters
|
| 971 |
+
|
| 972 |
+
if in_range and diff < best_diff:
|
| 973 |
+
best_labels = labels
|
| 974 |
+
best_n_clusters = n_clusters
|
| 975 |
+
best_params = {"min_cluster_size": mcs, "min_samples": ms}
|
| 976 |
+
best_diff = diff
|
| 977 |
+
|
| 978 |
+
if n_clusters == round(target_mid):
|
| 979 |
+
break
|
| 980 |
+
|
| 981 |
+
# Fallback: if nothing landed in range, pick closest
|
| 982 |
+
if best_labels is None:
|
| 983 |
+
logger.warning("Could not find params for target range, using best effort")
|
| 984 |
+
for mcs in [15, 20, 25, 30, 40, 50]:
|
| 985 |
+
clusterer = HDBSCAN(
|
| 986 |
+
min_cluster_size=mcs, min_samples=3,
|
| 987 |
+
metric="euclidean", cluster_selection_method="eom",
|
| 988 |
+
)
|
| 989 |
+
labels = clusterer.fit_predict(reduced_5d)
|
| 990 |
+
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
|
| 991 |
+
if n_clusters >= 5:
|
| 992 |
+
best_labels = labels
|
| 993 |
+
best_n_clusters = n_clusters
|
| 994 |
+
best_params = {"min_cluster_size": mcs, "min_samples": 3}
|
| 995 |
+
break
|
| 996 |
+
|
| 997 |
+
if best_labels is None:
|
| 998 |
+
raise RuntimeError("HDBSCAN could not produce any valid clusters.")
|
| 999 |
+
|
| 1000 |
+
logger.info(
|
| 1001 |
+
"HDBSCAN selected: %d clusters (params: %s)",
|
| 1002 |
+
best_n_clusters, best_params,
|
| 1003 |
+
)
|
| 1004 |
+
return best_labels, reduced_2d, best_n_clusters, best_params
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
def c_tf_idf(documents, m, ngram_range=(1, 1)):
|
| 1008 |
+
"""Compute class-based TF-IDF for topic keyword extraction."""
|
| 1009 |
+
from sklearn.feature_extraction.text import CountVectorizer
|
| 1010 |
+
|
| 1011 |
+
count = CountVectorizer(ngram_range=ngram_range, stop_words="english").fit(documents)
|
| 1012 |
+
t = count.transform(documents).toarray()
|
| 1013 |
+
w = t.sum(axis=1)
|
| 1014 |
+
tf = np.divide(t.T, w)
|
| 1015 |
+
sum_t = t.sum(axis=0)
|
| 1016 |
+
idf = np.log(np.divide(m, sum_t)).reshape(-1, 1)
|
| 1017 |
+
tf_idf = np.multiply(tf, idf)
|
| 1018 |
+
return tf_idf, count
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
def extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=10):
|
| 1022 |
+
"""Extract top N keywords per topic from c-TF-IDF matrix."""
|
| 1023 |
+
words = count.get_feature_names_out()
|
| 1024 |
+
labels = list(docs_per_topic.topic_id)
|
| 1025 |
+
tf_idf_transposed = tf_idf.T
|
| 1026 |
+
indices = tf_idf_transposed.argsort()[:, -n:]
|
| 1027 |
+
top_n_words = {
|
| 1028 |
+
label: [(words[j], tf_idf_transposed[i][j]) for j in indices[i]][::-1]
|
| 1029 |
+
for i, label in enumerate(labels)
|
| 1030 |
+
}
|
| 1031 |
+
return top_n_words
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
def extract_representative_sentences(
|
| 1035 |
+
df: pd.DataFrame,
|
| 1036 |
+
embeddings: np.ndarray,
|
| 1037 |
+
cluster_labels: np.ndarray,
|
| 1038 |
+
top_n: int = 5,
|
| 1039 |
+
) -> Dict[int, List[str]]:
|
| 1040 |
+
"""
|
| 1041 |
+
For each cluster, find the top_n documents closest to the cluster centroid.
|
| 1042 |
+
Returns {cluster_id: [sentence1, sentence2, ...]}.
|
| 1043 |
+
"""
|
| 1044 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 1045 |
+
|
| 1046 |
+
result = {}
|
| 1047 |
+
unique_labels = set(cluster_labels)
|
| 1048 |
+
|
| 1049 |
+
for cid in unique_labels:
|
| 1050 |
+
if cid == -1:
|
| 1051 |
+
continue
|
| 1052 |
+
mask = cluster_labels == cid
|
| 1053 |
+
cluster_embs = embeddings[mask]
|
| 1054 |
+
cluster_indices = np.where(mask)[0]
|
| 1055 |
+
|
| 1056 |
+
if len(cluster_embs) == 0:
|
| 1057 |
+
continue
|
| 1058 |
+
|
| 1059 |
+
centroid = cluster_embs.mean(axis=0).reshape(1, -1)
|
| 1060 |
+
sims = cosine_similarity(centroid, cluster_embs)[0]
|
| 1061 |
+
top_indices = sims.argsort()[-top_n:][::-1]
|
| 1062 |
+
|
| 1063 |
+
sentences = []
|
| 1064 |
+
for idx in top_indices:
|
| 1065 |
+
doc_idx = cluster_indices[idx]
|
| 1066 |
+
title = str(df.iloc[doc_idx].get("Title", ""))
|
| 1067 |
+
abstract = str(df.iloc[doc_idx].get("Abstract", ""))
|
| 1068 |
+
# Use title + first sentence of abstract
|
| 1069 |
+
first_sent = abstract.split(".")[0] if abstract else ""
|
| 1070 |
+
rep = f"{title}. {first_sent}".strip()
|
| 1071 |
+
if rep:
|
| 1072 |
+
sentences.append(rep)
|
| 1073 |
+
result[int(cid)] = sentences
|
| 1074 |
+
|
| 1075 |
+
return result
|
| 1076 |
+
|
| 1077 |
+
|
| 1078 |
+
def get_council_debate(keywords: str, groq_api_key: str) -> Dict[str, str]:
|
| 1079 |
+
"""
|
| 1080 |
+
Run a 3-agent AI Council debate to generate a topic label.
|
| 1081 |
+
Returns dict with: expert, analyst, anti_hallucination, final_label.
|
| 1082 |
+
"""
|
| 1083 |
+
import time
|
| 1084 |
+
|
| 1085 |
+
try:
|
| 1086 |
+
from groq import Groq
|
| 1087 |
+
except ImportError:
|
| 1088 |
+
kws = [kw.strip().title() for kw in keywords.split(",")][:3]
|
| 1089 |
+
return {
|
| 1090 |
+
"expert": ", ".join(kws),
|
| 1091 |
+
"analyst": ", ".join(kws),
|
| 1092 |
+
"anti_hallucination": "N/A",
|
| 1093 |
+
"final_label": ", ".join(kws),
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
client = Groq(api_key=groq_api_key)
|
| 1097 |
+
|
| 1098 |
+
# Persona 1: Domain Expert
|
| 1099 |
+
prompt_expert = (
|
| 1100 |
+
f"You are a scientific Domain Expert. Keywords: [{keywords}]. "
|
| 1101 |
+
"Propose a 3-5 word descriptive label for this academic topic. "
|
| 1102 |
+
"Return ONLY the label."
|
| 1103 |
+
)
|
| 1104 |
+
|
| 1105 |
+
# Persona 2: Data Analyst
|
| 1106 |
+
prompt_analyst = (
|
| 1107 |
+
f"You are a Data Analyst categorizing research clusters. Keywords: [{keywords}]. "
|
| 1108 |
+
"Give a short, precise categorical name (3-5 words). Return ONLY the name."
|
| 1109 |
+
)
|
| 1110 |
+
|
| 1111 |
+
try:
|
| 1112 |
+
res_expert = client.chat.completions.create(
|
| 1113 |
+
model="llama-3.3-70b-versatile",
|
| 1114 |
+
messages=[{"role": "user", "content": prompt_expert}],
|
| 1115 |
+
temperature=0.4, max_tokens=20,
|
| 1116 |
+
).choices[0].message.content.strip()
|
| 1117 |
+
|
| 1118 |
+
time.sleep(0.3)
|
| 1119 |
+
|
| 1120 |
+
res_analyst = client.chat.completions.create(
|
| 1121 |
+
model="llama-3.3-70b-versatile",
|
| 1122 |
+
messages=[{"role": "user", "content": prompt_analyst}],
|
| 1123 |
+
temperature=0.4, max_tokens=20,
|
| 1124 |
+
).choices[0].message.content.strip()
|
| 1125 |
+
|
| 1126 |
+
time.sleep(0.3)
|
| 1127 |
+
|
| 1128 |
+
# Persona 3: Anti-hallucination checker
|
| 1129 |
+
prompt_check = (
|
| 1130 |
+
f"Keywords: [{keywords}].\n"
|
| 1131 |
+
f"Expert label: '{res_expert}'.\n"
|
| 1132 |
+
f"Analyst label: '{res_analyst}'.\n"
|
| 1133 |
+
"Check: are these labels grounded in the keywords? "
|
| 1134 |
+
"If yes, synthesize into a final 3-5 word label. "
|
| 1135 |
+
"If not, create a grounded label from the keywords only. "
|
| 1136 |
+
"Return ONLY the final label."
|
| 1137 |
+
)
|
| 1138 |
+
|
| 1139 |
+
res_judge = client.chat.completions.create(
|
| 1140 |
+
model="llama-3.3-70b-versatile",
|
| 1141 |
+
messages=[{"role": "user", "content": prompt_check}],
|
| 1142 |
+
temperature=0.2, max_tokens=20,
|
| 1143 |
+
).choices[0].message.content.strip()
|
| 1144 |
+
res_judge = res_judge.replace('"', '').replace("'", "").strip()
|
| 1145 |
+
|
| 1146 |
+
time.sleep(0.3)
|
| 1147 |
+
|
| 1148 |
+
return {
|
| 1149 |
+
"expert": res_expert,
|
| 1150 |
+
"analyst": res_analyst,
|
| 1151 |
+
"anti_hallucination": res_judge,
|
| 1152 |
+
"final_label": res_judge,
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
except Exception as e:
|
| 1156 |
+
logger.warning("Council debate failed: %s", e)
|
| 1157 |
+
kws = [kw.strip().title() for kw in keywords.split(",")][:3]
|
| 1158 |
+
return {
|
| 1159 |
+
"expert": ", ".join(kws),
|
| 1160 |
+
"analyst": ", ".join(kws),
|
| 1161 |
+
"anti_hallucination": "fallback",
|
| 1162 |
+
"final_label": ", ".join(kws),
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
def build_cluster_graph(
|
| 1167 |
+
topics_df: pd.DataFrame,
|
| 1168 |
+
embeddings: np.ndarray,
|
| 1169 |
+
cluster_labels: np.ndarray,
|
| 1170 |
+
top_n_edges: int = 20,
|
| 1171 |
+
) -> List[Dict]:
|
| 1172 |
+
"""
|
| 1173 |
+
Build a similarity graph between clusters based on centroid cosine similarity.
|
| 1174 |
+
Returns list of edges: [{source, target, weight}, ...].
|
| 1175 |
+
"""
|
| 1176 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 1177 |
+
|
| 1178 |
+
unique_clusters = sorted([c for c in set(cluster_labels) if c != -1])
|
| 1179 |
+
if len(unique_clusters) < 2:
|
| 1180 |
+
return []
|
| 1181 |
+
|
| 1182 |
+
# Compute centroids
|
| 1183 |
+
centroids = []
|
| 1184 |
+
for cid in unique_clusters:
|
| 1185 |
+
mask = cluster_labels == cid
|
| 1186 |
+
centroids.append(embeddings[mask].mean(axis=0))
|
| 1187 |
+
centroids = np.array(centroids)
|
| 1188 |
+
|
| 1189 |
+
# Pairwise similarity
|
| 1190 |
+
sim_matrix = cosine_similarity(centroids)
|
| 1191 |
+
|
| 1192 |
+
edges = []
|
| 1193 |
+
for i in range(len(unique_clusters)):
|
| 1194 |
+
for j in range(i + 1, len(unique_clusters)):
|
| 1195 |
+
edges.append({
|
| 1196 |
+
"source": int(unique_clusters[i]),
|
| 1197 |
+
"target": int(unique_clusters[j]),
|
| 1198 |
+
"weight": round(float(sim_matrix[i][j]), 4),
|
| 1199 |
+
})
|
| 1200 |
+
|
| 1201 |
+
# Keep top N edges by weight
|
| 1202 |
+
edges.sort(key=lambda e: e["weight"], reverse=True)
|
| 1203 |
+
return edges[:top_n_edges]
|
| 1204 |
+
|
| 1205 |
+
|
| 1206 |
+
def merge_similar_clusters(
|
| 1207 |
+
topics_df: pd.DataFrame,
|
| 1208 |
+
cluster_labels: np.ndarray,
|
| 1209 |
+
df: pd.DataFrame,
|
| 1210 |
+
threshold: float = 0.75,
|
| 1211 |
+
groq_api_key: Optional[str] = None,
|
| 1212 |
+
) -> tuple:
|
| 1213 |
+
"""
|
| 1214 |
+
Merge clusters with highly similar keyword sets.
|
| 1215 |
+
Returns (new_topics_df, new_labels).
|
| 1216 |
+
"""
|
| 1217 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 1218 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 1219 |
+
|
| 1220 |
+
if len(topics_df) < 2:
|
| 1221 |
+
return topics_df, cluster_labels
|
| 1222 |
+
|
| 1223 |
+
# Compute pairwise similarity of keyword strings
|
| 1224 |
+
vectorizer = TfidfVectorizer(stop_words="english")
|
| 1225 |
+
kw_matrix = vectorizer.fit_transform(topics_df["keywords"].tolist())
|
| 1226 |
+
sim_matrix = cosine_similarity(kw_matrix)
|
| 1227 |
+
|
| 1228 |
+
# Find pairs to merge
|
| 1229 |
+
merge_map = {}
|
| 1230 |
+
topic_ids = topics_df["topic_id"].tolist()
|
| 1231 |
+
|
| 1232 |
+
for i in range(len(topic_ids)):
|
| 1233 |
+
for j in range(i + 1, len(topic_ids)):
|
| 1234 |
+
if sim_matrix[i][j] >= threshold:
|
| 1235 |
+
src = topic_ids[j]
|
| 1236 |
+
tgt = topic_ids[i]
|
| 1237 |
+
# Follow existing merge chains
|
| 1238 |
+
while tgt in merge_map:
|
| 1239 |
+
tgt = merge_map[tgt]
|
| 1240 |
+
merge_map[src] = tgt
|
| 1241 |
+
|
| 1242 |
+
if not merge_map:
|
| 1243 |
+
return topics_df, cluster_labels
|
| 1244 |
+
|
| 1245 |
+
logger.info("Merging %d cluster pairs (threshold=%.2f)", len(merge_map), threshold)
|
| 1246 |
+
|
| 1247 |
+
# Remap labels
|
| 1248 |
+
new_labels = np.array(cluster_labels, copy=True)
|
| 1249 |
+
for old_id, new_id in merge_map.items():
|
| 1250 |
+
new_labels[new_labels == old_id] = new_id
|
| 1251 |
+
|
| 1252 |
+
# Rebuild topics_df for remaining clusters
|
| 1253 |
+
remaining_ids = set(topic_ids) - set(merge_map.keys())
|
| 1254 |
+
new_topics_df = topics_df[topics_df["topic_id"].isin(remaining_ids)].copy()
|
| 1255 |
+
new_topics_df = new_topics_df.reset_index(drop=True)
|
| 1256 |
+
|
| 1257 |
+
return new_topics_df, new_labels
|
| 1258 |
+
|
| 1259 |
+
|
| 1260 |
+
def extract_subtopics_within_clusters(
|
| 1261 |
+
df: pd.DataFrame,
|
| 1262 |
+
cluster_labels: np.ndarray,
|
| 1263 |
+
min_topics: int = 100,
|
| 1264 |
+
n_keywords: int = 10,
|
| 1265 |
+
) -> pd.DataFrame:
|
| 1266 |
+
"""
|
| 1267 |
+
Extract fine-grained sub-topics within each cluster using NMF.
|
| 1268 |
+
Delegates to the implementation in run_specter.py.
|
| 1269 |
+
"""
|
| 1270 |
+
from run_specter import extract_subtopics_within_clusters as _extract
|
| 1271 |
+
return _extract(df, cluster_labels, min_topics, n_keywords)
|
| 1272 |
+
|