""" app.py ------ Streamlit UI — SPECTER2 + BERTopic + 3-LLM Council Research Topic Analyzer for SPJIMR × SPIT Group 14 """ import os import json import tempfile import pandas as pd import streamlit as st from tools import run_topic_modeling from agent import run_agent # ── Page setup ────────────────────────────────────────────────────────────── st.set_page_config( page_title="TMIS Topic Analyzer", page_icon="📐", layout="wide", initial_sidebar_state="expanded", ) # ── Custom CSS ─────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ── Header ─────────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ── Sidebar ────────────────────────────────────────────────────────────────── with st.sidebar: st.markdown("### API Keys") groq_key_input = st.text_input("Groq API Key", type="password", placeholder="GROQ_API_KEY env var") mistral_key_input = st.text_input("Mistral API Key", type="password", placeholder="MISTRAL_API_KEY env var") gemini_key_input = st.text_input("Gemini API Key", type="password", placeholder="GEMINI_API_KEY env var") st.caption("Keys are never stored. Leave blank to use env vars.") st.markdown("---") st.markdown("### Clustering Parameters") min_topic_size = st.slider("Min papers per cluster", min_value=3, max_value=20, value=5, help="Prof. Kamat spec: min=5") st.markdown( "Min clusters: 15" "Max clusters: 30", unsafe_allow_html=True ) st.markdown( "Cosine sim: 0.50–0.55", unsafe_allow_html=True ) st.markdown("---") st.markdown("### LLM Council") st.markdown("""
Groq / LLaMA-3.1 Mistral Small Gemini 2.5 Flash

Majority vote → best label selected.
Keyword-overlap fallback if no consensus.

""", unsafe_allow_html=True) st.markdown("---") if st.button("Reset Results", use_container_width=True): for key in ["agent_results", "topic_stats"]: st.session_state.pop(key, None) st.rerun() groq_api_key = groq_key_input.strip() or os.getenv("GROQ_API_KEY") mistral_api_key = mistral_key_input.strip() or os.getenv("MISTRAL_API_KEY") gemini_api_key = gemini_key_input.strip() or os.getenv("GEMINI_API_KEY") # ── Dataset upload ──────────────────────────────────────────────────────────── st.markdown("
Dataset
", unsafe_allow_html=True) col_up, col_sample = st.columns([3, 1]) with col_up: uploaded_file = st.file_uploader( "Upload Scopus CSV — must contain 'title' and 'abstract' columns", type=["csv"], help="Export your corpus from Scopus as CSV. The tool will combine Title + Abstract into one SPECTER2 vector per paper." ) with col_sample: st.markdown("
", unsafe_allow_html=True) use_sample = st.checkbox("Use sample dataset (50 papers)", value=False) if uploaded_file and not use_sample: try: df_preview = pd.read_csv(uploaded_file) uploaded_file.seek(0) col_a, col_b, col_c = st.columns(3) col_a.metric("Papers detected", len(df_preview)) col_b.metric("Columns", len(df_preview.columns)) has_both = {"title", "abstract"}.issubset(set(df_preview.columns.str.lower())) col_c.metric("Title + Abstract", "✓ present" if has_both else "✗ missing") if not has_both: st.error("CSV must have both 'title' and 'abstract' columns.") except Exception as e: st.error(f"Could not preview CSV: {e}") # ── Run Pipeline ───────────────────────────────────────────────────────────── st.markdown("
", unsafe_allow_html=True) run_btn = st.button("▶ Run Full Pipeline", type="primary") if run_btn: # Validation missing_keys = [] if not groq_api_key: missing_keys.append("Groq") if not mistral_api_key: missing_keys.append("Mistral") if not gemini_api_key: missing_keys.append("Gemini") if missing_keys: st.error(f"Missing API key(s): {', '.join(missing_keys)}. All three are required for the LLM council.") st.stop() if not use_sample and uploaded_file is None: st.error("Please upload a CSV file or enable the sample dataset.") st.stop() # Prepare CSV path if use_sample: import numpy as np rng = np.random.default_rng(42) topics_pool = [ ("Deep Learning for Healthcare Prediction", "We apply LSTM networks to predict patient readmission from EHR data."), ("Process Mining in Enterprise Systems", "Event log analysis using Petri nets for conformance checking in ERP workflows."), ("Recommender Systems Collaborative Filtering", "Matrix factorization techniques applied to e-commerce product recommendation."), ("LLM Applications in Information Systems", "GPT-4 used for automated requirements extraction from stakeholder documents."), ("Blockchain Smart Contract Security", "Formal verification of Solidity smart contracts for financial transaction safety."), ("Federated Learning Privacy Preservation", "Differential privacy mechanisms for distributed model training across hospitals."), ("Cybersecurity Intrusion Detection", "Random forest classifiers for network anomaly detection in enterprise environments."), ("Natural Language Processing Sentiment", "BERT fine-tuning for aspect-level sentiment analysis in product reviews."), ("Knowledge Graph Embedding", "TransE and RotatE models for biomedical entity relation prediction."), ("Computer Vision Medical Imaging", "CNN architectures for diabetic retinopathy grading from fundus photographs."), ] rows = [] for i in range(50): t, a = topics_pool[i % len(topics_pool)] rows.append({"title": t, "abstract": a + f" Study {i+1}.", "doi": f"10.1145/sample.{i+1}"}) df_s = pd.DataFrame(rows) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") df_s.to_csv(tmp.name, index=False) csv_path = tmp.name else: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv") tmp.write(uploaded_file.read()) tmp.flush() csv_path = tmp.name # Step 1: Topic modeling progress_bar = st.progress(0, text="Step 1/2 — SPECTER2 embeddings + HDBSCAN clustering (15–30 clusters)…") try: topic_results = run_topic_modeling(csv_path, min_topic_size=min_topic_size) n_clusters = len(topic_results["documents"]["topic_keywords"]) progress_bar.progress(50, text=f"Step 1/2 — Done. {n_clusters} clusters found.") except Exception as exc: st.error(f"Topic modeling failed: {exc}") st.stop() # Step 2: LLM Council progress_bar.progress(55, text="Step 2/2 — 3-LLM Council labelling (Groq + Mistral + Gemini)…") try: agent_results = run_agent( topic_results=topic_results, groq_key=groq_api_key, mistral_key=mistral_api_key, gemini_key=gemini_api_key, ) progress_bar.progress(100, text="Pipeline complete.") st.session_state["agent_results"] = agent_results # Compute summary stats interps = agent_results.get("interpretations", {}) novel_count = sum(1 for i in interps.values() if i.classification == "NOVEL") mapped_count = sum(1 for i in interps.values() if i.classification == "MAPPED") total_papers = sum(i.paper_count for i in interps.values()) st.session_state["topic_stats"] = { "n_topics": len(interps), "novel": novel_count, "mapped": mapped_count, "total_papers": total_papers, } st.success(f"Pipeline complete — {len(interps)} topics labelled by 3-LLM council.") except Exception as exc: st.error(f"LLM council failed: {exc}") st.stop() # ── Results Display ──────────────────────────────────────────────────────────── results = st.session_state.get("agent_results") stats = st.session_state.get("topic_stats") if results and stats: interps = results.get("interpretations", {}) # ── Summary stats ───────────────────────────────────────────────────────── st.markdown("
Pipeline Summary
", unsafe_allow_html=True) st.markdown(f"""
{stats['n_topics']}
Topics Found
{stats['total_papers']}
Papers Assigned
{stats['novel']}
NOVEL (no PAJAIS home)
{stats['mapped']}
MAPPED to PAJAIS
""", unsafe_allow_html=True) # ── Validation panel ────────────────────────────────────────────────────── st.markdown("
LLM Council Validation
", unsafe_allow_html=True) novel_pct = round(stats['novel'] / stats['n_topics'] * 100) if stats['n_topics'] else 0 mapped_pct = round(stats['mapped'] / stats['n_topics'] * 100) if stats['n_topics'] else 0 st.markdown(f"""

Instructor Spec Compliance

Embedding modelSPECTER2 (allenai/specter2_base)
Input columnTitle + Abstract (combined)
ClusteringUMAP → HDBSCAN (min=5, max=100 per cluster)
Cosine similarity range0.50 – 0.55 (merge / outlier reassign)
Total clusters{stats['n_topics']} (target: 15–30)
LLM councilGroq (LLaMA-3.1) + Mistral Small + Gemini 2.5 Flash
Label selectionMajority vote → keyword-overlap fallback
Rep. docs per topicTop-3 by cosine similarity to centroid
NOVEL themes (no PAJAIS home){novel_pct}% ({stats['novel']} topics)
MAPPED to PAJAIS taxonomy{mapped_pct}% ({stats['mapped']} topics)
""", unsafe_allow_html=True) # ── Filters ─────────────────────────────────────────────────────────────── st.markdown("
Topic Results
", unsafe_allow_html=True) rows = [] for tid, interp in sorted(interps.items()): rows.append({ "Topic ID": tid, "Label": interp.label, "Classification": interp.classification, "Category": interp.category, "Papers": interp.paper_count, "Keywords": ", ".join(interp.keywords[:8]), }) df_res = pd.DataFrame(rows).sort_values("Papers", ascending=False).reset_index(drop=True) col_f1, col_f2, col_f3 = st.columns([2, 2, 1]) with col_f1: cats = ["All"] + sorted(df_res["Category"].unique().tolist()) sel_cat = st.selectbox("Filter by category", cats) with col_f2: clsf = ["All", "NOVEL", "MAPPED"] sel_cls = st.selectbox("Filter by classification", clsf) with col_f3: sort_by = st.selectbox("Sort by", ["Papers ↓", "Papers ↑", "Label A–Z"]) df_f = df_res.copy() if sel_cat != "All": df_f = df_f[df_f["Category"] == sel_cat] if sel_cls != "All": df_f = df_f[df_f["Classification"] == sel_cls] if sort_by == "Papers ↓": df_f = df_f.sort_values("Papers", ascending=False) elif sort_by == "Papers ↑": df_f = df_f.sort_values("Papers", ascending=True) else: df_f = df_f.sort_values("Label") df_f = df_f.reset_index(drop=True) st.caption(f"Showing {len(df_f)} of {len(df_res)} topics") # ── Topic cards ─────────────────────────────────────────────────────────── view_mode = st.radio("View as", ["Table", "Cards"], horizontal=True) if view_mode == "Table": st.dataframe(df_f, use_container_width=True, height=420) else: for _, row in df_f.iterrows(): cls_pill = ( "NOVEL" if row["Classification"] == "NOVEL" else "MAPPED" ) card_cls = "topic-card novel" if row["Classification"] == "NOVEL" else "topic-card" st.markdown(f"""
{row['Label']}
{cls_pill} {row['Category']} {row['Papers']} papers
{row['Keywords']}
""", unsafe_allow_html=True) # ── Bar chart ───────────────────────────────────────────────────────────── st.markdown("
", unsafe_allow_html=True) with st.expander("Topic frequency chart", expanded=False): chart_df = df_f[["Label", "Papers"]].copy() chart_df["Label"] = chart_df["Label"].apply(lambda x: x[:35] + "…" if len(x) > 35 else x) chart_df = chart_df.set_index("Label") st.bar_chart(chart_df, height=380) # ── NOVEL / PAJAIS breakdown ─────────────────────────────────────────────── with st.expander("NOVEL vs PAJAIS breakdown — for paper §4.6", expanded=False): col_n, col_m = st.columns(2) with col_n: st.markdown("**NOVEL topics (no PAJAIS home)**") novel_df = df_f[df_f["Classification"] == "NOVEL"][["Label", "Papers", "Category"]].reset_index(drop=True) st.dataframe(novel_df, use_container_width=True) with col_m: st.markdown("**MAPPED topics (PAJAIS match)**") mapped_df = df_f[df_f["Classification"] == "MAPPED"][["Label", "Papers", "Category"]].reset_index(drop=True) st.dataframe(mapped_df, use_container_width=True) # ── Representative documents ────────────────────────────────────────────── with st.expander("Representative papers per topic (top-3 by centroid proximity)", expanded=False): rep_docs = results.get("rep_docs_raw", {}) # Pull from topic_results stored in session if available for tid, interp in sorted(interps.items()): st.markdown(f"**Topic {tid} — {interp.label}**") docs = interp.keywords # fallback; actual rep_docs wired below st.caption("See topics.json for full representative document titles.") st.info("Download topics.json below to see the 3 representative paper titles per cluster used for LLM labelling.") # ── Downloads ───────────────────────────────────────────────────────────── st.markdown("
Downloads
", unsafe_allow_html=True) col_d1, col_d2, col_d3 = st.columns(3) with col_d1: try: with open(results["json_path"], "r") as f: st.download_button( "⬇ topics.json", f.read(), file_name="tmis_topics.json", mime="application/json", use_container_width=True, ) except Exception: st.warning("JSON file not found.") with col_d2: try: df_dl = pd.read_csv(results["csv_path"]) st.download_button( "⬇ topics.csv", df_dl.to_csv(index=False), file_name="tmis_topics.csv", mime="text/csv", use_container_width=True, ) except Exception: st.warning("CSV file not found.") with col_d3: st.download_button( "⬇ results table", df_res.to_csv(index=False), file_name="tmis_topic_results.csv", mime="text/csv", use_container_width=True, ) # ── Method note for paper ───────────────────────────────────────────────── st.markdown("
", unsafe_allow_html=True) with st.expander("§3.4 methodology note — paste into paper", expanded=False): st.code(f"""Pipeline A (Unsupervised Discovery): SPECTER2 (allenai/specter2_base) generates one 768-dimensional document embedding per paper from a combined Title + Abstract column. UMAP (n_neighbors=15, n_components=5, metric=cosine) reduces dimensionality; HDBSCAN (min_cluster_size={min_topic_size}, metric=euclidean, cluster_selection=eom) clusters embeddings. Cosine similarity threshold 0.50–0.55 governs cluster merging and outlier reassignment. Total clusters constrained to 15–30 via iterative split/merge. Pipeline B (LLM Council Validation): For each cluster, the 3 papers nearest the centroid (by cosine similarity) are passed as representative titles to 3 independent LLMs: Groq/LLaMA-3.1-8b, Mistral-Small-Latest, and Gemini-2.5-Flash. Each LLM returns a structured JSON with label, taxonomy_category, and classification (MAPPED/NOVEL). Majority vote selects the final label; keyword-overlap fallback applies when no consensus. This is the 3-LLM Council approach validating AI output without using the same model for self-validation (per Carlsen & Ralund, 2022 CALM principle). Results: {stats['n_topics']} clusters discovered. {novel_pct}% classified as NOVEL (no PAJAIS 2019 home). {mapped_pct}% MAPPED to existing PAJAIS categories.""", language="text") # ── Empty state ─────────────────────────────────────────────────────────────── elif not results: st.markdown("""

UPLOAD CSV → ENTER API KEYS → RUN PIPELINE

SPECTER2 embeddings · HDBSCAN · 3-LLM council · PAJAIS validation

""", unsafe_allow_html=True)