atascioglu commited on
Commit
fafcc03
Β·
verified Β·
1 Parent(s): 6b8ae50

Upload 10 files

Browse files
Files changed (2) hide show
  1. app.py +662 -499
  2. style.css +146 -238
app.py CHANGED
@@ -1,600 +1,763 @@
1
- """
2
- AIBDM 2026 - AI & Big Data Management | ESCP Business School
3
- Gradio App Template for Hugging Face Spaces
 
 
 
 
4
 
5
- This app executes student Jupyter notebooks (data creation + analysis),
6
- displays results in a gallery, and provides an AI-powered dashboard
7
- for exploring the generated artifacts.
 
 
8
 
9
- Usage:
10
- 1. Place your two notebooks in the repo root.
11
- 2. Set environment variables (or use defaults).
12
- 3. Deploy to Hugging Face Spaces.
13
- """
14
 
15
- # ─────────────────────────────────────────────
16
- # Imports
17
- # ─────────────────────────────────────────────
18
- import os, json, glob, time, re, textwrap, traceback, subprocess, sys
19
- from pathlib import Path
20
- from functools import lru_cache
21
 
22
- import gradio as gr
23
- import pandas as pd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- # ─────────────────────────────────────────────
26
- # Configuration (all via environment variables)
27
- # ─────────────────────────────────────────────
28
- NB1 = os.getenv("NB1", "datacreation.ipynb")
29
- NB2 = os.getenv("NB2", "pythonanalysis.ipynb")
30
- HF_API_KEY = os.getenv("HF_API_KEY", "")
31
- MODEL_NAME = os.getenv("MODEL_NAME", "mistralai/Mistral-7B-Instruct-v0.3")
32
-
33
- FIG_DIR = Path("artifacts/py/figures")
34
- TABLE_DIR = Path("artifacts/py/tables")
35
- KERNEL_NAME = "python3"
36
-
37
- # ─────────────────────────────────────────────
38
- # Directory & kernel helpers
39
- # ─────────────────────────────────────────────
40
  def ensure_dirs():
41
- """Create artifact output directories if they don't exist."""
42
- FIG_DIR.mkdir(parents=True, exist_ok=True)
43
- TABLE_DIR.mkdir(parents=True, exist_ok=True)
44
 
 
 
45
 
46
- def ensure_python_kernelspec():
47
- """Register the current Python interpreter as a Jupyter kernel so
48
- Papermill can find it. Safe to call repeatedly."""
49
- try:
50
- subprocess.check_call(
51
- [sys.executable, "-m", "ipykernel", "install",
52
- "--user", "--name", KERNEL_NAME],
53
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
54
- )
55
- except Exception as exc:
56
- print(f"[WARN] Could not install kernelspec: {exc}")
57
 
58
- # Run once at import time
59
- ensure_dirs()
60
- ensure_python_kernelspec()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- # ─────────────────────────────────────────────
63
- # Notebook execution via Papermill
64
- # ─────────────────────────────────────────────
65
  def run_notebook(nb_name: str) -> str:
66
- """Execute a notebook with Papermill. Returns a log string."""
67
- import papermill as pm
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
- nb_path = Path(nb_name)
70
- if not nb_path.exists():
71
- return f"ERROR Notebook not found: {nb_name}"
72
 
73
- out_path = Path(f"artifacts/{nb_path.stem}_output.ipynb")
74
- out_path.parent.mkdir(parents=True, exist_ok=True)
 
 
 
 
 
 
75
 
76
- t0 = time.time()
77
  try:
78
- pm.execute_notebook(
79
- str(nb_path),
80
- str(out_path),
81
- kernel_name=KERNEL_NAME,
82
- progress_bar=False,
 
 
 
83
  )
84
- elapsed = time.time() - t0
85
- return f"OK {nb_name} finished in {elapsed:.1f}s\n Output -> {out_path}"
86
- except Exception:
87
- elapsed = time.time() - t0
88
- tb = traceback.format_exc()
89
- return f"FAIL {nb_name} failed after {elapsed:.1f}s\n{tb}"
90
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- # Pipeline runner wrappers (return status dict + log)
93
- _step_status = {"step1": "READY", "step2": "READY"}
94
 
 
 
 
95
 
96
- def _log_block(title: str, body: str) -> str:
97
- return f"\n{'='*60}\n {title}\n{'='*60}\n{body}\n"
 
 
 
 
98
 
99
 
100
- def run_datacreation():
101
- _step_status["step1"] = "RUNNING"
102
- log = _log_block("Step 1: Data Creation", run_notebook(NB1))
103
- _step_status["step1"] = "DONE" if "OK" in log else "ERROR"
104
- return render_status_html(), log
 
 
 
 
 
105
 
106
 
107
- def run_pythonanalysis():
108
- _step_status["step2"] = "RUNNING"
109
- log = _log_block("Step 2: Python Analysis", run_notebook(NB2))
110
- _step_status["step2"] = "DONE" if "OK" in log else "ERROR"
111
- return render_status_html(), log
112
 
 
 
 
113
 
114
- def run_full_pipeline():
115
- all_log = ""
116
- status, log = run_datacreation()
117
- all_log += log
118
- status, log = run_pythonanalysis()
119
- all_log += log
120
- return status, all_log
121
 
122
- # ─────────────────────────��───────────────────
123
- # Artifact indexing
124
- # ─────────────────────────────────────────────
125
- def artifacts_index():
126
- """Return (list_of_figure_paths, list_of_table_paths)."""
127
- figs = sorted(glob.glob(str(FIG_DIR / "*.png")))
128
- tables = (
129
- sorted(glob.glob(str(TABLE_DIR / "*.csv")))
130
- + sorted(glob.glob(str(TABLE_DIR / "*.json")))
131
  )
132
- return figs, tables
133
-
134
- # ─────────────────────────────────────────────
135
- # KPI helpers
136
- # ─────────────────────────────────────────────
137
- def load_kpis() -> dict:
138
- """Load KPIs from artifacts/py/tables/kpis.json (or return empty)."""
139
- kpi_path = TABLE_DIR / "kpis.json"
140
- if not kpi_path.exists():
141
- return {}
142
- try:
143
- with open(kpi_path) as f:
144
- return json.load(f)
145
- except Exception:
146
- return {}
147
 
148
 
149
- _KPI_META = {
150
- "n_titles": ("πŸ“š", "Book Titles", "#a48de8"),
151
- "n_months": ("πŸ“…", "Time Periods", "#7aa6f8"),
152
- "total_units_sold": ("πŸ“¦", "Units Sold", "#6ee7c7"),
153
- "total_revenue": ("πŸ’°", "Total Revenue", "#3dcba8"),
154
- }
 
 
155
 
 
 
 
156
 
157
- def render_kpi_cards(kpis: dict | None = None) -> str:
158
- """Return glassmorphism HTML cards for each KPI."""
159
- if kpis is None:
160
- kpis = load_kpis()
 
 
 
 
 
 
 
 
161
  if not kpis:
162
  return (
163
- '<div style="background:rgba(255,255,255,0.65);backdrop-filter:blur(16px);'
164
  'border-radius:20px;padding:28px;text-align:center;'
165
- 'border:1px solid rgba(197,180,240,0.3);'
166
- 'box-shadow:0 8px 32px rgba(124,92,191,0.08);">'
167
  '<div style="font-size:36px;margin-bottom:10px;">πŸ“Š</div>'
168
- '<div style="color:#6b5b8e;font-size:14px;font-weight:700;">No data yet</div>'
169
- '<div style="color:#9d8fc4;font-size:12px;margin-top:4px;">'
170
- 'Run the pipeline to populate these cards.</div></div>'
 
 
171
  )
172
 
173
- def _card(icon, label, value, colour):
174
- if isinstance(value, (int, float)):
175
- value = f"{value:,.0f}" if value > 100 else str(value)
176
- return (
177
- f'<div style="background:rgba(255,255,255,0.72);backdrop-filter:blur(16px);'
178
- f'border-radius:20px;padding:18px 14px 16px;text-align:center;'
179
- f'border:1px solid rgba(255,255,255,0.8);'
180
- f'box-shadow:0 4px 16px rgba(124,92,191,0.08);'
181
- f'border-top:3px solid {colour};">'
182
- f'<div style="font-size:26px;margin-bottom:7px;">{icon}</div>'
183
- f'<div style="color:#9d8fc4;font-size:10px;text-transform:uppercase;'
184
- f'letter-spacing:1.8px;font-weight:800;margin-bottom:7px;">{label}</div>'
185
- f'<div style="color:#2d1f4e;font-size:18px;font-weight:800;">{value}</div>'
186
- f'</div>'
187
- )
 
 
 
 
 
188
 
189
- cards_html = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  for key, val in kpis.items():
191
- icon, label, colour = _KPI_META.get(key, ("πŸ“ˆ", key.replace("_", " ").title(), "#a48de8"))
192
- cards_html += _card(icon, label, val, colour)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- return (
195
- f'<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));'
196
- f'gap:12px;margin-bottom:20px;">{cards_html}</div>'
197
- )
198
 
199
- # ─────────────────────────────────────────────
200
- # Status badges
201
- # ─────────────────────────────────────────────
202
- _BADGE_COLORS = {
203
- "READY": ("#888", "rgba(255,255,255,0.08)"),
204
- "RUNNING": ("#e8a835", "rgba(232,168,53,0.12)"),
205
- "DONE": ("#3eca6e", "rgba(62,202,110,0.12)"),
206
- "ERROR": ("#e84f4f", "rgba(232,79,79,0.12)"),
207
- }
208
-
209
-
210
- def render_status_html() -> str:
211
- """Render pipeline status badges as HTML."""
212
- badge_css = (
213
- "display:inline-flex;align-items:center;gap:8px;"
214
- "padding:8px 18px;border-radius:12px;margin:6px;"
215
- "font-family:system-ui,sans-serif;font-size:0.9em;"
216
- "backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.15);"
217
- )
218
- badges = []
219
- for label, key in [("Data Creation", "step1"), ("Python Analysis", "step2")]:
220
- st = _step_status.get(key, "READY")
221
- color, bg = _BADGE_COLORS.get(st, _BADGE_COLORS["READY"])
222
- dot = f"<span style='width:10px;height:10px;border-radius:50%;background:{color};display:inline-block;'></span>"
223
- badges.append(
224
- f"<div style='{badge_css}background:{bg};color:{color};'>"
225
- f"{dot}<strong>{label}</strong> &mdash; {st}</div>"
226
- )
227
- return f"<div style='display:flex;flex-wrap:wrap;justify-content:center;'>{''.join(badges)}</div>"
228
 
229
- # ─────────────────────────────────────────────
230
- # Gallery / table helpers
231
- # ─────────────────────────────────────────────
232
- def refresh_gallery():
233
- """Return (gallery_images, table_dropdown_choices)."""
234
- figs, tables = artifacts_index()
235
- table_names = [Path(t).name for t in tables]
236
- gallery = figs if figs else []
237
- return gallery, gr.update(choices=table_names, value=table_names[0] if table_names else None)
 
 
 
 
 
238
 
239
 
240
- def on_table_select(choice: str | None):
241
- """Load the selected table and return a DataFrame (or message)."""
242
- if not choice:
243
- return pd.DataFrame({"info": ["Select a table from the dropdown."]})
244
- for ext_dir in [TABLE_DIR]:
245
- path = ext_dir / choice
246
- if path.exists():
247
- if choice.endswith(".csv"):
248
- try:
249
- return pd.read_csv(path)
250
- except Exception as exc:
251
- return pd.DataFrame({"error": [str(exc)]})
252
- elif choice.endswith(".json"):
253
- try:
254
- with open(path) as f:
255
- data = json.load(f)
256
- if isinstance(data, list):
257
- return pd.DataFrame(data)
258
- return pd.DataFrame([data])
259
- except Exception as exc:
260
- return pd.DataFrame({"error": [str(exc)]})
261
- return pd.DataFrame({"error": [f"File not found: {choice}"]})
262
-
263
- # ─────────────────────────────────────────────
264
- # AI Dashboard
265
- # ─────────────────────────────────────────────
266
- _SYSTEM_PROMPT = textwrap.dedent("""\
267
- You are a data-analysis assistant for an ESCP Business School project.
268
- The student has generated the following artifacts:
269
-
270
- FIGURES: {figures}
271
- TABLES: {tables}
272
- KPIs: {kpis}
273
-
274
- Answer the student's question conversationally. When relevant, end your
275
- response with a JSON directive on its own line so the UI can display the
276
- right artifact:
277
- {{"show_figure": "filename.png"}} OR {{"show_table": "filename.csv"}}
278
- Only include the directive when an artifact is directly relevant.
279
- """)
280
-
281
-
282
- def _build_system_prompt() -> str:
283
- figs, tables = artifacts_index()
284
- fig_names = [Path(f).name for f in figs]
285
- tbl_names = [Path(t).name for t in tables]
286
  kpis = load_kpis()
287
- return _SYSTEM_PROMPT.format(
288
- figures=", ".join(fig_names) or "none yet",
289
- tables=", ".join(tbl_names) or "none yet",
290
- kpis=json.dumps(kpis) if kpis else "none yet",
291
- )
292
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- def _call_hf_api(messages: list[dict]) -> str:
295
- """Call Hugging Face Inference API (returns assistant text)."""
296
- from huggingface_hub import InferenceClient
297
- client = InferenceClient(model=MODEL_NAME, token=HF_API_KEY)
298
- resp = client.chat_completion(messages=messages, max_tokens=512)
299
- return resp.choices[0].message.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
 
302
- def _keyword_fallback(msg: str, idx: dict, kpis: dict) -> str:
303
- """Simple keyword matcher when no LLM is available."""
304
  msg_lower = msg.lower()
305
- figs, tables = idx
306
-
307
- # Try to match a figure
308
- for f in figs:
309
- name = Path(f).stem.lower().replace("_", " ")
310
- if any(w in msg_lower for w in name.split()):
311
- return (
312
- f"Here is the **{Path(f).stem}** chart I found for you.\n\n"
313
- f'{{"show_figure": "{Path(f).name}"}}'
314
- )
315
 
316
- # Try to match a table
317
- for t in tables:
318
- name = Path(t).stem.lower().replace("_", " ")
319
- if any(w in msg_lower for w in name.split()):
320
- return (
321
- f"Here is the **{Path(t).stem}** table.\n\n"
322
- f'{{"show_table": "{Path(t).name}"}}'
323
- )
324
 
325
- # KPI summary
326
- if any(k in msg_lower for k in ("kpi", "metric", "summary", "overview")):
327
- if kpis:
328
- lines = [f"- **{k.replace('_',' ').title()}**: {v}" for k, v in kpis.items()]
329
- return "Here are the current KPIs:\n\n" + "\n".join(lines)
330
- return "No KPIs have been generated yet. Please run the pipeline first."
331
-
332
- # Sentiment / review keywords
333
- if any(k in msg_lower for k in ("sentiment", "review", "opinion")):
334
- match = [f for f in figs if "sentiment" in Path(f).name.lower()]
335
- if match:
336
- return f'The sentiment analysis is shown in this chart.\n\n{{"show_figure": "{Path(match[0]).name}"}}'
337
-
338
- # Forecast / ARIMA
339
- if any(k in msg_lower for k in ("forecast", "arima", "predict", "future")):
340
- match = [f for f in figs if any(w in Path(f).name.lower() for w in ("forecast", "arima"))]
341
- if match:
342
- return f'Here is the forecast chart.\n\n{{"show_figure": "{Path(match[0]).name}"}}'
343
-
344
- # Price / pricing
345
- if any(k in msg_lower for k in ("price", "pricing", "cost")):
346
- match = [f for f in figs if "pric" in Path(f).name.lower()]
347
- if match:
348
- return f'Here is the pricing chart.\n\n{{"show_figure": "{Path(match[0]).name}"}}'
349
 
350
- return (
351
- "I can help you explore your project data. Try asking about "
352
- "**sentiment**, **forecasts**, **pricing**, **KPIs**, or mention "
353
- "a specific chart/table name."
354
- )
355
 
 
 
 
 
 
356
 
357
- def _parse_directive(text: str):
358
- """Extract a JSON directive from the assistant response."""
359
- match = re.search(r'\{[^{}]*"show_(?:figure|table)"[^{}]*\}', text)
360
- if not match:
361
- return None, None
362
- try:
363
- d = json.loads(match.group())
364
- if "show_figure" in d:
365
- return "figure", d["show_figure"]
366
- if "show_table" in d:
367
- return "table", d["show_table"]
368
- except json.JSONDecodeError:
369
- pass
370
- return None, None
371
 
 
 
 
 
 
372
 
373
- def ai_chat(user_msg: str, history: list):
374
- """Process a user message and return (history, viz_image, viz_table)."""
375
- if not user_msg.strip():
376
- return history, None, pd.DataFrame()
 
377
 
378
- figs, tables = artifacts_index()
379
- kpis = load_kpis()
 
 
 
 
380
 
381
- # Build assistant response
382
- if HF_API_KEY:
383
- try:
384
- api_msgs = [{"role": "system", "content": _build_system_prompt()}]
385
- for entry in (history or []):
386
- api_msgs.append(entry)
387
- api_msgs.append({"role": "user", "content": user_msg})
388
- assistant_text = _call_hf_api(api_msgs)
389
- except Exception as exc:
390
- assistant_text = f"LLM error ({exc}). Falling back to keyword mode.\n\n"
391
- assistant_text += _keyword_fallback(user_msg, (figs, tables), kpis)
392
- else:
393
- assistant_text = _keyword_fallback(user_msg, (figs, tables), kpis)
394
-
395
- # Parse directive
396
- kind, name = _parse_directive(assistant_text)
397
- # Strip the JSON directive from the displayed message
398
- clean_text = re.sub(r'\{[^{}]*"show_(?:figure|table)"[^{}]*\}', "", assistant_text).strip()
399
-
400
- viz_img = None
401
- viz_tbl = pd.DataFrame()
402
-
403
- if kind == "figure":
404
- fig_path = FIG_DIR / name
405
- if fig_path.exists():
406
- viz_img = str(fig_path)
407
- elif kind == "table":
408
- tbl_path = TABLE_DIR / name
409
- if tbl_path.exists():
410
- viz_tbl = on_table_select(name)
411
-
412
- # Gradio 6 uses messages format: [{"role": ..., "content": ...}]
413
- history = (history or []) + [
414
- {"role": "user", "content": user_msg},
415
- {"role": "assistant", "content": clean_text},
416
- ]
417
- return history, viz_img, viz_tbl
418
-
419
- # ─────────────────────────────────────────────
420
- # CSS
421
- # ─────────────────────────────────────────────
422
- BASE = Path(__file__).resolve().parent
423
-
424
- def _load_css() -> str:
425
- """Load external CSS and inject background image paths as CSS variables."""
426
- css_path = BASE / "style.css"
427
- css = css_path.read_text(encoding="utf-8") if css_path.exists() else ""
428
- # Inject CSS custom properties for background images (served via /file=)
429
- bg_vars = (
430
- ":root {\n"
431
- f" --bg-top: url('/file={BASE / 'background_top.png'}');\n"
432
- f" --bg-mid: url('/file={BASE / 'background_mid.png'}');\n"
433
- f" --bg-bottom: url('/file={BASE / 'background_bottom.png'}');\n"
434
- "}\n"
435
  )
436
- return bg_vars + css
437
 
438
- # ─────────────────────────────────────────────
439
- # Build the Gradio UI
440
- # ─────────────────────────────────────────────
441
- with gr.Blocks(title="AIBDM 2026 Dashboard") as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
  gr.Markdown(
444
- "# AIBDM 2026 &mdash; AI & Big Data Management Dashboard\n"
445
- "*ESCP Business School &bull; Hugging Face Spaces*",
446
  elem_id="escp_title",
447
  )
448
 
449
- # ── Tab 1: Pipeline Runner ────────────���─
 
 
450
  with gr.Tab("Pipeline Runner"):
451
- status_html = gr.HTML(value=render_status_html, every=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
452
 
453
  with gr.Row():
454
- btn_step1 = gr.Button("Step 1: Data Creation", variant="secondary")
455
- btn_step2 = gr.Button("Step 2: Python Analysis", variant="secondary")
456
- btn_full = gr.Button("Run Full Pipeline", variant="primary")
 
457
 
458
- log_box = gr.Textbox(
459
  label="Execution Log",
460
  lines=18,
461
- max_lines=40,
462
  interactive=False,
463
- elem_id="log-box",
464
  )
465
 
466
- btn_step1.click(fn=run_datacreation, outputs=[status_html, log_box])
467
- btn_step2.click(fn=run_pythonanalysis, outputs=[status_html, log_box])
468
- btn_full.click(fn=run_full_pipeline, outputs=[status_html, log_box])
469
 
470
- # ── Tab 2: Results Gallery ──────────────
 
 
471
  with gr.Tab("Results Gallery"):
472
  kpi_html = gr.HTML(value=render_kpi_cards)
473
 
474
- refresh_btn = gr.Button("Refresh Results", variant="secondary")
 
 
 
 
475
 
 
476
  gallery = gr.Gallery(
477
  label="Generated Figures",
478
- columns=3,
479
- height="auto",
480
  object_fit="contain",
481
  )
482
 
483
- table_dd = gr.Dropdown(label="Select a data table", choices=[], interactive=True)
484
- table_view = gr.Dataframe(label="Table Preview", wrap=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
 
486
- def _on_refresh():
487
- imgs, dd_update = refresh_gallery()
488
- khtml = render_kpi_cards()
489
- return imgs, dd_update, khtml
 
 
 
 
 
 
490
 
491
- refresh_btn.click(fn=_on_refresh, outputs=[gallery, table_dd, kpi_html])
492
- table_dd.change(fn=on_table_select, inputs=[table_dd], outputs=[table_view])
 
493
 
494
- # ── Tab 3: AI Dashboard ─────────────────
495
- with gr.Tab("AI Dashboard"):
496
- _llm_note = (
497
- "*LLM active.*" if HF_API_KEY
498
- else "*No API key detected. Using keyword matching. "
499
- "Set `HF_API_KEY` in Space secrets for full AI support.*"
500
  )
 
 
 
 
 
501
  gr.Markdown(
502
- "Ask questions about your generated data and the AI will "
503
- f"pick the best chart or table to show you.\n\n{_llm_note}"
 
 
 
 
 
 
504
  )
505
 
506
  with gr.Row(equal_height=True):
507
  with gr.Column(scale=1):
508
- chatbot = gr.Chatbot(label="Chat", height=420)
509
- with gr.Row():
510
- chat_input = gr.Textbox(
511
- placeholder="e.g. Show me the sentiment distribution",
512
- show_label=False,
513
- scale=4,
514
- )
515
- send_btn = gr.Button("Send", variant="primary", scale=1)
516
-
517
  gr.Examples(
518
  examples=[
519
- "Show me the sentiment analysis results",
520
- "What do the sales forecasts look like?",
521
- "Give me a KPI summary",
522
- "Show the pricing analysis",
523
- "Which books have the best reviews?",
 
524
  ],
525
- inputs=chat_input,
526
  )
527
 
528
  with gr.Column(scale=1):
529
- viz_image = gr.Image(label="Visualization", height=350)
530
- viz_table = gr.Dataframe(label="Data Table", wrap=True)
531
-
532
- def _send(msg, hist):
533
- return ai_chat(msg, hist)
534
-
535
- send_btn.click(
536
- fn=_send,
537
- inputs=[chat_input, chatbot],
538
- outputs=[chatbot, viz_image, viz_table],
539
- ).then(fn=lambda: "", outputs=[chat_input])
540
-
541
- chat_input.submit(
542
- fn=_send,
543
- inputs=[chat_input, chatbot],
544
- outputs=[chatbot, viz_image, viz_table],
545
- ).then(fn=lambda: "", outputs=[chat_input])
546
-
547
- # ── Tab 4: About ────────────────────────
548
- with gr.Tab("About"):
549
- gr.Markdown(textwrap.dedent("""\
550
- ## About This Dashboard
551
-
552
- This interactive dashboard was built for the **AI & Big Data Management**
553
- (AIBDM) course at **ESCP Business School** (2026 cohort).
554
-
555
- ### How It Works
556
- 1. **Data Creation** notebook scrapes the web and generates synthetic
557
- data (books, sales, reviews).
558
- 2. **Python Analysis** notebook runs sentiment analysis, creates
559
- visualizations, builds ARIMA forecasts, and computes pricing
560
- decisions.
561
- 3. All outputs are saved to `artifacts/py/figures/` and
562
- `artifacts/py/tables/`.
563
- 4. This Gradio app displays the results and lets you explore them
564
- with an AI assistant.
565
-
566
- ### How to Customize
567
- - Replace `datacreation.ipynb` and `pythonanalysis.ipynb` with your
568
- own notebooks.
569
- - Make sure your notebooks write PNGs to `artifacts/py/figures/`
570
- and CSVs/JSONs to `artifacts/py/tables/`.
571
- - Optionally export a `kpis.json` file to `artifacts/py/tables/`
572
- for the KPI cards.
573
- - Set the `HF_API_KEY` secret in your Space settings to enable the
574
- AI chat (otherwise keyword fallback is used).
575
-
576
- ### Environment Variables
577
- | Variable | Default | Description |
578
- |----------|---------|-------------|
579
- | `NB1` | `datacreation.ipynb` | Path to data-creation notebook |
580
- | `NB2` | `pythonanalysis.ipynb` | Path to analysis notebook |
581
- | `HF_API_KEY` | *(empty)* | HF Inference API token |
582
- | `MODEL_NAME` | `mistralai/Mistral-7B-Instruct-v0.3` | LLM model ID |
583
-
584
- ### Credits
585
- Built with [Gradio](https://gradio.app) and deployed on
586
- [Hugging Face Spaces](https://huggingface.co/spaces).
587
-
588
- *ESCP Business School &mdash; AIBDM 2026*
589
- """))
590
-
591
- # ─────────────────────────────────────────────
592
- # Launch (HF Spaces handles host/port)
593
- # ─────────────────────────────────────────────
594
- if __name__ == "__main__":
595
- demo.launch(
596
- server_name="0.0.0.0",
597
- server_port=7860,
598
- css=_load_css(),
599
- allowed_paths=[str(BASE)],
600
- )
 
1
+ import os
2
+ import re
3
+ import json
4
+ 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.express as px
13
+ import plotly.graph_objects as go
14
 
15
+ # Optional LLM (HuggingFace Inference API)
16
+ try:
17
+ from huggingface_hub import InferenceClient
18
+ except Exception:
19
+ InferenceClient = None
20
 
21
+ # =========================================================
22
+ # CONFIG
23
+ # =========================================================
 
 
 
24
 
25
+ BASE_DIR = Path(__file__).resolve().parent
26
+
27
+ NB1 = os.environ.get("NB1", "datacreation.ipynb").strip()
28
+ NB2 = os.environ.get("NB2", "pythonanalysis.ipynb").strip()
29
+
30
+ RUNS_DIR = BASE_DIR / "runs"
31
+ ART_DIR = BASE_DIR / "artifacts"
32
+ PY_FIG_DIR = ART_DIR / "py" / "figures"
33
+ PY_TAB_DIR = ART_DIR / "py" / "tables"
34
+
35
+ PAPERMILL_TIMEOUT = int(os.environ.get("PAPERMILL_TIMEOUT", "1800"))
36
+ MAX_PREVIEW_ROWS = int(os.environ.get("MAX_FILE_PREVIEW_ROWS", "50"))
37
+ MAX_LOG_CHARS = int(os.environ.get("MAX_LOG_CHARS", "8000"))
38
+
39
+ HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
40
+ MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-ai/DeepSeek-R1").strip()
41
+ HF_PROVIDER = os.environ.get("HF_PROVIDER", "novita").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": {
79
+ "figures": _ls(PY_FIG_DIR, (".png", ".jpg", ".jpeg")),
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
91
+ if not nb_in.exists():
92
+ return f"ERROR: {nb_name} not found."
93
+ nb_out = RUNS_DIR / f"run_{stamp()}_{nb_name}"
94
+ pm.execute_notebook(
95
+ input_path=str(nb_in),
96
+ output_path=str(nb_out),
97
+ cwd=str(BASE_DIR),
98
+ log_output=True,
99
+ progress_bar=False,
100
+ request_save_on_cell_execute=True,
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)
109
+ csvs = [f.name for f in BASE_DIR.glob("*.csv")]
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)
118
+ idx = artifacts_index()
119
+ figs = idx["python"]["figures"]
120
+ tabs = idx["python"]["tables"]
121
+ return (
122
+ f"OK {log}\n\n"
123
+ f"Figures: {', '.join(figs) or '(none)'}\n"
124
+ f"Tables: {', '.join(tabs) or '(none)'}"
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)
133
+ logs.append("STEP 1/2: Data Creation (web scraping + synthetic data)")
134
+ logs.append("=" * 50)
135
+ logs.append(run_datacreation())
136
+ logs.append("")
137
+ logs.append("=" * 50)
138
+ logs.append("STEP 2/2: Python Analysis (sentiment, ARIMA, dashboard)")
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":
159
+ obj = _read_json(path)
160
+ if isinstance(obj, dict):
161
+ return pd.DataFrame([obj])
162
+ return pd.DataFrame(obj)
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 = []
174
+ for name in idx["python"]["tables"]:
175
+ table_choices.append(name)
176
 
177
+ default_df = pd.DataFrame()
178
+ if table_choices:
179
+ default_df = _load_table_safe(PY_TAB_DIR / table_choices[0])
 
 
 
 
180
 
181
+ return (
182
+ figures if figures else [],
183
+ gr.update(choices=table_choices, value=table_choices[0] if table_choices else None),
184
+ default_df,
185
+ render_kpi_cards(),
 
 
 
 
186
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
 
189
+ def on_table_select(choice: str):
190
+ if not choice:
191
+ return pd.DataFrame([{"hint": "Select a table above."}])
192
+ path = PY_TAB_DIR / choice
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 CARDS (BubbleBusters style)
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():
205
+ try:
206
+ return _read_json(candidate)
207
+ except Exception:
208
+ pass
209
+ return {}
210
+
211
+
212
+ def render_kpi_cards() -> str:
213
+ kpis = load_kpis()
214
  if not kpis:
215
  return (
216
+ '<div style="background:rgba(255,255,255,.65);backdrop-filter:blur(16px);'
217
  'border-radius:20px;padding:28px;text-align:center;'
218
+ 'border:1.5px solid rgba(255,255,255,.7);'
219
+ 'box-shadow:0 8px 32px rgba(124,92,191,.08);">'
220
  '<div style="font-size:36px;margin-bottom:10px;">πŸ“Š</div>'
221
+ '<div style="color:#a48de8;font-size:14px;'
222
+ 'font-weight:800;margin-bottom:6px;">No data yet</div>'
223
+ '<div style="color:#9d8fc4;font-size:12px;">'
224
+ 'Run the pipeline to populate these cards.</div>'
225
+ '</div>'
226
  )
227
 
228
+ def card(icon, label, value, colour):
229
+ return f"""
230
+ <div style="background:rgba(255,255,255,.72);backdrop-filter:blur(16px);
231
+ border-radius:20px;padding:18px 14px 16px;text-align:center;
232
+ border:1.5px solid rgba(255,255,255,.8);
233
+ box-shadow:0 4px 16px rgba(124,92,191,.08);
234
+ border-top:3px solid {colour};">
235
+ <div style="font-size:26px;margin-bottom:7px;line-height:1;">{icon}</div>
236
+ <div style="color:#9d8fc4;font-size:9.5px;text-transform:uppercase;
237
+ letter-spacing:1.8px;margin-bottom:7px;font-weight:800;">{label}</div>
238
+ <div style="color:#2d1f4e;font-size:16px;font-weight:800;">{value}</div>
239
+ </div>"""
240
+
241
+ # Map KPI keys to display config
242
+ kpi_config = [
243
+ ("n_titles", "πŸ“š", "Book Titles", "#a48de8"),
244
+ ("n_months", "πŸ“…", "Time Periods", "#7aa6f8"),
245
+ ("total_units_sold", "πŸ“¦", "Units Sold", "#6ee7c7"),
246
+ ("total_revenue", "πŸ’°", "Revenue", "#3dcba8"),
247
+ ]
248
 
249
+ html = (
250
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));'
251
+ 'gap:12px;margin-bottom:24px;">'
252
+ )
253
+ for key, icon, label, colour in kpi_config:
254
+ val = kpis.get(key)
255
+ if val is None:
256
+ continue
257
+ if isinstance(val, (int, float)) and val > 100:
258
+ val = f"{val:,.0f}"
259
+ html += card(icon, label, str(val), colour)
260
+
261
+ # Add any extra KPIs not in the config
262
+ known = {k for k, *_ in kpi_config}
263
  for key, val in kpis.items():
264
+ if key not in known:
265
+ label = key.replace("_", " ").title()
266
+ if isinstance(val, (int, float)) and val > 100:
267
+ val = f"{val:,.0f}"
268
+ html += card("πŸ“ˆ", label, str(val), "#8fa8f8")
269
+
270
+ html += "</div>"
271
+ return html
272
+
273
+
274
+ # =========================================================
275
+ # AI DASHBOARD (Tab 3) -- LLM picks what to display
276
+ # =========================================================
277
+
278
+ DASHBOARD_SYSTEM = """You are an AI dashboard assistant for a book-sales analytics app.
279
+ The user asks questions or requests about their data. You have access to pre-computed
280
+ artifacts from a Python analysis pipeline.
281
+
282
+ AVAILABLE ARTIFACTS (only reference ones that exist):
283
+ {artifacts_json}
284
+
285
+ KPI SUMMARY: {kpis_json}
286
+
287
+ YOUR JOB:
288
+ 1. Answer the user's question conversationally using the KPIs and your knowledge of the artifacts.
289
+ 2. At the END of your response, output a JSON block (fenced with ```json ... ```) that tells
290
+ the dashboard which artifact to display. The JSON must have this shape:
291
+ {{"show": "figure"|"table"|"none", "scope": "python", "filename": "..."}}
292
+
293
+ - Use "show": "figure" to display a chart image.
294
+ - Use "show": "table" to display a CSV/JSON table.
295
+ - Use "show": "none" if no artifact is relevant.
296
+
297
+ RULES:
298
+ - If the user asks about sales trends or forecasting by title, show sales_trends or arima figures.
299
+ - If the user asks about sentiment, show sentiment figure or sentiment_counts table.
300
+ - If the user asks about forecast accuracy or model comparison, show arima figures.
301
+ - If the user asks about top sellers, show top_titles_by_units_sold.csv.
302
+ - If the user asks a general data question, pick the most relevant artifact.
303
+ - Keep your answer concise (2-4 sentences), then the JSON block.
304
+ """
305
 
306
+ JSON_BLOCK_RE = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL)
307
+ FALLBACK_JSON_RE = re.compile(r"\{[^{}]*\"show\"[^{}]*\}", re.DOTALL)
 
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
+ def _parse_display_directive(text: str) -> Dict[str, str]:
311
+ m = JSON_BLOCK_RE.search(text)
312
+ if m:
313
+ try:
314
+ return json.loads(m.group(1))
315
+ except json.JSONDecodeError:
316
+ pass
317
+ m = FALLBACK_JSON_RE.search(text)
318
+ if m:
319
+ try:
320
+ return json.loads(m.group(0))
321
+ except json.JSONDecodeError:
322
+ pass
323
+ return {"show": "none"}
324
 
325
 
326
+ def _clean_response(text: str) -> str:
327
+ """Strip the JSON directive block from the displayed response."""
328
+ return JSON_BLOCK_RE.sub("", text).strip()
329
+
330
+
331
+ def ai_chat(user_msg: str, history: list):
332
+ """Chat function for the AI Dashboard tab."""
333
+ if not user_msg or not user_msg.strip():
334
+ return history, "", None, None
335
+
336
+ idx = artifacts_index()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  kpis = load_kpis()
 
 
 
 
 
338
 
339
+ if not LLM_ENABLED:
340
+ reply, directive = _keyword_fallback(user_msg, idx, kpis)
341
+ else:
342
+ system = DASHBOARD_SYSTEM.format(
343
+ artifacts_json=json.dumps(idx, indent=2),
344
+ kpis_json=json.dumps(kpis, indent=2) if kpis else "(no KPIs yet, run the pipeline first)",
345
+ )
346
+ msgs = [{"role": "system", "content": system}]
347
+ for entry in (history or [])[-6:]:
348
+ msgs.append(entry)
349
+ msgs.append({"role": "user", "content": user_msg})
350
 
351
+ try:
352
+ r = llm_client.chat_completion(
353
+ model=MODEL_NAME,
354
+ messages=msgs,
355
+ temperature=0.3,
356
+ max_tokens=600,
357
+ stream=False,
358
+ )
359
+ raw = (
360
+ r["choices"][0]["message"]["content"]
361
+ if isinstance(r, dict)
362
+ else r.choices[0].message.content
363
+ )
364
+ directive = _parse_display_directive(raw)
365
+ reply = _clean_response(raw)
366
+ except Exception as e:
367
+ reply = f"LLM error: {e}. Falling back to keyword matching."
368
+ reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
369
+ reply += "\n\n" + reply_fb
370
+
371
+ # Resolve artifact paths
372
+ fig_out = None
373
+ tab_out = None
374
+ show = directive.get("show", "none")
375
+ fname = directive.get("filename", "")
376
+
377
+ if show == "figure" and fname:
378
+ fp = PY_FIG_DIR / fname
379
+ if fp.exists():
380
+ fig_out = str(fp)
381
+ else:
382
+ reply += f"\n\n*(Could not find figure: {fname})*"
383
+
384
+ if show == "table" and fname:
385
+ fp = PY_TAB_DIR / fname
386
+ if fp.exists():
387
+ tab_out = _load_table_safe(fp)
388
+ else:
389
+ reply += f"\n\n*(Could not find table: {fname})*"
390
+
391
+ new_history = (history or []) + [
392
+ {"role": "user", "content": user_msg},
393
+ {"role": "assistant", "content": reply},
394
+ ]
395
+
396
+ return new_history, "", fig_out, tab_out
397
 
398
 
399
+ def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
400
+ """Simple keyword matcher when LLM is unavailable."""
401
  msg_lower = msg.lower()
 
 
 
 
 
 
 
 
 
 
402
 
403
+ if not idx["python"]["figures"] and not idx["python"]["tables"]:
404
+ return (
405
+ "No artifacts found yet. Please run the pipeline first (Tab 1), "
406
+ "then come back here to explore the results.",
407
+ {"show": "none"},
408
+ )
 
 
409
 
410
+ kpi_text = ""
411
+ if kpis:
412
+ total = kpis.get("total_units_sold", 0)
413
+ kpi_text = (
414
+ f"Quick summary: **{kpis.get('n_titles', '?')}** book titles across "
415
+ f"**{kpis.get('n_months', '?')}** months, with **{total:,.0f}** total units sold."
416
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
+ if any(w in msg_lower for w in ["trend", "sales trend", "monthly sale"]):
419
+ return (
420
+ f"Here are the sales trends for sampled titles. {kpi_text}",
421
+ {"show": "figure", "scope": "python", "filename": "sales_trends_sampled_titles.png"},
422
+ )
423
 
424
+ if any(w in msg_lower for w in ["sentiment", "review", "positive", "negative"]):
425
+ return (
426
+ f"Here is the sentiment distribution across sampled book titles. {kpi_text}",
427
+ {"show": "figure", "scope": "python", "filename": "sentiment_distribution_sampled_titles.png"},
428
+ )
429
 
430
+ if any(w in msg_lower for w in ["arima", "forecast", "predict"]):
431
+ return (
432
+ f"Here are the ARIMA forecasts for sampled titles. {kpi_text}",
433
+ {"show": "figure", "scope": "python", "filename": "arima_forecasts_sampled_titles.png"},
434
+ )
 
 
 
 
 
 
 
 
 
435
 
436
+ if any(w in msg_lower for w in ["top", "best sell", "popular", "rank"]):
437
+ return (
438
+ f"Here are the top-selling titles by units sold. {kpi_text}",
439
+ {"show": "table", "scope": "python", "filename": "top_titles_by_units_sold.csv"},
440
+ )
441
 
442
+ if any(w in msg_lower for w in ["price", "pricing", "decision"]):
443
+ return (
444
+ f"Here are the pricing decisions. {kpi_text}",
445
+ {"show": "table", "scope": "python", "filename": "pricing_decisions.csv"},
446
+ )
447
 
448
+ if any(w in msg_lower for w in ["dashboard", "overview", "summary", "kpi"]):
449
+ return (
450
+ f"Dashboard overview: {kpi_text}\n\nAsk me about sales trends, sentiment, forecasts, "
451
+ "pricing, or top sellers to see specific visualizations.",
452
+ {"show": "table", "scope": "python", "filename": "df_dashboard.csv"},
453
+ )
454
 
455
+ # Default
456
+ return (
457
+ f"I can show you various analyses. {kpi_text}\n\n"
458
+ "Try asking about: **sales trends**, **sentiment**, **ARIMA forecasts**, "
459
+ "**pricing decisions**, **top sellers**, or **dashboard overview**.",
460
+ {"show": "none"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  )
 
462
 
463
+
464
+ # =========================================================
465
+ # INTERACTIVE PLOTLY CHARTS (built from CSV artifacts)
466
+ # =========================================================
467
+
468
+ CHART_PALETTE = ["#7c5cbf", "#2ec4a0", "#e8537a", "#e8a230", "#5e8fef",
469
+ "#c45ea8", "#3dbacc", "#a0522d", "#6aaa3a", "#d46060"]
470
+
471
+ def _styled_layout(**kwargs) -> dict:
472
+ defaults = dict(
473
+ template="plotly_white",
474
+ paper_bgcolor="rgba(255,255,255,0.95)",
475
+ plot_bgcolor="rgba(255,255,255,0.98)",
476
+ font=dict(family="system-ui, sans-serif", color="#2d1f4e", size=12),
477
+ margin=dict(l=60, r=20, t=70, b=70),
478
+ legend=dict(
479
+ orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1,
480
+ bgcolor="rgba(255,255,255,0.92)",
481
+ bordercolor="rgba(124,92,191,0.35)", borderwidth=1,
482
+ ),
483
+ title=dict(font=dict(size=15, color="#4b2d8a")),
484
+ )
485
+ defaults.update(kwargs)
486
+ return defaults
487
+
488
+
489
+ def _empty_chart(title: str) -> go.Figure:
490
+ fig = go.Figure()
491
+ fig.update_layout(
492
+ title=title, height=420,
493
+ template="plotly_white",
494
+ paper_bgcolor="rgba(255,255,255,0.95)",
495
+ annotations=[dict(
496
+ text="Run the pipeline to generate data",
497
+ x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False,
498
+ font=dict(size=14, color="rgba(124,92,191,0.5)"),
499
+ )],
500
+ )
501
+ return fig
502
+
503
+
504
+ def build_sales_chart() -> go.Figure:
505
+ """Interactive sales trends from the dashboard CSV."""
506
+ path = PY_TAB_DIR / "df_dashboard.csv"
507
+ if not path.exists():
508
+ return _empty_chart("Sales Trends β€” run the pipeline first")
509
+ df = pd.read_csv(path)
510
+ # Try to find a month/date column and a value column
511
+ date_col = next((c for c in df.columns if "month" in c.lower() or "date" in c.lower()), None)
512
+ val_cols = [c for c in df.columns if c != date_col and df[c].dtype in ("float64", "int64")]
513
+ if not date_col or not val_cols:
514
+ return _empty_chart("Could not auto-detect columns in df_dashboard.csv")
515
+ df[date_col] = pd.to_datetime(df[date_col], errors="coerce")
516
+ fig = go.Figure()
517
+ for i, col in enumerate(val_cols):
518
+ fig.add_trace(go.Scatter(
519
+ x=df[date_col], y=df[col], name=col.replace("_", " ").title(),
520
+ mode="lines+markers", line=dict(color=CHART_PALETTE[i % len(CHART_PALETTE)], width=2),
521
+ marker=dict(size=4),
522
+ hovertemplate=f"<b>{col.replace('_',' ').title()}</b><br>%{{x|%b %Y}}: %{{y:,.0f}}<extra></extra>",
523
+ ))
524
+ fig.update_layout(**_styled_layout(height=450, hovermode="x unified",
525
+ title=dict(text="Monthly Overview")))
526
+ fig.update_xaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
527
+ fig.update_yaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
528
+ return fig
529
+
530
+
531
+ def build_sentiment_chart() -> go.Figure:
532
+ """Interactive sentiment distribution from sentiment_counts CSV."""
533
+ path = PY_TAB_DIR / "sentiment_counts_sampled.csv"
534
+ if not path.exists():
535
+ return _empty_chart("Sentiment Distribution β€” run the pipeline first")
536
+ df = pd.read_csv(path)
537
+ # Expect columns like: grouped_title, negative, neutral, positive
538
+ title_col = df.columns[0]
539
+ sent_cols = [c for c in ["negative", "neutral", "positive"] if c in df.columns]
540
+ if not sent_cols:
541
+ return _empty_chart("No sentiment columns found in CSV")
542
+ colors = {"negative": "#e8537a", "neutral": "#5e8fef", "positive": "#2ec4a0"}
543
+ fig = go.Figure()
544
+ for col in sent_cols:
545
+ fig.add_trace(go.Bar(
546
+ name=col.title(), y=df[title_col], x=df[col],
547
+ orientation="h", marker_color=colors.get(col, "#888"),
548
+ hovertemplate=f"<b>{col.title()}</b>: %{{x}}<extra></extra>",
549
+ ))
550
+ fig.update_layout(**_styled_layout(
551
+ height=max(400, len(df) * 28),
552
+ barmode="stack",
553
+ title=dict(text="Sentiment Distribution by Book"),
554
+ ))
555
+ fig.update_xaxes(title="Number of Reviews")
556
+ fig.update_yaxes(autorange="reversed")
557
+ return fig
558
+
559
+
560
+ def build_top_sellers_chart() -> go.Figure:
561
+ """Interactive bar chart of top sellers."""
562
+ path = PY_TAB_DIR / "top_titles_by_units_sold.csv"
563
+ if not path.exists():
564
+ return _empty_chart("Top Sellers β€” run the pipeline first")
565
+ df = pd.read_csv(path).head(15)
566
+ title_col = next((c for c in df.columns if "title" in c.lower()), df.columns[0])
567
+ val_col = next((c for c in df.columns if "unit" in c.lower() or "sold" in c.lower()), df.columns[-1])
568
+ fig = go.Figure(go.Bar(
569
+ y=df[title_col], x=df[val_col],
570
+ orientation="h",
571
+ marker=dict(
572
+ color=df[val_col],
573
+ colorscale=[[0, "#c5b4f0"], [1, "#7c5cbf"]],
574
+ ),
575
+ hovertemplate="<b>%{y}</b><br>Units: %{x:,.0f}<extra></extra>",
576
+ ))
577
+ fig.update_layout(**_styled_layout(
578
+ height=max(400, len(df) * 30),
579
+ title=dict(text="Top Selling Titles"),
580
+ showlegend=False,
581
+ ))
582
+ fig.update_yaxes(autorange="reversed")
583
+ fig.update_xaxes(title="Total Units Sold")
584
+ return fig
585
+
586
+
587
+ def refresh_charts():
588
+ """Rebuild all interactive charts from latest CSV data."""
589
+ return build_sales_chart(), build_sentiment_chart(), build_top_sellers_chart()
590
+
591
+
592
+ # =========================================================
593
+ # UI
594
+ # =========================================================
595
+
596
+ ensure_dirs()
597
+
598
+ def load_css() -> str:
599
+ css_path = BASE_DIR / "style.css"
600
+ return css_path.read_text(encoding="utf-8") if css_path.exists() else ""
601
+
602
+
603
+ with gr.Blocks(title="AIBDM 2026 Workshop App") as demo:
604
 
605
  gr.Markdown(
606
+ "# AIBDM 2026 - AI & Big Data Management - Workshop App\n"
607
+ "*Run notebooks, explore results, and chat with your data*",
608
  elem_id="escp_title",
609
  )
610
 
611
+ # ===========================================================
612
+ # TAB 1 -- Pipeline Runner
613
+ # ===========================================================
614
  with gr.Tab("Pipeline Runner"):
615
+ gr.Markdown(
616
+ )
617
+
618
+ with gr.Row():
619
+ with gr.Column(scale=1):
620
+ btn_nb1 = gr.Button(
621
+ "Step 1: Data Creation",
622
+ variant="secondary",
623
+ )
624
+ with gr.Column(scale=1):
625
+ btn_nb2 = gr.Button(
626
+ "Step 2: Python Analysis",
627
+ variant="secondary",
628
+ )
629
 
630
  with gr.Row():
631
+ btn_all = gr.Button(
632
+ "Run Full Pipeline (Both Steps)",
633
+ variant="primary",
634
+ )
635
 
636
+ run_log = gr.Textbox(
637
  label="Execution Log",
638
  lines=18,
639
+ max_lines=30,
640
  interactive=False,
 
641
  )
642
 
643
+ btn_nb1.click(run_datacreation, outputs=[run_log])
644
+ btn_nb2.click(run_pythonanalysis, outputs=[run_log])
645
+ btn_all.click(run_full_pipeline, outputs=[run_log])
646
 
647
+ # ===========================================================
648
+ # TAB 2 -- Results Gallery
649
+ # ===========================================================
650
  with gr.Tab("Results Gallery"):
651
  kpi_html = gr.HTML(value=render_kpi_cards)
652
 
653
+ gr.Markdown(
654
+ "After running the pipeline, click **Refresh** to load all figures and tables."
655
+ )
656
+
657
+ refresh_btn = gr.Button("Refresh Gallery", variant="primary")
658
 
659
+ gr.Markdown("#### Figures")
660
  gallery = gr.Gallery(
661
  label="Generated Figures",
662
+ columns=2,
663
+ height=480,
664
  object_fit="contain",
665
  )
666
 
667
+ gr.Markdown("#### Tables")
668
+ table_dropdown = gr.Dropdown(
669
+ label="Select a table to view",
670
+ choices=[],
671
+ interactive=True,
672
+ )
673
+ table_display = gr.Dataframe(
674
+ label="Table Preview",
675
+ interactive=False,
676
+ )
677
+
678
+ refresh_btn.click(
679
+ refresh_gallery,
680
+ outputs=[gallery, table_dropdown, table_display, kpi_html],
681
+ )
682
+ table_dropdown.change(
683
+ on_table_select,
684
+ inputs=[table_dropdown],
685
+ outputs=[table_display],
686
+ )
687
 
688
+ # ===========================================================
689
+ # TAB 3 -- Interactive Charts
690
+ # ===========================================================
691
+ with gr.Tab("Interactive Charts"):
692
+ gr.Markdown(
693
+ "### Live interactive charts built from your pipeline data\n\n"
694
+ "These charts are generated from the CSV artifacts. "
695
+ "Click **Refresh Charts** after running the pipeline."
696
+ )
697
+ refresh_charts_btn = gr.Button("Refresh Charts", variant="primary")
698
 
699
+ chart_sales = gr.Plot(label="Monthly Overview", value=build_sales_chart)
700
+ chart_sentiment = gr.Plot(label="Sentiment Distribution", value=build_sentiment_chart)
701
+ chart_top = gr.Plot(label="Top Sellers", value=build_top_sellers_chart)
702
 
703
+ refresh_charts_btn.click(
704
+ refresh_charts,
705
+ outputs=[chart_sales, chart_sentiment, chart_top],
 
 
 
706
  )
707
+
708
+ # ===========================================================
709
+ # TAB 4 -- AI Dashboard
710
+ # ===========================================================
711
+ with gr.Tab('"AI" Dashboard'):
712
  gr.Markdown(
713
+ "### Ask questions, get visualisations\n\n"
714
+ "Describe what you want to see and the AI will pick the right chart or table. "
715
+ + (
716
+ "*LLM is active.*"
717
+ if LLM_ENABLED
718
+ else "*No API key detected \u2014 using keyword matching. "
719
+ "Set `HF_API_KEY` in Space secrets for full LLM support.*"
720
+ )
721
  )
722
 
723
  with gr.Row(equal_height=True):
724
  with gr.Column(scale=1):
725
+ chatbot = gr.Chatbot(
726
+ label="Conversation",
727
+ height=380,
728
+ )
729
+ user_input = gr.Textbox(
730
+ label="Ask about your data",
731
+ placeholder="e.g. Show me sales trends / What are the top sellers? / Sentiment analysis",
732
+ lines=1,
733
+ )
734
  gr.Examples(
735
  examples=[
736
+ "Show me the sales trends",
737
+ "What does the sentiment look like?",
738
+ "Which titles sell the most?",
739
+ "Show the ARIMA forecasts",
740
+ "What are the pricing decisions?",
741
+ "Give me a dashboard overview",
742
  ],
743
+ inputs=user_input,
744
  )
745
 
746
  with gr.Column(scale=1):
747
+ ai_figure = gr.Image(
748
+ label="Visualisation",
749
+ height=350,
750
+ )
751
+ ai_table = gr.Dataframe(
752
+ label="Data Table",
753
+ interactive=False,
754
+ )
755
+
756
+ user_input.submit(
757
+ ai_chat,
758
+ inputs=[user_input, chatbot],
759
+ outputs=[chatbot, user_input, ai_figure, ai_table],
760
+ )
761
+
762
+
763
+ demo.launch(css=load_css(), allowed_paths=[str(BASE_DIR)])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
style.css CHANGED
@@ -1,56 +1,14 @@
1
- /* ============================================================
2
- ESCP Business School β€” AI for Business Decision Making
3
- Gradio 5.x Custom Theme | ESCP Deep Purple + Glass Cards
4
- ============================================================ */
5
-
6
- /* ---------- design tokens ---------- */
7
- :root {
8
- --escp-purple: rgb(40, 9, 109);
9
- --escp-purple-light: rgb(60, 20, 140);
10
- --escp-gold: rgb(242, 198, 55);
11
- --bg-card: rgba(255, 255, 255, 0.95);
12
- --bg-card-glass: rgba(255, 255, 255, 0.88);
13
- --lavender: #c5b4f0;
14
- --lavender-mid: #a48de8;
15
- --violet: #7c5cbf;
16
- --violet-deep: #4b2d8a;
17
- --mint: #6ee7c7;
18
- --blush: #ffb3c8;
19
- --red: #ff6b8a;
20
- --text: #2d1f4e;
21
- --text-mid: #6b5b8e;
22
- --text-muted: #9d8fc4;
23
- --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
24
- --font-mono: 'SF Mono', 'Cascadia Code', Consolas, 'Liberation Mono', monospace;
25
- --radius-sm: 10px;
26
- --radius-md: 16px;
27
- --radius-lg: 20px;
28
- --radius-pill: 50px;
29
- --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
30
- --shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.18);
31
- --transition: 0.2s ease;
32
- }
33
-
34
- /* ---------- ESCP deep purple background (extends infinitely) ---------- */
35
- html, body {
36
- background-color: var(--escp-purple) !important;
37
- margin: 0 !important;
38
- padding: 0 !important;
39
- min-height: 100vh !important;
40
- }
41
 
42
- /* Background images: top (once at top) + mid (repeats to fill all remaining space) */
43
- /* NOTE: __BG_TOP__, __BG_MID__, __BG_BOTTOM__ are replaced at runtime by app.py
44
- with the correct Gradio file-serving paths. */
45
  gradio-app,
46
  .gradio-app,
47
  .main,
48
  #app,
49
  [data-testid="app"] {
50
- background-color: var(--escp-purple) !important;
51
  background-image:
52
- var(--bg-top),
53
- var(--bg-mid) !important;
54
  background-position:
55
  top center,
56
  0 913px !important;
@@ -63,7 +21,13 @@ gradio-app,
63
  min-height: 100vh !important;
64
  }
65
 
66
- /* Fixed bottom banner β€” stays pinned to viewport bottom */
 
 
 
 
 
 
67
  body::after {
68
  content: '' !important;
69
  position: fixed !important;
@@ -71,7 +35,7 @@ body::after {
71
  left: 0 !important;
72
  right: 0 !important;
73
  height: 130px !important;
74
- background-image: var(--bg-bottom) !important;
75
  background-size: 100% 100% !important;
76
  background-repeat: no-repeat !important;
77
  background-position: bottom center !important;
@@ -79,63 +43,47 @@ body::after {
79
  z-index: 9999 !important;
80
  }
81
 
82
- /* ---------- container: transparent so purple shows through ---------- */
83
  .gradio-container {
84
  max-width: 1400px !important;
85
  width: 94vw !important;
86
  margin: 0 auto !important;
87
- padding-top: 200px !important;
88
- padding-bottom: 160px !important;
89
  background: transparent !important;
90
- font-family: var(--font-sans) !important;
91
- }
92
-
93
- /* ---------- animations ---------- */
94
- @keyframes popIn {
95
- 0% { opacity: 0; transform: scale(0.94) translateY(10px); }
96
- 100% { opacity: 1; transform: scale(1) translateY(0); }
97
  }
98
 
99
- @keyframes shimmerSlide {
100
- 0% { background-position: -200% center; }
101
- 100% { background-position: 200% center; }
102
- }
103
-
104
- /* ---------- title: ESCP gold ---------- */
105
- #escp_title h1,
106
- .gradio-container > .main > div:first-child h1 {
107
- color: var(--escp-gold) !important;
108
- font-size: 2.8rem !important;
109
  font-weight: 800 !important;
110
  text-align: center !important;
111
- margin: 0 0 8px 0 !important;
112
- text-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
113
  }
114
 
115
- /* subtitle / italic text */
116
- #escp_title p, #escp_title em,
117
- .gradio-container > .main > div:first-child p,
118
- .gradio-container > .main > div:first-child em {
119
- color: rgba(255, 255, 255, 0.85) !important;
120
  text-align: center !important;
121
  }
122
 
123
- /* ---------- tab bar ---------- */
124
  .tabs > .tab-nav,
125
  .tab-nav,
126
- div[role="tablist"] {
127
- background: rgba(40, 9, 109, 0.6) !important;
128
- backdrop-filter: blur(14px) !important;
129
- -webkit-backdrop-filter: blur(14px) !important;
130
- border-radius: var(--radius-sm) var(--radius-sm) 0 0 !important;
131
  padding: 4px !important;
132
- gap: 2px !important;
133
- border: none !important;
134
  }
135
 
136
- .tabs > .tab-nav > button,
137
  .tab-nav button,
138
- button[role="tab"] {
 
 
 
 
139
  color: #ffffff !important;
140
  font-weight: 600 !important;
141
  border: none !important;
@@ -143,111 +91,87 @@ button[role="tab"] {
143
  padding: 10px 20px !important;
144
  border-radius: 8px 8px 0 0 !important;
145
  opacity: 1 !important;
146
- font-family: var(--font-sans) !important;
147
- font-size: 0.9rem !important;
148
- transition: all var(--transition) !important;
149
  }
150
 
151
- .tabs > .tab-nav > button.selected,
152
  .tab-nav button.selected,
153
- button[role="tab"][aria-selected="true"] {
154
- color: var(--escp-gold) !important;
155
- background: rgba(255, 255, 255, 0.12) !important;
156
- }
157
-
158
- .tabs > .tab-nav > button:hover:not(.selected),
159
- .tab-nav button:hover:not(.selected) {
160
- background: rgba(255, 255, 255, 0.08) !important;
161
- }
162
-
163
- /* ---------- tab content area (white cards on purple) ---------- */
164
- .tabitem, .gradio-tabitem {
165
- background: var(--bg-card) !important;
166
- border-radius: 0 0 var(--radius-sm) var(--radius-sm) !important;
167
- padding: 20px !important;
168
- animation: popIn 0.35s ease both;
169
- border: none !important;
170
- box-shadow: none !important;
171
  }
172
 
173
- /* ---------- white card panels inside tabs ---------- */
174
  .gradio-container .gr-block,
175
  .gradio-container .gr-box,
176
  .gradio-container .gr-panel,
177
  .gradio-container .gr-group {
178
  background: #ffffff !important;
179
- border-radius: var(--radius-sm) !important;
180
  }
181
 
182
- /* ---------- buttons: primary (ESCP purple) ---------- */
183
- .primary, button.primary,
184
- .gr-button-primary, .gr-button.primary {
185
- background-color: var(--escp-purple) !important;
186
- color: #ffffff !important;
187
- border: none !important;
188
- border-radius: var(--radius-sm) !important;
189
- padding: 10px 20px !important;
190
- font-weight: 600 !important;
191
- box-shadow: 0 3px 12px rgba(40, 9, 109, 0.3) !important;
192
- transition: all var(--transition) !important;
193
  }
194
 
195
- .primary:hover, button.primary:hover {
196
- background-color: var(--escp-purple-light) !important;
197
- transform: translateY(-1px) !important;
198
- box-shadow: 0 5px 18px rgba(40, 9, 109, 0.4) !important;
 
 
 
199
  }
200
 
201
- /* ---------- buttons: secondary ---------- */
202
- .secondary, button.secondary,
203
- .gr-button-secondary, .gr-button.secondary {
204
- background-color: #ffffff !important;
205
- color: var(--escp-purple) !important;
206
- border: 2px solid var(--escp-purple) !important;
207
- border-radius: var(--radius-sm) !important;
208
- padding: 10px 20px !important;
209
  font-weight: 600 !important;
210
- transition: all var(--transition) !important;
 
211
  }
212
 
213
- .secondary:hover, button.secondary:hover {
214
- background-color: rgb(240, 238, 250) !important;
215
- transform: translateY(-1px) !important;
 
216
  }
217
 
218
- /* ---------- inputs / textareas ---------- */
219
- .gradio-container input,
220
- .gradio-container textarea,
221
- .gradio-container select {
222
- background: #ffffff !important;
223
- border: 1px solid #d1d5db !important;
224
- border-radius: 8px !important;
225
- font-family: var(--font-sans) !important;
226
- color: var(--text) !important;
227
- transition: all var(--transition) !important;
228
  }
229
 
230
- .gradio-container input:focus,
231
- .gradio-container textarea:focus,
232
- .gradio-container select:focus {
233
- outline: none !important;
234
- border-color: var(--lavender-mid) !important;
235
- box-shadow: 0 0 0 3px rgba(164, 141, 232, 0.25) !important;
236
  }
237
 
238
- /* ---------- pipeline log (dark terminal) ---------- */
239
- #log-box textarea {
240
- background: #1a0e2e !important;
241
- color: var(--lavender) !important;
242
- font-family: var(--font-mono) !important;
243
- font-size: 0.82rem !important;
244
- line-height: 1.7 !important;
245
- border-radius: var(--radius-md) !important;
246
- border: 1px solid rgba(197, 180, 240, 0.2) !important;
247
- box-shadow: inset 0 2px 12px rgba(0, 0, 0, 0.2) !important;
248
  }
249
 
250
- /* ---------- chatbot ---------- */
 
 
 
 
 
 
 
 
251
  .gr-chatbot {
252
  min-height: 380px !important;
253
  background-color: #ffffff !important;
@@ -255,7 +179,7 @@ button[role="tab"][aria-selected="true"] {
255
  }
256
 
257
  .gr-chatbot .message.user {
258
- background-color: rgb(232, 225, 250) !important;
259
  border-radius: 12px !important;
260
  }
261
 
@@ -264,67 +188,42 @@ button[role="tab"][aria-selected="true"] {
264
  border-radius: 12px !important;
265
  }
266
 
267
- /* ---------- headings inside tabs ---------- */
268
- .tabitem h3, .gradio-tabitem h3 {
269
- color: var(--escp-purple) !important;
270
- font-weight: 700 !important;
271
- }
272
-
273
- .tabitem h4, .gradio-tabitem h4 {
274
- color: #374151 !important;
275
- }
276
-
277
- /* ---------- gallery ---------- */
278
  .gallery {
279
  background: #ffffff !important;
280
- border-radius: var(--radius-sm) !important;
281
  }
282
 
283
- /* ---------- dataframe / table ---------- */
284
- [data-testid="dataframe"] {
285
- background-color: #ffffff !important;
286
- border-radius: var(--radius-sm) !important;
287
  }
288
 
289
- table {
290
- font-size: 0.85rem !important;
 
 
 
 
 
 
291
  }
292
 
293
- /* ---------- example prompts ---------- */
294
  .examples-row button {
295
- background: rgb(240, 238, 250) !important;
296
- color: var(--escp-purple) !important;
297
- border: 1px solid var(--escp-purple) !important;
298
  border-radius: 8px !important;
299
  font-size: 0.85rem !important;
300
  }
301
 
302
  .examples-row button:hover {
303
- background: rgb(232, 225, 250) !important;
304
- }
305
-
306
- /* ---------- scrollbars ---------- */
307
- * {
308
- scrollbar-width: thin;
309
- scrollbar-color: var(--lavender) transparent;
310
  }
311
 
312
- ::-webkit-scrollbar { width: 6px; height: 6px; }
313
- ::-webkit-scrollbar-track { background: transparent; }
314
- ::-webkit-scrollbar-thumb {
315
- background: linear-gradient(180deg, var(--lavender), var(--mint));
316
- border-radius: 3px;
317
- }
318
-
319
- /* ---------- plotly charts ---------- */
320
- .js-plotly-plot .plot-container,
321
- .js-plotly-plot .main-svg,
322
- .js-plotly-plot .bg {
323
- fill: transparent !important;
324
- background: transparent !important;
325
- }
326
-
327
- /* ---------- header / footer: transparent ---------- */
328
  header, header *,
329
  footer, footer * {
330
  background: transparent !important;
@@ -340,45 +239,54 @@ header a, header button {
340
 
341
  section footer,
342
  section footer button,
343
- section footer a,
344
- section footer button *,
345
- section footer a * {
346
  background: transparent !important;
347
  background-color: transparent !important;
348
- box-shadow: none !important;
349
  border: none !important;
 
350
  color: white !important;
351
  }
352
 
353
- /* ---------- responsive ---------- */
354
- @media (max-width: 768px) {
355
- .gradio-container {
356
- padding-top: 120px !important;
357
- padding-bottom: 160px !important;
358
- width: 98vw !important;
359
- }
360
 
361
- .tabs > .tab-nav > button,
362
- .tab-nav button {
363
- padding: 8px 14px !important;
364
- font-size: 0.82rem !important;
365
- }
 
 
366
  }
367
 
368
- @media (max-width: 480px) {
369
- .gradio-container {
370
- padding-top: 80px !important;
371
- }
 
 
 
 
 
372
 
373
- #escp_title h1,
374
- .gradio-container > .main > div:first-child h1 {
375
- font-size: 1.8rem !important;
376
- }
 
 
 
 
377
  }
378
 
379
- /* ---------- smooth transitions ---------- */
380
- button, a, input, textarea, select {
381
- transition-property: background, border-color, box-shadow, color, opacity, transform;
382
- transition-duration: 0.2s;
383
- transition-timing-function: ease;
 
384
  }
 
1
+ /* ── ESCP Background: top (once) + mid (repeats) + bottom (fixed) ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
 
 
 
3
  gradio-app,
4
  .gradio-app,
5
  .main,
6
  #app,
7
  [data-testid="app"] {
8
+ background-color: rgb(40,9,109) !important;
9
  background-image:
10
+ url('file=background_top.png'),
11
+ url('file=background_mid.png') !important;
12
  background-position:
13
  top center,
14
  0 913px !important;
 
21
  min-height: 100vh !important;
22
  }
23
 
24
+ html, body {
25
+ background-color: rgb(40,9,109) !important;
26
+ margin: 0 !important;
27
+ padding: 0 !important;
28
+ min-height: 100vh !important;
29
+ }
30
+
31
  body::after {
32
  content: '' !important;
33
  position: fixed !important;
 
35
  left: 0 !important;
36
  right: 0 !important;
37
  height: 130px !important;
38
+ background-image: url('file=background_bottom.png') !important;
39
  background-size: 100% 100% !important;
40
  background-repeat: no-repeat !important;
41
  background-position: bottom center !important;
 
43
  z-index: 9999 !important;
44
  }
45
 
46
+ /* ── Container ── */
47
  .gradio-container {
48
  max-width: 1400px !important;
49
  width: 94vw !important;
50
  margin: 0 auto !important;
51
+ padding-top: 220px !important;
52
+ padding-bottom: 150px !important;
53
  background: transparent !important;
 
 
 
 
 
 
 
54
  }
55
 
56
+ /* ── Title: ESCP gold ── */
57
+ #escp_title h1 {
58
+ color: rgb(242,198,55) !important;
59
+ font-size: 3rem !important;
 
 
 
 
 
 
60
  font-weight: 800 !important;
61
  text-align: center !important;
62
+ margin: 0 0 12px 0 !important;
 
63
  }
64
 
65
+ #escp_title p, #escp_title em {
66
+ color: rgba(255,255,255,0.85) !important;
 
 
 
67
  text-align: center !important;
68
  }
69
 
70
+ /* ── Tab bar ── */
71
  .tabs > .tab-nav,
72
  .tab-nav,
73
+ div[role="tablist"],
74
+ .svelte-tabs > .tab-nav {
75
+ background: rgba(40,9,109,0.6) !important;
76
+ border-radius: 10px 10px 0 0 !important;
 
77
  padding: 4px !important;
 
 
78
  }
79
 
80
+ .tabs > .tab-nav button,
81
  .tab-nav button,
82
+ div[role="tablist"] button,
83
+ button[role="tab"],
84
+ .svelte-tabs button,
85
+ .tab-nav > button,
86
+ .tabs button {
87
  color: #ffffff !important;
88
  font-weight: 600 !important;
89
  border: none !important;
 
91
  padding: 10px 20px !important;
92
  border-radius: 8px 8px 0 0 !important;
93
  opacity: 1 !important;
 
 
 
94
  }
95
 
96
+ .tabs > .tab-nav button.selected,
97
  .tab-nav button.selected,
98
+ button[role="tab"][aria-selected="true"],
99
+ button[role="tab"].selected,
100
+ div[role="tablist"] button[aria-selected="true"],
101
+ .svelte-tabs button.selected {
102
+ color: rgb(242,198,55) !important;
103
+ background: rgba(255,255,255,0.12) !important;
104
+ }
105
+
106
+ .tabs > .tab-nav button:not(.selected),
107
+ .tab-nav button:not(.selected),
108
+ button[role="tab"][aria-selected="false"],
109
+ button[role="tab"]:not(.selected),
110
+ div[role="tablist"] button:not([aria-selected="true"]) {
111
+ color: #ffffff !important;
112
+ opacity: 1 !important;
 
 
 
113
  }
114
 
115
+ /* ── White card panels ── */
116
  .gradio-container .gr-block,
117
  .gradio-container .gr-box,
118
  .gradio-container .gr-panel,
119
  .gradio-container .gr-group {
120
  background: #ffffff !important;
121
+ border-radius: 10px !important;
122
  }
123
 
124
+ .tabitem {
125
+ background: rgba(255,255,255,0.95) !important;
126
+ border-radius: 0 0 10px 10px !important;
127
+ padding: 16px !important;
 
 
 
 
 
 
 
128
  }
129
 
130
+ /* ── Inputs ── */
131
+ .gradio-container input,
132
+ .gradio-container textarea,
133
+ .gradio-container select {
134
+ background: #ffffff !important;
135
+ border: 1px solid #d1d5db !important;
136
+ border-radius: 8px !important;
137
  }
138
 
139
+ /* ── Buttons ── */
140
+ .gradio-container button:not([role="tab"]) {
 
 
 
 
 
 
141
  font-weight: 600 !important;
142
+ padding: 10px 16px !important;
143
+ border-radius: 10px !important;
144
  }
145
 
146
+ button.primary {
147
+ background-color: rgb(40,9,109) !important;
148
+ color: #ffffff !important;
149
+ border: none !important;
150
  }
151
 
152
+ button.primary:hover {
153
+ background-color: rgb(60,20,140) !important;
 
 
 
 
 
 
 
 
154
  }
155
 
156
+ button.secondary {
157
+ background-color: #ffffff !important;
158
+ color: rgb(40,9,109) !important;
159
+ border: 2px solid rgb(40,9,109) !important;
 
 
160
  }
161
 
162
+ button.secondary:hover {
163
+ background-color: rgb(240,238,250) !important;
 
 
 
 
 
 
 
 
164
  }
165
 
166
+ /* ── Dataframes ── */
167
+ [data-testid="dataframe"] {
168
+ background-color: #ffffff !important;
169
+ border-radius: 10px !important;
170
+ }
171
+
172
+ table { font-size: 0.85rem !important; }
173
+
174
+ /* ── Chatbot ── */
175
  .gr-chatbot {
176
  min-height: 380px !important;
177
  background-color: #ffffff !important;
 
179
  }
180
 
181
  .gr-chatbot .message.user {
182
+ background-color: rgb(232,225,250) !important;
183
  border-radius: 12px !important;
184
  }
185
 
 
188
  border-radius: 12px !important;
189
  }
190
 
191
+ /* ── Gallery ── */
 
 
 
 
 
 
 
 
 
 
192
  .gallery {
193
  background: #ffffff !important;
194
+ border-radius: 10px !important;
195
  }
196
 
197
+ /* ── Log textbox ── */
198
+ textarea {
199
+ font-family: monospace !important;
200
+ font-size: 0.8rem !important;
201
  }
202
 
203
+ /* ── Headings inside tabs ── */
204
+ .tabitem h3 {
205
+ color: rgb(40,9,109) !important;
206
+ font-weight: 700 !important;
207
+ }
208
+
209
+ .tabitem h4 {
210
+ color: #374151 !important;
211
  }
212
 
213
+ /* ── Examples row ── */
214
  .examples-row button {
215
+ background: rgb(240,238,250) !important;
216
+ color: rgb(40,9,109) !important;
217
+ border: 1px solid rgb(40,9,109) !important;
218
  border-radius: 8px !important;
219
  font-size: 0.85rem !important;
220
  }
221
 
222
  .examples-row button:hover {
223
+ background: rgb(232,225,250) !important;
 
 
 
 
 
 
224
  }
225
 
226
+ /* ── Header / footer: transparent ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  header, header *,
228
  footer, footer * {
229
  background: transparent !important;
 
239
 
240
  section footer,
241
  section footer button,
242
+ section footer a {
 
 
243
  background: transparent !important;
244
  background-color: transparent !important;
 
245
  border: none !important;
246
+ box-shadow: none !important;
247
  color: white !important;
248
  }
249
 
250
+ section footer button *,
251
+ section footer a * {
252
+ background: transparent !important;
253
+ background-color: transparent !important;
254
+ box-shadow: none !important;
255
+ }
 
256
 
257
+ section footer button::before,
258
+ section footer button::after {
259
+ background: transparent !important;
260
+ background-color: transparent !important;
261
+ background-image: none !important;
262
+ box-shadow: none !important;
263
+ filter: none !important;
264
  }
265
 
266
+ .gradio-container footer button,
267
+ .gradio-container footer button *,
268
+ .gradio-container .footer button,
269
+ .gradio-container .footer button * {
270
+ background: transparent !important;
271
+ background-color: transparent !important;
272
+ background-image: none !important;
273
+ box-shadow: none !important;
274
+ }
275
 
276
+ .gradio-container footer button::before,
277
+ .gradio-container footer button::after,
278
+ .gradio-container .footer button::before,
279
+ .gradio-container .footer button::after {
280
+ background: transparent !important;
281
+ background-color: transparent !important;
282
+ background-image: none !important;
283
+ box-shadow: none !important;
284
  }
285
 
286
+ /* ── Responsive ── */
287
+ @media (max-width: 768px) {
288
+ .gradio-container {
289
+ padding-top: 120px !important;
290
+ width: 98vw !important;
291
+ }
292
  }