File size: 39,722 Bytes
f48f055
 
8cd14fd
 
 
f48f055
8cd14fd
 
 
 
 
f48f055
8cd14fd
 
 
 
 
 
f48f055
8cd14fd
 
 
3cef34a
 
8cd14fd
 
3cef34a
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
 
 
 
 
 
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f48f055
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
 
8cd14fd
 
 
 
 
 
 
 
 
 
3cef34a
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
8cd14fd
 
3cef34a
8cd14fd
3cef34a
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cd14fd
 
3cef34a
 
8cd14fd
 
 
 
 
 
 
 
3cef34a
8cd14fd
 
3cef34a
 
8cd14fd
 
 
3cef34a
 
 
 
 
 
8cd14fd
3cef34a
 
 
 
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
 
 
8cd14fd
 
 
 
 
 
 
3cef34a
 
 
8cd14fd
3cef34a
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f48f055
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f48f055
 
 
 
 
 
 
 
 
 
 
8cd14fd
f48f055
 
 
 
 
 
 
 
 
 
8cd14fd
 
 
 
f48f055
8cd14fd
 
f48f055
 
 
 
 
8cd14fd
 
 
 
 
 
 
 
 
3cef34a
 
 
 
 
 
 
 
 
 
8cd14fd
3cef34a
 
8cd14fd
 
3cef34a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cd14fd
 
3cef34a
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
3cef34a
 
 
 
 
 
 
 
 
 
 
8cd14fd
3cef34a
 
8cd14fd
 
 
3cef34a
8cd14fd
 
3cef34a
8cd14fd
 
 
 
3cef34a
8cd14fd
 
 
3cef34a
 
 
8cd14fd
3cef34a
 
8cd14fd
 
 
 
 
 
3cef34a
 
 
 
 
 
 
 
 
8cd14fd
 
 
3cef34a
8cd14fd
 
3cef34a
 
 
 
 
 
 
 
8cd14fd
 
9e14c54
 
 
 
 
 
3cef34a
8cd14fd
3cef34a
 
 
 
 
 
 
 
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
 
ec7a398
 
 
 
 
 
 
 
3cef34a
 
 
 
 
8cd14fd
3cef34a
 
 
 
8cd14fd
3cef34a
8cd14fd
3cef34a
 
8cd14fd
 
3cef34a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e14c54
 
 
 
 
3cef34a
00d130f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e14c54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
9e14c54
 
 
 
 
 
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
 
 
 
8cd14fd
 
 
 
3cef34a
 
8cd14fd
 
3cef34a
8cd14fd
3cef34a
 
 
 
8cd14fd
3cef34a
8cd14fd
3cef34a
 
 
 
 
 
 
 
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cd14fd
 
 
3cef34a
 
8cd14fd
3cef34a
 
 
 
 
 
 
 
 
8cd14fd
 
 
9e14c54
 
 
 
 
 
 
8cd14fd
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
 
 
9e14c54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cef34a
 
 
 
 
8cd14fd
 
3cef34a
 
 
8cd14fd
3cef34a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
import spaces  # kept only so HF's ZeroGPU hardware tier passes its startup check (see
                # _zerogpu_keepalive below) - actual generation no longer needs a GPU at all.

import ast
import json
import os
import re

import gradio as gr
import numpy as np
import pandas as pd
from huggingface_hub import InferenceClient
from sentence_transformers import SentenceTransformer

# ── Constants ─────────────────────────────────────────────────────────────────
DATASET_REPO = "Yoad22/surfmatch-dataset"
EMBED_MODEL_NAME = "sentence-transformers/all-mpnet-base-v2"
GEN_MODEL_NAME = "Qwen/Qwen3-1.7B"
GEN_PROVIDER = "featherless-ai"  # confirmed on the model's Hub page as an Inference Provider

CONDITIONS = ["small mushy beach breaks", "medium clean point breaks",
              "powerful reef breaks", "big open-ocean swell"]
NO_BOARD_YET = "I don't have a board yet"
CURRENT_STYLE = [NO_BOARD_YET, "longboard cruiser", "all-around funboard", "fish / groveler",
                  "performance shortboard", "step-up / big-wave gun"]
GOAL_OPTIONS = {
    "I'm brand new to surfing and want my first board": ("first_time_beginner", "get my very first surfboard and start learning to surf"),
    "Get more comfortable on the board I already ride": ("stay_and_improve", "get more comfortable and consistent on the board I already ride"),
    "Move toward something shorter and more maneuverable": ("transition_to_shortboard", "move from a longer, more stable board toward something shorter and more maneuverable"),
    "Move toward a longer, more stable board": ("transition_to_longboard", "move toward a longer, more stable board for easier paddling and glide"),
    "Get more stability and easier paddling": ("want_more_stability", "get more stability and easier paddling without changing style drastically"),
    "Get a faster, more responsive board for sharper turns": ("want_more_speed_and_response", "get a faster, more responsive board for sharper turns"),
    "Ride a board that can handle bigger, more powerful waves": ("want_bigger_wave_capability", "ride a board that can handle bigger, more powerful waves"),
    "Get an easier board to paddle and catch waves on": ("want_easier_paddling", "get an easier board to paddle and catch waves on"),
}
STYLE_FOR_PROMPT = {
    "longboard cruiser": "longboard cruiser",
    "all-around funboard": "all-around funboard",
    "fish / groveler": "fish/groveler-style board",
    "performance shortboard": "performance shortboard",
    "step-up / big-wave gun": "step-up gun",
}


def style_clause(current_style):
    """A grammatical clause describing the rider's current board, handling the
    'no board yet' beginner case separately since it isn't a real board style."""
    if current_style == NO_BOARD_YET:
        return "doesn't have a board yet - this will be their first one"
    return f"currently rides a {STYLE_FOR_PROMPT.get(current_style, current_style)}"

# Same skill -> experience-label ranges used to generate rider_profiles.csv in Part 1,
# used here only to turn the UI's single skill slider into an "experience" word for the
# Part 4 prompt, which expects one (the real notebook's riders already carry both fields).
SKILL_RANGES = {"beginner": (1, 22), "novice": (15, 40), "intermediate": (32, 65),
                 "advanced": (55, 88), "expert": (78, 100)}


def experience_for_skill(skill_score):
    for label, (lo, hi) in SKILL_RANGES.items():
        if lo <= skill_score <= hi:
            return label
    return "intermediate"

# ── Load data + models at startup ────────────────────────────────────────────
print("Loading data from the Hugging Face dataset repo...")
riders_df = pd.read_csv(f"hf://datasets/{DATASET_REPO}/rider_profiles.csv")
boards_df = pd.read_csv(f"hf://datasets/{DATASET_REPO}/board_catalog.csv")
boards_df = boards_df.reset_index(drop=True)

print("Loading the embedding model...")
embed_model = SentenceTransformer(EMBED_MODEL_NAME)

print("Loading precomputed board embeddings...")
board_emb_df = pd.read_parquet("board_embeddings.parquet")
boards_df = boards_df.merge(board_emb_df[["board_id", "embedding"]], on="board_id", how="left")
if boards_df["embedding"].isna().any():
    raise RuntimeError("board_embeddings.parquet is missing rows for some board_id values.")
BOARD_EMBS = np.array(boards_df["embedding"].tolist(), dtype=np.float32)

print("Setting up the generation client (runs on Hugging Face's hosted Inference API, not on this Space)...")
HF_TOKEN = os.environ.get("HF_TOKEN")  # set as a Space secret in Settings > Variables and secrets
gen_client = InferenceClient(provider=GEN_PROVIDER, api_key=HF_TOKEN)

print(f"Ready - {len(boards_df)} boards, {len(riders_df):,} rider profiles used for normalization bounds.")

# ── Normalization bounds + per-category ranges, from the real data ──────────
W_LO, W_HI = riders_df["weight_kg"].quantile([0.01, 0.99])
H_LO, H_HI = riders_df["height_cm"].quantile([0.01, 0.99])
SKILL_LO, SKILL_HI = riders_df["skill_score"].min(), riders_df["skill_score"].max()

# Same column names/rounding as the notebook's Part 4 (cell 127), so this Space's
# generation behaves identically to what was tested there.
CATEGORY_RANGES = boards_df.groupby("category").agg(
    len_lo=("length_ft", "min"), len_hi=("length_ft", "max"),
    width_lo=("width_in", "min"), width_hi=("width_in", "max"),
    thick_lo=("thickness_in", "min"), thick_hi=("thickness_in", "max"),
    vol_lo=("volume_l", "min"), vol_hi=("volume_l", "max"),
).round(1)

# ── Part 3: Recommendation with Embeddings (same logic as the notebook) ─────

def compute_style_fit(goal_key, skill_score, current_style):
    """Same rule used to build style_fit_truth in Part 1 and to pick a category in Part 3/4."""
    if goal_key == "first_time_beginner":
        return "longboard cruiser"  # the classic, most forgiving starting point
    if goal_key == "transition_to_shortboard":
        return "performance shortboard" if skill_score >= 55 else "fish / groveler"
    if goal_key == "transition_to_longboard":
        return "longboard cruiser"
    if goal_key == "want_bigger_wave_capability":
        return "step-up / big-wave gun"
    if goal_key == "want_more_speed_and_response":
        return "performance shortboard" if skill_score >= 60 else "fish / groveler"
    if goal_key in ("want_easier_paddling", "want_more_stability"):
        return "longboard cruiser" if skill_score < 40 else "all-around funboard"
    # Fallback: stick with the rider's current style - unless they don't have one yet,
    # in which case NO_BOARD_YET isn't a real category, so default to the safest starter.
    return current_style if current_style != NO_BOARD_YET else "longboard cruiser"


def pct(value, lo, hi):
    if hi == lo:
        return 0.5
    return float(np.clip((value - lo) / (hi - lo), 0, 1))


def within_category_pct(series):
    lo, hi = series.min(), series.max()
    if hi == lo:
        return pd.Series(0.5, index=series.index)
    return (series - lo) / (hi - lo)


def pick_diverse(candidates, score_col, k=3):
    picked, used_tails, used_fins, used_constr = [], set(), set(), set()
    remaining = list(candidates.index)
    for _ in range(k):
        if not remaining:
            break
        best_idx, best_novelty, best_score = None, -1, -1
        for idx in remaining:
            board = candidates.loc[idx]
            if not picked:
                novelty = 3
            else:
                novelty = (int(board["tail"] not in used_tails)
                           + int(board["fins"] not in used_fins)
                           + int(board["construction"] not in used_constr))
            score = board[score_col]
            if (novelty, score) > (best_novelty, best_score):
                best_idx, best_novelty, best_score = idx, novelty, score
        board = candidates.loc[best_idx]
        picked.append(board)
        used_tails.add(board["tail"]); used_fins.add(board["fins"]); used_constr.add(board["construction"])
        remaining.remove(best_idx)
    return pd.DataFrame(picked)


def build_query_text(height_cm, weight_kg, skill_score, current_style, conditions, goal_text, extra_notes=""):
    """Mirrors the phrasing of the real profile_text used to train/evaluate the embeddings,
    so a user's form inputs land in the same region of embedding space as real riders."""
    text = (
        f"I'm {height_cm}cm tall and {weight_kg}kg, with a skill level of {skill_score}/100. "
        f"I {style_clause(current_style)} and surf {conditions}, and I want to {goal_text}."
    )
    if extra_notes and extra_notes.strip():
        text += f" {extra_notes.strip()}"
    return text


# App-only enhancement, layered on top of the notebook's evaluated category-first algorithm
# (Part 3, cell ~ the recommend_boards definition): rather than hard-filtering to only the
# rule-based target category, every board is scored, with a bonus for matching the target
# category. Most results still come from that category (the bonus is large), but a board
# from a neighboring category can occasionally surface if its fit/similarity is strong enough
# - this is intentionally "sometimes", not a forced quota, per real user feedback that seeing
# 3-4 near-identical boards felt repetitive. The notebook's own Part 3 evaluation (hit_rate,
# diversity-ceiling stats in the README) was run against the strict same-category version and
# is unaffected by this - it documents the core algorithm, this is a UX layer on top of it.
CATEGORY_BONUS = 0.18


def recommend_boards(height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text,
                      extra_notes="", k=3):
    target_category = compute_style_fit(goal_key, skill_score, current_style)

    weight_pct = pct(weight_kg, W_LO, W_HI)
    height_pct = pct(height_cm, H_LO, H_HI)
    forgiveness_pct = 1 - pct(skill_score, SKILL_LO, SKILL_HI)
    volume_need = 0.5 * weight_pct + 0.5 * forgiveness_pct
    length_need = 0.5 * height_pct + 0.5 * forgiveness_pct

    candidates = boards_df.copy()
    cand_embs = BOARD_EMBS[candidates.index.values]

    query_text = build_query_text(height_cm, weight_kg, skill_score, current_style, conditions,
                                   goal_text, extra_notes)
    query_emb = embed_model.encode([query_text], normalize_embeddings=True)[0]
    candidates["similarity"] = cand_embs @ query_emb

    # Physical-fit percentiles computed within each board's OWN category (not just the target
    # one), so boards from every category are on the same comparable 0-1 fit scale.
    vol_pct = candidates.groupby("category")["volume_l"].transform(within_category_pct)
    len_pct = candidates.groupby("category")["length_ft"].transform(within_category_pct)
    candidates["volume_score"] = 1 - (volume_need - vol_pct).abs()
    candidates["length_score"] = 1 - (length_need - len_pct).abs()
    candidates["physical_fit"] = 0.5 * candidates["volume_score"] + 0.5 * candidates["length_score"]

    candidates["category_match"] = (candidates["category"] == target_category).astype(float)
    candidates["combined_score"] = (
        0.5 * candidates["similarity"] + 0.5 * candidates["physical_fit"]
        + CATEGORY_BONUS * candidates["category_match"]
    )

    return target_category, pick_diverse(candidates, "combined_score", k=k)


# ── Part 4: Generation - custom board spec + rationale (finalized version) ──
# Grounds the prompt with real per-category ranges and each rider's weight/height/skill
# given as percentiles, and forces a "sizing_reasoning" field to be written before any
# numeric field (reasoning-first). Testing at scale showed this reasoning is honest but
# not fully faithful to the model's own numeric output for weight/height (see the Space
# README for the full, honestly-documented finding) - skill-based sizing works reliably,
# weight/height-based sizing is directionally present but weak. A fallback below keeps
# the app usable on the ~10% of calls that don't parse.

def build_custom_board_prompt(rider, target_category):
    """Adapted from the project notebook's Part 4 (cell 127) - the exact prompt behind the
    documented 90% parse rate and the correlation numbers in the Space README, plus an
    optional free-text line from the app's UI (not present in the notebook version)."""
    r = CATEGORY_RANGES.loc[target_category]
    weight_pct = pct(rider["weight_kg"], W_LO, W_HI)
    height_pct = pct(rider["height_cm"], H_LO, H_HI)
    skill_pct = pct(rider["skill_score"], SKILL_LO, SKILL_HI)

    facts = (
        f"Surfer facts - height {rider['height_cm']}cm, weight {rider['weight_kg']}kg, "
        f"{rider['experience']} (skill {rider['skill_score']}/100), {style_clause(rider['current_style'])}, "
        f"surfs {rider['conditions']}, wants to: {rider['goal_text']}. "
        f"Closest matching standard category: {target_category}."
    )
    if rider.get("extra_notes") and rider["extra_notes"].strip():
        facts += f" In their own words, they also said: \"{rider['extra_notes'].strip()}\""
    percentile_hint = (
        f"Relative to other surfers in our data, this person is heavier than about {weight_pct:.0%} "
        f"of them, taller than about {height_pct:.0%} of them, and more skilled than about "
        f"{skill_pct:.0%} of them."
    )
    sizing_guidance = (
        f"Boards in the {target_category} category on this platform typically run "
        f"{r['len_lo']}-{r['len_hi']} feet long, {r['width_lo']}-{r['width_hi']} inches wide, "
        f"{r['thick_lo']}-{r['thick_hi']} inches thick, and {r['vol_lo']}-{r['vol_hi']} liters. "
        "A surfer who is heavier or less skilled than average needs more volume and a wider, "
        "thicker board. A surfer who is taller or less skilled than average wants relatively "
        "more length."
    )
    instruction = (
        "You are a surfboard shaper designing ONE custom, one-of-a-kind board for this surfer. "
        "This board does not have to follow typical conventions for its category - make an "
        "unconventional choice (for example, an unusual fin setup) if you genuinely think it "
        "suits this surfer better than the standard options would.\n\n"
        f"{facts}\n\n{percentile_hint}\n\n{sizing_guidance}\n\n"
        "Fin setups include single fin, twin fin, thruster (3 fin), quad (4 fin), and 2+1 setup. "
        "Tail shapes include round, square, squash, pin, and swallow. Construction options are "
        "PU/polyester, EPS/epoxy, or soft-top.\n\n"
        "Respond with ONLY a JSON object in this exact format, no other text. Fill in "
        "sizing_reasoning FIRST (keep it to ONE sentence, under 30 words), working through this "
        "surfer's weight percentile, height percentile, and skill percentile explicitly and how "
        "each pushes the numbers bigger or smaller, before deciding on the actual dimensions. "
        "Keep rationale to one short sentence too, under 30 words:\n"
        '{"sizing_reasoning": "<one sentence, under 30 words, reasoning through weight/height/skill '
        'percentiles and how each affects size>", "length_ft": <number>, "width_in": <number>, '
        '"thickness_in": <number>, "volume_l": <number>, "tail": "<tail shape>", '
        '"fins": "<fin setup>", "construction": "<construction type>", "rationale": "<one short '
        'sentence, under 30 words, on why this board, especially anything unconventional, suits '
        'this specific surfer>"}'
    )
    return instruction


def parse_custom_board_response(text):
    """Verbatim from the notebook's Part 4 (cell 127)."""
    m = re.search(r"\{.*\}", text, re.DOTALL)
    if not m:
        return None
    try:
        data = json.loads(m.group())
    except json.JSONDecodeError:
        return None
    required = {"sizing_reasoning", "length_ft", "width_in", "thickness_in", "volume_l",
                "tail", "fins", "construction", "rationale"}
    if not required.issubset(data.keys()):
        return None
    try:
        for field in ["length_ft", "width_in", "thickness_in", "volume_l"]:
            data[field] = float(data[field])
    except (TypeError, ValueError):
        return None
    return data


def fallback_custom_board(target_category):
    """Used on the minority of calls where neither attempt below parses, so the app always
    returns something usable rather than an error."""
    r = CATEGORY_RANGES.loc[target_category]
    cat_boards = boards_df[boards_df["category"] == target_category]
    spec = {
        "sizing_reasoning": "",
        "length_ft": round((r["len_lo"] + r["len_hi"]) / 2, 1),
        "width_in": round((r["width_lo"] + r["width_hi"]) / 2, 2),
        "thickness_in": round((r["thick_lo"] + r["thick_hi"]) / 2, 2),
        "volume_l": round((r["vol_lo"] + r["vol_hi"]) / 2, 1),
        "tail": cat_boards["tail"].mode().iat[0],
        "fins": cat_boards["fins"].mode().iat[0],
        "construction": cat_boards["construction"].mode().iat[0],
        "rationale": "A solidly built, category-typical board while our AI designer catches up - "
                     "click Find My Boards again for a more personalized custom spec.",
    }
    spec["_fallback"] = True
    return spec


@spaces.GPU(duration=5)
def _zerogpu_keepalive():
    """Never actually called - it exists only so Hugging Face's ZeroGPU hardware tier (the
    only free option available when this Space was created) passes its startup check, which
    requires at least one @spaces.GPU-decorated function to be present in the app. Real
    generation below goes through Hugging Face's hosted Inference API instead, which runs on
    that provider's own infrastructure and never touches this Space's GPU allocation - this
    is what actually avoids the ZeroGPU worker crash, not this function."""
    return True


def _run_generation(prompt):
    """Calls Hugging Face's hosted Inference API (via the Featherless AI provider, confirmed
    to serve this model on its Hub page) so generation runs on that provider's own servers,
    not on this Space - the ZeroGPU worker-allocation problem never gets a chance to happen."""
    completion = gen_client.chat_completion(
        messages=[{"role": "user", "content": prompt}],
        model=GEN_MODEL_NAME,
        max_tokens=400,
        temperature=0.8,
    )
    return completion.choices[0].message.content


def generate_custom_board(rider, target_category, max_attempts=2):
    """Verbatim from the notebook's Part 4 (cell 127), plus the app-only fallback for when
    both attempts fail to parse (or the API call itself fails/times out/rate-limits)."""
    prompt = build_custom_board_prompt(rider, target_category)
    for _ in range(max_attempts):
        try:
            raw_text = _run_generation(prompt)
        except Exception as e:
            print(f"Generation API call failed: {e}")
            continue
        spec = parse_custom_board_response(raw_text)
        if spec is not None:
            spec["_fallback"] = False
            return spec
    return fallback_custom_board(target_category)


# ── UI rendering ──────────────────────────────────────────────────────────────

def score_badge(display_pct):
    """display_pct is already rescaled (see rescale_display_scores) to a range calibrated
    against the boards actually being shown, so it always reads as a meaningful comparison
    rather than an intimidatingly low absolute number. Rendered as a rubber-stamp badge -
    a rotated circle with the percentage and a short tier word (no bare unqualified low
    number, and no repeated "Match" filling up the small stamp)."""
    if display_pct >= 85:
        cls, label = "stamp-great", "GREAT"
    elif display_pct >= 70:
        cls, label = "stamp-good", "GOOD"
    else:
        cls, label = "stamp-solid", "SOLID"
    return f'<div class="board-stamp {cls}">{display_pct:.0f}%<br>{label}</div>'


def rescale_display_scores(scores):
    """Rescales a set of raw combined_score values (which have no inherent 0-100 'quality'
    meaning on their own) into a 60-98% band relative to the boards actually being shown,
    so the badge always communicates 'how this one compares to your other results' rather
    than an absolute score that can look unflatteringly low for an otherwise solid match."""
    lo, hi = scores.min(), scores.max()
    if hi == lo:
        return pd.Series(82.0, index=scores.index)
    return 60 + 38 * (scores - lo) / (hi - lo)


def clean_model_name(brand, model_name):
    """The catalog's model_name already includes the brand (e.g. 'Al Merrick Puddle Jumper'
    for brand 'Al Merrick'), so showing both verbatim duplicated the brand - this strips it."""
    if model_name.lower().startswith(brand.lower()):
        return model_name[len(brand):].strip()
    return model_name


def board_card(board, rank, display_pct, target_category):
    """A single die-cut, sticker-shaped board card (the "Board Rack" surf-shop poster look).
    The category line is always shown, since it's the clearest way to flag the occasional
    cross-category pick the recommender now allows (see CATEGORY_BONUS above) - when it
    differs from the search's own target category, we mark it "outside category"."""
    category_label = board['category'].title()
    if board['category'] != target_category:
        category_label += ' <span class="outside-tag">&middot; outside category</span>'
    return f"""
    <div class="board-card">
      {score_badge(display_pct)}
      <div class="board-card-rank">#{rank}</div>
      <div class="board-card-brand">{board['brand']}</div>
      <div class="board-card-title">{clean_model_name(board['brand'], board['model_name'])}</div>
      <div class="board-card-category">{category_label}</div>
      <div class="board-card-spec">
        {board['length_ft']}ft &middot; {board['width_in']}in &middot; {board['thickness_in']}in &middot; {board['volume_l']}L
      </div>
      <div class="board-card-tags">
        <span class="tag-chip">{board['tail']}</span>
        <span class="tag-chip">{board['fins']}</span>
        <span class="tag-chip">{board['construction']}</span>
      </div>
    </div>"""


def custom_card(spec, target_category):
    if spec.get("_fallback"):
        note = '<div class="fallback-note">Our AI designer had trouble this time, so here\'s a solid category-typical spec instead.</div>'
    else:
        note = f'<div class="rationale"><strong>Why:</strong> {spec.get("sizing_reasoning", "")}</div>'
    return f"""
    <div class="board-card custom-card">
      <div class="board-stamp stamp-ai">AI<br>MADE</div>
      <div class="board-card-brand">AI Generated</div>
      <div class="board-card-title">Your Custom Board</div>
      <div class="board-card-category">{target_category.title()}</div>
      <div class="board-card-spec">
        {spec['length_ft']}ft &middot; {spec['width_in']}in &middot; {spec['thickness_in']}in &middot; {spec['volume_l']}L
      </div>
      <div class="board-card-tags">
        <span class="tag-chip">{spec['tail']}</span>
        <span class="tag-chip">{spec['fins']}</span>
        <span class="tag-chip">{spec['construction']}</span>
      </div>
      <div class="rationale">{spec['rationale']}</div>
      {note}
    </div>"""


def find_boards(height_cm, weight_kg, skill_score, goal_label, conditions, current_style, extra_notes=""):
    goal_key, goal_text = GOAL_OPTIONS[goal_label]
    target_category, recs = recommend_boards(
        height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text, extra_notes,
    )
    rider = {
        "height_cm": height_cm, "weight_kg": weight_kg, "skill_score": skill_score,
        "experience": experience_for_skill(skill_score), "current_style": current_style,
        "conditions": conditions, "goal_text": goal_text, "extra_notes": extra_notes,
    }
    custom = generate_custom_board(rider, target_category)

    display_pcts = rescale_display_scores(recs["combined_score"])

    cards_html = '<div class="results-head">RECOMMENDED: ' + target_category.upper() + '</div>'
    cards_html += '<div class="cards-grid">'
    for i, ((_, board), display_pct) in enumerate(zip(recs.iterrows(), display_pcts), 1):
        cards_html += board_card(board, i, display_pct, target_category)
    cards_html += custom_card(custom, target_category)
    cards_html += '</div>'
    return cards_html


QUICK_STARTERS = {
    "Never surfed before": (163, 65, 5,
        "I'm brand new to surfing and want my first board", "small mushy beach breaks", NO_BOARD_YET,
        "I'm a little nervous about duck-diving under waves and want something easy and forgiving."),
    "Beginner wanting stability": (168, 72, 15,
        "Get more stability and easier paddling", "small mushy beach breaks", "performance shortboard", ""),
    "Advanced wanting speed": (178, 75, 78,
        "Get a faster, more responsive board for sharper turns", "powerful reef breaks", "all-around funboard", ""),
    "Longboarder chasing big waves": (182, 85, 60,
        "Ride a board that can handle bigger, more powerful waves", "big open-ocean swell", "longboard cruiser", ""),
}

CSS = """
@import url('https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Barlow:wght@400;500;600;700&display=swap');

:root {
  --navy: #0d2b36;
  --blue: #134a5e;
  --yellow: #f2b705;
  --coral: #ff6a3d;
  --cream: #fdf6e3;
  --rust: #c1440e;
  --page-bg: #f3ead9;
  --muted: #5a6f77;
}

/* overflow-x:hidden on both body and the app container is a belt-and-suspenders fix for
   a stray horizontal scrollbar some browsers showed - nothing in this layout is meant to
   scroll sideways, so clipping any accidental overflow is always the right call here. */
body { overflow-x: hidden !important; }
.gradio-container { background: var(--page-bg) !important; font-family: 'Barlow', sans-serif !important;
  overflow-x: hidden !important; }
.gradio-container * { font-family: 'Barlow', sans-serif; }

/* ── The whole app sits on one big screen-printed poster sheet ────────────── */
.poster-shell {
  position: relative;
  overflow: hidden;
  background: var(--blue);
  border-radius: 20px;
  padding: 28px 26px 34px;
  margin-bottom: 4px;
}
/* !important on position/z-index guarantees these stay absolute + behind the real
   content no matter what a later broad selector (like the one below, which lifts
   every other direct child above them) does - Chromium paints a plain negative
   z-index behind the shell's own opaque background, which hid these entirely, so
   they're pinned to z-index:0 while real content is lifted to z-index:1 instead. */
.blob-1, .blob-2 { position: absolute !important; z-index: 0 !important; border-radius: 50%; pointer-events: none; }
.blob-1 { width: 300px; height: 300px; background: var(--yellow); top: -140px; right: -100px; opacity: 0.9; }
.blob-2 { width: 200px; height: 200px; background: var(--coral); bottom: -110px; left: -80px; opacity: 0.85; }

.poster-shell > * { position: relative; z-index: 1; }
/* Exception: the very first child is the gr.HTML block holding the two decorative blobs -
   Gradio wraps that call in its own auto-generated ".prose" div, which has no real content
   of its own (both its children are position:absolute). Left subject to the rule above, that
   wrapper becomes a new positioning root for the blobs and collapses to near-zero height,
   which drags both blobs into a thin sliver at the top of the page. Excluding just this
   first child keeps it out of the flow entirely so the blobs stay positioned against
   .poster-shell itself, where they were always meant to sit. */
.poster-shell > *:first-child { position: static !important; z-index: auto !important; }

.brand-row { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; }
.brand-word {
  font-family: 'Alfa Slab One', cursive; font-size: 34px; color: #fff;
  text-shadow: 3px 3px 0 var(--navy); letter-spacing: 0.01em; line-height: 1;
}
.brand-tag {
  background: #fff; color: var(--navy); font-weight: 600; font-size: 13px;
  padding: 10px 14px; border-radius: 10px; border: 2.5px solid var(--navy);
  max-width: 340px; line-height: 1.4;
}
.stat-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.stat-chip {
  background: rgba(255,255,255,0.18); color: #fff; border: 1.5px solid rgba(255,255,255,0.5);
  border-radius: 20px; padding: 4px 12px; font-size: 11.5px; font-weight: 600;
}

.qs-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.qs-btn {
  background: #fff !important;
  border: 2.5px solid var(--navy) !important;
  border-radius: 20px !important;
  color: var(--navy) !important;
  font-weight: 700 !important;
  font-size: 12.5px !important;
  box-shadow: none !important;
  transition: transform 0.1s ease, background 0.1s ease !important;
}
.qs-btn:hover { background: var(--yellow) !important; transform: translateY(-1px); }

/* ── Input panel: a thick-bordered card floating on the poster ────────────── */
.input-panel { background: #fff !important; border: 2.5px solid var(--navy) !important;
  border-radius: 14px !important; padding: 16px !important; }
/* Gradio marks every component's own label span with data-testid="block-info" regardless
   of component type (Slider/Textbox additionally wrap it in a <label>, Dropdown doesn't) -
   targeting that attribute directly is what actually catches all of them consistently,
   rather than relying on a <label> ancestor that Dropdown doesn't have. */
.input-panel label, .input-panel [data-testid="block-info"] { color: var(--navy) !important; font-weight: 700 !important;
  font-size: 12px !important; text-transform: uppercase; letter-spacing: 0.04em; }
/* The line above assumes the panel's own background is showing through (confirmed correct
   in every theme this was actually tested in) - but Hugging Face's embed loads the app at
   `__theme=system`, and on a visitor whose system is in dark mode, Gradio adds a `dark`
   class to an ancestor of the app and its own internal grouping wrapper around these fields
   (independent of our `.input-panel` background rule above) then paints itself in dark
   colors, leaving navy-on-dark just as unreadable as the original dark-on-dark bug. Keying
   the label color off `.dark` (the same ancestor class Gradio's own built-in dark-theme
   rules key off, confirmed by inspecting Gradio's own generated CSS) guarantees a readable
   label in both cases without touching the light-theme styling already confirmed working.
   Deliberately NOT written as "body.dark" - Gradio's CSS-injection pipeline rewrites any
   custom rule containing the literal word "body" and mishandles a compound "body.dark"
   selector, silently breaking it (confirmed by inspecting the actual injected stylesheet).
   The repeated ".dark" below isn't a typo - Gradio's own CSS pipeline duplicates the rule
   above with an auto-added ".gradio-container-5-9-1 .contain" prefix (confirmed by reading
   the actual injected stylesheet), which raises ITS specificity, so a single ".dark" here
   isn't enough to reliably win against it - repeating the class raises this rule's own
   specificity comfortably past that, regardless of what Gradio's rewriter does to either
   copy. */
.dark.dark.dark.dark.dark .input-panel label,
.dark.dark.dark.dark.dark .input-panel [data-testid="block-info"] {
  color: #fff !important;
}
/* Explicit background AND text color on every form control - don't rely on Gradio's
   theme (light vs dark) leaving these readable on its own, since that's exactly what
   went wrong before: dark theme's own dark input background + our navy text color
   stacked into invisible dark-on-dark. */
.input-panel input,
.input-panel textarea,
.input-panel select,
.input-panel [data-testid="textbox"] textarea,
.input-panel ul, .input-panel li {
  font-family: 'Barlow', sans-serif !important;
  background: #fff !important;
  color: var(--navy) !important;
  border: 1.5px solid #cfd8db !important;
  border-radius: 8px !important;
}
.input-panel input::placeholder, .input-panel textarea::placeholder {
  color: #8a97a0 !important; opacity: 1 !important;
}
.input-panel input[type="range"] { accent-color: var(--coral) !important; background: transparent !important; border: none !important; }
.input-panel input[type="number"] { border-radius: 8px !important; }

.welcome-block { margin: 4px 0 20px; }
.welcome-line { color: #fff; font-size: 15px; font-weight: 600; max-width: 460px; line-height: 1.55; }
.cta-btn { font-size: 17px !important; padding: 16px 20px !important; max-width: 300px; }

.find-btn {
  background: var(--coral) !important; border: 2.5px solid var(--navy) !important;
  border-radius: 12px !important; color: #fff !important; font-weight: 700 !important;
  font-size: 15px !important; text-transform: uppercase; letter-spacing: 0.03em;
  box-shadow: 4px 4px 0 rgba(13,43,54,0.5) !important;
}
.find-btn:hover { transform: translate(1px, 1px); box-shadow: 3px 3px 0 rgba(13,43,54,0.5) !important; }

/* ── Results ────────────────────────────────────────────────────────────────── */
.results-head {
  font-family: 'Alfa Slab One', cursive; font-size: 16px; color: #fff;
  text-shadow: 2px 2px 0 var(--navy); margin-bottom: 14px;
}

.cards-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 18px;
}

/* Die-cut, sticker-shaped board card */
.board-card {
  background: var(--cream);
  border: 3px solid var(--navy);
  border-radius: 4px 16px 4px 16px;
  padding: 16px;
  position: relative;
  box-shadow: 5px 5px 0 rgba(13,43,54,0.35);
}
.board-card-rank { position: absolute; top: 10px; left: 14px; font-family: 'Alfa Slab One', cursive;
  font-size: 12px; color: var(--muted); }
.board-stamp {
  position: absolute; top: -16px; right: 10px;
  border: 2.5px solid var(--navy); border-radius: 50%;
  width: 54px; height: 54px; display: flex; align-items: center; justify-content: center;
  flex-direction: column; text-align: center; line-height: 1.15;
  font-weight: 700; font-size: 11px; color: #fff; transform: rotate(8deg);
}
.stamp-great { background: #1a7f37; }
.stamp-good { background: var(--coral); }
.stamp-solid { background: #b06e00; }
.stamp-ai { background: var(--navy); }

.board-card-brand { color: var(--rust); font-size: 11px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.05em; margin: 16px 0 2px; }
.board-card-title { font-family: 'Alfa Slab One', cursive; font-size: 17px; color: var(--navy);
  margin: 0 0 8px; line-height: 1.15; }
.board-card-category { font-size: 11.5px; font-weight: 700; color: var(--muted); text-transform: uppercase;
  letter-spacing: 0.03em; margin-bottom: 9px; }
.outside-tag { color: var(--rust); font-weight: 600; text-transform: none; letter-spacing: 0; }
.board-card-spec { font-weight: 600; font-size: 12.5px; color: #3d5158; margin-bottom: 10px; }
.board-card-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
.tag-chip { background: var(--navy); color: #fff; font-size: 10.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }

.custom-card { background: #fff; border-style: dashed; }
.rationale { font-size: 12px; color: var(--muted); line-height: 1.5; font-weight: 500; margin-top: 8px; }
.fallback-note { font-size: 11px; color: var(--muted); margin-top: 6px; }
"""

BLOBS_HTML = '<div class="blob-1"></div><div class="blob-2"></div>'

HEADER_HTML = """
<div class="brand-row">
  <div class="brand-word">SurfMatch</div>
  <div class="brand-tag">Tell us about yourself and we'll match you with 3 real boards from
  our catalog, plus one custom, AI-designed board shaped just for you.</div>
</div>
<div class="stat-pills">
  <span class="stat-chip">150 boards</span>
  <span class="stat-chip">15,000 rider profiles analyzed</span>
  <span class="stat-chip">AI-powered</span>
</div>
"""

WELCOME_HTML = """
<div class="welcome-block">
  <div class="welcome-line">Answer a few quick questions about your height, weight, and how
  you like to surf, and we'll do the rest.</div>
</div>
"""

# Forces the light theme this app was designed around, regardless of the visitor's
# system/browser dark-mode setting - without this, Gradio's default dark theme bleeds
# through on the input panel and clashes with the custom CSS above.
FORCE_LIGHT_JS = """
function refresh() {
    const url = new URL(window.location);
    if (url.searchParams.get('__theme') !== 'light') {
        url.searchParams.set('__theme', 'light');
        window.location.href = url.href;
    }
}
"""

with gr.Blocks(title="SurfMatch", css=CSS, js=FORCE_LIGHT_JS) as demo:
    with gr.Column(elem_classes="poster-shell"):
        gr.HTML(BLOBS_HTML)
        gr.HTML(HEADER_HTML)

        with gr.Column(visible=True) as welcome_section:
            gr.HTML(WELCOME_HTML)
            start_btn = gr.Button("Let's Find Your Board", elem_classes="find-btn cta-btn")

        with gr.Column(visible=False) as form_section:
            with gr.Row(elem_classes="qs-row"):
                qs_buttons = [gr.Button(label, size="sm", elem_classes="qs-btn") for label in QUICK_STARTERS]
            with gr.Row():
                with gr.Column(scale=2, elem_classes="input-panel"):
                    height = gr.Slider(140, 210, value=175, step=1, label="Height (cm)")
                    weight = gr.Slider(40, 130, value=72, step=1, label="Weight (kg)")
                    skill = gr.Slider(1, 100, value=50, step=1, label="Skill level")
                    goal = gr.Dropdown(list(GOAL_OPTIONS.keys()), value=list(GOAL_OPTIONS.keys())[0], label="Your goal")
                    conditions = gr.Dropdown(CONDITIONS, value=CONDITIONS[0], label="Typical conditions")
                    current_style = gr.Dropdown(CURRENT_STYLE, value=CURRENT_STYLE[0], label="Current board")
                    extra_notes = gr.Textbox(
                        label="Anything else about you or the board you're picturing? (optional)",
                        placeholder="e.g. I want something colorful, I'm nervous about duck-diving, "
                                    "I want a board that turns heads...",
                        lines=2,
                    )
                    submit = gr.Button("Find My Boards", elem_classes="find-btn")
                with gr.Column(scale=3):
                    output = gr.HTML()

    # "Let's Find Your Board" swaps the welcome screen out for the actual form, instead of
    # showing every slider/dropdown up front.
    start_btn.click(
        lambda: (gr.update(visible=False), gr.update(visible=True)),
        outputs=[welcome_section, form_section],
    )

    all_inputs = [height, weight, skill, goal, conditions, current_style, extra_notes]
    submit.click(find_boards, inputs=all_inputs, outputs=output)

    for label, (h, w, s, g, c, cs, notes) in QUICK_STARTERS.items():
        idx = list(QUICK_STARTERS.keys()).index(label)
        qs_buttons[idx].click(
            lambda h=h, w=w, s=s, g=g, c=c, cs=cs, notes=notes: (h, w, s, g, c, cs, notes),
            outputs=all_inputs,
        ).then(find_boards, inputs=all_inputs, outputs=output)

demo.launch()