alxry92 commited on
Commit
dda3a44
·
verified ·
1 Parent(s): 1be56c7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +187 -188
app.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  import os
2
  import re
3
  import json
@@ -5,74 +8,74 @@ import time
5
  import traceback
6
  from pathlib import Path
7
  from typing import Dict, Any, List, Tuple
8
-
9
  import pandas as pd
10
  import gradio as gr
11
  import papermill as pm
12
  import plotly.graph_objects as go
13
-
14
  # Optional LLM (HuggingFace Inference API)
15
  try:
16
  from huggingface_hub import InferenceClient
17
  except Exception:
18
  InferenceClient = None
19
-
20
  # =========================================================
21
  # CONFIG
22
  # =========================================================
23
-
24
  BASE_DIR = Path(__file__).resolve().parent
25
-
26
  NB1 = os.environ.get("NB1", "datacreation.ipynb").strip()
27
  NB2 = os.environ.get("NB2", "pythonanalysis.ipynb").strip()
28
-
29
  RUNS_DIR = BASE_DIR / "runs"
30
  ART_DIR = BASE_DIR / "artifacts"
31
  PY_FIG_DIR = ART_DIR / "py" / "figures"
32
  PY_TAB_DIR = ART_DIR / "py" / "tables"
33
-
34
  PAPERMILL_TIMEOUT = int(os.environ.get("PAPERMILL_TIMEOUT", "1800"))
35
  MAX_PREVIEW_ROWS = int(os.environ.get("MAX_FILE_PREVIEW_ROWS", "50"))
36
  MAX_LOG_CHARS = int(os.environ.get("MAX_LOG_CHARS", "8000"))
37
-
38
  HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
39
  MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-ai/DeepSeek-R1").strip()
40
  HF_PROVIDER = os.environ.get("HF_PROVIDER", "novita").strip()
41
  N8N_WEBHOOK_URL = os.environ.get("N8N_WEBHOOK_URL", "").strip()
42
-
43
  LLM_ENABLED = bool(HF_API_KEY) and InferenceClient is not None
44
  llm_client = (
45
  InferenceClient(provider=HF_PROVIDER, api_key=HF_API_KEY)
46
  if LLM_ENABLED
47
  else None
48
  )
49
-
50
  # =========================================================
51
  # HELPERS
52
  # =========================================================
53
-
54
  def ensure_dirs():
55
  for p in [RUNS_DIR, ART_DIR, PY_FIG_DIR, PY_TAB_DIR]:
56
  p.mkdir(parents=True, exist_ok=True)
57
-
58
  def stamp():
59
  return time.strftime("%Y%m%d-%H%M%S")
60
-
61
  def tail(text: str, n: int = MAX_LOG_CHARS) -> str:
62
  return (text or "")[-n:]
63
-
64
  def _ls(dir_path: Path, exts: Tuple[str, ...]) -> List[str]:
65
  if not dir_path.is_dir():
66
  return []
67
  return sorted(p.name for p in dir_path.iterdir() if p.is_file() and p.suffix.lower() in exts)
68
-
69
  def _read_csv(path: Path) -> pd.DataFrame:
70
  return pd.read_csv(path, nrows=MAX_PREVIEW_ROWS)
71
-
72
  def _read_json(path: Path):
73
  with path.open(encoding="utf-8") as f:
74
  return json.load(f)
75
-
76
  def artifacts_index() -> Dict[str, Any]:
77
  return {
78
  "python": {
@@ -80,11 +83,11 @@ def artifacts_index() -> Dict[str, Any]:
80
  "tables": _ls(PY_TAB_DIR, (".csv", ".json")),
81
  },
82
  }
83
-
84
  # =========================================================
85
  # PIPELINE RUNNERS
86
  # =========================================================
87
-
88
  def run_notebook(nb_name: str) -> str:
89
  ensure_dirs()
90
  nb_in = BASE_DIR / nb_name
@@ -101,8 +104,8 @@ def run_notebook(nb_name: str) -> str:
101
  execution_timeout=PAPERMILL_TIMEOUT,
102
  )
103
  return f"Executed {nb_name}"
104
-
105
-
106
  def run_datacreation() -> str:
107
  try:
108
  log = run_notebook(NB1)
@@ -110,8 +113,8 @@ def run_datacreation() -> str:
110
  return f"OK {log}\n\nCSVs now in /app:\n" + "\n".join(f" - {c}" for c in sorted(csvs))
111
  except Exception as e:
112
  return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
113
-
114
-
115
  def run_pythonanalysis() -> str:
116
  try:
117
  log = run_notebook(NB2)
@@ -125,8 +128,8 @@ def run_pythonanalysis() -> str:
125
  )
126
  except Exception as e:
127
  return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
128
-
129
-
130
  def run_full_pipeline() -> str:
131
  logs = []
132
  logs.append("=" * 50)
@@ -139,20 +142,20 @@ def run_full_pipeline() -> str:
139
  logs.append("=" * 50)
140
  logs.append(run_pythonanalysis())
141
  return "\n".join(logs)
142
-
143
-
144
  # =========================================================
145
  # GALLERY LOADERS
146
  # =========================================================
147
-
148
  def _load_all_figures() -> List[Tuple[str, str]]:
149
  """Return list of (filepath, caption) for Gallery."""
150
  items = []
151
  for p in sorted(PY_FIG_DIR.glob("*.png")):
152
  items.append((str(p), p.stem.replace('_', ' ').title()))
153
  return items
154
-
155
-
156
  def _load_table_safe(path: Path) -> pd.DataFrame:
157
  try:
158
  if path.suffix == ".json":
@@ -163,26 +166,26 @@ def _load_table_safe(path: Path) -> pd.DataFrame:
163
  return _read_csv(path)
164
  except Exception as e:
165
  return pd.DataFrame([{"error": str(e)}])
166
-
167
-
168
  def refresh_gallery():
169
  """Called when user clicks Refresh on Gallery tab."""
170
  figures = _load_all_figures()
171
  idx = artifacts_index()
172
-
173
  table_choices = list(idx["python"]["tables"])
174
-
175
  default_df = pd.DataFrame()
176
  if table_choices:
177
  default_df = _load_table_safe(PY_TAB_DIR / table_choices[0])
178
-
179
  return (
180
  figures if figures else [],
181
  gr.update(choices=table_choices, value=table_choices[0] if table_choices else None),
182
  default_df,
183
  )
184
-
185
-
186
  def on_table_select(choice: str):
187
  if not choice:
188
  return pd.DataFrame([{"hint": "Select a table above."}])
@@ -190,12 +193,12 @@ def on_table_select(choice: str):
190
  if not path.exists():
191
  return pd.DataFrame([{"error": f"File not found: {choice}"}])
192
  return _load_table_safe(path)
193
-
194
-
195
  # =========================================================
196
  # KPI LOADER
197
  # =========================================================
198
-
199
  def load_kpis() -> Dict[str, Any]:
200
  for candidate in [PY_TAB_DIR / "kpis.json", PY_FIG_DIR / "kpis.json"]:
201
  if candidate.exists():
@@ -204,31 +207,32 @@ def load_kpis() -> Dict[str, Any]:
204
  except Exception:
205
  pass
206
  return {}
207
-
208
-
209
  # =========================================================
210
  # AI DASHBOARD -- LLM picks what to display
211
  # =========================================================
212
-
213
  DASHBOARD_SYSTEM = """You are an AI dashboard assistant for a book-sales analytics app.
214
- The user asks questions or requests about their data. You have access to pre-computed
215
- artifacts from a Python analysis pipeline.
216
-
217
  AVAILABLE ARTIFACTS (only reference ones that exist):
218
  {artifacts_json}
219
-
220
- KPI SUMMARY: {kpis_json}
221
-
 
222
  YOUR JOB:
223
  1. Answer the user's question conversationally using the KPIs and your knowledge of the artifacts.
224
  2. At the END of your response, output a JSON block (fenced with ```json ... ```) that tells
225
  the dashboard which artifact to display. The JSON must have this shape:
226
  {{"show": "figure"|"table"|"none", "scope": "python", "filename": "..."}}
227
-
228
- - Use "show": "figure" to display a chart image.
229
- - Use "show": "table" to display a CSV/JSON table.
230
- - Use "show": "none" if no artifact is relevant.
231
-
232
  RULES:
233
  - If the user asks about sales trends or forecasting by title, show sales_trends or arima figures.
234
  - If the user asks about sentiment, show sentiment figure or sentiment_counts table.
@@ -237,11 +241,11 @@ RULES:
237
  - If the user asks a general data question, pick the most relevant artifact.
238
  - Keep your answer concise (2-4 sentences), then the JSON block.
239
  """
240
-
241
  JSON_BLOCK_RE = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL)
242
  FALLBACK_JSON_RE = re.compile(r"\{[^{}]*\"show\"[^{}]*\}", re.DOTALL)
243
-
244
-
245
  def _parse_display_directive(text: str) -> Dict[str, str]:
246
  m = JSON_BLOCK_RE.search(text)
247
  if m:
@@ -256,13 +260,13 @@ def _parse_display_directive(text: str) -> Dict[str, str]:
256
  except json.JSONDecodeError:
257
  pass
258
  return {"show": "none"}
259
-
260
-
261
  def _clean_response(text: str) -> str:
262
  """Strip the JSON directive block from the displayed response."""
263
  return JSON_BLOCK_RE.sub("", text).strip()
264
-
265
-
266
  def _n8n_call(msg: str) -> Tuple[str, Dict]:
267
  """Call the student's n8n webhook and return (reply, directive)."""
268
  import requests as req
@@ -276,16 +280,16 @@ def _n8n_call(msg: str) -> Tuple[str, Dict]:
276
  return answer, {"show": "none"}
277
  except Exception as e:
278
  return f"n8n error: {e}. Falling back to keyword matching.", None
279
-
280
-
281
  def ai_chat(user_msg: str, history: list):
282
  """Chat function for the AI Dashboard tab."""
283
  if not user_msg or not user_msg.strip():
284
  return history, "", None, None
285
-
286
  idx = artifacts_index()
287
  kpis = load_kpis()
288
-
289
  # Priority: n8n webhook > HF LLM > keyword fallback
290
  if N8N_WEBHOOK_URL:
291
  reply, directive = _n8n_call(user_msg)
@@ -303,7 +307,7 @@ def ai_chat(user_msg: str, history: list):
303
  for entry in (history or [])[-6:]:
304
  msgs.append(entry)
305
  msgs.append({"role": "user", "content": user_msg})
306
-
307
  try:
308
  r = llm_client.chat_completion(
309
  model=MODEL_NAME,
@@ -323,60 +327,59 @@ def ai_chat(user_msg: str, history: list):
323
  reply = f"LLM error: {e}. Falling back to keyword matching."
324
  reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
325
  reply += "\n\n" + reply_fb
326
-
327
- # Resolve artifacts — build interactive Plotly charts when possible
328
  chart_out = None
329
  tab_out = None
330
  show = directive.get("show", "none")
331
  fname = directive.get("filename", "")
332
  chart_name = directive.get("chart", "")
333
-
334
- # Interactive chart builders keyed by name
335
  chart_builders = {
336
  "sales": build_sales_chart,
337
  "sentiment": build_sentiment_chart,
338
  "top_sellers": build_top_sellers_chart,
339
  }
340
-
341
  if chart_name and chart_name in chart_builders:
342
  chart_out = chart_builders[chart_name]()
343
  elif show == "figure" and fname:
344
- # Fallback: try to match filename to a chart builder
345
  if "sales_trend" in fname:
346
  chart_out = build_sales_chart()
347
  elif "sentiment" in fname:
348
  chart_out = build_sentiment_chart()
349
  elif "arima" in fname or "forecast" in fname:
350
- chart_out = build_sales_chart() # closest interactive equivalent
351
  else:
352
  chart_out = _empty_chart(f"No interactive chart for {fname}")
353
-
354
  if show == "table" and fname:
355
  fp = PY_TAB_DIR / fname
356
  if fp.exists():
357
  tab_out = _load_table_safe(fp)
358
  else:
359
  reply += f"\n\n*(Could not find table: {fname})*"
360
-
 
361
  new_history = (history or []) + [
362
  {"role": "user", "content": user_msg},
363
  {"role": "assistant", "content": reply},
364
  ]
365
-
366
  return new_history, "", chart_out, tab_out
367
-
368
-
369
  def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
370
  """Simple keyword matcher when LLM is unavailable."""
371
  msg_lower = msg.lower()
372
-
373
  if not idx["python"]["figures"] and not idx["python"]["tables"]:
374
  return (
375
  "No artifacts found yet. Please run the pipeline first (Tab 1), "
376
  "then come back here to explore the results.",
377
  {"show": "none"},
378
  )
379
-
380
  kpi_text = ""
381
  if kpis:
382
  total = kpis.get("total_units_sold", 0)
@@ -384,57 +387,47 @@ def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
384
  f"Quick summary: **{kpis.get('n_titles', '?')}** book titles across "
385
  f"**{kpis.get('n_months', '?')}** months, with **{total:,.0f}** total units sold."
386
  )
387
-
388
  if any(w in msg_lower for w in ["trend", "sales trend", "monthly sale"]):
389
- return (
390
- f"Here are the sales trends. {kpi_text}",
391
- {"show": "figure", "chart": "sales"},
392
- )
393
-
394
  if any(w in msg_lower for w in ["sentiment", "review", "positive", "negative"]):
395
- return (
396
- f"Here is the sentiment distribution across sampled book titles. {kpi_text}",
397
- {"show": "figure", "chart": "sentiment"},
398
- )
399
-
400
  if any(w in msg_lower for w in ["arima", "forecast", "predict"]):
401
- return (
402
- f"Here are the sales trends and forecasts. {kpi_text}",
403
- {"show": "figure", "chart": "sales"},
404
- )
405
-
406
  if any(w in msg_lower for w in ["top", "best sell", "popular", "rank"]):
407
  return (
408
- f"Here are the top-selling titles by units sold. {kpi_text}",
409
  {"show": "table", "scope": "python", "filename": "top_titles_by_units_sold.csv"},
410
  )
411
-
412
  if any(w in msg_lower for w in ["price", "pricing", "decision"]):
413
  return (
414
  f"Here are the pricing decisions. {kpi_text}",
415
  {"show": "table", "scope": "python", "filename": "pricing_decisions.csv"},
416
  )
417
-
418
  if any(w in msg_lower for w in ["dashboard", "overview", "summary", "kpi"]):
419
  return (
420
- f"Dashboard overview: {kpi_text}\n\nAsk me about sales trends, sentiment, forecasts, "
421
- "pricing, or top sellers to see specific visualizations.",
422
  {"show": "table", "scope": "python", "filename": "df_dashboard.csv"},
423
  )
424
-
425
- # Default
426
  return (
427
  f"I can show you various analyses. {kpi_text}\n\n"
428
  "Try asking about: **sales trends**, **sentiment**, **ARIMA forecasts**, "
429
  "**pricing decisions**, **top sellers**, or **dashboard overview**.",
430
  {"show": "none"},
431
  )
432
-
433
-
434
  # =========================================================
435
- # KPI CARDS (BubbleBusters style)
436
  # =========================================================
437
-
438
  def render_kpi_cards() -> str:
439
  kpis = load_kpis()
440
  if not kpis:
@@ -443,14 +436,12 @@ def render_kpi_cards() -> str:
443
  'border-radius:20px;padding:28px;text-align:center;'
444
  'border:1.5px solid rgba(255,255,255,.7);'
445
  'box-shadow:0 8px 32px rgba(124,92,191,.08);">'
446
- '<div style="font-size:36px;margin-bottom:10px;">📊</div>'
447
- '<div style="color:#a48de8;font-size:14px;'
448
- 'font-weight:800;margin-bottom:6px;">No data yet</div>'
449
- '<div style="color:#9d8fc4;font-size:12px;">'
450
- 'Run the pipeline to populate these cards.</div>'
451
  '</div>'
452
  )
453
-
454
  def card(icon, label, value, colour):
455
  return f"""
456
  <div style="background:rgba(255,255,255,.72);backdrop-filter:blur(16px);
@@ -463,14 +454,14 @@ def render_kpi_cards() -> str:
463
  letter-spacing:1.8px;margin-bottom:7px;font-weight:800;">{label}</div>
464
  <div style="color:#2d1f4e;font-size:16px;font-weight:800;">{value}</div>
465
  </div>"""
466
-
467
  kpi_config = [
468
- ("n_titles", "📚", "Book Titles", "#a48de8"),
469
- ("n_months", "📅", "Time Periods", "#7aa6f8"),
470
- ("total_units_sold", "📦", "Units Sold", "#6ee7c7"),
471
- ("total_revenue", "💰", "Revenue", "#3dcba8"),
472
  ]
473
-
474
  html = (
475
  '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));'
476
  'gap:12px;margin-bottom:24px;">'
@@ -482,25 +473,26 @@ def render_kpi_cards() -> str:
482
  if isinstance(val, (int, float)) and val > 100:
483
  val = f"{val:,.0f}"
484
  html += card(icon, label, str(val), colour)
485
- # Extra KPIs not in config
486
  known = {k for k, *_ in kpi_config}
487
  for key, val in kpis.items():
488
  if key not in known:
489
  label = key.replace("_", " ").title()
490
  if isinstance(val, (int, float)) and val > 100:
491
  val = f"{val:,.0f}"
492
- html += card("📈", label, str(val), "#8fa8f8")
493
  html += "</div>"
494
  return html
495
-
496
-
497
  # =========================================================
498
- # INTERACTIVE PLOTLY CHARTS (BubbleBusters style)
499
  # =========================================================
500
-
501
  CHART_PALETTE = ["#7c5cbf", "#2ec4a0", "#e8537a", "#e8a230", "#5e8fef",
502
  "#c45ea8", "#3dbacc", "#a0522d", "#6aaa3a", "#d46060"]
503
-
 
504
  def _styled_layout(**kwargs) -> dict:
505
  defaults = dict(
506
  template="plotly_white",
@@ -517,20 +509,22 @@ def _styled_layout(**kwargs) -> dict:
517
  )
518
  defaults.update(kwargs)
519
  return defaults
520
-
521
-
522
  def _empty_chart(title: str) -> go.Figure:
523
  fig = go.Figure()
524
  fig.update_layout(
525
  title=title, height=420, template="plotly_white",
526
  paper_bgcolor="rgba(255,255,255,0.95)",
527
- annotations=[dict(text="Run the pipeline to generate data",
 
528
  x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False,
529
- font=dict(size=14, color="rgba(124,92,191,0.5)"))],
 
530
  )
531
  return fig
532
-
533
-
534
  def build_sales_chart() -> go.Figure:
535
  path = PY_TAB_DIR / "df_dashboard.csv"
536
  if not path.exists():
@@ -545,17 +539,18 @@ def build_sales_chart() -> go.Figure:
545
  for i, col in enumerate(val_cols):
546
  fig.add_trace(go.Scatter(
547
  x=df[date_col], y=df[col], name=col.replace("_", " ").title(),
548
- mode="lines+markers", line=dict(color=CHART_PALETTE[i % len(CHART_PALETTE)], width=2),
 
549
  marker=dict(size=4),
550
  hovertemplate=f"<b>{col.replace('_',' ').title()}</b><br>%{{x|%b %Y}}: %{{y:,.0f}}<extra></extra>",
551
  ))
552
  fig.update_layout(**_styled_layout(height=450, hovermode="x unified",
553
- title=dict(text="Monthly Overview")))
554
  fig.update_xaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
555
  fig.update_yaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
556
  return fig
557
-
558
-
559
  def build_sentiment_chart() -> go.Figure:
560
  path = PY_TAB_DIR / "sentiment_counts_sampled.csv"
561
  if not path.exists():
@@ -569,8 +564,8 @@ def build_sentiment_chart() -> go.Figure:
569
  fig = go.Figure()
570
  for col in sent_cols:
571
  fig.add_trace(go.Bar(
572
- name=col.title(), y=df[title_col], x=df[col],
573
- orientation="h", marker_color=colors.get(col, "#888"),
574
  hovertemplate=f"<b>{col.title()}</b>: %{{x}}<extra></extra>",
575
  ))
576
  fig.update_layout(**_styled_layout(
@@ -580,8 +575,8 @@ def build_sentiment_chart() -> go.Figure:
580
  fig.update_xaxes(title="Number of Reviews")
581
  fig.update_yaxes(autorange="reversed")
582
  return fig
583
-
584
-
585
  def build_top_sellers_chart() -> go.Figure:
586
  path = PY_TAB_DIR / "top_titles_by_units_sold.csv"
587
  if not path.exists():
@@ -601,94 +596,104 @@ def build_top_sellers_chart() -> go.Figure:
601
  fig.update_yaxes(autorange="reversed")
602
  fig.update_xaxes(title="Total Units Sold")
603
  return fig
604
-
605
-
606
  def refresh_dashboard():
607
  return render_kpi_cards(), build_sales_chart(), build_sentiment_chart(), build_top_sellers_chart()
608
-
609
-
610
  # =========================================================
611
  # UI
612
  # =========================================================
613
-
614
  ensure_dirs()
615
-
 
616
  def load_css() -> str:
617
  css_path = BASE_DIR / "style.css"
618
  return css_path.read_text(encoding="utf-8") if css_path.exists() else ""
619
-
620
-
621
- with gr.Blocks(title="AIBDM 2026 Workshop App") as demo:
622
-
623
  gr.Markdown(
624
  "# SE21 App Template\n"
625
  "*This is an app template for SE21 students*",
626
  elem_id="escp_title",
627
  )
628
-
629
  # ===========================================================
630
  # TAB 1 -- Pipeline Runner
631
  # ===========================================================
632
  with gr.Tab("Pipeline Runner"):
633
- gr.Markdown()
634
-
635
  with gr.Row():
636
  with gr.Column(scale=1):
637
  btn_nb1 = gr.Button("Step 1: Data Creation", variant="secondary")
638
  with gr.Column(scale=1):
639
  btn_nb2 = gr.Button("Step 2: Python Analysis", variant="secondary")
640
-
641
  with gr.Row():
642
  btn_all = gr.Button("Run Full Pipeline (Both Steps)", variant="primary")
643
-
644
  run_log = gr.Textbox(
645
  label="Execution Log",
646
  lines=18,
647
  max_lines=30,
648
  interactive=False,
649
  )
650
-
651
  btn_nb1.click(run_datacreation, outputs=[run_log])
652
  btn_nb2.click(run_pythonanalysis, outputs=[run_log])
653
  btn_all.click(run_full_pipeline, outputs=[run_log])
654
-
655
  # ===========================================================
656
- # TAB 2 -- Dashboard (KPIs + Interactive Charts + Gallery)
657
  # ===========================================================
658
  with gr.Tab("Dashboard"):
659
- kpi_html = gr.HTML(value=render_kpi_cards)
660
-
661
  refresh_btn = gr.Button("Refresh Dashboard", variant="primary")
662
-
663
  gr.Markdown("#### Interactive Charts")
664
- chart_sales = gr.Plot(label="Monthly Overview")
665
- chart_sentiment = gr.Plot(label="Sentiment Distribution")
666
- chart_top = gr.Plot(label="Top Sellers")
667
-
668
  gr.Markdown("#### Static Figures (from notebooks)")
669
  gallery = gr.Gallery(
670
  label="Generated Figures",
671
  columns=2,
672
  height=480,
673
  object_fit="contain",
 
674
  )
675
-
676
  gr.Markdown("#### Data Tables")
 
 
 
 
 
 
 
 
677
  table_dropdown = gr.Dropdown(
678
  label="Select a table to view",
679
- choices=[],
 
680
  interactive=True,
681
  )
682
  table_display = gr.Dataframe(
683
  label="Table Preview",
684
  interactive=False,
 
685
  )
686
-
687
  def _on_refresh():
688
  kpi, c1, c2, c3 = refresh_dashboard()
689
  figs, dd, df = refresh_gallery()
690
  return kpi, c1, c2, c3, figs, dd, df
691
-
692
  refresh_btn.click(
693
  _on_refresh,
694
  outputs=[kpi_html, chart_sales, chart_sentiment, chart_top,
@@ -699,7 +704,7 @@ with gr.Blocks(title="AIBDM 2026 Workshop App") as demo:
699
  inputs=[table_dropdown],
700
  outputs=[table_display],
701
  )
702
-
703
  # ===========================================================
704
  # TAB 3 -- AI Dashboard
705
  # ===========================================================
@@ -707,24 +712,24 @@ with gr.Blocks(title="AIBDM 2026 Workshop App") as demo:
707
  _ai_status = (
708
  "Connected to your **n8n workflow**." if N8N_WEBHOOK_URL
709
  else "**LLM active.**" if LLM_ENABLED
710
- else "Using **keyword matching**. Upgrade options: "
711
- "set `N8N_WEBHOOK_URL` to connect your n8n workflow, "
712
- "or set `HF_API_KEY` for direct LLM access."
713
  )
714
  gr.Markdown(
715
  "### Ask questions, get interactive visualisations\n\n"
716
- f"Type a question and the system will pick the right interactive chart or table. {_ai_status}"
717
  )
718
-
719
  with gr.Row(equal_height=True):
720
  with gr.Column(scale=1):
 
721
  chatbot = gr.Chatbot(
722
  label="Conversation",
723
  height=380,
 
724
  )
725
  user_input = gr.Textbox(
726
  label="Ask about your data",
727
- placeholder="e.g. Show me sales trends / What are the top sellers? / Sentiment analysis",
728
  lines=1,
729
  )
730
  gr.Examples(
@@ -738,21 +743,15 @@ with gr.Blocks(title="AIBDM 2026 Workshop App") as demo:
738
  ],
739
  inputs=user_input,
740
  )
741
-
742
  with gr.Column(scale=1):
743
- ai_figure = gr.Plot(
744
- label="Interactive Chart",
745
- )
746
- ai_table = gr.Dataframe(
747
- label="Data Table",
748
- interactive=False,
749
- )
750
-
751
  user_input.submit(
752
  ai_chat,
753
  inputs=[user_input, chatbot],
754
  outputs=[chatbot, user_input, ai_figure, ai_table],
755
  )
756
-
757
-
758
- demo.launch(css=load_css(), allowed_paths=[str(BASE_DIR)])
 
1
+
2
+ Copier
3
+
4
  import os
5
  import re
6
  import json
 
8
  import traceback
9
  from pathlib import Path
10
  from typing import Dict, Any, List, Tuple
11
+
12
  import pandas as pd
13
  import gradio as gr
14
  import papermill as pm
15
  import plotly.graph_objects as go
16
+
17
  # Optional LLM (HuggingFace Inference API)
18
  try:
19
  from huggingface_hub import InferenceClient
20
  except Exception:
21
  InferenceClient = None
22
+
23
  # =========================================================
24
  # CONFIG
25
  # =========================================================
26
+
27
  BASE_DIR = Path(__file__).resolve().parent
28
+
29
  NB1 = os.environ.get("NB1", "datacreation.ipynb").strip()
30
  NB2 = os.environ.get("NB2", "pythonanalysis.ipynb").strip()
31
+
32
  RUNS_DIR = BASE_DIR / "runs"
33
  ART_DIR = BASE_DIR / "artifacts"
34
  PY_FIG_DIR = ART_DIR / "py" / "figures"
35
  PY_TAB_DIR = ART_DIR / "py" / "tables"
36
+
37
  PAPERMILL_TIMEOUT = int(os.environ.get("PAPERMILL_TIMEOUT", "1800"))
38
  MAX_PREVIEW_ROWS = int(os.environ.get("MAX_FILE_PREVIEW_ROWS", "50"))
39
  MAX_LOG_CHARS = int(os.environ.get("MAX_LOG_CHARS", "8000"))
40
+
41
  HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
42
  MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-ai/DeepSeek-R1").strip()
43
  HF_PROVIDER = os.environ.get("HF_PROVIDER", "novita").strip()
44
  N8N_WEBHOOK_URL = os.environ.get("N8N_WEBHOOK_URL", "").strip()
45
+
46
  LLM_ENABLED = bool(HF_API_KEY) and InferenceClient is not None
47
  llm_client = (
48
  InferenceClient(provider=HF_PROVIDER, api_key=HF_API_KEY)
49
  if LLM_ENABLED
50
  else None
51
  )
52
+
53
  # =========================================================
54
  # HELPERS
55
  # =========================================================
56
+
57
  def ensure_dirs():
58
  for p in [RUNS_DIR, ART_DIR, PY_FIG_DIR, PY_TAB_DIR]:
59
  p.mkdir(parents=True, exist_ok=True)
60
+
61
  def stamp():
62
  return time.strftime("%Y%m%d-%H%M%S")
63
+
64
  def tail(text: str, n: int = MAX_LOG_CHARS) -> str:
65
  return (text or "")[-n:]
66
+
67
  def _ls(dir_path: Path, exts: Tuple[str, ...]) -> List[str]:
68
  if not dir_path.is_dir():
69
  return []
70
  return sorted(p.name for p in dir_path.iterdir() if p.is_file() and p.suffix.lower() in exts)
71
+
72
  def _read_csv(path: Path) -> pd.DataFrame:
73
  return pd.read_csv(path, nrows=MAX_PREVIEW_ROWS)
74
+
75
  def _read_json(path: Path):
76
  with path.open(encoding="utf-8") as f:
77
  return json.load(f)
78
+
79
  def artifacts_index() -> Dict[str, Any]:
80
  return {
81
  "python": {
 
83
  "tables": _ls(PY_TAB_DIR, (".csv", ".json")),
84
  },
85
  }
86
+
87
  # =========================================================
88
  # PIPELINE RUNNERS
89
  # =========================================================
90
+
91
  def run_notebook(nb_name: str) -> str:
92
  ensure_dirs()
93
  nb_in = BASE_DIR / nb_name
 
104
  execution_timeout=PAPERMILL_TIMEOUT,
105
  )
106
  return f"Executed {nb_name}"
107
+
108
+
109
  def run_datacreation() -> str:
110
  try:
111
  log = run_notebook(NB1)
 
113
  return f"OK {log}\n\nCSVs now in /app:\n" + "\n".join(f" - {c}" for c in sorted(csvs))
114
  except Exception as e:
115
  return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
116
+
117
+
118
  def run_pythonanalysis() -> str:
119
  try:
120
  log = run_notebook(NB2)
 
128
  )
129
  except Exception as e:
130
  return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
131
+
132
+
133
  def run_full_pipeline() -> str:
134
  logs = []
135
  logs.append("=" * 50)
 
142
  logs.append("=" * 50)
143
  logs.append(run_pythonanalysis())
144
  return "\n".join(logs)
145
+
146
+
147
  # =========================================================
148
  # GALLERY LOADERS
149
  # =========================================================
150
+
151
  def _load_all_figures() -> List[Tuple[str, str]]:
152
  """Return list of (filepath, caption) for Gallery."""
153
  items = []
154
  for p in sorted(PY_FIG_DIR.glob("*.png")):
155
  items.append((str(p), p.stem.replace('_', ' ').title()))
156
  return items
157
+
158
+
159
  def _load_table_safe(path: Path) -> pd.DataFrame:
160
  try:
161
  if path.suffix == ".json":
 
166
  return _read_csv(path)
167
  except Exception as e:
168
  return pd.DataFrame([{"error": str(e)}])
169
+
170
+
171
  def refresh_gallery():
172
  """Called when user clicks Refresh on Gallery tab."""
173
  figures = _load_all_figures()
174
  idx = artifacts_index()
175
+
176
  table_choices = list(idx["python"]["tables"])
177
+
178
  default_df = pd.DataFrame()
179
  if table_choices:
180
  default_df = _load_table_safe(PY_TAB_DIR / table_choices[0])
181
+
182
  return (
183
  figures if figures else [],
184
  gr.update(choices=table_choices, value=table_choices[0] if table_choices else None),
185
  default_df,
186
  )
187
+
188
+
189
  def on_table_select(choice: str):
190
  if not choice:
191
  return pd.DataFrame([{"hint": "Select a table above."}])
 
193
  if not path.exists():
194
  return pd.DataFrame([{"error": f"File not found: {choice}"}])
195
  return _load_table_safe(path)
196
+
197
+
198
  # =========================================================
199
  # KPI LOADER
200
  # =========================================================
201
+
202
  def load_kpis() -> Dict[str, Any]:
203
  for candidate in [PY_TAB_DIR / "kpis.json", PY_FIG_DIR / "kpis.json"]:
204
  if candidate.exists():
 
207
  except Exception:
208
  pass
209
  return {}
210
+
211
+
212
  # =========================================================
213
  # AI DASHBOARD -- LLM picks what to display
214
  # =========================================================
215
+
216
  DASHBOARD_SYSTEM = """You are an AI dashboard assistant for a book-sales analytics app.
217
+ The user asks questions or requests about their data.
218
+ You have access to pre-computed artifacts from a Python analysis pipeline.
219
+
220
  AVAILABLE ARTIFACTS (only reference ones that exist):
221
  {artifacts_json}
222
+
223
+ KPI SUMMARY:
224
+ {kpis_json}
225
+
226
  YOUR JOB:
227
  1. Answer the user's question conversationally using the KPIs and your knowledge of the artifacts.
228
  2. At the END of your response, output a JSON block (fenced with ```json ... ```) that tells
229
  the dashboard which artifact to display. The JSON must have this shape:
230
  {{"show": "figure"|"table"|"none", "scope": "python", "filename": "..."}}
231
+
232
+ - Use "show": "figure" to display a chart image.
233
+ - Use "show": "table" to display a CSV/JSON table.
234
+ - Use "show": "none" if no artifact is relevant.
235
+
236
  RULES:
237
  - If the user asks about sales trends or forecasting by title, show sales_trends or arima figures.
238
  - If the user asks about sentiment, show sentiment figure or sentiment_counts table.
 
241
  - If the user asks a general data question, pick the most relevant artifact.
242
  - Keep your answer concise (2-4 sentences), then the JSON block.
243
  """
244
+
245
  JSON_BLOCK_RE = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL)
246
  FALLBACK_JSON_RE = re.compile(r"\{[^{}]*\"show\"[^{}]*\}", re.DOTALL)
247
+
248
+
249
  def _parse_display_directive(text: str) -> Dict[str, str]:
250
  m = JSON_BLOCK_RE.search(text)
251
  if m:
 
260
  except json.JSONDecodeError:
261
  pass
262
  return {"show": "none"}
263
+
264
+
265
  def _clean_response(text: str) -> str:
266
  """Strip the JSON directive block from the displayed response."""
267
  return JSON_BLOCK_RE.sub("", text).strip()
268
+
269
+
270
  def _n8n_call(msg: str) -> Tuple[str, Dict]:
271
  """Call the student's n8n webhook and return (reply, directive)."""
272
  import requests as req
 
280
  return answer, {"show": "none"}
281
  except Exception as e:
282
  return f"n8n error: {e}. Falling back to keyword matching.", None
283
+
284
+
285
  def ai_chat(user_msg: str, history: list):
286
  """Chat function for the AI Dashboard tab."""
287
  if not user_msg or not user_msg.strip():
288
  return history, "", None, None
289
+
290
  idx = artifacts_index()
291
  kpis = load_kpis()
292
+
293
  # Priority: n8n webhook > HF LLM > keyword fallback
294
  if N8N_WEBHOOK_URL:
295
  reply, directive = _n8n_call(user_msg)
 
307
  for entry in (history or [])[-6:]:
308
  msgs.append(entry)
309
  msgs.append({"role": "user", "content": user_msg})
310
+
311
  try:
312
  r = llm_client.chat_completion(
313
  model=MODEL_NAME,
 
327
  reply = f"LLM error: {e}. Falling back to keyword matching."
328
  reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
329
  reply += "\n\n" + reply_fb
330
+
331
+ # Resolve artifacts
332
  chart_out = None
333
  tab_out = None
334
  show = directive.get("show", "none")
335
  fname = directive.get("filename", "")
336
  chart_name = directive.get("chart", "")
337
+
 
338
  chart_builders = {
339
  "sales": build_sales_chart,
340
  "sentiment": build_sentiment_chart,
341
  "top_sellers": build_top_sellers_chart,
342
  }
343
+
344
  if chart_name and chart_name in chart_builders:
345
  chart_out = chart_builders[chart_name]()
346
  elif show == "figure" and fname:
 
347
  if "sales_trend" in fname:
348
  chart_out = build_sales_chart()
349
  elif "sentiment" in fname:
350
  chart_out = build_sentiment_chart()
351
  elif "arima" in fname or "forecast" in fname:
352
+ chart_out = build_sales_chart()
353
  else:
354
  chart_out = _empty_chart(f"No interactive chart for {fname}")
355
+
356
  if show == "table" and fname:
357
  fp = PY_TAB_DIR / fname
358
  if fp.exists():
359
  tab_out = _load_table_safe(fp)
360
  else:
361
  reply += f"\n\n*(Could not find table: {fname})*"
362
+
363
+ # FIX Gradio 6: history format is list of dicts with role/content
364
  new_history = (history or []) + [
365
  {"role": "user", "content": user_msg},
366
  {"role": "assistant", "content": reply},
367
  ]
368
+
369
  return new_history, "", chart_out, tab_out
370
+
371
+
372
  def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
373
  """Simple keyword matcher when LLM is unavailable."""
374
  msg_lower = msg.lower()
375
+
376
  if not idx["python"]["figures"] and not idx["python"]["tables"]:
377
  return (
378
  "No artifacts found yet. Please run the pipeline first (Tab 1), "
379
  "then come back here to explore the results.",
380
  {"show": "none"},
381
  )
382
+
383
  kpi_text = ""
384
  if kpis:
385
  total = kpis.get("total_units_sold", 0)
 
387
  f"Quick summary: **{kpis.get('n_titles', '?')}** book titles across "
388
  f"**{kpis.get('n_months', '?')}** months, with **{total:,.0f}** total units sold."
389
  )
390
+
391
  if any(w in msg_lower for w in ["trend", "sales trend", "monthly sale"]):
392
+ return (f"Here are the sales trends. {kpi_text}", {"show": "figure", "chart": "sales"})
393
+
 
 
 
394
  if any(w in msg_lower for w in ["sentiment", "review", "positive", "negative"]):
395
+ return (f"Here is the sentiment distribution. {kpi_text}", {"show": "figure", "chart": "sentiment"})
396
+
 
 
 
397
  if any(w in msg_lower for w in ["arima", "forecast", "predict"]):
398
+ return (f"Here are the sales trends and forecasts. {kpi_text}", {"show": "figure", "chart": "sales"})
399
+
 
 
 
400
  if any(w in msg_lower for w in ["top", "best sell", "popular", "rank"]):
401
  return (
402
+ f"Here are the top-selling titles. {kpi_text}",
403
  {"show": "table", "scope": "python", "filename": "top_titles_by_units_sold.csv"},
404
  )
405
+
406
  if any(w in msg_lower for w in ["price", "pricing", "decision"]):
407
  return (
408
  f"Here are the pricing decisions. {kpi_text}",
409
  {"show": "table", "scope": "python", "filename": "pricing_decisions.csv"},
410
  )
411
+
412
  if any(w in msg_lower for w in ["dashboard", "overview", "summary", "kpi"]):
413
  return (
414
+ f"Dashboard overview: {kpi_text}\n\nAsk me about sales trends, sentiment, "
415
+ "forecasts, pricing, or top sellers.",
416
  {"show": "table", "scope": "python", "filename": "df_dashboard.csv"},
417
  )
418
+
 
419
  return (
420
  f"I can show you various analyses. {kpi_text}\n\n"
421
  "Try asking about: **sales trends**, **sentiment**, **ARIMA forecasts**, "
422
  "**pricing decisions**, **top sellers**, or **dashboard overview**.",
423
  {"show": "none"},
424
  )
425
+
426
+
427
  # =========================================================
428
+ # KPI CARDS
429
  # =========================================================
430
+
431
  def render_kpi_cards() -> str:
432
  kpis = load_kpis()
433
  if not kpis:
 
436
  'border-radius:20px;padding:28px;text-align:center;'
437
  'border:1.5px solid rgba(255,255,255,.7);'
438
  'box-shadow:0 8px 32px rgba(124,92,191,.08);">'
439
+ '<div style="font-size:36px;margin-bottom:10px;">&#128202;</div>'
440
+ '<div style="color:#a48de8;font-size:14px;font-weight:800;margin-bottom:6px;">No data yet</div>'
441
+ '<div style="color:#9d8fc4;font-size:12px;">Run the pipeline to populate these cards.</div>'
 
 
442
  '</div>'
443
  )
444
+
445
  def card(icon, label, value, colour):
446
  return f"""
447
  <div style="background:rgba(255,255,255,.72);backdrop-filter:blur(16px);
 
454
  letter-spacing:1.8px;margin-bottom:7px;font-weight:800;">{label}</div>
455
  <div style="color:#2d1f4e;font-size:16px;font-weight:800;">{value}</div>
456
  </div>"""
457
+
458
  kpi_config = [
459
+ ("n_titles", "&#128218;", "Book Titles", "#a48de8"),
460
+ ("n_months", "&#128197;", "Time Periods", "#7aa6f8"),
461
+ ("total_units_sold", "&#128230;", "Units Sold", "#6ee7c7"),
462
+ ("total_revenue", "&#128176;", "Revenue", "#3dcba8"),
463
  ]
464
+
465
  html = (
466
  '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));'
467
  'gap:12px;margin-bottom:24px;">'
 
473
  if isinstance(val, (int, float)) and val > 100:
474
  val = f"{val:,.0f}"
475
  html += card(icon, label, str(val), colour)
476
+
477
  known = {k for k, *_ in kpi_config}
478
  for key, val in kpis.items():
479
  if key not in known:
480
  label = key.replace("_", " ").title()
481
  if isinstance(val, (int, float)) and val > 100:
482
  val = f"{val:,.0f}"
483
+ html += card("&#128200;", label, str(val), "#8fa8f8")
484
  html += "</div>"
485
  return html
486
+
487
+
488
  # =========================================================
489
+ # INTERACTIVE PLOTLY CHARTS
490
  # =========================================================
491
+
492
  CHART_PALETTE = ["#7c5cbf", "#2ec4a0", "#e8537a", "#e8a230", "#5e8fef",
493
  "#c45ea8", "#3dbacc", "#a0522d", "#6aaa3a", "#d46060"]
494
+
495
+
496
  def _styled_layout(**kwargs) -> dict:
497
  defaults = dict(
498
  template="plotly_white",
 
509
  )
510
  defaults.update(kwargs)
511
  return defaults
512
+
513
+
514
  def _empty_chart(title: str) -> go.Figure:
515
  fig = go.Figure()
516
  fig.update_layout(
517
  title=title, height=420, template="plotly_white",
518
  paper_bgcolor="rgba(255,255,255,0.95)",
519
+ annotations=[dict(
520
+ text="Run the pipeline to generate data",
521
  x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False,
522
+ font=dict(size=14, color="rgba(124,92,191,0.5)"),
523
+ )],
524
  )
525
  return fig
526
+
527
+
528
  def build_sales_chart() -> go.Figure:
529
  path = PY_TAB_DIR / "df_dashboard.csv"
530
  if not path.exists():
 
539
  for i, col in enumerate(val_cols):
540
  fig.add_trace(go.Scatter(
541
  x=df[date_col], y=df[col], name=col.replace("_", " ").title(),
542
+ mode="lines+markers",
543
+ line=dict(color=CHART_PALETTE[i % len(CHART_PALETTE)], width=2),
544
  marker=dict(size=4),
545
  hovertemplate=f"<b>{col.replace('_',' ').title()}</b><br>%{{x|%b %Y}}: %{{y:,.0f}}<extra></extra>",
546
  ))
547
  fig.update_layout(**_styled_layout(height=450, hovermode="x unified",
548
+ title=dict(text="Monthly Overview")))
549
  fig.update_xaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
550
  fig.update_yaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
551
  return fig
552
+
553
+
554
  def build_sentiment_chart() -> go.Figure:
555
  path = PY_TAB_DIR / "sentiment_counts_sampled.csv"
556
  if not path.exists():
 
564
  fig = go.Figure()
565
  for col in sent_cols:
566
  fig.add_trace(go.Bar(
567
+ name=col.title(), y=df[title_col], x=df[col], orientation="h",
568
+ marker_color=colors.get(col, "#888"),
569
  hovertemplate=f"<b>{col.title()}</b>: %{{x}}<extra></extra>",
570
  ))
571
  fig.update_layout(**_styled_layout(
 
575
  fig.update_xaxes(title="Number of Reviews")
576
  fig.update_yaxes(autorange="reversed")
577
  return fig
578
+
579
+
580
  def build_top_sellers_chart() -> go.Figure:
581
  path = PY_TAB_DIR / "top_titles_by_units_sold.csv"
582
  if not path.exists():
 
596
  fig.update_yaxes(autorange="reversed")
597
  fig.update_xaxes(title="Total Units Sold")
598
  return fig
599
+
600
+
601
  def refresh_dashboard():
602
  return render_kpi_cards(), build_sales_chart(), build_sentiment_chart(), build_top_sellers_chart()
603
+
604
+
605
  # =========================================================
606
  # UI
607
  # =========================================================
608
+
609
  ensure_dirs()
610
+
611
+
612
  def load_css() -> str:
613
  css_path = BASE_DIR / "style.css"
614
  return css_path.read_text(encoding="utf-8") if css_path.exists() else ""
615
+
616
+
617
+ with gr.Blocks(title="AIBDM 2026 Workshop App", css=load_css()) as demo:
618
+
619
  gr.Markdown(
620
  "# SE21 App Template\n"
621
  "*This is an app template for SE21 students*",
622
  elem_id="escp_title",
623
  )
624
+
625
  # ===========================================================
626
  # TAB 1 -- Pipeline Runner
627
  # ===========================================================
628
  with gr.Tab("Pipeline Runner"):
 
 
629
  with gr.Row():
630
  with gr.Column(scale=1):
631
  btn_nb1 = gr.Button("Step 1: Data Creation", variant="secondary")
632
  with gr.Column(scale=1):
633
  btn_nb2 = gr.Button("Step 2: Python Analysis", variant="secondary")
634
+
635
  with gr.Row():
636
  btn_all = gr.Button("Run Full Pipeline (Both Steps)", variant="primary")
637
+
638
  run_log = gr.Textbox(
639
  label="Execution Log",
640
  lines=18,
641
  max_lines=30,
642
  interactive=False,
643
  )
644
+
645
  btn_nb1.click(run_datacreation, outputs=[run_log])
646
  btn_nb2.click(run_pythonanalysis, outputs=[run_log])
647
  btn_all.click(run_full_pipeline, outputs=[run_log])
648
+
649
  # ===========================================================
650
+ # TAB 2 -- Dashboard
651
  # ===========================================================
652
  with gr.Tab("Dashboard"):
653
+ kpi_html = gr.HTML(value=render_kpi_cards()) # FIX: call function directly for initial load
654
+
655
  refresh_btn = gr.Button("Refresh Dashboard", variant="primary")
656
+
657
  gr.Markdown("#### Interactive Charts")
658
+ chart_sales = gr.Plot(label="Monthly Overview", value=build_sales_chart()) # FIX: initial load
659
+ chart_sentiment = gr.Plot(label="Sentiment Distribution", value=build_sentiment_chart()) # FIX: initial load
660
+ chart_top = gr.Plot(label="Top Sellers", value=build_top_sellers_chart()) # FIX: initial load
661
+
662
  gr.Markdown("#### Static Figures (from notebooks)")
663
  gallery = gr.Gallery(
664
  label="Generated Figures",
665
  columns=2,
666
  height=480,
667
  object_fit="contain",
668
+ value=_load_all_figures(), # FIX: initial load
669
  )
670
+
671
  gr.Markdown("#### Data Tables")
672
+
673
+ # FIX: load initial table choices
674
+ _initial_idx = artifacts_index()
675
+ _initial_table_choices = list(_initial_idx["python"]["tables"])
676
+ _initial_df = pd.DataFrame()
677
+ if _initial_table_choices:
678
+ _initial_df = _load_table_safe(PY_TAB_DIR / _initial_table_choices[0])
679
+
680
  table_dropdown = gr.Dropdown(
681
  label="Select a table to view",
682
+ choices=_initial_table_choices,
683
+ value=_initial_table_choices[0] if _initial_table_choices else None,
684
  interactive=True,
685
  )
686
  table_display = gr.Dataframe(
687
  label="Table Preview",
688
  interactive=False,
689
+ value=_initial_df if not _initial_df.empty else None,
690
  )
691
+
692
  def _on_refresh():
693
  kpi, c1, c2, c3 = refresh_dashboard()
694
  figs, dd, df = refresh_gallery()
695
  return kpi, c1, c2, c3, figs, dd, df
696
+
697
  refresh_btn.click(
698
  _on_refresh,
699
  outputs=[kpi_html, chart_sales, chart_sentiment, chart_top,
 
704
  inputs=[table_dropdown],
705
  outputs=[table_display],
706
  )
707
+
708
  # ===========================================================
709
  # TAB 3 -- AI Dashboard
710
  # ===========================================================
 
712
  _ai_status = (
713
  "Connected to your **n8n workflow**." if N8N_WEBHOOK_URL
714
  else "**LLM active.**" if LLM_ENABLED
715
+ else "Using **keyword matching**. Set `N8N_WEBHOOK_URL` or `HF_API_KEY` to upgrade."
 
 
716
  )
717
  gr.Markdown(
718
  "### Ask questions, get interactive visualisations\n\n"
719
+ f"{_ai_status}"
720
  )
721
+
722
  with gr.Row(equal_height=True):
723
  with gr.Column(scale=1):
724
+ # FIX Gradio 6: type="messages" pour le format dict
725
  chatbot = gr.Chatbot(
726
  label="Conversation",
727
  height=380,
728
+ type="messages",
729
  )
730
  user_input = gr.Textbox(
731
  label="Ask about your data",
732
+ placeholder="e.g. Show me sales trends / What are the top sellers?",
733
  lines=1,
734
  )
735
  gr.Examples(
 
743
  ],
744
  inputs=user_input,
745
  )
746
+
747
  with gr.Column(scale=1):
748
+ ai_figure = gr.Plot(label="Interactive Chart")
749
+ ai_table = gr.Dataframe(label="Data Table", interactive=False)
750
+
 
 
 
 
 
751
  user_input.submit(
752
  ai_chat,
753
  inputs=[user_input, chatbot],
754
  outputs=[chatbot, user_input, ai_figure, ai_table],
755
  )
756
+
757
+ demo.launch(allowed_paths=[str(BASE_DIR)])