JimKau commited on
Commit
4ed5932
·
verified ·
1 Parent(s): 2f7a371

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +787 -0
  2. requirements.txt +14 -0
  3. style.css +324 -0
app.py ADDED
@@ -0,0 +1,787 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.graph_objects as go
13
+
14
+ # Optional LLM (HuggingFace Inference API)
15
+ try:
16
+ from huggingface_hub import InferenceClient
17
+ except Exception:
18
+ InferenceClient = None
19
+
20
+ # =========================================================
21
+ # CONFIG
22
+ # =========================================================
23
+
24
+ BASE_DIR = Path(__file__).resolve().parent
25
+
26
+ NB1 = os.environ.get("NB1", "datacreation.ipynb").strip()
27
+ NB2 = os.environ.get("NB2", "pythonanalysis.ipynb").strip()
28
+
29
+ RUNS_DIR = BASE_DIR / "runs"
30
+ ART_DIR = BASE_DIR / "artifacts"
31
+ PY_FIG_DIR = ART_DIR / "figures" # notebooks write to artifacts/figures/
32
+ PY_TAB_DIR = ART_DIR / "tables" # notebooks write to artifacts/tables/
33
+
34
+ PAPERMILL_TIMEOUT = int(os.environ.get("PAPERMILL_TIMEOUT", "1800"))
35
+ MAX_PREVIEW_ROWS = int(os.environ.get("MAX_FILE_PREVIEW_ROWS", "50"))
36
+ MAX_LOG_CHARS = int(os.environ.get("MAX_LOG_CHARS", "8000"))
37
+
38
+ HF_API_KEY = os.environ.get("HF_API_KEY", "").strip()
39
+ MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-ai/DeepSeek-R1").strip()
40
+ HF_PROVIDER = os.environ.get("HF_PROVIDER", "novita").strip()
41
+ N8N_WEBHOOK_URL = os.environ.get("N8N_WEBHOOK_URL", "").strip()
42
+
43
+ LLM_ENABLED = bool(HF_API_KEY) and InferenceClient is not None
44
+ llm_client = (
45
+ InferenceClient(provider=HF_PROVIDER, api_key=HF_API_KEY)
46
+ if LLM_ENABLED
47
+ else None
48
+ )
49
+
50
+ # =========================================================
51
+ # HELPERS
52
+ # =========================================================
53
+
54
+ def ensure_dirs():
55
+ for p in [RUNS_DIR, ART_DIR, PY_FIG_DIR, PY_TAB_DIR]:
56
+ p.mkdir(parents=True, exist_ok=True)
57
+
58
+ def stamp():
59
+ return time.strftime("%Y%m%d-%H%M%S")
60
+
61
+ def tail(text: str, n: int = MAX_LOG_CHARS) -> str:
62
+ return (text or "")[-n:]
63
+
64
+ def _ls(dir_path: Path, exts: Tuple[str, ...]) -> List[str]:
65
+ if not dir_path.is_dir():
66
+ return []
67
+ return sorted(p.name for p in dir_path.iterdir() if p.is_file() and p.suffix.lower() in exts)
68
+
69
+ def _read_csv(path: Path) -> pd.DataFrame:
70
+ return pd.read_csv(path, nrows=MAX_PREVIEW_ROWS)
71
+
72
+ def _read_json(path: Path):
73
+ with path.open(encoding="utf-8") as f:
74
+ return json.load(f)
75
+
76
+ def artifacts_index() -> Dict[str, Any]:
77
+ return {
78
+ "python": {
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 (ART_DIR).glob("*.csv")]
110
+ return f"OK {log}\n\nCSVs in /artifacts/:\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 (IMDb scrape + 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 (VADER, ARIMA, Random Forest)")
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
+ figures = _load_all_figures()
170
+ idx = artifacts_index()
171
+ table_choices = list(idx["python"]["tables"])
172
+ default_df = pd.DataFrame()
173
+ if table_choices:
174
+ default_df = _load_table_safe(PY_TAB_DIR / table_choices[0])
175
+ return (
176
+ figures if figures else [],
177
+ gr.update(choices=table_choices, value=table_choices[0] if table_choices else None),
178
+ default_df,
179
+ )
180
+
181
+
182
+ def on_table_select(choice: str):
183
+ if not choice:
184
+ return pd.DataFrame([{"hint": "Select a table above."}])
185
+ path = PY_TAB_DIR / choice
186
+ if not path.exists():
187
+ return pd.DataFrame([{"error": f"File not found: {choice}"}])
188
+ return _load_table_safe(path)
189
+
190
+
191
+ # =========================================================
192
+ # KPI LOADER
193
+ # =========================================================
194
+
195
+ def load_kpis() -> Dict[str, Any]:
196
+ for candidate in [PY_TAB_DIR / "kpis.json", PY_FIG_DIR / "kpis.json"]:
197
+ if candidate.exists():
198
+ try:
199
+ return _read_json(candidate)
200
+ except Exception:
201
+ pass
202
+ return {}
203
+
204
+
205
+ # =========================================================
206
+ # AI DASHBOARD — LLM picks what to display
207
+ # =========================================================
208
+
209
+ DASHBOARD_SYSTEM = """You are an AI dashboard assistant for a streaming platform analytics app.
210
+ The platform uses data from IMDb combined with synthetic streaming KPIs to predict whether
211
+ each TV show should be Renewed, Cancelled, or given more Investment.
212
+
213
+ AVAILABLE ARTIFACTS (only reference ones that exist):
214
+ {artifacts_json}
215
+
216
+ KPI SUMMARY: {kpis_json}
217
+
218
+ YOUR JOB:
219
+ 1. Answer the user's question conversationally using the KPIs and your knowledge of the artifacts.
220
+ 2. At the END of your response, output a JSON block (fenced with ```json ... ```) that tells
221
+ the dashboard which artifact to display. The JSON must have this shape:
222
+ {{"show": "figure"|"table"|"none", "scope": "python", "filename": "..."}}
223
+
224
+ - Use "show": "figure" to display a chart image.
225
+ - Use "show": "table" to display a CSV/JSON table.
226
+ - Use "show": "none" if no artifact is relevant.
227
+
228
+ RULES:
229
+ - If the user asks about sentiment or VADER scores, show vader_sentiment_analysis.png or figure chart "sentiment".
230
+ - If the user asks about viewership trends, show viewership_trends_sampled.png or chart "platform_streams".
231
+ - If the user asks about ARIMA or forecasting, show arima_forecasts.png or chart "platform_streams".
232
+ - If the user asks about the Random Forest or model accuracy, show random_forest_results.png.
233
+ - If the user asks about renewal decisions or genre analysis, show decision_analysis.png or table "renewal_recommendations".
234
+ - If the user asks about platform overview or total streams, show platform_overview.png or chart "platform_streams".
235
+ - If the user asks about feature importances, show table feature_importances.csv.
236
+ - If the user asks about recommendations or show list, show table renewal_recommendations.csv.
237
+ - Keep your answer concise (2-4 sentences), then the JSON block.
238
+ """
239
+
240
+ JSON_BLOCK_RE = re.compile(r"```json\s*(\{.*?\})\s*```", re.DOTALL)
241
+ FALLBACK_JSON_RE = re.compile(r"\{[^{}]*\"show\"[^{}]*\}", re.DOTALL)
242
+
243
+
244
+ def _parse_display_directive(text: str) -> Dict[str, str]:
245
+ m = JSON_BLOCK_RE.search(text)
246
+ if m:
247
+ try:
248
+ return json.loads(m.group(1))
249
+ except json.JSONDecodeError:
250
+ pass
251
+ m = FALLBACK_JSON_RE.search(text)
252
+ if m:
253
+ try:
254
+ return json.loads(m.group(0))
255
+ except json.JSONDecodeError:
256
+ pass
257
+ return {"show": "none"}
258
+
259
+
260
+ def _clean_response(text: str) -> str:
261
+ return JSON_BLOCK_RE.sub("", text).strip()
262
+
263
+
264
+ def _n8n_call(msg: str) -> Tuple[str, Dict]:
265
+ import requests as req
266
+ try:
267
+ resp = req.post(N8N_WEBHOOK_URL, json={"question": msg}, timeout=20)
268
+ data = resp.json()
269
+ answer = data.get("answer", "No response from n8n workflow.")
270
+ chart = data.get("chart", "none")
271
+ if chart and chart != "none":
272
+ return answer, {"show": "figure", "chart": chart}
273
+ return answer, {"show": "none"}
274
+ except Exception as e:
275
+ return f"n8n error: {e}. Falling back to keyword matching.", None
276
+
277
+
278
+ def ai_chat(user_msg: str, history: list):
279
+ if not user_msg or not user_msg.strip():
280
+ return history, "", None, None
281
+
282
+ idx = artifacts_index()
283
+ kpis = load_kpis()
284
+
285
+ # Priority: n8n webhook > HF LLM > keyword fallback
286
+ if N8N_WEBHOOK_URL:
287
+ reply, directive = _n8n_call(user_msg)
288
+ if directive is None:
289
+ reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
290
+ reply += "\n\n" + reply_fb
291
+ elif not LLM_ENABLED:
292
+ reply, directive = _keyword_fallback(user_msg, idx, kpis)
293
+ else:
294
+ system = DASHBOARD_SYSTEM.format(
295
+ artifacts_json=json.dumps(idx, indent=2),
296
+ kpis_json=json.dumps(kpis, indent=2) if kpis else "(no KPIs yet — run the pipeline first)",
297
+ )
298
+ msgs = [{"role": "system", "content": system}]
299
+ for entry in (history or [])[-6:]:
300
+ msgs.append(entry)
301
+ msgs.append({"role": "user", "content": user_msg})
302
+
303
+ try:
304
+ r = llm_client.chat_completion(
305
+ model=MODEL_NAME,
306
+ messages=msgs,
307
+ temperature=0.3,
308
+ max_tokens=600,
309
+ stream=False,
310
+ )
311
+ raw = (
312
+ r["choices"][0]["message"]["content"]
313
+ if isinstance(r, dict)
314
+ else r.choices[0].message.content
315
+ )
316
+ directive = _parse_display_directive(raw)
317
+ reply = _clean_response(raw)
318
+ except Exception as e:
319
+ reply = f"LLM error: {e}. Falling back to keyword matching."
320
+ reply_fb, directive = _keyword_fallback(user_msg, idx, kpis)
321
+ reply += "\n\n" + reply_fb
322
+
323
+ # Resolve artifacts — build interactive Plotly charts when possible
324
+ chart_out = None
325
+ tab_out = None
326
+ show = directive.get("show", "none")
327
+ fname = directive.get("filename", "")
328
+ chart_name = directive.get("chart", "")
329
+
330
+ chart_builders = {
331
+ "platform_streams": build_platform_streams_chart,
332
+ "sentiment": build_sentiment_chart,
333
+ "renewal": build_renewal_chart,
334
+ }
335
+
336
+ if chart_name and chart_name in chart_builders:
337
+ chart_out = chart_builders[chart_name]()
338
+ elif show == "figure" and fname:
339
+ if "vader" in fname or "sentiment" in fname:
340
+ chart_out = build_sentiment_chart()
341
+ elif "platform_overview" in fname or "viewership" in fname or "arima" in fname:
342
+ chart_out = build_platform_streams_chart()
343
+ elif "decision" in fname or "random_forest" in fname:
344
+ chart_out = build_renewal_chart()
345
+ else:
346
+ chart_out = _empty_chart(f"No interactive chart for {fname}")
347
+
348
+ if show == "table" and fname:
349
+ fp = PY_TAB_DIR / fname
350
+ if fp.exists():
351
+ tab_out = _load_table_safe(fp)
352
+ else:
353
+ reply += f"\n\n*(Could not find table: {fname})*"
354
+
355
+ new_history = (history or []) + [
356
+ {"role": "user", "content": user_msg},
357
+ {"role": "assistant", "content": reply},
358
+ ]
359
+
360
+ return new_history, "", chart_out, tab_out
361
+
362
+
363
+ def _keyword_fallback(msg: str, idx: Dict, kpis: Dict) -> Tuple[str, Dict]:
364
+ msg_lower = msg.lower()
365
+
366
+ if not idx["python"]["figures"] and not idx["python"]["tables"]:
367
+ return (
368
+ "No artifacts found yet. Please run the pipeline first (Tab 1), "
369
+ "then come back here to explore the results.",
370
+ {"show": "none"},
371
+ )
372
+
373
+ kpi_text = ""
374
+ if kpis:
375
+ total = kpis.get("total_shows", "?")
376
+ renew = kpis.get("shows_to_renew", "?")
377
+ cancel = kpis.get("shows_to_cancel", "?")
378
+ invest = kpis.get("shows_invest_more", "?")
379
+ kpi_text = (
380
+ f"Quick summary: **{total}** shows analysed — "
381
+ f"**{renew}** to renew, **{cancel}** to cancel, **{invest}** need more investment."
382
+ )
383
+
384
+ if any(w in msg_lower for w in ["sentiment", "vader", "review", "positive", "negative"]):
385
+ return (
386
+ f"Here is the VADER sentiment analysis by renewal decision. {kpi_text}",
387
+ {"show": "figure", "chart": "sentiment"},
388
+ )
389
+
390
+ if any(w in msg_lower for w in ["arima", "forecast", "predict", "viewership", "trend", "stream"]):
391
+ return (
392
+ f"Here are the platform viewership trends and ARIMA forecasts. {kpi_text}",
393
+ {"show": "figure", "chart": "platform_streams"},
394
+ )
395
+
396
+ if any(w in msg_lower for w in ["renew", "cancel", "decision", "genre", "random forest", "model"]):
397
+ return (
398
+ f"Here is the renewal decision breakdown. {kpi_text}",
399
+ {"show": "figure", "chart": "renewal"},
400
+ )
401
+
402
+ if any(w in msg_lower for w in ["feature", "importance", "variable"]):
403
+ return (
404
+ f"Here are the most important features from the Random Forest model. {kpi_text}",
405
+ {"show": "table", "scope": "python", "filename": "feature_importances.csv"},
406
+ )
407
+
408
+ if any(w in msg_lower for w in ["recommend", "list", "show", "top", "best", "worst"]):
409
+ return (
410
+ f"Here is the full renewal recommendations table. {kpi_text}",
411
+ {"show": "table", "scope": "python", "filename": "renewal_recommendations.csv"},
412
+ )
413
+
414
+ if any(w in msg_lower for w in ["dashboard", "overview", "summary", "kpi"]):
415
+ return (
416
+ f"Dashboard overview: {kpi_text}\n\nAsk me about sentiment, viewership trends, "
417
+ "ARIMA forecasts, renewal decisions, or feature importances.",
418
+ {"show": "figure", "chart": "renewal"},
419
+ )
420
+
421
+ # Default
422
+ return (
423
+ f"I can help you explore the streaming analytics. {kpi_text}\n\n"
424
+ "Try asking about: **sentiment analysis**, **viewership trends**, **ARIMA forecasts**, "
425
+ "**renewal decisions**, **feature importances**, or **show recommendations**.",
426
+ {"show": "none"},
427
+ )
428
+
429
+
430
+ # =========================================================
431
+ # KPI CARDS
432
+ # =========================================================
433
+
434
+ def render_kpi_cards() -> str:
435
+ kpis = load_kpis()
436
+ if not kpis:
437
+ return (
438
+ '<div style="background:rgba(255,255,255,.65);backdrop-filter:blur(16px);'
439
+ 'border-radius:20px;padding:28px;text-align:center;'
440
+ 'border:1.5px solid rgba(255,255,255,.7);'
441
+ 'box-shadow:0 8px 32px rgba(124,92,191,.08);">'
442
+ '<div style="font-size:36px;margin-bottom:10px;">📊</div>'
443
+ '<div style="color:#a48de8;font-size:14px;'
444
+ 'font-weight:800;margin-bottom:6px;">No data yet</div>'
445
+ '<div style="color:#9d8fc4;font-size:12px;">'
446
+ 'Run the pipeline to populate these cards.</div>'
447
+ '</div>'
448
+ )
449
+
450
+ def card(icon, label, value, colour):
451
+ return f"""
452
+ <div style="background:rgba(255,255,255,.72);backdrop-filter:blur(16px);
453
+ border-radius:20px;padding:18px 14px 16px;text-align:center;
454
+ border:1.5px solid rgba(255,255,255,.8);
455
+ box-shadow:0 4px 16px rgba(124,92,191,.08);
456
+ border-top:3px solid {colour};">
457
+ <div style="font-size:26px;margin-bottom:7px;line-height:1;">{icon}</div>
458
+ <div style="color:#9d8fc4;font-size:9.5px;text-transform:uppercase;
459
+ letter-spacing:1.8px;margin-bottom:7px;font-weight:800;">{label}</div>
460
+ <div style="color:#2d1f4e;font-size:16px;font-weight:800;">{value}</div>
461
+ </div>"""
462
+
463
+ # Ordered KPI config aligned with kpis.json from pythonanalysis notebook
464
+ kpi_config = [
465
+ ("total_shows", "🎬", "Shows Analysed", "#a48de8"),
466
+ ("shows_to_renew", "✅", "Renew", "#2ec4a0"),
467
+ ("shows_invest_more", "📈", "Invest More", "#e8a230"),
468
+ ("shows_to_cancel", "❌", "Cancel", "#e8537a"),
469
+ ("avg_imdb_rating", "⭐", "Avg IMDb Rating", "#7aa6f8"),
470
+ ("avg_platform_roi", "💰", "Avg Platform ROI %", "#3dcba8"),
471
+ ("avg_completion_rate", "▶️", "Avg Completion Rate","#5e8fef"),
472
+ ("sentiment_alignment", "💬", "Sentiment Alignment","#c45ea8"),
473
+ ]
474
+
475
+ html = (
476
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));'
477
+ 'gap:12px;margin-bottom:24px;">'
478
+ )
479
+ known = {k for k, *_ in kpi_config}
480
+ for key, icon, label, colour in kpi_config:
481
+ val = kpis.get(key)
482
+ if val is None:
483
+ continue
484
+ if isinstance(val, float):
485
+ val = f"{val:,.3f}" if val < 10 else f"{val:,.1f}"
486
+ elif isinstance(val, int) and val > 999:
487
+ val = f"{val:,}"
488
+ html += card(icon, label, str(val), colour)
489
+ # Any extra keys not in config
490
+ for key, val in kpis.items():
491
+ if key not in known:
492
+ label = key.replace("_", " ").title()
493
+ if isinstance(val, (int, float)) and val > 100:
494
+ val = f"{val:,.0f}"
495
+ html += card("📊", label, str(val), "#8fa8f8")
496
+ html += "</div>"
497
+ return html
498
+
499
+
500
+ # =========================================================
501
+ # INTERACTIVE PLOTLY CHARTS
502
+ # =========================================================
503
+
504
+ CHART_PALETTE = ["#7c5cbf", "#2ec4a0", "#e8537a", "#e8a230", "#5e8fef",
505
+ "#c45ea8", "#3dbacc", "#a0522d", "#6aaa3a", "#d46060"]
506
+
507
+ def _styled_layout(**kwargs) -> dict:
508
+ defaults = dict(
509
+ template="plotly_white",
510
+ paper_bgcolor="rgba(255,255,255,0.95)",
511
+ plot_bgcolor="rgba(255,255,255,0.98)",
512
+ font=dict(family="system-ui, sans-serif", color="#2d1f4e", size=12),
513
+ margin=dict(l=60, r=20, t=70, b=70),
514
+ legend=dict(
515
+ orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1,
516
+ bgcolor="rgba(255,255,255,0.92)",
517
+ bordercolor="rgba(124,92,191,0.35)", borderwidth=1,
518
+ ),
519
+ title=dict(font=dict(size=15, color="#4b2d8a")),
520
+ )
521
+ defaults.update(kwargs)
522
+ return defaults
523
+
524
+
525
+ def _empty_chart(title: str) -> go.Figure:
526
+ fig = go.Figure()
527
+ fig.update_layout(
528
+ title=title, height=420, template="plotly_white",
529
+ paper_bgcolor="rgba(255,255,255,0.95)",
530
+ annotations=[dict(text="Run the pipeline to generate data",
531
+ x=0.5, y=0.5, xref="paper", yref="paper", showarrow=False,
532
+ font=dict(size=14, color="rgba(124,92,191,0.5)"))],
533
+ )
534
+ return fig
535
+
536
+
537
+ def build_platform_streams_chart() -> go.Figure:
538
+ """Monthly platform total streams from monthly_platform_totals.csv."""
539
+ path = ART_DIR / "monthly_platform_totals.csv"
540
+ if not path.exists():
541
+ return _empty_chart("Platform Streams — run the pipeline first")
542
+ df = pd.read_csv(path)
543
+ df["month"] = pd.to_datetime(df["month"], errors="coerce")
544
+ fig = go.Figure()
545
+ fig.add_trace(go.Scatter(
546
+ x=df["month"], y=df["total_streams_k"],
547
+ name="Monthly Streams (k)",
548
+ mode="lines+markers",
549
+ fill="tozeroy",
550
+ fillcolor="rgba(124,92,191,0.12)",
551
+ line=dict(color="#7c5cbf", width=2.5),
552
+ marker=dict(size=5),
553
+ hovertemplate="<b>%{x|%b %Y}</b>: %{y:,.0f}k streams<extra></extra>",
554
+ ))
555
+ rolling = df["total_streams_k"].rolling(3, center=True).mean()
556
+ fig.add_trace(go.Scatter(
557
+ x=df["month"], y=rolling,
558
+ name="3-month rolling avg",
559
+ mode="lines",
560
+ line=dict(color="#e8537a", width=2, dash="dash"),
561
+ hovertemplate="<b>Rolling avg</b>: %{y:,.0f}k<extra></extra>",
562
+ ))
563
+ fig.update_layout(**_styled_layout(
564
+ height=420, hovermode="x unified",
565
+ title=dict(text="Total Platform Streams Over Time"),
566
+ ))
567
+ fig.update_xaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True)
568
+ fig.update_yaxes(gridcolor="rgba(124,92,191,0.15)", showgrid=True, title="Streams (thousands)")
569
+ return fig
570
+
571
+
572
+ def build_sentiment_chart() -> go.Figure:
573
+ """VADER score distribution by renewal decision from shows_final.csv."""
574
+ path = PY_TAB_DIR / "renewal_recommendations.csv"
575
+ if not path.exists():
576
+ return _empty_chart("Sentiment Analysis — run the pipeline first")
577
+ df = pd.read_csv(path)
578
+ if "avg_vader_score" not in df.columns or "renewal_decision" not in df.columns:
579
+ return _empty_chart("Expected columns not found in renewal_recommendations.csv")
580
+
581
+ decision_order = ["Renew", "Invest More", "Cancel"]
582
+ decision_colors = {"Renew": "#2ec4a0", "Invest More": "#e8a230", "Cancel": "#e8537a"}
583
+
584
+ fig = go.Figure()
585
+ for decision in decision_order:
586
+ subset = df[df["renewal_decision"] == decision]["avg_vader_score"].dropna()
587
+ if subset.empty:
588
+ continue
589
+ fig.add_trace(go.Box(
590
+ y=subset,
591
+ name=decision,
592
+ marker_color=decision_colors.get(decision, "#888"),
593
+ boxmean="sd",
594
+ hovertemplate=f"<b>{decision}</b><br>VADER: %{{y:.3f}}<extra></extra>",
595
+ ))
596
+ fig.update_layout(**_styled_layout(
597
+ height=440,
598
+ title=dict(text="VADER Sentiment Score by Renewal Decision"),
599
+ ))
600
+ fig.update_yaxes(title="Avg VADER Compound Score", gridcolor="rgba(124,92,191,0.15)")
601
+ return fig
602
+
603
+
604
+ def build_renewal_chart() -> go.Figure:
605
+ """Renewal decision breakdown from renewal_recommendations.csv."""
606
+ path = PY_TAB_DIR / "renewal_recommendations.csv"
607
+ if not path.exists():
608
+ return _empty_chart("Renewal Decisions — run the pipeline first")
609
+ df = pd.read_csv(path)
610
+ if "renewal_decision" not in df.columns:
611
+ return _empty_chart("renewal_decision column not found")
612
+
613
+ counts = df["renewal_decision"].value_counts().reindex(["Renew", "Invest More", "Cancel"], fill_value=0)
614
+ colors = ["#2ec4a0", "#e8a230", "#e8537a"]
615
+
616
+ fig = go.Figure(go.Bar(
617
+ x=counts.index,
618
+ y=counts.values,
619
+ marker_color=colors,
620
+ text=counts.values,
621
+ textposition="outside",
622
+ hovertemplate="<b>%{x}</b>: %{y} shows<extra></extra>",
623
+ ))
624
+ fig.update_layout(**_styled_layout(
625
+ height=400, showlegend=False,
626
+ title=dict(text="Renewal Decision Breakdown"),
627
+ ))
628
+ fig.update_yaxes(title="Number of Shows", gridcolor="rgba(124,92,191,0.15)")
629
+ return fig
630
+
631
+
632
+ def refresh_dashboard():
633
+ return (
634
+ render_kpi_cards(),
635
+ build_platform_streams_chart(),
636
+ build_sentiment_chart(),
637
+ build_renewal_chart(),
638
+ )
639
+
640
+
641
+ # =========================================================
642
+ # UI
643
+ # =========================================================
644
+
645
+ ensure_dirs()
646
+
647
+ def load_css() -> str:
648
+ css_path = BASE_DIR / "style.css"
649
+ return css_path.read_text(encoding="utf-8") if css_path.exists() else ""
650
+
651
+
652
+ with gr.Blocks(title="Streaming Cancellation Risk Predictor") as demo:
653
+
654
+ gr.Markdown(
655
+ "# 🎬 Streaming Platform — Cancellation Risk Predictor\n"
656
+ "*Group 36 · ESCP · AIBDM 2026*",
657
+ elem_id="escp_title",
658
+ )
659
+
660
+ # ===========================================================
661
+ # TAB 1 — Pipeline Runner
662
+ # ===========================================================
663
+ with gr.Tab("Pipeline Runner"):
664
+ gr.Markdown(
665
+ "Run the notebooks to collect IMDb data, generate synthetic streaming KPIs, "
666
+ "and train the cancellation-risk model."
667
+ )
668
+
669
+ with gr.Row():
670
+ with gr.Column(scale=1):
671
+ btn_nb1 = gr.Button("Step 1: Data Creation", variant="secondary")
672
+ with gr.Column(scale=1):
673
+ btn_nb2 = gr.Button("Step 2: Python Analysis", variant="secondary")
674
+
675
+ with gr.Row():
676
+ btn_all = gr.Button("Run Full Pipeline (Both Steps)", variant="primary")
677
+
678
+ run_log = gr.Textbox(
679
+ label="Execution Log",
680
+ lines=18,
681
+ max_lines=30,
682
+ interactive=False,
683
+ )
684
+
685
+ btn_nb1.click(run_datacreation, outputs=[run_log])
686
+ btn_nb2.click(run_pythonanalysis, outputs=[run_log])
687
+ btn_all.click(run_full_pipeline, outputs=[run_log])
688
+
689
+ # ===========================================================
690
+ # TAB 2 — Dashboard
691
+ # ===========================================================
692
+ with gr.Tab("Dashboard"):
693
+ kpi_html = gr.HTML(value=render_kpi_cards)
694
+
695
+ refresh_btn = gr.Button("Refresh Dashboard", variant="primary")
696
+
697
+ gr.Markdown("#### Interactive Charts")
698
+ chart_streams = gr.Plot(label="Platform Streams Over Time")
699
+ chart_sentiment = gr.Plot(label="Sentiment by Renewal Decision")
700
+ chart_renewal = gr.Plot(label="Renewal Decision Breakdown")
701
+
702
+ gr.Markdown("#### Static Figures (from notebooks)")
703
+ gallery = gr.Gallery(
704
+ label="Generated Figures",
705
+ columns=2,
706
+ height=480,
707
+ object_fit="contain",
708
+ )
709
+
710
+ gr.Markdown("#### Data Tables")
711
+ table_dropdown = gr.Dropdown(
712
+ label="Select a table to view",
713
+ choices=[],
714
+ interactive=True,
715
+ )
716
+ table_display = gr.Dataframe(
717
+ label="Table Preview",
718
+ interactive=False,
719
+ )
720
+
721
+ def _on_refresh():
722
+ kpi, c1, c2, c3 = refresh_dashboard()
723
+ figs, dd, df = refresh_gallery()
724
+ return kpi, c1, c2, c3, figs, dd, df
725
+
726
+ refresh_btn.click(
727
+ _on_refresh,
728
+ outputs=[kpi_html, chart_streams, chart_sentiment, chart_renewal,
729
+ gallery, table_dropdown, table_display],
730
+ )
731
+ table_dropdown.change(
732
+ on_table_select,
733
+ inputs=[table_dropdown],
734
+ outputs=[table_display],
735
+ )
736
+
737
+ # ===========================================================
738
+ # TAB 3 — AI Dashboard
739
+ # ===========================================================
740
+ with gr.Tab('"AI" Dashboard'):
741
+ _ai_status = (
742
+ "Connected to your **n8n workflow**." if N8N_WEBHOOK_URL
743
+ else "**LLM active.**" if LLM_ENABLED
744
+ else "Using **keyword matching**. Upgrade options: "
745
+ "set `N8N_WEBHOOK_URL` to connect your n8n workflow, "
746
+ "or set `HF_API_KEY` for direct LLM access."
747
+ )
748
+ gr.Markdown(
749
+ "### Ask questions, get interactive visualisations\n\n"
750
+ f"Type a question and the system will pick the right chart or table. {_ai_status}"
751
+ )
752
+
753
+ with gr.Row(equal_height=True):
754
+ with gr.Column(scale=1):
755
+ chatbot = gr.Chatbot(
756
+ label="Conversation",
757
+ height=380,
758
+ )
759
+ user_input = gr.Textbox(
760
+ label="Ask about your data",
761
+ placeholder="e.g. Show sentiment analysis / Which shows should be cancelled? / ARIMA forecasts",
762
+ lines=1,
763
+ )
764
+ gr.Examples(
765
+ examples=[
766
+ "Show me the platform viewership trends",
767
+ "What does the sentiment analysis show?",
768
+ "Which shows should be cancelled?",
769
+ "Show the ARIMA forecasts",
770
+ "What are the most important features?",
771
+ "Give me the full recommendations table",
772
+ ],
773
+ inputs=user_input,
774
+ )
775
+
776
+ with gr.Column(scale=1):
777
+ ai_figure = gr.Plot(label="Interactive Chart")
778
+ ai_table = gr.Dataframe(label="Data Table", interactive=False)
779
+
780
+ user_input.submit(
781
+ ai_chat,
782
+ inputs=[user_input, chatbot],
783
+ outputs=[chatbot, user_input, ai_figure, ai_table],
784
+ )
785
+
786
+
787
+ demo.launch(css=load_css(), allowed_paths=[str(BASE_DIR)])
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.0.0
2
+ pandas>=2.0.0
3
+ numpy>=1.24.0
4
+ matplotlib>=3.7.0
5
+ seaborn>=0.13.0
6
+ statsmodels>=0.14.0
7
+ scikit-learn>=1.3.0
8
+ papermill>=2.5.0
9
+ nbformat>=5.9.0
10
+ pillow>=10.0.0
11
+ requests>=2.31.0
12
+ vaderSentiment>=3.3.2
13
+ huggingface_hub>=0.20.0
14
+ plotly>=5.18.0
style.css ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* --- Target the Gradio app wrapper for backgrounds --- */
2
+ gradio-app,
3
+ .gradio-app,
4
+ .main,
5
+ #app,
6
+ [data-testid="app"] {
7
+ background-color: rgb(40,9,109) !important;
8
+ background-image:
9
+ url('https://huggingface.co/spaces/JimKau/Coding_Project_II/resolve/main/background_top.png'),
10
+ url('https://huggingface.co/spaces/JimKau/Coding_Project_II/resolve/main/background_mid.png'),
11
+ url('https://huggingface.co/spaces/JimKau/Coding_Project_II/resolve/main/background_bottom.png') !important;
12
+ background-position:
13
+ top center,
14
+ 0 913px,
15
+ bottom center !important;
16
+ background-repeat:
17
+ no-repeat,
18
+ repeat-y,
19
+ no-repeat !important;
20
+ background-size:
21
+ 100% auto,
22
+ 100% auto,
23
+ 100% auto !important;
24
+ min-height: 100vh !important;
25
+ }
26
+
27
+ /* --- Fallback on html/body --- */
28
+ html, body {
29
+ background-color: rgb(40,9,109) !important;
30
+ margin: 0 !important;
31
+ padding: 0 !important;
32
+ min-height: 100vh !important;
33
+ }
34
+
35
+ /* --- Main container --- */
36
+ .gradio-container {
37
+ max-width: 1400px !important;
38
+ width: 94vw !important;
39
+ margin: 0 auto !important;
40
+ padding-top: 220px !important;
41
+ padding-bottom: 150px !important;
42
+ background: transparent !important;
43
+ }
44
+
45
+ /* --- Title in ESCP gold --- */
46
+ #escp_title h1 {
47
+ color: rgb(242,198,55) !important;
48
+ font-size: 3rem !important;
49
+ font-weight: 800 !important;
50
+ text-align: center !important;
51
+ margin: 0 0 12px 0 !important;
52
+ }
53
+
54
+ /* --- Subtitle --- */
55
+ #escp_title p, #escp_title em {
56
+ color: rgba(255,255,255,0.85) !important;
57
+ text-align: center !important;
58
+ }
59
+
60
+ /* --- Tab bar background --- */
61
+ .tabs > .tab-nav,
62
+ .tab-nav,
63
+ div[role="tablist"],
64
+ .svelte-tabs > .tab-nav {
65
+ background: rgba(40,9,109,0.6) !important;
66
+ border-radius: 10px 10px 0 0 !important;
67
+ padding: 4px !important;
68
+ }
69
+
70
+ /* --- ALL tab buttons: force white text --- */
71
+ .tabs > .tab-nav button,
72
+ .tab-nav button,
73
+ div[role="tablist"] button,
74
+ button[role="tab"],
75
+ .svelte-tabs button,
76
+ .tab-nav > button,
77
+ .tabs button {
78
+ color: #ffffff !important;
79
+ font-weight: 600 !important;
80
+ border: none !important;
81
+ background: transparent !important;
82
+ padding: 10px 20px !important;
83
+ border-radius: 8px 8px 0 0 !important;
84
+ opacity: 1 !important;
85
+ }
86
+
87
+ /* --- Selected tab: ESCP gold --- */
88
+ .tabs > .tab-nav button.selected,
89
+ .tab-nav button.selected,
90
+ button[role="tab"][aria-selected="true"],
91
+ button[role="tab"].selected,
92
+ div[role="tablist"] button[aria-selected="true"],
93
+ .svelte-tabs button.selected {
94
+ color: rgb(242,198,55) !important;
95
+ background: rgba(255,255,255,0.12) !important;
96
+ }
97
+
98
+ /* --- Unselected tabs: ensure visibility --- */
99
+ .tabs > .tab-nav button:not(.selected),
100
+ .tab-nav button:not(.selected),
101
+ button[role="tab"][aria-selected="false"],
102
+ button[role="tab"]:not(.selected),
103
+ div[role="tablist"] button:not([aria-selected="true"]) {
104
+ color: #ffffff !important;
105
+ opacity: 1 !important;
106
+ }
107
+
108
+ /* --- White card panels --- */
109
+ .gradio-container .gr-block,
110
+ .gradio-container .gr-box,
111
+ .gradio-container .gr-panel,
112
+ .gradio-container .gr-group {
113
+ background: #ffffff !important;
114
+ border-radius: 10px !important;
115
+ }
116
+
117
+ /* --- Tab content area --- */
118
+ .tabitem {
119
+ background: rgba(255,255,255,0.95) !important;
120
+ border-radius: 0 0 10px 10px !important;
121
+ padding: 16px !important;
122
+ }
123
+
124
+ /* --- Inputs --- */
125
+ .gradio-container input,
126
+ .gradio-container textarea,
127
+ .gradio-container select {
128
+ background: #ffffff !important;
129
+ border: 1px solid #d1d5db !important;
130
+ border-radius: 8px !important;
131
+ }
132
+
133
+ /* --- Buttons: ESCP purple primary --- */
134
+ .gradio-container button:not([role="tab"]) {
135
+ font-weight: 600 !important;
136
+ padding: 10px 16px !important;
137
+ border-radius: 10px !important;
138
+ }
139
+
140
+ button.primary {
141
+ background-color: rgb(40,9,109) !important;
142
+ color: #ffffff !important;
143
+ border: none !important;
144
+ }
145
+
146
+ button.primary:hover {
147
+ background-color: rgb(60,20,140) !important;
148
+ }
149
+
150
+ button.secondary {
151
+ background-color: #ffffff !important;
152
+ color: rgb(40,9,109) !important;
153
+ border: 2px solid rgb(40,9,109) !important;
154
+ }
155
+
156
+ button.secondary:hover {
157
+ background-color: rgb(240,238,250) !important;
158
+ }
159
+
160
+ /* --- Dataframes --- */
161
+ [data-testid="dataframe"] {
162
+ background-color: #ffffff !important;
163
+ border-radius: 10px !important;
164
+ }
165
+
166
+ table {
167
+ font-size: 0.85rem !important;
168
+ }
169
+
170
+ /* --- Chatbot (AI Dashboard tab) --- */
171
+ .gr-chatbot {
172
+ min-height: 380px !important;
173
+ background-color: #ffffff !important;
174
+ border-radius: 12px !important;
175
+ }
176
+
177
+ .gr-chatbot .message.user {
178
+ background-color: rgb(232,225,250) !important;
179
+ border-radius: 12px !important;
180
+ }
181
+
182
+ .gr-chatbot .message.bot {
183
+ background-color: #f3f4f6 !important;
184
+ border-radius: 12px !important;
185
+ }
186
+
187
+ /* --- Gallery --- */
188
+ .gallery {
189
+ background: #ffffff !important;
190
+ border-radius: 10px !important;
191
+ }
192
+
193
+ /* --- Log textbox --- */
194
+ textarea {
195
+ font-family: monospace !important;
196
+ font-size: 0.8rem !important;
197
+ }
198
+
199
+ /* --- Markdown headings inside tabs --- */
200
+ .tabitem h3 {
201
+ color: rgb(40,9,109) !important;
202
+ font-weight: 700 !important;
203
+ }
204
+
205
+ .tabitem h4 {
206
+ color: #374151 !important;
207
+ }
208
+
209
+ /* --- Examples row (AI Dashboard) --- */
210
+ .examples-row button {
211
+ background: rgb(240,238,250) !important;
212
+ color: rgb(40,9,109) !important;
213
+ border: 1px solid rgb(40,9,109) !important;
214
+ border-radius: 8px !important;
215
+ font-size: 0.85rem !important;
216
+ }
217
+
218
+ .examples-row button:hover {
219
+ background: rgb(232,225,250) !important;
220
+ }
221
+
222
+ /* --- Header / footer: transparent over banner --- */
223
+ header, header *,
224
+ footer, footer * {
225
+ background: transparent !important;
226
+ box-shadow: none !important;
227
+ }
228
+
229
+ footer a, footer button,
230
+ header a, header button {
231
+ background: transparent !important;
232
+ border: none !important;
233
+ box-shadow: none !important;
234
+ }
235
+
236
+ #footer, #footer *,
237
+ [class*="footer"], [class*="footer"] *,
238
+ [class*="chip"], [class*="pill"], [class*="chip"] *, [class*="pill"] * {
239
+ background: transparent !important;
240
+ border: none !important;
241
+ box-shadow: none !important;
242
+ }
243
+
244
+ [data-testid*="api"], [data-testid*="settings"],
245
+ [id*="api"], [id*="settings"],
246
+ [class*="api"], [class*="settings"],
247
+ [class*="bottom"], [class*="toolbar"], [class*="controls"] {
248
+ background: transparent !important;
249
+ box-shadow: none !important;
250
+ }
251
+
252
+ [data-testid*="api"] *, [data-testid*="settings"] *,
253
+ [id*="api"] *, [id*="settings"] *,
254
+ [class*="api"] *, [class*="settings"] * {
255
+ background: transparent !important;
256
+ box-shadow: none !important;
257
+ }
258
+
259
+ section footer {
260
+ background: transparent !important;
261
+ }
262
+
263
+ section footer button,
264
+ section footer a {
265
+ background: transparent !important;
266
+ background-color: transparent !important;
267
+ border: none !important;
268
+ box-shadow: none !important;
269
+ color: white !important;
270
+ }
271
+
272
+ section footer button:hover,
273
+ section footer button:focus,
274
+ section footer a:hover,
275
+ section footer a:focus {
276
+ background: transparent !important;
277
+ background-color: transparent !important;
278
+ box-shadow: none !important;
279
+ }
280
+
281
+ section footer button,
282
+ section footer button * {
283
+ background: transparent !important;
284
+ background-color: transparent !important;
285
+ background-image: none !important;
286
+ box-shadow: none !important;
287
+ filter: none !important;
288
+ }
289
+
290
+ section footer button::before,
291
+ section footer button::after {
292
+ background: transparent !important;
293
+ background-color: transparent !important;
294
+ background-image: none !important;
295
+ box-shadow: none !important;
296
+ filter: none !important;
297
+ }
298
+
299
+ section footer a,
300
+ section footer a * {
301
+ background: transparent !important;
302
+ background-color: transparent !important;
303
+ box-shadow: none !important;
304
+ }
305
+
306
+ .gradio-container footer button,
307
+ .gradio-container footer button *,
308
+ .gradio-container .footer button,
309
+ .gradio-container .footer button * {
310
+ background: transparent !important;
311
+ background-color: transparent !important;
312
+ background-image: none !important;
313
+ box-shadow: none !important;
314
+ }
315
+
316
+ .gradio-container footer button::before,
317
+ .gradio-container footer button::after,
318
+ .gradio-container .footer button::before,
319
+ .gradio-container .footer button::after {
320
+ background: transparent !important;
321
+ background-color: transparent !important;
322
+ background-image: none !important;
323
+ box-shadow: none !important;
324
+ }