Spaces:
Runtime error
Runtime error
| """ | |
| app.py β BERTopic Agentic Thematic Analysis β Gradio UI | |
| THEMIS: Thematic Engine for Mining and Identifying Scholarly Topics | |
| Implements Braun & Clarke (2006) 6-Phase Framework | |
| """ | |
| import gradio as gr | |
| import json | |
| import os | |
| import pandas as pd | |
| from agent import invoke_agent, reset_agent | |
| CHECKPOINT_DIR = "checkpoints" | |
| os.makedirs(CHECKPOINT_DIR, exist_ok=True) | |
| # ββ Phase Checkpoint Detection βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_phase_status(): | |
| files = { | |
| "summaries.json": False, | |
| "labels.json": False, | |
| "themes.json": False, | |
| "taxonomy_map.json": False, | |
| "comparison.csv": False, | |
| "narrative.txt": False, | |
| } | |
| for fname in files: | |
| files[fname] = os.path.exists(os.path.join(CHECKPOINT_DIR, fname)) | |
| return files | |
| def render_phase_bar(): | |
| status = get_phase_status() | |
| phases = [ | |
| ("β ", "Load", status["summaries.json"]), | |
| ("β‘", "Codes", status["labels.json"]), | |
| ("β’", "Themes", status["themes.json"]), | |
| ("β£β€", "Review & Name", status["themes.json"]), | |
| ("β€Β½", "PAJAIS Map", status["taxonomy_map.json"]), | |
| ("β₯", "Report", status["comparison.csv"] and status["narrative.txt"]), | |
| ] | |
| items = "" | |
| for num, name, done in phases: | |
| icon = "β " if done else "β¬" | |
| cls = "phase-done" if done else "phase-pending" | |
| items += f'<div class="phase-item {cls}"><span class="phase-num">{num}</span><span class="phase-name">{icon} {name}</span></div>' | |
| return f'<div class="phase-bar">{items}</div>' | |
| # ββ Review Table Loader ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_review_table(): | |
| """Load highest-priority checkpoint for the review table.""" | |
| checkpoint_priority = [ | |
| ("taxonomy_map.json", "themes"), | |
| ("themes.json", "themes"), | |
| ("labels.json", "topics"), | |
| ("summaries.json", "topics"), | |
| ] | |
| for fname, key in checkpoint_priority: | |
| fpath = os.path.join(CHECKPOINT_DIR, fname) | |
| if os.path.exists(fpath): | |
| with open(fpath) as f: | |
| data = json.load(f) | |
| items = data.get(key, []) | |
| if not items: | |
| continue | |
| rows = [] | |
| for item in items: | |
| is_taxonomy = fname == "taxonomy_map.json" | |
| top_evidence = item.get("top_sentences", [""])[0] if item.get("top_sentences") else "" | |
| rows.append({ | |
| "ID": item.get("topic_id", item.get("theme_name", "")), | |
| "Label / Theme": item.get("label") or item.get("theme_name", ""), | |
| "Category": item.get("category") or item.get("pajais_match", ""), | |
| "Sentences": item.get("count") or item.get("sentence_count", 0), | |
| "Confidence": round(float(item.get("confidence") or item.get("match_confidence", 0)), 2), | |
| "Top Evidence": top_evidence[:120] + "..." if len(top_evidence) > 120 else top_evidence, | |
| "Approve": item.get("approve", ""), | |
| "Rename To": item.get("rename_to", ""), | |
| "Reasoning": item.get("user_reasoning", ""), | |
| }) | |
| return pd.DataFrame(rows) | |
| return pd.DataFrame(columns=["ID", "Label / Theme", "Category", "Sentences", | |
| "Confidence", "Top Evidence", "Approve", "Rename To", "Reasoning"]) | |
| # ββ Charts Loader ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_chart_options(): | |
| options = [] | |
| for run_key in ["abstract", "title"]: | |
| fpath = os.path.join(CHECKPOINT_DIR, f"{run_key}_charts.json") | |
| if os.path.exists(fpath): | |
| options.extend([ | |
| f"{run_key.title()} β Intertopic Map", | |
| f"{run_key.title()} β Topic Sizes", | |
| f"{run_key.title()} β Similarity Heatmap", | |
| f"{run_key.title()} β Size Distribution", | |
| ]) | |
| return options if options else ["No charts yet β run analysis first"] | |
| def load_chart(selection: str) -> str: | |
| if not selection or "No charts" in selection: | |
| return "<p style='color:#888; text-align:center; padding:40px'>Run analysis to generate charts</p>" | |
| parts = selection.lower().split(" β ") | |
| if len(parts) < 2: | |
| return "" | |
| run_key = parts[0].strip() | |
| chart_key_map = { | |
| "intertopic map": "intertopic", | |
| "topic sizes": "bars", | |
| "similarity heatmap": "heatmap", | |
| "size distribution": "distribution", | |
| } | |
| chart_key = chart_key_map.get(parts[1].strip(), "intertopic") | |
| fpath = os.path.join(CHECKPOINT_DIR, f"{run_key}_charts.json") | |
| if not os.path.exists(fpath): | |
| return f"<p style='color:#888'>No charts for {run_key} run yet.</p>" | |
| with open(fpath) as f: | |
| charts = json.load(f) | |
| html = charts.get(chart_key, "<p>Chart not found</p>") | |
| return f'<div style="width:100%;overflow:auto">{html}</div>' | |
| # ββ Download Links βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_download_links(): | |
| downloads = [ | |
| ("summaries.json", "β Load Summary", "phase-1"), | |
| ("labels.json", "β‘ Topic Labels", "phase-2"), | |
| ("themes.json", "β’ Consolidated Themes", "phase-3"), | |
| ("taxonomy_map.json", "β€Β½ PAJAIS Taxonomy Map", "phase-5"), | |
| ("comparison.csv", "β₯ Abstract vs Title Comparison", "phase-6"), | |
| ("narrative.txt", "β₯ Section 7 Narrative", "phase-6"), | |
| ] | |
| html = '<div class="download-grid">' | |
| for fname, label, phase_cls in downloads: | |
| fpath = os.path.join(CHECKPOINT_DIR, fname) | |
| if os.path.exists(fpath): | |
| size = os.path.getsize(fpath) | |
| size_str = f"{size/1024:.1f} KB" if size > 1024 else f"{size} B" | |
| html += f''' | |
| <div class="download-card available"> | |
| <div class="dl-icon">π</div> | |
| <div class="dl-info"> | |
| <div class="dl-label">{label}</div> | |
| <div class="dl-meta">{fname} Β· {size_str}</div> | |
| </div> | |
| <a href="/file={fpath}" class="dl-btn" download="{fname}">β Download</a> | |
| </div>''' | |
| else: | |
| html += f''' | |
| <div class="download-card pending"> | |
| <div class="dl-icon">β³</div> | |
| <div class="dl-info"> | |
| <div class="dl-label">{label}</div> | |
| <div class="dl-meta">{fname} Β· not yet generated</div> | |
| </div> | |
| <span class="dl-btn-disabled">Pending</span> | |
| </div>''' | |
| html += '</div>' | |
| return html | |
| # ββ Submit Review Handler ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def submit_review(table_data: pd.DataFrame, chat_history: list): | |
| """Convert table decisions to agent message and send.""" | |
| if table_data is None or len(table_data) == 0: | |
| return chat_history, chat_history | |
| # Serialize table decisions | |
| decisions = [] | |
| for _, row in table_data.iterrows(): | |
| approve = str(row.get("Approve", "")).strip().upper() | |
| if approve: | |
| decisions.append( | |
| f"- {row.get('Label / Theme', row.get('ID', ''))}: " | |
| f"Approve={approve}, Rename To={row.get('Rename To', '')}, " | |
| f"Reasoning={row.get('Reasoning', '')}" | |
| ) | |
| if not decisions: | |
| msg = "Researcher submitted review table with no changes β all topics accepted as-is." | |
| else: | |
| msg = "Researcher submitted review table:\n" + "\n".join(decisions) | |
| # Send to agent | |
| response = invoke_agent(msg, chat_history) | |
| chat_history = chat_history + [["π [Review Table Submitted]", response]] | |
| return chat_history, chat_history | |
| # ββ CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&family=Inter:wght@300;400;500&display=swap'); | |
| :root { | |
| --bg-deep: #09090f; | |
| --bg-panel: #0f0f1a; | |
| --bg-card: #141422; | |
| --bg-hover: #1a1a2e; | |
| --border: rgba(108, 82, 255, 0.18); | |
| --border-glow: rgba(108, 82, 255, 0.45); | |
| --accent: #6c52ff; | |
| --accent-2: #c084fc; | |
| --accent-3: #38bdf8; | |
| --accent-ok: #4ade80; | |
| --accent-warn: #facc15; | |
| --text-primary: #f0eeff; | |
| --text-secondary: #9b8fd4; | |
| --text-muted: #5a5380; | |
| --font-display: 'Syne', sans-serif; | |
| --font-body: 'Inter', sans-serif; | |
| --font-mono: 'JetBrains Mono', monospace; | |
| --radius: 12px; | |
| --glow: 0 0 40px rgba(108, 82, 255, 0.15); | |
| --glow-lg: 0 0 80px rgba(108, 82, 255, 0.2); | |
| } | |
| /* ββ Base ββ */ | |
| *, *::before, *::after { box-sizing: border-box; } | |
| body, .gradio-container { | |
| background: var(--bg-deep) !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-body) !important; | |
| } | |
| .gradio-container { | |
| max-width: 1400px !important; | |
| margin: 0 auto !important; | |
| padding: 0 !important; | |
| } | |
| /* ββ App Header ββ */ | |
| .app-header { | |
| background: linear-gradient(135deg, #0d0d1f 0%, #110f2a 50%, #0d0d1f 100%); | |
| border-bottom: 1px solid var(--border); | |
| padding: 28px 40px 22px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .app-header::before { | |
| content: ''; | |
| position: absolute; | |
| top: -60px; right: -60px; | |
| width: 300px; height: 300px; | |
| background: radial-gradient(circle, rgba(108,82,255,0.12) 0%, transparent 70%); | |
| pointer-events: none; | |
| } | |
| .app-header::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -40px; left: 10%; | |
| width: 200px; height: 200px; | |
| background: radial-gradient(circle, rgba(192,132,252,0.08) 0%, transparent 70%); | |
| pointer-events: none; | |
| } | |
| .header-logo { | |
| font-family: var(--font-display); | |
| font-size: 32px; | |
| font-weight: 800; | |
| letter-spacing: -1px; | |
| background: linear-gradient(135deg, #a78bfa, #6c52ff, #38bdf8); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| line-height: 1; | |
| margin-bottom: 4px; | |
| } | |
| .header-subtitle { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| letter-spacing: 2px; | |
| text-transform: uppercase; | |
| } | |
| .header-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: rgba(108,82,255,0.12); | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 4px 12px; | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| color: var(--accent-2); | |
| margin-top: 8px; | |
| } | |
| /* ββ Phase Progress Bar ββ */ | |
| .phase-bar { | |
| display: flex; | |
| gap: 0; | |
| background: var(--bg-panel); | |
| border-bottom: 1px solid var(--border); | |
| padding: 0; | |
| overflow-x: auto; | |
| } | |
| .phase-item { | |
| flex: 1; | |
| min-width: 100px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 10px 8px; | |
| border-right: 1px solid var(--border); | |
| transition: background 0.2s; | |
| position: relative; | |
| } | |
| .phase-item:last-child { border-right: none; } | |
| .phase-num { | |
| font-family: var(--font-display); | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| line-height: 1; | |
| } | |
| .phase-name { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| margin-top: 3px; | |
| text-align: center; | |
| letter-spacing: 0.5px; | |
| } | |
| .phase-done { | |
| background: rgba(108,82,255,0.08); | |
| } | |
| .phase-done .phase-num { color: var(--accent-2); } | |
| .phase-done .phase-name { color: var(--accent-ok); } | |
| .phase-done::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: 0; left: 0; right: 0; | |
| height: 2px; | |
| background: linear-gradient(90deg, var(--accent), var(--accent-2)); | |
| } | |
| /* ββ Section containers ββ */ | |
| .section-block { | |
| background: var(--bg-panel); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| margin: 16px; | |
| overflow: hidden; | |
| box-shadow: var(--glow); | |
| } | |
| .section-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 14px 20px; | |
| background: linear-gradient(90deg, rgba(108,82,255,0.08), transparent); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .section-num { | |
| width: 28px; height: 28px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent-2)); | |
| border-radius: 8px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-family: var(--font-display); | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: white; | |
| flex-shrink: 0; | |
| } | |
| .section-title { | |
| font-family: var(--font-display); | |
| font-size: 15px; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| letter-spacing: 0.3px; | |
| } | |
| .section-desc { | |
| font-family: var(--font-body); | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| margin-left: auto; | |
| } | |
| /* ββ File upload ββ */ | |
| .upload-zone { | |
| border: 2px dashed var(--border) !important; | |
| border-radius: var(--radius) !important; | |
| background: rgba(108,82,255,0.03) !important; | |
| transition: all 0.3s !important; | |
| margin: 16px !important; | |
| } | |
| .upload-zone:hover { | |
| border-color: var(--border-glow) !important; | |
| background: rgba(108,82,255,0.07) !important; | |
| } | |
| /* ββ Chat ββ */ | |
| .chat-wrap { padding: 0 16px 16px; } | |
| .gradio-chatbot { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: var(--radius) !important; | |
| font-family: var(--font-body) !important; | |
| font-size: 14px !important; | |
| min-height: 380px !important; | |
| } | |
| .gradio-chatbot .message.user { | |
| background: linear-gradient(135deg, rgba(108,82,255,0.25), rgba(192,132,252,0.15)) !important; | |
| border: 1px solid rgba(108,82,255,0.3) !important; | |
| border-radius: 12px 12px 2px 12px !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-body) !important; | |
| } | |
| .gradio-chatbot .message.bot { | |
| background: var(--bg-hover) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 12px 12px 12px 2px !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-body) !important; | |
| } | |
| /* Textbox */ | |
| .msg-input textarea { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-body) !important; | |
| font-size: 14px !important; | |
| padding: 12px 16px !important; | |
| transition: border-color 0.2s !important; | |
| } | |
| .msg-input textarea:focus { | |
| border-color: var(--border-glow) !important; | |
| box-shadow: 0 0 0 3px rgba(108,82,255,0.1) !important; | |
| outline: none !important; | |
| } | |
| .msg-input textarea::placeholder { color: var(--text-muted) !important; } | |
| /* ββ Buttons ββ */ | |
| .btn-send, .btn-review, .btn-reset { | |
| border-radius: 10px !important; | |
| font-family: var(--font-display) !important; | |
| font-weight: 600 !important; | |
| font-size: 14px !important; | |
| letter-spacing: 0.3px !important; | |
| transition: all 0.2s !important; | |
| border: none !important; | |
| cursor: pointer !important; | |
| } | |
| .btn-send { | |
| background: linear-gradient(135deg, var(--accent), var(--accent-2)) !important; | |
| color: white !important; | |
| padding: 12px 24px !important; | |
| box-shadow: 0 4px 20px rgba(108,82,255,0.35) !important; | |
| } | |
| .btn-send:hover { | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0 6px 28px rgba(108,82,255,0.5) !important; | |
| } | |
| .btn-review { | |
| background: linear-gradient(135deg, rgba(74,222,128,0.15), rgba(74,222,128,0.08)) !important; | |
| color: var(--accent-ok) !important; | |
| border: 1px solid rgba(74,222,128,0.3) !important; | |
| padding: 12px 24px !important; | |
| } | |
| .btn-review:hover { | |
| background: rgba(74,222,128,0.2) !important; | |
| border-color: rgba(74,222,128,0.5) !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| .btn-reset { | |
| background: transparent !important; | |
| color: var(--text-muted) !important; | |
| border: 1px solid var(--border) !important; | |
| padding: 10px 18px !important; | |
| font-size: 12px !important; | |
| } | |
| .btn-reset:hover { | |
| color: var(--text-secondary) !important; | |
| border-color: var(--border-glow) !important; | |
| } | |
| /* ββ Tabs ββ */ | |
| .gradio-tabs .tab-nav { | |
| background: var(--bg-panel) !important; | |
| border-bottom: 1px solid var(--border) !important; | |
| padding: 0 8px !important; | |
| gap: 4px !important; | |
| } | |
| .gradio-tabs .tab-nav button { | |
| font-family: var(--font-display) !important; | |
| font-size: 13px !important; | |
| font-weight: 600 !important; | |
| color: var(--text-muted) !important; | |
| background: transparent !important; | |
| border: none !important; | |
| border-bottom: 2px solid transparent !important; | |
| padding: 12px 20px !important; | |
| border-radius: 0 !important; | |
| transition: all 0.2s !important; | |
| } | |
| .gradio-tabs .tab-nav button.selected { | |
| color: var(--accent-2) !important; | |
| border-bottom-color: var(--accent) !important; | |
| } | |
| .gradio-tabs .tab-nav button:hover:not(.selected) { | |
| color: var(--text-secondary) !important; | |
| } | |
| /* ββ Dataframe / Table ββ */ | |
| .gradio-dataframe { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: var(--radius) !important; | |
| overflow: hidden !important; | |
| } | |
| .gradio-dataframe table { | |
| font-family: var(--font-mono) !important; | |
| font-size: 12px !important; | |
| } | |
| .gradio-dataframe thead th { | |
| background: linear-gradient(90deg, rgba(108,82,255,0.15), rgba(108,82,255,0.05)) !important; | |
| color: var(--accent-2) !important; | |
| font-family: var(--font-display) !important; | |
| font-size: 11px !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.8px !important; | |
| text-transform: uppercase !important; | |
| padding: 10px 14px !important; | |
| border-bottom: 1px solid var(--border) !important; | |
| } | |
| .gradio-dataframe tbody tr { | |
| border-bottom: 1px solid rgba(108,82,255,0.07) !important; | |
| transition: background 0.15s !important; | |
| } | |
| .gradio-dataframe tbody tr:hover { | |
| background: var(--bg-hover) !important; | |
| } | |
| .gradio-dataframe tbody td { | |
| color: var(--text-primary) !important; | |
| padding: 9px 14px !important; | |
| } | |
| /* ββ Dropdown ββ */ | |
| .gradio-dropdown { | |
| background: var(--bg-card) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 10px !important; | |
| } | |
| .gradio-dropdown select, .gradio-dropdown input { | |
| background: var(--bg-card) !important; | |
| color: var(--text-primary) !important; | |
| font-family: var(--font-body) !important; | |
| border: none !important; | |
| } | |
| /* ββ HTML outputs ββ */ | |
| .phase-bar-wrap { background: transparent; } | |
| /* ββ Download cards ββ */ | |
| .download-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| padding: 16px; | |
| } | |
| .download-card { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 14px 16px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-card); | |
| transition: all 0.2s; | |
| } | |
| .download-card.available { | |
| border-color: rgba(74,222,128,0.2); | |
| background: rgba(74,222,128,0.04); | |
| } | |
| .download-card.available:hover { | |
| border-color: rgba(74,222,128,0.4); | |
| background: rgba(74,222,128,0.08); | |
| transform: translateY(-1px); | |
| } | |
| .download-card.pending { opacity: 0.5; } | |
| .dl-icon { font-size: 20px; flex-shrink: 0; } | |
| .dl-info { flex: 1; min-width: 0; } | |
| .dl-label { | |
| font-family: var(--font-display); | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--text-primary); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .dl-meta { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| margin-top: 2px; | |
| } | |
| .dl-btn { | |
| background: linear-gradient(135deg, rgba(74,222,128,0.2), rgba(74,222,128,0.1)); | |
| color: var(--accent-ok); | |
| border: 1px solid rgba(74,222,128,0.3); | |
| border-radius: 7px; | |
| padding: 6px 14px; | |
| font-family: var(--font-display); | |
| font-size: 12px; | |
| font-weight: 600; | |
| text-decoration: none; | |
| white-space: nowrap; | |
| transition: all 0.2s; | |
| flex-shrink: 0; | |
| } | |
| .dl-btn:hover { | |
| background: rgba(74,222,128,0.3); | |
| border-color: rgba(74,222,128,0.5); | |
| } | |
| .dl-btn-disabled { | |
| color: var(--text-muted); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| flex-shrink: 0; | |
| padding: 6px 14px; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| } | |
| /* ββ Status indicators ββ */ | |
| .status-dot { | |
| width: 7px; height: 7px; | |
| border-radius: 50%; | |
| background: var(--accent-ok); | |
| display: inline-block; | |
| box-shadow: 0 0 8px var(--accent-ok); | |
| animation: pulse-dot 2s ease-in-out infinite; | |
| margin-right: 6px; | |
| } | |
| @keyframes pulse-dot { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.6; transform: scale(0.85); } | |
| } | |
| /* ββ Scrollbars ββ */ | |
| ::-webkit-scrollbar { width: 5px; height: 5px; } | |
| ::-webkit-scrollbar-track { background: var(--bg-deep); } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--border); | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--border-glow); } | |
| /* ββ Responsive ββ */ | |
| @media (max-width: 768px) { | |
| .download-grid { grid-template-columns: 1fr; } | |
| .app-header { padding: 20px; } | |
| .section-block { margin: 10px; } | |
| } | |
| """ | |
| # ββ Header HTML ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HEADER_HTML = """ | |
| <div class="app-header"> | |
| <div style="display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:16px"> | |
| <div> | |
| <div class="header-logo">THEMIS</div> | |
| <div class="header-subtitle">Thematic Engine for Mining & Identifying Scholarly Topics</div> | |
| <div class="header-badge"> | |
| <span class="status-dot"></span> | |
| Braun & Clarke (2006) Β· BERTopic Β· PAJAIS Taxonomy Β· Mistral LLM | |
| </div> | |
| </div> | |
| <div style="text-align:right"> | |
| <div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5a5380; line-height:1.8"> | |
| <div>EMBEDDING Β· all-MiniLM-L6-v2 Β· 384d</div> | |
| <div>CLUSTERING Β· AgglomerativeClustering Β· cosine</div> | |
| <div>THRESHOLD Β· 0.7 β ~100 topics</div> | |
| <div>TAXONOMY Β· PAJAIS Β· 25 categories</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| # ββ Section header helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def section_header(num: str, title: str, desc: str = "") -> str: | |
| return f""" | |
| <div class="section-header"> | |
| <div class="section-num">{num}</div> | |
| <div class="section-title">{title}</div> | |
| {"<div class='section-desc'>" + desc + "</div>" if desc else ""} | |
| </div> | |
| """ | |
| # ββ Build Gradio app βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Blocks( | |
| css=CUSTOM_CSS, | |
| title="THEMIS β BERTopic Thematic Analysis", | |
| theme=gr.themes.Base( | |
| primary_hue="violet", | |
| neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("Inter"), "sans-serif"], | |
| ), | |
| ) as demo: | |
| # ββ App Header ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| gr.HTML(HEADER_HTML) | |
| # ββ Phase Progress Bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Row(): | |
| phase_bar = gr.HTML(render_phase_bar(), elem_classes=["phase-bar-wrap"]) | |
| # ββ Section β : Data Input ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Group(elem_classes=["section-block"]): | |
| gr.HTML(section_header("β ", "Data Input", "Upload your Scopus CSV export")) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| file_input = gr.File( | |
| label="Scopus CSV Export", | |
| file_types=[".csv"], | |
| elem_classes=["upload-zone"], | |
| ) | |
| with gr.Column(scale=2): | |
| gr.HTML(""" | |
| <div style="padding:16px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#5a5380; line-height:2"> | |
| <div style="color:#9b8fd4; font-weight:600; margin-bottom:8px">Required CSV columns:</div> | |
| <div>Β· Authors</div> | |
| <div>Β· Title</div> | |
| <div>Β· Abstract</div> | |
| <div>Β· Author Keywords</div> | |
| <div>Β· Cited by</div> | |
| <div>Β· Source title</div> | |
| <div>Β· Year</div> | |
| </div> | |
| """) | |
| # ββ Section β‘: Agent Chat ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Group(elem_classes=["section-block"]): | |
| gr.HTML(section_header("β‘", "THEMIS Agent", "6-phase Braun & Clarke analysis pipeline")) | |
| with gr.Column(elem_classes=["chat-wrap"]): | |
| chatbot = gr.Chatbot( | |
| label="", | |
| height=420, | |
| show_label=False, | |
| elem_classes=["gradio-chatbot"], | |
| ) | |
| with gr.Row(): | |
| msg_input = gr.Textbox( | |
| placeholder="Type a message... (e.g. 'run abstract', 'run title', or ask a question)", | |
| show_label=False, | |
| scale=5, | |
| elem_classes=["msg-input"], | |
| container=False, | |
| ) | |
| send_btn = gr.Button("Send β", scale=1, elem_classes=["btn-send"]) | |
| with gr.Row(): | |
| reset_btn = gr.Button("β³ Reset Session", elem_classes=["btn-reset"], scale=1) | |
| gr.HTML('<div style="flex:4"></div>') | |
| # ββ Section β’: Results βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Group(elem_classes=["section-block"]): | |
| gr.HTML(section_header("β’", "Results", "Review table Β· Charts Β· Downloads")) | |
| with gr.Tabs(): | |
| # Tab A: Review Table | |
| with gr.Tab("π Review Table"): | |
| review_table = gr.Dataframe( | |
| value=load_review_table(), | |
| interactive=True, | |
| wrap=False, | |
| height=400, | |
| elem_classes=["gradio-dataframe"], | |
| column_widths=["60px","160px","120px","80px","80px","200px","80px","120px","160px"], | |
| ) | |
| with gr.Row(): | |
| refresh_table_btn = gr.Button("β» Refresh Table", elem_classes=["btn-reset"], scale=1) | |
| submit_review_btn = gr.Button("β Submit Review β", elem_classes=["btn-review"], scale=2) | |
| gr.HTML('<div style="flex:3"></div>') | |
| # Tab B: Charts | |
| with gr.Tab("π Charts"): | |
| with gr.Row(): | |
| chart_dropdown = gr.Dropdown( | |
| choices=get_chart_options(), | |
| label="Select Chart", | |
| value=None, | |
| scale=2, | |
| ) | |
| refresh_charts_btn = gr.Button("β» Refresh", elem_classes=["btn-reset"], scale=1) | |
| chart_output = gr.HTML( | |
| '<div style="text-align:center;color:#5a5380;padding:60px;font-family:\'JetBrains Mono\',monospace;font-size:12px">' | |
| 'Run analysis to generate interactive charts</div>' | |
| ) | |
| # Tab C: Downloads | |
| with gr.Tab("β¬ Downloads"): | |
| download_html = gr.HTML(render_download_links()) | |
| refresh_dl_btn = gr.Button("β» Refresh Downloads", elem_classes=["btn-reset"]) | |
| # ββ Event Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Send message | |
| def handle_send(message: str, history: list): | |
| if not message.strip(): | |
| return history, "", load_review_table(), render_phase_bar() | |
| response = invoke_agent(message, history) | |
| history = history + [[message, response]] | |
| return history, "", load_review_table(), render_phase_bar() | |
| send_btn.click( | |
| fn=handle_send, | |
| inputs=[msg_input, chatbot], | |
| outputs=[chatbot, msg_input, review_table, phase_bar], | |
| ) | |
| msg_input.submit( | |
| fn=handle_send, | |
| inputs=[msg_input, chatbot], | |
| outputs=[chatbot, msg_input, review_table, phase_bar], | |
| ) | |
| # CSV upload β auto-trigger agent | |
| def handle_upload(file, history: list): | |
| if file is None: | |
| return history, load_review_table(), render_phase_bar() | |
| response = invoke_agent(f"Analyze my Scopus CSV: {file.name}", history) | |
| history = history + [[f"π Uploaded CSV: {os.path.basename(file.name)}", response]] | |
| return history, load_review_table(), render_phase_bar() | |
| file_input.change( | |
| fn=handle_upload, | |
| inputs=[file_input, chatbot], | |
| outputs=[chatbot, review_table, phase_bar], | |
| ) | |
| # Submit review | |
| def handle_submit_review(table_data, history): | |
| new_history, _ = submit_review(table_data, history) | |
| return new_history, load_review_table(), render_phase_bar() | |
| submit_review_btn.click( | |
| fn=handle_submit_review, | |
| inputs=[review_table, chatbot], | |
| outputs=[chatbot, review_table, phase_bar], | |
| ) | |
| # Refresh table | |
| refresh_table_btn.click( | |
| fn=lambda: (load_review_table(), render_phase_bar()), | |
| outputs=[review_table, phase_bar], | |
| ) | |
| # Chart dropdown | |
| chart_dropdown.change( | |
| fn=load_chart, | |
| inputs=[chart_dropdown], | |
| outputs=[chart_output], | |
| ) | |
| # Refresh charts | |
| def refresh_charts(): | |
| opts = get_chart_options() | |
| return gr.update(choices=opts, value=opts[0] if opts else None) | |
| refresh_charts_btn.click(fn=refresh_charts, outputs=[chart_dropdown]) | |
| # Downloads refresh | |
| refresh_dl_btn.click(fn=render_download_links, outputs=[download_html]) | |
| # Reset | |
| def handle_reset(): | |
| msg = reset_agent() | |
| return [], render_phase_bar() | |
| reset_btn.click( | |
| fn=handle_reset, | |
| outputs=[chatbot, phase_bar], | |
| ) | |
| # ββ Launch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| ) |