Update app.py
Browse files
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 150 |
f"I'm {height_cm}cm tall and {weight_kg}kg, with a skill level of {skill_score}/100. "
|
| 151 |
-
f"I
|
| 152 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
-
def recommend_boards(height_cm, weight_kg, skill_score, current_style, conditions, goal_key, goal_text,
|
|
|
|
| 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
|
| 165 |
cand_embs = BOARD_EMBS[candidates.index.values]
|
| 166 |
|
| 167 |
-
query_text = build_query_text(height_cm, weight_kg, skill_score, current_style, conditions,
|
|
|
|
| 168 |
query_emb = embed_model.encode([query_text], normalize_embeddings=True)[0]
|
| 169 |
candidates["similarity"] = cand_embs @ query_emb
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
candidates["physical_fit"] = 0.5 * candidates["volume_score"] + 0.5 * candidates["length_score"]
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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),
|
| 199 |
-
f"
|
| 200 |
-
f"
|
| 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(
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
else:
|
| 331 |
-
cls, label = "
|
| 332 |
-
return f'<
|
| 333 |
|
| 334 |
|
| 335 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
return f"""
|
| 337 |
<div class="board-card">
|
|
|
|
| 338 |
<div class="board-card-rank">#{rank}</div>
|
| 339 |
-
<div class="board-card-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
</
|
| 348 |
-
<
|
| 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="
|
| 366 |
-
<div class="board-card-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
</
|
| 375 |
-
<
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
"
|
| 409 |
-
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
}
|
| 412 |
|
| 413 |
CSS = """
|
| 414 |
-
@import url('https://fonts.googleapis.com/css2?family=
|
| 415 |
|
| 416 |
:root {
|
| 417 |
-
--
|
| 418 |
-
--
|
| 419 |
-
--
|
| 420 |
-
--
|
| 421 |
-
--
|
| 422 |
-
--
|
| 423 |
-
--
|
|
|
|
| 424 |
}
|
| 425 |
|
| 426 |
-
.gradio-container { background: var(--
|
|
|
|
| 427 |
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
| 434 |
}
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
| 442 |
-
.
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
line-height: 1.5;
|
| 447 |
-
max-width: 560px;
|
| 448 |
}
|
| 449 |
-
.
|
| 450 |
.stat-chip {
|
| 451 |
-
background: rgba(255,255,255,0.
|
| 452 |
-
|
| 453 |
-
border-radius: 20px;
|
| 454 |
-
padding: 5px 14px;
|
| 455 |
-
font-size: 12px;
|
| 456 |
-
font-weight: 500;
|
| 457 |
}
|
| 458 |
|
| 459 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
|
| 461 |
-
.
|
| 462 |
-
|
| 463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
}
|
| 465 |
|
| 466 |
.cards-grid {
|
| 467 |
display: grid;
|
| 468 |
-
grid-template-columns: repeat(auto-fit, minmax(
|
| 469 |
-
gap:
|
| 470 |
}
|
| 471 |
|
|
|
|
| 472 |
.board-card {
|
| 473 |
-
background:
|
| 474 |
-
border
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
border: 1px solid #eee6d8;
|
| 478 |
position: relative;
|
|
|
|
| 479 |
}
|
| 480 |
-
.board-card-rank {
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 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 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 527 |
-
<
|
| 528 |
-
<
|
| 529 |
-
plus one custom, AI-designed board shaped just for you.</
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
</
|
| 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.
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 575 |
-
).then(find_boards, inputs=
|
| 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">· 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 · {board['width_in']}in · {board['thickness_in']}in · {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 · {spec['width_in']}in · {spec['thickness_in']}in · {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()
|