""" BERTopic Topic Modelling Agent — Gradio Interface """ import os os.environ.pop("GRADIO_HOT_RELOAD", None) import gradio as gr import pandas as pd import json from pathlib import Path # ── Try to import the real agent; fall back to a stub so the UI still loads ── try: from agent import BERTopicAgent except ImportError: class BERTopicAgent: """Stub agent used when agent.py is not present.""" def __init__(self): self.phase = "idle" self._charts: dict[str, str] = {} self._downloads: list[str] = [] def handle_message(self, message: str, history: list, csv_path: str | None = None): yield ( history + [[message, "⚠️ `agent.py` not found — running in UI-preview mode."]], self.phase, self._charts, self._downloads, [], ) def handle_review(self, table_data: list, history: list): yield ( history + [["[Review submitted]", "Review received. No real agent attached."]], self.phase, self._charts, self._downloads, ) # ────────────────────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────────────────────── PHASES = ["idle", "loading", "embedding", "clustering", "labelling", "review", "done"] PHASE_LABELS = { "idle": "Idle", "loading": "Loading data", "embedding": "Embedding documents", "clustering": "Clustering topics", "labelling": "Labelling topics", "review": "Awaiting review", "done": "Complete", } TABLE_COLS = ["#", "Topic Label", "Top Evidence", "Sentences", "Papers", "Approve", "Rename To", "Reasoning"] EMPTY_TABLE = pd.DataFrame(columns=TABLE_COLS) def phase_html(phase: str) -> str: """Render a step-progress bar for the current pipeline phase.""" steps = [p for p in PHASES if p != "idle"] try: current_idx = steps.index(phase) if phase in steps else -1 except ValueError: current_idx = -1 dots = [] for i, step in enumerate(steps): if i < current_idx: cls, icon = "step done", "✓" elif i == current_idx: cls, icon = "step active", "●" else: cls, icon = "step", "○" dots.append( f'
No chart available yet.
" return ( f'' ) # ────────────────────────────────────────────────────────────────────────────── # CSS # ────────────────────────────────────────────────────────────────────────────── CUSTOM_CSS = """ /* ── Google Fonts ── */ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&family=Lato:wght@300;400;700&display=swap'); /* ── Root palette ── */ :root { --bg: #0d0f14; --surface: #151820; --border: #252a38; --accent: #4f8ef7; --accent2: #a259ff; --success: #2dce89; --warn: #f7c94f; --danger: #f76f6f; --text: #e8ecf4; --muted: #7a8299; --radius: 10px; --mono: 'JetBrains Mono', monospace; --sans: 'Lato', sans-serif; --display: 'Syne', sans-serif; } /* ── Global resets ── */ body, .gradio-container { background: var(--bg) !important; color: var(--text) !important; font-family: var(--sans) !important; } /* ── App header ── */ .app-header { padding: 2rem 0 1.2rem; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; } .app-header h1 { font-family: var(--display); font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin: 0 0 .25rem; } .app-header p { color: var(--muted); font-size: .9rem; margin: 0; } /* ── Section cards ── */ .section-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; margin-bottom: 1.2rem; } .section-title { font-family: var(--display); font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--accent); margin: 0 0 1rem; } /* ── Phase progress bar ── */ .phase-wrap { padding: .5rem 0 .8rem; } .phase-steps { display: flex; gap: 0; margin-bottom: .6rem; flex-wrap: wrap; } .step { display: flex; flex-direction: column; align-items: center; flex: 1; min-width: 70px; position: relative; opacity: .38; } .step::after { content: ''; position: absolute; top: 10px; left: 50%; width: 100%; height: 2px; background: var(--border); z-index: 0; } .step:last-child::after { display: none; } .step .icon { font-size: .85rem; z-index: 1; background: var(--surface); padding: 0 4px; color: var(--muted); } .step .label { font-size: .62rem; font-family: var(--mono); color: var(--muted); margin-top: 4px; text-align: center; } .step.done { opacity: .7; } .step.done .icon { color: var(--success); } .step.active { opacity: 1; } .step.active .icon { color: var(--accent); animation: pulse 1.2s ease-in-out infinite; } .step.active .label { color: var(--accent); font-weight: 600; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} } .phase-bar-bg { height: 4px; background: var(--border); border-radius: 4px; overflow: hidden; } .phase-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 4px; transition: width .5s ease; } /* ── Chatbot ── */ .chatbot-wrap .message.user { background: #1a2035 !important; } .chatbot-wrap .message.bot { background: #151c2e !important; } /* ── Buttons ── */ .btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; color: #fff !important; border: none !important; font-family: var(--display) !important; font-weight: 700 !important; letter-spacing: .04em !important; border-radius: var(--radius) !important; padding: .55rem 1.4rem !important; transition: opacity .2s, transform .1s !important; } .btn-primary:hover { opacity: .88 !important; transform: translateY(-1px) !important; } .btn-primary:active { transform: translateY(0) !important; } .btn-secondary { background: var(--surface) !important; border: 1px solid var(--border) !important; color: var(--text) !important; font-family: var(--display) !important; font-weight: 600 !important; border-radius: var(--radius) !important; } .btn-secondary:hover { border-color: var(--accent) !important; color: var(--accent) !important; } /* ── Tabs ── */ .tab-nav button { font-family: var(--display) !important; font-weight: 600 !important; font-size: .82rem !important; letter-spacing: .06em !important; text-transform: uppercase !important; color: var(--muted) !important; border-bottom: 2px solid transparent !important; padding: .5rem 1rem !important; background: transparent !important; } .tab-nav button.selected { color: var(--accent) !important; border-bottom-color: var(--accent) !important; } /* ── Dataframe ── */ .dataframe-wrap table { font-family: var(--mono) !important; font-size: .78rem !important; } .dataframe-wrap th { background: #1a1f2e !important; color: var(--accent) !important; font-family: var(--display) !important; font-size: .7rem !important; letter-spacing: .08em !important; text-transform: uppercase !important; } .dataframe-wrap td { border-color: var(--border) !important; } /* ── File upload ── */ .file-drop { border: 2px dashed var(--border) !important; border-radius: var(--radius) !important; background: var(--surface) !important; color: var(--muted) !important; transition: border-color .2s, background .2s !important; } .file-drop:hover { border-color: var(--accent) !important; background: #151c2e !important; } /* ── Textbox ── */ textarea, input[type=text] { background: var(--surface) !important; border: 1px solid var(--border) !important; color: var(--text) !important; font-family: var(--sans) !important; border-radius: var(--radius) !important; } textarea:focus, input[type=text]:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(79,142,247,.15) !important; } /* ── Dropdown ── */ select, .gr-dropdown { background: var(--surface) !important; border: 1px solid var(--border) !important; color: var(--text) !important; border-radius: var(--radius) !important; } /* ── Misc ── */ label { color: var(--muted) !important; font-size: .8rem !important; font-family: var(--mono) !important; } """ # ────────────────────────────────────────────────────────────────────────────── # Build UI # ────────────────────────────────────────────────────────────────────────────── def build_app(): agent = BERTopicAgent() with gr.Blocks(title="BERTopic Agent") as demo: # ── State ────────────────────────────────────────────────────────── state_csv_path = gr.State(None) # path to uploaded CSV state_charts = gr.State({}) # {label: iframe_src} state_downloads = gr.State([]) # list of file paths # ── Header ──────────────────────────────────────────────────────── gr.HTML("""Conversational topic modelling powered by BERTopic + LLM review
① Data Input
') with gr.Row(): csv_upload = gr.File( label="Upload CSV (one document per row)", file_types=[".csv"], elem_classes=["file-drop"], ) with gr.Column(scale=2): gr.Markdown( "**Expected format:** CSV with at least a `text` column. " "Optional columns: `id`, `title`, `source`.\n\n" "After uploading, tell the agent which column holds your documents.", elem_id="upload-hint", ) gr.HTML('② Agent Conversation
') chatbot = gr.Chatbot( label="", height=420, show_label=False, type="tuples", elem_classes=["chatbot-wrap"], avatar_images=( None, # user — use default "https://em-content.zobj.net/source/apple/391/robot_1f916.png", ), ) with gr.Row(): user_input = gr.Textbox( placeholder="Ask the agent to run topic modelling, explain results, adjust parameters…", lines=1, show_label=False, scale=8, ) send_btn = gr.Button("Send ⟶", elem_classes=["btn-primary"], scale=1) gr.HTML('③ Results
') with gr.Tabs(elem_classes=["tab-nav"]): # ── Tab A: Review Table ────────────────────────────────────── with gr.Tab("📋 Review Table"): review_table = gr.Dataframe( value=EMPTY_TABLE, headers=TABLE_COLS, datatype=["number", "str", "str", "number", "number", "bool", "str", "str"], interactive=True, wrap=True, elem_classes=["dataframe-wrap"], label="Topic review — edit Approve / Rename To / Reasoning, then click Submit", ) submit_review_btn = gr.Button( "Submit Review ✓", elem_classes=["btn-primary"], size="sm", ) # ── Tab B: Charts ──────────────────────────────────────────── with gr.Tab("📊 Charts"): chart_selector = gr.Dropdown( label="Select chart", choices=[], interactive=True, ) chart_display = gr.HTML( value="Charts will appear here after topic modelling completes.
" ) # ── Tab C: Download ────────────────────────────────────────── with gr.Tab("⬇ Download"): download_files = gr.File( label="Output files", file_count="multiple", interactive=False, ) gr.HTML('