Yoad22 commited on
Commit
3cef34a
Β·
verified Β·
1 Parent(s): f162af6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +290 -184
app.py CHANGED
@@ -20,9 +20,11 @@ GEN_PROVIDER = "featherless-ai" # confirmed on the model's Hub page as an Infer
20
 
21
  CONDITIONS = ["small mushy beach breaks", "medium clean point breaks",
22
  "powerful reef breaks", "big open-ocean swell"]
23
- CURRENT_STYLE = ["longboard cruiser", "all-around funboard", "fish / groveler",
 
24
  "performance shortboard", "step-up / big-wave gun"]
25
  GOAL_OPTIONS = {
 
26
  "Get more comfortable on the board I already ride": ("stay_and_improve", "get more comfortable and consistent on the board I already ride"),
27
  "Move toward something shorter and more maneuverable": ("transition_to_shortboard", "move from a longer, more stable board toward something shorter and more maneuverable"),
28
  "Move toward a longer, more stable board": ("transition_to_longboard", "move toward a longer, more stable board for easier paddling and glide"),
@@ -39,6 +41,14 @@ STYLE_FOR_PROMPT = {
39
  "step-up / big-wave gun": "step-up gun",
40
  }
41
 
 
 
 
 
 
 
 
 
42
  # Same skill -> experience-label ranges used to generate rider_profiles.csv in Part 1,
43
  # used here only to turn the UI's single skill slider into an "experience" word for the
44
  # Part 4 prompt, which expects one (the real notebook's riders already carry both fields).
@@ -92,6 +102,8 @@ CATEGORY_RANGES = boards_df.groupby("category").agg(
92
 
93
  def compute_style_fit(goal_key, skill_score, current_style):
94
  """Same rule used to build style_fit_truth in Part 1 and to pick a category in Part 3/4."""
 
 
95
  if goal_key == "transition_to_shortboard":
96
  return "performance shortboard" if skill_score >= 55 else "fish / groveler"
97
  if goal_key == "transition_to_longboard":
@@ -102,7 +114,9 @@ def compute_style_fit(goal_key, skill_score, current_style):
102
  return "performance shortboard" if skill_score >= 60 else "fish / groveler"
103
  if goal_key in ("want_easier_paddling", "want_more_stability"):
104
  return "longboard cruiser" if skill_score < 40 else "all-around funboard"
105
- return current_style
 
 
106
 
107
 
108
  def pct(value, lo, hi):
@@ -143,16 +157,32 @@ def pick_diverse(candidates, score_col, k=3):
143
  return pd.DataFrame(picked)
144
 
145
 
146
- def build_query_text(height_cm, weight_kg, skill_score, current_style, conditions, goal_text):
147
  """Mirrors the phrasing of the real profile_text used to train/evaluate the embeddings,
148
  so a user's form inputs land in the same region of embedding space as real riders."""
149
- return (
150
  f"I'm {height_cm}cm tall and {weight_kg}kg, with a skill level of {skill_score}/100. "
151
- f"I currently ride a {current_style} and surf {conditions}, and I want to {goal_text}."
152
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
 
155
- def recommend_boards(height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text, k=3):
 
156
  target_category = compute_style_fit(goal_key, skill_score, current_style)
157
 
158
  weight_pct = pct(weight_kg, W_LO, W_HI)
@@ -161,17 +191,27 @@ def recommend_boards(height_cm, weight_kg, skill_score, current_style, condition
161
  volume_need = 0.5 * weight_pct + 0.5 * forgiveness_pct
162
  length_need = 0.5 * height_pct + 0.5 * forgiveness_pct
163
 
164
- candidates = boards_df[boards_df["category"] == target_category].copy()
165
  cand_embs = BOARD_EMBS[candidates.index.values]
166
 
167
- query_text = build_query_text(height_cm, weight_kg, skill_score, current_style, conditions, goal_text)
 
168
  query_emb = embed_model.encode([query_text], normalize_embeddings=True)[0]
169
  candidates["similarity"] = cand_embs @ query_emb
170
 
171
- candidates["volume_score"] = 1 - (volume_need - within_category_pct(candidates["volume_l"])).abs()
172
- candidates["length_score"] = 1 - (length_need - within_category_pct(candidates["length_ft"])).abs()
 
 
 
 
173
  candidates["physical_fit"] = 0.5 * candidates["volume_score"] + 0.5 * candidates["length_score"]
174
- candidates["combined_score"] = 0.5 * candidates["similarity"] + 0.5 * candidates["physical_fit"]
 
 
 
 
 
175
 
176
  return target_category, pick_diverse(candidates, "combined_score", k=k)
177
 
@@ -186,8 +226,9 @@ def recommend_boards(height_cm, weight_kg, skill_score, current_style, condition
186
  # the app usable on the ~10% of calls that don't parse.
187
 
188
  def build_custom_board_prompt(rider, target_category):
189
- """Verbatim from the project notebook's Part 4 (cell 127) - the exact prompt behind the
190
- documented 90% parse rate and the correlation numbers in the Space README."""
 
191
  r = CATEGORY_RANGES.loc[target_category]
192
  weight_pct = pct(rider["weight_kg"], W_LO, W_HI)
193
  height_pct = pct(rider["height_cm"], H_LO, H_HI)
@@ -195,10 +236,12 @@ def build_custom_board_prompt(rider, target_category):
195
 
196
  facts = (
197
  f"Surfer facts - height {rider['height_cm']}cm, weight {rider['weight_kg']}kg, "
198
- f"{rider['experience']} (skill {rider['skill_score']}/100), currently rides "
199
- f"a {STYLE_FOR_PROMPT[rider['current_style']]}, surfs {rider['conditions']}, "
200
- f"wants to: {rider['goal_text']}. Closest matching standard category: {target_category}."
201
  )
 
 
202
  percentile_hint = (
203
  f"Relative to other surfers in our data, this person is heavier than about {weight_pct:.0%} "
204
  f"of them, taller than about {height_pct:.0%} of them, and more skilled than about "
@@ -321,36 +364,62 @@ def generate_custom_board(rider, target_category, max_attempts=2):
321
 
322
  # ── UI rendering ──────────────────────────────────────────────────────────────
323
 
324
- def score_badge(score):
325
- pct_val = score * 100
326
- if pct_val >= 65:
327
- cls, label = "badge-great", "Great fit"
328
- elif pct_val >= 50:
329
- cls, label = "badge-good", "Good fit"
 
 
 
 
330
  else:
331
- cls, label = "badge-fair", "Fair fit"
332
- return f'<span class="score-badge {cls}">{label} &middot; {pct_val:.0f}%</span>'
333
 
334
 
335
- def board_card(board, rank):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  return f"""
337
  <div class="board-card">
 
338
  <div class="board-card-rank">#{rank}</div>
339
- <div class="board-card-body">
340
- <div class="board-card-title">{board['brand']} {board['model_name']}</div>
341
- <div class="board-card-category">{board['category'].title()}</div>
342
- <div class="board-card-stats">
343
- <span>{board['length_ft']}ft</span><span>&middot;</span>
344
- <span>{board['width_in']}in</span><span>&middot;</span>
345
- <span>{board['thickness_in']}in</span><span>&middot;</span>
346
- <span>{board['volume_l']}L</span>
347
- </div>
348
- <div class="board-card-chips">
349
- <span class="chip">{board['tail']}</span>
350
- <span class="chip">{board['fins']}</span>
351
- <span class="chip">{board['construction']}</span>
352
- </div>
353
- {score_badge(board['combined_score'])}
354
  </div>
355
  </div>"""
356
 
@@ -362,176 +431,205 @@ def custom_card(spec, target_category):
362
  note = f'<div class="rationale"><strong>Why:</strong> {spec.get("sizing_reasoning", "")}</div>'
363
  return f"""
364
  <div class="board-card custom-card">
365
- <div class="ai-badge">&#10024; AI Generated</div>
366
- <div class="board-card-body">
367
- <div class="board-card-title">Your Custom Board</div>
368
- <div class="board-card-category">{target_category.title()}</div>
369
- <div class="board-card-stats">
370
- <span>{spec['length_ft']}ft</span><span>&middot;</span>
371
- <span>{spec['width_in']}in</span><span>&middot;</span>
372
- <span>{spec['thickness_in']}in</span><span>&middot;</span>
373
- <span>{spec['volume_l']}L</span>
374
- </div>
375
- <div class="board-card-chips">
376
- <span class="chip chip-accent">{spec['tail']}</span>
377
- <span class="chip chip-accent">{spec['fins']}</span>
378
- <span class="chip chip-accent">{spec['construction']}</span>
379
- </div>
380
- <div class="rationale">{spec['rationale']}</div>
381
- {note}
382
  </div>
 
 
383
  </div>"""
384
 
385
 
386
- def find_boards(height_cm, weight_kg, skill_score, goal_label, conditions, current_style):
387
  goal_key, goal_text = GOAL_OPTIONS[goal_label]
388
  target_category, recs = recommend_boards(
389
- height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text,
390
  )
391
  rider = {
392
  "height_cm": height_cm, "weight_kg": weight_kg, "skill_score": skill_score,
393
  "experience": experience_for_skill(skill_score), "current_style": current_style,
394
- "conditions": conditions, "goal_text": goal_text,
395
  }
396
  custom = generate_custom_board(rider, target_category)
397
 
398
- cards_html = '<div class="results-intro">Recommended category: <strong>' + target_category.title() + '</strong></div>'
 
 
399
  cards_html += '<div class="cards-grid">'
400
- for i, (_, board) in enumerate(recs.iterrows(), 1):
401
- cards_html += board_card(board, i)
402
  cards_html += custom_card(custom, target_category)
403
  cards_html += '</div>'
404
  return cards_html
405
 
406
 
407
  QUICK_STARTERS = {
408
- "Beginner wanting stability": (168, 72, 15, "Get more stability and easier paddling", "small mushy beach breaks", "performance shortboard"),
409
- "Advanced wanting speed": (178, 75, 78, "Get a faster, more responsive board for sharper turns", "powerful reef breaks", "all-around funboard"),
410
- "Longboarder chasing big waves": (182, 85, 60, "Ride a board that can handle bigger, more powerful waves", "big open-ocean swell", "longboard cruiser"),
 
 
 
 
 
 
411
  }
412
 
413
  CSS = """
414
- @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&family=Inter:wght@400;500;600&display=swap');
415
 
416
  :root {
417
- --ocean-deep: #073b4c;
418
- --ocean-mid: #0e7c9c;
419
- --ocean-light: #4fc4d9;
420
- --sand: #fdf6ec;
421
- --coral: #ff6b5b;
422
- --ink: #1c2b33;
423
- --muted: #6b7c85;
 
424
  }
425
 
426
- .gradio-container { background: var(--sand) !important; font-family: 'Inter', sans-serif !important; }
 
427
 
428
- .header-box {
429
- background: linear-gradient(135deg, var(--ocean-deep) 0%, var(--ocean-mid) 100%);
430
- border-radius: 18px;
431
- padding: 32px 36px;
432
- margin-bottom: 12px;
433
- box-shadow: 0 8px 24px rgba(7, 59, 76, 0.25);
 
 
434
  }
435
- .header-box h1 {
436
- font-family: 'Poppins', sans-serif;
437
- margin: 0 0 6px 0;
438
- font-size: 30px;
439
- font-weight: 700;
440
- color: white;
 
 
 
 
 
 
 
 
 
441
  }
442
- .header-box p {
443
- margin: 0 0 16px 0;
444
- color: rgba(255,255,255,0.88);
445
- font-size: 14px;
446
- line-height: 1.5;
447
- max-width: 560px;
448
  }
449
- .chips-row { display: flex; gap: 8px; flex-wrap: wrap; }
450
  .stat-chip {
451
- background: rgba(255,255,255,0.16);
452
- color: white;
453
- border-radius: 20px;
454
- padding: 5px 14px;
455
- font-size: 12px;
456
- font-weight: 500;
457
  }
458
 
459
- .quick-starters-label { font-weight: 600; color: var(--ink); margin-bottom: 4px; font-size: 13px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
- .results-intro {
462
- font-size: 15px; color: var(--ink); margin-bottom: 14px; padding: 10px 16px;
463
- background: white; border-radius: 10px; border: 1px solid #e5e0d8;
 
 
 
 
 
 
 
 
 
464
  }
465
 
466
  .cards-grid {
467
  display: grid;
468
- grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
469
- gap: 16px;
470
  }
471
 
 
472
  .board-card {
473
- background: white;
474
- border-radius: 14px;
475
- padding: 18px;
476
- box-shadow: 0 2px 10px rgba(7, 59, 76, 0.08);
477
- border: 1px solid #eee6d8;
478
  position: relative;
 
479
  }
480
- .board-card-rank {
481
- position: absolute; top: 14px; right: 14px;
482
- background: var(--ocean-light); color: white;
483
- border-radius: 50%; width: 24px; height: 24px;
484
- display: flex; align-items: center; justify-content: center;
485
- font-size: 12px; font-weight: 700;
486
- }
487
- .board-card-title { font-weight: 700; font-size: 16px; color: var(--ink); margin-bottom: 2px; }
488
- .board-card-category { color: var(--ocean-mid); font-size: 12px; font-weight: 600; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.03em; }
489
- .board-card-stats { display: flex; gap: 6px; color: var(--muted); font-size: 13px; margin-bottom: 10px; }
490
- .board-card-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
491
- .chip { background: #eef6f8; color: var(--ocean-deep); border-radius: 20px; padding: 3px 10px; font-size: 11px; font-weight: 500; }
492
- .chip-accent { background: #fff0ee; color: var(--coral); }
493
-
494
- .score-badge { display: inline-block; border-radius: 20px; padding: 4px 12px; font-size: 12px; font-weight: 600; }
495
- .badge-great { background: #e3f9e5; color: #1a7f37; }
496
- .badge-good { background: #fff4e0; color: #b06e00; }
497
- .badge-fair { background: #f1f1f1; color: #666; }
498
-
499
- .custom-card { border: 2px solid var(--coral); }
500
- .ai-badge {
501
- position: absolute; top: -10px; left: 14px;
502
- background: var(--coral); color: white;
503
- border-radius: 20px; padding: 3px 12px; font-size: 11px; font-weight: 700;
504
  }
505
- .rationale { font-size: 13px; color: var(--muted); line-height: 1.5; font-style: italic; margin-top: 4px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  .fallback-note { font-size: 11px; color: var(--muted); margin-top: 6px; }
507
-
508
- .find-btn { background: var(--coral) !important; border: none !important; font-weight: 600 !important; }
509
-
510
- .qs-btn {
511
- background: white !important;
512
- border: 1.5px solid #e5e0d8 !important;
513
- border-radius: 10px !important;
514
- color: var(--ink) !important;
515
- font-weight: 500 !important;
516
- font-size: 13px !important;
517
- transition: all 0.15s ease !important;
518
- }
519
- .qs-btn:hover {
520
- border-color: var(--ocean-mid) !important;
521
- background: #eef6f8 !important;
522
- }
523
  """
524
 
 
 
525
  HEADER_HTML = """
526
- <div class="header-box">
527
- <h1>&#127754; SurfMatch</h1>
528
- <p>Tell us about yourself and we'll match you with 3 real boards from our catalog,
529
- plus one custom, AI-designed board shaped just for you.</p>
530
- <div class="chips-row">
531
- <span class="stat-chip">150 boards</span>
532
- <span class="stat-chip">15,000 rider profiles analyzed</span>
533
- <span class="stat-chip">AI-powered</span>
534
- </div>
535
  </div>
536
  """
537
 
@@ -549,29 +647,37 @@ function refresh() {
549
  """
550
 
551
  with gr.Blocks(title="SurfMatch", css=CSS, js=FORCE_LIGHT_JS) as demo:
552
- gr.HTML(HEADER_HTML)
553
- with gr.Row():
554
- with gr.Column(scale=2):
555
- gr.Markdown('<div class="quick-starters-label">Quick Starters</div>')
556
- with gr.Row():
557
- qs_buttons = [gr.Button(label, size="sm", elem_classes="qs-btn") for label in QUICK_STARTERS]
558
- height = gr.Slider(140, 210, value=175, step=1, label="Height (cm)")
559
- weight = gr.Slider(40, 130, value=72, step=1, label="Weight (kg)")
560
- skill = gr.Slider(1, 100, value=50, step=1, label="Skill level")
561
- goal = gr.Dropdown(list(GOAL_OPTIONS.keys()), value=list(GOAL_OPTIONS.keys())[0], label="Your goal")
562
- conditions = gr.Dropdown(CONDITIONS, value=CONDITIONS[0], label="Typical conditions")
563
- current_style = gr.Dropdown(CURRENT_STYLE, value=CURRENT_STYLE[0], label="Current board")
564
- submit = gr.Button("Find My Boards", elem_classes="find-btn")
565
- with gr.Column(scale=3):
566
- output = gr.HTML()
567
-
568
- submit.click(find_boards, inputs=[height, weight, skill, goal, conditions, current_style], outputs=output)
569
-
570
- for label, (h, w, s, g, c, cs) in QUICK_STARTERS.items():
 
 
 
 
 
 
 
 
571
  idx = list(QUICK_STARTERS.keys()).index(label)
572
  qs_buttons[idx].click(
573
- lambda h=h, w=w, s=s, g=g, c=c, cs=cs: (h, w, s, g, c, cs),
574
- outputs=[height, weight, skill, goal, conditions, current_style],
575
- ).then(find_boards, inputs=[height, weight, skill, goal, conditions, current_style], outputs=output)
576
 
577
- demo.launch()
 
20
 
21
  CONDITIONS = ["small mushy beach breaks", "medium clean point breaks",
22
  "powerful reef breaks", "big open-ocean swell"]
23
+ NO_BOARD_YET = "I don't have a board yet"
24
+ CURRENT_STYLE = [NO_BOARD_YET, "longboard cruiser", "all-around funboard", "fish / groveler",
25
  "performance shortboard", "step-up / big-wave gun"]
26
  GOAL_OPTIONS = {
27
+ "I'm brand new to surfing and want my first board": ("first_time_beginner", "get my very first surfboard and start learning to surf"),
28
  "Get more comfortable on the board I already ride": ("stay_and_improve", "get more comfortable and consistent on the board I already ride"),
29
  "Move toward something shorter and more maneuverable": ("transition_to_shortboard", "move from a longer, more stable board toward something shorter and more maneuverable"),
30
  "Move toward a longer, more stable board": ("transition_to_longboard", "move toward a longer, more stable board for easier paddling and glide"),
 
41
  "step-up / big-wave gun": "step-up gun",
42
  }
43
 
44
+
45
+ def style_clause(current_style):
46
+ """A grammatical clause describing the rider's current board, handling the
47
+ 'no board yet' beginner case separately since it isn't a real board style."""
48
+ if current_style == NO_BOARD_YET:
49
+ return "doesn't have a board yet - this will be their first one"
50
+ return f"currently rides a {STYLE_FOR_PROMPT.get(current_style, current_style)}"
51
+
52
  # Same skill -> experience-label ranges used to generate rider_profiles.csv in Part 1,
53
  # used here only to turn the UI's single skill slider into an "experience" word for the
54
  # Part 4 prompt, which expects one (the real notebook's riders already carry both fields).
 
102
 
103
  def compute_style_fit(goal_key, skill_score, current_style):
104
  """Same rule used to build style_fit_truth in Part 1 and to pick a category in Part 3/4."""
105
+ if goal_key == "first_time_beginner":
106
+ return "longboard cruiser" # the classic, most forgiving starting point
107
  if goal_key == "transition_to_shortboard":
108
  return "performance shortboard" if skill_score >= 55 else "fish / groveler"
109
  if goal_key == "transition_to_longboard":
 
114
  return "performance shortboard" if skill_score >= 60 else "fish / groveler"
115
  if goal_key in ("want_easier_paddling", "want_more_stability"):
116
  return "longboard cruiser" if skill_score < 40 else "all-around funboard"
117
+ # Fallback: stick with the rider's current style - unless they don't have one yet,
118
+ # in which case NO_BOARD_YET isn't a real category, so default to the safest starter.
119
+ return current_style if current_style != NO_BOARD_YET else "longboard cruiser"
120
 
121
 
122
  def pct(value, lo, hi):
 
157
  return pd.DataFrame(picked)
158
 
159
 
160
+ def build_query_text(height_cm, weight_kg, skill_score, current_style, conditions, goal_text, extra_notes=""):
161
  """Mirrors the phrasing of the real profile_text used to train/evaluate the embeddings,
162
  so a user's form inputs land in the same region of embedding space as real riders."""
163
+ text = (
164
  f"I'm {height_cm}cm tall and {weight_kg}kg, with a skill level of {skill_score}/100. "
165
+ f"I {style_clause(current_style)} and surf {conditions}, and I want to {goal_text}."
166
  )
167
+ if extra_notes and extra_notes.strip():
168
+ text += f" {extra_notes.strip()}"
169
+ return text
170
+
171
+
172
+ # App-only enhancement, layered on top of the notebook's evaluated category-first algorithm
173
+ # (Part 3, cell ~ the recommend_boards definition): rather than hard-filtering to only the
174
+ # rule-based target category, every board is scored, with a bonus for matching the target
175
+ # category. Most results still come from that category (the bonus is large), but a board
176
+ # from a neighboring category can occasionally surface if its fit/similarity is strong enough
177
+ # - this is intentionally "sometimes", not a forced quota, per real user feedback that seeing
178
+ # 3-4 near-identical boards felt repetitive. The notebook's own Part 3 evaluation (hit_rate,
179
+ # diversity-ceiling stats in the README) was run against the strict same-category version and
180
+ # is unaffected by this - it documents the core algorithm, this is a UX layer on top of it.
181
+ CATEGORY_BONUS = 0.18
182
 
183
 
184
+ def recommend_boards(height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text,
185
+ extra_notes="", k=3):
186
  target_category = compute_style_fit(goal_key, skill_score, current_style)
187
 
188
  weight_pct = pct(weight_kg, W_LO, W_HI)
 
191
  volume_need = 0.5 * weight_pct + 0.5 * forgiveness_pct
192
  length_need = 0.5 * height_pct + 0.5 * forgiveness_pct
193
 
194
+ candidates = boards_df.copy()
195
  cand_embs = BOARD_EMBS[candidates.index.values]
196
 
197
+ query_text = build_query_text(height_cm, weight_kg, skill_score, current_style, conditions,
198
+ goal_text, extra_notes)
199
  query_emb = embed_model.encode([query_text], normalize_embeddings=True)[0]
200
  candidates["similarity"] = cand_embs @ query_emb
201
 
202
+ # Physical-fit percentiles computed within each board's OWN category (not just the target
203
+ # one), so boards from every category are on the same comparable 0-1 fit scale.
204
+ vol_pct = candidates.groupby("category")["volume_l"].transform(within_category_pct)
205
+ len_pct = candidates.groupby("category")["length_ft"].transform(within_category_pct)
206
+ candidates["volume_score"] = 1 - (volume_need - vol_pct).abs()
207
+ candidates["length_score"] = 1 - (length_need - len_pct).abs()
208
  candidates["physical_fit"] = 0.5 * candidates["volume_score"] + 0.5 * candidates["length_score"]
209
+
210
+ candidates["category_match"] = (candidates["category"] == target_category).astype(float)
211
+ candidates["combined_score"] = (
212
+ 0.5 * candidates["similarity"] + 0.5 * candidates["physical_fit"]
213
+ + CATEGORY_BONUS * candidates["category_match"]
214
+ )
215
 
216
  return target_category, pick_diverse(candidates, "combined_score", k=k)
217
 
 
226
  # the app usable on the ~10% of calls that don't parse.
227
 
228
  def build_custom_board_prompt(rider, target_category):
229
+ """Adapted from the project notebook's Part 4 (cell 127) - the exact prompt behind the
230
+ documented 90% parse rate and the correlation numbers in the Space README, plus an
231
+ optional free-text line from the app's UI (not present in the notebook version)."""
232
  r = CATEGORY_RANGES.loc[target_category]
233
  weight_pct = pct(rider["weight_kg"], W_LO, W_HI)
234
  height_pct = pct(rider["height_cm"], H_LO, H_HI)
 
236
 
237
  facts = (
238
  f"Surfer facts - height {rider['height_cm']}cm, weight {rider['weight_kg']}kg, "
239
+ f"{rider['experience']} (skill {rider['skill_score']}/100), {style_clause(rider['current_style'])}, "
240
+ f"surfs {rider['conditions']}, wants to: {rider['goal_text']}. "
241
+ f"Closest matching standard category: {target_category}."
242
  )
243
+ if rider.get("extra_notes") and rider["extra_notes"].strip():
244
+ facts += f" In their own words, they also said: \"{rider['extra_notes'].strip()}\""
245
  percentile_hint = (
246
  f"Relative to other surfers in our data, this person is heavier than about {weight_pct:.0%} "
247
  f"of them, taller than about {height_pct:.0%} of them, and more skilled than about "
 
364
 
365
  # ── UI rendering ──────────────────────────────────────────────────────────────
366
 
367
+ def score_badge(display_pct):
368
+ """display_pct is already rescaled (see rescale_display_scores) to a range calibrated
369
+ against the boards actually being shown, so it always reads as a meaningful comparison
370
+ rather than an intimidatingly low absolute number. Rendered as a rubber-stamp badge -
371
+ a rotated circle with the percentage and a short tier word (no bare unqualified low
372
+ number, and no repeated "Match" filling up the small stamp)."""
373
+ if display_pct >= 85:
374
+ cls, label = "stamp-great", "GREAT"
375
+ elif display_pct >= 70:
376
+ cls, label = "stamp-good", "GOOD"
377
  else:
378
+ cls, label = "stamp-solid", "SOLID"
379
+ return f'<div class="board-stamp {cls}">{display_pct:.0f}%<br>{label}</div>'
380
 
381
 
382
+ def rescale_display_scores(scores):
383
+ """Rescales a set of raw combined_score values (which have no inherent 0-100 'quality'
384
+ meaning on their own) into a 60-98% band relative to the boards actually being shown,
385
+ so the badge always communicates 'how this one compares to your other results' rather
386
+ than an absolute score that can look unflatteringly low for an otherwise solid match."""
387
+ lo, hi = scores.min(), scores.max()
388
+ if hi == lo:
389
+ return pd.Series(82.0, index=scores.index)
390
+ return 60 + 38 * (scores - lo) / (hi - lo)
391
+
392
+
393
+ def clean_model_name(brand, model_name):
394
+ """The catalog's model_name already includes the brand (e.g. 'Al Merrick Puddle Jumper'
395
+ for brand 'Al Merrick'), so showing both verbatim duplicated the brand - this strips it."""
396
+ if model_name.lower().startswith(brand.lower()):
397
+ return model_name[len(brand):].strip()
398
+ return model_name
399
+
400
+
401
+ def board_card(board, rank, display_pct, target_category):
402
+ """A single die-cut, sticker-shaped board card (the "Board Rack" surf-shop poster look).
403
+ The category line is always shown, since it's the clearest way to flag the occasional
404
+ cross-category pick the recommender now allows (see CATEGORY_BONUS above) - when it
405
+ differs from the search's own target category, we mark it "outside category"."""
406
+ category_label = board['category'].title()
407
+ if board['category'] != target_category:
408
+ category_label += ' <span class="outside-tag">&middot; outside category</span>'
409
  return f"""
410
  <div class="board-card">
411
+ {score_badge(display_pct)}
412
  <div class="board-card-rank">#{rank}</div>
413
+ <div class="board-card-brand">{board['brand']}</div>
414
+ <div class="board-card-title">{clean_model_name(board['brand'], board['model_name'])}</div>
415
+ <div class="board-card-category">{category_label}</div>
416
+ <div class="board-card-spec">
417
+ {board['length_ft']}ft &middot; {board['width_in']}in &middot; {board['thickness_in']}in &middot; {board['volume_l']}L
418
+ </div>
419
+ <div class="board-card-tags">
420
+ <span class="tag-chip">{board['tail']}</span>
421
+ <span class="tag-chip">{board['fins']}</span>
422
+ <span class="tag-chip">{board['construction']}</span>
 
 
 
 
 
423
  </div>
424
  </div>"""
425
 
 
431
  note = f'<div class="rationale"><strong>Why:</strong> {spec.get("sizing_reasoning", "")}</div>'
432
  return f"""
433
  <div class="board-card custom-card">
434
+ <div class="board-stamp stamp-ai">AI<br>MADE</div>
435
+ <div class="board-card-brand">AI Generated</div>
436
+ <div class="board-card-title">Your Custom Board</div>
437
+ <div class="board-card-category">{target_category.title()}</div>
438
+ <div class="board-card-spec">
439
+ {spec['length_ft']}ft &middot; {spec['width_in']}in &middot; {spec['thickness_in']}in &middot; {spec['volume_l']}L
440
+ </div>
441
+ <div class="board-card-tags">
442
+ <span class="tag-chip">{spec['tail']}</span>
443
+ <span class="tag-chip">{spec['fins']}</span>
444
+ <span class="tag-chip">{spec['construction']}</span>
 
 
 
 
 
 
445
  </div>
446
+ <div class="rationale">{spec['rationale']}</div>
447
+ {note}
448
  </div>"""
449
 
450
 
451
+ def find_boards(height_cm, weight_kg, skill_score, goal_label, conditions, current_style, extra_notes=""):
452
  goal_key, goal_text = GOAL_OPTIONS[goal_label]
453
  target_category, recs = recommend_boards(
454
+ height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text, extra_notes,
455
  )
456
  rider = {
457
  "height_cm": height_cm, "weight_kg": weight_kg, "skill_score": skill_score,
458
  "experience": experience_for_skill(skill_score), "current_style": current_style,
459
+ "conditions": conditions, "goal_text": goal_text, "extra_notes": extra_notes,
460
  }
461
  custom = generate_custom_board(rider, target_category)
462
 
463
+ display_pcts = rescale_display_scores(recs["combined_score"])
464
+
465
+ cards_html = '<div class="results-head">RECOMMENDED: ' + target_category.upper() + '</div>'
466
  cards_html += '<div class="cards-grid">'
467
+ for i, ((_, board), display_pct) in enumerate(zip(recs.iterrows(), display_pcts), 1):
468
+ cards_html += board_card(board, i, display_pct, target_category)
469
  cards_html += custom_card(custom, target_category)
470
  cards_html += '</div>'
471
  return cards_html
472
 
473
 
474
  QUICK_STARTERS = {
475
+ "Never surfed before": (163, 65, 5,
476
+ "I'm brand new to surfing and want my first board", "small mushy beach breaks", NO_BOARD_YET,
477
+ "I'm a little nervous about duck-diving under waves and want something easy and forgiving."),
478
+ "Beginner wanting stability": (168, 72, 15,
479
+ "Get more stability and easier paddling", "small mushy beach breaks", "performance shortboard", ""),
480
+ "Advanced wanting speed": (178, 75, 78,
481
+ "Get a faster, more responsive board for sharper turns", "powerful reef breaks", "all-around funboard", ""),
482
+ "Longboarder chasing big waves": (182, 85, 60,
483
+ "Ride a board that can handle bigger, more powerful waves", "big open-ocean swell", "longboard cruiser", ""),
484
  }
485
 
486
  CSS = """
487
+ @import url('https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Barlow:wght@400;500;600;700&display=swap');
488
 
489
  :root {
490
+ --navy: #0d2b36;
491
+ --blue: #134a5e;
492
+ --yellow: #f2b705;
493
+ --coral: #ff6a3d;
494
+ --cream: #fdf6e3;
495
+ --rust: #c1440e;
496
+ --page-bg: #f3ead9;
497
+ --muted: #5a6f77;
498
  }
499
 
500
+ .gradio-container { background: var(--page-bg) !important; font-family: 'Barlow', sans-serif !important; }
501
+ .gradio-container * { font-family: 'Barlow', sans-serif; }
502
 
503
+ /* ── The whole app sits on one big screen-printed poster sheet ────────────── */
504
+ .poster-shell {
505
+ position: relative;
506
+ overflow: hidden;
507
+ background: var(--blue);
508
+ border-radius: 20px;
509
+ padding: 28px 26px 34px;
510
+ margin-bottom: 4px;
511
  }
512
+ /* !important on position/z-index guarantees these stay absolute + behind the real
513
+ content no matter what a later broad selector (like the one below, which lifts
514
+ every other direct child above them) does - Chromium paints a plain negative
515
+ z-index behind the shell's own opaque background, which hid these entirely, so
516
+ they're pinned to z-index:0 while real content is lifted to z-index:1 instead. */
517
+ .blob-1, .blob-2 { position: absolute !important; z-index: 0 !important; border-radius: 50%; pointer-events: none; }
518
+ .blob-1 { width: 300px; height: 300px; background: var(--yellow); top: -140px; right: -100px; opacity: 0.9; }
519
+ .blob-2 { width: 200px; height: 200px; background: var(--coral); bottom: -110px; left: -80px; opacity: 0.85; }
520
+
521
+ .poster-shell > * { position: relative; z-index: 1; }
522
+
523
+ .brand-row { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; }
524
+ .brand-word {
525
+ font-family: 'Alfa Slab One', cursive; font-size: 34px; color: #fff;
526
+ text-shadow: 3px 3px 0 var(--navy); letter-spacing: 0.01em; line-height: 1;
527
  }
528
+ .brand-tag {
529
+ background: #fff; color: var(--navy); font-weight: 600; font-size: 13px;
530
+ padding: 10px 14px; border-radius: 10px; border: 2.5px solid var(--navy);
531
+ max-width: 340px; line-height: 1.4;
 
 
532
  }
533
+ .stat-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
534
  .stat-chip {
535
+ background: rgba(255,255,255,0.18); color: #fff; border: 1.5px solid rgba(255,255,255,0.5);
536
+ border-radius: 20px; padding: 4px 12px; font-size: 11.5px; font-weight: 600;
 
 
 
 
537
  }
538
 
539
+ .qs-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
540
+ .qs-btn {
541
+ background: #fff !important;
542
+ border: 2.5px solid var(--navy) !important;
543
+ border-radius: 20px !important;
544
+ color: var(--navy) !important;
545
+ font-weight: 700 !important;
546
+ font-size: 12.5px !important;
547
+ box-shadow: none !important;
548
+ transition: transform 0.1s ease, background 0.1s ease !important;
549
+ }
550
+ .qs-btn:hover { background: var(--yellow) !important; transform: translateY(-1px); }
551
+
552
+ /* ── Input panel: a thick-bordered card floating on the poster ────────────── */
553
+ .input-panel { background: #fff !important; border: 2.5px solid var(--navy) !important;
554
+ border-radius: 14px !important; padding: 16px !important; }
555
+ .input-panel label span { color: var(--navy) !important; font-weight: 700 !important;
556
+ font-size: 12px !important; text-transform: uppercase; letter-spacing: 0.04em; }
557
+ .input-panel input, .input-panel textarea, .input-panel select {
558
+ font-family: 'Barlow', sans-serif !important; color: var(--navy) !important;
559
+ }
560
+ .input-panel input[type="range"] { accent-color: var(--coral) !important; }
561
 
562
+ .find-btn {
563
+ background: var(--coral) !important; border: 2.5px solid var(--navy) !important;
564
+ border-radius: 12px !important; color: #fff !important; font-weight: 700 !important;
565
+ font-size: 15px !important; text-transform: uppercase; letter-spacing: 0.03em;
566
+ box-shadow: 4px 4px 0 rgba(13,43,54,0.5) !important;
567
+ }
568
+ .find-btn:hover { transform: translate(1px, 1px); box-shadow: 3px 3px 0 rgba(13,43,54,0.5) !important; }
569
+
570
+ /* ── Results ────────────────────────────────────────────────────────────────── */
571
+ .results-head {
572
+ font-family: 'Alfa Slab One', cursive; font-size: 16px; color: #fff;
573
+ text-shadow: 2px 2px 0 var(--navy); margin-bottom: 14px;
574
  }
575
 
576
  .cards-grid {
577
  display: grid;
578
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
579
+ gap: 18px;
580
  }
581
 
582
+ /* Die-cut, sticker-shaped board card */
583
  .board-card {
584
+ background: var(--cream);
585
+ border: 3px solid var(--navy);
586
+ border-radius: 4px 16px 4px 16px;
587
+ padding: 16px;
 
588
  position: relative;
589
+ box-shadow: 5px 5px 0 rgba(13,43,54,0.35);
590
  }
591
+ .board-card-rank { position: absolute; top: 10px; left: 14px; font-family: 'Alfa Slab One', cursive;
592
+ font-size: 12px; color: var(--muted); }
593
+ .board-stamp {
594
+ position: absolute; top: -16px; right: 10px;
595
+ border: 2.5px solid var(--navy); border-radius: 50%;
596
+ width: 54px; height: 54px; display: flex; align-items: center; justify-content: center;
597
+ flex-direction: column; text-align: center; line-height: 1.15;
598
+ font-weight: 700; font-size: 11px; color: #fff; transform: rotate(8deg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  }
600
+ .stamp-great { background: #1a7f37; }
601
+ .stamp-good { background: var(--coral); }
602
+ .stamp-solid { background: #b06e00; }
603
+ .stamp-ai { background: var(--navy); }
604
+
605
+ .board-card-brand { color: var(--rust); font-size: 11px; font-weight: 700; text-transform: uppercase;
606
+ letter-spacing: 0.05em; margin: 16px 0 2px; }
607
+ .board-card-title { font-family: 'Alfa Slab One', cursive; font-size: 17px; color: var(--navy);
608
+ margin: 0 0 8px; line-height: 1.15; }
609
+ .board-card-category { font-size: 11.5px; font-weight: 700; color: var(--muted); text-transform: uppercase;
610
+ letter-spacing: 0.03em; margin-bottom: 9px; }
611
+ .outside-tag { color: var(--rust); font-weight: 600; text-transform: none; letter-spacing: 0; }
612
+ .board-card-spec { font-weight: 600; font-size: 12.5px; color: #3d5158; margin-bottom: 10px; }
613
+ .board-card-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
614
+ .tag-chip { background: var(--navy); color: #fff; font-size: 10.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
615
+
616
+ .custom-card { background: #fff; border-style: dashed; }
617
+ .rationale { font-size: 12px; color: var(--muted); line-height: 1.5; font-weight: 500; margin-top: 8px; }
618
  .fallback-note { font-size: 11px; color: var(--muted); margin-top: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  """
620
 
621
+ BLOBS_HTML = '<div class="blob-1"></div><div class="blob-2"></div>'
622
+
623
  HEADER_HTML = """
624
+ <div class="brand-row">
625
+ <div class="brand-word">SurfMatch</div>
626
+ <div class="brand-tag">Tell us about yourself and we'll match you with 3 real boards from
627
+ our catalog, plus one custom, AI-designed board shaped just for you.</div>
628
+ </div>
629
+ <div class="stat-pills">
630
+ <span class="stat-chip">150 boards</span>
631
+ <span class="stat-chip">15,000 rider profiles analyzed</span>
632
+ <span class="stat-chip">AI-powered</span>
633
  </div>
634
  """
635
 
 
647
  """
648
 
649
  with gr.Blocks(title="SurfMatch", css=CSS, js=FORCE_LIGHT_JS) as demo:
650
+ with gr.Column(elem_classes="poster-shell"):
651
+ gr.HTML(BLOBS_HTML)
652
+ gr.HTML(HEADER_HTML)
653
+ with gr.Row(elem_classes="qs-row"):
654
+ qs_buttons = [gr.Button(label, size="sm", elem_classes="qs-btn") for label in QUICK_STARTERS]
655
+ with gr.Row():
656
+ with gr.Column(scale=2, elem_classes="input-panel"):
657
+ height = gr.Slider(140, 210, value=175, step=1, label="Height (cm)")
658
+ weight = gr.Slider(40, 130, value=72, step=1, label="Weight (kg)")
659
+ skill = gr.Slider(1, 100, value=50, step=1, label="Skill level")
660
+ goal = gr.Dropdown(list(GOAL_OPTIONS.keys()), value=list(GOAL_OPTIONS.keys())[0], label="Your goal")
661
+ conditions = gr.Dropdown(CONDITIONS, value=CONDITIONS[0], label="Typical conditions")
662
+ current_style = gr.Dropdown(CURRENT_STYLE, value=CURRENT_STYLE[0], label="Current board")
663
+ extra_notes = gr.Textbox(
664
+ label="Anything else about you or the board you're picturing? (optional)",
665
+ placeholder="e.g. I want something colorful, I'm nervous about duck-diving, "
666
+ "I want a board that turns heads...",
667
+ lines=2,
668
+ )
669
+ submit = gr.Button("Find My Boards", elem_classes="find-btn")
670
+ with gr.Column(scale=3):
671
+ output = gr.HTML()
672
+
673
+ all_inputs = [height, weight, skill, goal, conditions, current_style, extra_notes]
674
+ submit.click(find_boards, inputs=all_inputs, outputs=output)
675
+
676
+ for label, (h, w, s, g, c, cs, notes) in QUICK_STARTERS.items():
677
  idx = list(QUICK_STARTERS.keys()).index(label)
678
  qs_buttons[idx].click(
679
+ lambda h=h, w=w, s=s, g=g, c=c, cs=cs, notes=notes: (h, w, s, g, c, cs, notes),
680
+ outputs=all_inputs,
681
+ ).then(find_boards, inputs=all_inputs, outputs=output)
682
 
683
+ demo.launch()