techatcreated commited on
Commit
5f52a16
Β·
verified Β·
1 Parent(s): 5e86daa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1223 -580
app.py CHANGED
@@ -1,5 +1,5 @@
1
  # -*- coding: utf-8 -*-
2
- """Enhanced CI Outcome Predictor with Modern UI (HF Spaces ready)"""
3
 
4
  import os
5
  import re
@@ -11,70 +11,75 @@ import gradio as gr
11
  from pathlib import Path
12
 
13
  # =========================
14
- # PATHS
 
 
 
 
 
15
  # =========================
16
  BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
17
- ASSETS_DIR = BASE_DIR / "assets"
18
- if not ASSETS_DIR.exists():
19
- ASSETS_DIR = BASE_DIR # fallback: files in repo root
20
 
21
- VAL_CSV_PATH = ASSETS_DIR / "validation_data.csv"
22
- MAIN_CSV_PATH = ASSETS_DIR / "Cochlear_Implant_Dataset.csv"
23
- CLF_PKL_PATH = ASSETS_DIR / "ci_success_classifier.pkl"
24
- REG_PKL_PATH = ASSETS_DIR / "ci_speech_score_regressor.pkl"
25
 
26
- # Writable location on HF Spaces for generated outputs
27
  BATCH_OUT_PATH = Path("/tmp/predictions_output.csv")
28
 
29
- def _require(path: Path) -> bool:
30
- return path.exists() and path.is_file()
31
 
32
- def _missing_assets_html():
 
 
 
 
 
 
 
 
 
33
  return f"""
34
- <div class="result-card fade-in">
35
- <div class="result-head">
36
- <div class="result-title">
37
- <div class="title-icon">⚠️</div>
38
- <div>Setup required</div>
39
- </div>
40
- <div class="pill warn pulse-badge"><span class="dot"></span><span class="pill-ic">!</span><span>Missing files</span></div>
41
  </div>
42
- <div class="box hover-lift">
43
- <div class="k">This Space is missing required files.</div>
44
- <div class="sub">Upload these to <span class="mono">/assets</span> (recommended) or repo root:</div>
45
- <div class="v mono" style="font-weight:700; font-size:12px; line-height:1.6;">
46
- validation_data.csv<br/>
47
- Cochlear_Implant_Dataset.csv<br/>
48
- ci_success_classifier.pkl<br/>
49
- ci_speech_score_regressor.pkl
50
- </div>
51
  </div>
52
  </div>
53
  """
54
 
55
- APP_READY = all(map(_require, [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]))
56
 
57
  # =========================
58
- # Load data + models (guarded)
59
  # =========================
 
 
 
 
 
 
 
60
  if APP_READY:
61
- val_df = pd.read_csv(VAL_CSV_PATH)
62
  main_df = pd.read_csv(MAIN_CSV_PATH)
63
- else:
64
- val_df = pd.DataFrame()
65
- main_df = pd.DataFrame()
66
 
67
- def load_model(path: Path):
68
- try:
69
- return joblib.load(path)
70
- except Exception:
71
- with open(path, "rb") as f:
72
- return pickle.load(f)
 
 
 
73
 
74
- clf_model = load_model(CLF_PKL_PATH) if APP_READY else None
75
- reg_model = load_model(REG_PKL_PATH) if APP_READY else None
76
 
77
  def get_model_feature_names(m):
 
 
78
  if hasattr(m, "feature_names_in_"):
79
  return list(getattr(m, "feature_names_in_"))
80
  if hasattr(m, "named_steps"):
@@ -83,21 +88,25 @@ def get_model_feature_names(m):
83
  return list(step.feature_names_in_)
84
  return None
85
 
86
- clf_expected = get_model_feature_names(clf_model) or [] if APP_READY else []
87
- reg_expected = get_model_feature_names(reg_model) or [] if APP_READY else []
88
 
 
 
 
 
89
  input_cols = []
90
  for colset in [clf_expected, reg_expected]:
91
  for c in colset:
92
  if c not in input_cols:
93
  input_cols.append(c)
94
- if not input_cols:
95
- input_cols = list(val_df.columns) if APP_READY else []
96
 
97
  # =========================
98
- # Build Gene dropdown choices
99
  # =========================
100
  def find_gene_column(df: pd.DataFrame):
 
 
101
  if "Gene" in df.columns:
102
  return "Gene"
103
  for c in df.columns:
@@ -105,6 +114,7 @@ def find_gene_column(df: pd.DataFrame):
105
  return c
106
  return None
107
 
 
108
  def normalize_str_series(s: pd.Series) -> pd.Series:
109
  return (
110
  s.astype(str)
@@ -113,6 +123,7 @@ def normalize_str_series(s: pd.Series) -> pd.Series:
113
  "": np.nan, "nan": np.nan, "NaN": np.nan})
114
  )
115
 
 
116
  gene_choices = []
117
  if APP_READY:
118
  gene_col_main = find_gene_column(main_df)
@@ -127,6 +138,12 @@ if APP_READY:
127
  # Helpers
128
  # =========================
129
  def parse_age_to_years(age_raw: str, mode: str):
 
 
 
 
 
 
130
  if age_raw is None:
131
  return np.nan
132
 
@@ -142,6 +159,7 @@ def parse_age_to_years(age_raw: str, mode: str):
142
  except:
143
  return np.nan
144
 
 
145
  if cleaned.count(".") == 1:
146
  a, b = cleaned.split(".")
147
  if a.isdigit() and b.isdigit() and len(b) == 2:
@@ -149,6 +167,7 @@ def parse_age_to_years(age_raw: str, mode: str):
149
  months = int(b)
150
  if 0 <= months <= 11:
151
  return years + months / 12.0
 
152
  try:
153
  return float(cleaned)
154
  except:
@@ -159,12 +178,14 @@ def parse_age_to_years(age_raw: str, mode: str):
159
  except:
160
  return np.nan
161
 
 
162
  def safe_pct(x):
163
  try:
164
  return int(round(float(x) * 100))
165
  except:
166
  return None
167
 
 
168
  def get_gene_feature_name(cols):
169
  for c in cols:
170
  if c.lower() == "gene":
@@ -174,12 +195,15 @@ def get_gene_feature_name(cols):
174
  return c
175
  return None
176
 
 
177
  def get_age_feature_names(cols):
178
  return [c for c in cols if "age" in c.lower()]
179
 
 
180
  GENE_FEAT = get_gene_feature_name(input_cols) if APP_READY else None
181
  AGE_FEATS = get_age_feature_names(input_cols) if APP_READY else []
182
 
 
183
  def align_to_expected(df: pd.DataFrame, expected_cols):
184
  if not expected_cols:
185
  return df
@@ -189,22 +213,23 @@ def align_to_expected(df: pd.DataFrame, expected_cols):
189
  out[c] = np.nan
190
  return out[expected_cols]
191
 
192
- # =========================
193
- # UI rendering (HTML cards)
194
- # =========================
195
  def render_single_result_html(gene, age_entered, age_used_years, parse_mode, label, prob, speech):
196
  if label == 1:
197
  status = "Likely Success"
198
  badge = "ok"
199
  icon = "βœ“"
 
200
  elif label == 0:
201
  status = "Lower Likelihood"
202
  badge = "warn"
203
  icon = "!"
 
204
  else:
205
  status = "Unavailable"
206
  badge = "neutral"
207
  icon = "?"
 
208
 
209
  prob_pct = safe_pct(prob) if prob is not None else None
210
  prob_text = f"{prob_pct}%" if prob_pct is not None else "β€”"
@@ -219,70 +244,94 @@ def render_single_result_html(gene, age_entered, age_used_years, parse_mode, lab
219
  gene_disp = str(gene) if gene is not None else "β€”"
220
 
221
  return f"""
222
- <div class="result-card fade-in">
223
- <div class="result-head">
224
- <div class="result-title">
225
- <div class="title-icon">🎯</div>
226
- <div>Prediction Results</div>
227
- </div>
228
- <div class="pill {badge} pulse-badge">
229
- <span class="dot"></span>
230
- <span class="pill-ic">{icon}</span>
231
- <span>{status}</span>
232
  </div>
 
233
  </div>
234
 
235
- <div class="grid2">
236
- <div class="box hover-lift">
237
- <div class="box-icon">🧬</div>
238
- <div class="k">Gene</div>
239
- <div class="v mono">{gene_disp}</div>
240
  </div>
241
- <div class="box hover-lift">
242
- <div class="box-icon">πŸ“…</div>
243
- <div class="k">Age entered</div>
244
- <div class="v mono">{age_entered}</div>
 
 
 
 
 
 
 
 
 
 
245
  </div>
246
  </div>
247
 
248
- <div class="box hover-lift" style="margin-top:12px;">
249
- <div class="box-icon">βš™οΈ</div>
250
- <div class="k">Age used by model</div>
251
- <div class="v mono">{age_used_disp}</div>
252
- <div class="sub">Parsing mode: <span class="mono">{parse_mode}</span></div>
253
- </div>
254
-
255
- <div class="box hover-lift" style="margin-top:12px;">
256
- <div class="box-icon">πŸ“Š</div>
257
- <div class="k">Success probability (Class 1)</div>
258
- <div class="prob-row">
259
- <div class="prob-bar"><div class="prob-fill animate-bar" style="width:{bar_width};"></div></div>
260
- <div class="prob-txt mono">{prob_text}</div>
 
261
  </div>
262
  </div>
263
 
264
- <div class="grid2" style="margin-top:12px;">
265
- <div class="box hover-lift">
266
- <div class="box-icon">🏷️</div>
267
- <div class="k">Predicted label</div>
268
- <div class="v mono">{label}</div>
 
 
 
 
 
 
269
  </div>
270
- <div class="box hover-lift">
271
- <div class="box-icon">πŸ’¬</div>
272
- <div class="k">Predicted speech score</div>
273
- <div class="v mono">{speech_disp}</div>
 
 
 
 
 
 
274
  </div>
275
  </div>
276
 
277
- <div class="fine">
278
- <span class="info-icon">ℹ️</span> Informational tool only. Not medical advice.
 
 
 
279
  </div>
280
  </div>
281
  """
282
 
 
283
  def predict_single(gene, age_text, parse_mode):
284
  if not APP_READY:
285
- return _missing_assets_html()
286
 
287
  if gene is None or str(gene).strip() == "":
288
  raise gr.Error("Please select a Gene.")
@@ -314,24 +363,34 @@ def predict_single(gene, age_text, parse_mode):
314
  speech = reg_model.predict(Xr)[0]
315
  return render_single_result_html(gene, age_text, age_used, parse_mode, label, prob, speech)
316
 
 
317
  def _file_to_path(file_obj):
 
 
 
 
 
 
 
318
  if file_obj is None:
319
  return None
320
  if isinstance(file_obj, str):
321
  return file_obj
322
- if hasattr(file_obj, "path"): # gradio 4
323
  return file_obj.path
324
- if hasattr(file_obj, "name"):
325
  return file_obj.name
326
- if isinstance(file_obj, dict) and "name" in file_obj:
327
- return file_obj["name"]
328
- if isinstance(file_obj, dict) and "path" in file_obj:
329
- return file_obj["path"]
 
330
  return None
331
 
 
332
  def predict_batch(csv_file, parse_mode):
333
  if not APP_READY:
334
- raise gr.Error("Missing required model/dataset files. Upload them to /assets or repo root.")
335
 
336
  path = _file_to_path(csv_file)
337
  if not path:
@@ -411,570 +470,1154 @@ def predict_batch(csv_file, parse_mode):
411
  pass
412
 
413
  summary = f"""
414
- <div class="result-card fade-in">
415
- <div class="result-head">
416
- <div class="result-title">
417
- <div class="title-icon">πŸ“ˆ</div>
418
- <div>Batch Summary</div>
419
- </div>
420
- <div class="pill neutral pulse-badge">
421
- <span class="dot"></span>
422
- <span class="pill-ic">β†―</span>
423
- <span>{n} rows</span>
424
  </div>
 
425
  </div>
426
- <div class="grid3">
427
- <div class="box hover-lift">
428
- <div class="box-icon">βœ“</div>
429
- <div class="k">Predicted success</div>
430
- <div class="v mono stat-number">{succ}</div>
431
- </div>
432
- <div class="box hover-lift">
433
- <div class="box-icon">%</div>
434
- <div class="k">Success rate</div>
435
- <div class="v mono stat-number">{succ_pct}%</div>
436
  </div>
437
- <div class="box hover-lift">
438
- <div class="box-icon">πŸ“Š</div>
439
- <div class="k">Avg prob (Class 1)</div>
440
- <div class="v mono stat-number">{avg_prob_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  </div>
442
  </div>
443
- <div class="box hover-lift" style="margin-top:12px;">
444
- <div class="box-icon">πŸ’¬</div>
445
- <div class="k">Avg speech score</div>
446
- <div class="v mono stat-number">{avg_speech_txt}</div>
447
- <div class="sub">Parsing mode: <span class="mono">{parse_mode}</span></div>
448
- </div>
449
- <div class="fine">
450
- <span class="info-icon">πŸ“₯</span> Download the output CSV below for complete results.
451
  </div>
452
  </div>
453
  """
454
  return summary, out.head(20), str(BATCH_OUT_PATH)
455
 
 
456
  def age_preview(age_text, parse_mode):
457
  v = parse_age_to_years(age_text, parse_mode)
458
  if isinstance(v, (float, np.floating)) and np.isfinite(v):
459
- return f"<div class='hint fade-in'><span class='hint-icon'>πŸ’‘</span> Model will use: <span class='mono'><b>{v:.3f}</b> years</span></div>"
460
- return "<div class='hint'><span class='hint-icon'>⚠️</span> Model will use: <span class='mono'>β€”</span></div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
  # =========================
463
- # Enhanced CSS (your UI + fixes for HF light/dark + Gradio inputs)
 
464
  # =========================
465
- CSS = r"""
466
- /* ---------- Theme variables (Light default) ---------- */
 
 
467
  :root {
468
- --bg: #f0f4f8;
469
- --bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
470
- --card: #ffffff;
471
- --card-hover: #fefeff;
472
- --border: #e2e8f0;
473
- --text: #1a202c;
474
- --text-secondary: #4a5568;
475
- --muted: #718096;
476
- --accent: #667eea;
477
- --accent-hover: #5568d3;
478
- --ok: #48bb78;
479
- --warn: #ed8936;
480
- --shadow: 0 20px 60px rgba(102, 126, 234, 0.15);
481
- --shadow-hover: 0 30px 80px rgba(102, 126, 234, 0.25);
482
- --radius: 20px;
483
- --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
484
- }
485
-
486
- /* ---------- Dark theme (opt-in via data-theme="dark") ---------- */
487
- html[data-theme="dark"] {
488
- --bg: #0f172a;
489
- --bg-gradient: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%);
490
- --card: #1e293b;
491
- --card-hover: #293548;
492
- --border: #334155;
493
- --text: #f1f5f9;
 
 
 
 
494
  --text-secondary: #cbd5e1;
495
- --muted: #94a3b8;
496
- --accent: #818cf8;
497
- --accent-hover: #6366f1;
498
- --shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
499
- --shadow-hover: 0 30px 80px rgba(0, 0, 0, 0.6);
 
500
  }
501
 
502
- /* ---------- Base ---------- */
503
- * { transition: background-color .25s ease, color .25s ease, border-color .25s ease; }
 
 
 
 
504
 
505
  .gradio-container {
506
- background: var(--bg) !important;
507
- color: var(--text) !important;
508
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
509
  }
510
 
511
  /* Hide Gradio footer */
512
- footer, .footer, #footer, .gradio-footer { display:none !important; height:0 !important; }
513
-
514
- /* Page wrapper */
515
- #wrap { max-width: 1200px; margin: 0 auto; padding: 20px 16px 40px; }
516
- .gr-row { flex-wrap: wrap !important; gap: 16px !important; }
517
- .gr-column { min-width: 320px; }
518
-
519
- /* ---------- Theme Toggle Button ---------- */
520
- #theme-toggle {
521
- position: fixed;
522
- top: 20px;
523
- right: 20px;
524
- z-index: 1000;
525
- width: 56px;
526
- height: 56px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  border-radius: 50%;
528
- background: var(--card);
529
- border: 2px solid var(--border);
530
- box-shadow: var(--shadow);
531
- cursor: pointer;
 
 
 
 
 
532
  display: flex;
 
533
  align-items: center;
534
- justify-content: center;
535
- font-size: 22px;
536
- transition: var(--transition);
537
- }
538
- #theme-toggle:hover { transform: scale(1.06) rotate(8deg); box-shadow: var(--shadow-hover); }
539
-
540
- /* ---------- Hero ---------- */
541
- .hero {
542
- padding: 40px 32px;
543
- border-radius: var(--radius);
544
- border: 2px solid var(--border);
545
- background: var(--card);
546
- box-shadow: var(--shadow);
547
  margin-bottom: 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  position: relative;
549
  overflow: hidden;
550
- animation: fade-in .6s ease-out;
551
  }
552
- .hero::before {
553
- content:'';
554
- position:absolute; top:0; left:0; right:0;
555
- height:6px; background: var(--bg-gradient);
 
 
 
 
 
 
556
  }
557
- .hero h1 {
558
- margin:0;
559
- font-size: 32px;
560
- font-weight: 900;
561
- letter-spacing: -0.5px;
562
- background: var(--bg-gradient);
563
- -webkit-background-clip:text;
564
- -webkit-text-fill-color:transparent;
565
- background-clip:text;
566
- }
567
- .hero p { margin:12px 0 0; color: var(--muted); font-size: 16px; line-height: 1.6; }
568
-
569
- /* ---------- Cards ---------- */
570
- .card {
571
- background: var(--card);
572
- border: 2px solid var(--border);
573
- border-radius: var(--radius);
574
- box-shadow: var(--shadow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  padding: 24px;
576
- transition: var(--transition);
577
- animation: fade-in .6s ease-out;
578
  }
579
- .card:hover { box-shadow: var(--shadow-hover); border-color: var(--accent); }
580
 
581
- .mono {
582
- font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  font-weight: 600;
 
584
  }
585
 
586
- /* ---------- Result Cards ---------- */
587
- .result-card {
588
- background: var(--card);
589
- border: 2px solid var(--border);
590
- border-radius: var(--radius);
 
 
 
 
 
 
 
 
 
591
  padding: 24px;
592
- box-shadow: var(--shadow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  position: relative;
594
  overflow: hidden;
 
 
 
 
 
595
  }
596
- .result-card::before {
597
- content:'';
598
- position:absolute; top:0; left:0; right:0;
599
- height:4px; background: var(--bg-gradient);
 
 
 
600
  }
601
- .result-head {
602
- display:flex; align-items:center; justify-content:space-between;
603
- gap:12px; margin-bottom: 20px; flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
604
  }
605
- .result-title {
606
- display:flex; align-items:center; gap:12px;
607
- font-size: 18px; font-weight: 900; letter-spacing: .3px;
 
 
 
 
 
608
  }
609
- .title-icon { font-size: 24px; }
610
 
611
- .grid2 { display:grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; }
612
- .grid3 { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
 
 
 
 
 
613
 
614
- .box {
615
- border: 2px solid var(--border);
616
- background: var(--card);
617
- border-radius: 16px;
618
  padding: 20px;
619
- transition: var(--transition);
 
 
620
  }
621
- .box:hover { background: var(--card-hover); border-color: var(--accent); }
622
- .hover-lift:hover { transform: translateY(-4px); box-shadow: var(--shadow); }
623
 
624
- .box-icon { font-size: 20px; margin-bottom: 8px; display:inline-block; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
 
626
- .k {
627
- color: var(--muted);
628
  font-size: 12px;
629
- font-weight: 700;
630
  text-transform: uppercase;
631
- letter-spacing: .5px;
 
 
632
  }
633
- .v { color: var(--text); font-size: 18px; font-weight: 900; margin-top: 6px; }
634
- .sub { margin-top: 8px; color: var(--muted); font-size: 12px; }
635
 
636
- .stat-number {
637
- background: var(--bg-gradient);
638
- -webkit-background-clip:text;
639
- -webkit-text-fill-color:transparent;
640
- background-clip:text;
641
  }
642
 
643
- /* ---------- Pills ---------- */
644
- .pill {
645
- display:flex; align-items:center; gap: 8px;
646
- padding: 10px 16px;
647
- border-radius: 999px;
648
- border: 2px solid var(--border);
649
- background: rgba(255,255,255,.02);
 
 
 
650
  font-size: 13px;
651
- font-weight: 700;
652
- white-space: nowrap;
653
- }
654
- .pill .dot {
655
- width: 12px; height: 12px; border-radius: 999px;
656
- background: var(--muted);
657
- }
658
- .pill.ok { border-color: var(--ok); background: rgba(72,187,120,.10); }
659
- .pill.ok .dot { background: var(--ok); box-shadow: 0 0 20px rgba(72,187,120,.4); }
660
- .pill.warn { border-color: var(--warn); background: rgba(237,137,54,.10); }
661
- .pill.warn .dot { background: var(--warn); box-shadow: 0 0 20px rgba(237,137,54,.4); }
662
- .pill.neutral { border-color: var(--accent); background: rgba(102,126,234,.10); }
663
- .pill.neutral .dot { background: var(--accent); box-shadow: 0 0 20px rgba(102,126,234,.4); }
664
- .pill-ic { font-weight: 900; font-size: 16px; }
665
-
666
- /* ---------- Progress bar ---------- */
667
- .prob-row { display:flex; align-items:center; gap: 12px; margin-top: 12px; }
668
- .prob-bar {
669
- flex:1; height: 14px; border-radius: 999px;
670
- background: var(--border);
671
- overflow:hidden;
672
- }
673
- .prob-fill { height:100%; background: var(--bg-gradient); border-radius: 999px; }
674
- .animate-bar { animation: fill-bar 1s ease-out; }
675
- @keyframes fill-bar { from { width: 0%; } }
676
-
677
- .prob-txt { width: 60px; text-align:right; color: var(--text); font-weight: 900; font-size: 16px; }
678
-
679
- /* ---------- Info + Hint ---------- */
680
- .fine {
681
- margin-top: 20px;
682
- font-size: 12px;
683
- color: var(--muted);
684
  line-height: 1.5;
685
- display:flex; align-items:center; gap: 8px;
686
- padding: 12px;
687
- background: rgba(102,126,234,.05);
688
- border-radius: 12px;
689
- border: 1px dashed var(--border);
690
  }
691
- .hint {
692
- margin-top: 12px;
693
- font-size: 13px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  color: var(--text-secondary);
695
- padding: 16px;
696
- border: 2px dashed var(--accent);
697
- border-radius: 14px;
698
- background: rgba(102,126,234,.05);
699
- display:flex; align-items:center; gap: 10px;
700
- }
701
-
702
- /* ---------- Buttons ---------- */
703
- #primaryBtn button {
704
- border-radius: 16px !important;
705
- border: 2px solid var(--accent) !important;
706
- background: var(--bg-gradient) !important;
707
- color: #fff !important;
708
- font-weight: 900 !important;
709
- font-size: 16px !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  padding: 16px 32px !important;
711
- box-shadow: 0 10px 30px rgba(102,126,234,.30) !important;
712
- }
713
-
714
- /* ============================================================
715
- FIXES: Gradio inputs (dropdown/textbox/radio/file/table)
716
- These solve the HF light/dark mismatches you showed.
717
- ============================================================ */
718
-
719
- /* Labels: never washed out */
720
- .gradio-container label,
721
- .gradio-container label span,
722
- .gradio-container .label,
723
- .gradio-container .wrap label {
724
- color: var(--text) !important;
725
- font-weight: 800 !important;
726
- }
727
-
728
- /* Textbox border + background */
729
- .gradio-container input[type="text"],
730
- .gradio-container textarea,
731
- .gradio-container .gr-textbox input,
732
- .gradio-container .gr-textbox textarea {
733
- background: var(--card) !important;
734
- color: var(--text) !important;
735
- border: 2px solid var(--border) !important;
736
- border-radius: 14px !important;
737
- box-shadow: none !important;
738
- }
739
- .gradio-container input[type="text"]:focus,
740
- .gradio-container textarea:focus {
741
- outline: none !important;
742
- border-color: var(--accent) !important;
743
- box-shadow: 0 0 0 3px rgba(102,126,234,.18) !important;
744
- }
745
-
746
- /* Dropdown: remove weird inner "white chip", enforce border */
747
- .gradio-container select,
748
- .gradio-container .gr-dropdown,
749
- .gradio-container .gr-dropdown > div,
750
- .gradio-container div[role="combobox"] {
751
- background: var(--card) !important;
752
- color: var(--text) !important;
753
- }
754
- .gradio-container .gr-dropdown,
755
- .gradio-container div[role="combobox"] {
756
- border: 2px solid var(--border) !important;
757
- border-radius: 14px !important;
758
- }
759
- .gradio-container .gr-dropdown * {
760
- background: transparent !important; /* kills the extra inner white container */
761
- color: var(--text) !important;
762
- }
763
- .gradio-container .gr-dropdown:hover,
764
- .gradio-container div[role="combobox"]:hover {
765
- border-color: var(--accent) !important;
766
- }
767
-
768
- /* Radio: ensure dot shows + selection looks clean */
769
- .gradio-container input[type="radio"] {
770
- accent-color: var(--accent) !important;
771
- width: 16px !important;
772
- height: 16px !important;
773
- }
774
- .gradio-container .gr-radio,
775
- .gradio-container .gr-radio * {
776
- color: var(--text) !important;
777
- }
778
- .gradio-container .gr-radio label {
779
- background: rgba(102,126,234,.06) !important;
780
- border: 2px solid var(--border) !important;
781
- border-radius: 14px !important;
782
- padding: 10px 12px !important;
783
- }
784
- .gradio-container .gr-radio label:has(input:checked) {
785
- border-color: var(--accent) !important;
786
- box-shadow: 0 0 0 3px rgba(102,126,234,.16) !important;
787
- }
788
-
789
- /* File upload area styling (fixes batch tab "miscolored/off") */
790
- .gradio-container .gr-file,
791
- .gradio-container .gr-file * {
792
- color: var(--text) !important;
793
- }
794
- .gradio-container .gr-file .wrap,
795
- .gradio-container .gr-file .upload,
796
- .gradio-container .gr-file .container,
797
- .gradio-container .gr-file .dropzone {
798
- background: var(--card) !important;
799
- border: 2px dashed var(--border) !important;
800
- border-radius: 16px !important;
801
- }
802
- .gradio-container .gr-file .dropzone:hover {
803
- border-color: var(--accent) !important;
804
- }
805
-
806
- /* Dataframe preview styling */
807
- .gradio-container .gr-dataframe,
808
- .gradio-container .gr-dataframe * {
809
- color: var(--text) !important;
810
- }
811
- .gradio-container .gr-dataframe table {
812
- background: var(--card) !important;
813
- border-collapse: separate !important;
814
- border-spacing: 0 !important;
815
- }
816
- .gradio-container .gr-dataframe thead th {
817
- background: rgba(102,126,234,.08) !important;
818
- border-bottom: 2px solid var(--border) !important;
819
- }
820
- .gradio-container .gr-dataframe tbody td {
821
- border-bottom: 1px solid var(--border) !important;
822
- }
823
-
824
- /* Tabs: make selected tab readable in both themes */
825
- .gradio-container .tab-nav button.selected {
826
- background: var(--bg-gradient) !important;
827
- color: #fff !important;
828
- border-color: var(--accent) !important;
829
- }
830
-
831
- /* Responsive */
832
  @media (max-width: 768px) {
833
- #theme-toggle { top: 10px; right: 10px; width: 48px; height: 48px; font-size: 18px; }
834
- .hero h1 { font-size: 24px; }
835
- .hero p { font-size: 14px; }
836
- #primaryBtn button { width: 100% !important; padding: 14px 24px !important; }
837
- .grid2, .grid3 { grid-template-columns: 1fr; }
838
- .result-head { flex-direction: column; align-items: flex-start; }
839
- .gr-column { min-width: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  }
841
 
842
- /* Fade-in */
843
- @keyframes fade-in {
844
- from { opacity: 0; transform: translateY(20px); }
845
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
 
 
 
846
  }
847
- .fade-in { animation: fade-in .6s ease-out; }
848
  """
849
 
850
- theme = gr.themes.Base(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  primary_hue="blue",
852
  secondary_hue="purple",
853
  neutral_hue="slate",
854
  radius_size="lg",
855
- font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
856
  )
857
 
858
- # =========================
859
- # UI
860
- # =========================
861
- with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
862
- with gr.Column(elem_id="wrap"):
863
-
864
- # Theme toggle + script (fixes HF dark mode overrides cleanly)
865
- gr.HTML("""
866
- <button id="theme-toggle" title="Toggle theme">πŸŒ™</button>
867
- <script>
868
- (function(){
869
- const root = document.documentElement;
870
- const btn = document.getElementById("theme-toggle");
871
-
872
- function setTheme(t){
873
- root.setAttribute("data-theme", t);
874
- localStorage.setItem("ci_theme", t);
875
- btn.textContent = (t === "dark") ? "β˜€οΈ" : "πŸŒ™";
876
- }
877
-
878
- const saved = localStorage.getItem("ci_theme");
879
- if(saved){
880
- setTheme(saved);
881
- } else {
882
- const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
883
- setTheme(prefersDark ? "dark" : "light");
884
- }
885
-
886
- btn.addEventListener("click", () => {
887
- const cur = root.getAttribute("data-theme") || "light";
888
- setTheme(cur === "dark" ? "light" : "dark");
889
- });
890
- })();
891
- </script>
892
- """)
893
-
894
- gr.HTML(f"""
895
- <div class="hero">
896
- <h1>CI Outcome Predictor</h1>
897
- <p>Single and batch predictions. Age parsing is shown transparently.</p>
898
  </div>
899
- """)
900
-
901
- with gr.Tabs():
902
- with gr.Tab("Single Prediction"):
903
- with gr.Row():
904
- with gr.Column(scale=1):
905
- with gr.Group(elem_classes=["card"]):
906
- gene_in = gr.Dropdown(
907
- choices=gene_choices,
908
- value=gene_choices[0] if gene_choices else None,
909
- label="Gene",
910
- filterable=True,
911
- )
912
- age_in = gr.Textbox(
913
- label="Age",
914
- placeholder="Examples: 1.11 | 1.6YRS | 2.3"
915
- )
916
- parse_mode = gr.Radio(
917
- choices=[
918
- "Decimal (1.11 = 1.11 years)",
919
- "Years.Months (1.11 = 1y 11m)"
920
- ],
921
- value="Decimal (1.11 = 1.11 years)",
922
- label="Age parsing"
923
- )
924
-
925
- age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)"))
926
- btn = gr.Button("Run Prediction", elem_id="primaryBtn")
927
-
928
- with gr.Column(scale=1):
929
- single_out = gr.HTML(
930
- value=_missing_assets_html() if not APP_READY else "",
931
- elem_classes=["card"]
932
- )
933
-
934
- age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
935
- parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
936
-
937
- btn.click(
938
- fn=predict_single,
939
- inputs=[gene_in, age_in, parse_mode],
940
- outputs=[single_out]
941
- )
942
-
943
- with gr.Tab("Batch Prediction (CSV)"):
944
- with gr.Group(elem_classes=["card"]):
945
- gr.Markdown(
946
- "**Minimum required columns:** `Gene`, `Age`",
947
- elem_classes=["mono"]
948
  )
949
-
950
- parse_mode_b = gr.Radio(
 
 
 
 
951
  choices=[
952
  "Decimal (1.11 = 1.11 years)",
953
  "Years.Months (1.11 = 1y 11m)"
954
  ],
955
  value="Decimal (1.11 = 1.11 years)",
956
- label="Age parsing"
 
 
 
 
 
 
 
 
 
 
 
957
  )
958
 
959
- csv_in = gr.File(file_types=[".csv"], label="Upload CSV")
960
- run_b = gr.Button("Run Batch Prediction", elem_id="primaryBtn")
961
 
962
- batch_summary = gr.HTML(value="")
963
- preview = gr.Dataframe(label="Preview (first 20 rows)", wrap=True)
964
- out_file = gr.File(label="Download predictions_output.csv")
965
 
966
- run_b.click(
967
- fn=predict_batch,
968
- inputs=[csv_in, parse_mode_b],
969
- outputs=[batch_summary, preview, out_file]
970
- )
971
 
972
- # HF Spaces: do NOT use share=True. Also keep logs quiet.
973
- IS_SPACES = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE") or os.getenv("SYSTEM") == "spaces")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
974
 
975
  if __name__ == "__main__":
976
- demo.queue().launch(
977
- share=(not IS_SPACES),
978
- show_error=False,
979
- quiet=True
980
- )
 
1
  # -*- coding: utf-8 -*-
2
+ """Enhanced CI Outcome Predictor with Advanced UI (HF Spaces Ready)"""
3
 
4
  import os
5
  import re
 
11
  from pathlib import Path
12
 
13
  # =========================
14
+ # PATHS (Hugging Face Spaces / repo root)
15
+ # Place these files in the SAME folder as app.py:
16
+ # - validation_data.csv
17
+ # - Cochlear_Implant_Dataset.csv
18
+ # - ci_success_classifier.pkl
19
+ # - ci_speech_score_regressor.pkl
20
  # =========================
21
  BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
 
 
 
22
 
23
+ VAL_CSV_PATH = BASE_DIR / "validation_data.csv"
24
+ MAIN_CSV_PATH = BASE_DIR / "Cochlear_Implant_Dataset.csv"
25
+ CLF_PKL_PATH = BASE_DIR / "ci_success_classifier.pkl"
26
+ REG_PKL_PATH = BASE_DIR / "ci_speech_score_regressor.pkl"
27
 
28
+ # Writable location on HF Spaces
29
  BATCH_OUT_PATH = Path("/tmp/predictions_output.csv")
30
 
 
 
31
 
32
+ def _require(p: Path) -> bool:
33
+ return p.exists() and p.is_file()
34
+
35
+
36
+ def _missing_files_message():
37
+ missing = []
38
+ for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]:
39
+ if not _require(p):
40
+ missing.append(p.name)
41
+
42
  return f"""
43
+ <div class="info-box">
44
+ <strong>⚠️ Setup required:</strong> Missing required files in repo root:<br/>
45
+ <div style="margin-top:8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;">
46
+ {"<br/>".join(missing) if missing else "β€”"}
 
 
 
47
  </div>
48
+ <div style="margin-top:10px; opacity:0.9;">
49
+ Upload the files to the <b>same folder as app.py</b> and restart the Space.
 
 
 
 
 
 
 
50
  </div>
51
  </div>
52
  """
53
 
 
54
 
55
  # =========================
56
+ # Load data + models (guarded so Space can boot even if files missing)
57
  # =========================
58
+ APP_READY = all(_require(p) for p in [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH])
59
+
60
+ val_df = pd.DataFrame()
61
+ main_df = pd.DataFrame()
62
+ clf_model = None
63
+ reg_model = None
64
+
65
  if APP_READY:
66
+ val_df = pd.read_csv(VAL_CSV_PATH)
67
  main_df = pd.read_csv(MAIN_CSV_PATH)
 
 
 
68
 
69
+ def load_model(path: Path):
70
+ try:
71
+ return joblib.load(path)
72
+ except Exception:
73
+ with open(path, "rb") as f:
74
+ return pickle.load(f)
75
+
76
+ clf_model = load_model(CLF_PKL_PATH)
77
+ reg_model = load_model(REG_PKL_PATH)
78
 
 
 
79
 
80
  def get_model_feature_names(m):
81
+ if m is None:
82
+ return None
83
  if hasattr(m, "feature_names_in_"):
84
  return list(getattr(m, "feature_names_in_"))
85
  if hasattr(m, "named_steps"):
 
88
  return list(step.feature_names_in_)
89
  return None
90
 
 
 
91
 
92
+ clf_expected = get_model_feature_names(clf_model) or []
93
+ reg_expected = get_model_feature_names(reg_model) or []
94
+
95
+ # Union of expected columns (preserve order)
96
  input_cols = []
97
  for colset in [clf_expected, reg_expected]:
98
  for c in colset:
99
  if c not in input_cols:
100
  input_cols.append(c)
101
+ if not input_cols and APP_READY:
102
+ input_cols = list(val_df.columns)
103
 
104
  # =========================
105
+ # Build Gene dropdown choices from MAIN dataset
106
  # =========================
107
  def find_gene_column(df: pd.DataFrame):
108
+ if df is None or df.empty:
109
+ return None
110
  if "Gene" in df.columns:
111
  return "Gene"
112
  for c in df.columns:
 
114
  return c
115
  return None
116
 
117
+
118
  def normalize_str_series(s: pd.Series) -> pd.Series:
119
  return (
120
  s.astype(str)
 
123
  "": np.nan, "nan": np.nan, "NaN": np.nan})
124
  )
125
 
126
+
127
  gene_choices = []
128
  if APP_READY:
129
  gene_col_main = find_gene_column(main_df)
 
138
  # Helpers
139
  # =========================
140
  def parse_age_to_years(age_raw: str, mode: str):
141
+ """
142
+ mode:
143
+ - "Years.Months (1.11 = 1y 11m)" -> 1 + 11/12
144
+ - "Decimal (1.11 = 1.11 years)" -> 1.11
145
+ Accepts "1.6YRS", "2yrs", etc.
146
+ """
147
  if age_raw is None:
148
  return np.nan
149
 
 
159
  except:
160
  return np.nan
161
 
162
+ # Years.Months mode
163
  if cleaned.count(".") == 1:
164
  a, b = cleaned.split(".")
165
  if a.isdigit() and b.isdigit() and len(b) == 2:
 
167
  months = int(b)
168
  if 0 <= months <= 11:
169
  return years + months / 12.0
170
+ # fallback to decimal
171
  try:
172
  return float(cleaned)
173
  except:
 
178
  except:
179
  return np.nan
180
 
181
+
182
  def safe_pct(x):
183
  try:
184
  return int(round(float(x) * 100))
185
  except:
186
  return None
187
 
188
+
189
  def get_gene_feature_name(cols):
190
  for c in cols:
191
  if c.lower() == "gene":
 
195
  return c
196
  return None
197
 
198
+
199
  def get_age_feature_names(cols):
200
  return [c for c in cols if "age" in c.lower()]
201
 
202
+
203
  GENE_FEAT = get_gene_feature_name(input_cols) if APP_READY else None
204
  AGE_FEATS = get_age_feature_names(input_cols) if APP_READY else []
205
 
206
+
207
  def align_to_expected(df: pd.DataFrame, expected_cols):
208
  if not expected_cols:
209
  return df
 
213
  out[c] = np.nan
214
  return out[expected_cols]
215
 
216
+
 
 
217
  def render_single_result_html(gene, age_entered, age_used_years, parse_mode, label, prob, speech):
218
  if label == 1:
219
  status = "Likely Success"
220
  badge = "ok"
221
  icon = "βœ“"
222
+ emoji = "πŸŽ‰"
223
  elif label == 0:
224
  status = "Lower Likelihood"
225
  badge = "warn"
226
  icon = "!"
227
+ emoji = "⚠️"
228
  else:
229
  status = "Unavailable"
230
  badge = "neutral"
231
  icon = "?"
232
+ emoji = "❓"
233
 
234
  prob_pct = safe_pct(prob) if prob is not None else None
235
  prob_text = f"{prob_pct}%" if prob_pct is not None else "β€”"
 
244
  gene_disp = str(gene) if gene is not None else "β€”"
245
 
246
  return f"""
247
+ <div class="result-card fade-in-up">
248
+ <div class="result-banner {badge}">
249
+ <div class="banner-emoji">{emoji}</div>
250
+ <div class="banner-text">
251
+ <div class="banner-title">{status}</div>
252
+ <div class="banner-sub">Prediction Complete</div>
 
 
 
 
253
  </div>
254
+ <div class="pulse-dot"></div>
255
  </div>
256
 
257
+ <div class="metrics-grid">
258
+ <div class="metric-card glass-effect hover-lift">
259
+ <div class="metric-icon">🧬</div>
260
+ <div class="metric-label">Gene</div>
261
+ <div class="metric-value">{gene_disp}</div>
262
  </div>
263
+ <div class="metric-card glass-effect hover-lift">
264
+ <div class="metric-icon">πŸ“…</div>
265
+ <div class="metric-label">Age Entered</div>
266
+ <div class="metric-value">{age_entered}</div>
267
+ </div>
268
+ <div class="metric-card glass-effect hover-lift">
269
+ <div class="metric-icon">πŸ”¬</div>
270
+ <div class="metric-label">Model Age</div>
271
+ <div class="metric-value">{age_used_disp}</div>
272
+ </div>
273
+ <div class="metric-card glass-effect hover-lift">
274
+ <div class="metric-icon">πŸ“Š</div>
275
+ <div class="metric-label">Parse Mode</div>
276
+ <div class="metric-value small">{parse_mode.split('(')[0].strip()}</div>
277
  </div>
278
  </div>
279
 
280
+ <div class="probability-section glass-effect">
281
+ <div class="prob-header">
282
+ <span class="prob-title">Success Probability</span>
283
+ <span class="prob-value-large">{prob_text}</span>
284
+ </div>
285
+ <div class="prob-bar-container">
286
+ <div class="prob-bar-track">
287
+ <div class="prob-bar-fill {badge}" style="width:{bar_width};">
288
+ <div class="shimmer"></div>
289
+ </div>
290
+ </div>
291
+ <div class="prob-markers">
292
+ <span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
293
+ </div>
294
  </div>
295
  </div>
296
 
297
+ <div class="details-grid">
298
+ <div class="detail-box glass-effect hover-lift">
299
+ <div class="detail-icon {badge}">
300
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
301
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
302
+ </svg>
303
+ </div>
304
+ <div class="detail-content">
305
+ <div class="detail-label">Predicted Label</div>
306
+ <div class="detail-value">{label}</div>
307
+ </div>
308
  </div>
309
+ <div class="detail-box glass-effect hover-lift">
310
+ <div class="detail-icon {badge}">
311
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
312
+ <path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
313
+ </svg>
314
+ </div>
315
+ <div class="detail-content">
316
+ <div class="detail-label">Speech Score</div>
317
+ <div class="detail-value">{speech_disp}</div>
318
+ </div>
319
  </div>
320
  </div>
321
 
322
+ <div class="disclaimer">
323
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
324
+ <circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
325
+ </svg>
326
+ <span>Informational tool only. Not medical advice. Consult healthcare professionals for clinical decisions.</span>
327
  </div>
328
  </div>
329
  """
330
 
331
+
332
  def predict_single(gene, age_text, parse_mode):
333
  if not APP_READY:
334
+ return _missing_files_message()
335
 
336
  if gene is None or str(gene).strip() == "":
337
  raise gr.Error("Please select a Gene.")
 
363
  speech = reg_model.predict(Xr)[0]
364
  return render_single_result_html(gene, age_text, age_used, parse_mode, label, prob, speech)
365
 
366
+
367
  def _file_to_path(file_obj):
368
+ """
369
+ Gradio v3/v4 compatibility:
370
+ - Sometimes a string path
371
+ - Sometimes object with .name
372
+ - In Gradio 4: often has .path
373
+ - Sometimes dict-like with 'name' or 'path'
374
+ """
375
  if file_obj is None:
376
  return None
377
  if isinstance(file_obj, str):
378
  return file_obj
379
+ if hasattr(file_obj, "path") and file_obj.path:
380
  return file_obj.path
381
+ if hasattr(file_obj, "name") and file_obj.name:
382
  return file_obj.name
383
+ if isinstance(file_obj, dict):
384
+ if file_obj.get("path"):
385
+ return file_obj["path"]
386
+ if file_obj.get("name"):
387
+ return file_obj["name"]
388
  return None
389
 
390
+
391
  def predict_batch(csv_file, parse_mode):
392
  if not APP_READY:
393
+ raise gr.Error("Missing required model/data files in repo root. Please upload them and restart the Space.")
394
 
395
  path = _file_to_path(csv_file)
396
  if not path:
 
470
  pass
471
 
472
  summary = f"""
473
+ <div class="batch-results fade-in-up">
474
+ <div class="batch-header">
475
+ <div class="batch-title">
476
+ <span class="batch-icon">πŸ“Š</span>
477
+ <span>Batch Analysis Complete</span>
 
 
 
 
 
478
  </div>
479
+ <div class="batch-count">{n} rows processed</div>
480
  </div>
481
+
482
+ <div class="stats-showcase">
483
+ <div class="stat-mega glass-effect hover-lift">
484
+ <div class="stat-mega-icon ok">βœ“</div>
485
+ <div class="stat-mega-content">
486
+ <div class="stat-mega-label">Predicted Success</div>
487
+ <div class="stat-mega-value">{succ}</div>
488
+ <div class="stat-mega-sub">{succ_pct}% of total</div>
489
+ </div>
 
490
  </div>
491
+
492
+ <div class="stats-grid">
493
+ <div class="stat-card glass-effect hover-lift">
494
+ <div class="stat-icon">🎯</div>
495
+ <div class="stat-label">Avg Probability</div>
496
+ <div class="stat-value">{avg_prob_txt}</div>
497
+ </div>
498
+ <div class="stat-card glass-effect hover-lift">
499
+ <div class="stat-icon">🎀</div>
500
+ <div class="stat-label">Avg Speech Score</div>
501
+ <div class="stat-value">{avg_speech_txt}</div>
502
+ </div>
503
+ <div class="stat-card glass-effect hover-lift">
504
+ <div class="stat-icon">βš™οΈ</div>
505
+ <div class="stat-label">Parse Mode</div>
506
+ <div class="stat-value small">{parse_mode.split('(')[0].strip()}</div>
507
+ </div>
508
  </div>
509
  </div>
510
+
511
+ <div class="download-prompt">
512
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
513
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
514
+ </svg>
515
+ <span>Download the complete results CSV below</span>
 
 
516
  </div>
517
  </div>
518
  """
519
  return summary, out.head(20), str(BATCH_OUT_PATH)
520
 
521
+
522
  def age_preview(age_text, parse_mode):
523
  v = parse_age_to_years(age_text, parse_mode)
524
  if isinstance(v, (float, np.floating)) and np.isfinite(v):
525
+ return f"""
526
+ <div class='age-preview-box fade-in'>
527
+ <div class="preview-icon">✨</div>
528
+ <div class="preview-content">
529
+ <div class="preview-label">Model will use</div>
530
+ <div class="preview-value">{v:.3f} years</div>
531
+ </div>
532
+ </div>
533
+ """
534
+ return """
535
+ <div class='age-preview-box fade-in'>
536
+ <div class="preview-icon">❌</div>
537
+ <div class="preview-content">
538
+ <div class="preview-label">Model will use</div>
539
+ <div class="preview-value">Invalid</div>
540
+ </div>
541
+ </div>
542
+ """
543
+
544
 
545
  # =========================
546
+ # CSS + JS (UNCHANGED from your provided script)
547
+ # =========================
548
  # =========================
549
+ # ENHANCED CSS WITH LIGHT/DARK MODE & ANIMATIONS
550
+ # =========================
551
+ CSS = """
552
+ /* ========== THEME VARIABLES ========== */
553
  :root {
554
+ --bg-primary: #f8fafc;
555
+ --bg-secondary: #ffffff;
556
+ --bg-tertiary: #f1f5f9;
557
+ --text-primary: #0f172a;
558
+ --text-secondary: #475569;
559
+ --text-muted: #94a3b8;
560
+ --border-color: #e2e8f0;
561
+ --accent-primary: #3b82f6;
562
+ --accent-secondary: #8b5cf6;
563
+ --success-color: #10b981;
564
+ --warning-color: #f59e0b;
565
+ --danger-color: #ef4444;
566
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.05);
567
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.08);
568
+ --shadow-lg: 0 10px 30px rgba(0,0,0,0.12);
569
+ --shadow-xl: 0 20px 50px rgba(0,0,0,0.15);
570
+ --radius-sm: 8px;
571
+ --radius-md: 12px;
572
+ --radius-lg: 16px;
573
+ --radius-xl: 24px;
574
+ --transition-fast: 0.15s ease;
575
+ --transition-normal: 0.3s ease;
576
+ --transition-slow: 0.5s ease;
577
+ }
578
+
579
+ [data-theme="dark"] {
580
+ --bg-primary: #0f172a;
581
+ --bg-secondary: #1e293b;
582
+ --bg-tertiary: #334155;
583
+ --text-primary: #f1f5f9;
584
  --text-secondary: #cbd5e1;
585
+ --text-muted: #64748b;
586
+ --border-color: #334155;
587
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
588
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.4);
589
+ --shadow-lg: 0 10px 30px rgba(0,0,0,0.5);
590
+ --shadow-xl: 0 20px 50px rgba(0,0,0,0.6);
591
  }
592
 
593
+ /* ========== BASE STYLES ========== */
594
+ * {
595
+ transition: background-color var(--transition-normal),
596
+ border-color var(--transition-normal),
597
+ color var(--transition-normal);
598
+ }
599
 
600
  .gradio-container {
601
+ background: var(--bg-primary) !important;
602
+ color: var(--text-primary) !important;
603
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
604
  }
605
 
606
  /* Hide Gradio footer */
607
+ footer, .footer, #footer, .gradio-footer {
608
+ display: none !important;
609
+ }
610
+
611
+ /* ========== ANIMATIONS ========== */
612
+ @keyframes fadeInUp {
613
+ from {
614
+ opacity: 0;
615
+ transform: translateY(20px);
616
+ }
617
+ to {
618
+ opacity: 1;
619
+ transform: translateY(0);
620
+ }
621
+ }
622
+
623
+ @keyframes fadeIn {
624
+ from { opacity: 0; }
625
+ to { opacity: 1; }
626
+ }
627
+
628
+ @keyframes pulse {
629
+ 0%, 100% { opacity: 1; }
630
+ 50% { opacity: 0.5; }
631
+ }
632
+
633
+ @keyframes shimmer {
634
+ 0% { transform: translateX(-100%); }
635
+ 100% { transform: translateX(100%); }
636
+ }
637
+
638
+ @keyframes rotate {
639
+ from { transform: rotate(0deg); }
640
+ to { transform: rotate(360deg); }
641
+ }
642
+
643
+ @keyframes scaleIn {
644
+ from {
645
+ opacity: 0;
646
+ transform: scale(0.9);
647
+ }
648
+ to {
649
+ opacity: 1;
650
+ transform: scale(1);
651
+ }
652
+ }
653
+
654
+ @keyframes slideInRight {
655
+ from {
656
+ opacity: 0;
657
+ transform: translateX(30px);
658
+ }
659
+ to {
660
+ opacity: 1;
661
+ transform: translateX(0);
662
+ }
663
+ }
664
+
665
+ .fade-in-up {
666
+ animation: fadeInUp 0.6s ease-out;
667
+ }
668
+
669
+ .fade-in {
670
+ animation: fadeIn 0.4s ease-out;
671
+ }
672
+
673
+ .scale-in {
674
+ animation: scaleIn 0.4s ease-out;
675
+ }
676
+
677
+ .slide-in-right {
678
+ animation: slideInRight 0.5s ease-out;
679
+ }
680
+
681
+ /* ========== GLASS EFFECT ========== */
682
+ .glass-effect {
683
+ background: rgba(255, 255, 255, 0.7) !important;
684
+ backdrop-filter: blur(10px);
685
+ -webkit-backdrop-filter: blur(10px);
686
+ border: 1px solid rgba(255, 255, 255, 0.3);
687
+ }
688
+
689
+ [data-theme="dark"] .glass-effect {
690
+ background: rgba(30, 41, 59, 0.7) !important;
691
+ border: 1px solid rgba(255, 255, 255, 0.1);
692
+ }
693
+
694
+ /* ========== HOVER EFFECTS ========== */
695
+ .hover-lift {
696
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
697
+ }
698
+
699
+ .hover-lift:hover {
700
+ transform: translateY(-4px);
701
+ box-shadow: var(--shadow-lg) !important;
702
+ }
703
+
704
+ /* ========== HEADER ========== */
705
+ .app-header {
706
+ background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
707
+ padding: 48px 32px;
708
+ border-radius: var(--radius-xl);
709
+ box-shadow: var(--shadow-xl);
710
+ margin-bottom: 32px;
711
+ position: relative;
712
+ overflow: hidden;
713
+ animation: fadeInUp 0.6s ease-out;
714
+ }
715
+
716
+ .app-header::before {
717
+ content: '';
718
+ position: absolute;
719
+ top: -50%;
720
+ right: -10%;
721
+ width: 300px;
722
+ height: 300px;
723
+ background: rgba(255, 255, 255, 0.1);
724
  border-radius: 50%;
725
+ animation: pulse 3s ease-in-out infinite;
726
+ }
727
+
728
+ .header-content {
729
+ position: relative;
730
+ z-index: 1;
731
+ }
732
+
733
+ .header-top {
734
  display: flex;
735
+ justify-content: space-between;
736
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  margin-bottom: 24px;
738
+ }
739
+
740
+ .header-title {
741
+ display: flex;
742
+ align-items: center;
743
+ gap: 16px;
744
+ }
745
+
746
+ .header-icon {
747
+ font-size: 48px;
748
+ animation: scaleIn 0.8s ease-out;
749
+ }
750
+
751
+ .header-text h1 {
752
+ margin: 0;
753
+ font-size: 36px;
754
+ font-weight: 800;
755
+ color: white;
756
+ letter-spacing: -0.5px;
757
+ text-shadow: 0 2px 10px rgba(0,0,0,0.1);
758
+ }
759
+
760
+ .header-text .subtitle {
761
+ margin: 8px 0 0;
762
+ font-size: 16px;
763
+ color: rgba(255, 255, 255, 0.9);
764
+ font-weight: 500;
765
+ }
766
+
767
+ .header-stats {
768
+ display: flex;
769
+ gap: 24px;
770
+ margin-top: 24px;
771
+ }
772
+
773
+ .header-stat {
774
+ background: rgba(255, 255, 255, 0.15);
775
+ backdrop-filter: blur(10px);
776
+ padding: 16px 24px;
777
+ border-radius: var(--radius-md);
778
+ border: 1px solid rgba(255, 255, 255, 0.2);
779
+ }
780
+
781
+ .header-stat-label {
782
+ font-size: 12px;
783
+ color: rgba(255, 255, 255, 0.8);
784
+ text-transform: uppercase;
785
+ letter-spacing: 0.5px;
786
+ font-weight: 600;
787
+ }
788
+
789
+ .header-stat-value {
790
+ font-size: 24px;
791
+ color: white;
792
+ font-weight: 800;
793
+ margin-top: 4px;
794
+ }
795
+
796
+ /* ========== THEME TOGGLE ========== */
797
+ #theme-toggle-btn {
798
+ background: rgba(255, 255, 255, 0.2) !important;
799
+ backdrop-filter: blur(10px);
800
+ border: 1px solid rgba(255, 255, 255, 0.3) !important;
801
+ border-radius: 50px !important;
802
+ padding: 12px 24px !important;
803
+ color: white !important;
804
+ font-weight: 600 !important;
805
+ cursor: pointer;
806
+ transition: all var(--transition-fast) !important;
807
+ display: flex;
808
+ align-items: center;
809
+ gap: 8px;
810
+ }
811
+
812
+ #theme-toggle-btn:hover {
813
+ background: rgba(255, 255, 255, 0.3) !important;
814
+ transform: scale(1.05);
815
+ }
816
+
817
+ /* ========== CARDS & CONTAINERS ========== */
818
+ .input-card, .output-card {
819
+ background: var(--bg-secondary);
820
+ border: 1px solid var(--border-color);
821
+ border-radius: var(--radius-lg);
822
+ padding: 24px;
823
+ box-shadow: var(--shadow-md);
824
+ animation: fadeInUp 0.6s ease-out;
825
+ }
826
+
827
+ .section-title {
828
+ font-size: 18px;
829
+ font-weight: 700;
830
+ color: var(--text-primary);
831
+ margin-bottom: 20px;
832
+ display: flex;
833
+ align-items: center;
834
+ gap: 10px;
835
+ }
836
+
837
+ .section-title::before {
838
+ content: '';
839
+ width: 4px;
840
+ height: 24px;
841
+ background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary));
842
+ border-radius: 2px;
843
+ }
844
+
845
+ /* ========== RESULT CARDS ========== */
846
+ .result-card {
847
+ background: var(--bg-secondary);
848
+ border: 1px solid var(--border-color);
849
+ border-radius: var(--radius-xl);
850
+ padding: 0;
851
+ box-shadow: var(--shadow-lg);
852
+ overflow: hidden;
853
+ }
854
+
855
+ .result-banner {
856
+ padding: 24px 28px;
857
+ display: flex;
858
+ align-items: center;
859
+ gap: 16px;
860
  position: relative;
861
  overflow: hidden;
 
862
  }
863
+
864
+ .result-banner::before {
865
+ content: '';
866
+ position: absolute;
867
+ top: 0;
868
+ left: 0;
869
+ right: 0;
870
+ bottom: 0;
871
+ opacity: 0.1;
872
+ z-index: 0;
873
  }
874
+
875
+ .result-banner.ok {
876
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
877
+ }
878
+
879
+ .result-banner.warn {
880
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
881
+ }
882
+
883
+ .result-banner.neutral {
884
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
885
+ }
886
+
887
+ .banner-emoji {
888
+ font-size: 48px;
889
+ position: relative;
890
+ z-index: 1;
891
+ animation: scaleIn 0.6s ease-out;
892
+ }
893
+
894
+ .banner-text {
895
+ flex: 1;
896
+ position: relative;
897
+ z-index: 1;
898
+ }
899
+
900
+ .banner-title {
901
+ font-size: 24px;
902
+ font-weight: 800;
903
+ color: white;
904
+ margin-bottom: 4px;
905
+ }
906
+
907
+ .banner-sub {
908
+ font-size: 14px;
909
+ color: rgba(255, 255, 255, 0.9);
910
+ font-weight: 500;
911
+ }
912
+
913
+ .pulse-dot {
914
+ width: 12px;
915
+ height: 12px;
916
+ background: white;
917
+ border-radius: 50%;
918
+ position: relative;
919
+ z-index: 1;
920
+ animation: pulse 2s ease-in-out infinite;
921
+ }
922
+
923
+ /* ========== METRICS GRID ========== */
924
+ .metrics-grid {
925
+ display: grid;
926
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
927
+ gap: 16px;
928
  padding: 24px;
 
 
929
  }
 
930
 
931
+ .metric-card {
932
+ background: var(--bg-tertiary);
933
+ border-radius: var(--radius-md);
934
+ padding: 20px;
935
+ text-align: center;
936
+ border: 1px solid var(--border-color);
937
+ }
938
+
939
+ .metric-icon {
940
+ font-size: 32px;
941
+ margin-bottom: 12px;
942
+ }
943
+
944
+ .metric-label {
945
+ font-size: 12px;
946
+ color: var(--text-muted);
947
+ text-transform: uppercase;
948
+ letter-spacing: 0.5px;
949
  font-weight: 600;
950
+ margin-bottom: 8px;
951
  }
952
 
953
+ .metric-value {
954
+ font-size: 18px;
955
+ font-weight: 800;
956
+ color: var(--text-primary);
957
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
958
+ }
959
+
960
+ .metric-value.small {
961
+ font-size: 14px;
962
+ }
963
+
964
+ /* ========== PROBABILITY SECTION ========== */
965
+ .probability-section {
966
+ margin: 24px;
967
  padding: 24px;
968
+ background: var(--bg-tertiary);
969
+ border-radius: var(--radius-lg);
970
+ border: 1px solid var(--border-color);
971
+ }
972
+
973
+ .prob-header {
974
+ display: flex;
975
+ justify-content: space-between;
976
+ align-items: center;
977
+ margin-bottom: 20px;
978
+ }
979
+
980
+ .prob-title {
981
+ font-size: 16px;
982
+ font-weight: 700;
983
+ color: var(--text-primary);
984
+ }
985
+
986
+ .prob-value-large {
987
+ font-size: 32px;
988
+ font-weight: 800;
989
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
990
+ -webkit-background-clip: text;
991
+ -webkit-text-fill-color: transparent;
992
+ background-clip: text;
993
+ }
994
+
995
+ .prob-bar-container {
996
+ margin-top: 16px;
997
+ }
998
+
999
+ .prob-bar-track {
1000
+ height: 16px;
1001
+ background: var(--bg-secondary);
1002
+ border-radius: 999px;
1003
+ overflow: hidden;
1004
+ border: 2px solid var(--border-color);
1005
+ position: relative;
1006
+ }
1007
+
1008
+ .prob-bar-fill {
1009
+ height: 100%;
1010
+ border-radius: 999px;
1011
  position: relative;
1012
  overflow: hidden;
1013
+ transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
1014
+ }
1015
+
1016
+ .prob-bar-fill.ok {
1017
+ background: linear-gradient(90deg, #10b981, #059669);
1018
  }
1019
+
1020
+ .prob-bar-fill.warn {
1021
+ background: linear-gradient(90deg, #f59e0b, #d97706);
1022
+ }
1023
+
1024
+ .prob-bar-fill.neutral {
1025
+ background: linear-gradient(90deg, #3b82f6, #2563eb);
1026
  }
1027
+
1028
+ .shimmer {
1029
+ position: absolute;
1030
+ top: 0;
1031
+ left: 0;
1032
+ width: 100%;
1033
+ height: 100%;
1034
+ background: linear-gradient(
1035
+ 90deg,
1036
+ transparent,
1037
+ rgba(255, 255, 255, 0.3),
1038
+ transparent
1039
+ );
1040
+ animation: shimmer 2s infinite;
1041
  }
1042
+
1043
+ .prob-markers {
1044
+ display: flex;
1045
+ justify-content: space-between;
1046
+ margin-top: 8px;
1047
+ font-size: 11px;
1048
+ color: var(--text-muted);
1049
+ font-weight: 600;
1050
  }
 
1051
 
1052
+ /* ========== DETAILS GRID ========== */
1053
+ .details-grid {
1054
+ display: grid;
1055
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1056
+ gap: 16px;
1057
+ padding: 0 24px 24px;
1058
+ }
1059
 
1060
+ .detail-box {
1061
+ background: var(--bg-tertiary);
1062
+ border: 1px solid var(--border-color);
1063
+ border-radius: var(--radius-md);
1064
  padding: 20px;
1065
+ display: flex;
1066
+ align-items: center;
1067
+ gap: 16px;
1068
  }
 
 
1069
 
1070
+ .detail-icon {
1071
+ width: 48px;
1072
+ height: 48px;
1073
+ border-radius: var(--radius-sm);
1074
+ display: flex;
1075
+ align-items: center;
1076
+ justify-content: center;
1077
+ flex-shrink: 0;
1078
+ }
1079
+
1080
+ .detail-icon.ok {
1081
+ background: linear-gradient(135deg, #10b981, #059669);
1082
+ color: white;
1083
+ }
1084
+
1085
+ .detail-icon.warn {
1086
+ background: linear-gradient(135deg, #f59e0b, #d97706);
1087
+ color: white;
1088
+ }
1089
+
1090
+ .detail-icon.neutral {
1091
+ background: linear-gradient(135deg, #3b82f6, #2563eb);
1092
+ color: white;
1093
+ }
1094
+
1095
+ .detail-content {
1096
+ flex: 1;
1097
+ }
1098
 
1099
+ .detail-label {
 
1100
  font-size: 12px;
1101
+ color: var(--text-muted);
1102
  text-transform: uppercase;
1103
+ letter-spacing: 0.5px;
1104
+ font-weight: 600;
1105
+ margin-bottom: 4px;
1106
  }
 
 
1107
 
1108
+ .detail-value {
1109
+ font-size: 20px;
1110
+ font-weight: 800;
1111
+ color: var(--text-primary);
1112
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
1113
  }
1114
 
1115
+ /* ========== DISCLAIMER ========== */
1116
+ .disclaimer {
1117
+ margin: 0 24px 24px;
1118
+ padding: 16px;
1119
+ background: rgba(59, 130, 246, 0.1);
1120
+ border: 1px solid rgba(59, 130, 246, 0.2);
1121
+ border-radius: var(--radius-md);
1122
+ display: flex;
1123
+ align-items: flex-start;
1124
+ gap: 12px;
1125
  font-size: 13px;
1126
+ color: var(--text-secondary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  line-height: 1.5;
 
 
 
 
 
1128
  }
1129
+
1130
+ .disclaimer svg {
1131
+ color: var(--accent-primary);
1132
+ flex-shrink: 0;
1133
+ margin-top: 2px;
1134
+ }
1135
+
1136
+ /* ========== BATCH RESULTS ========== */
1137
+ .batch-results {
1138
+ background: var(--bg-secondary);
1139
+ border: 1px solid var(--border-color);
1140
+ border-radius: var(--radius-xl);
1141
+ overflow: hidden;
1142
+ box-shadow: var(--shadow-lg);
1143
+ }
1144
+
1145
+ .batch-header {
1146
+ background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
1147
+ padding: 24px 28px;
1148
+ display: flex;
1149
+ justify-content: space-between;
1150
+ align-items: center;
1151
+ }
1152
+
1153
+ .batch-title {
1154
+ display: flex;
1155
+ align-items: center;
1156
+ gap: 12px;
1157
+ font-size: 24px;
1158
+ font-weight: 800;
1159
+ color: white;
1160
+ }
1161
+
1162
+ .batch-icon {
1163
+ font-size: 32px;
1164
+ }
1165
+
1166
+ .batch-count {
1167
+ background: rgba(255, 255, 255, 0.2);
1168
+ padding: 8px 16px;
1169
+ border-radius: 999px;
1170
+ font-size: 14px;
1171
+ color: white;
1172
+ font-weight: 600;
1173
+ border: 1px solid rgba(255, 255, 255, 0.3);
1174
+ }
1175
+
1176
+ .stats-showcase {
1177
+ padding: 24px;
1178
+ }
1179
+
1180
+ .stat-mega {
1181
+ background: var(--bg-tertiary);
1182
+ border: 1px solid var(--border-color);
1183
+ border-radius: var(--radius-lg);
1184
+ padding: 32px;
1185
+ margin-bottom: 20px;
1186
+ display: flex;
1187
+ align-items: center;
1188
+ gap: 24px;
1189
+ position: relative;
1190
+ overflow: hidden;
1191
+ }
1192
+
1193
+ .stat-mega-icon {
1194
+ width: 64px;
1195
+ height: 64px;
1196
+ border-radius: var(--radius-md);
1197
+ display: flex;
1198
+ align-items: center;
1199
+ justify-content: center;
1200
+ font-size: 32px;
1201
+ color: white;
1202
+ flex-shrink: 0;
1203
+ }
1204
+
1205
+ .stat-mega-icon.ok {
1206
+ background: linear-gradient(135deg, #10b981, #059669);
1207
+ }
1208
+
1209
+ .stat-mega-content {
1210
+ flex: 1;
1211
+ }
1212
+
1213
+ .stat-mega-label {
1214
+ font-size: 14px;
1215
+ color: var(--text-muted);
1216
+ text-transform: uppercase;
1217
+ letter-spacing: 0.5px;
1218
+ font-weight: 600;
1219
+ margin-bottom: 8px;
1220
+ }
1221
+
1222
+ .stat-mega-value {
1223
+ font-size: 48px;
1224
+ font-weight: 800;
1225
+ color: var(--text-primary);
1226
+ line-height: 1;
1227
+ margin-bottom: 8px;
1228
+ }
1229
+
1230
+ .stat-mega-sub {
1231
+ font-size: 16px;
1232
  color: var(--text-secondary);
1233
+ font-weight: 500;
1234
+ }
1235
+
1236
+ .stat-ring {
1237
+ width: 100px;
1238
+ height: 100px;
1239
+ position: relative;
1240
+ }
1241
+
1242
+ .stat-ring svg {
1243
+ transform: rotate(-90deg);
1244
+ width: 100%;
1245
+ height: 100%;
1246
+ }
1247
+
1248
+ .ring-bg {
1249
+ fill: none;
1250
+ stroke: var(--border-color);
1251
+ stroke-width: 8;
1252
+ }
1253
+
1254
+ .ring-fill {
1255
+ fill: none;
1256
+ stroke: url(#gradient);
1257
+ stroke-width: 8;
1258
+ stroke-linecap: round;
1259
+ stroke-dasharray: 283;
1260
+ transition: stroke-dashoffset 1s ease;
1261
+ }
1262
+
1263
+ .stats-grid {
1264
+ display: grid;
1265
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1266
+ gap: 16px;
1267
+ }
1268
+
1269
+ .stat-card {
1270
+ background: var(--bg-tertiary);
1271
+ border: 1px solid var(--border-color);
1272
+ border-radius: var(--radius-md);
1273
+ padding: 20px;
1274
+ text-align: center;
1275
+ }
1276
+
1277
+ .stat-icon {
1278
+ font-size: 32px;
1279
+ margin-bottom: 12px;
1280
+ }
1281
+
1282
+ .stat-label {
1283
+ font-size: 12px;
1284
+ color: var(--text-muted);
1285
+ text-transform: uppercase;
1286
+ letter-spacing: 0.5px;
1287
+ font-weight: 600;
1288
+ margin-bottom: 8px;
1289
+ }
1290
+
1291
+ .stat-value {
1292
+ font-size: 24px;
1293
+ font-weight: 800;
1294
+ color: var(--text-primary);
1295
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
1296
+ }
1297
+
1298
+ .stat-value.small {
1299
+ font-size: 16px;
1300
+ }
1301
+
1302
+ .download-prompt {
1303
+ margin-top: 24px;
1304
+ padding: 20px;
1305
+ background: rgba(59, 130, 246, 0.1);
1306
+ border: 1px solid rgba(59, 130, 246, 0.2);
1307
+ border-radius: var(--radius-md);
1308
+ display: flex;
1309
+ align-items: center;
1310
+ justify-content: center;
1311
+ gap: 12px;
1312
+ font-size: 14px;
1313
+ color: var(--text-primary);
1314
+ font-weight: 600;
1315
+ }
1316
+
1317
+ .download-prompt svg {
1318
+ color: var(--accent-primary);
1319
+ }
1320
+
1321
+ /* ========== AGE PREVIEW ========== */
1322
+ .age-preview-box {
1323
+ margin-top: 12px;
1324
+ padding: 16px 20px;
1325
+ background: var(--bg-tertiary);
1326
+ border: 2px dashed var(--border-color);
1327
+ border-radius: var(--radius-md);
1328
+ display: flex;
1329
+ align-items: center;
1330
+ gap: 12px;
1331
+ }
1332
+
1333
+ .preview-icon {
1334
+ font-size: 24px;
1335
+ }
1336
+
1337
+ .preview-content {
1338
+ flex: 1;
1339
+ }
1340
+
1341
+ .preview-label {
1342
+ font-size: 12px;
1343
+ color: var(--text-muted);
1344
+ text-transform: uppercase;
1345
+ letter-spacing: 0.5px;
1346
+ font-weight: 600;
1347
+ margin-bottom: 4px;
1348
+ }
1349
+
1350
+ .preview-value {
1351
+ font-size: 18px;
1352
+ font-weight: 800;
1353
+ color: var(--text-primary);
1354
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
1355
+ }
1356
+
1357
+ /* ========== BUTTONS ========== */
1358
+ #predict-btn button, #batch-btn button {
1359
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
1360
+ color: white !important;
1361
+ border: none !important;
1362
+ border-radius: var(--radius-md) !important;
1363
  padding: 16px 32px !important;
1364
+ font-size: 16px !important;
1365
+ font-weight: 700 !important;
1366
+ box-shadow: var(--shadow-md) !important;
1367
+ transition: all var(--transition-fast) !important;
1368
+ }
1369
+
1370
+ #predict-btn button:hover, #batch-btn button:hover {
1371
+ transform: translateY(-2px);
1372
+ box-shadow: var(--shadow-lg) !important;
1373
+ }
1374
+
1375
+ /* ========== TABS ========== */
1376
+ .tabs {
1377
+ border-bottom: 2px solid var(--border-color);
1378
+ margin-bottom: 24px;
1379
+ }
1380
+
1381
+ .tab-nav button {
1382
+ background: transparent !important;
1383
+ border: none !important;
1384
+ border-bottom: 3px solid transparent !important;
1385
+ color: var(--text-secondary) !important;
1386
+ font-weight: 600 !important;
1387
+ padding: 12px 24px !important;
1388
+ transition: all var(--transition-fast) !important;
1389
+ }
1390
+
1391
+ .tab-nav button.selected {
1392
+ color: var(--accent-primary) !important;
1393
+ border-bottom-color: var(--accent-primary) !important;
1394
+ }
1395
+
1396
+ /* ========== INFO BOX ========== */
1397
+ .info-box {
1398
+ background: rgba(139, 92, 246, 0.1);
1399
+ border: 1px solid rgba(139, 92, 246, 0.2);
1400
+ border-radius: var(--radius-md);
1401
+ padding: 16px 20px;
1402
+ margin-bottom: 20px;
1403
+ }
1404
+
1405
+ .info-box strong {
1406
+ color: var(--accent-secondary);
1407
+ font-weight: 700;
1408
+ }
1409
+
1410
+ /* ========== RESPONSIVE ========== */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1411
  @media (max-width: 768px) {
1412
+ .header-top {
1413
+ flex-direction: column;
1414
+ align-items: flex-start;
1415
+ gap: 16px;
1416
+ }
1417
+
1418
+ .header-stats {
1419
+ flex-direction: column;
1420
+ width: 100%;
1421
+ }
1422
+
1423
+ .header-stat {
1424
+ width: 100%;
1425
+ }
1426
+
1427
+ .header-text h1 {
1428
+ font-size: 28px;
1429
+ }
1430
+
1431
+ .metrics-grid {
1432
+ grid-template-columns: 1fr;
1433
+ }
1434
+
1435
+ .details-grid {
1436
+ grid-template-columns: 1fr;
1437
+ }
1438
+
1439
+ .stat-mega {
1440
+ flex-direction: column;
1441
+ text-align: center;
1442
+ }
1443
+
1444
+ .stats-grid {
1445
+ grid-template-columns: 1fr;
1446
+ }
1447
+
1448
+ .batch-header {
1449
+ flex-direction: column;
1450
+ align-items: flex-start;
1451
+ gap: 12px;
1452
+ }
1453
+ }
1454
+
1455
+ /* ========== GRADIO OVERRIDES ========== */
1456
+ .gr-button-primary {
1457
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
1458
+ border: none !important;
1459
  }
1460
 
1461
+ .gr-input, .gr-box {
1462
+ border-color: var(--border-color) !important;
1463
+ background: var(--bg-secondary) !important;
1464
+ }
1465
+
1466
+ .gr-form {
1467
+ background: var(--bg-secondary) !important;
1468
+ border-color: var(--border-color) !important;
1469
+ }
1470
+
1471
+ .gr-panel {
1472
+ background: var(--bg-secondary) !important;
1473
+ border-color: var(--border-color) !important;
1474
  }
 
1475
  """
1476
 
1477
+ JS = r"""
1478
+ function() {
1479
+ const root = document.documentElement;
1480
+ const currentTheme = root.getAttribute('data-theme');
1481
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
1482
+ root.setAttribute('data-theme', newTheme);
1483
+ localStorage.setItem('theme', newTheme);
1484
+ const btn = document.querySelector('#theme-toggle-btn button');
1485
+ if (btn) {
1486
+ btn.textContent = newTheme === 'dark' ? 'β˜€οΈ Light Mode' : 'πŸŒ™ Dark Mode';
1487
+ }
1488
+ return newTheme;
1489
+ }
1490
+ """
1491
+
1492
+ INIT_JS = r"""
1493
+ function() {
1494
+ const savedTheme = localStorage.getItem('theme') || 'light';
1495
+ document.documentElement.setAttribute('data-theme', savedTheme);
1496
+ const btn = document.querySelector('#theme-toggle-btn button');
1497
+ if (btn) {
1498
+ btn.textContent = savedTheme === 'dark' ? 'β˜€οΈ Light Mode' : 'πŸŒ™ Dark Mode';
1499
+ }
1500
+ }
1501
+ """
1502
+
1503
+ theme = gr.themes.Soft(
1504
  primary_hue="blue",
1505
  secondary_hue="purple",
1506
  neutral_hue="slate",
1507
  radius_size="lg",
1508
+ font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
1509
  )
1510
 
1511
+ with gr.Blocks(theme=theme, css=CSS, js=INIT_JS, title="CI Outcome Predictor") as demo:
1512
+ gr.HTML(f"""
1513
+ <div class="app-header">
1514
+ <div class="header-content">
1515
+ <div class="header-top">
1516
+ <div class="header-title">
1517
+ <div class="header-icon">πŸ”¬</div>
1518
+ <div class="header-text">
1519
+ <h1>CI Outcome Predictor</h1>
1520
+ <div class="subtitle">Advanced ML-powered Cochlear Implant Success Analysis</div>
1521
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1522
  </div>
1523
+ </div>
1524
+ <div class="header-stats">
1525
+ <div class="header-stat">
1526
+ <div class="header-stat-label">Gene Options</div>
1527
+ <div class="header-stat-value">{len(gene_choices) if APP_READY else 0}</div>
1528
+ </div>
1529
+ <div class="header-stat">
1530
+ <div class="header-stat-label">Model Features</div>
1531
+ <div class="header-stat-value">{len(input_cols) if APP_READY else 0}</div>
1532
+ </div>
1533
+ <div class="header-stat">
1534
+ <div class="header-stat-label">Prediction Models</div>
1535
+ <div class="header-stat-value">2</div>
1536
+ </div>
1537
+ </div>
1538
+ </div>
1539
+ </div>
1540
+ """)
1541
+
1542
+ theme_state = gr.State("light")
1543
+ theme_btn = gr.Button("πŸŒ™ Dark Mode", elem_id="theme-toggle-btn")
1544
+ theme_btn.click(fn=None, js=JS, outputs=theme_state)
1545
+
1546
+ with gr.Tabs():
1547
+ with gr.Tab("🎯 Single Prediction"):
1548
+ with gr.Row():
1549
+ with gr.Column(scale=1):
1550
+ gr.HTML('<div class="section-title">πŸ“ Input Parameters</div>')
1551
+ gene_in = gr.Dropdown(
1552
+ choices=gene_choices,
1553
+ value=gene_choices[0] if gene_choices else None,
1554
+ label="🧬 Select Gene",
1555
+ info="Choose the gene variant for analysis",
1556
+ filterable=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1557
  )
1558
+ age_in = gr.Textbox(
1559
+ label="πŸ“… Patient Age",
1560
+ placeholder="Examples: 1.11 | 1.6YRS | 2.3",
1561
+ info="Enter age in supported format"
1562
+ )
1563
+ parse_mode = gr.Radio(
1564
  choices=[
1565
  "Decimal (1.11 = 1.11 years)",
1566
  "Years.Months (1.11 = 1y 11m)"
1567
  ],
1568
  value="Decimal (1.11 = 1.11 years)",
1569
+ label="βš™οΈ Age Parsing Mode",
1570
+ info="Select how age should be interpreted"
1571
+ )
1572
+
1573
+ age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)"))
1574
+ btn = gr.Button("πŸš€ Run Prediction", elem_id="predict-btn", size="lg")
1575
+
1576
+ with gr.Column(scale=1):
1577
+ gr.HTML('<div class="section-title">πŸ“Š Prediction Results</div>')
1578
+ single_out = gr.HTML(
1579
+ value=_missing_files_message() if not APP_READY else
1580
+ "<div style='text-align:center; padding:60px 20px; color:var(--text-muted);'>πŸ‘ˆ Enter parameters and click 'Run Prediction' to see results</div>"
1581
  )
1582
 
1583
+ age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
1584
+ parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
1585
 
1586
+ btn.click(fn=predict_single, inputs=[gene_in, age_in, parse_mode], outputs=[single_out])
 
 
1587
 
1588
+ with gr.Tab("πŸ“Š Batch Prediction (CSV)"):
1589
+ gr.HTML('<div class="section-title">πŸ“ Batch Processing</div>')
 
 
 
1590
 
1591
+ gr.HTML(f"""
1592
+ <div class="info-box">
1593
+ <strong>πŸ“‹ Requirements:</strong> Your CSV must include <strong>Gene</strong> and <strong>Age</strong> columns.
1594
+ Additional features will be auto-filled. Model expects <strong>{len(input_cols) if APP_READY else 0}</strong> total feature columns.
1595
+ </div>
1596
+ """)
1597
+
1598
+ parse_mode_b = gr.Radio(
1599
+ choices=[
1600
+ "Decimal (1.11 = 1.11 years)",
1601
+ "Years.Months (1.11 = 1y 11m)"
1602
+ ],
1603
+ value="Decimal (1.11 = 1.11 years)",
1604
+ label="βš™οΈ Age Parsing Mode",
1605
+ info="How should ages be interpreted?"
1606
+ )
1607
+
1608
+ csv_in = gr.File(file_types=[".csv"], label="πŸ“€ Upload CSV File", file_count="single")
1609
+ run_b = gr.Button("πŸš€ Run Batch Prediction", elem_id="batch-btn", size="lg")
1610
+
1611
+ batch_summary = gr.HTML(value="")
1612
+ gr.HTML('<div class="section-title" style="margin-top:24px;">πŸ‘€ Preview (First 20 Rows)</div>')
1613
+ batch_preview = gr.Dataframe(headers=None, interactive=False, wrap=True)
1614
+
1615
+ gr.HTML('<div class="section-title" style="margin-top:24px;">πŸ’Ύ Download Results</div>')
1616
+ batch_file = gr.File(label="πŸ“₯ Download Full Results CSV")
1617
+
1618
+ run_b.click(fn=predict_batch, inputs=[csv_in, parse_mode_b], outputs=[batch_summary, batch_preview, batch_file])
1619
 
1620
  if __name__ == "__main__":
1621
+ # HF Spaces-safe launch (no share=True)
1622
+ port = int(os.getenv("PORT", "7860"))
1623
+ demo.launch(server_name="0.0.0.0", server_port=port, show_error=False)