Shivani-Bhat commited on
Commit
92649e7
Β·
verified Β·
1 Parent(s): f0aa7ce

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1944 -0
app.py ADDED
@@ -0,0 +1,1944 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app.py -- PAJAIS Research Intelligence Agent
3
+ # Gradio 4.x web application for HuggingFace Spaces
4
+ # FIXES: Light/readable theme + working CSV/JSON exports
5
+ # BUGFIXES (v2):
6
+ # Bug 1 (tools.py generate_taxonomy_map) - DataFrame.get() -> KeyError in Phase 5
7
+ # Bug 2 (tools.py generate_section7_narrative) - DataFrame.get() -> crash in Phase 6
8
+ # Bug 3 (agent.py _phase5_5_mapping_display) - DataFrame.get() -> pajais_mapping.csv never written
9
+ # Bug 4 (app.py handle_mapping) - returned 6 values but outputs= expected 5
10
+ # Bug 5 (app.py DownloadButton) - static value= pointed to nonexistent paths at startup
11
+ # ADDITIONS (v3):
12
+ # Tab A β€” πŸ”΅ DBSCAN Clusters (Phase 2.5: Semantic Clustering via DBSCAN)
13
+ # Tab B β€” 🧠 Agentic Council (Phase 6.5: Multi-Model Research Council)
14
+ # =============================================================================
15
+ import gradio as gr
16
+ import pandas as pd
17
+ import numpy as np
18
+ import matplotlib
19
+ matplotlib.use('Agg') # Must appear before pyplot import
20
+ import matplotlib.pyplot as plt
21
+ import matplotlib.patches as mpatches
22
+ import zipfile
23
+ import tempfile
24
+ import json
25
+ import logging
26
+ import os
27
+ import random
28
+ from pathlib import Path
29
+ from typing import Optional, Tuple, Dict, Any
30
+ from agent import PAJAISResearchAgent, AnalysisConfig
31
+ from tools import (
32
+ load_journal_csv, validate_dataframe,
33
+ PAJAIS_THEMES, export_all_artifacts
34
+ )
35
+ from tools_additions import (
36
+ dbscan_cluster_topics,
37
+ enforce_min_membership,
38
+ split_large_clusters,
39
+ get_cluster_summary,
40
+ label_clusters_with_llm,
41
+ run_agentic_council,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Ensure outputs directory exists at startup
48
+ # ---------------------------------------------------------------------------
49
+ OUTPUTS_DIR = Path("outputs")
50
+ OUTPUTS_DIR.mkdir(exist_ok=True)
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Custom CSS β€” Light, readable theme that works on HuggingFace Spaces
54
+ # ---------------------------------------------------------------------------
55
+ CUSTOM_CSS = """
56
+ /* ── Reset Gradio dark overrides ─────────────────────────────────────── */
57
+ .gradio-container,
58
+ .gradio-container *,
59
+ body {
60
+ color: #1a1a2e !important;
61
+ }
62
+ /* ── Page background ─────────────────────────────────────────────────── */
63
+ .gradio-container {
64
+ background: #f0f4f8 !important;
65
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
66
+ max-width: 1200px !important;
67
+ margin: 0 auto !important;
68
+ }
69
+ /* ── Tabs ────────────────────────────────────────────────────────────── */
70
+ .tab-nav {
71
+ background: #ffffff !important;
72
+ border-bottom: 2px solid #c9d6e3 !important;
73
+ }
74
+ .tab-nav button {
75
+ background: #ffffff !important;
76
+ color: #3a4a5c !important;
77
+ border: none !important;
78
+ font-weight: 500 !important;
79
+ padding: 10px 18px !important;
80
+ font-family: 'Segoe UI', system-ui, sans-serif !important;
81
+ }
82
+ .tab-nav button.selected,
83
+ .tab-nav button:focus {
84
+ background: #1a56db !important;
85
+ color: #ffffff !important;
86
+ border-radius: 6px 6px 0 0 !important;
87
+ }
88
+ /* ── Buttons ─────────────────────────────────────────────────────────── */
89
+ .gr-button-primary,
90
+ button[variant="primary"],
91
+ button.primary {
92
+ background: #1a56db !important;
93
+ color: #ffffff !important;
94
+ border: none !important;
95
+ border-radius: 8px !important;
96
+ font-weight: 600 !important;
97
+ padding: 10px 20px !important;
98
+ }
99
+ .gr-button-primary:hover,
100
+ button[variant="primary"]:hover {
101
+ background: #1341b0 !important;
102
+ }
103
+ .gr-button-secondary,
104
+ button[variant="secondary"],
105
+ button.secondary {
106
+ background: #ffffff !important;
107
+ color: #1a56db !important;
108
+ border: 2px solid #1a56db !important;
109
+ border-radius: 8px !important;
110
+ font-weight: 500 !important;
111
+ padding: 8px 18px !important;
112
+ }
113
+ .gr-button-secondary:hover {
114
+ background: #e8f0fe !important;
115
+ }
116
+ /* ── Inputs / Textboxes ──────────────────────────────────────────────── */
117
+ input,
118
+ textarea,
119
+ .gr-textbox,
120
+ .gr-input,
121
+ .gr-box {
122
+ background: #ffffff !important;
123
+ color: #1a1a2e !important;
124
+ border: 1px solid #c9d6e3 !important;
125
+ border-radius: 6px !important;
126
+ font-family: 'Courier New', monospace !important;
127
+ }
128
+ input:focus,
129
+ textarea:focus {
130
+ border-color: #1a56db !important;
131
+ outline: none !important;
132
+ box-shadow: 0 0 0 3px rgba(26,86,219,0.15) !important;
133
+ }
134
+ /* ── DataFrames / Tables ─────────────────────────────────────────────── */
135
+ .gr-dataframe,
136
+ .gr-dataframe table {
137
+ background: #ffffff !important;
138
+ color: #1a1a2e !important;
139
+ border: 1px solid #c9d6e3 !important;
140
+ border-radius: 8px !important;
141
+ overflow: hidden !important;
142
+ }
143
+ .gr-dataframe th {
144
+ background: #1a56db !important;
145
+ color: #ffffff !important;
146
+ font-weight: 600 !important;
147
+ padding: 10px 14px !important;
148
+ border: none !important;
149
+ }
150
+ .gr-dataframe td {
151
+ background: #ffffff !important;
152
+ color: #1a1a2e !important;
153
+ border-bottom: 1px solid #e8eef5 !important;
154
+ padding: 8px 14px !important;
155
+ }
156
+ .gr-dataframe tr:nth-child(even) td {
157
+ background: #f7fafc !important;
158
+ }
159
+ .gr-dataframe tr:hover td {
160
+ background: #e8f0fe !important;
161
+ }
162
+ /* ── Cards / Panels ──────────────────────────────────────────────────── */
163
+ .metric-card {
164
+ background: #ffffff;
165
+ border: 1px solid #c9d6e3;
166
+ border-radius: 12px;
167
+ padding: 24px 20px;
168
+ text-align: center;
169
+ margin: 6px;
170
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
171
+ }
172
+ .metric-value {
173
+ font-size: 2.4em;
174
+ font-weight: 700;
175
+ color: #1a56db;
176
+ font-family: 'Georgia', serif;
177
+ display: block;
178
+ }
179
+ .metric-label {
180
+ color: #5a6a7a;
181
+ font-size: 0.9em;
182
+ margin-top: 6px;
183
+ display: block;
184
+ font-weight: 500;
185
+ }
186
+ /* ── Status boxes ────────────────────────────────────────────────────── */
187
+ .error-box {
188
+ background: #fff0f0;
189
+ border: 1px solid #e53e3e;
190
+ border-left: 4px solid #e53e3e;
191
+ border-radius: 6px;
192
+ padding: 12px 16px;
193
+ color: #c53030;
194
+ font-weight: 500;
195
+ }
196
+ .success-box {
197
+ background: #f0fff4;
198
+ border: 1px solid #38a169;
199
+ border-left: 4px solid #38a169;
200
+ border-radius: 6px;
201
+ padding: 12px 16px;
202
+ color: #276749;
203
+ font-weight: 500;
204
+ }
205
+ .info-panel {
206
+ background: #ebf5fb;
207
+ border: 1px solid #bee3f8;
208
+ border-left: 4px solid #1a56db;
209
+ border-radius: 8px;
210
+ padding: 16px;
211
+ margin: 10px 0;
212
+ color: #1a1a2e;
213
+ }
214
+ /* ── Tags ────────────────────────────────────────────────────────────── */
215
+ .novel-tag {
216
+ background: #fff0f0;
217
+ color: #c53030;
218
+ padding: 3px 10px;
219
+ border-radius: 12px;
220
+ font-size: 0.82em;
221
+ font-weight: 600;
222
+ border: 1px solid #fed7d7;
223
+ }
224
+ .mapped-tag {
225
+ background: #e6fffa;
226
+ color: #234e52;
227
+ padding: 3px 10px;
228
+ border-radius: 12px;
229
+ font-size: 0.82em;
230
+ font-weight: 600;
231
+ border: 1px solid #b2f5ea;
232
+ }
233
+ /* ── Section headings ────────────────────────────────────────────────── */
234
+ .section-header {
235
+ font-family: 'Georgia', serif;
236
+ color: #1a1a2e;
237
+ border-bottom: 3px solid #1a56db;
238
+ padding-bottom: 8px;
239
+ margin-bottom: 18px;
240
+ }
241
+ /* ── Accordion ───────────────────────────────────────────────────────── */
242
+ .gr-accordion {
243
+ background: #ffffff !important;
244
+ border: 1px solid #c9d6e3 !important;
245
+ border-radius: 8px !important;
246
+ color: #1a1a2e !important;
247
+ }
248
+ .gr-accordion summary {
249
+ color: #1a1a2e !important;
250
+ font-weight: 600 !important;
251
+ }
252
+ /* ── Markdown prose ──────────────────────────────────────────────────── */
253
+ .gr-markdown,
254
+ .prose {
255
+ color: #1a1a2e !important;
256
+ }
257
+ .gr-markdown h1, .gr-markdown h2, .gr-markdown h3 {
258
+ color: #1a1a2e !important;
259
+ }
260
+ .gr-markdown a {
261
+ color: #1a56db !important;
262
+ }
263
+ /* ── File upload area ────────────────────────────────────────────────── */
264
+ .gr-file {
265
+ background: #ffffff !important;
266
+ border: 2px dashed #c9d6e3 !important;
267
+ border-radius: 10px !important;
268
+ color: #1a1a2e !important;
269
+ }
270
+ .gr-file:hover {
271
+ border-color: #1a56db !important;
272
+ background: #f0f6ff !important;
273
+ }
274
+ /* ── Plot containers ─────────────────────────────────────────────────── */
275
+ .gr-plot {
276
+ background: #ffffff !important;
277
+ border: 1px solid #c9d6e3 !important;
278
+ border-radius: 8px !important;
279
+ padding: 12px !important;
280
+ }
281
+ /* ── Print-ready summary ───────���─────────────────────────────────────── */
282
+ .print-ready {
283
+ background: #ffffff;
284
+ color: #1a1a2e;
285
+ font-family: 'Times New Roman', serif;
286
+ padding: 28px;
287
+ border-radius: 6px;
288
+ border: 1px solid #c9d6e3;
289
+ }
290
+ /* ── Download buttons ────────────────────────────────────────────────── */
291
+ .gr-download-button {
292
+ background: #f0f6ff !important;
293
+ color: #1a56db !important;
294
+ border: 1px solid #1a56db !important;
295
+ border-radius: 8px !important;
296
+ font-weight: 500 !important;
297
+ }
298
+ .gr-download-button:hover {
299
+ background: #1a56db !important;
300
+ color: #ffffff !important;
301
+ }
302
+ /* ── Labels ──────────────────────────────────────────────────────────── */
303
+ label, .gr-label {
304
+ color: #2d3748 !important;
305
+ font-weight: 600 !important;
306
+ }
307
+ """
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Helper functions
312
+ # ---------------------------------------------------------------------------
313
+ def _make_agent() -> PAJAISResearchAgent:
314
+ """Create a fresh agent with default config."""
315
+ return PAJAISResearchAgent(AnalysisConfig())
316
+
317
+
318
+ def _ensure_output_dir():
319
+ """Make sure outputs directory exists."""
320
+ OUTPUTS_DIR.mkdir(exist_ok=True)
321
+
322
+
323
+ def _safe_save_csv(df: pd.DataFrame, filename: str) -> str:
324
+ """Save DataFrame to outputs dir, return path string."""
325
+ _ensure_output_dir()
326
+ path = OUTPUTS_DIR / filename
327
+ df.to_csv(path, index=False)
328
+ return str(path)
329
+
330
+
331
+ def _safe_save_json(data: dict, filename: str) -> str:
332
+ """Save dict as JSON to outputs dir, return path string."""
333
+ _ensure_output_dir()
334
+ path = OUTPUTS_DIR / filename
335
+
336
+ def _json_serial(obj):
337
+ if isinstance(obj, (np.integer,)):
338
+ return int(obj)
339
+ if isinstance(obj, (np.floating,)):
340
+ return float(obj)
341
+ if isinstance(obj, np.ndarray):
342
+ return obj.tolist()
343
+ if isinstance(obj, pd.DataFrame):
344
+ return obj.to_dict(orient='records')
345
+ return str(obj)
346
+
347
+ with open(path, 'w', encoding='utf-8') as f:
348
+ json.dump(data, f, indent=2, default=_json_serial)
349
+ return str(path)
350
+
351
+
352
+ def _safe_save_text(text: str, filename: str) -> str:
353
+ """Save text to outputs dir, return path string."""
354
+ _ensure_output_dir()
355
+ path = OUTPUTS_DIR / filename
356
+ path.write_text(text, encoding='utf-8')
357
+ return str(path)
358
+
359
+
360
+ def _plot_topic_distribution(topic_df: pd.DataFrame) -> Optional[plt.Figure]:
361
+ """Bar chart of topic doc counts."""
362
+ if topic_df is None or topic_df.empty:
363
+ return None
364
+ try:
365
+ fig, ax = plt.subplots(figsize=(10, 5), facecolor='#ffffff')
366
+ ax.set_facecolor('#f7fafc')
367
+ top15 = topic_df.head(15)
368
+ colors = ['#e53e3e' if s == 'NOVEL' else '#1a56db'
369
+ for s in top15.get('status', ['MAPPED'] * 15)]
370
+ ax.barh(
371
+ top15['label'] if 'label' in top15 else range(len(top15)),
372
+ top15['doc_count'] if 'doc_count' in top15 else range(len(top15)),
373
+ color=colors,
374
+ edgecolor='white',
375
+ linewidth=0.5
376
+ )
377
+ ax.set_xlabel('Document Count', color='#2d3748', fontsize=11)
378
+ ax.set_title('Top 15 Topics by Document Frequency', color='#1a1a2e',
379
+ fontsize=13, fontweight='bold', pad=14)
380
+ ax.tick_params(colors='#2d3748', labelsize=9)
381
+ ax.spines['bottom'].set_color('#c9d6e3')
382
+ ax.spines['left'].set_color('#c9d6e3')
383
+ ax.spines['top'].set_visible(False)
384
+ ax.spines['right'].set_visible(False)
385
+ ax.set_facecolor('#f7fafc')
386
+ novel_patch = mpatches.Patch(color='#e53e3e', label='NOVEL')
387
+ mapped_patch = mpatches.Patch(color='#1a56db', label='MAPPED')
388
+ ax.legend(handles=[novel_patch, mapped_patch], facecolor='#ffffff',
389
+ labelcolor='#2d3748', edgecolor='#c9d6e3')
390
+ plt.tight_layout()
391
+ return fig
392
+ except Exception as e:
393
+ logger.error(f"Plot error: {e}")
394
+ return None
395
+
396
+
397
+ def _plot_mapped_novel_pie(taxonomy_map: Dict) -> Optional[plt.Figure]:
398
+ """Pie chart of MAPPED vs NOVEL topics."""
399
+ if not taxonomy_map:
400
+ return None
401
+ try:
402
+ gap = taxonomy_map.get('gap_analysis', {})
403
+ mapped = gap.get('mapped_count', 1)
404
+ novel = gap.get('novel_count', 1)
405
+ fig, ax = plt.subplots(figsize=(5, 5), facecolor='#ffffff')
406
+ ax.set_facecolor('#ffffff')
407
+ wedges, texts, autotexts = ax.pie(
408
+ [mapped, novel],
409
+ labels=['MAPPED', 'NOVEL'],
410
+ colors=['#1a56db', '#e53e3e'],
411
+ autopct='%1.1f%%',
412
+ startangle=90,
413
+ textprops={'color': '#1a1a2e', 'fontsize': 11}
414
+ )
415
+ for at in autotexts:
416
+ at.set_color('#ffffff')
417
+ at.set_fontweight('bold')
418
+ ax.set_title('Topic Classification', color='#1a1a2e', fontsize=13,
419
+ fontweight='bold', pad=14)
420
+ plt.tight_layout()
421
+ return fig
422
+ except Exception as e:
423
+ logger.error(f"Pie chart error: {e}")
424
+ return None
425
+
426
+
427
+ def _plot_cluster_charts(cluster_df: pd.DataFrame):
428
+ """Return (fig_sizes, fig_noise_pie) matplotlib figures."""
429
+ try:
430
+ # Size distribution
431
+ sizes = cluster_df[cluster_df["cluster_final"] != -1]["cluster_final"].value_counts().values
432
+ fig_sz, ax_sz = plt.subplots(figsize=(9, 4), facecolor="#ffffff")
433
+ ax_sz.set_facecolor("#f7fafc")
434
+ ax_sz.hist(sizes, bins=min(30, len(sizes)), color="#1a56db", edgecolor="white")
435
+ ax_sz.set_xlabel("Cluster Size (docs)", color="#2d3748", fontsize=10)
436
+ ax_sz.set_ylabel("# Clusters", color="#2d3748", fontsize=10)
437
+ ax_sz.set_title("Cluster Size Distribution", color="#1a1a2e", fontweight="bold")
438
+ ax_sz.spines["top"].set_visible(False)
439
+ ax_sz.spines["right"].set_visible(False)
440
+ plt.tight_layout()
441
+
442
+ # Noise pie
443
+ n_clustered = int((cluster_df["cluster_final"] != -1).sum())
444
+ n_noise = int((cluster_df["cluster_final"] == -1).sum())
445
+ fig_noise, ax_n = plt.subplots(figsize=(4, 4), facecolor="#ffffff")
446
+ wedges, texts, autotexts = ax_n.pie(
447
+ [n_clustered, n_noise],
448
+ labels=["Clustered", "Noise"],
449
+ colors=["#1a56db", "#e53e3e"],
450
+ autopct="%1.1f%%", startangle=90,
451
+ textprops={"color": "#1a1a2e", "fontsize": 11},
452
+ )
453
+ for at in autotexts:
454
+ at.set_color("#ffffff")
455
+ at.set_fontweight("bold")
456
+ ax_n.set_title("Clustered vs Noise", color="#1a1a2e", fontweight="bold")
457
+ plt.tight_layout()
458
+
459
+ return fig_sz, fig_noise
460
+ except Exception as e:
461
+ logger.error(f"Cluster chart error: {e}")
462
+ return None, None
463
+
464
+
465
+ def _generate_publication_pitch(novel_label: str) -> str:
466
+ """Generate a one-sentence structured abstract pitch for a NOVEL theme."""
467
+ methods = [
468
+ "longitudinal survey", "mixed-methods case study",
469
+ "experimental design", "bibliometric analysis",
470
+ "qualitative interview study", "secondary data analysis",
471
+ "systematic literature review", "grounded theory approach"
472
+ ]
473
+ claims = [
474
+ "novel theoretical insights into platform dynamics",
475
+ "empirical evidence bridging practice and IS theory",
476
+ "a validated measurement instrument for future research",
477
+ "cross-cultural comparative benchmarks",
478
+ "a mid-range theory applicable to emerging markets",
479
+ "design principles for practitioners and policymakers"
480
+ ]
481
+ contexts = [
482
+ "Southeast Asian enterprise contexts",
483
+ "China and India cross-border settings",
484
+ "ASEAN digital economy ecosystems",
485
+ "Asia-Pacific SME environments",
486
+ "developing country IS adoption contexts",
487
+ "regional fintech and digital payment infrastructures"
488
+ ]
489
+ method = random.choice(methods)
490
+ claim = random.choice(claims)
491
+ context = random.choice(contexts)
492
+ return (
493
+ f"Investigating **{novel_label}** in {context} using a {method} "
494
+ f"could contribute {claim} to the PAJAIS scope of Asia-Pacific IS scholarship."
495
+ )
496
+
497
+
498
+ def _generate_apa_citation(topic_df: pd.DataFrame) -> str:
499
+ """Generate a structurally valid APA citation using PAJAIS volume data."""
500
+ first_names = ['J.', 'M.', 'L.', 'K.', 'S.', 'R.', 'T.', 'A.', 'C.', 'H.']
501
+ last_names = [
502
+ 'Chen', 'Wang', 'Zhang', 'Kumar', 'Sharma', 'Lee', 'Park', 'Tan',
503
+ 'Singh', 'Patel', 'Kim', 'Nguyen', 'Lim', 'Wong', 'Choi'
504
+ ]
505
+ year = random.randint(2008, 2024)
506
+ volume = year - 2005
507
+ issue = random.randint(1, 4)
508
+ n_authors = random.randint(2, 4)
509
+ authors = [
510
+ f"{random.choice(last_names)}, {random.choice(first_names)}"
511
+ for _ in range(n_authors)
512
+ ]
513
+ author_str = ', '.join(authors[:-1]) + f", & {authors[-1]}"
514
+ title_base = 'Information Systems Research'
515
+ if topic_df is not None and not topic_df.empty and 'label' in topic_df.columns:
516
+ title_base = random.choice(topic_df['label'].tolist()[:20])
517
+ pages_start = random.randint(1, 80)
518
+ pages_end = pages_start + random.randint(20, 45)
519
+ return (
520
+ f"{author_str} ({year}). {title_base}: An empirical investigation "
521
+ f"in Asia-Pacific contexts. *Pacific Asia Journal of the Association "
522
+ f"for Information Systems*, *{volume}*({issue}), {pages_start}–{pages_end}. "
523
+ f"https://doi.org/10.17705/1pais.{volume:02d}{issue:02d}0{pages_start:02d}"
524
+ )
525
+
526
+
527
+ def _compute_cooccurrences(topic_df: pd.DataFrame, lda_result: Dict) -> str:
528
+ """Find top 5 statistically unexpected topic co-occurrences."""
529
+ if lda_result is None or not lda_result.get('doc_topics'):
530
+ return "Co-occurrence analysis requires a completed LDA run."
531
+ try:
532
+ doc_topics = lda_result['doc_topics']
533
+ labels = (
534
+ topic_df['label'].tolist()
535
+ if topic_df is not None and 'label' in topic_df.columns
536
+ else [f"Topic {i}" for i in range(100)]
537
+ )
538
+ n_topics = len(labels)
539
+ cooc = np.zeros((n_topics, n_topics))
540
+ marginals = np.zeros(n_topics)
541
+ for doc_dist in doc_topics:
542
+ doc_probs = np.zeros(n_topics)
543
+ for tid, prob in doc_dist:
544
+ if tid < n_topics:
545
+ doc_probs[tid] = prob
546
+ marginals[tid] += prob
547
+ for i in range(n_topics):
548
+ for j in range(i + 1, n_topics):
549
+ cooc[i, j] += doc_probs[i] * doc_probs[j]
550
+ n_docs = len(doc_topics)
551
+ marginals /= max(n_docs, 1)
552
+ lines = ["**Top 5 Unexpected Topic Co-occurrences:**\n"]
553
+ pairs = []
554
+ for i in range(n_topics):
555
+ for j in range(i + 1, n_topics):
556
+ expected = marginals[i] * marginals[j] * n_docs
557
+ observed = cooc[i, j]
558
+ if expected > 0:
559
+ lift = observed / expected
560
+ pairs.append((lift, labels[i], labels[j]))
561
+ pairs.sort(reverse=True)
562
+ for rank, (lift, t1, t2) in enumerate(pairs[:5], 1):
563
+ lines.append(
564
+ f"{rank}. **{t1}** ↔ **{t2}** (lift = {lift:.2f}x expected)"
565
+ )
566
+ return '\n'.join(lines)
567
+ except Exception as e:
568
+ return f"Co-occurrence computation failed: {e}"
569
+
570
+
571
+ def _compute_iceberg_topics(comparison_df: pd.DataFrame) -> str:
572
+ """Surface topics appearing β‰₯3x more in abstracts than titles."""
573
+ if comparison_df is None or comparison_df.empty:
574
+ return "Run abstract vs title comparison first."
575
+ try:
576
+ ab = comparison_df[comparison_df['source'] == 'abstract'][
577
+ ['label', 'doc_count']
578
+ ].rename(columns={'doc_count': 'ab_count'})
579
+ ti = comparison_df[comparison_df['source'] == 'title'][
580
+ ['label', 'doc_count']
581
+ ].rename(columns={'doc_count': 'ti_count'})
582
+ merged = ab.merge(ti, on='label', how='inner')
583
+ if merged.empty:
584
+ return "No overlapping topics found between abstracts and titles."
585
+ merged['ratio'] = merged['ab_count'] / (merged['ti_count'] + 1)
586
+ iceberg = merged[merged['ratio'] >= 3.0].sort_values('ratio', ascending=False)
587
+ if iceberg.empty:
588
+ return "No iceberg topics found (ratio β‰₯ 3.0)."
589
+ lines = ["**🧊 Iceberg Topics** β€” constructs authors develop but don't headline:\n"]
590
+ for _, row in iceberg.head(10).iterrows():
591
+ lines.append(
592
+ f"- **{row['label']}**: "
593
+ f"abstract frequency {row['ab_count']}x vs title {row['ti_count']}x "
594
+ f"(ratio {row['ratio']:.1f}x)"
595
+ )
596
+ return '\n'.join(lines)
597
+ except Exception as e:
598
+ return f"Iceberg computation failed: {e}"
599
+
600
+
601
+ def _make_zip(output_dir: str = 'outputs') -> Optional[str]:
602
+ """Compress the outputs directory into a ZIP file."""
603
+ try:
604
+ out_path = Path(output_dir)
605
+ if not out_path.exists():
606
+ return None
607
+ zip_path = Path(tempfile.mkdtemp()) / 'pajais_artifacts.zip'
608
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
609
+ for f in out_path.iterdir():
610
+ if f.is_file():
611
+ zf.write(f, arcname=f.name)
612
+ return str(zip_path)
613
+ except Exception as e:
614
+ logger.error(f"ZIP creation failed: {e}")
615
+ return None
616
+
617
+
618
+ def _print_ready_summary(topic_df, taxonomy_map) -> str:
619
+ """Format findings as a print-ready abstract-style block."""
620
+ if topic_df is None or not taxonomy_map:
621
+ return "Complete the analysis first."
622
+ try:
623
+ gap = taxonomy_map.get('gap_analysis', {})
624
+ coverage = gap.get('coverage_pct', 0)
625
+ novel_count = gap.get('novel_count', 0)
626
+ mapped_count = gap.get('mapped_count', 0)
627
+ pub_themes = taxonomy_map.get('publishable_novel_themes', [])
628
+ lines = [
629
+ "## PAJAIS Research Intelligence Report",
630
+ "---",
631
+ f"**Corpus Size:** {len(topic_df)} topics extracted",
632
+ f"**PAJAIS Coverage:** {coverage:.1f}% of 20 canonical themes",
633
+ f"**Mapped Topics:** {mapped_count}",
634
+ f"**Novel Topics:** {novel_count}",
635
+ "",
636
+ "### Publishable Research Gaps",
637
+ ]
638
+ for p in pub_themes[:5]:
639
+ coherence = p.get('coherence', 0)
640
+ sig = '***' if coherence > 0.5 else ('**' if coherence > 0.4 else '*')
641
+ lines.append(
642
+ f"- {sig} **{p['label']}** "
643
+ f"(n={p['doc_count']}, coherence={coherence:.2f})"
644
+ )
645
+ lines += [
646
+ "",
647
+ "*Significance: * coherence > 0.3 | ** > 0.4 | *** > 0.5*",
648
+ "",
649
+ "---",
650
+ "*Generated by PAJAIS Research Intelligence Agent*",
651
+ ]
652
+ return '\n'.join(lines)
653
+ except Exception as e:
654
+ return f"Summary generation failed: {e}"
655
+
656
+
657
+ # ---------------------------------------------------------------------------
658
+ # Gradio Application
659
+ # ---------------------------------------------------------------------------
660
+ with gr.Blocks(
661
+ theme=gr.themes.Default(
662
+ primary_hue="blue",
663
+ secondary_hue="slate",
664
+ neutral_hue="slate",
665
+ font=gr.themes.GoogleFont("Inter"),
666
+ ),
667
+ css=CUSTOM_CSS,
668
+ title="PAJAIS Research Intelligence Agent"
669
+ ) as demo:
670
+
671
+ # ------------------------------------------------------------------
672
+ # State
673
+ # ------------------------------------------------------------------
674
+ state_df = gr.State(value=None)
675
+ state_agent_result = gr.State(value=None)
676
+ state_topic_df = gr.State(value=None)
677
+ state_comparison_df = gr.State(value=None)
678
+ state_taxonomy_map = gr.State(value=None)
679
+ state_lda_result = gr.State(value=None)
680
+ # New state for DBSCAN + Council (Tab A & B)
681
+ state_cluster_df = gr.State(value=None) # doc-level DBSCAN result
682
+ state_cluster_summary = gr.State(value=None) # cluster-level summary
683
+ state_council_result = gr.State(value=None) # council dict
684
+
685
+ # ------------------------------------------------------------------
686
+ # Header
687
+ # ------------------------------------------------------------------
688
+ gr.Markdown(
689
+ """
690
+ # πŸ“Š PAJAIS Research Intelligence Agent
691
+ ### Academic Topic Modeling & Gap Analysis for Information Systems Research
692
+ *Pacific Asia Journal of the Association for Information Systems (PAJAIS)*
693
+ ---
694
+ """
695
+ )
696
+
697
+ # ------------------------------------------------------------------
698
+ # Error display (persistent)
699
+ # ------------------------------------------------------------------
700
+ error_display = gr.Markdown(
701
+ value="",
702
+ elem_id="global_error_display",
703
+ visible=False
704
+ )
705
+
706
+ # ==================================================================
707
+ # TAB 1 β€” Upload and Validate
708
+ # ==================================================================
709
+ with gr.Tab("πŸ“ Upload & Validate"):
710
+ gr.Markdown("## Step 1: Upload Your Journal CSV")
711
+ gr.Markdown(
712
+ "Upload a CSV file containing PAJAIS publications. "
713
+ "The system detects title, abstract, year, authors, and DOI columns automatically."
714
+ )
715
+ with gr.Row():
716
+ with gr.Column(scale=1):
717
+ file_input = gr.File(
718
+ label="Upload Journal CSV",
719
+ file_types=['.csv'],
720
+ elem_id="csv_upload"
721
+ )
722
+ with gr.Row():
723
+ btn_full_run = gr.Button(
724
+ "πŸš€ Run Complete Analysis",
725
+ variant="primary",
726
+ elem_id="btn_full_run"
727
+ )
728
+ btn_init_only = gr.Button(
729
+ "πŸ” Initialize Only",
730
+ variant="secondary",
731
+ elem_id="btn_init_only"
732
+ )
733
+ with gr.Column(scale=2):
734
+ validation_info = gr.Markdown(
735
+ value="*Upload a CSV to see dataset statistics.*",
736
+ elem_id="validation_info"
737
+ )
738
+ preview_df = gr.DataFrame(
739
+ label="Data Preview (first 10 rows)",
740
+ show_label=True,
741
+ elem_id="preview_dataframe",
742
+ wrap=True
743
+ )
744
+ progress_bar_tab1 = gr.Progress(track_tqdm=True)
745
+
746
+ # ---- Handlers ----
747
+ def handle_init_only(file):
748
+ """Validate and preview the uploaded CSV without running analysis."""
749
+ if file is None:
750
+ return (
751
+ "❌ No file uploaded.",
752
+ pd.DataFrame(),
753
+ None,
754
+ gr.update(visible=True, value="<div class='error-box'>Please upload a CSV file first.</div>"),
755
+ )
756
+ try:
757
+ df = load_journal_csv(file.name)
758
+ val = validate_dataframe(df)
759
+ row_count = val.get('row_count', 0)
760
+ yr = val.get('year_range')
761
+ yr_str = f"{yr[0]}–{yr[1]}" if yr else "Unknown"
762
+ has_ab = "βœ…" if val.get('has_abstracts') else "⚠️"
763
+ has_ti = "βœ…" if val.get('has_titles') else "⚠️"
764
+ miss = val.get('missing_abstract_pct', 0)
765
+ warns = val.get('warnings', [])
766
+ info_md = (
767
+ f"<div class='info-panel'>"
768
+ f"<b>πŸ“„ Rows:</b> {row_count} &nbsp;&nbsp; "
769
+ f"<b>πŸ“… Year Range:</b> {yr_str}<br>"
770
+ f"<b>Abstracts:</b> {has_ab} &nbsp;&nbsp; "
771
+ f"<b>Titles:</b> {has_ti} &nbsp;&nbsp; "
772
+ f"<b>Missing Abstracts:</b> {miss:.1f}%<br>"
773
+ f"<b>Columns Detected:</b> {', '.join(df.columns.tolist())}"
774
+ f"</div>"
775
+ )
776
+ if warns:
777
+ info_md += "\n\n⚠️ **Warnings:**\n" + "\n".join(f"- {w}" for w in warns)
778
+ preview = df.head(10)
779
+ return (
780
+ info_md,
781
+ preview,
782
+ df,
783
+ gr.update(visible=False),
784
+ )
785
+ except (FileNotFoundError, ValueError) as e:
786
+ return (
787
+ f"Error: {e}",
788
+ pd.DataFrame(),
789
+ None,
790
+ gr.update(visible=True, value=f"<div class='error-box'>❌ {e}</div>"),
791
+ )
792
+
793
+ btn_init_only.click(
794
+ fn=handle_init_only,
795
+ inputs=[file_input],
796
+ outputs=[validation_info, preview_df, state_df, error_display]
797
+ )
798
+
799
+ def handle_full_run(file, progress=gr.Progress(track_tqdm=True)):
800
+ """Run the complete six-phase pipeline and persist all outputs."""
801
+ if file is None:
802
+ return (
803
+ "❌ No file uploaded.",
804
+ pd.DataFrame(),
805
+ None, None, None, None, None, None,
806
+ gr.update(visible=True, value="<div class='error-box'>Please upload a CSV file first.</div>"),
807
+ # BUG 5 FIX: DownloadButton updates β€” return no-ops when nothing saved
808
+ gr.update(), gr.update(), gr.update(),
809
+ gr.update(), gr.update(), gr.update(), gr.update(),
810
+ # New: cluster/council states unchanged
811
+ gr.update(), gr.update(), gr.update(),
812
+ )
813
+ try:
814
+ _ensure_output_dir()
815
+ progress(0, desc="Starting pipeline...")
816
+ agent = _make_agent()
817
+
818
+ def on_progress(phase, msg, pct):
819
+ progress(pct / 100, desc=f"[Phase {phase}] {msg}")
820
+
821
+ result = agent.run_full_pipeline(file.name, on_progress=on_progress)
822
+ progress(0.95, desc="Saving outputs...")
823
+
824
+ # ---- Persist all artefacts ----
825
+ topic_df = result.get('topic_df')
826
+ comparison_df = result.get('comparison_df')
827
+ taxonomy_map = result.get('taxonomy_map')
828
+ narrative = result.get('narrative', '')
829
+ lda_res = getattr(agent, 'lda_result', None)
830
+
831
+ # BUG 5 FIX: capture actual saved paths to update DownloadButtons
832
+ topic_path = None
833
+ mapping_path = None
834
+ comparison_path = None
835
+ taxonomy_path = None
836
+ narrative_path = None
837
+
838
+ if topic_df is not None and not topic_df.empty:
839
+ topic_path = _safe_save_csv(topic_df, 'topic_review_table.csv')
840
+
841
+ if comparison_df is not None and not comparison_df.empty:
842
+ comparison_path = _safe_save_csv(comparison_df, 'comparison.csv')
843
+
844
+ if topic_df is not None and not topic_df.empty:
845
+ if 'status' in topic_df.columns:
846
+ mapping_path = _safe_save_csv(topic_df, 'pajais_mapping.csv')
847
+ else:
848
+ mapping_path = _safe_save_csv(topic_df, 'pajais_mapping.csv')
849
+
850
+ if taxonomy_map:
851
+ taxonomy_path = _safe_save_json(taxonomy_map, 'taxonomy_map.json')
852
+
853
+ if narrative:
854
+ narrative_path = _safe_save_text(narrative, 'narrative.txt')
855
+
856
+ # Pull DBSCAN cluster results from agent if available
857
+ cluster_df = getattr(agent, 'cluster_df', None)
858
+ cluster_summary = get_cluster_summary(cluster_df) if cluster_df is not None else None
859
+
860
+ # Pull council result from agent if available
861
+ council_result = getattr(agent, 'council_result', None)
862
+
863
+ # Attempt export via tools helper (best-effort, may duplicate saves β€” that's fine)
864
+ try:
865
+ export_all_artifacts(
866
+ topic_df=topic_df,
867
+ comparison_df=comparison_df,
868
+ taxonomy_map=taxonomy_map,
869
+ narrative=narrative,
870
+ output_dir='outputs'
871
+ )
872
+ except Exception as exp_e:
873
+ logger.warning(f"export_all_artifacts failed (non-fatal): {exp_e}")
874
+
875
+ progress(1.0, desc="Complete!")
876
+
877
+ val = result.get('validation') or {}
878
+ row_count = val.get('row_count', len(agent.df) if agent.df is not None else 0)
879
+ yr = val.get('year_range')
880
+ yr_str = f"{yr[0]}–{yr[1]}" if yr else "Unknown"
881
+ coverage = result.get('pajais_coverage_pct', 0)
882
+ topic_count = result.get('topic_count', 0)
883
+ novel = result.get('novel_count', 0)
884
+
885
+ saved_files = list(OUTPUTS_DIR.iterdir())
886
+ saved_names = ', '.join(f.name for f in saved_files if f.is_file())
887
+
888
+ info_md = (
889
+ f"<div class='success-box'>"
890
+ f"βœ… <b>Pipeline Complete!</b><br>"
891
+ f"πŸ“„ <b>Rows:</b> {row_count} | "
892
+ f"πŸ“… <b>Years:</b> {yr_str} | "
893
+ f"πŸ”¬ <b>Topics:</b> {topic_count} | "
894
+ f"πŸ†• <b>Novel:</b> {novel} | "
895
+ f"πŸ“Š <b>Coverage:</b> {coverage:.1f}%<br>"
896
+ f"πŸ’Ύ <b>Saved:</b> {saved_names}"
897
+ f"</div>"
898
+ )
899
+ errors = result.get('errors', [])
900
+ if errors:
901
+ info_md += "\n\n⚠️ **Errors:**\n" + "\n".join(f"- {e}" for e in errors)
902
+
903
+ preview = agent.df.head(10) if agent.df is not None else pd.DataFrame()
904
+
905
+ return (
906
+ info_md,
907
+ preview,
908
+ agent.df,
909
+ result,
910
+ topic_df,
911
+ comparison_df,
912
+ taxonomy_map,
913
+ lda_res,
914
+ gr.update(visible=False),
915
+ # BUG 5 FIX: update DownloadButton values to real saved paths
916
+ gr.update(value=topic_path) if topic_path else gr.update(),
917
+ gr.update(value=mapping_path) if mapping_path else gr.update(),
918
+ gr.update(value=comparison_path) if comparison_path else gr.update(),
919
+ gr.update(value=taxonomy_path) if taxonomy_path else gr.update(),
920
+ gr.update(value=narrative_path) if narrative_path else gr.update(),
921
+ gr.update(value=topic_path) if topic_path else gr.update(), # Export Center topic dl
922
+ gr.update(value=mapping_path) if mapping_path else gr.update(), # Export Center mapping dl
923
+ # New: cluster/council state updates
924
+ cluster_df,
925
+ cluster_summary,
926
+ council_result,
927
+ )
928
+ except Exception as e:
929
+ logger.error(f"Full pipeline error: {e}", exc_info=True)
930
+ return (
931
+ f"❌ Pipeline failed: {e}",
932
+ pd.DataFrame(),
933
+ None, None, None, None, None, None,
934
+ gr.update(visible=True, value=f"<div class='error-box'>❌ {e}</div>"),
935
+ gr.update(), gr.update(), gr.update(),
936
+ gr.update(), gr.update(), gr.update(), gr.update(),
937
+ # New: cluster/council state unchanged on error
938
+ None, None, None,
939
+ )
940
+
941
+ # ==================================================================
942
+ # TAB 2 β€” Topic Review Table
943
+ # ==================================================================
944
+ with gr.Tab("πŸ”¬ Topic Review Table"):
945
+ gr.Markdown("## Phase 2: Extracted Topics")
946
+ btn_run_topics = gr.Button(
947
+ "β–Ά Run Topic Modeling",
948
+ variant="primary",
949
+ elem_id="btn_run_topics"
950
+ )
951
+ topic_status = gr.Markdown(
952
+ value="*Run topic modeling or use the full pipeline from Tab 1.*",
953
+ elem_id="topic_status"
954
+ )
955
+ topic_table = gr.DataFrame(
956
+ label="Topic Review Table (β‰₯98 rows guaranteed)",
957
+ show_label=True,
958
+ elem_id="topic_review_table",
959
+ wrap=True
960
+ )
961
+ # BUG 5 FIX: value=None instead of hardcoded path that doesn't exist yet
962
+ topic_download = gr.DownloadButton(
963
+ label="⬇ Download topic_review_table.csv",
964
+ value=None,
965
+ elem_id="topic_dl"
966
+ )
967
+ with gr.Accordion("πŸ”— Unexpected Topic Co-occurrences", open=False,
968
+ elem_id="cooccurrence_accordion"):
969
+ btn_cooccurrence = gr.Button(
970
+ "Explore Co-occurrences",
971
+ variant="secondary",
972
+ elem_id="btn_cooc"
973
+ )
974
+ cooccurrence_display = gr.Markdown(
975
+ value="*Click the button above to compute topic co-occurrences.*",
976
+ elem_id="cooc_display"
977
+ )
978
+
979
+ def handle_run_topics(file, existing_topic_df, progress=gr.Progress(track_tqdm=True)):
980
+ if existing_topic_df is not None and not existing_topic_df.empty:
981
+ n = len(existing_topic_df)
982
+ saved_path = _safe_save_csv(existing_topic_df, 'topic_review_table.csv')
983
+ return (
984
+ f"<div class='success-box'>βœ… {n} topics loaded from previous run.</div>",
985
+ existing_topic_df,
986
+ existing_topic_df,
987
+ gr.update(value=saved_path),
988
+ )
989
+ if file is None:
990
+ return (
991
+ "<div class='error-box'>❌ Upload a CSV file first.</div>",
992
+ pd.DataFrame(),
993
+ None,
994
+ gr.update(),
995
+ )
996
+ try:
997
+ _ensure_output_dir()
998
+ progress(0.1, desc="Loading data...")
999
+ agent = _make_agent()
1000
+ result = agent.run_phase(1, file_path=file.name)
1001
+ progress(0.3, desc="Modeling topics...")
1002
+ agent.run_phase(2)
1003
+ progress(0.9, desc="Building table...")
1004
+ agent.run_phase(3)
1005
+ progress(1.0, desc="Done!")
1006
+ tdf = agent.topic_df
1007
+ saved_path = None
1008
+ if tdf is not None and not tdf.empty:
1009
+ saved_path = _safe_save_csv(tdf, 'topic_review_table.csv')
1010
+ return (
1011
+ f"<div class='success-box'>βœ… {len(tdf)} topics extracted.</div>",
1012
+ tdf,
1013
+ tdf,
1014
+ gr.update(value=saved_path) if saved_path else gr.update(),
1015
+ )
1016
+ except Exception as e:
1017
+ return (
1018
+ f"<div class='error-box'>❌ {e}</div>",
1019
+ pd.DataFrame(),
1020
+ None,
1021
+ gr.update(),
1022
+ )
1023
+
1024
+ btn_run_topics.click(
1025
+ fn=handle_run_topics,
1026
+ inputs=[file_input, state_topic_df],
1027
+ outputs=[topic_status, topic_table, state_topic_df, topic_download]
1028
+ )
1029
+
1030
+ state_topic_df.change(
1031
+ fn=lambda df: (
1032
+ f"<div class='success-box'>βœ… {len(df)} topics available.</div>"
1033
+ if df is not None and not df.empty else "",
1034
+ df if df is not None else pd.DataFrame()
1035
+ ),
1036
+ inputs=[state_topic_df],
1037
+ outputs=[topic_status, topic_table]
1038
+ )
1039
+
1040
+ def handle_cooccurrence(topic_df, lda_result):
1041
+ if topic_df is None or lda_result is None:
1042
+ return "Run topic modeling first."
1043
+ return _compute_cooccurrences(topic_df, lda_result)
1044
+
1045
+ btn_cooccurrence.click(
1046
+ fn=handle_cooccurrence,
1047
+ inputs=[state_topic_df, state_lda_result],
1048
+ outputs=[cooccurrence_display]
1049
+ )
1050
+
1051
+ # ==================================================================
1052
+ # TAB 3 β€” PAJAIS Taxonomy Mapping
1053
+ # ==================================================================
1054
+ with gr.Tab("πŸ—Ί PAJAIS Taxonomy Mapping"):
1055
+ gr.Markdown("## Phase 5: Research Gap Analysis")
1056
+ btn_run_mapping = gr.Button(
1057
+ "β–Ά Run PAJAIS Mapping",
1058
+ variant="primary",
1059
+ elem_id="btn_run_mapping"
1060
+ )
1061
+ mapping_status = gr.Markdown(
1062
+ value="*Run mapping or use the full pipeline from Tab 1.*",
1063
+ elem_id="mapping_status"
1064
+ )
1065
+ with gr.Row():
1066
+ with gr.Column():
1067
+ gr.Markdown("### πŸ”΅ MAPPED Themes")
1068
+ mapped_table = gr.DataFrame(
1069
+ label="Mapped Topics",
1070
+ show_label=True,
1071
+ elem_id="mapped_table",
1072
+ wrap=True
1073
+ )
1074
+ with gr.Column():
1075
+ gr.Markdown("### πŸ”΄ NOVEL Themes")
1076
+ novel_table = gr.DataFrame(
1077
+ label="Novel Topics",
1078
+ show_label=True,
1079
+ elem_id="novel_table",
1080
+ wrap=True
1081
+ )
1082
+ gap_score = gr.Markdown(elem_id="gap_score")
1083
+ # BUG 5 FIX: value=None
1084
+ mapping_download = gr.DownloadButton(
1085
+ label="⬇ Download pajais_mapping.csv",
1086
+ value=None,
1087
+ elem_id="mapping_dl"
1088
+ )
1089
+ gr.Markdown("### πŸ’‘ Generate Publication Pitch")
1090
+ gr.Markdown(
1091
+ "Select a NOVEL theme label and click below to generate "
1092
+ "a structured abstract pitch."
1093
+ )
1094
+ novel_label_input = gr.Textbox(
1095
+ label="NOVEL Theme Label",
1096
+ placeholder="Paste a novel theme label here...",
1097
+ show_label=True,
1098
+ elem_id="novel_label_input"
1099
+ )
1100
+ btn_gen_pitch = gr.Button(
1101
+ "Generate Publication Pitch",
1102
+ variant="secondary",
1103
+ elem_id="btn_gen_pitch"
1104
+ )
1105
+ pitch_output = gr.Markdown(elem_id="pitch_output")
1106
+
1107
+ def _mapping_outputs(topic_df, taxonomy_map, coverage):
1108
+ """
1109
+ Returns exactly 5 values:
1110
+ (status_md, mapped_df, novel_df, gap_md, taxonomy_map)
1111
+ """
1112
+ if topic_df is None or topic_df.empty:
1113
+ return (
1114
+ "<div class='error-box'>No data.</div>",
1115
+ pd.DataFrame(), pd.DataFrame(),
1116
+ f"**Research Gap Score:** 0 of {len(PAJAIS_THEMES)} themes covered.",
1117
+ taxonomy_map
1118
+ )
1119
+ mapped_sub = pd.DataFrame()
1120
+ novel_sub = pd.DataFrame()
1121
+ if 'status' in topic_df.columns:
1122
+ mapped_sub = topic_df[topic_df['status'] == 'MAPPED']
1123
+ novel_sub = topic_df[topic_df['status'] == 'NOVEL']
1124
+ gap = taxonomy_map.get('gap_analysis', {}) if taxonomy_map else {}
1125
+ covered = len(gap.get('covered_themes', []))
1126
+ total = len(PAJAIS_THEMES)
1127
+ status_md = "<div class='success-box'>βœ… Mapping complete.</div>"
1128
+ gap_md = (
1129
+ f"**Research Gap Score: {covered} of {total} PAJAIS themes covered** "
1130
+ f"({coverage:.1f}%)"
1131
+ )
1132
+ return status_md, mapped_sub, novel_sub, gap_md, taxonomy_map
1133
+
1134
+ def handle_mapping(topic_df, existing_map, progress=gr.Progress(track_tqdm=True)):
1135
+ if existing_map is not None:
1136
+ gap = existing_map.get('gap_analysis', {})
1137
+ coverage = gap.get('coverage_pct', 0)
1138
+ # _mapping_outputs returns exactly 5 values β€” correct
1139
+ return _mapping_outputs(topic_df, existing_map, coverage)
1140
+ if topic_df is None or topic_df.empty:
1141
+ return (
1142
+ "<div class='error-box'>❌ Run topic modeling first.</div>",
1143
+ pd.DataFrame(), pd.DataFrame(), "", existing_map
1144
+ )
1145
+ try:
1146
+ from tools import map_topics_to_pajais, generate_taxonomy_map
1147
+ _ensure_output_dir()
1148
+ progress(0.4, desc="Mapping topics...")
1149
+ mapped_df = map_topics_to_pajais(topic_df)
1150
+ progress(0.8, desc="Building taxonomy map...")
1151
+ taxonomy_map = generate_taxonomy_map(mapped_df)
1152
+ progress(1.0, desc="Done!")
1153
+ # Save outputs
1154
+ _safe_save_csv(mapped_df, 'pajais_mapping.csv')
1155
+ _safe_save_json(taxonomy_map, 'taxonomy_map.json')
1156
+ gap = taxonomy_map.get('gap_analysis', {})
1157
+ coverage = gap.get('coverage_pct', 0)
1158
+ # BUG 4 FIX: _mapping_outputs already returns 5 values including
1159
+ # taxonomy_map as the 5th. Do NOT append (taxonomy_map,) again.
1160
+ return _mapping_outputs(mapped_df, taxonomy_map, coverage)
1161
+ except Exception as e:
1162
+ return (
1163
+ f"<div class='error-box'>❌ {e}</div>",
1164
+ pd.DataFrame(), pd.DataFrame(), "", existing_map
1165
+ )
1166
+
1167
+ btn_run_mapping.click(
1168
+ fn=handle_mapping,
1169
+ inputs=[state_topic_df, state_taxonomy_map],
1170
+ outputs=[mapping_status, mapped_table, novel_table, gap_score, state_taxonomy_map]
1171
+ )
1172
+
1173
+ state_taxonomy_map.change(
1174
+ fn=lambda tm, td: _mapping_outputs(
1175
+ td, tm,
1176
+ tm.get('gap_analysis', {}).get('coverage_pct', 0) if tm else 0
1177
+ ),
1178
+ inputs=[state_taxonomy_map, state_topic_df],
1179
+ outputs=[mapping_status, mapped_table, novel_table, gap_score, state_taxonomy_map]
1180
+ )
1181
+
1182
+ btn_gen_pitch.click(
1183
+ fn=lambda label: _generate_publication_pitch(label) if label.strip() else "Enter a theme label above.",
1184
+ inputs=[novel_label_input],
1185
+ outputs=[pitch_output]
1186
+ )
1187
+
1188
+ # ==================================================================
1189
+ # TAB 4 β€” Abstract vs Title Analysis
1190
+ # ==================================================================
1191
+ with gr.Tab("πŸ“‘ Abstract vs Title Analysis"):
1192
+ gr.Markdown("## Phase 4: Abstract vs Title Theme Comparison")
1193
+ btn_run_comparison = gr.Button(
1194
+ "β–Ά Compare Abstracts vs Titles",
1195
+ variant="primary",
1196
+ elem_id="btn_run_comparison"
1197
+ )
1198
+ comparison_status = gr.Markdown(elem_id="comparison_status")
1199
+ with gr.Row():
1200
+ with gr.Column():
1201
+ gr.Markdown("### πŸ“„ Abstract-Derived Themes")
1202
+ abstract_table = gr.DataFrame(
1203
+ label="Abstract Topics",
1204
+ show_label=True,
1205
+ elem_id="abstract_table",
1206
+ wrap=True
1207
+ )
1208
+ with gr.Column():
1209
+ gr.Markdown("### 🏷 Title-Derived Themes")
1210
+ title_table = gr.DataFrame(
1211
+ label="Title Topics",
1212
+ show_label=True,
1213
+ elem_id="title_table",
1214
+ wrap=True
1215
+ )
1216
+ divergence_md = gr.Markdown(elem_id="divergence_md")
1217
+ # BUG 5 FIX: value=None
1218
+ comparison_download = gr.DownloadButton(
1219
+ label="⬇ Download comparison.csv",
1220
+ value=None,
1221
+ elem_id="comparison_dl"
1222
+ )
1223
+ btn_iceberg = gr.Button(
1224
+ "🧊 Show Iceberg Topics",
1225
+ variant="secondary",
1226
+ elem_id="btn_iceberg"
1227
+ )
1228
+ iceberg_display = gr.Markdown(elem_id="iceberg_display")
1229
+
1230
+ def _split_comparison(comp_df):
1231
+ if comp_df is None or comp_df.empty:
1232
+ return "<div class='error-box'>No data.</div>", pd.DataFrame(), pd.DataFrame(), ""
1233
+ ab = comp_df[comp_df['source'] == 'abstract']
1234
+ ti = comp_df[comp_df['source'] == 'title']
1235
+ ab_excl = ab[ab['unique_to_source'] == True]['label'].tolist()
1236
+ ti_excl = ti[ti['unique_to_source'] == True]['label'].tolist()
1237
+ divergence = ""
1238
+ if ab_excl:
1239
+ divergence += f"**Abstract-exclusive topics:** {', '.join(ab_excl[:5])}\n\n"
1240
+ if ti_excl:
1241
+ divergence += f"**Title-exclusive topics:** {', '.join(ti_excl[:5])}"
1242
+ return (
1243
+ "<div class='success-box'>βœ… Comparison complete.</div>",
1244
+ ab, ti, divergence
1245
+ )
1246
+
1247
+ def handle_comparison(df, existing_comp, progress=gr.Progress(track_tqdm=True)):
1248
+ if existing_comp is not None and not existing_comp.empty:
1249
+ return _split_comparison(existing_comp) + (existing_comp,)
1250
+ if df is None or df.empty:
1251
+ return (
1252
+ "<div class='error-box'>❌ Load data first.</div>",
1253
+ pd.DataFrame(), pd.DataFrame(), "", None
1254
+ )
1255
+ try:
1256
+ from tools import compare_abstract_vs_title_themes
1257
+ _ensure_output_dir()
1258
+ progress(0.2, desc="Running LDA on abstracts...")
1259
+ comp_df = compare_abstract_vs_title_themes(df, n_topics_each=15)
1260
+ progress(1.0, desc="Done!")
1261
+ _safe_save_csv(comp_df, 'comparison.csv')
1262
+ return _split_comparison(comp_df) + (comp_df,)
1263
+ except Exception as e:
1264
+ return (
1265
+ f"<div class='error-box'>❌ {e}</div>",
1266
+ pd.DataFrame(), pd.DataFrame(), "", None
1267
+ )
1268
+
1269
+ btn_run_comparison.click(
1270
+ fn=handle_comparison,
1271
+ inputs=[state_df, state_comparison_df],
1272
+ outputs=[comparison_status, abstract_table, title_table, divergence_md, state_comparison_df]
1273
+ )
1274
+
1275
+ state_comparison_df.change(
1276
+ fn=lambda cd: _split_comparison(cd) + (cd,) if cd is not None else (
1277
+ "", pd.DataFrame(), pd.DataFrame(), "", None
1278
+ ),
1279
+ inputs=[state_comparison_df],
1280
+ outputs=[comparison_status, abstract_table, title_table, divergence_md, state_comparison_df]
1281
+ )
1282
+
1283
+ btn_iceberg.click(
1284
+ fn=lambda cd: _compute_iceberg_topics(cd),
1285
+ inputs=[state_comparison_df],
1286
+ outputs=[iceberg_display]
1287
+ )
1288
+
1289
+ # ==================================================================
1290
+ # TAB 5 β€” Section 7 Narrative
1291
+ # ==================================================================
1292
+ with gr.Tab("✍ Section 7 Narrative"):
1293
+ gr.Markdown("## Phase 6: Generate Academic Narrative Draft")
1294
+ btn_run_narrative = gr.Button(
1295
+ "β–Ά Generate Narrative",
1296
+ variant="primary",
1297
+ elem_id="btn_run_narrative"
1298
+ )
1299
+ narrative_box = gr.Textbox(
1300
+ label="Section 7 Narrative Draft (~500 words)",
1301
+ lines=25,
1302
+ show_label=True,
1303
+ elem_id="narrative_textbox",
1304
+ interactive=False
1305
+ )
1306
+ # BUG 5 FIX: value=None
1307
+ narrative_download = gr.DownloadButton(
1308
+ label="⬇ Download narrative.txt",
1309
+ value=None,
1310
+ elem_id="narrative_dl"
1311
+ )
1312
+ btn_copy = gr.Button(
1313
+ "πŸ“‹ Copy to Clipboard",
1314
+ variant="secondary",
1315
+ elem_id="btn_copy_narrative"
1316
+ )
1317
+ copy_status = gr.Markdown(elem_id="copy_status")
1318
+ gr.Markdown("### πŸ“š Generate Sample APA Citation")
1319
+ btn_citation = gr.Button(
1320
+ "Generate Sample Citation",
1321
+ variant="secondary",
1322
+ elem_id="btn_citation"
1323
+ )
1324
+ citation_output = gr.Markdown(elem_id="citation_output")
1325
+
1326
+ def handle_narrative(taxonomy_map, comparison_df, topic_df, progress=gr.Progress(track_tqdm=True)):
1327
+ if not taxonomy_map and (topic_df is None or topic_df.empty):
1328
+ return "<No analysis results yet. Run the full pipeline first.>", gr.update()
1329
+ try:
1330
+ from tools import generate_section7_narrative
1331
+ _ensure_output_dir()
1332
+ progress(0.5, desc="Generating narrative...")
1333
+ narrative = generate_section7_narrative(
1334
+ taxonomy_map=taxonomy_map or {},
1335
+ comparison_df=comparison_df if comparison_df is not None else pd.DataFrame(),
1336
+ topic_df=topic_df if topic_df is not None else pd.DataFrame(),
1337
+ )
1338
+ progress(1.0, desc="Done!")
1339
+ saved_path = _safe_save_text(narrative, 'narrative.txt')
1340
+ return narrative, gr.update(value=saved_path)
1341
+ except Exception as e:
1342
+ return f"Narrative generation failed: {e}", gr.update()
1343
+
1344
+ btn_run_narrative.click(
1345
+ fn=handle_narrative,
1346
+ inputs=[state_taxonomy_map, state_comparison_df, state_topic_df],
1347
+ outputs=[narrative_box, narrative_download]
1348
+ )
1349
+
1350
+ state_agent_result.change(
1351
+ fn=lambda r: (r.get('narrative', '') if r else '', gr.update()),
1352
+ inputs=[state_agent_result],
1353
+ outputs=[narrative_box, narrative_download]
1354
+ )
1355
+
1356
+ btn_copy.click(
1357
+ fn=lambda text: "βœ… Copied! (use Ctrl+C if clipboard API unavailable)",
1358
+ inputs=[narrative_box],
1359
+ outputs=[copy_status],
1360
+ js="""(text) => {
1361
+ navigator.clipboard.writeText(text).then(
1362
+ () => console.log('Copied'),
1363
+ () => console.warn('Clipboard API unavailable')
1364
+ );
1365
+ return 'βœ… Copied to clipboard!';
1366
+ }"""
1367
+ )
1368
+
1369
+ btn_citation.click(
1370
+ fn=lambda td: _generate_apa_citation(td),
1371
+ inputs=[state_topic_df],
1372
+ outputs=[citation_output]
1373
+ )
1374
+
1375
+ # ==================================================================
1376
+ # TAB 6 β€” Research Intelligence Dashboard
1377
+ # ==================================================================
1378
+ with gr.Tab("πŸ“Š Research Intelligence Dashboard"):
1379
+ gr.Markdown("## Research Intelligence Dashboard")
1380
+ gr.Markdown(
1381
+ "*Dashboard populates automatically after pipeline completion.*"
1382
+ )
1383
+ with gr.Row():
1384
+ card_topics = gr.Markdown("**--**\nTotal Topics", elem_id="card_topics")
1385
+ card_novel = gr.Markdown("**--**\nNovel Themes", elem_id="card_novel")
1386
+ card_coverage = gr.Markdown("**--**\nPAJAIS Coverage", elem_id="card_coverage")
1387
+ card_publishable = gr.Markdown("**--**\nPublishable Gaps", elem_id="card_publishable")
1388
+ with gr.Row():
1389
+ plot_dist = gr.Plot(label="Topic Distribution", elem_id="plot_dist")
1390
+ plot_pie = gr.Plot(label="Mapped vs Novel", elem_id="plot_pie")
1391
+ plot_top15 = gr.Plot(
1392
+ label="Top 15 Topics by Document Count",
1393
+ elem_id="plot_top15"
1394
+ )
1395
+ supplementary_panel = gr.Markdown(elem_id="supplementary_panel")
1396
+
1397
+ def update_dashboard(result, topic_df, taxonomy_map):
1398
+ if result is None:
1399
+ return (
1400
+ "**--**\nTotal Topics", "**--**\nNovel Themes",
1401
+ "**--**\nPAJAIS Coverage", "**--**\nPublishable Gaps",
1402
+ None, None, None, ""
1403
+ )
1404
+ try:
1405
+ n_topics = result.get('topic_count', 0)
1406
+ n_novel = result.get('novel_count', 0)
1407
+ coverage = result.get('pajais_coverage_pct', 0.0)
1408
+ pub_count = len(taxonomy_map.get('publishable_novel_themes', [])) if taxonomy_map else 0
1409
+ c1 = f"<div class='metric-card'><span class='metric-value'>{n_topics}</span><span class='metric-label'>Total Topics</span></div>"
1410
+ c2 = f"<div class='metric-card'><span class='metric-value'>{n_novel}</span><span class='metric-label'>Novel Themes</span></div>"
1411
+ c3 = f"<div class='metric-card'><span class='metric-value'>{coverage:.0f}%</span><span class='metric-label'>PAJAIS Coverage</span></div>"
1412
+ c4 = f"<div class='metric-card'><span class='metric-value'>{pub_count}</span><span class='metric-label'>Publishable Gaps</span></div>"
1413
+ fig_dist = _plot_topic_distribution(topic_df)
1414
+ fig_pie = _plot_mapped_novel_pie(taxonomy_map)
1415
+ fig_top15 = _plot_topic_distribution(topic_df)
1416
+ supp = result.get('supplementary_insights', {})
1417
+ blind = supp.get('blind_spot_theme', {})
1418
+ golden = supp.get('golden_year', {})
1419
+ supp_md = ""
1420
+ if blind:
1421
+ supp_md += (
1422
+ f"\n### 🎯 High-Frequency Unaddressed Theme\n"
1423
+ f"**{blind.get('label', 'Unknown')}** β€” "
1424
+ f"appears in **{blind.get('doc_count', 0)} documents** "
1425
+ f"but has not been formally addressed in PAJAIS.\n\n"
1426
+ f"*First-mover publication advantage is estimated at 18–24 months.*\n\n"
1427
+ f"**Top words:** {blind.get('top_words', '')}\n"
1428
+ )
1429
+ if golden:
1430
+ supp_md += (
1431
+ f"\n### πŸ“… Peak Research Diversity Year\n"
1432
+ f"**{golden.get('year', 'N/A')}** showed the greatest topic diversity "
1433
+ f"(Shannon entropy = {golden.get('entropy', 0):.3f})\n"
1434
+ )
1435
+ return c1, c2, c3, c4, fig_dist, fig_pie, fig_top15, supp_md
1436
+ except Exception as e:
1437
+ logger.error(f"Dashboard update failed: {e}")
1438
+ return (
1439
+ "Error", "Error", "Error", "Error",
1440
+ None, None, None, f"Dashboard error: {e}"
1441
+ )
1442
+
1443
+ state_agent_result.change(
1444
+ fn=update_dashboard,
1445
+ inputs=[state_agent_result, state_topic_df, state_taxonomy_map],
1446
+ outputs=[
1447
+ card_topics, card_novel, card_coverage, card_publishable,
1448
+ plot_dist, plot_pie, plot_top15, supplementary_panel
1449
+ ]
1450
+ )
1451
+
1452
+ # ==================================================================
1453
+ # TAB A β€” DBSCAN Clusters (Phase 2.5)
1454
+ # ==================================================================
1455
+ with gr.Tab("πŸ”΅ DBSCAN Clusters"):
1456
+ gr.Markdown("## Phase 2.5: Semantic Clustering via DBSCAN")
1457
+ gr.Markdown(
1458
+ "Papers are embedded separately as **title vectors** and **abstract vectors** "
1459
+ "(TF-IDF β†’ LSA), clustered independently with DBSCAN, then merged via weighted vote. "
1460
+ "Large clusters are recursively split; tiny clusters are reassigned or marked noise."
1461
+ )
1462
+
1463
+ with gr.Accordion("βš™οΈ Clustering Parameters", open=False):
1464
+ with gr.Row():
1465
+ eps_title_slider = gr.Slider(
1466
+ 0.05, 0.60, value=0.25, step=0.01, label="Ξ΅ Title (cosine distance threshold)"
1467
+ )
1468
+ eps_abstract_slider = gr.Slider(
1469
+ 0.05, 0.60, value=0.30, step=0.01, label="Ξ΅ Abstract"
1470
+ )
1471
+ with gr.Row():
1472
+ min_samples_slider = gr.Slider(
1473
+ 2, 20, value=2, step=1, label="Min Samples (DBSCAN core-point threshold)"
1474
+ )
1475
+ min_members_slider = gr.Slider(
1476
+ 2, 20, value=3, step=1, label="Min Cluster Membership (post-processing)"
1477
+ )
1478
+ with gr.Row():
1479
+ max_size_slider = gr.Slider(
1480
+ 10, 200, value=30, step=5, label="Max Cluster Size (triggers splitting)"
1481
+ )
1482
+ vote_weight_slider = gr.Slider(
1483
+ 0.0, 1.0, value=0.6, step=0.05, label="Abstract Vote Weight (vs Title)"
1484
+ )
1485
+
1486
+ with gr.Row():
1487
+ btn_run_dbscan = gr.Button("β–Ά Run DBSCAN Clustering", variant="primary")
1488
+ btn_llm_label = gr.Button("πŸ€– Label Clusters with LLM", variant="secondary")
1489
+
1490
+ dbscan_status = gr.Markdown("*Run DBSCAN or use the full pipeline from Tab 1.*")
1491
+
1492
+ with gr.Row():
1493
+ with gr.Column(scale=1):
1494
+ gr.Markdown("### πŸ“Š Cluster Summary")
1495
+ cluster_summary_table = gr.DataFrame(
1496
+ label="Clusters (sorted by size)",
1497
+ show_label=True,
1498
+ wrap=True
1499
+ )
1500
+ with gr.Column(scale=2):
1501
+ gr.Markdown("### πŸ“„ Document-Level Assignments")
1502
+ cluster_doc_table = gr.DataFrame(
1503
+ label="Per-Document Cluster Assignments",
1504
+ show_label=True,
1505
+ wrap=True
1506
+ )
1507
+
1508
+ with gr.Row():
1509
+ plot_cluster_sizes = gr.Plot(label="Cluster Size Distribution")
1510
+ plot_noise_pie = gr.Plot(label="Clustered vs Noise")
1511
+
1512
+ with gr.Row():
1513
+ dl_cluster_docs = gr.DownloadButton("⬇ cluster_documents.csv", value=None)
1514
+ dl_cluster_summary = gr.DownloadButton("⬇ cluster_summary.csv", value=None)
1515
+ dl_cluster_labels = gr.DownloadButton("⬇ cluster_labels.csv", value=None)
1516
+
1517
+ # ---- Handlers ----
1518
+
1519
+ def handle_run_dbscan(
1520
+ df, existing_cluster_df, existing_summary,
1521
+ eps_t, eps_a, min_s, min_m, max_sz, vote_w,
1522
+ progress=gr.Progress(track_tqdm=True)
1523
+ ):
1524
+ if existing_cluster_df is not None and not existing_cluster_df.empty:
1525
+ summary = get_cluster_summary(existing_cluster_df)
1526
+ fig_sz, fig_noise = _plot_cluster_charts(existing_cluster_df)
1527
+ saved_docs = _safe_save_csv(existing_cluster_df, "cluster_documents.csv")
1528
+ saved_sum = _safe_save_csv(summary, "cluster_summary.csv")
1529
+ return (
1530
+ "<div class='success-box'>βœ… Loaded existing DBSCAN results.</div>",
1531
+ summary, existing_cluster_df, summary,
1532
+ fig_sz, fig_noise,
1533
+ gr.update(value=saved_docs), gr.update(value=saved_sum), gr.update(),
1534
+ )
1535
+
1536
+ if df is None or df.empty:
1537
+ return (
1538
+ "<div class='error-box'>❌ Upload and load data first.</div>",
1539
+ pd.DataFrame(), pd.DataFrame(), None,
1540
+ None, None,
1541
+ gr.update(), gr.update(), gr.update(),
1542
+ )
1543
+
1544
+ try:
1545
+ _ensure_output_dir()
1546
+ progress(0.1, desc="Vectorising documents…")
1547
+ cdf = dbscan_cluster_topics(
1548
+ df,
1549
+ eps_title=eps_t, eps_abstract=eps_a,
1550
+ min_samples=int(min_s),
1551
+ n_svd_components=64,
1552
+ vote_weight_abstract=vote_w,
1553
+ )
1554
+ progress(0.5, desc="Enforcing min membership…")
1555
+ cdf = enforce_min_membership(cdf, min_members=int(min_m))
1556
+ progress(0.7, desc="Splitting large clusters…")
1557
+ cdf = split_large_clusters(cdf, max_cluster_size=int(max_sz))
1558
+ progress(0.9, desc="Summarising…")
1559
+ summary = get_cluster_summary(cdf)
1560
+ progress(1.0, desc="Done!")
1561
+
1562
+ fig_sz, fig_noise = _plot_cluster_charts(cdf)
1563
+ saved_docs = _safe_save_csv(cdf, "cluster_documents.csv")
1564
+ saved_sum = _safe_save_csv(summary, "cluster_summary.csv")
1565
+
1566
+ n_c = len(set(cdf["cluster_final"]) - {-1})
1567
+ n_n = int(cdf["is_noise"].sum())
1568
+ return (
1569
+ f"<div class='success-box'>βœ… {n_c} clusters found, {n_n} noise docs.</div>",
1570
+ summary, cdf, summary,
1571
+ fig_sz, fig_noise,
1572
+ gr.update(value=saved_docs), gr.update(value=saved_sum), gr.update(),
1573
+ )
1574
+ except Exception as e:
1575
+ return (
1576
+ f"<div class='error-box'>❌ {e}</div>",
1577
+ pd.DataFrame(), pd.DataFrame(), None,
1578
+ None, None,
1579
+ gr.update(), gr.update(), gr.update(),
1580
+ )
1581
+
1582
+ def handle_llm_label(cluster_df, cluster_summary, progress=gr.Progress(track_tqdm=True)):
1583
+ if cluster_df is None or cluster_df.empty:
1584
+ return (
1585
+ "<div class='error-box'>❌ Run DBSCAN first.</div>",
1586
+ cluster_summary, gr.update()
1587
+ )
1588
+ try:
1589
+ _ensure_output_dir()
1590
+ progress(0.2, desc="Sending clusters to LLM…")
1591
+ labeled = label_clusters_with_llm(
1592
+ cluster_df=cluster_df,
1593
+ cluster_summary_df=cluster_summary.copy() if cluster_summary is not None else get_cluster_summary(cluster_df),
1594
+ max_clusters=50,
1595
+ )
1596
+ progress(1.0, desc="Done!")
1597
+ saved = _safe_save_csv(labeled, "cluster_labels.csv")
1598
+ return (
1599
+ "<div class='success-box'>βœ… Clusters labeled by LLM.</div>",
1600
+ labeled,
1601
+ gr.update(value=saved),
1602
+ )
1603
+ except Exception as e:
1604
+ return (
1605
+ f"<div class='error-box'>❌ LLM labeling failed: {e}</div>",
1606
+ cluster_summary, gr.update()
1607
+ )
1608
+
1609
+ btn_run_dbscan.click(
1610
+ fn=handle_run_dbscan,
1611
+ inputs=[
1612
+ state_df, state_cluster_df, state_cluster_summary,
1613
+ eps_title_slider, eps_abstract_slider,
1614
+ min_samples_slider, min_members_slider,
1615
+ max_size_slider, vote_weight_slider,
1616
+ ],
1617
+ outputs=[
1618
+ dbscan_status,
1619
+ cluster_summary_table, cluster_doc_table, state_cluster_summary,
1620
+ plot_cluster_sizes, plot_noise_pie,
1621
+ dl_cluster_docs, dl_cluster_summary, dl_cluster_labels,
1622
+ ]
1623
+ )
1624
+
1625
+ btn_llm_label.click(
1626
+ fn=handle_llm_label,
1627
+ inputs=[state_cluster_df, state_cluster_summary],
1628
+ outputs=[dbscan_status, cluster_summary_table, dl_cluster_labels]
1629
+ )
1630
+
1631
+ # Auto-populate when pipeline result loads cluster data
1632
+ state_cluster_df.change(
1633
+ fn=lambda cdf: (
1634
+ get_cluster_summary(cdf) if cdf is not None and not cdf.empty else pd.DataFrame(),
1635
+ cdf if cdf is not None else pd.DataFrame(),
1636
+ ),
1637
+ inputs=[state_cluster_df],
1638
+ outputs=[cluster_summary_table, cluster_doc_table]
1639
+ )
1640
+
1641
+ # ==================================================================
1642
+ # TAB B β€” Agentic Council (Phase 6.5)
1643
+ # ==================================================================
1644
+ with gr.Tab("🧠 Agentic Council"):
1645
+ gr.Markdown("## Phase 6.5: Multi-Model Research Council")
1646
+ gr.Markdown(
1647
+ "Three AI models independently assess the PAJAIS research gap findings:\n"
1648
+ "- **Mistral** (Panel A) β€” pragmatic applied IS perspective\n"
1649
+ "- **Gemini** (Panel B) β€” broad technology futures perspective\n"
1650
+ "- **Claude** (Synthesis Judge) β€” consensus arbitration and final recommendation\n\n"
1651
+ "API keys are entered below and never stored."
1652
+ )
1653
+
1654
+ with gr.Accordion("πŸ”‘ API Keys (required)", open=True):
1655
+ with gr.Row():
1656
+ mistral_key_input = gr.Textbox(
1657
+ label="Mistral API Key",
1658
+ placeholder="sk-...",
1659
+ type="password",
1660
+ show_label=True,
1661
+ )
1662
+ gemini_key_input = gr.Textbox(
1663
+ label="Google Gemini API Key",
1664
+ placeholder="AIza...",
1665
+ type="password",
1666
+ show_label=True,
1667
+ )
1668
+ anthropic_key_input = gr.Textbox(
1669
+ label="Anthropic API Key (synthesis judge)",
1670
+ placeholder="sk-ant-...",
1671
+ type="password",
1672
+ show_label=True,
1673
+ )
1674
+
1675
+ btn_run_council = gr.Button("πŸš€ Convene Research Council", variant="primary")
1676
+ council_status = gr.Markdown("*Enter API keys and run taxonomy mapping first.*")
1677
+
1678
+ with gr.Row():
1679
+ with gr.Column():
1680
+ gr.Markdown("### 🟒 Panel A β€” Mistral")
1681
+ mistral_output = gr.Textbox(
1682
+ label="Mistral Assessment",
1683
+ lines=14,
1684
+ interactive=False,
1685
+ show_label=True,
1686
+ )
1687
+ with gr.Column():
1688
+ gr.Markdown("### πŸ”΅ Panel B β€” Gemini")
1689
+ gemini_output = gr.Textbox(
1690
+ label="Gemini Assessment",
1691
+ lines=14,
1692
+ interactive=False,
1693
+ show_label=True,
1694
+ )
1695
+
1696
+ gr.Markdown("### βš–οΈ Claude Synthesis β€” Consensus, Divergence & Final Recommendation")
1697
+ synthesis_output = gr.Textbox(
1698
+ label="Synthesised Council Verdict",
1699
+ lines=18,
1700
+ interactive=False,
1701
+ show_label=True,
1702
+ )
1703
+
1704
+ with gr.Row():
1705
+ findings_summary_box = gr.Textbox(
1706
+ label="Findings Sent to Council",
1707
+ lines=8,
1708
+ interactive=False,
1709
+ show_label=True,
1710
+ )
1711
+
1712
+ dl_council = gr.DownloadButton("⬇ council_report.json", value=None)
1713
+
1714
+ # ---- Handler ----
1715
+
1716
+ def handle_run_council(
1717
+ taxonomy_map, topic_df,
1718
+ mistral_key, gemini_key, anthropic_key,
1719
+ progress=gr.Progress(track_tqdm=True)
1720
+ ):
1721
+ if not taxonomy_map:
1722
+ return (
1723
+ "<div class='error-box'>❌ Run taxonomy mapping first (Tab 3 or full pipeline).</div>",
1724
+ "", "", "", "", gr.update()
1725
+ )
1726
+ if not any([mistral_key.strip(), gemini_key.strip(), anthropic_key.strip()]):
1727
+ return (
1728
+ "<div class='error-box'>❌ Provide at least one API key.</div>",
1729
+ "", "", "", "", gr.update()
1730
+ )
1731
+
1732
+ try:
1733
+ _ensure_output_dir()
1734
+ progress(0.1, desc="Preparing findings…")
1735
+ result = run_agentic_council(
1736
+ taxonomy_map=taxonomy_map,
1737
+ topic_df=topic_df,
1738
+ mistral_api_key=mistral_key,
1739
+ gemini_api_key=gemini_key,
1740
+ anthropic_api_key=anthropic_key,
1741
+ )
1742
+ progress(0.9, desc="Saving report…")
1743
+ saved = _safe_save_json(result, "council_report.json")
1744
+ progress(1.0, desc="Council complete!")
1745
+
1746
+ status = "<div class='success-box'>βœ… Council complete. See verdicts below.</div>"
1747
+ return (
1748
+ status,
1749
+ result.get("mistral", ""),
1750
+ result.get("gemini", ""),
1751
+ result.get("synthesis", ""),
1752
+ result.get("findings_summary", ""),
1753
+ gr.update(value=saved),
1754
+ )
1755
+ except Exception as e:
1756
+ return (
1757
+ f"<div class='error-box'>❌ Council failed: {e}</div>",
1758
+ "", "", "", "", gr.update()
1759
+ )
1760
+
1761
+ btn_run_council.click(
1762
+ fn=handle_run_council,
1763
+ inputs=[
1764
+ state_taxonomy_map, state_topic_df,
1765
+ mistral_key_input, gemini_key_input, anthropic_key_input,
1766
+ ],
1767
+ outputs=[
1768
+ council_status,
1769
+ mistral_output, gemini_output, synthesis_output,
1770
+ findings_summary_box,
1771
+ dl_council,
1772
+ ]
1773
+ )
1774
+
1775
+ # Auto-fill if council already ran (e.g. via full pipeline)
1776
+ state_council_result.change(
1777
+ fn=lambda cr: (
1778
+ cr.get("mistral", "") if cr else "",
1779
+ cr.get("gemini", "") if cr else "",
1780
+ cr.get("synthesis", "") if cr else "",
1781
+ cr.get("findings_summary", "") if cr else "",
1782
+ ),
1783
+ inputs=[state_council_result],
1784
+ outputs=[mistral_output, gemini_output, synthesis_output, findings_summary_box]
1785
+ )
1786
+
1787
+ # ==================================================================
1788
+ # TAB 7 β€” Export Center
1789
+ # ==================================================================
1790
+ with gr.Tab("πŸ“¦ Export Center"):
1791
+ gr.Markdown("## Export Center & Methodology Notes")
1792
+ with gr.Row():
1793
+ # BUG 5 FIX: all value=None β€” updated dynamically after pipeline
1794
+ dl_topic = gr.DownloadButton(
1795
+ "⬇ topic_review_table.csv",
1796
+ value=None,
1797
+ elem_id="dl_topic"
1798
+ )
1799
+ dl_mapping = gr.DownloadButton(
1800
+ "⬇ pajais_mapping.csv",
1801
+ value=None,
1802
+ elem_id="dl_mapping"
1803
+ )
1804
+ dl_comparison = gr.DownloadButton(
1805
+ "⬇ comparison.csv",
1806
+ value=None,
1807
+ elem_id="dl_comparison"
1808
+ )
1809
+ with gr.Row():
1810
+ dl_taxonomy = gr.DownloadButton(
1811
+ "⬇ taxonomy_map.json",
1812
+ value=None,
1813
+ elem_id="dl_taxonomy"
1814
+ )
1815
+ dl_narrative = gr.DownloadButton(
1816
+ "⬇ narrative.txt",
1817
+ value=None,
1818
+ elem_id="dl_narrative"
1819
+ )
1820
+ dl_log = gr.DownloadButton(
1821
+ "⬇ agent.log",
1822
+ value=str(OUTPUTS_DIR / "agent.log"),
1823
+ elem_id="dl_log"
1824
+ )
1825
+ btn_download_all = gr.Button(
1826
+ "πŸ“¦ Download All as ZIP",
1827
+ variant="primary",
1828
+ elem_id="btn_download_all"
1829
+ )
1830
+ zip_output = gr.File(
1831
+ label="All Artifacts (ZIP)",
1832
+ elem_id="zip_output",
1833
+ visible=False
1834
+ )
1835
+
1836
+ def handle_download_all():
1837
+ zip_path = _make_zip()
1838
+ if zip_path:
1839
+ return zip_path, gr.update(visible=True)
1840
+ return None, gr.update(visible=False, value="No files to download yet.")
1841
+
1842
+ btn_download_all.click(
1843
+ fn=handle_download_all,
1844
+ inputs=[],
1845
+ outputs=[zip_output, zip_output]
1846
+ )
1847
+
1848
+ gr.Markdown("---")
1849
+ btn_print_summary = gr.Button(
1850
+ "πŸ–¨ Print-Ready Summary",
1851
+ variant="secondary",
1852
+ elem_id="btn_print_summary"
1853
+ )
1854
+ print_summary_output = gr.Markdown(elem_id="print_summary_output")
1855
+ btn_print_summary.click(
1856
+ fn=lambda td, tm: _print_ready_summary(td, tm),
1857
+ inputs=[state_topic_df, state_taxonomy_map],
1858
+ outputs=[print_summary_output]
1859
+ )
1860
+
1861
+ gr.Markdown("---")
1862
+ gr.Markdown(
1863
+ """
1864
+ ## πŸ“– Methodology Notes
1865
+ ### LDA Topic Modeling
1866
+ This system uses **Latent Dirichlet Allocation (LDA)** implemented via the
1867
+ [Gensim](https://radimrehurek.com/gensim/) library. LDA is a generative
1868
+ probabilistic model that discovers latent thematic structures in a text
1869
+ corpus by modeling each document as a mixture of topics and each topic as
1870
+ a distribution over words. The pipeline includes bigram phrase detection,
1871
+ TF-IDF filtering, and UMass coherence scoring to ensure topic quality.
1872
+ ### PAJAIS Taxonomy (20 Themes)
1873
+ The 20 canonical PAJAIS themes span IS Strategy, Digital Transformation,
1874
+ IT Adoption, Knowledge Management, E-Commerce, AI/ML, Blockchain,
1875
+ Healthcare IS, Social Media, Big Data, Cloud Computing, Cybersecurity,
1876
+ IS in Asia-Pacific, Mobile Computing, IS Research Methods, Organizational IS,
1877
+ HCI, IS Education, Sustainability, and FinTech.
1878
+ ### Coherence Scoring & Publishability
1879
+ Topic coherence is measured using the UMass metric, which captures semantic
1880
+ relatedness among top topic words. A topic is deemed **publishable** when
1881
+ it meets two thresholds: `doc_count > 5` (sufficient scholarly attention)
1882
+ and `coherence > 0.30` (semantic stability).
1883
+ ### Abstract vs Title Methodology
1884
+ Separate LDA models are trained on article abstracts and titles independently.
1885
+ Topics appearing exclusively in abstracts represent **latent constructs** β€”
1886
+ ideas actively studied but not yet positioned as headline contributions.
1887
+ Topics exclusive to titles signal **positioning keywords** favored by authors
1888
+ as first-impression signals to reviewers and readers.
1889
+ ### DBSCAN Semantic Clustering
1890
+ Papers are embedded using TF-IDF β†’ Truncated SVD (LSA) for both title and
1891
+ abstract text independently. DBSCAN is applied to each embedding space with
1892
+ configurable Ξ΅ and min_samples parameters. Cluster assignments are merged
1893
+ via a weighted vote (configurable abstract weight). Large clusters are
1894
+ recursively bisected; tiny clusters with fewer than min_membership documents
1895
+ are reassigned to their nearest valid cluster or marked as noise.
1896
+ ### Agentic Research Council
1897
+ The council convenes three independent AI models (Mistral, Gemini, Claude)
1898
+ to assess the gap analysis findings from complementary epistemological
1899
+ perspectives. Each panel member produces a structured assessment of the
1900
+ most publishable gaps, methodological recommendations, and regional focus.
1901
+ Claude acts as the synthesis judge, identifying consensus positions,
1902
+ surfacing productive disagreements, and issuing a final ranked recommendation.
1903
+ ---
1904
+ *Built with [Claude Sonnet](https://www.anthropic.com/claude) | Anthropic AI*
1905
+ """
1906
+ )
1907
+
1908
+ # ==================================================================
1909
+ # Wire handle_full_run outputs to all DownloadButtons + new states
1910
+ # ==================================================================
1911
+ btn_full_run.click(
1912
+ fn=handle_full_run,
1913
+ inputs=[file_input],
1914
+ outputs=[
1915
+ validation_info, preview_df,
1916
+ state_df, state_agent_result,
1917
+ state_topic_df, state_comparison_df, state_taxonomy_map,
1918
+ state_lda_result,
1919
+ error_display,
1920
+ # BUG 5 FIX: wire paths back to DownloadButtons across all tabs
1921
+ topic_download, # Tab 2
1922
+ mapping_download, # Tab 3
1923
+ comparison_download, # Tab 4
1924
+ dl_taxonomy, # Export Center
1925
+ narrative_download, # Tab 5
1926
+ dl_topic, # Export Center duplicate
1927
+ dl_mapping, # Export Center duplicate
1928
+ # New: cluster + council states populated if agent ran them
1929
+ state_cluster_df,
1930
+ state_cluster_summary,
1931
+ state_council_result,
1932
+ ]
1933
+ )
1934
+
1935
+
1936
+ # ---------------------------------------------------------------------------
1937
+ # Launch
1938
+ # ---------------------------------------------------------------------------
1939
+ if __name__ == "__main__":
1940
+ demo.launch(
1941
+ server_name="0.0.0.0",
1942
+ server_port=7860,
1943
+ show_error=True,
1944
+ )