Spaces:
Running
Running
Download agents.py from Enayut/topic_modelling_agent: direct link, hf CLI and curl.
- Browser
- Download file 23.9 kB
-
https://huggingface.co/spaces/Enayut/topic_modelling_agent/resolve/main/agents.py
- Command line
-
hf download hf://spaces/Enayut/topic_modelling_agent/agents.py
-
curl -L -o agents.py https://huggingface.co/spaces/Enayut/topic_modelling_agent/resolve/main/agents.py
23.9 kB
| from tools import merge_similar_clusters, extract_subtopics_within_clusters | |
| """ | |
| agents.py β TopicAgent orchestrates the end-to-end topic modeling workflow. | |
| This module defines the TopicAgent class, which: | |
| 1. Loads and validates the CSV dataset. | |
| 2. Preprocesses text for Titles and Abstracts separately. | |
| 3. Runs topic modeling on each corpus (β₯100 topics guaranteed). | |
| 4. Generates human-readable labels for every topic. | |
| 5. Compares dominant themes across Title and Abstract topics. | |
| 6. Produces a taxonomy map (MAPPED / NOVEL classification). | |
| 7. Exports structured outputs: topics table, comparison CSV, taxonomy JSON. | |
| OR (SPECTER mode): | |
| 1. Loads CSV | |
| 2. Generates SPECTER2 embeddings + stores in ChromaDB | |
| 3. Adaptive HDBSCAN clustering (5-140 clusters) | |
| 4. c-TF-IDF keyword extraction + representative sentences | |
| 5. AI Council labeling with debate transcripts | |
| 6. Taxonomy mapping | |
| 7. Export | |
| Usage: | |
| agent = TopicAgent(csv_path="dataset.csv") | |
| results = agent.run() | |
| """ | |
| import os | |
| import json | |
| import logging | |
| from dataclasses import dataclass, field | |
| from typing import Dict, Any, List, Optional | |
| import numpy as np | |
| import pandas as pd | |
| from tools import ( | |
| load_csv, | |
| preprocess_text, | |
| run_topic_modeling, | |
| generate_labels, | |
| compare_themes, | |
| create_taxonomy_map, | |
| generate_specter2_embeddings, | |
| store_in_chroma, | |
| load_embeddings_from_chroma, | |
| adaptive_hdbscan_clustering, | |
| c_tf_idf, | |
| extract_top_n_words_per_topic, | |
| extract_representative_sentences, | |
| get_council_debate, | |
| build_cluster_graph, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Logging | |
| # --------------------------------------------------------------------------- | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Structured result container | |
| # --------------------------------------------------------------------------- | |
| class AgentResult: | |
| """Container for all outputs produced by the TopicAgent.""" | |
| # Core dataframes | |
| title_topics: pd.DataFrame = field(default_factory=pd.DataFrame) | |
| abstract_topics: pd.DataFrame = field(default_factory=pd.DataFrame) | |
| combined_topics: pd.DataFrame = field(default_factory=pd.DataFrame) | |
| comparison: pd.DataFrame = field(default_factory=pd.DataFrame) | |
| # Taxonomy map (dict serialisable to JSON) | |
| taxonomy_map: Dict[str, Any] = field(default_factory=dict) | |
| # SPECTER/Cluster-specific data | |
| cluster_data: Dict[str, Any] = field(default_factory=dict) | |
| # Holds: embeddings_2d, cluster_labels, cluster_details, representative_sentences, | |
| # cluster_graph_edges, n_clusters, hdbscan_params | |
| council_debates: Dict[int, Dict[str, str]] = field(default_factory=dict) | |
| # {topic_id: {expert, analyst, anti_hallucination, final_label, ...}} | |
| # Pipeline mode: "default" or "specter" | |
| pipeline_mode: str = "default" | |
| # Execution metadata | |
| status: str = "pending" | |
| steps_completed: list = field(default_factory=list) | |
| errors: list = field(default_factory=list) | |
| # File paths of exported artefacts | |
| exported_files: Dict[str, str] = field(default_factory=dict) | |
| # --------------------------------------------------------------------------- | |
| # TopicAgent | |
| # --------------------------------------------------------------------------- | |
| class TopicAgent: | |
| """ | |
| Orchestrates the research-paper topic modeling pipeline. | |
| Parameters | |
| ---------- | |
| csv_path : str | |
| Path to the input CSV file. | |
| output_dir : str | |
| Directory to write output files. | |
| min_topics : int | |
| Minimum number of topics to generate per source (default 100). | |
| use_llm_labels : bool | |
| Whether to use Groq LLM for label generation. | |
| use_specter : bool | |
| Whether to use SPECTER2 + HDBSCAN clustering pipeline. | |
| groq_api_key : str, optional | |
| API key for Groq (used only when use_llm_labels is True). | |
| """ | |
| def __init__( | |
| self, | |
| csv_path: str, | |
| output_dir: str = "outputs", | |
| min_topics: int = 100, | |
| max_clusters: int = 25, | |
| use_llm_labels: bool = False, | |
| use_specter: bool = False, | |
| groq_api_key: Optional[str] = None, | |
| ): | |
| self.csv_path = csv_path | |
| self.output_dir = output_dir | |
| self.min_topics = min_topics | |
| self.max_clusters = max_clusters | |
| self.use_llm_labels = use_llm_labels | |
| self.use_specter = use_specter | |
| self.groq_api_key = groq_api_key | |
| # Ensure output directory exists | |
| os.makedirs(self.output_dir, exist_ok=True) | |
| self._result = AgentResult() | |
| if use_specter: | |
| self._result.pipeline_mode = "specter" | |
| # Internal state | |
| self._df = None | |
| self._embeddings = None | |
| self._cluster_labels = None | |
| self._reduced_2d = None | |
| # ----------------------------------------------------------------- | |
| # Public interface | |
| # ----------------------------------------------------------------- | |
| def run(self) -> AgentResult: | |
| """Execute the full pipeline. Use sub-steps for progress display.""" | |
| logger.info("=" * 60) | |
| logger.info("TopicAgent β Starting pipeline (mode=%s)", self._result.pipeline_mode) | |
| logger.info("=" * 60) | |
| try: | |
| self._step_load_csv() | |
| if self.use_specter: | |
| self._step_specter_embed() | |
| self._step_specter_cluster() | |
| self._step_specter_extract() | |
| self._step_merge_similar_clusters() | |
| self._step_specter_subtopics() | |
| self._step_specter_label() | |
| self._step_specter_taxonomy() | |
| else: | |
| self._step_preprocess() | |
| self._step_model_titles() | |
| self._step_model_abstracts() | |
| self._step_generate_labels() | |
| self._step_combine_topics() | |
| self._step_compare_themes() | |
| self._step_taxonomy_map() | |
| self._step_export() | |
| self._result.status = "success" | |
| logger.info("Pipeline completed successfully.") | |
| except Exception as exc: | |
| self._result.status = "failed" | |
| self._result.errors.append(str(exc)) | |
| logger.error("Pipeline failed: %s", exc, exc_info=True) | |
| return self._result | |
| # ----------------------------------------------------------------- | |
| # Shared steps | |
| # ----------------------------------------------------------------- | |
| def _step_load_csv(self): | |
| """Step 1 β Ingest CSV dataset.""" | |
| logger.info("Step 1: Loading CSV β¦") | |
| self._df = load_csv(self.csv_path) | |
| self._result.steps_completed.append("load_csv") | |
| logger.info(" β %d papers loaded.", len(self._df)) | |
| # ----------------------------------------------------------------- | |
| # DEFAULT pipeline steps | |
| # ----------------------------------------------------------------- | |
| def _step_preprocess(self): | |
| """Step 2 β Preprocess Title and Abstract text.""" | |
| logger.info("Step 2: Preprocessing text β¦") | |
| self._titles_clean = preprocess_text(self._df["Title"].tolist()) | |
| self._abstracts_clean = preprocess_text(self._df["Abstract"].tolist()) | |
| self._result.steps_completed.append("preprocess_text") | |
| def _step_model_titles(self): | |
| """Step 3 β Topic modeling on Titles.""" | |
| logger.info("Step 3: Topic modeling on Titles β¦") | |
| self._title_topics_df, self._title_model = run_topic_modeling( | |
| self._titles_clean, source_label="Titles", min_topics=self.min_topics, | |
| ) | |
| self._result.steps_completed.append("topic_modeling_titles") | |
| def _step_model_abstracts(self): | |
| """Step 4 β Topic modeling on Abstracts.""" | |
| logger.info("Step 4: Topic modeling on Abstracts β¦") | |
| self._abstract_topics_df, self._abstract_model = run_topic_modeling( | |
| self._abstracts_clean, source_label="Abstracts", min_topics=self.min_topics, | |
| ) | |
| self._result.steps_completed.append("topic_modeling_abstracts") | |
| def _step_generate_labels(self): | |
| """Step 5 β Generate human-readable labels.""" | |
| logger.info("Step 5: Generating topic labels β¦") | |
| self._title_topics_df = generate_labels( | |
| self._title_topics_df, use_llm=self.use_llm_labels, groq_api_key=self.groq_api_key, | |
| ) | |
| self._abstract_topics_df = generate_labels( | |
| self._abstract_topics_df, use_llm=self.use_llm_labels, groq_api_key=self.groq_api_key, | |
| ) | |
| self._result.title_topics = self._title_topics_df | |
| self._result.abstract_topics = self._abstract_topics_df | |
| self._result.steps_completed.append("generate_labels") | |
| def _step_combine_topics(self): | |
| """Step 6 β Combine title and abstract topics.""" | |
| logger.info("Step 6: Building combined topics table β¦") | |
| combined = pd.concat([self._title_topics_df, self._abstract_topics_df], ignore_index=True) | |
| combined["global_id"] = range(len(combined)) | |
| self._result.combined_topics = combined | |
| self._result.steps_completed.append("combine_topics") | |
| def _step_compare_themes(self): | |
| """Step 7 β Compare title vs abstract themes.""" | |
| logger.info("Step 7: Comparing title vs abstract themes β¦") | |
| comparison = compare_themes(self._title_topics_df, self._abstract_topics_df) | |
| self._result.comparison = comparison | |
| self._result.steps_completed.append("compare_themes") | |
| def _step_taxonomy_map(self): | |
| """Step 8 β Create taxonomy map.""" | |
| logger.info("Step 8: Building taxonomy map β¦") | |
| taxonomy = create_taxonomy_map(self._result.combined_topics) | |
| self._result.taxonomy_map = taxonomy | |
| self._result.steps_completed.append("create_taxonomy_map") | |
| # ----------------------------------------------------------------- | |
| # SPECTER pipeline steps (discrete, callable from app.py) | |
| # ----------------------------------------------------------------- | |
| def _step_specter_embed(self): | |
| """SPECTER Step 2 β Generate embeddings and store in ChromaDB.""" | |
| logger.info(">>> Clustering pipeline started") | |
| logger.info("SPECTER Step 2: Generating SPECTER2 embeddings β¦") | |
| titles = [str(t) if t else "" for t in self._df["Title"].tolist()] | |
| abstracts = [str(a) if a else "" for a in self._df["Abstract"].tolist()] | |
| combined_docs = [t + " " + a for t, a in zip(titles, abstracts)] | |
| # Check if embeddings already cached in ChromaDB with correct count | |
| try: | |
| cached_emb, cached_docs, _ = load_embeddings_from_chroma() | |
| if len(cached_emb) == len(self._df): | |
| logger.info("Loaded embeddings from vector DB β %d vectors", len(cached_emb)) | |
| self._embeddings = cached_emb | |
| self._combined_docs = combined_docs | |
| self._result.steps_completed.append("specter_embed_cached") | |
| return | |
| except (ValueError, Exception) as e: | |
| logger.info("No cached embeddings found (%s), generating fresh β¦", e) | |
| # Generate fresh embeddings | |
| papers = [{"title": t, "abstract": a} for t, a in zip(titles, abstracts)] | |
| self._embeddings = generate_specter2_embeddings(papers) | |
| self._combined_docs = combined_docs | |
| # Store in ChromaDB | |
| metadatas = [{"id": i, "title": titles[i][:200]} for i in range(len(combined_docs))] | |
| store_in_chroma(self._embeddings, documents=combined_docs, metadatas=metadatas) | |
| logger.info("Loaded embeddings from vector DB β %d vectors", len(self._embeddings)) | |
| # Validate β no nulls | |
| null_count = int(np.isnan(self._embeddings).any(axis=1).sum()) | |
| if null_count > 0: | |
| logger.warning("Found %d embeddings with NaN values, replacing with zeros", null_count) | |
| self._embeddings = np.nan_to_num(self._embeddings, nan=0.0) | |
| self._result.steps_completed.append("specter_embed") | |
| def _step_specter_cluster(self): | |
| """SPECTER Step 3 β Adaptive HDBSCAN clustering (10β40 clusters).""" | |
| logger.info("Running HDBSCAN (targeting %dβ%d clusters)...", 10, self.max_clusters) | |
| labels, reduced_2d, n_clusters, params = adaptive_hdbscan_clustering( | |
| self._embeddings, min_clusters=10, max_clusters=self.max_clusters, | |
| ) | |
| self._cluster_labels = np.array(labels) | |
| self._reduced_2d = reduced_2d | |
| self._df["cluster_id"] = labels | |
| logger.info("Clusters generated: %d", n_clusters) | |
| # Safeguard: if < 5 clusters, raise error | |
| if n_clusters < 5: | |
| raise RuntimeError( | |
| f"Clustering produced only {n_clusters} clusters (minimum 5 required). " | |
| "Try adjusting parameters or adding more data." | |
| ) | |
| self._result.cluster_data["n_clusters"] = n_clusters | |
| self._result.cluster_data["hdbscan_params"] = params | |
| self._result.cluster_data["embeddings_2d"] = reduced_2d.tolist() | |
| self._result.cluster_data["cluster_labels"] = labels.tolist() if hasattr(labels, 'tolist') else list(labels) | |
| self._result.steps_completed.append("specter_cluster") | |
| def _step_specter_extract(self): | |
| """SPECTER Step 4 β Extract cluster-level keywords and representative sentences.""" | |
| logger.info("SPECTER Step 4: Extracting cluster keywords and representative sentences β¦") | |
| # c-TF-IDF keyword extraction at CLUSTER level | |
| df_with_clusters = self._df.copy() | |
| df_with_clusters["cluster_id"] = self._cluster_labels | |
| # Group documents by cluster for c-TF-IDF | |
| docs_per_topic = df_with_clusters.groupby("cluster_id", as_index=False).agg( | |
| {"Title": " ".join, "Abstract": " ".join} | |
| ) | |
| docs_per_topic = docs_per_topic[docs_per_topic["cluster_id"] != -1] | |
| docs_per_topic = docs_per_topic.rename(columns={"cluster_id": "topic_id"}) | |
| # Use combined Title + Abstract for better keyword extraction | |
| combined_text = (docs_per_topic["Title"] + " " + docs_per_topic["Abstract"]).values | |
| tf_idf, count = c_tf_idf(combined_text, m=len(self._df)) | |
| top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=10) | |
| # Build CLUSTER-level DataFrame (these are the high-level groups) | |
| cluster_rows = [] | |
| for cluster_id, words in top_n_words.items(): | |
| if cluster_id == -1: | |
| continue | |
| kw = ", ".join([w for w, _ in words]) | |
| n_docs = int((self._cluster_labels == cluster_id).sum()) | |
| cluster_rows.append({ | |
| "topic_id": int(cluster_id), | |
| "keywords": kw, | |
| "source": "SPECTER2+HDBSCAN", | |
| "n_documents": n_docs, | |
| }) | |
| self._clusters_df = pd.DataFrame(cluster_rows) | |
| logger.info(" β %d clusters with keywords extracted", len(self._clusters_df)) | |
| # Extract representative sentences | |
| rep_sentences = extract_representative_sentences( | |
| self._df, self._embeddings, self._cluster_labels, top_n=5, | |
| ) | |
| self._result.cluster_data["representative_sentences"] = rep_sentences | |
| # Build cluster graph | |
| edges = build_cluster_graph(self._clusters_df, self._embeddings, self._cluster_labels) | |
| self._result.cluster_data["graph_edges"] = edges | |
| # Store per-cluster details | |
| cluster_details = {} | |
| for _, row in self._clusters_df.iterrows(): | |
| cid = int(row["topic_id"]) | |
| cluster_details[cid] = { | |
| "cluster_id": cid, | |
| "keywords": row["keywords"], | |
| "n_documents": int(row["n_documents"]), | |
| "representatives": rep_sentences.get(cid, []), | |
| } | |
| self._result.cluster_data["cluster_details"] = cluster_details | |
| self._result.steps_completed.append("specter_extract") | |
| def _step_specter_subtopics(self): | |
| """SPECTER Step 5 β Extract β₯100 fine-grained sub-topics within each cluster.""" | |
| logger.info( | |
| "SPECTER Step 5: Extracting sub-topics (target β₯ %d topics from %d clusters) β¦", | |
| self.min_topics, len(self._clusters_df), | |
| ) | |
| self._topics_df = extract_subtopics_within_clusters( | |
| self._df, | |
| self._cluster_labels, | |
| min_topics=self.min_topics, | |
| n_keywords=10, | |
| ) | |
| # Add cluster_label from the clusters_df | |
| cluster_label_map = {} | |
| for _, row in self._clusters_df.iterrows(): | |
| cid = int(row["topic_id"]) | |
| # Heuristic label from top 3 keywords | |
| kws = [kw.strip().title() for kw in row["keywords"].split(",")][:3] | |
| cluster_label_map[cid] = " / ".join(kws) | |
| self._topics_df["cluster_label"] = self._topics_df["cluster_id"].map(cluster_label_map).fillna("") | |
| logger.info( | |
| " β %d topics extracted from %d clusters", | |
| len(self._topics_df), | |
| self._topics_df["cluster_id"].nunique(), | |
| ) | |
| self._result.steps_completed.append("specter_subtopics") | |
| def _step_specter_label(self): | |
| """SPECTER Step 6 β Label clusters and topics.""" | |
| logger.info("SPECTER Step 6: Labeling clusters and topics β¦") | |
| # --- Label CLUSTERS (high-level) --- | |
| if self.use_llm_labels and self.groq_api_key: | |
| logger.info("Running AI Council debate for %d clusters β¦", len(self._clusters_df)) | |
| cluster_labels_list = [] | |
| debates = {} | |
| for _, row in self._clusters_df.iterrows(): | |
| cid = int(row["topic_id"]) | |
| debate = get_council_debate(row["keywords"], self.groq_api_key) | |
| debates[cid] = debate | |
| cluster_labels_list.append(debate["final_label"]) | |
| logger.info(" Cluster %d β '%s'", cid, debate["final_label"]) | |
| self._clusters_df["label"] = cluster_labels_list | |
| self._result.council_debates = debates | |
| # Update cluster_label in topics_df with LLM labels | |
| cluster_label_map = dict(zip(self._clusters_df["topic_id"].astype(int), self._clusters_df["label"])) | |
| self._topics_df["cluster_label"] = self._topics_df["cluster_id"].map(cluster_label_map).fillna("") | |
| else: | |
| logger.info("Using heuristic labels (LLM not enabled)") | |
| self._clusters_df["label"] = [ | |
| ", ".join(row["keywords"].split(",")[:3]).strip().title() | |
| for _, row in self._clusters_df.iterrows() | |
| ] | |
| # --- Label TOPICS (fine-grained) --- | |
| if self.use_llm_labels and self.groq_api_key: | |
| logger.info("Generating LLM labels for %d topics β¦", len(self._topics_df)) | |
| from tools import _generate_labels_llm | |
| topic_labels_df = self._topics_df.copy() | |
| topic_labels_df = _generate_labels_llm(topic_labels_df, self.groq_api_key) | |
| self._topics_df["label"] = topic_labels_df["label"] | |
| else: | |
| self._topics_df["label"] = [ | |
| ", ".join(row["keywords"].split(",")[:3]).strip().title() | |
| for _, row in self._topics_df.iterrows() | |
| ] | |
| # Populate result β combined_topics now has the TWO-LEVEL hierarchy | |
| self._result.combined_topics = self._topics_df | |
| self._result.title_topics = self._clusters_df # Cluster-level view | |
| self._result.abstract_topics = pd.DataFrame() # Not applicable for SPECTER | |
| self._result.steps_completed.append("specter_label") | |
| def _step_merge_similar_clusters(self): | |
| """SPECTER Step 4b β Merge semantically similar clusters before sub-topic extraction.""" | |
| from tools import merge_similar_clusters | |
| if not hasattr(self, "_clusters_df") or self._clusters_df.empty: | |
| return | |
| logger.info("SPECTER Step 4b: Post-merging similar clusters...") | |
| new_clusters_df, new_labels = merge_similar_clusters( | |
| self._clusters_df, self._cluster_labels, self._df, | |
| threshold=0.75, groq_api_key=self.groq_api_key | |
| ) | |
| self._clusters_df = new_clusters_df | |
| self._cluster_labels = new_labels | |
| n_clusters = len(self._clusters_df) | |
| self._result.cluster_data["cluster_labels"] = new_labels.tolist() if hasattr(new_labels, 'tolist') else list(new_labels) | |
| self._result.cluster_data["n_clusters"] = n_clusters | |
| logger.info(" β After merging: %d clusters remain", n_clusters) | |
| self._result.steps_completed.append("specter_merge") | |
| def _step_specter_taxonomy(self): | |
| """SPECTER Step 6 β Build taxonomy map for clustered topics.""" | |
| logger.info("SPECTER Step 6: Building taxonomy map β¦") | |
| taxonomy = create_taxonomy_map(self._result.combined_topics) | |
| self._result.taxonomy_map = taxonomy | |
| self._result.steps_completed.append("specter_taxonomy") | |
| # ----------------------------------------------------------------- | |
| # Export (shared) | |
| # ----------------------------------------------------------------- | |
| def _step_export(self): | |
| """Export all outputs to disk.""" | |
| logger.info("Exporting outputs β¦") | |
| # (a) Combined topics table CSV | |
| topics_path = os.path.join(self.output_dir, "topics_table.csv") | |
| self._result.combined_topics.to_csv(topics_path, index=False) | |
| self._result.exported_files["topics_table"] = topics_path | |
| # (b) Comparison CSV | |
| comparison_path = os.path.join(self.output_dir, "comparison.csv") | |
| self._result.comparison.to_csv(comparison_path, index=False) | |
| self._result.exported_files["comparison"] = comparison_path | |
| # (c) Taxonomy map JSON | |
| taxonomy_path = os.path.join(self.output_dir, "taxonomy_map.json") | |
| with open(taxonomy_path, "w", encoding="utf-8") as f: | |
| json.dump(self._result.taxonomy_map, f, indent=2, ensure_ascii=False) | |
| self._result.exported_files["taxonomy_map"] = taxonomy_path | |
| # (d) Title topics CSV | |
| title_path = os.path.join(self.output_dir, "title_topics.csv") | |
| self._result.title_topics.to_csv(title_path, index=False) | |
| self._result.exported_files["title_topics"] = title_path | |
| # (e) Abstract topics CSV | |
| abstract_path = os.path.join(self.output_dir, "abstract_topics.csv") | |
| self._result.abstract_topics.to_csv(abstract_path, index=False) | |
| self._result.exported_files["abstract_topics"] = abstract_path | |
| # (f) Cluster data JSON (SPECTER mode) | |
| if self._result.pipeline_mode == "specter" and self._result.cluster_data: | |
| cluster_path = os.path.join(self.output_dir, "cluster_data.json") | |
| # Don't save embeddings_2d to JSON (too large), save separately | |
| cluster_export = { | |
| k: v for k, v in self._result.cluster_data.items() | |
| if k != "embeddings_2d" | |
| } | |
| with open(cluster_path, "w", encoding="utf-8") as f: | |
| json.dump(cluster_export, f, indent=2, ensure_ascii=False, default=str) | |
| self._result.exported_files["cluster_data"] = cluster_path | |
| # (g) Council debates JSON | |
| if self._result.council_debates: | |
| debates_path = os.path.join(self.output_dir, "council_debates.json") | |
| with open(debates_path, "w", encoding="utf-8") as f: | |
| json.dump(self._result.council_debates, f, indent=2, ensure_ascii=False, default=str) | |
| self._result.exported_files["council_debates"] = debates_path | |
| self._result.steps_completed.append("export") | |
| logger.info(" β All outputs exported successfully.") | |