teomello commited on
Commit
3a8b2e0
·
verified ·
1 Parent(s): d552245

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +728 -0
  2. requirements.txt +7 -0
  3. style.css +444 -0
app.py ADDED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Optional, Tuple
8
+
9
+ import pandas as pd
10
+ import gradio as gr
11
+ import papermill as pm
12
+
13
+ # Optional LLM (HuggingFace Inference API)
14
+ try:
15
+ from huggingface_hub import InferenceClient
16
+ except Exception:
17
+ InferenceClient = None
18
+
19
+ # =========================================================
20
+ # CONFIG
21
+ # =========================================================
22
+
23
+ BASE_DIR = Path(__file__).resolve().parent
24
+
25
+ NB1 = os.environ.get("NB1", "datacreation.ipynb").strip()
26
+ NB2 = os.environ.get("NB2", "pythonanalysis.ipynb").strip()
27
+ NB3 = os.environ.get("NB3", "ranalysis.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
+ R_FIG_DIR = ART_DIR / "r" / "figures"
34
+ R_TAB_DIR = ART_DIR / "r" / "tables"
35
+
36
+ PAPERMILL_TIMEOUT = int(os.environ.get("PAPERMILL_TIMEOUT", "1800"))
37
+ MAX_PREVIEW_ROWS = int(os.environ.get("MAX_FILE_PREVIEW_ROWS", "50"))
38
+ MAX_LOG_CHARS = int(os.environ.get("MAX_LOG_CHARS", "8000"))
39
+
40
+ HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
41
+ MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-ai/DeepSeek-R1").strip()
42
+ HF_PROVIDER = os.environ.get("HF_PROVIDER", "novita").strip()
43
+
44
+ LLM_ENABLED = bool(HF_API_KEY) and InferenceClient is not None
45
+ llm_client = (
46
+ InferenceClient(provider=HF_PROVIDER, api_key=HF_API_KEY)
47
+ if LLM_ENABLED
48
+ else None
49
+ )
50
+
51
+ # =========================================================
52
+ # HELPERS
53
+ # =========================================================
54
+
55
+ def ensure_dirs():
56
+ for p in [RUNS_DIR, ART_DIR, PY_FIG_DIR, PY_TAB_DIR, R_FIG_DIR, R_TAB_DIR]:
57
+ p.mkdir(parents=True, exist_ok=True)
58
+
59
+ def stamp():
60
+ return time.strftime("%Y%m%d-%H%M%S")
61
+
62
+ def tail(text: str, n: int = MAX_LOG_CHARS) -> str:
63
+ return (text or "")[-n:]
64
+
65
+ def _ls(dir_path: Path, exts: Tuple[str, ...]) -> List[str]:
66
+ if not dir_path.is_dir():
67
+ return []
68
+ return sorted(p.name for p in dir_path.iterdir() if p.is_file() and p.suffix.lower() in exts)
69
+
70
+ def _read_csv(path: Path) -> pd.DataFrame:
71
+ return pd.read_csv(path, nrows=MAX_PREVIEW_ROWS)
72
+
73
+ def _read_json(path: Path):
74
+ with path.open(encoding="utf-8") as f:
75
+ return json.load(f)
76
+
77
+ def artifacts_index() -> Dict[str, Any]:
78
+ return {
79
+ "python": {
80
+ "figures": _ls(PY_FIG_DIR, (".png", ".jpg", ".jpeg")),
81
+ "tables": _ls(PY_TAB_DIR, (".csv", ".json")),
82
+ },
83
+ "r": {
84
+ "figures": _ls(R_FIG_DIR, (".png", ".jpg", ".jpeg")),
85
+ "tables": _ls(R_TAB_DIR, (".csv", ".json")),
86
+ },
87
+ }
88
+
89
+ # =========================================================
90
+ # PIPELINE RUNNERS
91
+ # =========================================================
92
+
93
+ def run_notebook(nb_name: str, kernel_name: str | None = None) -> str:
94
+ ensure_dirs()
95
+ nb_in = BASE_DIR / nb_name
96
+ if not nb_in.exists():
97
+ return f"ERROR: {nb_name} not found."
98
+ nb_out = RUNS_DIR / f"run_{stamp()}_{nb_name}"
99
+ pm.execute_notebook(
100
+ input_path=str(nb_in),
101
+ output_path=str(nb_out),
102
+ cwd=str(BASE_DIR),
103
+ log_output=True,
104
+ progress_bar=False,
105
+ request_save_on_cell_execute=True,
106
+ execution_timeout=PAPERMILL_TIMEOUT,
107
+ kernel_name=kernel_name, # 👈 add this
108
+ )
109
+ return f"Executed {nb_name}"
110
+
111
+
112
+ def run_datacreation() -> str:
113
+ try:
114
+ log = run_notebook(NB1, kernel_name="python3")
115
+ csvs = [f.name for f in BASE_DIR.glob("*.csv")]
116
+ return f"OK {log}\n\nCSVs now in /app:\n" + "\n".join(f" - {c}" for c in sorted(csvs))
117
+ except Exception as e:
118
+ return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
119
+
120
+
121
+ def run_pythonanalysis() -> str:
122
+ try:
123
+ log = run_notebook(NB2)
124
+ idx = artifacts_index()
125
+ figs = idx["python"]["figures"]
126
+ tabs = idx["python"]["tables"]
127
+ return (
128
+ f"OK {log}\n\n"
129
+ f"Figures: {', '.join(figs) or '(none)'}\n"
130
+ f"Tables: {', '.join(tabs) or '(none)'}"
131
+ )
132
+ except Exception as e:
133
+ return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
134
+
135
+
136
+ def run_r() -> str:
137
+ try:
138
+ import inspect, sys, subprocess
139
+ dbg = []
140
+ dbg.append(f"RUNNING app.py path: {__file__}")
141
+ dbg.append("RUN_R source (first 300 chars):\n" + inspect.getsource(run_r)[:300])
142
+ dbg.append("Kernels:\n" + subprocess.run(
143
+ [sys.executable, "-m", "jupyter", "kernelspec", "list"],
144
+ capture_output=True, text=True
145
+ ).stdout)
146
+
147
+ log = run_notebook(NB3, kernel_name="ir")
148
+ idx = artifacts_index()
149
+ figs = idx["r"]["figures"]
150
+ tabs = idx["r"]["tables"]
151
+ return (
152
+ f"OK {log}\n\n"
153
+ f"Figures: {', '.join(figs) or '(none)'}\n"
154
+ f"Tables: {', '.join(tabs) or '(none)'}"
155
+ )
156
+ except Exception as e:
157
+ return f"FAILED {e}\n\n{traceback.format_exc()[-2000:]}"
158
+
159
+
160
+ def run_full_pipeline() -> str:
161
+ logs = []
162
+ logs.append("=" * 50)
163
+ logs.append("STEP 1/3: Data Creation (web scraping + synthetic data)")
164
+ logs.append("=" * 50)
165
+ logs.append(run_datacreation())
166
+ logs.append("")
167
+ logs.append("=" * 50)
168
+ logs.append("STEP 2/3: Python Analysis (sentiment, ARIMA, dashboard)")
169
+ logs.append("=" * 50)
170
+ logs.append(run_pythonanalysis())
171
+ logs.append("")
172
+ logs.append("=" * 50)
173
+ logs.append("STEP 3/3: R Analysis (ETS/ARIMA forecasting)")
174
+ logs.append("=" * 50)
175
+ logs.append(run_r())
176
+ return "\n".join(logs)
177
+
178
+
179
+ # =========================================================
180
+ # GALLERY LOADERS
181
+ # =========================================================
182
+
183
+ def _load_all_figures() -> List[Tuple[str, str]]:
184
+ """Return list of (filepath, caption) for Python figures only (Gallery)."""
185
+ items = []
186
+ for p in sorted(PY_FIG_DIR.glob("*.png")):
187
+ items.append((str(p), f"Python | {p.stem.replace('_', ' ').title()}"))
188
+ return items
189
+
190
+
191
+ def _list_r_html() -> List[str]:
192
+ """Return sorted list of interactive HTML filenames in R figures dir."""
193
+ return _ls(R_FIG_DIR, (".html",))
194
+
195
+
196
+ def load_r_html_figure(name: str) -> str:
197
+ """Return an iframe HTML string for the given R plotly HTML filename."""
198
+ if not name:
199
+ return "<p style='color:#888;padding:16px;'>Select a plot from the dropdown above.</p>"
200
+ path = R_FIG_DIR / name
201
+ if not path.exists():
202
+ return f"<p style='color:red;padding:16px;'>File not found: {name}. Run the R pipeline first.</p>"
203
+ return (
204
+ f'<iframe src="/gradio_api/file={path}" '
205
+ f'width="100%" height="640px" '
206
+ f'style="border:none;border-radius:8px;background:#fff;"></iframe>'
207
+ )
208
+
209
+
210
+ def _load_table_safe(path: Path) -> pd.DataFrame:
211
+ try:
212
+ if path.suffix == ".json":
213
+ obj = _read_json(path)
214
+ if isinstance(obj, dict):
215
+ return pd.DataFrame([obj])
216
+ return pd.DataFrame(obj)
217
+ return _read_csv(path)
218
+ except Exception as e:
219
+ return pd.DataFrame([{"error": str(e)}])
220
+
221
+
222
+ def refresh_gallery():
223
+ """Called when user clicks Refresh on Gallery tab."""
224
+ py_figures = _load_all_figures()
225
+ r_html_list = _list_r_html()
226
+ idx = artifacts_index()
227
+
228
+ # Build table choices
229
+ table_choices = []
230
+ for scope in ("python", "r"):
231
+ for name in idx[scope]["tables"]:
232
+ table_choices.append(f"{scope}/{name}")
233
+
234
+ # Default: show first table if available
235
+ default_df = pd.DataFrame()
236
+ if table_choices:
237
+ parts = table_choices[0].split("/", 1)
238
+ base = PY_TAB_DIR if parts[0] == "python" else R_TAB_DIR
239
+ default_df = _load_table_safe(base / parts[1])
240
+
241
+ default_html = load_r_html_figure(r_html_list[0] if r_html_list else "")
242
+
243
+ return (
244
+ py_figures if py_figures else [],
245
+ gr.update(choices=r_html_list, value=r_html_list[0] if r_html_list else None),
246
+ default_html,
247
+ gr.update(choices=table_choices, value=table_choices[0] if table_choices else None),
248
+ default_df,
249
+ )
250
+
251
+
252
+ def on_r_html_select(name: str) -> str:
253
+ return load_r_html_figure(name)
254
+
255
+
256
+ def on_table_select(choice: str):
257
+ if not choice or "/" not in choice:
258
+ return pd.DataFrame([{"hint": "Select a table above."}])
259
+ scope, name = choice.split("/", 1)
260
+ base = {"python": PY_TAB_DIR, "r": R_TAB_DIR}.get(scope)
261
+ if not base:
262
+ return pd.DataFrame([{"error": f"Unknown scope: {scope}"}])
263
+ path = base / name
264
+ if not path.exists():
265
+ return pd.DataFrame([{"error": f"File not found: {path}"}])
266
+ return _load_table_safe(path)
267
+
268
+
269
+ # =========================================================
270
+ # KPI LOADER
271
+ # =========================================================
272
+
273
+ def load_kpis() -> Dict[str, Any]:
274
+ for candidate in [PY_TAB_DIR / "kpis.json", PY_FIG_DIR / "kpis.json"]:
275
+ if candidate.exists():
276
+ try:
277
+ return _read_json(candidate)
278
+ except Exception:
279
+ pass
280
+ return {}
281
+
282
+
283
+ # =========================================================
284
+ # AI DASHBOARD (Tab 3) -- LLM picks what to display
285
+ # =========================================================
286
+
287
+ DASHBOARD_SYSTEM = """You are an AI dashboard assistant for a book-sales analytics app.
288
+ The user asks questions or requests about their data. You have access to pre-computed
289
+ artifacts from Python and R analysis pipelines.
290
+
291
+ AVAILABLE ARTIFACTS (only reference ones that exist):
292
+ {artifacts_json}
293
+
294
+ KPI SUMMARY: {kpis_json}
295
+
296
+ YOUR JOB:
297
+ 1. Answer the user's question conversationally using the KPIs and your knowledge of the artifacts.
298
+ 2. At the END of your response, output a JSON block (fenced with ```json ... ```) that tells
299
+ the dashboard which artifact to display. The JSON must have this shape:
300
+ {{"show": "figure"|"table"|"none", "scope": "python"|"r", "filename": "..."}}
301
+
302
+ - Use "show": "figure" to display a chart image.
303
+ - Use "show": "table" to display a CSV/JSON table.
304
+ - Use "show": "none" if no artifact is relevant.
305
+
306
+ RULES:
307
+ - If the user asks about sales trends or forecasting by title, show sales_trends or arima figures.
308
+ - If the user asks about sentiment, show sentiment figure or sentiment_counts table.
309
+ - If the user asks about R regression, the R notebook focuses on forecasting, show accuracy_table.csv.
310
+ - If the user asks about forecast accuracy or model comparison, show accuracy_table.csv or forecast_compare.png.
311
+ - If the user asks about top sellers, show top_titles_by_units_sold.csv.
312
+ - If the user asks a general data question, pick the most relevant artifact.
313
+ - Keep your answer concise (2-4 sentences), then the JSON block.
314
+ """
315
+
316
+ JSON_BLOCK_RE = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL)
317
+ FALLBACK_JSON_RE = re.compile(r"\{[^{}]*\"show\"[^{}]*\}", re.DOTALL)
318
+
319
+
320
+ def _parse_display_directive(text: str) -> Dict[str, str]:
321
+ m = JSON_BLOCK_RE.search(text)
322
+ if m:
323
+ try:
324
+ return json.loads(m.group(1))
325
+ except json.JSONDecodeError:
326
+ pass
327
+ m = FALLBACK_JSON_RE.search(text)
328
+ if m:
329
+ try:
330
+ return json.loads(m.group(0))
331
+ except json.JSONDecodeError:
332
+ pass
333
+ return {"show": "none"}
334
+
335
+
336
+ def _clean_response(text: str) -> str:
337
+ """Strip the JSON directive block from the displayed response."""
338
+ return JSON_BLOCK_RE.sub("", text).strip()
339
+
340
+
341
+ def ai_chat(user_msg: str, history: list):
342
+ """Chat function for the AI Dashboard tab."""
343
+ if not user_msg or not user_msg.strip():
344
+ return history, "", None, None, ""
345
+
346
+ idx = artifacts_index()
347
+ kpis = load_kpis()
348
+
349
+ if not LLM_ENABLED:
350
+ reply, directive = _keyword_fallback(user_msg, idx, kpis)
351
+ else:
352
+ system = DASHBOARD_SYSTEM.format(
353
+ artifacts_json=json.dumps(idx, indent=2),
354
+ kpis_json=json.dumps(kpis, indent=2) if kpis else "(no KPIs yet, run the pipeline first)",
355
+ )
356
+ msgs = [{"role": "system", "content": system}]
357
+ for entry in (history or [])[-6:]:
358
+ msgs.append(entry)
359
+ msgs.append({"role": "user", "content": user_msg})
360
+
361
+ try:
362
+ r = llm_client.chat_completion(
363
+ model=MODEL_NAME,
364
+ messages=msgs,
365
+ temperature=0.3,
366
+ max_tokens=600,
367
+ stream=False,
368
+ )
369
+ raw = (
370
+ r["choices"][0]["message"]["content"]
371
+ if isinstance(r, dict)
372
+ else r.choices[0].message.content
373
+ )
374
+ directive = _parse_display_directive(raw)
375
+ reply = _clean_response(raw)
376
+ except Exception as e:
377
+ reply = f"LLM error: {e}. Falling back to keyword matching."
378
+ reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
379
+ reply += "\n\n" + reply_fb
380
+
381
+ # Resolve artifact paths
382
+ fig_out = None
383
+ tab_out = None
384
+ html_out = ""
385
+ show = directive.get("show", "none")
386
+ scope = directive.get("scope", "")
387
+ fname = directive.get("filename", "")
388
+
389
+ if show == "figure" and scope and fname:
390
+ base_fig = {"python": PY_FIG_DIR, "r": R_FIG_DIR}.get(scope)
391
+ if base_fig:
392
+ path = base_fig / fname
393
+ if path.exists():
394
+ # R interactive HTML figures — render in HTML panel
395
+ if path.suffix.lower() == ".html":
396
+ html_out = (
397
+ f'<iframe src="/gradio_api/file={path}" '
398
+ f'width="100%" height="520px" '
399
+ f'style="border:none;border-radius:8px;background:#fff;"></iframe>'
400
+ )
401
+ else:
402
+ fig_out = str(path)
403
+ else:
404
+ # Maybe the user meant an HTML version
405
+ html_path = base_fig / (path.stem + ".html")
406
+ if html_path.exists():
407
+ html_out = (
408
+ f'<iframe src="/gradio_api/file={html_path}" '
409
+ f'width="100%" height="520px" '
410
+ f'style="border:none;border-radius:8px;background:#fff;"></iframe>'
411
+ )
412
+ else:
413
+ reply += f"\n\n*(Could not find figure: {scope}/{fname})*"
414
+
415
+ if show == "table" and scope and fname:
416
+ base = {"python": PY_TAB_DIR, "r": R_TAB_DIR}.get(scope)
417
+ if base and (base / fname).exists():
418
+ tab_out = _load_table_safe(base / fname)
419
+ else:
420
+ reply += f"\n\n*(Could not find table: {scope}/{fname})*"
421
+
422
+ new_history = (history or []) + [
423
+ {"role": "user", "content": user_msg},
424
+ {"role": "assistant", "content": reply},
425
+ ]
426
+
427
+ return new_history, "", fig_out, tab_out, html_out
428
+
429
+
430
+ def ai_clear():
431
+ """Clear the AI chat history and all output panels."""
432
+ return [], "", None, None, ""
433
+
434
+
435
+ def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
436
+ """Simple keyword matcher when LLM is unavailable."""
437
+ msg_lower = msg.lower()
438
+
439
+ if not any(idx[s]["figures"] or idx[s]["tables"] for s in ("python", "r")):
440
+ return (
441
+ "No artifacts found yet. Please run the pipeline first (Tab 1), "
442
+ "then come back here to explore the results.",
443
+ {"show": "none"},
444
+ )
445
+
446
+ kpi_text = ""
447
+ if kpis:
448
+ total = kpis.get("total_units_sold", 0)
449
+ kpi_text = (
450
+ f"Quick summary: **{kpis.get('n_titles', '?')}** book titles across "
451
+ f"**{kpis.get('n_months', '?')}** months, with **{total:,.0f}** total units sold."
452
+ )
453
+
454
+ if any(w in msg_lower for w in ["trend", "sales trend", "monthly sale"]):
455
+ return (
456
+ f"Here are the sales trends for sampled titles. {kpi_text}",
457
+ {"show": "figure", "scope": "python", "filename": "sales_trends_sampled_titles.png"},
458
+ )
459
+
460
+ if any(w in msg_lower for w in ["sentiment", "review", "positive", "negative"]):
461
+ return (
462
+ f"Here is the sentiment distribution across sampled book titles. {kpi_text}",
463
+ {"show": "figure", "scope": "python", "filename": "sentiment_distribution_sampled_titles.png"},
464
+ )
465
+
466
+ if any(w in msg_lower for w in ["arima", "forecast", "predict"]):
467
+ if "compar" in msg_lower or "ets" in msg_lower or "accuracy" in msg_lower:
468
+ if "forecast_compare.png" in idx.get("r", {}).get("figures", []):
469
+ return (
470
+ "Here is the ARIMA+Fourier vs ETS forecast comparison from the R analysis.",
471
+ {"show": "figure", "scope": "r", "filename": "forecast_compare.png"},
472
+ )
473
+ return (
474
+ f"Here are the ARIMA forecasts for sampled titles from the Python analysis. {kpi_text}",
475
+ {"show": "figure", "scope": "python", "filename": "arima_forecasts_sampled_titles.png"},
476
+ )
477
+
478
+ if any(w in msg_lower for w in ["regression", "lm", "coefficient", "price effect", "rating effect"]):
479
+ return (
480
+ "The R notebook focuses on forecasting rather than regression. "
481
+ "Here is the forecast accuracy comparison instead.",
482
+ {"show": "table", "scope": "r", "filename": "accuracy_table.csv"},
483
+ )
484
+
485
+ if any(w in msg_lower for w in ["top", "best sell", "popular", "rank"]):
486
+ return (
487
+ f"Here are the top-selling titles by units sold. {kpi_text}",
488
+ {"show": "table", "scope": "python", "filename": "top_titles_by_units_sold.csv"},
489
+ )
490
+
491
+ if any(w in msg_lower for w in ["accuracy", "benchmark", "rmse", "mape"]):
492
+ return (
493
+ "Here is the forecast accuracy comparison (ARIMA+Fourier vs ETS) from the R analysis.",
494
+ {"show": "table", "scope": "r", "filename": "accuracy_table.csv"},
495
+ )
496
+
497
+ if any(w in msg_lower for w in ["r analysis", "r output", "r result"]):
498
+ if "forecast_compare.png" in idx.get("r", {}).get("figures", []):
499
+ return (
500
+ "Here is the main R output: forecast model comparison plot.",
501
+ {"show": "figure", "scope": "r", "filename": "forecast_compare.png"},
502
+ )
503
+
504
+ if any(w in msg_lower for w in ["dashboard", "overview", "summary", "kpi"]):
505
+ return (
506
+ f"Dashboard overview: {kpi_text}\n\nAsk me about sales trends, sentiment, forecasts, "
507
+ "forecast accuracy, or top sellers to see specific visualizations.",
508
+ {"show": "table", "scope": "python", "filename": "df_dashboard.csv"},
509
+ )
510
+
511
+ # Default
512
+ return (
513
+ f"I can show you various analyses. {kpi_text}\n\n"
514
+ "Try asking about: **sales trends**, **sentiment**, **ARIMA forecasts**, "
515
+ "**forecast accuracy**, **top sellers**, or **dashboard overview**.",
516
+ {"show": "none"},
517
+ )
518
+
519
+
520
+ # =========================================================
521
+ # UI
522
+ # =========================================================
523
+
524
+ ensure_dirs()
525
+
526
+ def load_css() -> str:
527
+ css_path = BASE_DIR / "style.css"
528
+ return css_path.read_text(encoding="utf-8") if css_path.exists() else ""
529
+
530
+
531
+ with gr.Blocks(title="RX12 Workshop App") as demo:
532
+
533
+ gr.Markdown(
534
+ "# NYC Airbnb Pricing & Uplift Analysis Platform\n"
535
+ "*An integrated pipeline for data generation, pricing optimization and market uplift analysis*",
536
+ elem_id="escp_title",
537
+ )
538
+
539
+ # ===========================================================
540
+ # TAB 1 -- Pipeline Runner
541
+ # ===========================================================
542
+ with gr.Tab("Pipeline Runner"):
543
+ gr.Markdown(
544
+ )
545
+
546
+ with gr.Row():
547
+ with gr.Column(scale=1):
548
+ btn_nb1 = gr.Button(
549
+ "Step 1: Data Creation",
550
+ variant="secondary",
551
+ )
552
+ gr.Markdown(
553
+ )
554
+
555
+ gr.Markdown(
556
+ )
557
+ with gr.Column(scale=1):
558
+ btn_r = gr.Button(
559
+ "Step 2: R Analysis",
560
+ variant="secondary",
561
+ )
562
+ gr.Markdown(
563
+ )
564
+
565
+ with gr.Row():
566
+ btn_all = gr.Button(
567
+ "Run All the Steps",
568
+ variant="primary",
569
+ )
570
+
571
+ run_log = gr.Textbox(
572
+ label="Execution Log",
573
+ lines=18,
574
+ max_lines=30,
575
+ interactive=False,
576
+ )
577
+
578
+ btn_nb1.click(run_datacreation, outputs=[run_log])
579
+
580
+ btn_r.click(run_r, outputs=[run_log])
581
+ btn_all.click(run_full_pipeline, outputs=[run_log])
582
+
583
+ # ===========================================================
584
+ # TAB 2 -- Results Gallery
585
+ # ===========================================================
586
+ with gr.Tab("Results Gallery"):
587
+ gr.Markdown(
588
+ "### All generated artifacts\n\n"
589
+ "After running the pipeline, click **Refresh** to load figures and tables."
590
+ )
591
+
592
+ refresh_btn = gr.Button("Refresh Gallery", variant="primary")
593
+
594
+ # ── Python figures (static PNG gallery) ──────────────────────────────
595
+ gr.Markdown("#### Python Figures")
596
+ gallery = gr.Gallery(
597
+ label="Python Figures",
598
+ columns=2,
599
+ height=400,
600
+ object_fit="contain",
601
+ )
602
+
603
+ # ── R figures (interactive plotly HTML) ──────────────────────────────
604
+ gr.Markdown("#### Interactive R Figures")
605
+ r_html_dropdown = gr.Dropdown(
606
+ label="Select an R plot",
607
+ choices=[],
608
+ interactive=True,
609
+ )
610
+ r_html_display = gr.HTML(
611
+ value="<p style='color:#888;padding:16px;'>Click <b>Refresh Gallery</b> to load the interactive R plots.</p>"
612
+ )
613
+
614
+ # ── Tables ───────────────────────────────────────────────────────────
615
+ gr.Markdown("#### Tables")
616
+ table_dropdown = gr.Dropdown(
617
+ label="Select a table to view",
618
+ choices=[],
619
+ interactive=True,
620
+ )
621
+ table_display = gr.Dataframe(
622
+ label="Table Preview",
623
+ interactive=False,
624
+ )
625
+
626
+ refresh_btn.click(
627
+ refresh_gallery,
628
+ outputs=[gallery, r_html_dropdown, r_html_display, table_dropdown, table_display],
629
+ )
630
+ r_html_dropdown.change(
631
+ on_r_html_select,
632
+ inputs=[r_html_dropdown],
633
+ outputs=[r_html_display],
634
+ )
635
+ table_dropdown.change(
636
+ on_table_select,
637
+ inputs=[table_dropdown],
638
+ outputs=[table_display],
639
+ )
640
+
641
+ # ===========================================================
642
+ # TAB 3 -- AI Dashboard
643
+ # ===========================================================
644
+ with gr.Tab('"AI" Dashboard'):
645
+ gr.Markdown(
646
+ "### Ask questions, get visualisations\n\n"
647
+ "Describe what you want to see and the AI will pick the right chart or table. "
648
+ + (
649
+ "*LLM is active.*"
650
+ if LLM_ENABLED
651
+ else "*No API key detected — using keyword matching. "
652
+ "Set `HF_API_KEY` in Space secrets for full LLM support.*"
653
+ )
654
+ )
655
+
656
+ with gr.Row(equal_height=False):
657
+ # ── Left: chat panel ─────────────────────────────────────────
658
+ with gr.Column(scale=1):
659
+ chatbot = gr.Chatbot(
660
+ label="Conversation",
661
+ height=400,
662
+ type="messages",
663
+ bubble_full_width=False,
664
+ )
665
+ with gr.Row():
666
+ user_input = gr.Textbox(
667
+ label="",
668
+ placeholder="e.g. Show me sales trends / Which titles sell the most?",
669
+ lines=1,
670
+ scale=5,
671
+ elem_id="ai_chat_input",
672
+ )
673
+ send_btn = gr.Button("Send", variant="primary", scale=1, min_width=80)
674
+ clear_btn = gr.Button("Clear conversation", variant="secondary", size="sm")
675
+ gr.Examples(
676
+ examples=[
677
+ "Show me the sales trends",
678
+ "What does the sentiment look like?",
679
+ "Which titles sell the most?",
680
+ "Show the forecast accuracy comparison",
681
+ "Compare the ARIMA and ETS forecasts",
682
+ "Give me a dashboard overview",
683
+ ],
684
+ inputs=user_input,
685
+ label="Try asking:",
686
+ )
687
+
688
+ # ── Right: output panels ─────────────────────────────────────
689
+ with gr.Column(scale=1):
690
+ ai_figure = gr.Image(
691
+ label="Chart",
692
+ height=320,
693
+ visible=True,
694
+ )
695
+ ai_html = gr.HTML(
696
+ value="",
697
+ label="Interactive Chart",
698
+ )
699
+ ai_table = gr.Dataframe(
700
+ label="Data Table",
701
+ interactive=False,
702
+ )
703
+
704
+ # ── Event wiring ─────────────────────────────────────────────────
705
+ _ai_outputs = [chatbot, user_input, ai_figure, ai_table, ai_html]
706
+
707
+ user_input.submit(
708
+ ai_chat,
709
+ inputs=[user_input, chatbot],
710
+ outputs=_ai_outputs,
711
+ )
712
+ send_btn.click(
713
+ ai_chat,
714
+ inputs=[user_input, chatbot],
715
+ outputs=_ai_outputs,
716
+ )
717
+ clear_btn.click(
718
+ ai_clear,
719
+ outputs=_ai_outputs,
720
+ )
721
+
722
+
723
+ demo.launch(
724
+ server_name="0.0.0.0",
725
+ server_port=7860,
726
+ css=load_css(),
727
+ allowed_paths=[str(BASE_DIR)]
728
+ )
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio
2
+ papermill
3
+ pandas
4
+ numpy
5
+ matplotlib
6
+ nbformat
7
+ huggingface_hub
style.css ADDED
@@ -0,0 +1,444 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================
2
+ GLOBAL BACKGROUND (NYC)
3
+ ========================= */
4
+
5
+ gradio-app,
6
+ .gradio-app,
7
+ .main,
8
+ #app,
9
+ [data-testid="app"],
10
+ html,
11
+ body {
12
+ background: url("https://huggingface.co/spaces/teomello/FinalProjectAPP/resolve/main/Z2kAxe.jpg") no-repeat center center fixed !important;
13
+ background-size: cover !important;
14
+ min-height: 100vh !important;
15
+ margin: 0 !important;
16
+ padding: 0 !important;
17
+ }
18
+
19
+ /* Overlay for readability */
20
+ gradio-app::before,
21
+ .gradio-app::before,
22
+ .main::before,
23
+ #app::before,
24
+ [data-testid="app"]::before {
25
+ content: "" !important;
26
+ position: fixed !important;
27
+ inset: 0 !important;
28
+ background: rgba(0, 0, 0, 0.30) !important;
29
+ pointer-events: none !important;
30
+ z-index: 0 !important;
31
+ }
32
+
33
+ /* Remove any previous bottom banner */
34
+ body::after {
35
+ content: none !important;
36
+ }
37
+
38
+ /* =========================
39
+ MAIN CONTAINER
40
+ ========================= */
41
+
42
+ .gradio-container {
43
+ position: relative !important;
44
+ z-index: 1 !important;
45
+ max-width: 1400px !important;
46
+ width: 94vw !important;
47
+ margin: 0 auto !important;
48
+ padding-top: 180px !important;
49
+ padding-bottom: 120px !important;
50
+ background: transparent !important;
51
+ }
52
+
53
+ /* =========================
54
+ TITLE / HERO
55
+ ========================= */
56
+
57
+ /* Optional: add a dark panel behind title/subtitle for readability */
58
+ #escp_title {
59
+ background: rgba(0, 0, 0, 0.45) !important;
60
+ padding: 20px !important;
61
+ border-radius: 12px !important;
62
+ max-width: 1100px !important;
63
+ margin: 0 auto 18px auto !important;
64
+ }
65
+
66
+ #escp_title h1 {
67
+ color: rgb(242,198,55) !important;
68
+ font-size: 3rem !important;
69
+ font-weight: 800 !important;
70
+ text-align: center !important;
71
+ margin: 0 0 12px 0 !important;
72
+ }
73
+
74
+ /* Subtitle more visible */
75
+ #escp_title p,
76
+ #escp_title em {
77
+ color: rgba(255,255,255,0.98) !important;
78
+ font-size: 1.1rem !important;
79
+ font-weight: 500 !important;
80
+ text-align: center !important;
81
+ max-width: 900px !important;
82
+ margin: 0 auto 6px auto !important;
83
+ line-height: 1.5 !important;
84
+ }
85
+
86
+ /* =========================
87
+ TABS
88
+ ========================= */
89
+
90
+ .tabs > .tab-nav,
91
+ .tab-nav,
92
+ div[role="tablist"],
93
+ .svelte-tabs > .tab-nav {
94
+ background: rgba(0, 0, 0, 0.45) !important;
95
+ border-radius: 10px 10px 0 0 !important;
96
+ padding: 4px !important;
97
+ }
98
+
99
+ /* ALL tab buttons */
100
+ .tabs > .tab-nav button,
101
+ .tab-nav button,
102
+ div[role="tablist"] button,
103
+ button[role="tab"],
104
+ .svelte-tabs button,
105
+ .tab-nav > button,
106
+ .tabs button {
107
+ color: #ffffff !important;
108
+ font-weight: 600 !important;
109
+ border: none !important;
110
+ background: transparent !important;
111
+ padding: 10px 20px !important;
112
+ border-radius: 8px 8px 0 0 !important;
113
+ opacity: 1 !important;
114
+ }
115
+
116
+ /* Selected tab */
117
+ .tabs > .tab-nav button.selected,
118
+ .tab-nav button.selected,
119
+ button[role="tab"][aria-selected="true"],
120
+ button[role="tab"].selected,
121
+ div[role="tablist"] button[aria-selected="true"],
122
+ .svelte-tabs button.selected {
123
+ color: rgb(242, 198, 55) !important;
124
+ background: rgba(255, 255, 255, 0.15) !important;
125
+ }
126
+
127
+ /* Unselected tabs visibility */
128
+ .tabs > .tab-nav button:not(.selected),
129
+ .tab-nav button:not(.selected),
130
+ button[role="tab"][aria-selected="false"],
131
+ button[role="tab"]:not(.selected),
132
+ div[role="tablist"] button:not([aria-selected="true"]) {
133
+ color: #ffffff !important;
134
+ opacity: 1 !important;
135
+ }
136
+
137
+ /* =========================
138
+ PANELS / CARDS
139
+ ========================= */
140
+
141
+ .gradio-container .gr-block,
142
+ .gradio-container .gr-box,
143
+ .gradio-container .gr-panel,
144
+ .gradio-container .gr-group {
145
+ background: rgba(255, 255, 255, 0.96) !important;
146
+ border-radius: 12px !important;
147
+ }
148
+
149
+ /* Tab content */
150
+ .tabitem {
151
+ background: rgba(255, 255, 255, 0.95) !important;
152
+ border-radius: 0 0 12px 12px !important;
153
+ padding: 16px !important;
154
+ }
155
+
156
+ /* =========================
157
+ INPUTS
158
+ ========================= */
159
+
160
+ .gradio-container input,
161
+ .gradio-container textarea,
162
+ .gradio-container select {
163
+ background: #ffffff !important;
164
+ border: 1px solid #d1d5db !important;
165
+ border-radius: 8px !important;
166
+ }
167
+
168
+ /* =========================
169
+ EXECUTION LOG (terminal style)
170
+ Force black background + white text
171
+ ========================= */
172
+
173
+ /* Execution log: terminal dark style — scope to run_log only.
174
+ The Gradio Textbox with lines>=10 gets a [data-testid="textbox"] container.
175
+ We target it by excluding the AI chat input. */
176
+ .gradio-container [data-testid="textbox"]:not(#ai_chat_input) textarea,
177
+ .gradio-container .gr-textbox:not(#ai_chat_input) textarea {
178
+ background-color: #111111 !important;
179
+ color: #eaeaea !important;
180
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
181
+ font-size: 0.85rem !important;
182
+ border-radius: 10px !important;
183
+ padding: 12px !important;
184
+ border: 1px solid rgba(255,255,255,0.18) !important;
185
+ }
186
+
187
+ .gradio-container [data-testid="textbox"]:not(#ai_chat_input) textarea::placeholder {
188
+ color: rgba(255,255,255,0.55) !important;
189
+ }
190
+
191
+ /* AI chat input: clean white box */
192
+ #ai_chat_input textarea {
193
+ background-color: #ffffff !important;
194
+ color: #111111 !important;
195
+ font-family: inherit !important;
196
+ font-size: 0.95rem !important;
197
+ border-radius: 8px !important;
198
+ padding: 10px 12px !important;
199
+ border: 1px solid #d1d5db !important;
200
+ }
201
+
202
+ #ai_chat_input textarea::placeholder {
203
+ color: #9ca3af !important;
204
+ }
205
+
206
+ /* =========================
207
+ BUTTONS
208
+ ========================= */
209
+
210
+ /* Only style "real" action buttons, not icon toolbar buttons or thumbnails */
211
+ .gradio-container button:not([role="tab"]):not(.icon-button):not(.thumbnail-item):not([class*="thumbnail"]) {
212
+ font-weight: 600 !important;
213
+ padding: 10px 16px !important;
214
+ border-radius: 10px !important;
215
+ }
216
+
217
+ button.primary {
218
+ background-color: rgb(40, 9, 109) !important;
219
+ color: #ffffff !important;
220
+ border: none !important;
221
+ }
222
+
223
+ button.primary:hover {
224
+ background-color: rgb(60, 20, 140) !important;
225
+ }
226
+
227
+ button.secondary {
228
+ background-color: #ffffff !important;
229
+ color: rgb(40, 9, 109) !important;
230
+ border: 2px solid rgb(40, 9, 109) !important;
231
+ }
232
+
233
+ button.secondary:hover {
234
+ background-color: rgb(240, 238, 250) !important;
235
+ }
236
+
237
+ /* =========================
238
+ DATAFRAMES / TABLES
239
+ ========================= */
240
+
241
+ [data-testid="dataframe"] {
242
+ background-color: #ffffff !important;
243
+ border-radius: 10px !important;
244
+ }
245
+
246
+ table {
247
+ font-size: 0.85rem !important;
248
+ }
249
+
250
+ /* =========================
251
+ CHATBOT (AI Dashboard)
252
+ ========================= */
253
+
254
+ .gr-chatbot {
255
+ min-height: 380px !important;
256
+ background-color: #ffffff !important;
257
+ border-radius: 12px !important;
258
+ }
259
+
260
+ .gr-chatbot .message.user {
261
+ background-color: rgb(232, 225, 250) !important;
262
+ border-radius: 12px !important;
263
+ }
264
+
265
+ .gr-chatbot .message.bot {
266
+ background-color: #f3f4f6 !important;
267
+ border-radius: 12px !important;
268
+ }
269
+
270
+ /* =========================
271
+ GALLERY
272
+ ========================= */
273
+
274
+ .gallery {
275
+ background: #ffffff !important;
276
+ border-radius: 10px !important;
277
+ }
278
+
279
+ /* =========================
280
+ MARKDOWN HEADINGS
281
+ ========================= */
282
+
283
+ .tabitem h3 {
284
+ color: rgb(40, 9, 109) !important;
285
+ font-weight: 700 !important;
286
+ }
287
+
288
+ .tabitem h4 {
289
+ color: #374151 !important;
290
+ }
291
+
292
+ /* =========================
293
+ EXAMPLES ROW (AI Dashboard)
294
+ ========================= */
295
+
296
+ .examples-row button {
297
+ background: rgb(240, 238, 250) !important;
298
+ color: rgb(40, 9, 109) !important;
299
+ border: 1px solid rgb(40, 9, 109) !important;
300
+ border-radius: 8px !important;
301
+ font-size: 0.85rem !important;
302
+ }
303
+
304
+ .examples-row button:hover {
305
+ background: rgb(232, 225, 250) !important;
306
+ }
307
+
308
+ /* =========================
309
+ HEADER / FOOTER CLEANUP
310
+ ========================= */
311
+
312
+ header,
313
+ footer,
314
+ header *,
315
+ footer * {
316
+ background: transparent !important;
317
+ box-shadow: none !important;
318
+ }
319
+
320
+ /* Hover state */
321
+ .gradio-container .gr-modal button:hover,
322
+ .gradio-container .gallery button:hover {
323
+ background: rgb(240, 238, 250) !important;
324
+ }
325
+
326
+ /* =========================
327
+ FIX: Image viewer icon-button toolbar (Download/Fullscreen/Share/Close)
328
+ These use SVG with fill/stroke="currentColor", so we must set a visible color.
329
+ Targets .icon-button class directly — works for gr.Image AND gallery lightbox.
330
+ ========================= */
331
+
332
+ .gradio-container button.icon-button,
333
+ .gradio-container .icon-button,
334
+ button.icon-button,
335
+ .icon-button {
336
+ color: #374151 !important; /* dark gray — visible on white bg */
337
+ background: rgba(255,255,255,0.90) !important;
338
+ border-radius: 8px !important;
339
+ padding: 4px !important; /* keep compact — these are icon-only buttons */
340
+ }
341
+
342
+ .gradio-container button.icon-button:hover,
343
+ .gradio-container .icon-button:hover,
344
+ button.icon-button:hover,
345
+ .icon-button:hover {
346
+ background: rgba(232, 225, 250, 0.95) !important;
347
+ color: rgb(40, 9, 109) !important;
348
+ }
349
+
350
+ /* Ensure SVGs inside icon-buttons inherit the visible color */
351
+ .gradio-container button.icon-button svg,
352
+ .gradio-container .icon-button svg,
353
+ button.icon-button svg,
354
+ .icon-button svg {
355
+ color: inherit !important;
356
+ stroke: currentColor;
357
+ }
358
+
359
+ /* Also cover any toolbar buttons wrapped in [role=dialog] (older Gradio) */
360
+
361
+ .gradio-container [role="dialog"] button[aria-label]:not(.icon-button),
362
+ .gradio-container [role="dialog"] button[title]:not(.icon-button) {
363
+ background: rgba(255,255,255,0.95) !important;
364
+ border: 1px solid rgba(40, 9, 109, 0.40) !important;
365
+ border-radius: 10px !important;
366
+ color: rgb(40, 9, 109) !important;
367
+ }
368
+
369
+ .gradio-container [role="dialog"] button[aria-label]:not(.icon-button) svg,
370
+ .gradio-container [role="dialog"] button[title]:not(.icon-button) svg {
371
+ color: rgb(40, 9, 109) !important;
372
+ }
373
+
374
+ /* =========================
375
+ FIX: Gallery bottom strip — thumbnail-item buttons
376
+ ========================= */
377
+
378
+ .gradio-container button.thumbnail-item,
379
+ .gradio-container [class*="thumbnail-item"] {
380
+ background: transparent !important;
381
+ border: 2px solid transparent !important;
382
+ border-radius: 6px !important;
383
+ padding: 2px !important; /* remove the 10px 16px from the general rule */
384
+ overflow: hidden !important;
385
+ min-width: 52px !important;
386
+ min-height: 52px !important;
387
+ }
388
+
389
+ .gradio-container button.thumbnail-item.selected,
390
+ .gradio-container [class*="thumbnail-item"].selected {
391
+ border-color: rgb(40, 9, 109) !important;
392
+ }
393
+
394
+ .gradio-container button.thumbnail-item img,
395
+ .gradio-container [class*="thumbnail-item"] img {
396
+ display: block !important;
397
+ width: 100% !important;
398
+ height: 100% !important;
399
+ object-fit: cover !important;
400
+ opacity: 1 !important;
401
+ visibility: visible !important;
402
+ filter: none !important;
403
+ }
404
+
405
+ /* =========================
406
+ FIX: Gallery grid thumbnails
407
+ ========================= */
408
+
409
+ .gradio-container .gallery,
410
+ .gradio-container [data-testid="gallery"] {
411
+ background: #ffffff !important;
412
+ }
413
+
414
+ .gradio-container .gallery button:not(.icon-button):not(.thumbnail-item),
415
+ .gradio-container [data-testid="gallery"] button:not(.icon-button):not(.thumbnail-item) {
416
+ background: transparent !important;
417
+ overflow: hidden !important;
418
+ min-width: 56px !important;
419
+ min-height: 56px !important;
420
+ padding: 0 !important;
421
+ }
422
+
423
+ .gradio-container .gallery img,
424
+ .gradio-container [data-testid="gallery"] img {
425
+ display: block !important;
426
+ opacity: 1 !important;
427
+ visibility: visible !important;
428
+ filter: none !important;
429
+ mix-blend-mode: normal !important;
430
+ width: 100% !important;
431
+ height: 100% !important;
432
+ object-fit: cover !important;
433
+ }
434
+
435
+ .gradio-container .gallery canvas,
436
+ .gradio-container [data-testid="gallery"] canvas {
437
+ display: block !important;
438
+ opacity: 1 !important;
439
+ visibility: visible !important;
440
+ filter: none !important;
441
+ width: 100% !important;
442
+ height: 100% !important;
443
+ }
444
+