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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +468 -306
app.py CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  import os
2
  import re
3
  import pickle
@@ -23,17 +26,23 @@ REG_PKL_PATH = ASSETS_DIR / "ci_speech_score_regressor.pkl"
23
  # Writable location on HF Spaces for generated outputs
24
  BATCH_OUT_PATH = Path("/tmp/predictions_output.csv")
25
 
 
 
 
26
  def _missing_assets_html():
27
  return f"""
28
- <div class="result-card">
29
  <div class="result-head">
30
- <div class="result-title">Setup required</div>
31
- <div class="pill warn"><span class="dot"></span><span class="pill-ic">!</span><span>Missing files</span></div>
 
 
 
32
  </div>
33
- <div class="box">
34
  <div class="k">This Space is missing required files.</div>
35
  <div class="sub">Upload these to <span class="mono">/assets</span> (recommended) or repo root:</div>
36
- <div class="v mono" style="font-weight:700; font-size:12px; line-height:1.5;">
37
  validation_data.csv<br/>
38
  Cochlear_Implant_Dataset.csv<br/>
39
  ci_success_classifier.pkl<br/>
@@ -43,33 +52,27 @@ def _missing_assets_html():
43
  </div>
44
  """
45
 
46
- def _require(path: Path):
47
- return path.exists() and path.is_file()
48
 
49
  # =========================
50
  # Load data + models (guarded)
51
  # =========================
52
- APP_READY = all(map(_require, [VAL_CSV_PATH, MAIN_CSV_PATH, CLF_PKL_PATH, REG_PKL_PATH]))
53
-
54
  if APP_READY:
55
  val_df = pd.read_csv(VAL_CSV_PATH)
56
  main_df = pd.read_csv(MAIN_CSV_PATH)
57
-
58
- def load_model(path: Path):
59
- try:
60
- return joblib.load(path)
61
- except Exception:
62
- with open(path, "rb") as f:
63
- return pickle.load(f)
64
-
65
- clf_model = load_model(CLF_PKL_PATH)
66
- reg_model = load_model(REG_PKL_PATH)
67
  else:
68
- # Placeholders so the file imports cleanly on Spaces even when assets are missing
69
  val_df = pd.DataFrame()
70
  main_df = pd.DataFrame()
71
- clf_model = None
72
- reg_model = None
 
 
 
 
 
 
 
 
73
 
74
  def get_model_feature_names(m):
75
  if hasattr(m, "feature_names_in_"):
@@ -83,7 +86,6 @@ def get_model_feature_names(m):
83
  clf_expected = get_model_feature_names(clf_model) or [] if APP_READY else []
84
  reg_expected = get_model_feature_names(reg_model) or [] if APP_READY else []
85
 
86
- # Union of expected columns (preserve order)
87
  input_cols = []
88
  for colset in [clf_expected, reg_expected]:
89
  for c in colset:
@@ -93,7 +95,7 @@ if not input_cols:
93
  input_cols = list(val_df.columns) if APP_READY else []
94
 
95
  # =========================
96
- # Build Gene dropdown choices from MAIN dataset
97
  # =========================
98
  def find_gene_column(df: pd.DataFrame):
99
  if "Gene" in df.columns:
@@ -125,12 +127,6 @@ if APP_READY:
125
  # Helpers
126
  # =========================
127
  def parse_age_to_years(age_raw: str, mode: str):
128
- """
129
- mode:
130
- - "Years.Months (1.11 = 1y 11m)" -> 1 + 11/12
131
- - "Decimal (1.11 = 1.11 years)" -> 1.11
132
- Accepts "1.6YRS", "2yrs", etc.
133
- """
134
  if age_raw is None:
135
  return np.nan
136
 
@@ -146,7 +142,6 @@ def parse_age_to_years(age_raw: str, mode: str):
146
  except:
147
  return np.nan
148
 
149
- # Years.Months mode
150
  if cleaned.count(".") == 1:
151
  a, b = cleaned.split(".")
152
  if a.isdigit() and b.isdigit() and len(b) == 2:
@@ -154,7 +149,6 @@ def parse_age_to_years(age_raw: str, mode: str):
154
  months = int(b)
155
  if 0 <= months <= 11:
156
  return years + months / 12.0
157
- # fallback to decimal
158
  try:
159
  return float(cleaned)
160
  except:
@@ -195,6 +189,9 @@ def align_to_expected(df: pd.DataFrame, expected_cols):
195
  out[c] = np.nan
196
  return out[expected_cols]
197
 
 
 
 
198
  def render_single_result_html(gene, age_entered, age_used_years, parse_mode, label, prob, speech):
199
  if label == 1:
200
  status = "Likely Success"
@@ -222,49 +219,63 @@ def render_single_result_html(gene, age_entered, age_used_years, parse_mode, lab
222
  gene_disp = str(gene) if gene is not None else "—"
223
 
224
  return f"""
225
- <div class="result-card">
226
  <div class="result-head">
227
- <div class="result-title">Prediction</div>
228
- <div class="pill {badge}">
 
 
 
229
  <span class="dot"></span>
230
  <span class="pill-ic">{icon}</span>
231
  <span>{status}</span>
232
  </div>
233
  </div>
 
234
  <div class="grid2">
235
- <div class="box">
 
236
  <div class="k">Gene</div>
237
  <div class="v mono">{gene_disp}</div>
238
  </div>
239
- <div class="box">
 
240
  <div class="k">Age entered</div>
241
  <div class="v mono">{age_entered}</div>
242
  </div>
243
  </div>
244
- <div class="box" style="margin-top:12px;">
 
 
245
  <div class="k">Age used by model</div>
246
  <div class="v mono">{age_used_disp}</div>
247
  <div class="sub">Parsing mode: <span class="mono">{parse_mode}</span></div>
248
  </div>
249
- <div class="box" style="margin-top:12px;">
 
 
250
  <div class="k">Success probability (Class 1)</div>
251
  <div class="prob-row">
252
- <div class="prob-bar"><div class="prob-fill" style="width:{bar_width};"></div></div>
253
  <div class="prob-txt mono">{prob_text}</div>
254
  </div>
255
  </div>
 
256
  <div class="grid2" style="margin-top:12px;">
257
- <div class="box">
 
258
  <div class="k">Predicted label</div>
259
  <div class="v mono">{label}</div>
260
  </div>
261
- <div class="box">
 
262
  <div class="k">Predicted speech score</div>
263
  <div class="v mono">{speech_disp}</div>
264
  </div>
265
  </div>
 
266
  <div class="fine">
267
- Informational tool only. Not medical advice.
268
  </div>
269
  </div>
270
  """
@@ -308,15 +319,19 @@ def _file_to_path(file_obj):
308
  return None
309
  if isinstance(file_obj, str):
310
  return file_obj
 
 
311
  if hasattr(file_obj, "name"):
312
  return file_obj.name
313
  if isinstance(file_obj, dict) and "name" in file_obj:
314
  return file_obj["name"]
 
 
315
  return None
316
 
317
  def predict_batch(csv_file, parse_mode):
318
  if not APP_READY:
319
- raise gr.Error("This Space is missing required model/dataset files. Please upload them to /assets or repo root.")
320
 
321
  path = _file_to_path(csv_file)
322
  if not path:
@@ -396,21 +411,44 @@ def predict_batch(csv_file, parse_mode):
396
  pass
397
 
398
  summary = f"""
399
- <div class="result-card">
400
  <div class="result-head">
401
- <div class="result-title">Batch Summary</div>
402
- <div class="pill neutral"><span class="dot"></span><span class="pill-ic"></span><span>{n} rows</span></div>
 
 
 
 
 
 
 
403
  </div>
404
  <div class="grid3">
405
- <div class="box"><div class="k">Predicted success</div><div class="v mono">{succ}</div></div>
406
- <div class="box"><div class="k">Predicted success (%)</div><div class="v mono">{succ_pct}%</div></div>
407
- <div class="box"><div class="k">Avg prob (Class 1)</div><div class="v mono">{avg_prob_txt}</div></div>
 
 
 
 
 
 
 
 
 
 
 
 
408
  </div>
409
- <div class="box" style="margin-top:12px;">
410
- <div class="k">Avg speech score</div><div class="v mono">{avg_speech_txt}</div>
 
 
411
  <div class="sub">Parsing mode: <span class="mono">{parse_mode}</span></div>
412
  </div>
413
- <div class="fine">Download the output CSV below.</div>
 
 
414
  </div>
415
  """
416
  return summary, out.head(20), str(BATCH_OUT_PATH)
@@ -418,316 +456,400 @@ def predict_batch(csv_file, parse_mode):
418
  def age_preview(age_text, parse_mode):
419
  v = parse_age_to_years(age_text, parse_mode)
420
  if isinstance(v, (float, np.floating)) and np.isfinite(v):
421
- return f"<div class='hint'>Model will use: <span class='mono'><b>{v:.3f}</b> years</span></div>"
422
- return "<div class='hint'>Model will use: <span class='mono'>—</span></div>"
423
 
424
  # =========================
425
- # CSS: minimal, clean, mobile responsive + hide Gradio footer
426
  # =========================
427
- CSS = """
428
- /* =========================================================
429
- FIX: Force single LIGHT theme across HF / Gradio / devices
430
- ========================================================= */
431
-
432
- /* Always render as light */
433
- :root { color-scheme: light !important; }
434
-
435
- html, body, .gradio-container{
436
- color-scheme: light !important;
437
- background: #f6f7fb !important;
438
- color: #0f172a !important;
439
- }
440
-
441
- /* HF + Gradio sometimes set dark theme via class or data attr */
442
- .dark, .dark body, .dark .gradio-container,
443
- html[data-theme="dark"], body[data-theme="dark"], [data-theme="dark"] .gradio-container{
444
- color-scheme: light !important;
445
- background: #f6f7fb !important;
446
- color: #0f172a !important;
447
- }
448
-
449
- /* Also override when OS prefers dark */
450
- @media (prefers-color-scheme: dark){
451
- html, body, .gradio-container{
452
- color-scheme: light !important;
453
- background: #f6f7fb !important;
454
- color: #0f172a !important;
455
- }
456
- }
457
-
458
- /* Hide theme toggles (selectors vary by Gradio version) */
459
- #theme-toggle,
460
- .theme-toggle,
461
- button[aria-label*="theme" i],
462
- button[aria-label*="dark" i],
463
- button[title*="theme" i],
464
- button[title*="dark" i]{
465
- display:none !important;
466
- }
467
-
468
- /* =========================================================
469
- Your design tokens
470
- ========================================================= */
471
- :root{
472
- --bg:#f6f7fb;
473
- --card:#ffffff;
474
- --border:#e5e7eb;
475
- --text:#0f172a;
476
- --muted:#64748b;
477
- --accent:#2563eb;
478
- --ok:#16a34a;
479
- --warn:#d97706;
480
- --shadow: 0 10px 30px rgba(15, 23, 42, .08);
481
- --radius: 16px;
482
- }
483
-
484
- /* =========================================================
485
- Hard override Gradio theme tokens (this is the KEY fix)
486
- ========================================================= */
487
- .gradio-container{
488
- background: var(--bg) !important;
489
- color: var(--text) !important;
490
-
491
- --body-background-fill: var(--bg) !important;
492
- --body-background-fill-secondary: var(--card) !important;
493
-
494
- --body-text-color: var(--text) !important;
495
- --body-text-color-subdued: var(--muted) !important;
496
-
497
- --block-background-fill: var(--card) !important;
498
- --border-color-primary: var(--border) !important;
499
 
500
- --input-background-fill: #fbfcff !important;
501
- --input-border-color: var(--border) !important;
502
- --input-text-color: var(--text) !important;
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
- --button-primary-background-fill: var(--accent) !important;
505
- --button-primary-text-color: #ffffff !important;
506
 
507
- --block-label-text-color: var(--text) !important;
508
- --block-title-text-color: var(--text) !important;
 
 
509
  }
510
 
511
  /* Hide Gradio footer */
512
  footer, .footer, #footer, .gradio-footer { display:none !important; height:0 !important; }
513
 
514
- /* Wrapper */
515
- #wrap{ max-width: 980px; margin: 0 auto; padding: 14px 12px 28px; }
516
- .gr-row{ flex-wrap: wrap !important; gap: 12px !important; }
517
- .gr-column{ min-width: 280px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
 
519
- /* Hero */
520
- .hero{
521
- padding: 16px 16px;
 
522
  border-radius: var(--radius);
523
- border: 1px solid var(--border);
524
- background: linear-gradient(180deg, #ffffff, #fbfdff);
525
  box-shadow: var(--shadow);
526
- margin-bottom: 12px;
 
 
527
  }
528
- .hero h1{ margin:0; font-size: 18px; font-weight: 800; letter-spacing:.2px; color: var(--text) !important; }
529
- .hero p{ margin:6px 0 0; color: var(--muted) !important; font-size: 13px; line-height:1.35; }
530
 
531
- /* Card wrapper */
532
- .card{
 
 
 
 
 
533
  background: var(--card);
534
- border: 1px solid var(--border);
535
  border-radius: var(--radius);
 
536
  box-shadow: var(--shadow);
537
- padding: 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  }
539
 
540
- .mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
- /* =========================================================
543
- FIX INPUTS: borders + dropdown inner-white box removal
544
- ========================================================= */
545
 
546
- /* Labels should ALWAYS be dark (not grey) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  .gradio-container label,
 
548
  .gradio-container .label,
549
- .gradio-container .block-label,
550
- .gradio-container .wrap > label,
551
- .gradio-container .form > label{
552
  color: var(--text) !important;
553
- font-weight: 600 !important;
554
  }
555
 
556
- /* Textbox border (Age) */
557
  .gradio-container input[type="text"],
558
- .gradio-container input[type="number"],
559
- .gradio-container textarea{
560
- border: 1px solid var(--border) !important;
561
- background: #fbfcff !important;
562
  color: var(--text) !important;
563
- border-radius: 12px !important;
 
564
  box-shadow: none !important;
565
  }
566
-
567
- /* Dropdown/Combobox wrapper should have border */
568
- .gradio-container div[role="combobox"],
569
- .gradio-container div[role="listbox"]{
570
- border: 1px solid var(--border) !important;
571
- background: #fbfcff !important;
572
- border-radius: 12px !important;
573
- box-shadow: none !important;
574
  }
575
 
576
- /* REMOVE the inner "white container" look inside dropdown */
577
- .gradio-container div[role="combobox"] input{
578
- background: transparent !important;
579
- border: none !important;
580
- box-shadow: none !important;
581
- padding: 0 !important;
582
  color: var(--text) !important;
583
  }
584
-
585
- /* =========================================================
586
- FIX RADIO: ensure selection dot is visible + looks consistent
587
- ========================================================= */
588
-
589
- .gradio-container input[type="radio"]{
590
- accent-color: var(--accent) !important;
591
  }
592
-
593
- /* Radiogroup option styling (works across Gradio v3/v4) */
594
- .gradio-container div[role="radiogroup"] label{
595
- display: flex !important;
596
- align-items: center !important;
597
- gap: 10px !important;
598
- border: 1px solid var(--border) !important;
599
- background: #fbfcff !important;
600
- border-radius: 12px !important;
601
- padding: 8px 10px !important;
602
  color: var(--text) !important;
603
  }
 
 
 
 
604
 
605
- /* Make the radio circle always visible */
606
- .gradio-container div[role="radiogroup"] input[type="radio"]{
 
607
  width: 16px !important;
608
  height: 16px !important;
609
- margin: 0 !important;
610
  }
611
-
612
- /* =========================================================
613
- Batch tab: file upload + dataframe keep same colors in “dark”
614
- ========================================================= */
615
-
616
- /* Upload area / dropzone (multiple selectors for HF/Gradio versions) */
617
- .gradio-container .upload,
618
- .gradio-container .file-upload,
619
- .gradio-container [data-testid="file"],
620
- .gradio-container [data-testid="file-upload"],
621
- .gradio-container [aria-label*="Upload" i]{
622
- background: #ffffff !important;
623
  color: var(--text) !important;
624
- border: 1px dashed rgba(100,116,139,.35) !important;
 
 
 
625
  border-radius: 14px !important;
 
 
 
 
 
626
  }
627
 
628
- /* Dataframe readability */
629
- .gradio-container table,
630
- .gradio-container thead,
631
- .gradio-container tbody{
632
  color: var(--text) !important;
633
  }
634
-
635
- /* Results card */
636
- .result-card{
637
- background: #ffffff;
638
- border: 1px solid var(--border);
639
- border-radius: var(--radius);
640
- padding: 14px;
641
- box-shadow: var(--shadow);
642
  }
643
- .result-head{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
644
- .result-title{ font-size: 13px; font-weight: 900; letter-spacing:.3px; color: var(--text) !important; }
645
-
646
- .grid2{ display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
647
- .grid3{ display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
648
-
649
- .box{
650
- border: 1px solid var(--border);
651
- background: #fbfcff;
652
- border-radius: 14px;
653
- padding: 12px;
654
  }
655
- .k{ color: var(--muted); font-size: 12px; }
656
- .v{ color: var(--text); font-size: 14px; font-weight: 800; margin-top: 3px; }
657
- .sub{ margin-top:6px; color: var(--muted); font-size: 11px; }
658
 
659
- .pill{
660
- display:flex; align-items:center; gap:8px;
661
- padding: 8px 10px;
662
- border-radius: 999px;
663
- border: 1px solid var(--border);
664
- background: #ffffff;
665
- font-size: 12px;
666
- white-space: nowrap;
667
  }
668
- .pill .dot{ width:10px; height:10px; border-radius:999px; background: rgba(100,116,139,.25); }
669
- .pill.ok{ border-color: rgba(22,163,74,.25); }
670
- .pill.ok .dot{ background: var(--ok); }
671
- .pill.warn{ border-color: rgba(217,119,6,.25); }
672
- .pill.warn .dot{ background: var(--warn); }
673
- .pill.neutral{ border-color: rgba(37,99,235,.20); }
674
- .pill.neutral .dot{ background: var(--accent); }
675
- .pill-ic{ font-weight: 900; }
676
-
677
- .prob-row{ display:flex; align-items:center; gap: 10px; margin-top: 6px; }
678
- .prob-bar{
679
- flex: 1;
680
- height: 10px;
681
- border-radius: 999px;
682
- background: #eef2ff;
683
- border: 1px solid rgba(37,99,235,.15);
684
- overflow: hidden;
685
  }
686
- .prob-fill{
687
- height: 100%;
688
- background: linear-gradient(90deg, rgba(37,99,235,.95), rgba(22,163,74,.85));
689
- border-radius: 999px;
690
  }
691
- .prob-txt{ width: 56px; text-align:right; color: var(--text); font-weight: 900; }
692
-
693
- .fine{
694
- margin-top: 12px;
695
- font-size: 11px;
696
- color: var(--muted);
697
- line-height: 1.35;
698
  }
699
 
700
- .hint{
701
- margin-top: 6px;
702
- font-size: 12px;
703
- color: var(--muted);
704
- padding: 8px 10px;
705
- border: 1px dashed rgba(100,116,139,.35);
706
- border-radius: 12px;
707
- background: #ffffff;
708
  }
709
 
710
- /* Primary button */
711
- #primaryBtn button{
712
- border-radius: 14px !important;
713
- border: 1px solid rgba(37,99,235,.35) !important;
714
- background: var(--accent) !important;
715
- color: white !important;
716
- font-weight: 900 !important;
 
 
717
  }
718
 
719
- @media (max-width: 740px){
720
- #primaryBtn button{ width: 100% !important; }
721
- .grid2{ grid-template-columns: 1fr; }
722
- .grid3{ grid-template-columns: 1fr; }
723
- .result-head{ flex-direction: column; align-items: flex-start; }
724
- .gr-column{ min-width: 100%; }
725
  }
 
726
  """
727
 
728
  theme = gr.themes.Base(
729
  primary_hue="blue",
730
- secondary_hue="emerald",
731
  neutral_hue="slate",
732
  radius_size="lg",
733
  font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
@@ -738,10 +860,41 @@ theme = gr.themes.Base(
738
  # =========================
739
  with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
740
  with gr.Column(elem_id="wrap"):
 
 
741
  gr.HTML("""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  <div class="hero">
743
  <h1>CI Outcome Predictor</h1>
744
- <p>Single and batch predictions. Gene options are loaded from the dataset. Age parsing is shown transparently.</p>
745
  </div>
746
  """)
747
 
@@ -766,15 +919,17 @@ with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
766
  "Years.Months (1.11 = 1y 11m)"
767
  ],
768
  value="Decimal (1.11 = 1.11 years)",
769
- label="Age format"
770
  )
771
 
772
  age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)"))
773
-
774
  btn = gr.Button("Run Prediction", elem_id="primaryBtn")
775
 
776
  with gr.Column(scale=1):
777
- single_out = gr.HTML(value=_missing_assets_html() if not APP_READY else "", elem_classes=["card"])
 
 
 
778
 
779
  age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
780
  parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
@@ -788,7 +943,7 @@ with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
788
  with gr.Tab("Batch Prediction (CSV)"):
789
  with gr.Group(elem_classes=["card"]):
790
  gr.Markdown(
791
- "**Required columns:** `Gene`, `Age`",
792
  elem_classes=["mono"]
793
  )
794
 
@@ -798,7 +953,7 @@ with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
798
  "Years.Months (1.11 = 1y 11m)"
799
  ],
800
  value="Decimal (1.11 = 1.11 years)",
801
- label="Age format"
802
  )
803
 
804
  csv_in = gr.File(file_types=[".csv"], label="Upload CSV")
@@ -806,7 +961,7 @@ with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
806
 
807
  batch_summary = gr.HTML(value="")
808
  preview = gr.Dataframe(label="Preview (first 20 rows)", wrap=True)
809
- out_file = gr.File(label="Download results")
810
 
811
  run_b.click(
812
  fn=predict_batch,
@@ -814,5 +969,12 @@ with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
814
  outputs=[batch_summary, preview, out_file]
815
  )
816
 
817
- # For Hugging Face Spaces: don't use share=True
818
- demo.launch(show_error=False, quiet=True)
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Enhanced CI Outcome Predictor with Modern UI (HF Spaces ready)"""
3
+
4
  import os
5
  import re
6
  import pickle
 
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/>
 
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_"):
 
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:
 
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:
 
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
  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
  months = int(b)
150
  if 0 <= months <= 11:
151
  return years + months / 12.0
 
152
  try:
153
  return float(cleaned)
154
  except:
 
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"
 
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
  """
 
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
  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)
 
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"],
 
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
 
 
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])
 
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
 
 
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")
 
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,
 
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
+ )