Edwin Sam Claude Opus 4.8 (1M context) commited on
Commit
64a007e
·
1 Parent(s): 9a58087

Polish for judges: cold-start hint, example prompts, graceful errors, prewarm

Browse files

- Cold-start note under the command bar so a 30-60s first request reads as
expected, not broken
- Clickable example prompts (gr.Examples) so a cold visitor reaches the magic
moment without staring at an empty box
- Graceful failure in on_command/on_brief: a model cold-start or timeout shows a
retry hint instead of a raw stack trace, and keeps the typed text in the box
- Best-effort startup prewarm thread so the Space self-warms both Modal models
- Expand README with explicit Build Small track/prize/badge write-up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. README.md +31 -1
  2. app.py +55 -6
README.md CHANGED
@@ -88,4 +88,34 @@ EXTRACT_URL -> the Extractor.web URL (MiniCPM3-4B)
88
  COACH_URL -> the Coach.web URL (Nemotron-3-Nano-4B)
89
  ```
90
 
91
- Built for the build-small-hackathon. Models by OpenBMB and NVIDIA, GPUs by Modal.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  COACH_URL -> the Coach.web URL (Nemotron-3-Nano-4B)
89
  ```
90
 
91
+ ## Built for Build Small
92
+
93
+ **Track — Backyard AI.** A practical, problem-solving tool for the daily work of a
94
+ solo health coach: it removes the manual CRM upkeep so they can spend their time
95
+ coaching, not bookkeeping.
96
+
97
+ **How each prize is earned:**
98
+
99
+ - **Modal** — both models run entirely on Modal. `modal_app.py` defines two GPU
100
+ services (L4), each warm-loaded and scaling to zero between calls; the cockpit is
101
+ a thin client that reaches them over HTTP. Modal is the whole runtime, not a side
102
+ call.
103
+ - **OpenBMB (MiniCPM3-4B)** — the write path. One model does routing, structured
104
+ extraction, stage classification, and method harvesting, leaning on MiniCPM3's
105
+ strong format- and JSON-following.
106
+ - **NVIDIA (Nemotron-3-Nano-4B)** — the read path. Compact agentic reasoning over
107
+ the coach's own data to answer questions and write grounded briefs.
108
+ - **Tiny Titan** — every model the app depends on is ≤4B parameters.
109
+ - **Best Agent** — an update flows through a multi-step pipeline: route → extract →
110
+ classify stage → log timeline → harvest reusable methods, then surfaces what needs
111
+ attention. Planning and tool use, not a single prompt.
112
+ - **Off Brand** — a purpose-built CRM cockpit (command bar, pipeline board,
113
+ needs-attention panel, knowledgebase), not the default Gradio look.
114
+
115
+ **Honest small-model fit.** Neither model does medicine. One turns natural language
116
+ into a structured record; the other reasons only over text the coach supplied and is
117
+ told never to introduce outside clinical claims. Every brief ends with "Not medical
118
+ advice. Coach reviews before sending," and nothing is ever sent to a client
119
+ automatically.
120
+
121
+ Models by OpenBMB and NVIDIA, GPUs by Modal.
app.py CHANGED
@@ -26,6 +26,7 @@ import json
26
  import os
27
  import re
28
  import sqlite3
 
29
 
30
  import gradio as gr
31
  import requests
@@ -476,13 +477,25 @@ def _refresh(active):
476
 
477
 
478
  def on_command(text, active):
479
- """The single command bar. Routes to update or query."""
 
480
  text = (text or "").strip()
481
  if not text:
482
  out = "Type an update or a question."
483
  return (out, record_html(active), board_html(), needs_html(),
484
  gr.update(), gr.update(choices=[], value=[]), [], "")
 
 
 
 
 
 
 
 
 
 
485
 
 
486
  route = _post(EXTRACT_URL, {"op": "route", "text": text, "clients": list_clients()})
487
  intent = route.get("intent", "update")
488
  if text.endswith("?"):
@@ -549,11 +562,15 @@ def on_seed_kb(title, body):
549
  def on_brief(name, question):
550
  if not name:
551
  return "Pick a client first."
552
- record = get_client(name)["record"]
553
- kb = retrieve_kb(record)
554
- out = _strip_think(_post(COACH_URL, {"op": "brief", "record": record, "kb": kb, "question": question})["brief"])
555
- used = ", ".join(e["title"] for e in kb) if kb else "none yet"
556
- return f"**Knowledgebase used:** {used}\n\n{out}"
 
 
 
 
557
 
558
 
559
  # ---------------------------------------------------------------------------
@@ -570,6 +587,7 @@ CSS = """
570
  #tag { color:var(--muted); margin-top:-.35rem; font-size:.9rem; }
571
  .muted { color:var(--muted); }
572
  #cmdout { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:6px 14px; min-height:2.2rem; }
 
573
  .record { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; }
574
  .rec-head { display:flex; align-items:center; gap:10px; }
575
  .rec-name { font-weight:800; font-size:1.15rem; }
@@ -599,7 +617,24 @@ ul.flags li { color:var(--warn); } ul.follow li { color:#9fd0ff; }
599
  .gr-button-primary { background:linear-gradient(90deg,#3fcfa0,#4f84ff) !important; border:none !important; }
600
  """
601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  db_init()
 
603
  _initial = (list_clients() or [None])[0]
604
 
605
  with gr.Blocks(title="Spruce") as demo:
@@ -618,6 +653,20 @@ with gr.Blocks(title="Spruce") as demo:
618
  lines=2, scale=5,
619
  )
620
  send = gr.Button("Send", variant="primary", scale=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  cmd_out = gr.Markdown(elem_id="cmdout")
622
 
623
  with gr.Row():
 
26
  import os
27
  import re
28
  import sqlite3
29
+ import threading
30
 
31
  import gradio as gr
32
  import requests
 
477
 
478
 
479
  def on_command(text, active):
480
+ """The single command bar. Routes to update or query, with graceful failure if a
481
+ model is cold or times out so a live demo never surfaces a raw stack trace."""
482
  text = (text or "").strip()
483
  if not text:
484
  out = "Type an update or a question."
485
  return (out, record_html(active), board_html(), needs_html(),
486
  gr.update(), gr.update(choices=[], value=[]), [], "")
487
+ try:
488
+ return _handle_command(text, active)
489
+ except Exception:
490
+ out = ("⚠️ The model is waking up or the request timed out. The first request "
491
+ "after idle can take ~30–60s while the models load on Modal — give it a "
492
+ "moment and send again.")
493
+ # Keep the text in the box so the coach can just retry.
494
+ return (out, record_html(active), board_html(), needs_html(),
495
+ gr.update(), gr.update(choices=[], value=[]), [], text)
496
+
497
 
498
+ def _handle_command(text, active):
499
  route = _post(EXTRACT_URL, {"op": "route", "text": text, "clients": list_clients()})
500
  intent = route.get("intent", "update")
501
  if text.endswith("?"):
 
562
  def on_brief(name, question):
563
  if not name:
564
  return "Pick a client first."
565
+ try:
566
+ record = get_client(name)["record"]
567
+ kb = retrieve_kb(record)
568
+ out = _strip_think(_post(COACH_URL, {"op": "brief", "record": record, "kb": kb, "question": question})["brief"])
569
+ used = ", ".join(e["title"] for e in kb) if kb else "none yet"
570
+ return f"**Knowledgebase used:** {used}\n\n{out}"
571
+ except Exception:
572
+ return ("⚠️ The coach model is waking up or timed out. The first request after "
573
+ "idle can take ~30–60s on Modal — give it a moment and try again.")
574
 
575
 
576
  # ---------------------------------------------------------------------------
 
587
  #tag { color:var(--muted); margin-top:-.35rem; font-size:.9rem; }
588
  .muted { color:var(--muted); }
589
  #cmdout { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:6px 14px; min-height:2.2rem; }
590
+ #coldhint { color:var(--muted); font-size:.78rem; margin:2px 2px 8px; }
591
  .record { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; }
592
  .rec-head { display:flex; align-items:center; gap:10px; }
593
  .rec-name { font-weight:800; font-size:1.15rem; }
 
617
  .gr-button-primary { background:linear-gradient(90deg,#3fcfa0,#4f84ff) !important; border:none !important; }
618
  """
619
 
620
+ def _prewarm():
621
+ """Fire one cheap request at each model on boot so the first real interaction is
622
+ warm, not a 30–60s cold start. Best-effort and off the request path: runs in a
623
+ daemon thread, and any failure (endpoint unset, still loading) is ignored."""
624
+ for url, payload in (
625
+ (EXTRACT_URL, {"op": "route", "text": "hello", "clients": []}),
626
+ (COACH_URL, {"op": "answer", "context": "{}", "question": "hello"}),
627
+ ):
628
+ if not url:
629
+ continue
630
+ try:
631
+ requests.post(url, json=payload, timeout=180)
632
+ except Exception:
633
+ pass
634
+
635
+
636
  db_init()
637
+ threading.Thread(target=_prewarm, daemon=True).start()
638
  _initial = (list_clients() or [None])[0]
639
 
640
  with gr.Blocks(title="Spruce") as demo:
 
653
  lines=2, scale=5,
654
  )
655
  send = gr.Button("Send", variant="primary", scale=1)
656
+ gr.Examples(
657
+ examples=[
658
+ ["Had a discovery call with Max, wants to drop 5kg before September and fix afternoon energy crashes. Booked his analysis call Thursday. I always have clients front-load protein at breakfast when energy dips."],
659
+ ["Dana reported her 3pm energy crash is gone after the protein change. Moving her into weeks 5-8."],
660
+ ["What's the update on Dana?"],
661
+ ["Who have I not contacted in a while?"],
662
+ ],
663
+ inputs=cmd,
664
+ label="Try one (click to load, then Send):",
665
+ )
666
+ gr.HTML(
667
+ "<div id='coldhint'>First request after idle can take ~30–60s while "
668
+ "MiniCPM3-4B and Nemotron-3-Nano-4B wake on Modal — then it's fast.</div>"
669
+ )
670
  cmd_out = gr.Markdown(elem_id="cmdout")
671
 
672
  with gr.Row():