JimKau commited on
Commit
64ad9ea
Β·
verified Β·
1 Parent(s): ffbf3ac

Upload 3 files

Browse files
Files changed (3) hide show
  1. app (1).py +356 -0
  2. requirements (1).txt +12 -0
  3. style (1).css +196 -0
app (1).py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ import numpy as np
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import traceback
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+
11
+ # ── output folders (same structure Notebook 2 writes to)
12
+ ART_DIR = Path("artifacts")
13
+ FIG_DIR = ART_DIR / "figures"
14
+ TAB_DIR = ART_DIR / "tables"
15
+ for p in [FIG_DIR, TAB_DIR]:
16
+ p.mkdir(parents=True, exist_ok=True)
17
+
18
+ # ────────────────────────────────────────────
19
+ # PIPELINE RUNNER
20
+ # ────────────────────────────────────────────
21
+
22
+ def run_notebook(path: str) -> str:
23
+ try:
24
+ result = subprocess.run(
25
+ [sys.executable, "-m", "jupyter", "nbconvert",
26
+ "--to", "notebook", "--execute",
27
+ "--ExecutePreprocessor.timeout=600",
28
+ "--inplace", path],
29
+ capture_output=True, text=True
30
+ )
31
+ if result.returncode != 0:
32
+ return f"❌ Error running {path}:\n{result.stderr[-2000:]}"
33
+ return f"βœ… {path} completed successfully."
34
+ except Exception as e:
35
+ return f"❌ Exception: {traceback.format_exc()}"
36
+
37
+ def run_data_creation():
38
+ log = "β–Ά Running Notebook 1 β€” Data Collection & Creation...\n"
39
+ log += run_notebook("datacreation.ipynb")
40
+ return log
41
+
42
+ def run_analysis():
43
+ log = "β–Ά Running Notebook 2 β€” Data Analysis & Modelling...\n"
44
+ log += run_notebook("pythonanalysis.ipynb")
45
+ return log
46
+
47
+ def run_full_pipeline():
48
+ log = "β–Ά Running full pipeline...\n\n"
49
+ log += "Step 1 β€” Data Collection & Creation\n"
50
+ log += run_notebook("datacreation.ipynb") + "\n\n"
51
+ log += "Step 2 β€” Data Analysis & Modelling\n"
52
+ log += run_notebook("pythonanalysis.ipynb")
53
+ return log
54
+
55
+ # ────────────────────────────────────────────
56
+ # DASHBOARD HELPERS
57
+ # ────────────────────────────────────────────
58
+
59
+ def load_kpis():
60
+ kpi_path = TAB_DIR / "kpis.json"
61
+ if not kpi_path.exists():
62
+ return None
63
+ with open(kpi_path) as f:
64
+ return json.load(f)
65
+
66
+ def load_shows():
67
+ path = TAB_DIR / "shows_final.csv"
68
+ if not path.exists():
69
+ path = ART_DIR / "shows_master.csv"
70
+ if not path.exists():
71
+ return None
72
+ return pd.read_csv(path)
73
+
74
+ def load_recommendations():
75
+ path = TAB_DIR / "renewal_recommendations.csv"
76
+ if not path.exists():
77
+ return None
78
+ return pd.read_csv(path)
79
+
80
+ def load_monthly():
81
+ path = ART_DIR / "monthly_platform_totals.csv"
82
+ if not path.exists():
83
+ return None
84
+ df = pd.read_csv(path)
85
+ df["month"] = pd.to_datetime(df["month"])
86
+ return df
87
+
88
+ def kpi_html(kpis):
89
+ if not kpis:
90
+ return "<p style='color:#888;text-align:center;padding:40px'>Run the pipeline first to populate the dashboard.</p>"
91
+ return f"""
92
+ <div style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center;padding:16px 0">
93
+ <div class="kpi-card kpi-total">
94
+ <div class="kpi-value">{kpis.get('total_shows','β€”')}</div>
95
+ <div class="kpi-label">Total Shows</div>
96
+ </div>
97
+ <div class="kpi-card kpi-renew">
98
+ <div class="kpi-value">{kpis.get('shows_to_renew','β€”')}</div>
99
+ <div class="kpi-label">Renew</div>
100
+ </div>
101
+ <div class="kpi-card kpi-invest">
102
+ <div class="kpi-value">{kpis.get('shows_invest_more','β€”')}</div>
103
+ <div class="kpi-label">Invest More</div>
104
+ </div>
105
+ <div class="kpi-card kpi-cancel">
106
+ <div class="kpi-value">{kpis.get('shows_to_cancel','β€”')}</div>
107
+ <div class="kpi-label">Cancel</div>
108
+ </div>
109
+ <div class="kpi-card kpi-roi">
110
+ <div class="kpi-value">{kpis.get('avg_platform_roi','β€”')}%</div>
111
+ <div class="kpi-label">Avg Platform ROI</div>
112
+ </div>
113
+ <div class="kpi-card kpi-completion">
114
+ <div class="kpi-value">{round(kpis.get('avg_completion_rate',0)*100,1)}%</div>
115
+ <div class="kpi-label">Avg Completion Rate</div>
116
+ </div>
117
+ <div class="kpi-card kpi-rating">
118
+ <div class="kpi-value">{kpis.get('avg_imdb_rating','β€”')}</div>
119
+ <div class="kpi-label">Avg IMDb Rating</div>
120
+ </div>
121
+ <div class="kpi-card kpi-sentiment">
122
+ <div class="kpi-value">{round(kpis.get('sentiment_alignment',0)*100,1)}%</div>
123
+ <div class="kpi-label">Sentiment Alignment</div>
124
+ </div>
125
+ </div>
126
+ """
127
+
128
+ def refresh_dashboard():
129
+ kpis = load_kpis()
130
+ shows = load_recommendations()
131
+
132
+ kpi_block = kpi_html(kpis)
133
+
134
+ # figures
135
+ figs = {}
136
+ for name in ["vader_sentiment_analysis", "viewership_trends_sampled",
137
+ "arima_forecasts", "random_forest_results",
138
+ "decision_analysis", "platform_overview"]:
139
+ p = FIG_DIR / f"{name}.png"
140
+ figs[name] = str(p) if p.exists() else None
141
+
142
+ # recommendation table β€” filter by decision
143
+ table_renew = shows[shows["renewal_decision"]=="Renew"][
144
+ ["title","primary_genre","imdb_rating","num_seasons",
145
+ "avg_monthly_streams_k","platform_roi_pct","avg_vader_score"]
146
+ ].round(2).head(20) if shows is not None else pd.DataFrame()
147
+
148
+ table_cancel = shows[shows["renewal_decision"]=="Cancel"][
149
+ ["title","primary_genre","imdb_rating","num_seasons",
150
+ "avg_monthly_streams_k","platform_roi_pct","avg_vader_score"]
151
+ ].round(2).head(20) if shows is not None else pd.DataFrame()
152
+
153
+ table_invest = shows[shows["renewal_decision"]=="Invest More"][
154
+ ["title","primary_genre","imdb_rating","num_seasons",
155
+ "avg_monthly_streams_k","platform_roi_pct","avg_vader_score"]
156
+ ].round(2).head(20) if shows is not None else pd.DataFrame()
157
+
158
+ return (
159
+ kpi_block,
160
+ figs.get("platform_overview"),
161
+ figs.get("viewership_trends_sampled"),
162
+ figs.get("vader_sentiment_analysis"),
163
+ figs.get("arima_forecasts"),
164
+ figs.get("random_forest_results"),
165
+ figs.get("decision_analysis"),
166
+ table_renew,
167
+ table_cancel,
168
+ table_invest
169
+ )
170
+
171
+ # ────────────────────────────────────────────
172
+ # SEARCH
173
+ # ────────────────────────────────────────────
174
+
175
+ def search_shows(query, decision_filter):
176
+ shows = load_recommendations()
177
+ if shows is None:
178
+ return pd.DataFrame({"message": ["Run the pipeline first."]})
179
+ df = shows.copy()
180
+ if query.strip():
181
+ df = df[df["title"].str.contains(query.strip(), case=False, na=False)]
182
+ if decision_filter != "All":
183
+ df = df[df["renewal_decision"] == decision_filter]
184
+ cols = ["title","primary_genre","imdb_rating","num_seasons",
185
+ "avg_monthly_streams_k","platform_roi_pct",
186
+ "avg_vader_score","renewal_decision"]
187
+ return df[cols].round(2).head(50)
188
+
189
+ # ────────────────────────────────────────────
190
+ # AI DASHBOARD β€” n8n webhook
191
+ # ────────────────────────────────────────────
192
+
193
+ import requests as req
194
+
195
+ N8N_WEBHOOK = "https://jimkaufmann.app.n8n.cloud/webhook/streaming-analyst"
196
+
197
+ def ask_ai(question, history):
198
+ if not question.strip():
199
+ return history, ""
200
+
201
+ shows = load_shows()
202
+ kpis = load_kpis()
203
+
204
+ context = ""
205
+ if kpis:
206
+ context += f"Platform KPIs: {json.dumps(kpis)}\n"
207
+ if shows is not None:
208
+ summary = shows[["title","renewal_decision","imdb_rating",
209
+ "platform_roi_pct","avg_monthly_streams_k"]]\
210
+ .head(30).to_dict(orient="records")
211
+ context += f"Sample shows data: {json.dumps(summary)}\n"
212
+
213
+ try:
214
+ response = req.post(
215
+ N8N_WEBHOOK,
216
+ json={"question": question, "context": context},
217
+ timeout=30
218
+ )
219
+ if response.status_code == 200:
220
+ data = response.json()
221
+ answer = data.get("answer") or data.get("text") or str(data)
222
+ else:
223
+ answer = f"Webhook returned status {response.status_code}. Make sure your n8n workflow is active."
224
+ except Exception as e:
225
+ answer = f"Could not reach the n8n workflow: {e}"
226
+
227
+ history = history or []
228
+ history.append((question, answer))
229
+ return history, ""
230
+
231
+ # ────────────────────────────────────────────
232
+ # BUILD UI
233
+ # ────────────────────────────────────────────
234
+
235
+ with gr.Blocks(css=open("style.css").read(), title="Streaming Cancellation Risk Predictor") as demo:
236
+
237
+ # ── HEADER
238
+ gr.HTML("""
239
+ <div class="header-wrap">
240
+ <img src="/file=background_top.png" class="bg-top"/>
241
+ <div class="header-content">
242
+ <h1 class="app-title">🎬 Streaming Cancellation Risk Predictor</h1>
243
+ <p class="app-subtitle">Which shows should we Renew, Cancel, or Invest More in?</p>
244
+ </div>
245
+ </div>
246
+ """)
247
+
248
+ with gr.Tabs():
249
+
250
+ # ── TAB 1: PIPELINE RUNNER
251
+ with gr.Tab("β–Ά Pipeline Runner"):
252
+ gr.Markdown("""
253
+ Run the two notebooks to collect IMDb data, generate synthetic viewership and reviews,
254
+ run VADER sentiment analysis, ARIMA forecasting, and train the Random Forest classifier.
255
+ Results are saved automatically and populate the Dashboard tab.
256
+ """)
257
+ with gr.Row():
258
+ btn_nb1 = gr.Button("Step 1 β€” Data Collection & Creation", variant="secondary", size="lg")
259
+ btn_nb2 = gr.Button("Step 2 β€” Data Analysis & Modelling", variant="secondary", size="lg")
260
+ btn_full = gr.Button("πŸš€ Run Full Pipeline (Both Steps)", variant="primary", size="lg")
261
+ log_box = gr.Textbox(label="Execution Log", lines=12, interactive=False)
262
+
263
+ btn_nb1.click(run_data_creation, outputs=log_box)
264
+ btn_nb2.click(run_analysis, outputs=log_box)
265
+ btn_full.click(run_full_pipeline, outputs=log_box)
266
+
267
+ # ── TAB 2: DASHBOARD
268
+ with gr.Tab("πŸ“Š Dashboard"):
269
+
270
+ btn_refresh = gr.Button("πŸ”„ Refresh Dashboard", variant="primary")
271
+
272
+ kpi_display = gr.HTML(label="KPIs")
273
+
274
+ gr.Markdown("### Platform Overview")
275
+ img_platform = gr.Image(label="Total Monthly Streams", show_label=False)
276
+
277
+ gr.Markdown("### Viewership Trends")
278
+ img_trends = gr.Image(label="Viewership Trends", show_label=False)
279
+
280
+ gr.Markdown("### Sentiment Analysis")
281
+ img_vader = gr.Image(label="VADER Sentiment", show_label=False)
282
+
283
+ gr.Markdown("### ARIMA Forecasts")
284
+ img_arima = gr.Image(label="ARIMA Forecasts", show_label=False)
285
+
286
+ gr.Markdown("### Random Forest Results")
287
+ img_rf = gr.Image(label="Random Forest", show_label=False)
288
+
289
+ gr.Markdown("### Decision Analysis")
290
+ img_decisions = gr.Image(label="Decision Analysis", show_label=False)
291
+
292
+ gr.Markdown("### 🟒 Shows to Renew")
293
+ tbl_renew = gr.DataFrame(label="Renew")
294
+ gr.Markdown("### πŸ”΄ Shows to Cancel")
295
+ tbl_cancel = gr.DataFrame(label="Cancel")
296
+ gr.Markdown("### 🟑 Shows to Invest More In")
297
+ tbl_invest = gr.DataFrame(label="Invest More")
298
+
299
+ all_outputs = [
300
+ kpi_display,
301
+ img_platform, img_trends, img_vader,
302
+ img_arima, img_rf, img_decisions,
303
+ tbl_renew, tbl_cancel, tbl_invest
304
+ ]
305
+ btn_refresh.click(refresh_dashboard, outputs=all_outputs)
306
+ demo.load(refresh_dashboard, outputs=all_outputs)
307
+
308
+ # ── TAB 3: SEARCH
309
+ with gr.Tab("πŸ” Show Search"):
310
+ gr.Markdown("""
311
+ Search across all shows in the dataset. Filter by renewal decision to quickly find
312
+ the platform's top renewal candidates or shows flagged for cancellation.
313
+ """)
314
+ with gr.Row():
315
+ search_box = gr.Textbox(placeholder="Search by show title...", label="", scale=3)
316
+ decision_drop = gr.Dropdown(
317
+ choices=["All", "Renew", "Invest More", "Cancel"],
318
+ value="All", label="Filter by decision", scale=1
319
+ )
320
+ search_btn = gr.Button("Search", variant="primary")
321
+ search_table = gr.DataFrame(label="Results")
322
+
323
+ search_btn.click(search_shows,
324
+ inputs=[search_box, decision_drop],
325
+ outputs=search_table)
326
+ search_box.submit(search_shows,
327
+ inputs=[search_box, decision_drop],
328
+ outputs=search_table)
329
+
330
+ # ── TAB 4: AI DASHBOARD
331
+ with gr.Tab("πŸ€– AI Dashboard"):
332
+ gr.Markdown("""
333
+ Ask questions about the platform's content portfolio and get AI-powered answers.
334
+ Connected to our n8n workflow which has access to the full show dataset and KPIs.
335
+
336
+ *Examples: "Which drama shows should we prioritise for renewal?", "What genres have the best ROI?",
337
+ "Which shows have high viewership but negative sentiment?"*
338
+ """)
339
+ chatbot = gr.Chatbot(height=420, label="")
340
+ with gr.Row():
341
+ msg_box = gr.Textbox(placeholder="Ask a question about the data...",
342
+ label="", scale=4)
343
+ send_btn = gr.Button("Send", variant="primary", scale=1)
344
+
345
+ send_btn.click(ask_ai, inputs=[msg_box, chatbot], outputs=[chatbot, msg_box])
346
+ msg_box.submit(ask_ai, inputs=[msg_box, chatbot], outputs=[chatbot, msg_box])
347
+
348
+ # ── FOOTER
349
+ gr.HTML("""
350
+ <div class="footer">
351
+ <img src="/file=background_bottom.png" class="bg-bottom"/>
352
+ <p>ESCP Business School β€” AI for Big Data Management β€” Group Project 2026</p>
353
+ </div>
354
+ """)
355
+
356
+ demo.launch()
requirements (1).txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ pandas
3
+ numpy
4
+ matplotlib
5
+ seaborn
6
+ statsmodels
7
+ scikit-learn
8
+ vaderSentiment
9
+ requests
10
+ nbconvert
11
+ ipykernel
12
+ jupyter
style (1).css ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Base ── */
2
+ body, .gradio-container {
3
+ font-family: 'Inter', sans-serif;
4
+ background: #0d0d1a;
5
+ color: #e8e8f0;
6
+ }
7
+
8
+ /* ── Header ── */
9
+ .header-wrap {
10
+ position: relative;
11
+ width: 100%;
12
+ min-height: 180px;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ overflow: hidden;
17
+ margin-bottom: 8px;
18
+ }
19
+ .bg-top {
20
+ position: absolute;
21
+ top: 0; left: 0;
22
+ width: 100%; height: 100%;
23
+ object-fit: cover;
24
+ opacity: 0.35;
25
+ z-index: 0;
26
+ }
27
+ .header-content {
28
+ position: relative;
29
+ z-index: 1;
30
+ text-align: center;
31
+ padding: 32px 16px;
32
+ }
33
+ .app-title {
34
+ font-size: 2.2rem;
35
+ font-weight: 800;
36
+ color: #f5c518;
37
+ margin: 0 0 8px 0;
38
+ letter-spacing: -0.5px;
39
+ text-shadow: 0 2px 12px rgba(0,0,0,0.6);
40
+ }
41
+ .app-subtitle {
42
+ font-size: 1.05rem;
43
+ color: #c8c8e0;
44
+ margin: 0;
45
+ }
46
+
47
+ /* ── KPI Cards ── */
48
+ .kpi-card {
49
+ background: #1a1a2e;
50
+ border-radius: 12px;
51
+ padding: 18px 24px;
52
+ min-width: 130px;
53
+ text-align: center;
54
+ border: 1px solid #2a2a4a;
55
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
56
+ transition: transform 0.15s ease;
57
+ }
58
+ .kpi-card:hover { transform: translateY(-3px); }
59
+ .kpi-value {
60
+ font-size: 1.9rem;
61
+ font-weight: 800;
62
+ line-height: 1;
63
+ margin-bottom: 6px;
64
+ }
65
+ .kpi-label {
66
+ font-size: 0.75rem;
67
+ color: #888;
68
+ text-transform: uppercase;
69
+ letter-spacing: 0.5px;
70
+ }
71
+ .kpi-total .kpi-value { color: #a78bfa; }
72
+ .kpi-renew .kpi-value { color: #2ecc71; }
73
+ .kpi-invest .kpi-value { color: #f39c12; }
74
+ .kpi-cancel .kpi-value { color: #e74c3c; }
75
+ .kpi-roi .kpi-value { color: #3498db; }
76
+ .kpi-completion .kpi-value { color: #1abc9c; }
77
+ .kpi-rating .kpi-value { color: #f5c518; }
78
+ .kpi-sentiment .kpi-value { color: #e91e8c; }
79
+
80
+ /* ── Tabs ── */
81
+ .tab-nav button {
82
+ background: transparent !important;
83
+ color: #a0a0c0 !important;
84
+ border-bottom: 2px solid transparent !important;
85
+ font-weight: 500;
86
+ transition: all 0.2s;
87
+ }
88
+ .tab-nav button.selected {
89
+ color: #f5c518 !important;
90
+ border-bottom: 2px solid #f5c518 !important;
91
+ }
92
+
93
+ /* ── Buttons ── */
94
+ button.primary {
95
+ background: linear-gradient(135deg, #f5c518, #e8a000) !important;
96
+ color: #0d0d1a !important;
97
+ font-weight: 700 !important;
98
+ border: none !important;
99
+ border-radius: 8px !important;
100
+ }
101
+ button.secondary {
102
+ background: #1a1a2e !important;
103
+ color: #e8e8f0 !important;
104
+ border: 1px solid #3a3a5a !important;
105
+ border-radius: 8px !important;
106
+ }
107
+ button:hover { opacity: 0.9; }
108
+
109
+ /* ── Inputs ── */
110
+ input, textarea, .gr-textbox textarea {
111
+ background: #1a1a2e !important;
112
+ border: 1px solid #3a3a5a !important;
113
+ color: #e8e8f0 !important;
114
+ border-radius: 8px !important;
115
+ }
116
+
117
+ /* ── DataFrames ── */
118
+ .gr-dataframe table {
119
+ background: #1a1a2e;
120
+ border-radius: 8px;
121
+ overflow: hidden;
122
+ }
123
+ .gr-dataframe th {
124
+ background: #2a2a4a;
125
+ color: #f5c518;
126
+ font-size: 0.8rem;
127
+ text-transform: uppercase;
128
+ letter-spacing: 0.5px;
129
+ }
130
+ .gr-dataframe td {
131
+ color: #c8c8e0;
132
+ font-size: 0.85rem;
133
+ border-bottom: 1px solid #2a2a3a;
134
+ }
135
+ .gr-dataframe tr:hover td { background: #22223a; }
136
+
137
+ /* ── Images ── */
138
+ .gr-image img {
139
+ border-radius: 10px;
140
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
141
+ }
142
+
143
+ /* ── Chatbot ── */
144
+ .gr-chatbot {
145
+ background: #1a1a2e !important;
146
+ border: 1px solid #3a3a5a !important;
147
+ border-radius: 10px !important;
148
+ }
149
+ .gr-chatbot .message.user {
150
+ background: #2a2a4a !important;
151
+ color: #e8e8f0 !important;
152
+ border-radius: 8px !important;
153
+ }
154
+ .gr-chatbot .message.bot {
155
+ background: #0f3460 !important;
156
+ color: #e8e8f0 !important;
157
+ border-radius: 8px !important;
158
+ }
159
+
160
+ /* ── Log box ── */
161
+ .gr-textbox textarea {
162
+ font-family: 'Courier New', monospace !important;
163
+ font-size: 0.82rem !important;
164
+ }
165
+
166
+ /* ── Footer ── */
167
+ .footer {
168
+ position: relative;
169
+ text-align: center;
170
+ padding: 24px;
171
+ margin-top: 16px;
172
+ color: #666;
173
+ font-size: 0.8rem;
174
+ overflow: hidden;
175
+ }
176
+ .bg-bottom {
177
+ position: absolute;
178
+ bottom: 0; left: 0;
179
+ width: 100%; height: 100%;
180
+ object-fit: cover;
181
+ opacity: 0.15;
182
+ z-index: 0;
183
+ }
184
+ .footer p {
185
+ position: relative;
186
+ z-index: 1;
187
+ }
188
+
189
+ /* ── Markdown headings ── */
190
+ .gr-markdown h3 {
191
+ color: #f5c518;
192
+ font-size: 1rem;
193
+ margin: 20px 0 8px;
194
+ border-bottom: 1px solid #2a2a4a;
195
+ padding-bottom: 4px;
196
+ }