techatcreated commited on
Commit
42efec6
·
verified ·
1 Parent(s): d0c250a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +272 -137
app.py CHANGED
@@ -11,41 +11,47 @@ from pathlib import Path
11
  # PATHS (Hugging Face Spaces safe)
12
  # =========================
13
  BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
14
-
15
- # Put your files either in repo root OR in ./assets/
16
  ASSETS_DIR = BASE_DIR / "assets"
17
  if not ASSETS_DIR.exists():
18
- ASSETS_DIR = BASE_DIR
19
 
20
  VAL_CSV_PATH = ASSETS_DIR / "validation_data.csv"
21
  MAIN_CSV_PATH = ASSETS_DIR / "Cochlear_Implant_Dataset.csv"
22
  CLF_PKL_PATH = ASSETS_DIR / "ci_success_classifier.pkl"
23
  REG_PKL_PATH = ASSETS_DIR / "ci_speech_score_regressor.pkl"
24
 
25
- # Batch output: /tmp is writable on HF Spaces
26
  BATCH_OUT_PATH = Path("/tmp/predictions_output.csv")
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- def _require_file(path: Path, label: str):
30
- if not path.exists():
31
- raise FileNotFoundError(
32
- f"Missing required file: {label}. "
33
- f"Expected at: {path}. "
34
- f"Upload it to your Space repo (recommended: /assets folder)."
35
- )
36
 
37
  # =========================
38
- # Load data + models (guarded for HF)
39
  # =========================
40
- APP_READY = True
41
- APP_ERROR_MSG = ""
42
-
43
- try:
44
- _require_file(VAL_CSV_PATH, "validation_data.csv")
45
- _require_file(MAIN_CSV_PATH, "Cochlear_Implant_Dataset.csv")
46
- _require_file(CLF_PKL_PATH, "ci_success_classifier.pkl")
47
- _require_file(REG_PKL_PATH, "ci_speech_score_regressor.pkl")
48
 
 
49
  val_df = pd.read_csv(VAL_CSV_PATH)
50
  main_df = pd.read_csv(MAIN_CSV_PATH)
51
 
@@ -58,23 +64,13 @@ try:
58
 
59
  clf_model = load_model(CLF_PKL_PATH)
60
  reg_model = load_model(REG_PKL_PATH)
 
 
 
 
 
 
61
 
62
- except Exception:
63
- APP_READY = False
64
- # Keep errors user-safe (no stacktraces); admins can view logs in HF
65
- APP_ERROR_MSG = (
66
- "This app is not configured yet. Please upload the required model and dataset files to the Space.\n\n"
67
- "Required files:\n"
68
- "- validation_data.csv\n"
69
- "- Cochlear_Implant_Dataset.csv\n"
70
- "- ci_success_classifier.pkl\n"
71
- "- ci_speech_score_regressor.pkl\n\n"
72
- "Recommended location: a folder named 'assets' in the Space repo."
73
- )
74
-
75
- # =========================
76
- # Feature name extraction
77
- # =========================
78
  def get_model_feature_names(m):
79
  if hasattr(m, "feature_names_in_"):
80
  return list(getattr(m, "feature_names_in_"))
@@ -84,25 +80,17 @@ def get_model_feature_names(m):
84
  return list(step.feature_names_in_)
85
  return None
86
 
87
- # If app isn't ready, define minimal placeholders to avoid NameErrors
88
- if not APP_READY:
89
- val_df = pd.DataFrame()
90
- main_df = pd.DataFrame()
91
- clf_model = None
92
- reg_model = None
93
- clf_expected, reg_expected, input_cols = [], [], []
94
- else:
95
- clf_expected = get_model_feature_names(clf_model) or []
96
- reg_expected = get_model_feature_names(reg_model) or []
97
-
98
- # Union of expected columns (preserve order)
99
- input_cols = []
100
- for colset in [clf_expected, reg_expected]:
101
- for c in colset:
102
- if c not in input_cols:
103
- input_cols.append(c)
104
- if not input_cols:
105
- input_cols = list(val_df.columns)
106
 
107
  # =========================
108
  # Build Gene dropdown choices from MAIN dataset
@@ -137,6 +125,12 @@ if APP_READY:
137
  # Helpers
138
  # =========================
139
  def parse_age_to_years(age_raw: str, mode: str):
 
 
 
 
 
 
140
  if age_raw is None:
141
  return np.nan
142
 
@@ -152,6 +146,7 @@ def parse_age_to_years(age_raw: str, mode: str):
152
  except:
153
  return np.nan
154
 
 
155
  if cleaned.count(".") == 1:
156
  a, b = cleaned.split(".")
157
  if a.isdigit() and b.isdigit() and len(b) == 2:
@@ -159,6 +154,7 @@ def parse_age_to_years(age_raw: str, mode: str):
159
  months = int(b)
160
  if 0 <= months <= 11:
161
  return years + months / 12.0
 
162
  try:
163
  return float(cleaned)
164
  except:
@@ -280,7 +276,7 @@ def render_single_result_html(gene, age_entered, age_used_years, parse_mode, lab
280
 
281
  def predict_single(gene, age_text, parse_mode):
282
  if not APP_READY:
283
- raise gr.Error("App is not configured. Please upload required files to the Space.")
284
 
285
  if gene is None or str(gene).strip() == "":
286
  raise gr.Error("Please select a Gene.")
@@ -325,7 +321,7 @@ def _file_to_path(file_obj):
325
 
326
  def predict_batch(csv_file, parse_mode):
327
  if not APP_READY:
328
- raise gr.Error("App is not configured. Please upload required files to the Space.")
329
 
330
  path = _file_to_path(csv_file)
331
  if not path:
@@ -431,11 +427,151 @@ def age_preview(age_text, parse_mode):
431
  return "<div class='hint'>Model will use: <span class='mono'>—</span></div>"
432
 
433
  # =========================
434
- # CSS (unchanged)
435
  # =========================
436
- CSS = """<YOUR EXISTING CSS HERE>"""
437
- # ↑ Keep your CSS block exactly as-is.
438
- # (I’m not re-pasting it here to keep the patch focused.)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
  theme = gr.themes.Base(
441
  primary_hue="blue",
@@ -446,87 +582,86 @@ theme = gr.themes.Base(
446
  )
447
 
448
  # =========================
449
- # UI
450
  # =========================
451
  with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
452
  with gr.Column(elem_id="wrap"):
453
- if not APP_READY:
454
- gr.Markdown(APP_ERROR_MSG)
455
- else:
456
- gr.HTML("""
457
- <div class="hero">
458
- <h1>CI Outcome Predictor</h1>
459
- <p>Single and batch predictions. Gene options are loaded from the dataset. Age parsing is shown transparently.</p>
460
- </div>
461
- """)
462
-
463
- with gr.Tabs():
464
- with gr.Tab("Single Prediction"):
465
- with gr.Row():
466
- with gr.Column(scale=1):
467
- with gr.Group(elem_classes=["card"]):
468
- gene_in = gr.Dropdown(
469
- choices=gene_choices,
470
- value=gene_choices[0] if gene_choices else None,
471
- label="Gene",
472
- filterable=True,
473
- )
474
- age_in = gr.Textbox(
475
- label="Age",
476
- placeholder="Examples: 1.11 | 1.6YRS | 2.3"
477
- )
478
- parse_mode = gr.Radio(
479
- choices=[
480
- "Decimal (1.11 = 1.11 years)",
481
- "Years.Months (1.11 = 1y 11m)"
482
- ],
483
- value="Decimal (1.11 = 1.11 years)",
484
- label="Age format"
485
- )
486
-
487
- age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)"))
488
- btn = gr.Button("Run Prediction", elem_id="primaryBtn")
489
-
490
- with gr.Column(scale=1):
491
- single_out = gr.HTML(value="", elem_classes=["card"])
492
-
493
- age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
494
- parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
495
-
496
- btn.click(
497
- fn=predict_single,
498
- inputs=[gene_in, age_in, parse_mode],
499
- outputs=[single_out]
 
 
 
 
 
 
500
  )
501
 
502
- with gr.Tab("Batch Prediction (CSV)"):
503
- with gr.Group(elem_classes=["card"]):
504
- gr.Markdown(
505
- "**Required columns:** `Gene`, `Age`",
506
- elem_classes=["mono"]
507
- )
508
-
509
- parse_mode_b = gr.Radio(
510
- choices=[
511
- "Decimal (1.11 = 1.11 years)",
512
- "Years.Months (1.11 = 1y 11m)"
513
- ],
514
- value="Decimal (1.11 = 1.11 years)",
515
- label="Age format"
516
- )
517
-
518
- csv_in = gr.File(file_types=[".csv"], label="Upload CSV")
519
- run_b = gr.Button("Run Batch Prediction", elem_id="primaryBtn")
520
-
521
- batch_summary = gr.HTML(value="")
522
- preview = gr.Dataframe(label="Preview (first 20 rows)", wrap=True)
523
- out_file = gr.File(label="Download results")
524
-
525
- run_b.click(
526
- fn=predict_batch,
527
- inputs=[csv_in, parse_mode_b],
528
- outputs=[batch_summary, preview, out_file]
529
  )
530
 
531
- # Hugging Face Spaces provides the external URL; don't use share=True there.
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  demo.launch(show_error=False, quiet=True)
 
11
  # PATHS (Hugging Face Spaces safe)
12
  # =========================
13
  BASE_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Path.cwd()
 
 
14
  ASSETS_DIR = BASE_DIR / "assets"
15
  if not ASSETS_DIR.exists():
16
+ ASSETS_DIR = BASE_DIR # fallback: files in repo root
17
 
18
  VAL_CSV_PATH = ASSETS_DIR / "validation_data.csv"
19
  MAIN_CSV_PATH = ASSETS_DIR / "Cochlear_Implant_Dataset.csv"
20
  CLF_PKL_PATH = ASSETS_DIR / "ci_success_classifier.pkl"
21
  REG_PKL_PATH = ASSETS_DIR / "ci_speech_score_regressor.pkl"
22
 
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/>
40
+ ci_speech_score_regressor.pkl
41
+ </div>
42
+ </div>
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
 
 
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_"):
76
  return list(getattr(m, "feature_names_in_"))
 
80
  return list(step.feature_names_in_)
81
  return None
82
 
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:
90
+ if c not in input_cols:
91
+ input_cols.append(c)
92
+ 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
 
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
  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
  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:
 
276
 
277
  def predict_single(gene, age_text, parse_mode):
278
  if not APP_READY:
279
+ return _missing_assets_html()
280
 
281
  if gene is None or str(gene).strip() == "":
282
  raise gr.Error("Please select a Gene.")
 
321
 
322
  def predict_batch(csv_file, parse_mode):
323
  if not APP_READY:
324
+ raise gr.Error("This Space is missing required model/dataset files. Please upload them to /assets or repo root.")
325
 
326
  path = _file_to_path(csv_file)
327
  if not path:
 
427
  return "<div class='hint'>Model will use: <span class='mono'>—</span></div>"
428
 
429
  # =========================
430
+ # CSS: minimal, clean, mobile responsive + hide Gradio footer
431
  # =========================
432
+ CSS = """
433
+ :root{
434
+ --bg:#f6f7fb;
435
+ --card:#ffffff;
436
+ --border:#e5e7eb;
437
+ --text:#0f172a;
438
+ --muted:#64748b;
439
+ --accent:#2563eb;
440
+ --ok:#16a34a;
441
+ --warn:#d97706;
442
+ --shadow: 0 10px 30px rgba(15, 23, 42, .08);
443
+ --radius: 16px;
444
+ }
445
+
446
+ .gradio-container{
447
+ background: var(--bg);
448
+ color: var(--text);
449
+ }
450
+
451
+ /* Hide Gradio footer / API bar */
452
+ footer, .footer, #footer, .gradio-footer { display:none !important; height:0 !important; }
453
+
454
+ /* Page wrapper */
455
+ #wrap{ max-width: 980px; margin: 0 auto; padding: 14px 12px 28px; }
456
+
457
+ /* Make Rows wrap on small screens */
458
+ .gr-row{ flex-wrap: wrap !important; gap: 12px !important; }
459
+ .gr-column{ min-width: 280px; }
460
+
461
+ /* Hero */
462
+ .hero{
463
+ padding: 16px 16px;
464
+ border-radius: var(--radius);
465
+ border: 1px solid var(--border);
466
+ background: linear-gradient(180deg, #ffffff, #fbfdff);
467
+ box-shadow: var(--shadow);
468
+ margin-bottom: 12px;
469
+ }
470
+ .hero h1{ margin:0; font-size: 18px; font-weight: 800; letter-spacing:.2px; }
471
+ .hero p{ margin:6px 0 0; color: var(--muted); font-size: 13px; line-height:1.35; }
472
+
473
+ /* Card wrapper for inputs/outputs */
474
+ .card{
475
+ background: var(--card);
476
+ border: 1px solid var(--border);
477
+ border-radius: var(--radius);
478
+ box-shadow: var(--shadow);
479
+ padding: 14px;
480
+ }
481
+
482
+ .mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
483
+
484
+ /* Results */
485
+ .result-card{
486
+ background: #ffffff;
487
+ border: 1px solid var(--border);
488
+ border-radius: var(--radius);
489
+ padding: 14px;
490
+ box-shadow: var(--shadow);
491
+ }
492
+ .result-head{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
493
+ .result-title{ font-size: 13px; font-weight: 900; letter-spacing:.3px; }
494
+
495
+ .grid2{ display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }
496
+ .grid3{ display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
497
+
498
+ .box{
499
+ border: 1px solid var(--border);
500
+ background: #fbfcff;
501
+ border-radius: 14px;
502
+ padding: 12px;
503
+ }
504
+ .k{ color: var(--muted); font-size: 12px; }
505
+ .v{ color: var(--text); font-size: 14px; font-weight: 800; margin-top: 3px; }
506
+ .sub{ margin-top:6px; color: var(--muted); font-size: 11px; }
507
+
508
+ .pill{
509
+ display:flex; align-items:center; gap:8px;
510
+ padding: 8px 10px;
511
+ border-radius: 999px;
512
+ border: 1px solid var(--border);
513
+ background: #ffffff;
514
+ font-size: 12px;
515
+ white-space: nowrap;
516
+ }
517
+ .pill .dot{ width:10px; height:10px; border-radius:999px; background: rgba(100,116,139,.25); }
518
+ .pill.ok{ border-color: rgba(22,163,74,.25); }
519
+ .pill.ok .dot{ background: var(--ok); }
520
+ .pill.warn{ border-color: rgba(217,119,6,.25); }
521
+ .pill.warn .dot{ background: var(--warn); }
522
+ .pill.neutral{ border-color: rgba(37,99,235,.20); }
523
+ .pill.neutral .dot{ background: var(--accent); }
524
+ .pill-ic{ font-weight: 900; }
525
+
526
+ .prob-row{ display:flex; align-items:center; gap: 10px; margin-top: 6px; }
527
+ .prob-bar{
528
+ flex: 1;
529
+ height: 10px;
530
+ border-radius: 999px;
531
+ background: #eef2ff;
532
+ border: 1px solid rgba(37,99,235,.15);
533
+ overflow: hidden;
534
+ }
535
+ .prob-fill{
536
+ height: 100%;
537
+ background: linear-gradient(90deg, rgba(37,99,235,.95), rgba(22,163,74,.85));
538
+ border-radius: 999px;
539
+ }
540
+ .prob-txt{ width: 56px; text-align:right; color: var(--text); font-weight: 900; }
541
+
542
+ .fine{
543
+ margin-top: 12px;
544
+ font-size: 11px;
545
+ color: var(--muted);
546
+ line-height: 1.35;
547
+ }
548
+
549
+ .hint{
550
+ margin-top: 6px;
551
+ font-size: 12px;
552
+ color: var(--muted);
553
+ padding: 8px 10px;
554
+ border: 1px dashed rgba(100,116,139,.35);
555
+ border-radius: 12px;
556
+ background: #ffffff;
557
+ }
558
+
559
+ /* Primary button styling + full width on mobile */
560
+ #primaryBtn button{
561
+ border-radius: 14px !important;
562
+ border: 1px solid rgba(37,99,235,.35) !important;
563
+ background: var(--accent) !important;
564
+ color: white !important;
565
+ font-weight: 900 !important;
566
+ }
567
+ @media (max-width: 740px){
568
+ #primaryBtn button{ width: 100% !important; }
569
+ .grid2{ grid-template-columns: 1fr; }
570
+ .grid3{ grid-template-columns: 1fr; }
571
+ .result-head{ flex-direction: column; align-items: flex-start; }
572
+ .gr-column{ min-width: 100%; }
573
+ }
574
+ """
575
 
576
  theme = gr.themes.Base(
577
  primary_hue="blue",
 
582
  )
583
 
584
  # =========================
585
+ # UI (UNCHANGED)
586
  # =========================
587
  with gr.Blocks(theme=theme, css=CSS, title="CI Outcome Predictor") as demo:
588
  with gr.Column(elem_id="wrap"):
589
+ gr.HTML("""
590
+ <div class="hero">
591
+ <h1>CI Outcome Predictor</h1>
592
+ <p>Single and batch predictions. Gene options are loaded from the dataset. Age parsing is shown transparently.</p>
593
+ </div>
594
+ """)
595
+
596
+ with gr.Tabs():
597
+ with gr.Tab("Single Prediction"):
598
+ with gr.Row():
599
+ with gr.Column(scale=1):
600
+ with gr.Group(elem_classes=["card"]):
601
+ gene_in = gr.Dropdown(
602
+ choices=gene_choices,
603
+ value=gene_choices[0] if gene_choices else None,
604
+ label="Gene",
605
+ filterable=True,
606
+ )
607
+ age_in = gr.Textbox(
608
+ label="Age",
609
+ placeholder="Examples: 1.11 | 1.6YRS | 2.3"
610
+ )
611
+ parse_mode = gr.Radio(
612
+ choices=[
613
+ "Decimal (1.11 = 1.11 years)",
614
+ "Years.Months (1.11 = 1y 11m)"
615
+ ],
616
+ value="Decimal (1.11 = 1.11 years)",
617
+ label="Age format"
618
+ )
619
+
620
+ age_hint = gr.HTML(value=age_preview("", "Decimal (1.11 = 1.11 years)"))
621
+
622
+ btn = gr.Button("Run Prediction", elem_id="primaryBtn")
623
+
624
+ with gr.Column(scale=1):
625
+ # If assets missing, show styled HTML card instead of blank
626
+ single_out = gr.HTML(value=_missing_assets_html() if not APP_READY else "", elem_classes=["card"])
627
+
628
+ age_in.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
629
+ parse_mode.change(fn=age_preview, inputs=[age_in, parse_mode], outputs=[age_hint])
630
+
631
+ btn.click(
632
+ fn=predict_single,
633
+ inputs=[gene_in, age_in, parse_mode],
634
+ outputs=[single_out]
635
+ )
636
+
637
+ with gr.Tab("Batch Prediction (CSV)"):
638
+ with gr.Group(elem_classes=["card"]):
639
+ gr.Markdown(
640
+ "**Required columns:** `Gene`, `Age`",
641
+ elem_classes=["mono"]
642
  )
643
 
644
+ parse_mode_b = gr.Radio(
645
+ choices=[
646
+ "Decimal (1.11 = 1.11 years)",
647
+ "Years.Months (1.11 = 1y 11m)"
648
+ ],
649
+ value="Decimal (1.11 = 1.11 years)",
650
+ label="Age format"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  )
652
 
653
+ csv_in = gr.File(file_types=[".csv"], label="Upload CSV")
654
+ run_b = gr.Button("Run Batch Prediction", elem_id="primaryBtn")
655
+
656
+ batch_summary = gr.HTML(value="")
657
+ preview = gr.Dataframe(label="Preview (first 20 rows)", wrap=True)
658
+ out_file = gr.File(label="Download results")
659
+
660
+ run_b.click(
661
+ fn=predict_batch,
662
+ inputs=[csv_in, parse_mode_b],
663
+ outputs=[batch_summary, preview, out_file]
664
+ )
665
+
666
+ # For Hugging Face Spaces: don't use share=True
667
  demo.launch(show_error=False, quiet=True)