Shivam63s commited on
Commit
c4039cf
Β·
verified Β·
1 Parent(s): 3ba3024

ishaan-app

Browse files
Files changed (1) hide show
  1. app.py +405 -287
app.py CHANGED
@@ -1,9 +1,21 @@
1
  # app.py β€” BERTopic Thematic Analysis Agent
2
  # Built specifically for Gradio 6.11.0.
 
 
 
 
 
 
 
 
 
3
 
4
  import sys
5
  import shutil
6
 
 
 
 
7
  try:
8
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
9
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
@@ -22,10 +34,21 @@ import time
22
  import plotly.io as pio
23
  from agent import agent
24
 
25
- _ = [
26
- os.environ.get(k) or print(f"\n{'!'*80}\nCRITICAL WARNING: {k} environment variable is NOT set.\n{'!'*80}\n")
27
- for k in ["MISTRAL_API_KEY", "GROQ_API_KEY", "GOOGLE_API_KEY"]
28
- ]
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  print(f"[app.py] Starting with Gradio {gr.__version__}")
31
 
@@ -45,13 +68,16 @@ EMPTY_REVIEW_DF = pd.DataFrame(
45
  DOWNLOAD_FILES = [
46
  "narrative.txt", "comparison.csv", "themes.json",
47
  "taxonomy_map.json", "labels_abstract.json", "labels_title.json", "labels_combined.json",
 
48
  "summaries_abstract.json", "summaries_title.json", "summaries_combined.json",
 
49
  "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_bars.png",
50
  "chart_title_dbscan_scatter.png", "chart_title_dbscan_bars.png",
51
  "chart_combined_dbscan_scatter.png", "chart_combined_dbscan_bars.png",
52
  "chart_abstract_intertopic.html", "chart_title_intertopic.html", "chart_combined_intertopic.html",
53
  ]
54
 
 
55
  CHECKPOINT_FILES = [
56
  "loaded_data.csv",
57
  "summaries_abstract.json", "summaries_title.json", "summaries_combined.json",
@@ -63,9 +89,11 @@ CHECKPOINT_FILES = [
63
  "chart_title_dbscan_scatter.html", "chart_title_dbscan_bars.html",
64
  "chart_combined_dbscan_scatter.html", "chart_combined_dbscan_bars.html",
65
  "chart_abstract_intertopic.html", "chart_title_intertopic.html", "chart_combined_intertopic.html",
 
66
  "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_bars.png",
67
  "chart_title_dbscan_scatter.png", "chart_title_dbscan_bars.png",
68
  "chart_combined_dbscan_scatter.png", "chart_combined_dbscan_bars.png",
 
69
  ]
70
 
71
  CHART_OPTIONS = [
@@ -85,6 +113,8 @@ PHASE_LABELS = [
85
  ("4","β‘£ Review"), ("5","β‘€ Names"), ("5.5","β‘€Β½ PAJAIS"), ("6","β‘₯ Report"),
86
  ]
87
 
 
 
88
  CORRUPT_HISTORY_SIGNALS = [
89
  "INVALID_CHAT_HISTORY",
90
  "ToolMessage",
@@ -118,6 +148,7 @@ footer { display: none !important; }
118
  }
119
  .resizeable-table-wrap table { min-width: 100%; }
120
 
 
121
  #review_table_wrap .svelte-1o8r8wm,
122
  #review_table_wrap .table-wrap {
123
  resize: vertical;
@@ -150,46 +181,63 @@ footer { display: none !important; }
150
  }
151
  """
152
 
 
 
 
 
 
153
  def _msg(role: str, content: str) -> dict:
154
  return {"role": role, "content": str(content)}
155
 
 
156
  def append_msgs(history: list, user_text: str, bot_text: str) -> list:
 
157
  return history + [_msg("user", user_text), _msg("assistant", bot_text)]
158
 
 
159
  def empty_history() -> list:
160
  return []
161
 
 
 
 
 
162
  def log_error(msg: str, ctx: str = "") -> None:
163
  ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
164
  with open("error.txt", "a", encoding="utf-8") as f:
165
  f.write(f"\n{'='*60}\nTIME: {ts}\nCONTEXT: {ctx}\n"
166
  f"ERROR: {msg}\nTRACEBACK:\n{traceback.format_exc()}\n")
 
167
  try:
168
  print(f"[ERROR] {ctx}: {str(msg)[:120]}")
169
  except UnicodeEncodeError:
170
  print(f"[ERROR] {ctx}: (non-ASCII chars in message β€” see error.txt)")
171
 
 
172
  def safe_str(val) -> str:
173
- def _parse_list_item(item):
174
- match item:
175
- case str(): return item
176
- case dict(): return str(item.get("content", item.get("text", "")))
177
- case _ if hasattr(item, "content"): return safe_str(item.content)
178
- case _: return str(item)
179
-
180
- match val:
181
- case None:
182
- return ""
183
- case str():
184
- return val
185
- case list():
186
- return "\n".join(filter(None, map(_parse_list_item, val)))
187
- case dict():
188
- return str(val.get("content", val.get("text", str(val))))
189
- case _ if hasattr(val, "content"):
190
- return safe_str(val.content)
191
- case _:
192
- return str(val)
 
 
 
193
 
194
  def detect_phase_status() -> dict:
195
  return {
@@ -202,22 +250,19 @@ def detect_phase_status() -> dict:
202
  "6": os.path.exists("narrative.txt"),
203
  }
204
 
 
205
  def build_phase_bar(status: dict) -> str:
206
  items = ""
207
  for key, label in PHASE_LABELS:
208
  done = status.get(key, False)
209
-
210
- # Dispatch tables mapping boolean state to UI properties
211
- bg_map = {True: "#2ecc71", False: "#2a2a3e"}
212
- col_map = {True: "#000", False: "#888"}
213
- bdr_map = {True: "#2ecc71", False: "#444"}
214
- icon_map = {True: "βœ… ", False: ""}
215
-
216
  items += (
217
  f'<span style="display:inline-block;padding:4px 11px;margin:2px;'
218
- f'background:{bg_map[done]};border:1.5px solid {bdr_map[done]};border-radius:18px;'
219
- f'font-size:0.75rem;font-weight:700;color:{col_map[done]};white-space:nowrap;">'
220
- f'{icon_map[done]}{label}</span>'
221
  )
222
  return (
223
  f'<div style="background:#12122a;padding:9px 14px;border-radius:8px;'
@@ -227,289 +272,342 @@ def build_phase_bar(status: dict) -> str:
227
  f'{items}</div>'
228
  )
229
 
 
230
  def parse_phase_status(text, current: dict) -> dict:
231
  text = safe_str(text)
232
  updated = dict(current)
233
  for line in text.splitlines():
234
- try:
235
  raw = line.split("PHASE_STATUS:", 1)[1].strip()
236
  for part in [p.strip() for p in raw.split(",")]:
237
- try:
238
  k, v = part.split("=", 1)
239
  updated[k.strip()] = "βœ…" in v
240
- except ValueError:
241
- pass
242
- except IndexError:
243
- pass
244
-
245
  for k, v in detect_phase_status().items():
246
  updated[k] = updated.get(k, False) or v
247
  return updated
248
 
249
 
250
  # ─────────────────────────────────────────────────────────────────────────────
251
- # load_review_table β€” Dictionary Dispatch & EAFP
252
  # ─────────────────────────────────────────────────────────────────────────────
253
- def _load_taxonomy(data):
254
- evidence_map = {
255
- True: lambda it: f"β†’ NOVEL | {it.get('reasoning','')[:80]}",
256
- False: lambda it: f"β†’ PAJAIS: {it.get('pajais_match','')} | {it.get('reasoning','')[:60]}"
257
- }
258
- rows = [{"#": i, "Topic Label": item.get("theme_name", ""),
259
- "Top Evidence Sentence": evidence_map[item.get("is_novel", False)](item),
260
- "Sent.": 0, "Papers": 0, "Approve": True, "Rename To": ""}
261
- for i, item in enumerate(data)]
262
-
263
- return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
264
-
265
- def _load_themes(data):
266
- rows = [{"#": i, "Topic Label": item.get("theme_name", ""),
267
- "Top Evidence Sentence": (item.get("representative_sentences") and item.get("representative_sentences")[0][:120]) or "",
268
- "Sent.": item.get("total_sentences", 0),
269
- "Papers": max(1, item.get("total_sentences", 0) // 10),
270
- "Approve": False, "Rename To": ""}
271
- for i, item in enumerate(data)]
272
-
273
- return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
274
-
275
- def _load_labels(data):
276
- rows = [{"#": t.get("topic_id", 0),
277
- "Topic Label": t.get("label", f"Topic {t.get('topic_id',0)}"),
278
- "Top Evidence Sentence": (t.get("nearest_sentences") and t.get("nearest_sentences")[0][:120]) or "",
279
- "Sent.": t.get("count", 0),
280
- "Papers": max(1, t.get("count", 0) // 10),
281
- "Approve": False, "Rename To": ""}
282
- for t in data]
283
-
284
- return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
285
-
286
  def load_review_table() -> pd.DataFrame:
287
- loaders_map = {
288
- "taxonomy_map.json": _load_taxonomy,
289
- "themes.json": _load_themes,
290
- "labels_combined.json": _load_labels,
291
- "labels_abstract.json": _load_labels,
292
- "labels_title.json": _load_labels
293
- }
294
- for file_path, loader_func in loaders_map.items():
295
- try:
296
- with open(file_path, encoding="utf-8") as f:
297
- return loader_func(json.load(f))
298
- except FileNotFoundError:
299
- pass
300
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  return EMPTY_REVIEW_DF
302
 
 
303
  def load_council_report() -> str:
 
304
  possible_files = [
305
  "council_labels_combined.json", "labels_combined.json",
306
  "council_labels_abstract.json", "labels_abstract.json",
307
  "council_labels_title.json", "labels_title.json"
308
  ]
309
- found = list(filter(os.path.exists, possible_files))
 
 
310
 
311
- match found:
312
- case []:
313
- return "<div style='padding:40px;text-align:center;color:#4a5a7a;'>AI Council arguments will appear here after Phase 3 or after running DBSCAN Council.</div>"
314
- case [target_file, *_]:
315
- with open(target_file, encoding="utf-8") as f:
316
- data = json.load(f)
317
-
318
- html_parts = [
319
- f"""
320
- <div style="background:#1a1a2e; border:1px solid #2a2a4a; border-radius:8px; padding:12px;">
321
- <div style="display:flex; justify-content:space-between; margin-bottom:8px;">
322
- <span style="color:#7fb3f5; font-weight:bold;">Topic #{item.get('topic_id', item.get('cluster_id', '?'))}</span>
323
- <span style="color:#fff; font-size:0.9rem;">Final Choice: <b style="color:#ffffff; font-weight:700;">{item.get('label', item.get('consensus_label', 'Unknown'))}</b></span>
324
- </div>
325
- {item.get('council_ui', item.get('council_reasoning', ''))}
326
- </div>
327
- """ for item in data[:20]
328
- ]
329
- return "<div style='display:flex; flex-direction:column; gap:12px;'>" + "".join(html_parts) + "</div>"
 
 
 
330
 
331
  def get_downloads():
332
- return list(filter(os.path.exists, DOWNLOAD_FILES)) or None
 
 
333
 
334
  def render_chart(chart_file: str) -> str:
335
- is_valid = bool(chart_file) and os.path.exists(chart_file)
336
- match is_valid:
337
- case False:
338
- return "<div style='padding:40px;text-align:center;color:#555;'>Chart not available yet β€” run analysis first.</div>"
339
- case True:
340
- content = open(chart_file, encoding="utf-8").read()
341
- escaped = content.replace("&", "&amp;").replace('"', "&quot;").replace("'", "&#39;")
342
- return f'<iframe srcdoc="{escaped}" style="width:100%;height:540px;border:none;border-radius:6px;" sandbox="allow-scripts allow-same-origin"></iframe>'
343
 
344
 
345
- # ─────────────────────────────────────────────────────────────────────────────
346
- # PNG Chart Processing
347
- # ─────────────────────────────────────────────────────────────────────────────
348
  def export_chart_png(html_file: str) -> str:
 
 
 
 
 
349
  png_file = html_file.replace(".html", ".png")
350
- html_newer = (not os.path.exists(png_file)) or (os.path.getmtime(html_file) > os.path.getmtime(png_file))
351
- should_write = os.path.exists(html_file) and html_newer
352
- return (should_write and _write_png(html_file, png_file)) or (os.path.exists(png_file) and png_file) or ""
 
 
 
 
 
 
 
 
353
 
354
  def _write_png(html_file: str, png_file: str) -> str:
 
 
 
 
355
  import re as _re
356
  raw = open(html_file, encoding="utf-8").read()
 
357
  match = _re.search(r'Plotly\.newPlot\([^,]+,\s*(\[.*?\]|\{.*?\}),\s*\{', raw, _re.DOTALL)
358
- return (match and _pio_from_html(html_file, png_file)) or _pio_save(png_file)
 
 
 
 
 
 
359
 
360
  def _pio_from_html(html_file: str, png_file: str) -> str:
 
361
  result = png_file
362
  try:
363
  import plotly.io as _pio
 
 
364
  import re as _re, json as _json
365
  raw = open(html_file, encoding="utf-8").read()
366
  m = _re.search(r'({"data".*?"layout".*?})', raw, _re.DOTALL)
367
- fig = (m and _pio.from_json(m.group(1))) or None
368
  _ = fig and _pio.write_image(fig, png_file, format="png", width=1200, height=700, scale=2)
369
  except Exception:
370
  result = ""
371
  return result
372
 
 
373
  def _pio_save(png_file: str) -> str:
 
374
  return ""
375
 
 
376
  def get_chart_png(chart_label: str) -> str:
 
377
  html_file = dict(CHART_OPTIONS).get(chart_label, "")
378
- return (html_file and export_chart_png(html_file)) or ""
379
 
380
 
381
  # ─────────────────────────────────────────────────────────────────────────────
382
- # Agent Caller
 
 
 
 
 
 
 
383
  # ─────────────────────────────────────────────────────────────────────────────
384
  def call_agent(message: str, session_id: str, max_retries: int = 3) -> tuple[str, str]:
 
 
 
 
 
 
385
  current_sid = session_id
386
 
387
  for attempt in range(max_retries):
388
  try:
389
  config = {"configurable": {"thread_id": current_sid}}
 
 
 
 
 
 
 
 
 
 
390
 
391
- # --- TRASH FILTER --- (EAFP and Logical Chaining)
392
- try:
393
- prefix, rest = message.split("{", 1)
394
- message = (prefix.strip() and not prefix.endswith("******") and "{" + rest) or message
395
- except ValueError:
396
- pass
397
-
398
- message = ("******" in message and not message.startswith("******") and "******" + message.split("******", 1)[1]) or message
399
 
400
  result = agent.invoke(
401
  {"messages": [{"role": "user", "content": message}]},
402
  config=config,
403
  )
 
 
404
 
405
- messages = (result and result.get("messages", [])) or []
406
-
407
- def _extract_msg(msg):
408
- match msg:
409
- case _ if hasattr(msg, "type") and msg.type == "ai":
410
- return safe_str(msg.content)
411
- case dict() as d if d.get("role") in ("assistant", "ai"):
412
- return safe_str(d.get("content", ""))
413
- case _:
414
- return None
415
-
416
- valid_msgs = filter(None, map(_extract_msg, reversed(messages)))
417
- return (next(valid_msgs, "Agent returned no response. Please try again."), current_sid)
418
 
419
  except Exception as e:
420
  err = str(e)
421
  tb = traceback.format_exc()
422
 
423
- # ── FIX-A & FIX-B checks via match-case structural checks
424
- is_corrupted = any(sig in err for sig in CORRUPT_HISTORY_SIGNALS)
425
- is_rate_limit = any(c in err for c in ["429", "520", "502", "503", "529", "mistral.ai", "Rate limit"])
426
-
427
- match (is_corrupted, is_rate_limit):
428
- case (True, _):
429
- new_sid = str(uuid.uuid4())
430
- log_error(err, ctx=f"call_agent [corrupt-history β†’ rotating {current_sid[:8]}β†’{new_sid[:8]}]")
431
- print(f"⚠️ Corrupt history detected β€” rotating session {current_sid[:8]} β†’ {new_sid[:8]}")
432
- recovery_msg = (
433
- f"{message}\n\n"
434
- "[SYSTEM NOTE: The previous session thread had a corrupted history "
435
- "due to a mid-tool API failure. This is a fresh thread. "
436
- "Checkpoint files (themes.json, taxonomy_map.json, etc.) are intact on disk. "
437
- "Please resume from where we left off based on the existing checkpoint files.]"
 
 
 
 
 
438
  )
439
- current_sid = new_sid
440
- try:
441
- config = {"configurable": {"thread_id": current_sid}}
442
- result = agent.invoke(
443
- {"messages": [{"role": "user", "content": recovery_msg}]},
444
- config=config,
445
- )
446
- messages = (result and result.get("messages", [])) or []
447
- valid_msgs = filter(None, map(_extract_msg, reversed(messages)))
448
- return (next(valid_msgs, "Agent returned empty result after rotation."), current_sid)
449
- except Exception as e2:
450
- tb2 = traceback.format_exc()
451
- log_error(str(e2), ctx="call_agent [post-rotation]")
452
- return f"⚠️ Agent Error after session rotation: {e2}\n\nTraceback:\n{tb2}", current_sid
453
-
454
- case (_, True):
455
- log_error(err, ctx=f"call_agent attempt {attempt + 1}")
456
- wait = 30 * (attempt + 1)
457
- print(f"⚠️ Mistral rate-limit/server error β€” retrying in {wait}s…")
458
- time.sleep(wait)
459
- continue
460
-
461
- case _:
462
- log_error(err, ctx="call_agent")
463
- return f"⚠️ Agent Error: {err}\n\nTraceback:\n{tb}", current_sid
 
 
 
 
464
 
465
  return "❌ Mistral not responding after retries. Wait a few minutes and try again.", current_sid
466
 
467
 
468
  # ─────────────────────────────────────────────────────────────────────────────
469
- # Event handlers
470
  # ─────────────────────────────────────────────────────────────────────────────
471
  def on_upload(file_obj, history, sid, status):
472
- match file_obj:
473
- case None:
474
- return history, sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads()
475
- case _:
476
- try:
477
- path = getattr(file_obj, "name", str(file_obj))
478
- clean_path = path.replace("\\", "/")
479
-
480
- msg = (
481
- f"I have uploaded my Scopus CSV. File path: {clean_path}\n\n"
482
- "Please begin Phase 1: load the file, show all dataset statistics "
483
- "(papers, abstract sentences, title sentences, year range, columns, "
484
- "sample titles), then ask me which run_key to use."
485
- )
486
- response, new_sid = call_agent(msg, sid)
487
- new_hist = append_msgs(history, msg, response)
488
- new_status = parse_phase_status(response, status)
489
- return new_hist, new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
490
- except Exception as e:
491
- log_error(str(e), ctx="on_upload")
492
- return (append_msgs(history, "[File Upload]", f"Upload error: {e}"),
493
- sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
494
 
495
  def on_send(user_msg, history, sid, status):
496
- match bool(user_msg.strip()):
497
- case False:
498
- return history, "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads()
499
- case True:
500
- try:
501
- response, new_sid = call_agent(user_msg, sid)
502
- new_hist = append_msgs(history, user_msg, response)
503
- new_status = parse_phase_status(response, status)
504
- return new_hist, "", new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
505
- except Exception as e:
506
- log_error(str(e), ctx="on_send")
507
- return (append_msgs(history, user_msg, f"Error: {e}"),
508
- "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
509
 
510
  def on_submit_review(review_df, history, sid, status):
511
  try:
512
- df = (isinstance(review_df, pd.DataFrame) and review_df) or pd.DataFrame(review_df)
513
  approved = df[df["Approve"].astype(bool)]
514
  rename_map = {}
515
  labels_list = []
@@ -518,13 +616,18 @@ def on_submit_review(review_df, history, sid, status):
518
  tid = str(row.get("#", ""))
519
  label = str(row.get("Topic Label", "")).strip()
520
  ren = str(row.get("Rename To", "")).strip()
521
- labels_list.append(ren or label)
522
- (ren and rename_map.setdefault(tid, ren))
 
523
 
524
  lines = []
525
- (labels_list and lines.append(f"Approved {len(labels_list)} row(s): " + ", ".join(labels_list[:6]) + ((len(labels_list) > 6 and "…") or "")))
526
- (rename_map and lines.append("Renames: " + ", ".join(f"#{k}β†’'{v}'" for k, v in list(rename_map.items())[:5])))
527
- summary = (lines and "\n".join(lines)) or "No approvals or renames submitted."
 
 
 
 
528
 
529
  msg = (
530
  "I have submitted the Review Table.\n\n"
@@ -539,21 +642,25 @@ def on_submit_review(review_df, history, sid, status):
539
  except Exception as e:
540
  log_error(str(e), ctx="on_submit_review")
541
  return (append_msgs(history, "[Submit Review]", f"Submit error: {e}"),
542
- sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
 
543
 
544
  def on_chart_change(label: str) -> str:
545
  return render_chart(dict(CHART_OPTIONS).get(label, ""))
546
 
 
547
  def on_clear(sid):
548
- for f in filter(os.path.exists, CHECKPOINT_FILES):
549
- try: os.remove(f)
550
- except OSError: pass
551
-
 
 
 
552
  new_sid = str(uuid.uuid4())
553
  blank = {k: False for k in ["1", "2", "3", "4", "5", "5.5", "6"]}
554
  new_status = parse_phase_status("", blank)
555
- return (empty_history(), new_sid, new_status, build_phase_bar(new_status),
556
- load_review_table(), load_council_report(), get_downloads(), render_chart(""))
557
 
558
 
559
  # ─────────────────────────────────────────────────────────────────────────────
@@ -563,10 +670,12 @@ INIT_STATUS = parse_phase_status("", {k: False for k in ["1","2","3","4","5","5.
563
 
564
  with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
565
 
 
566
  sid_state = gr.State(str(uuid.uuid4()))
567
  history_state = gr.State(empty_history())
568
  status_state = gr.State(INIT_STATUS)
569
 
 
570
  gr.HTML("""
571
  <div style="padding:16px 0 4px;">
572
  <h1 style="color:#e8f0fe;font-size:1.5rem;font-weight:900;margin:0;">
@@ -581,6 +690,7 @@ with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
581
 
582
  with gr.Row(equal_height=False):
583
 
 
584
  with gr.Column(scale=1, min_width=230):
585
  gr.HTML('<div class="section-hdr">β‘  DATA INPUT</div>')
586
  file_input = gr.File(
@@ -591,6 +701,7 @@ with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
591
  gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
592
  "Upload CSV β†’ auto-triggers Phase 1</p>")
593
 
 
594
  with gr.Column(scale=3):
595
  gr.HTML('<div class="section-hdr">β‘‘ AGENT CONVERSATION</div>')
596
 
@@ -609,6 +720,7 @@ with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
609
  send_btn = gr.Button("Send ➀", variant="primary", scale=1, min_width=85)
610
  clear_btn = gr.Button("πŸ—‘ Clear Chat & Reset", variant="secondary", size="sm")
611
 
 
612
  with gr.Row():
613
  with gr.Column():
614
  gr.HTML('<div class="section-hdr">'
@@ -632,74 +744,80 @@ with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
632
  gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
633
  "Tick Approve / fill Rename To, then click Submit Review.</p>")
634
 
635
- with gr.Tab("βš–οΈ AI Council"):
636
- council_html = gr.HTML(value=load_council_report())
637
-
638
- with gr.Tab("πŸ“Š Charts"):
639
- chart_dropdown = gr.Dropdown(
640
- choices=[label for label, _ in CHART_OPTIONS],
641
  value=CHART_OPTIONS[0][0],
642
- show_label=False,
 
643
  )
644
- chart_html = gr.HTML(value=render_chart(CHART_OPTIONS[0][1]))
645
-
646
- with gr.Tab("πŸ“₯ Downloads"):
647
- downloads_files = gr.File(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  value=get_downloads(),
 
649
  file_count="multiple",
650
- label="Generated files",
651
  interactive=False,
 
652
  )
653
 
654
- # ─────────────────────────────────────────────────────────────────────────
655
- # Event wiring
656
- # ─────────────────────────────────────────────────────────────────────────
657
- result_outputs = [
658
- chatbot, sid_state, status_state, phase_bar,
659
- review_table, council_html, downloads_files,
660
- ]
661
 
662
- file_input.upload(
663
- on_upload,
664
- inputs=[file_input, chatbot, sid_state, status_state],
665
- outputs=result_outputs,
666
  )
667
-
668
- send_outputs = [
669
- chatbot, chat_input, sid_state, status_state, phase_bar,
670
- review_table, council_html, downloads_files,
671
- ]
672
 
673
  send_btn.click(
674
- on_send,
675
- inputs=[chat_input, chatbot, sid_state, status_state],
676
- outputs=send_outputs,
677
  )
678
  chat_input.submit(
679
- on_send,
680
- inputs=[chat_input, chatbot, sid_state, status_state],
681
- outputs=send_outputs,
682
  )
683
-
684
  submit_btn.click(
685
- on_submit_review,
686
- inputs=[review_table, chatbot, sid_state, status_state],
687
- outputs=result_outputs,
688
  )
689
-
690
- chart_dropdown.change(
691
- on_chart_change,
692
- inputs=[chart_dropdown],
693
- outputs=[chart_html],
694
- )
695
-
696
  clear_btn.click(
697
- on_clear,
698
  inputs=[sid_state],
699
- outputs=result_outputs + [chart_html],
700
  )
701
 
702
- demo.queue()
703
 
704
  if __name__ == "__main__":
705
- demo.launch()
 
 
 
 
 
 
1
  # app.py β€” BERTopic Thematic Analysis Agent
2
  # Built specifically for Gradio 6.11.0.
3
+ #
4
+ # KEY FIXES in this version:
5
+ # FIX-A: call_agent detects INVALID_CHAT_HISTORY (dangling tool call in
6
+ # MemorySaver after a mid-tool 429) and rotates to a fresh thread_id.
7
+ # FIX-B: Rate-limit back-off extended to 30 / 60 / 90 s (was 10/20/30 s).
8
+ # FIX-C: on_clear() now deletes all checkpoint files so Phase 1 truly resets.
9
+ # FIX-D: All UI handlers return the (possibly rotated) sid_state.
10
+ # FIX-E: stdout/stderr reconfigured to UTF-8 so Mistral emoji (βœ…πŸ“„β¬œ) don't
11
+ # crash print() on Windows cp1252 consoles.
12
 
13
  import sys
14
  import shutil
15
 
16
+ # FIX-E: Reconfigure console to UTF-8 BEFORE any print() calls.
17
+ # Windows default (cp1252) cannot encode Mistral's emoji responses,
18
+ # causing UnicodeEncodeError inside log_error() which propagated to the UI.
19
  try:
20
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
21
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
 
34
  import plotly.io as pio
35
  from agent import agent
36
 
37
+ # Check for API Keys
38
+ if not os.environ.get("MISTRAL_API_KEY"):
39
+ print("\n" + "!"*80)
40
+ print("CRITICAL WARNING: MISTRAL_API_KEY environment variable is NOT set.")
41
+ print("!"*80 + "\n")
42
+
43
+ if not os.environ.get("GROQ_API_KEY"):
44
+ print("\n" + "!"*80)
45
+ print("CRITICAL WARNING: GROQ_API_KEY environment variable is NOT set.")
46
+ print("!"*80 + "\n")
47
+
48
+ if not os.environ.get("GOOGLE_API_KEY"):
49
+ print("\n" + "!"*80)
50
+ print("CRITICAL WARNING: GOOGLE_API_KEY environment variable is NOT set.")
51
+ print("!"*80 + "\n")
52
 
53
  print(f"[app.py] Starting with Gradio {gr.__version__}")
54
 
 
68
  DOWNLOAD_FILES = [
69
  "narrative.txt", "comparison.csv", "themes.json",
70
  "taxonomy_map.json", "labels_abstract.json", "labels_title.json", "labels_combined.json",
71
+ # ── New DBSCAN + AI Council outputs ──
72
  "summaries_abstract.json", "summaries_title.json", "summaries_combined.json",
73
+ # PNG chart exports
74
  "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_bars.png",
75
  "chart_title_dbscan_scatter.png", "chart_title_dbscan_bars.png",
76
  "chart_combined_dbscan_scatter.png", "chart_combined_dbscan_bars.png",
77
  "chart_abstract_intertopic.html", "chart_title_intertopic.html", "chart_combined_intertopic.html",
78
  ]
79
 
80
+ # Files to wipe when the user resets the session
81
  CHECKPOINT_FILES = [
82
  "loaded_data.csv",
83
  "summaries_abstract.json", "summaries_title.json", "summaries_combined.json",
 
89
  "chart_title_dbscan_scatter.html", "chart_title_dbscan_bars.html",
90
  "chart_combined_dbscan_scatter.html", "chart_combined_dbscan_bars.html",
91
  "chart_abstract_intertopic.html", "chart_title_intertopic.html", "chart_combined_intertopic.html",
92
+ # PNG exports
93
  "chart_abstract_dbscan_scatter.png", "chart_abstract_dbscan_bars.png",
94
  "chart_title_dbscan_scatter.png", "chart_title_dbscan_bars.png",
95
  "chart_combined_dbscan_scatter.png", "chart_combined_dbscan_bars.png",
96
+
97
  ]
98
 
99
  CHART_OPTIONS = [
 
113
  ("4","β‘£ Review"), ("5","β‘€ Names"), ("5.5","β‘€Β½ PAJAIS"), ("6","β‘₯ Report"),
114
  ]
115
 
116
+ # Error strings that indicate a corrupted MemorySaver thread
117
+ # (dangling AIMessage with tool_call but no ToolMessage)
118
  CORRUPT_HISTORY_SIGNALS = [
119
  "INVALID_CHAT_HISTORY",
120
  "ToolMessage",
 
148
  }
149
  .resizeable-table-wrap table { min-width: 100%; }
150
 
151
+ /* Make Gradio dataframe container resizeable */
152
  #review_table_wrap .svelte-1o8r8wm,
153
  #review_table_wrap .table-wrap {
154
  resize: vertical;
 
181
  }
182
  """
183
 
184
+
185
+ # ─────────────────────────────────────────────────────────────────────────────
186
+ # Message helpers
187
+ # Gradio 6.11 ALWAYS needs: {"role": "user"|"assistant", "content": str}
188
+ # ─────────────────────────────────────────────────────────────────────────────
189
  def _msg(role: str, content: str) -> dict:
190
  return {"role": role, "content": str(content)}
191
 
192
+
193
  def append_msgs(history: list, user_text: str, bot_text: str) -> list:
194
+ """Append a user+assistant exchange to chat history."""
195
  return history + [_msg("user", user_text), _msg("assistant", bot_text)]
196
 
197
+
198
  def empty_history() -> list:
199
  return []
200
 
201
+
202
+ # ─────────────────────────────────────────────────────────────────────────────
203
+ # Utilities
204
+ # ─────────────────────────────────────────────────────────────────────────────
205
  def log_error(msg: str, ctx: str = "") -> None:
206
  ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
207
  with open("error.txt", "a", encoding="utf-8") as f:
208
  f.write(f"\n{'='*60}\nTIME: {ts}\nCONTEXT: {ctx}\n"
209
  f"ERROR: {msg}\nTRACEBACK:\n{traceback.format_exc()}\n")
210
+ # Secondary safety net: if stdout reconfigure didn't work, don't crash
211
  try:
212
  print(f"[ERROR] {ctx}: {str(msg)[:120]}")
213
  except UnicodeEncodeError:
214
  print(f"[ERROR] {ctx}: (non-ASCII chars in message β€” see error.txt)")
215
 
216
+
217
  def safe_str(val) -> str:
218
+ """Convert any LangGraph output to plain str safely."""
219
+ if val is None:
220
+ return ""
221
+ if isinstance(val, str):
222
+ return val
223
+ if isinstance(val, list):
224
+ parts = []
225
+ for item in val:
226
+ if isinstance(item, str):
227
+ parts.append(item)
228
+ elif isinstance(item, dict):
229
+ parts.append(str(item.get("content", item.get("text", ""))))
230
+ elif hasattr(item, "content"):
231
+ parts.append(safe_str(item.content))
232
+ else:
233
+ parts.append(str(item))
234
+ return "\n".join(filter(None, parts))
235
+ if isinstance(val, dict):
236
+ return str(val.get("content", val.get("text", str(val))))
237
+ if hasattr(val, "content"):
238
+ return safe_str(val.content)
239
+ return str(val)
240
+
241
 
242
  def detect_phase_status() -> dict:
243
  return {
 
250
  "6": os.path.exists("narrative.txt"),
251
  }
252
 
253
+
254
  def build_phase_bar(status: dict) -> str:
255
  items = ""
256
  for key, label in PHASE_LABELS:
257
  done = status.get(key, False)
258
+ bg = "#2ecc71" if done else "#2a2a3e"
259
+ col = "#000" if done else "#888"
260
+ bdr = "#2ecc71" if done else "#444"
 
 
 
 
261
  items += (
262
  f'<span style="display:inline-block;padding:4px 11px;margin:2px;'
263
+ f'background:{bg};border:1.5px solid {bdr};border-radius:18px;'
264
+ f'font-size:0.75rem;font-weight:700;color:{col};white-space:nowrap;">'
265
+ f'{"βœ… " if done else ""}{label}</span>'
266
  )
267
  return (
268
  f'<div style="background:#12122a;padding:9px 14px;border-radius:8px;'
 
272
  f'{items}</div>'
273
  )
274
 
275
+
276
  def parse_phase_status(text, current: dict) -> dict:
277
  text = safe_str(text)
278
  updated = dict(current)
279
  for line in text.splitlines():
280
+ if "PHASE_STATUS:" in line:
281
  raw = line.split("PHASE_STATUS:", 1)[1].strip()
282
  for part in [p.strip() for p in raw.split(",")]:
283
+ if "=" in part:
284
  k, v = part.split("=", 1)
285
  updated[k.strip()] = "βœ…" in v
 
 
 
 
 
286
  for k, v in detect_phase_status().items():
287
  updated[k] = updated.get(k, False) or v
288
  return updated
289
 
290
 
291
  # ─────────────────────────────────────────────────────────────────────────────
292
+ # Review table loader
293
  # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  def load_review_table() -> pd.DataFrame:
295
+ if os.path.exists("taxonomy_map.json"):
296
+ data = json.loads(open("taxonomy_map.json", encoding="utf-8").read())
297
+ rows = []
298
+ for i, item in enumerate(data):
299
+ evidence = (
300
+ f"β†’ NOVEL | {item.get('reasoning','')[:80]}"
301
+ if item.get("is_novel", False)
302
+ else f"β†’ PAJAIS: {item.get('pajais_match','')} | {item.get('reasoning','')[:60]}"
303
+ )
304
+ rows.append({"#": i, "Topic Label": item.get("theme_name", ""),
305
+ "Top Evidence Sentence": evidence,
306
+ "Sent.": 0, "Papers": 0, "Approve": True, "Rename To": ""})
307
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
308
+
309
+ if os.path.exists("themes.json"):
310
+ data = json.loads(open("themes.json", encoding="utf-8").read())
311
+ rows = []
312
+ for i, item in enumerate(data):
313
+ s = item.get("total_sentences", 0)
314
+ rows.append({"#": i, "Topic Label": item.get("theme_name", ""),
315
+ "Top Evidence Sentence": (
316
+ item.get("representative_sentences", [""])[0][:120]
317
+ if item.get("representative_sentences") else ""),
318
+ "Sent.": s, "Papers": max(1, s // 10),
319
+ "Approve": False, "Rename To": ""})
320
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
321
+
322
+ for rk in ("combined", "abstract", "title"):
323
+ p = f"labels_{rk}.json"
324
+ if os.path.exists(p):
325
+ data = json.loads(open(p, encoding="utf-8").read())
326
+ rows = []
327
+ for t in data:
328
+ s = t.get("count", 0)
329
+ rows.append({"#": t.get("topic_id", 0),
330
+ "Topic Label": t.get("label", f"Topic {t.get('topic_id',0)}"),
331
+ "Top Evidence Sentence": (
332
+ t.get("nearest_sentences", [""])[0][:120]
333
+ if t.get("nearest_sentences") else ""),
334
+ "Sent.": s, "Papers": max(1, s // 10),
335
+ "Approve": False, "Rename To": ""})
336
+ return pd.DataFrame(rows, columns=REVIEW_COLUMNS) if rows else EMPTY_REVIEW_DF
337
+
338
  return EMPTY_REVIEW_DF
339
 
340
+
341
  def load_council_report() -> str:
342
+ """Return a detailed HTML report of the AI Council arguments."""
343
  possible_files = [
344
  "council_labels_combined.json", "labels_combined.json",
345
  "council_labels_abstract.json", "labels_abstract.json",
346
  "council_labels_title.json", "labels_title.json"
347
  ]
348
+ found = [f for f in possible_files if os.path.exists(f)]
349
+ if not found:
350
+ return "<div style='padding:40px;text-align:center;color:#4a5a7a;'>AI Council arguments will appear here after Phase 3 or after running DBSCAN Council.</div>"
351
 
352
+ with open(found[0], encoding="utf-8") as f:
353
+ data = json.load(f)
354
+
355
+ # We want to show the top 10 most interesting arguments (or all if few)
356
+ items = data[:20]
357
+ html = "<div style='display:flex; flex-direction:column; gap:12px;'>"
358
+ for item in items:
359
+ # Check if the tool output the UI block or we need to build it
360
+ ui = item.get("council_ui", item.get("council_reasoning", ""))
361
+ label = item.get("label", item.get("consensus_label", "Unknown"))
362
+ html += f"""
363
+ <div style="background:#1a1a2e; border:1px solid #2a2a4a; border-radius:8px; padding:12px;">
364
+ <div style="display:flex; justify-content:space-between; margin-bottom:8px;">
365
+ <span style="color:#7fb3f5; font-weight:bold;">Topic #{item.get('topic_id', item.get('cluster_id', '?'))}</span>
366
+ <span style="color:#fff; font-size:0.9rem;">Final Choice: <b style="color:#ffffff; font-weight:700;">{label}</b></span>
367
+ </div>
368
+ {ui}
369
+ </div>
370
+ """
371
+ html += "</div>"
372
+ return html
373
+
374
 
375
  def get_downloads():
376
+ found = [f for f in DOWNLOAD_FILES if os.path.exists(f)]
377
+ return found if found else None
378
+
379
 
380
  def render_chart(chart_file: str) -> str:
381
+ if not chart_file or not os.path.exists(chart_file):
382
+ return ("<div style='padding:40px;text-align:center;color:#555;'>"
383
+ "Chart not available yet β€” run analysis first.</div>")
384
+ content = open(chart_file, encoding="utf-8").read()
385
+ escaped = content.replace("&", "&amp;").replace('"', "&quot;").replace("'", "&#39;")
386
+ return (f'<iframe srcdoc="{escaped}" style="width:100%;height:540px;'
387
+ f'border:none;border-radius:6px;" '
388
+ f'sandbox="allow-scripts allow-same-origin"></iframe>')
389
 
390
 
 
 
 
391
  def export_chart_png(html_file: str) -> str:
392
+ """
393
+ Export a Plotly HTML chart to PNG using kaleido.
394
+ Returns the PNG file path if successful, or empty string on failure.
395
+ Kaleido reads the JSON embedded in the HTML to re-render as static image.
396
+ """
397
  png_file = html_file.replace(".html", ".png")
398
+ # Only regenerate if HTML is newer than existing PNG
399
+ html_newer = (
400
+ not os.path.exists(png_file)
401
+ or os.path.getmtime(html_file) > os.path.getmtime(png_file)
402
+ )
403
+ return (
404
+ _write_png(html_file, png_file)
405
+ if (os.path.exists(html_file) and html_newer)
406
+ else (png_file if os.path.exists(png_file) else "")
407
+ )
408
+
409
 
410
  def _write_png(html_file: str, png_file: str) -> str:
411
+ """
412
+ Extract the Plotly JSON from an HTML file and save as PNG via pio.write_image.
413
+ Returns png_file path on success, empty string if kaleido is unavailable.
414
+ """
415
  import re as _re
416
  raw = open(html_file, encoding="utf-8").read()
417
+ # Plotly embeds the figure JSON in window.PlotlyConfig or as react call
418
  match = _re.search(r'Plotly\.newPlot\([^,]+,\s*(\[.*?\]|\{.*?\}),\s*\{', raw, _re.DOTALL)
419
+ result = (
420
+ _pio_save(png_file)
421
+ if match is None # Fallback: blank placeholder
422
+ else _pio_from_html(html_file, png_file)
423
+ )
424
+ return result
425
+
426
 
427
  def _pio_from_html(html_file: str, png_file: str) -> str:
428
+ """Use plotly.io to write a static image from an HTML chart."""
429
  result = png_file
430
  try:
431
  import plotly.io as _pio
432
+ # plotly.io.write_image requires a Figure object, not HTML.
433
+ # We use a workaround: read JSON from HTML via regex.
434
  import re as _re, json as _json
435
  raw = open(html_file, encoding="utf-8").read()
436
  m = _re.search(r'({"data".*?"layout".*?})', raw, _re.DOTALL)
437
+ fig = _pio.from_json(m.group(1)) if m else None
438
  _ = fig and _pio.write_image(fig, png_file, format="png", width=1200, height=700, scale=2)
439
  except Exception:
440
  result = ""
441
  return result
442
 
443
+
444
  def _pio_save(png_file: str) -> str:
445
+ """Fallback: kaleido not available β€” return empty."""
446
  return ""
447
 
448
+
449
  def get_chart_png(chart_label: str) -> str:
450
+ """Return the PNG path for the selected chart label, exporting it on demand."""
451
  html_file = dict(CHART_OPTIONS).get(chart_label, "")
452
+ return export_chart_png(html_file) if html_file else ""
453
 
454
 
455
  # ─────────────────────────────────────────────────────────────────────────────
456
+ # Agent caller β€” returns (response_str, session_id_used)
457
+ #
458
+ # FIX-A: When MemorySaver thread is corrupted (dangling AIMessage with
459
+ # tool_call, no ToolMessage), we detect the INVALID_CHAT_HISTORY
460
+ # error and rotate to a brand-new thread_id. The caller receives
461
+ # the new sid so it can update sid_state and avoid the permanent lock.
462
+ #
463
+ # FIX-B: Rate-limit back-off is now 30/60/90 s (was 10/20/30 s).
464
  # ─────────────────────────────────────────────────────────────────────────────
465
  def call_agent(message: str, session_id: str, max_retries: int = 3) -> tuple[str, str]:
466
+ """
467
+ Invoke the LangGraph agent.
468
+ Returns (response_text, session_id_used).
469
+ session_id_used may differ from the input session_id if history corruption
470
+ forced a thread rotation (FIX-A).
471
+ """
472
  current_sid = session_id
473
 
474
  for attempt in range(max_retries):
475
  try:
476
  config = {"configurable": {"thread_id": current_sid}}
477
+ # --- TRASH FILTER ---
478
+ # Strips any hallucinated prefixes like "mΓ₯nd", "migrations", or "onderlinge"
479
+ # It looks for the first '{' and assumes the tool arguments start there if found.
480
+ if "{" in message:
481
+ try:
482
+ # Only strip if there's actual text before the first brace
483
+ prefix = message.split("{")[0]
484
+ if prefix.strip() and not prefix.endswith("******"):
485
+ message = "{" + message.split("{", 1)[1]
486
+ except Exception: pass
487
 
488
+ if "******" in message and not message.startswith("******"):
489
+ message = "******" + message.split("******", 1)[1]
 
 
 
 
 
 
490
 
491
  result = agent.invoke(
492
  {"messages": [{"role": "user", "content": message}]},
493
  config=config,
494
  )
495
+ if not result:
496
+ return "Agent returned empty result. Please try again.", current_sid
497
 
498
+ messages = result.get("messages", [])
499
+ if messages is None:
500
+ messages = []
501
+
502
+ for msg in reversed(messages):
503
+ if hasattr(msg, "type") and msg.type == "ai":
504
+ return safe_str(msg.content), current_sid
505
+ if isinstance(msg, dict) and msg.get("role") in ("assistant", "ai"):
506
+ return safe_str(msg.get("content", "")), current_sid
507
+ return "Agent returned no response. Please try again.", current_sid
 
 
 
508
 
509
  except Exception as e:
510
  err = str(e)
511
  tb = traceback.format_exc()
512
 
513
+ # ── FIX-A: Corrupted history (dangling tool call in MemorySaver) ──
514
+ # Rotate to a new thread so MemorySaver starts fresh.
515
+ if any(sig in err for sig in CORRUPT_HISTORY_SIGNALS):
516
+ new_sid = str(uuid.uuid4())
517
+ log_error(err, ctx=f"call_agent [corrupt-history β†’ rotating {current_sid[:8]}β†’{new_sid[:8]}]")
518
+ print(f"⚠️ Corrupt history detected β€” rotating session {current_sid[:8]} β†’ {new_sid[:8]}")
519
+ recovery_msg = (
520
+ f"{message}\n\n"
521
+ "[SYSTEM NOTE: The previous session thread had a corrupted history "
522
+ "due to a mid-tool API failure. This is a fresh thread. "
523
+ "Checkpoint files (themes.json, taxonomy_map.json, etc.) are intact on disk. "
524
+ "Please resume from where we left off based on the existing checkpoint files.]"
525
+ )
526
+ current_sid = new_sid
527
+ # Retry immediately on the clean thread (don't sleep)
528
+ try:
529
+ config = {"configurable": {"thread_id": current_sid}}
530
+ result = agent.invoke(
531
+ {"messages": [{"role": "user", "content": recovery_msg}]},
532
+ config=config,
533
  )
534
+
535
+ if not result:
536
+ return "Agent returned empty result after rotation.", current_sid
537
+
538
+ messages = result.get("messages", [])
539
+ if messages is None:
540
+ messages = []
541
+
542
+ for msg in reversed(messages):
543
+ if hasattr(msg, "type") and msg.type == "ai":
544
+ return safe_str(msg.content), current_sid
545
+ if isinstance(msg, dict) and msg.get("role") in ("assistant", "ai"):
546
+ return safe_str(msg.get("content", "")), current_sid
547
+ return "Agent returned no response after history rotation. Please try again.", current_sid
548
+ except Exception as e2:
549
+ tb2 = traceback.format_exc()
550
+ log_error(str(e2), ctx="call_agent [post-rotation]")
551
+ return f"⚠️ Agent Error after session rotation: {e2}\n\nTraceback:\n{tb2}", current_sid
552
+
553
+ # ── FIX-B: Mistral rate-limit / server errors β€” extended back-off ──
554
+ if any(c in err for c in ["429", "520", "502", "503", "529", "mistral.ai", "Rate limit"]):
555
+ log_error(err, ctx=f"call_agent attempt {attempt + 1}")
556
+ wait = 30 * (attempt + 1) # 30 / 60 / 90 s
557
+ print(f"⚠️ Mistral rate-limit/server error β€” retrying in {wait}s…")
558
+ time.sleep(wait)
559
+ continue
560
+
561
+ log_error(err, ctx="call_agent")
562
+ return f"⚠️ Agent Error: {err}\n\nTraceback:\n{tb}", current_sid
563
 
564
  return "❌ Mistral not responding after retries. Wait a few minutes and try again.", current_sid
565
 
566
 
567
  # ─────────────────────────────────────────────────────────────────────────────
568
+ # Event handlers (all return the sid so sid_state stays up-to-date)
569
  # ─────────────────────────────────────────────────────────────────────────────
570
  def on_upload(file_obj, history, sid, status):
571
+ if file_obj is None:
572
+ return history, sid, status, build_phase_bar(status), load_review_table(), get_downloads()
573
+ try:
574
+ path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
575
+ # Normalize for Windows to prevent escape sequence errors (\U, \t)
576
+ clean_path = path.replace("\\", "/")
577
+
578
+ msg = (
579
+ f"I have uploaded my Scopus CSV. File path: {clean_path}\n\n"
580
+ "Please begin Phase 1: load the file, show all dataset statistics "
581
+ "(papers, abstract sentences, title sentences, year range, columns, "
582
+ "sample titles), then ask me which run_key to use."
583
+ )
584
+ response, new_sid = call_agent(msg, sid)
585
+ new_hist = append_msgs(history, msg, response)
586
+ new_status = parse_phase_status(response, status)
587
+ return new_hist, new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
588
+ except Exception as e:
589
+ log_error(str(e), ctx="on_upload")
590
+ return (append_msgs(history, "[File Upload]", f"Upload error: {e}"),
591
+ sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
592
+
593
 
594
  def on_send(user_msg, history, sid, status):
595
+ if not user_msg.strip():
596
+ return history, "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads()
597
+ try:
598
+ response, new_sid = call_agent(user_msg, sid)
599
+ new_hist = append_msgs(history, user_msg, response)
600
+ new_status = parse_phase_status(response, status)
601
+ return new_hist, "", new_sid, new_status, build_phase_bar(new_status), load_review_table(), load_council_report(), get_downloads()
602
+ except Exception as e:
603
+ log_error(str(e), ctx="on_send")
604
+ return (append_msgs(history, user_msg, f"Error: {e}"),
605
+ "", sid, status, build_phase_bar(status), load_review_table(), load_council_report(), get_downloads())
606
+
 
607
 
608
  def on_submit_review(review_df, history, sid, status):
609
  try:
610
+ df = review_df if isinstance(review_df, pd.DataFrame) else pd.DataFrame(review_df)
611
  approved = df[df["Approve"].astype(bool)]
612
  rename_map = {}
613
  labels_list = []
 
616
  tid = str(row.get("#", ""))
617
  label = str(row.get("Topic Label", "")).strip()
618
  ren = str(row.get("Rename To", "")).strip()
619
+ labels_list.append(ren if ren else label)
620
+ if ren:
621
+ rename_map[tid] = ren
622
 
623
  lines = []
624
+ if labels_list:
625
+ shown = ", ".join(labels_list[:6]) + ("…" if len(labels_list) > 6 else "")
626
+ lines.append(f"Approved {len(labels_list)} row(s): {shown}")
627
+ if rename_map:
628
+ lines.append("Renames: " + ", ".join(
629
+ f"#{k}β†’'{v}'" for k, v in list(rename_map.items())[:5]))
630
+ summary = "\n".join(lines) if lines else "No approvals or renames submitted."
631
 
632
  msg = (
633
  "I have submitted the Review Table.\n\n"
 
642
  except Exception as e:
643
  log_error(str(e), ctx="on_submit_review")
644
  return (append_msgs(history, "[Submit Review]", f"Submit error: {e}"),
645
+ sid, status, build_phase_bar(status), load_review_table(), get_downloads())
646
+
647
 
648
  def on_chart_change(label: str) -> str:
649
  return render_chart(dict(CHART_OPTIONS).get(label, ""))
650
 
651
+
652
  def on_clear(sid):
653
+ """Reset the UI and wipe all checkpoint files so Phase 1 re-runs clean."""
654
+ for f in CHECKPOINT_FILES:
655
+ if os.path.exists(f):
656
+ try:
657
+ os.remove(f)
658
+ except OSError:
659
+ pass
660
  new_sid = str(uuid.uuid4())
661
  blank = {k: False for k in ["1", "2", "3", "4", "5", "5.5", "6"]}
662
  new_status = parse_phase_status("", blank)
663
+ return empty_history(), new_sid, new_status, build_phase_bar(new_status)
 
664
 
665
 
666
  # ─────────────────────────────────────────────────────────────────────────────
 
670
 
671
  with gr.Blocks(title="BERTopic Agentic Topic Modelling") as demo:
672
 
673
+ # State
674
  sid_state = gr.State(str(uuid.uuid4()))
675
  history_state = gr.State(empty_history())
676
  status_state = gr.State(INIT_STATUS)
677
 
678
+ # Header
679
  gr.HTML("""
680
  <div style="padding:16px 0 4px;">
681
  <h1 style="color:#e8f0fe;font-size:1.5rem;font-weight:900;margin:0;">
 
690
 
691
  with gr.Row(equal_height=False):
692
 
693
+ # ── Data Input ────────────────────────────────────────────────────────
694
  with gr.Column(scale=1, min_width=230):
695
  gr.HTML('<div class="section-hdr">β‘  DATA INPUT</div>')
696
  file_input = gr.File(
 
701
  gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
702
  "Upload CSV β†’ auto-triggers Phase 1</p>")
703
 
704
+ # ── Chatbot ───────────────────────────────────────────────────────────
705
  with gr.Column(scale=3):
706
  gr.HTML('<div class="section-hdr">β‘‘ AGENT CONVERSATION</div>')
707
 
 
720
  send_btn = gr.Button("Send ➀", variant="primary", scale=1, min_width=85)
721
  clear_btn = gr.Button("πŸ—‘ Clear Chat & Reset", variant="secondary", size="sm")
722
 
723
+ # ── Results ───────────────────────────────────────────────────────────────
724
  with gr.Row():
725
  with gr.Column():
726
  gr.HTML('<div class="section-hdr">'
 
744
  gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
745
  "Tick Approve / fill Rename To, then click Submit Review.</p>")
746
 
747
+ with gr.Tab("πŸ“ˆ Charts"):
748
+ chart_dd = gr.Dropdown(
749
+ choices=[o[0] for o in CHART_OPTIONS],
 
 
 
750
  value=CHART_OPTIONS[0][0],
751
+ label="Select chart",
752
+ interactive=True,
753
  )
754
+ chart_display = gr.HTML(
755
+ "<div style='padding:30px;text-align:center;color:#444;'>"
756
+ "Charts appear after Phase 2 completes.</div>")
757
+ gr.HTML(
758
+ "<p style='color:#4a5a7a;font-size:0.7rem;margin:2px 2px;'>"
759
+ "Interactive Plotly charts. HTML files are available in Downloads tab.</p>"
760
+ )
761
+
762
+ with gr.Tab("βš–οΈ AI Council"):
763
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.73rem;margin:4px 2px;'>"
764
+ "Real-time arguments between Model A (Mistral) and Model B (Groq), and Model C (Gemini).</p>")
765
+ council_display = gr.HTML(value=load_council_report())
766
+
767
+ with gr.Tab("πŸ’Ύ Download"):
768
+ gr.HTML("<p style='color:#4a5a7a;font-size:0.78rem;padding:6px 2px;'>"
769
+ "<code>narrative.txt</code> Β· <code>comparison.csv</code> Β· "
770
+ "<code>themes.json</code> Β· <code>taxonomy_map.json</code> Β· "
771
+ "<code>dbscan_summaries*.json</code> Β· "
772
+ "<code>council_labels*.json</code> Β· "
773
+ "<code>*.png</code> charts</p>")
774
+ dl_box = gr.File(
775
  value=get_downloads(),
776
+ show_label=False,
777
  file_count="multiple",
 
778
  interactive=False,
779
+ height=180,
780
  )
781
 
782
+ # ── Event wiring ──────────────────────────────────────────────────────────
783
+ # FIX-C: Removed the chatbot.change β†’ history_state sync listener.
784
+ # history_state is now updated directly by each handler's return value.
 
 
 
 
785
 
786
+ file_input.change(
787
+ fn=on_upload,
788
+ inputs=[file_input, history_state, sid_state, status_state],
789
+ outputs=[chatbot, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
790
  )
791
+ # Keep history_state in sync with chatbot (chatbot is the source of truth)
792
+ chatbot.change(fn=lambda h: h, inputs=chatbot, outputs=history_state)
 
 
 
793
 
794
  send_btn.click(
795
+ fn=on_send,
796
+ inputs=[chat_input, history_state, sid_state, status_state],
797
+ outputs=[chatbot, chat_input, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
798
  )
799
  chat_input.submit(
800
+ fn=on_send,
801
+ inputs=[chat_input, history_state, sid_state, status_state],
802
+ outputs=[chatbot, chat_input, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
803
  )
 
804
  submit_btn.click(
805
+ fn=on_submit_review,
806
+ inputs=[review_table, history_state, sid_state, status_state],
807
+ outputs=[chatbot, sid_state, status_state, phase_bar, review_table, council_display, dl_box],
808
  )
809
+ chart_dd.change(fn=on_chart_change, inputs=chart_dd, outputs=chart_display)
 
 
 
 
 
 
810
  clear_btn.click(
811
+ fn=on_clear,
812
  inputs=[sid_state],
813
+ outputs=[chatbot, sid_state, status_state, phase_bar],
814
  )
815
 
 
816
 
817
  if __name__ == "__main__":
818
+ demo.launch(
819
+ server_name="0.0.0.0",
820
+ server_port=7860,
821
+ show_error=True,
822
+ css=CSS,
823
+ )