""" 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'
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("""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"): 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('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"""| # | Topic Label | Top Evidence | Sentences | Papers | Approve | Rename To | Reasoning |
|---|