""" BERTopic Topic Modelling Agent — Gradio Interface """ import os os.environ.pop("GRADIO_HOT_RELOAD", None) import gradio as gr # ── Patch for Starlette 1.0.0 TemplateResponse signature change ────────────── import starlette.templating _original_template_response = starlette.templating.Jinja2Templates.TemplateResponse def _patched_template_response(self, *args, **kwargs): # If Gradio calls it the old positional way: TemplateResponse("name.html", {"request": ...}) if len(args) >= 2 and isinstance(args[0], str) and isinstance(args[1], dict): name, context = args[0], args[1] request = context.get("request") # Route it to the new signature explicitly return _original_template_response(self, request=request, name=name, context=context, **kwargs) # Otherwise, pass through normally return _original_template_response(self, *args, **kwargs) starlette.templating.Jinja2Templates.TemplateResponse = _patched_template_response # ── Patch the gradio_client JSON-schema parser crash safely ────────────────── import gradio_client.utils as gc_utils import copy _original_parser = gc_utils._json_schema_to_python_type # Updated to accept *args and **kwargs to handle newer Gradio version signatures safely def _patched_parser(schema, *args, **kwargs): try: # Deepcopy to avoid mutating the original schema object safe_schema = copy.deepcopy(schema) # Recursively hunt down and replace boolean additionalProperties def fix_booleans(obj): if isinstance(obj, dict): if "additionalProperties" in obj and isinstance(obj["additionalProperties"], bool): obj["additionalProperties"] = {} for v in obj.values(): fix_booleans(v) elif isinstance(obj, list): for item in obj: fix_booleans(item) fix_booleans(safe_schema) # Pass the safe schema along with any other arguments (like `defs`) return _original_parser(safe_schema, *args, **kwargs) except Exception: # Fallback to the original schema if our scrubber fails return _original_parser(schema, *args, **kwargs) gc_utils._json_schema_to_python_type = _patched_parser 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'
{icon}' f'{PHASE_LABELS[step]}
' ) bar_pct = 0 if current_idx < 0 else int((current_idx + 1) / len(steps) * 100) return f"""
{''.join(dots)}
""" def make_chart_html(iframe_src: str) -> str: if not iframe_src: return "

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; } /* ── Review Table (custom HTML — replaces gr.Dataframe) ── */ #review-table-wrap { overflow-x: auto; width: 100%; border-radius: var(--radius); border: 1px solid var(--border); } .rt-table { width: 100%; min-width: 960px; border-collapse: collapse; font-family: var(--mono); font-size: .78rem; } .rt-table th { background: #1a1f2e; color: var(--accent); font-family: var(--display); font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; padding: .65rem .8rem; text-align: left; border-bottom: 2px solid var(--border); white-space: nowrap; position: sticky; top: 0; z-index: 2; } .rt-table td { padding: .55rem .8rem; border-bottom: 1px solid var(--border); vertical-align: top; color: var(--text); } .rt-table tr:nth-child(even) td { background: rgba(255,255,255,.02); } .rt-table tr:hover td { background: rgba(79,142,247,.06); transition: background .15s; } .rt-col-num { width: 45px; text-align: center; color: var(--muted); } .rt-col-label { min-width: 160px; font-weight: 600; } .rt-col-evid { min-width: 240px; line-height: 1.6; color: var(--muted); } .rt-col-sent { width: 80px; text-align: center; } .rt-col-paper { width: 70px; text-align: center; } .rt-col-appr { width: 72px; text-align: center; } .rt-col-rename { min-width: 140px; } .rt-col-reason { min-width: 200px; } .rt-check { width: 18px; height: 18px; cursor: pointer; accent-color: var(--success); } .rt-input, .rt-textarea { width: 100%; box-sizing: border-box; padding: .32rem .55rem; background: #0d0f14; border: 1px solid var(--border); color: var(--text); border-radius: 5px; font-family: var(--sans); font-size: .78rem; transition: border-color .15s; } .rt-input:focus, .rt-textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px rgba(79,142,247,.18); } .rt-textarea { resize: vertical; min-height: 46px; } .rt-empty { color: var(--muted); padding: 1.5rem; font-style: italic; text-align: center; } /* ── 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", theme=gr.themes.Base( primary_hue="blue", neutral_hue="slate", font=[gr.themes.GoogleFont("Lato"), "sans-serif"], ), css=CUSTOM_CSS ) 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("""

⬡ BERTopic Agent

Conversational topic modelling powered by BERTopic + LLM review

""") # ── Phase progress ───────────────────────────────────────────────── phase_display = gr.HTML(value=phase_html("idle"), elem_id="phase-display") # ══════════════════════════════════════════════════════════════════ # SECTION 1 — Data Input # ══════════════════════════════════════════════════════════════════ gr.HTML('

① 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('
') # ══════════════════════════════════════════════════════════════════ # SECTION 2 — Agent Conversation # ══════════════════════════════════════════════════════════════════ 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('
') # ══════════════════════════════════════════════════════════════════ # SECTION 3 — Results # ══════════════════════════════════════════════════════════════════ gr.HTML('

③ Results

') with gr.Tabs(elem_classes=["tab-nav"]): # ── Tab A: Review Table ────────────────────────────────────── with gr.Tab("📋 Review Table"): gr.HTML( "

" "Check ✓ Approve, optionally fill Rename To / Reasoning, then click Submit.

" ) review_table_html = gr.HTML( value="

" "No topics yet — run topic modelling first.

", label="", ) # Hidden textbox holds JSON-serialised table rows, kept in sync by JS review_json_state = gr.Textbox( value="[]", visible=False, elem_id="review-json-state", ) 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('
') # ══════════════════════════════════════════════════════════════════ # Event handlers # ══════════════════════════════════════════════════════════════════ # ── Store CSV path when file is uploaded ───────────────────────── def on_csv_upload(file): if file is None: return None return file.name csv_upload.change( fn=on_csv_upload, inputs=[csv_upload], outputs=[state_csv_path], ) # ── Send message to agent ───────────────────────────────────────── def on_send(message, history, csv_path, charts_state, downloads_state): """Stream agent responses and update all UI elements.""" if not message.strip(): yield history, "", phase_html("idle"), gr.update(), gr.update(), None, charts_state, downloads_state return # Accumulate streamed updates last_history = history last_phase = "idle" last_charts = charts_state last_downloads = downloads_state new_topics = [] for result in agent.handle_message(message, history, csv_path=csv_path): # Agent yields: (history, phase, charts_dict, downloads_list, topic_rows) (last_history, last_phase, last_charts, last_downloads, new_topics) = result _html, _json = _rows_to_html(new_topics) if new_topics else (gr.update(), gr.update()) yield ( last_history, "", # clear input phase_html(last_phase), _html, _json, gr.update(), # downloads updated below last_charts, last_downloads, ) # Final yield with downloads _html, _json = _rows_to_html(new_topics) if new_topics else (gr.update(), gr.update()) yield ( last_history, "", phase_html(last_phase), _html, _json, last_downloads if last_downloads else None, last_charts, last_downloads, ) def _rows_to_html(rows): """Convert agent row data → (html_str, json_str). Accepts list-of-lists, list-of-dicts, list-of-tuples, or pd.DataFrame. """ if rows is None: rows = [] # ── Normalise to list-of-lists in TABLE_COLS order ────────────── # Alias map: agent may use slightly different key names _ALIASES = { "top evidence (truncated)": "Top Evidence", "top evidence": "Top Evidence", "topic label": "Topic Label", "rename to": "Rename To", "approve": "Approve", "reasoning": "Reasoning", "sentences": "Sentences", "papers": "Papers", "#": "#", } if isinstance(rows, pd.DataFrame): # Normalise column names via alias map rows.columns = [_ALIASES.get(c.lower(), c) for c in rows.columns] for col in TABLE_COLS: if col not in rows.columns: rows[col] = "" padded = rows[TABLE_COLS].fillna("").values.tolist() elif rows and isinstance(rows[0], dict): # Remap keys through alias table then extract in TABLE_COLS order normalised = [] for r in rows: remapped = {_ALIASES.get(k.lower(), k): v for k, v in r.items()} normalised.append([remapped.get(col, "") for col in TABLE_COLS]) padded = normalised else: # List of lists / tuples — pad / truncate to exactly 8 columns padded = [] for r in rows: row = list(r) + [""] * max(0, 8 - len(list(r))) padded.append(row[:8]) if not padded: html = "

No topics yet — run topic modelling first.

" return html, "[]" # ── Serialise to JSON (drives JS state) ───────────────────────── # Convert non-serialisable types (numpy bools, NaN, etc.) safely def _safe(v): if v is None or (isinstance(v, float) and v != v): # NaN check return "" try: import numpy as np if isinstance(v, (np.bool_, np.integer, np.floating)): return v.item() except ImportError: pass return v safe_padded = [[_safe(c) for c in row] for row in padded] json_data = json.dumps(safe_padded, ensure_ascii=False) # ── Build HTML rows ────────────────────────────────────────────── def _esc(v, attr=False): s = str(v) if v is not None else "" s = s.replace("&", "&").replace("<", "<").replace(">", ">") if attr: s = s.replace('"', """).replace("'", "'") return s rows_html = "" for i, row in enumerate(safe_padded): num, label, evidence, sentences, papers, approve, rename, reasoning = row checked = "checked" if str(approve).lower() in ("true", "1", "yes", "✓") else "" rows_html += f""" {_esc(num)} {_esc(label)} {_esc(evidence)} {_esc(sentences)} {_esc(papers)} """ # ── Assemble full HTML block ───────────────────────────────────── # Use a unique suffix so re-renders don't collide with stale globals uid = id(safe_padded) html = f"""
{rows_html}
# Topic Label Top Evidence Sentences Papers Approve Rename To Reasoning
""" return html, json_data send_outputs = [ chatbot, user_input, phase_display, review_table_html, review_json_state, download_files, state_charts, state_downloads, ] send_btn.click( fn=on_send, inputs=[user_input, chatbot, state_csv_path, state_charts, state_downloads], outputs=send_outputs, ) user_input.submit( fn=on_send, inputs=[user_input, chatbot, state_csv_path, state_charts, state_downloads], outputs=send_outputs, ) # ── Submit review ───────────────────────────────────────────────── def on_submit_review(json_str, history, charts_state, downloads_state): """Pass edited table back to the agent as a review submission.""" try: rows = json.loads(json_str) if json_str and json_str.strip() not in ("", "[]") else [] except Exception: rows = [] last_history = history last_phase = "review" last_charts = charts_state last_downloads = downloads_state for result in agent.handle_review(rows, history): (last_history, last_phase, last_charts, last_downloads) = result yield ( last_history, phase_html(last_phase), last_downloads if last_downloads else None, last_charts, last_downloads, ) yield ( last_history, phase_html(last_phase), last_downloads if last_downloads else None, last_charts, last_downloads, ) submit_review_btn.click( fn=on_submit_review, inputs=[review_json_state, chatbot, state_charts, state_downloads], outputs=[chatbot, phase_display, download_files, state_charts, state_downloads], ) # ── Chart selector ──────────────────────────────────────────────── def on_chart_change(chart_name, charts_state): src = (charts_state or {}).get(chart_name, "") return make_chart_html(src) chart_selector.change( fn=on_chart_change, inputs=[chart_selector, state_charts], outputs=[chart_display], ) # ── Update chart dropdown when charts_state changes ────────────── def on_charts_state_change(charts_state): choices = list((charts_state or {}).keys()) if choices: return gr.update(choices=choices, value=choices[0]) return gr.update(choices=[], value=None) state_charts.change( fn=on_charts_state_change, inputs=[state_charts], outputs=[chart_selector], ) return demo # ────────────────────────────────────────────────────────────────────────────── # Entry point # ────────────────────────────────────────────────────────────────────────────── app = build_app() app.launch( server_name="0.0.0.0", server_port=7860, share=False, show_error=True )