Enayut commited on
Commit
b2a2482
·
verified ·
1 Parent(s): ed51280

Upload 6 files

Browse files
Files changed (5) hide show
  1. agent.py +125 -27
  2. agents.py +552 -0
  3. app.py +622 -78
  4. run_specter.py +308 -0
  5. 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
- # Step 1: Load CSV
123
  self._step_load_csv()
124
 
125
- # Step 2: Preprocess text
126
- self._step_preprocess()
127
-
128
- # Step 3: Topic modeling on Titles
129
- self._step_model_titles()
130
-
131
- # Step 4: Topic modeling on Abstracts
132
- self._step_model_abstracts()
133
-
134
- # Step 5: Generate labels
135
- self._step_generate_labels()
136
-
137
- # Step 6: Build combined topics table
138
- self._step_combine_topics()
139
-
140
- # Step 7: Compare themes
141
- self._step_compare_themes()
142
-
143
- # Step 8: Create taxonomy map
144
- self._step_taxonomy_map()
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
- logger.info(" → %d title topics discovered.", len(self._title_topics_df))
 
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
- logger.info(" → %d abstract topics discovered.", len(self._abstract_topics_df))
 
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 agent import TopicAgent
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=200,
306
  value=100,
307
  step=10,
308
- help="Minimum number of topics to generate per source (Titles / Abstracts).",
 
 
 
 
 
 
 
 
 
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. Preprocess text (Titles + Abstracts)",
332
- "3. Topic modeling — Titles (≥{} topics)".format(min_topics),
333
- "4. Topic modeling — Abstracts (≥{} topics)".format(min_topics),
334
- "5. Generate human-readable labels",
335
- "6. Combine topics table",
336
- "7. Compare themes (Title vs Abstract)",
337
- "8. Build taxonomy map (MAPPED / NOVEL)",
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
- progress.progress(10, text="Step 2/9: Preprocessing text …")
399
- agent._step_preprocess()
400
- progress.progress(20, text="Step 3/9: Topic modeling on Titles …")
401
- agent._step_model_titles()
402
- progress.progress(45, text="Step 4/9: Topic modeling on Abstracts …")
403
- agent._step_model_abstracts()
404
- progress.progress(65, text="Step 5/9: Generating topic labels …")
405
- agent._step_generate_labels()
406
- progress.progress(75, text="Step 6/9: Building combined topics table …")
407
- agent._step_combine_topics()
408
- progress.progress(80, text="Step 7/9: Comparing themes …")
409
- agent._step_compare_themes()
 
 
 
 
 
 
 
410
  progress.progress(90, text="Step 8/9: Building taxonomy map …")
411
- agent._step_taxonomy_map()
 
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
- c1, c2, c3, c4, c5 = st.columns(5)
 
 
 
 
 
482
  with c1:
483
  st.markdown(f"""
484
  <div class="stat-card">
485
- <div class="stat-value">{len(result.title_topics)}</div>
486
- <div class="stat-label">Title Topics</div>
487
  </div>
488
  """, unsafe_allow_html=True)
489
  with c2:
490
  st.markdown(f"""
491
  <div class="stat-card">
492
- <div class="stat-value">{len(result.abstract_topics)}</div>
493
- <div class="stat-label">Abstract Topics</div>
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.combined_topics)}</div>
500
- <div class="stat-label">Total Topics</div>
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 c5:
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, tab2, tab3, tab4, tab5, tab_review, tab_charts = st.tabs([
524
  "📋 Topics Table",
525
- "🔬 Title Topics",
526
- "📄 Abstract Topics",
 
 
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
- st.markdown(f"Showing all **{len(result.combined_topics)}** topics from both Titles and Abstracts.")
 
 
 
 
 
 
 
 
537
 
538
  # Filter controls
539
- fcol1, fcol2 = st.columns(2)
540
  with fcol1:
541
- source_filter = st.multiselect(
542
- "Filter by Source",
543
- options=result.combined_topics["source"].unique().tolist(),
544
- default=result.combined_topics["source"].unique().tolist(),
545
- )
 
 
 
546
  with fcol2:
547
  search_term = st.text_input("🔍 Search keywords", "")
 
 
 
 
 
 
 
 
 
 
 
548
 
549
- display_df = result.combined_topics[result.combined_topics["source"].isin(source_filter)]
 
 
 
 
550
  if search_term:
551
  mask = display_df["keywords"].str.contains(search_term, case=False, na=False)
552
- mask |= display_df["label"].str.contains(search_term, case=False, na=False)
 
 
 
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
- "topic_id": st.column_config.NumberColumn("Topic ID", width="small"),
561
- "keywords": st.column_config.TextColumn("Keywords", width="large"),
562
- "label": st.column_config.TextColumn("Label", width="medium"),
563
- "source": st.column_config.TextColumn("Source", width="small"),
564
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  )
566
 
567
- # Tab 2: Title Topics
568
- with tab2:
569
- st.markdown('<div class="section-header">Title Topics</div>', unsafe_allow_html=True)
570
- st.markdown(f"**{len(result.title_topics)}** topics discovered from paper titles.")
571
- st.dataframe(result.title_topics, use_container_width=True, height=500)
 
572
 
573
- # Tab 3: Abstract Topics
574
- with tab3:
575
- st.markdown('<div class="section-header">Abstract Topics</div>', unsafe_allow_html=True)
576
- st.markdown(f"**{len(result.abstract_topics)}** topics discovered from paper abstracts.")
577
- st.dataframe(result.abstract_topics, use_container_width=True, height=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
  # Tab 4: Theme Comparison
580
  with tab4:
581
- st.markdown('<div class="section-header">Theme Comparison: Titles vs Abstracts</div>', unsafe_allow_html=True)
582
 
583
  if not result.comparison.empty:
584
- # Alignment distribution
 
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
- review_source_filter = st.multiselect(
750
- "Filter by Source",
751
- options=st.session_state.review_df["source"].unique().tolist(),
752
- default=st.session_state.review_df["source"].unique().tolist(),
753
- key="review_source_filter",
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
- st.session_state.review_df["source"].isin(review_source_filter)
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 (Titles vs Abstracts).")
854
 
855
- source_counts = result.combined_topics["source"].value_counts().reset_index()
856
- source_counts.columns = ["Source", "Count"]
 
 
 
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> &nbsp; {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
- required = {"title", "abstract"}
107
- missing = required - set(col_map.keys())
108
- if missing:
 
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={col_map["title"]: "Title", col_map["abstract"]: "Abstract"})
 
 
 
 
 
 
113
 
114
- # Drop rows where both Title and Abstract are empty
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 topics via BERTopic (or LDA fallback)
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. Try BERTopic with UMAP + HDBSCAN. If the result has < min_topics,
199
- automatically fall back to sklearn LDA.
200
- 2. LDA is configured with n_components = min_topics to guarantee the
201
- requested topic count.
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 (comma-separated), representative_docs
218
  model : object
219
- The fitted topic model for downstream inspection.
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("Running topic modeling on %d %s (target ≥ %d topics)", len(valid_docs), source_label, min_topics)
 
 
 
227
 
228
- topics_df = None
 
229
  model = None
230
 
231
- # ------ Attempt BERTopic ------
232
  if use_bertopic:
233
  try:
234
- topics_df, model = _run_bertopic(valid_docs, source_label, min_topics)
235
  except Exception as exc:
236
  logger.warning("BERTopic failed (%s). Falling back to LDA.", exc)
237
- topics_df = None
238
 
239
- # ------ Fallback to LDA if needed ------
240
- if topics_df is None or len(topics_df) < min_topics:
241
- logger.info("Using LDA to guarantee ≥ %d topics for %s", min_topics, source_label)
242
- topics_df, model = _run_lda(valid_docs, source_label, min_topics)
243
 
244
- logger.info("Topic modeling complete for %s: %d topics discovered", source_label, len(topics_df))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  return topics_df, model
246
 
247
 
248
- def _run_bertopic(docs: List[str], source_label: str, min_topics: int):
249
- """Run BERTopic with tuned parameters."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="auto",
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, n_topics: int):
300
- """Run sklearn LDA to guarantee the requested number of topics."""
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=n_topics,
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
+