EdwinS9 commited on
Commit
466d528
·
1 Parent(s): 007bdf0

Rebuild as Spruce: AI-native CRM for a health coach

Browse files

Two small models on Modal: MiniCPM3-4B routes updates to the right client,
extracts a structured record, classifies the pipeline stage, and harvests
reusable methods; Nemotron-3-Nano-4B answers questions over the client data
and writes grounded briefs. Custom cockpit UI with a command bar, stage board,
recency timeline, and a Needs-attention panel.

Files changed (5) hide show
  1. .gitignore +4 -0
  2. README.md +68 -6
  3. app.py +519 -145
  4. modal_app.py +437 -0
  5. requirements.txt +4 -13
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.db
4
+ IDEATION.md
README.md CHANGED
@@ -1,15 +1,77 @@
1
  ---
2
- title: MiniRobot
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: 'small VLM judging robot manipulation - MiniCPM-V + GVL '
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Spruce
3
+ emoji: 🌲
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
+ short_description: 'AI-native CRM you talk to, powered by small models'
13
  ---
14
 
15
+ # Spruce
16
+
17
+ An agent-native CRM for a health coach who runs message-based coaching with the
18
+ occasional checkup call. It does two jobs, each with a genuinely small model:
19
+
20
+ 1. **Structure every client.** Paste anything a client sent (a message thread, a
21
+ call note, an intake) and **MiniCPM3-4B** (OpenBMB, 4B) turns it into a
22
+ structured record: goals and current protocol, history of what was tried,
23
+ flags to watch, and open follow-ups. It also harvests any reusable method the
24
+ coach stated, growing a knowledgebase that starts empty.
25
+
26
+ 2. **Brief the coach before they reply.** Cornerman retrieves the relevant
27
+ knowledgebase entries and **Nemotron-3-Nano-4B** (NVIDIA, 4B) writes a short
28
+ brief: which of the coach's own methods apply, what was already tried, what to
29
+ watch for. The coach writes the actual message.
30
+
31
+ ## Honest small-model fit
32
+
33
+ Neither model does medicine. One restructures natural language into a record.
34
+ The other reasons over text the coach supplied (the client's record and the
35
+ coach's own knowledgebase) and is told never to introduce outside clinical
36
+ claims. Both tasks are squarely what small instruction-tuned models do well,
37
+ which keeps the small-model story honest.
38
+
39
+ The brief always ends with "Not medical advice. Coach reviews before sending,"
40
+ and nothing is ever sent to a client automatically.
41
+
42
+ ## Two models, two jobs
43
+
44
+ | Job | Model | Why this model |
45
+ | --- | --- | --- |
46
+ | Extract + harvest | `openbmb/MiniCPM3-4B` | Strong at format-following and JSON output |
47
+ | Coaching brief | `nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16` | Built for compact agentic reasoning |
48
+
49
+ Both are 4B or under. They need different `transformers` versions, so the
50
+ backend runs them as two separate Modal services, each warm-loaded and scaling
51
+ to zero between calls.
52
+
53
+ ## How it works
54
+
55
+ - **Frontend (this Space):** `app.py`. A custom Gradio cockpit: client list,
56
+ per-client record, paste-to-ingest, a knowledgebase panel, and a "Brief me"
57
+ flow. It owns the data (SQLite) and the keyword retrieval; the models stay
58
+ stateless.
59
+ - **Model backend:** `modal_app.py`. Two Modal GPU services exposing small HTTP
60
+ endpoints for extraction, harvesting, and briefing.
61
+
62
+ ## Running it
63
+
64
+ Backend:
65
+
66
+ ```
67
+ modal deploy modal_app.py # prints two web endpoint URLs
68
+ ```
69
+
70
+ Then set two Space secrets to those URLs:
71
+
72
+ ```
73
+ EXTRACT_URL -> the Extractor.web URL (MiniCPM3-4B)
74
+ COACH_URL -> the Coach.web URL (Nemotron-3-Nano-4B)
75
+ ```
76
+
77
+ Built for the build-small-hackathon. Models by OpenBMB and NVIDIA, GPUs by Modal.
app.py CHANGED
@@ -1,170 +1,544 @@
1
  """
2
- Build Small hackathon starter: sub-32B VLM as a training-free judge for robot manipulation.
3
-
4
- What it does
5
- ------------
6
- Given a manipulation episode (a list of frames + a task description), it uses MiniCPM-V to
7
- estimate per-frame task progress (0-100) using the GVL trick (shuffle frames so the model
8
- can't just copy temporal order), then:
9
- - plots the progress curve,
10
- - derives a success verdict from late-stage progress,
11
- - reports Value-Order Correlation (VOC) = rank corr between predicted progress and true
12
- chronological order. High VOC means the model actually understands task progression.
13
-
14
- Method reference: Ma et al., "Vision Language Models are In-Context Value Learners" (GVL),
15
- arXiv 2411.04549. VOC and the shuffled-frame formulation are from that paper.
16
-
17
- Notes
18
- -----
19
- - The MiniCPM-V call signature can change between versions. Verify against the current model
20
- card at https://huggingface.co/openbmb/MiniCPM-V-4_5 before relying on it.
21
- - Use MiniCPM-V-4.6 for local dev on 8GB VRAM, MiniCPM-V-4_5 (8B) on ZeroGPU/Modal
22
- for the real numbers. The 4.5-vs-4.6 gap is your headline finding.
23
- - This is a scaffold. The parts that matter (GVL scoring, VOC) are correct; the model I/O is
24
- where you should expect to adjust.
25
  """
26
 
27
- import random
28
- import numpy as np
29
- from PIL import Image
30
- from scipy.stats import spearmanr
 
 
31
  import gradio as gr
 
32
 
33
- # ZeroGPU: the decorator moves work onto a GPU slice on Spaces. Harmless to import locally.
34
- try:
35
- import spaces
 
36
 
37
- GPU = spaces.GPU
38
- except Exception: # local dev without the spaces package
 
 
 
 
 
 
39
 
40
- def GPU(*a, **k):
41
- def wrap(fn):
42
- return fn
 
 
 
 
 
43
 
44
- return wrap if (a and callable(a[0]) is False) else (a[0] if a else None)
 
 
 
 
 
45
 
46
 
47
- import torch
48
- from transformers import AutoModel, AutoTokenizer
49
 
50
- MODEL_ID = "openbmb/MiniCPM-V-4_5" # swap to openbmb/MiniCPM-V-4.6 for the smaller local run
51
- N_FRAMES = 16
52
- SUCCESS_THRESHOLD = (
53
- 70.0 # late-stage progress above this counts as success; tune on your data
54
- )
55
 
56
- _model = None
57
- _tokenizer = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
 
 
 
 
 
 
59
 
60
- def _load():
61
- global _model, _tokenizer
62
- if _model is None:
63
- _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
64
- _model = (
65
- AutoModel.from_pretrained(
66
- MODEL_ID,
67
- trust_remote_code=True,
68
- torch_dtype=torch.bfloat16,
69
- attn_implementation="sdpa",
70
  )
71
- .eval()
72
- .cuda()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  )
74
- return _model, _tokenizer
 
 
75
 
76
 
77
- def sample_frames(frames, n=N_FRAMES):
78
- """Uniformly sample n frames from a list of PIL images."""
79
- if len(frames) <= n:
80
- return list(range(len(frames))), frames
81
- idx = np.linspace(0, len(frames) - 1, n).astype(int).tolist()
82
- return idx, [frames[i] for i in idx]
83
 
84
 
85
- def _ask_progress(model, tokenizer, task, init_frame, query_frame):
86
- """One VLM call: given the initial frame as the 0% reference, score one query frame."""
87
- prompt = (
88
- f"A robot is performing this task: {task}.\n"
89
- f"The first image is the initial state (0% complete).\n"
90
- f"The second image is a frame from somewhere in the episode (order unknown).\n"
91
- f"Estimate how complete the task is in the second image, as a single integer 0-100. "
92
- f"Reply with the number only."
 
 
 
93
  )
94
- msgs = [{"role": "user", "content": [init_frame, query_frame, prompt]}]
95
- out = model.chat(
96
- image=None, msgs=msgs, tokenizer=tokenizer
97
- ) # verify signature per model card
98
- digits = "".join(c for c in str(out) if c.isdigit())
99
- if not digits:
100
- return 0.0
101
- return float(max(0, min(100, int(digits[:3]))))
102
-
103
-
104
- @GPU(duration=120)
105
- def score_episode(frames, task):
106
- """GVL-style scoring. Returns (chronological progress list, VOC)."""
107
- model, tokenizer = _load()
108
- _, sampled = sample_frames(frames)
109
- init = sampled[0]
110
- rest = list(enumerate(sampled))[1:] # (true_chrono_index, frame)
111
-
112
- shuffled = rest[:]
113
- random.shuffle(shuffled) # the GVL trick: break temporal adjacency
114
-
115
- scored = {}
116
- for true_idx, frame in shuffled:
117
- scored[true_idx] = _ask_progress(model, tokenizer, task, init, frame)
118
-
119
- progress = [0.0] + [scored[i] for i, _ in rest] # restore chronological order
120
- true_order = list(range(len(progress)))
121
- voc, _ = spearmanr(progress, true_order)
122
- return progress, float(voc)
123
-
124
-
125
- def run(video_frames_dir_or_list, task):
126
- """Gradio entry. Expects a list of PIL frames; adapt to your loader (lerobot / decord)."""
127
- frames = video_frames_dir_or_list # TODO: plug your LeRobot/LIBERO/OXE episode loader here
128
- if not frames:
129
- return None, "No frames loaded.", None
130
- progress, voc = score_episode(frames, task)
131
- late = np.mean(progress[-3:]) if len(progress) >= 3 else progress[-1]
132
- verdict = "SUCCESS" if late >= SUCCESS_THRESHOLD else "FAILURE"
133
-
134
- import matplotlib.pyplot as plt
135
-
136
- fig, ax = plt.subplots(figsize=(6, 3))
137
- ax.plot(range(len(progress)), progress, marker="o")
138
- ax.set_xlabel("frame (chronological)")
139
- ax.set_ylabel("predicted progress (%)")
140
- ax.set_ylim(-5, 105)
141
- ax.set_title(f"VOC = {voc:.2f}")
142
- fig.tight_layout()
143
-
144
- summary = f"Verdict: {verdict}\nLate-stage progress: {late:.0f}%\nVOC: {voc:.2f}"
145
- return fig, summary, voc
146
-
147
-
148
- with gr.Blocks(title="Can a small VLM judge robot success?") as demo:
149
- gr.Markdown(
150
- "## Small VLM as a training-free robot judge\n"
151
- "Upload or pick a manipulation episode. The model estimates task progress per frame "
152
- "(GVL shuffled-frame method) and reports VOC and a success verdict."
153
  )
154
- with gr.Row():
155
- with gr.Column():
156
- task_in = gr.Textbox(
157
- label="Task description", value="put the red block in the bowl"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  )
159
- frames_in = gr.State(
160
- []
161
- ) # replace with a gr.File / example picker wired to your loader
162
- run_btn = gr.Button("Judge episode", variant="primary")
163
- with gr.Column():
164
- plot_out = gr.Plot(label="Progress curve")
165
- summary_out = gr.Textbox(label="Result")
166
- voc_out = gr.Number(label="VOC")
167
- run_btn.click(run, [frames_in, task_in], [plot_out, summary_out, voc_out])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  if __name__ == "__main__":
170
- demo.launch()
 
 
1
  """
2
+ Spruce: an AI-native CRM a health coach talks to instead of maintaining a board.
3
+
4
+ The coach types (later: speaks) one line into the command bar. Spruce decides
5
+ whether it is an update or a question:
6
+
7
+ * Update -> MiniCPM3-4B (on Modal) routes it to the right client (creating one
8
+ if needed), extracts a structured record, classifies the pipeline stage, and
9
+ logs a dated timeline event. It also harvests any reusable method the coach
10
+ stated, to grow a knowledgebase.
11
+ * Question -> Nemotron-3-Nano-4B (on Modal) answers from the client data
12
+ ("what's the update on Max?", "who have I not contacted lately?").
13
+
14
+ A "Needs attention" panel surfaces stale clients and open follow-ups so nothing
15
+ is dropped. A stage board gives the visual overview as a secondary view. The
16
+ models never message a client and never invent clinical claims.
17
+
18
+ Two endpoints, both from modal_app.py:
19
+ EXTRACT_URL : MiniCPM3-4B (route / extract / harvest)
20
+ COACH_URL : Nemotron-3-Nano-4B (answer / brief)
21
+ Set them as Space secrets in production.
 
 
 
22
  """
23
 
24
+ import datetime as dt
25
+ import json
26
+ import os
27
+ import re
28
+ import sqlite3
29
+
30
  import gradio as gr
31
+ import requests
32
 
33
+ EXTRACT_URL = os.environ.get("EXTRACT_URL", "").rstrip("/")
34
+ COACH_URL = os.environ.get("COACH_URL", "").rstrip("/")
35
+ DB_PATH = os.environ.get("CORNERMAN_DB", "spruce.db")
36
+ STALE_DAYS = int(os.environ.get("SPRUCE_STALE_DAYS", "7"))
37
 
38
+ STAGES = [
39
+ "Discovery Call",
40
+ "Customer booked",
41
+ "Analysis call",
42
+ "Second call",
43
+ "Weeks 1-4",
44
+ "Weeks 5-8",
45
+ ]
46
 
47
+ EMPTY_RECORD = {
48
+ "stage": "",
49
+ "goals": "",
50
+ "current_protocol": "",
51
+ "next_step": "",
52
+ "flags": [],
53
+ "follow_ups": [],
54
+ }
55
 
56
+ _STOP = set(
57
+ "the a an and or of to in on for with is are was were be been being this that "
58
+ "these those it its as at by from he she they you i we client coach about into "
59
+ "after before over under more most some any not no do does did has have had will "
60
+ "would can could should still your their them his her my our up out".split()
61
+ )
62
 
63
 
64
+ def _today():
65
+ return dt.date.today().isoformat()
66
 
 
 
 
 
 
67
 
68
+ # ---------------------------------------------------------------------------
69
+ # Storage.
70
+ # ---------------------------------------------------------------------------
71
+ def _conn():
72
+ c = sqlite3.connect(DB_PATH)
73
+ c.row_factory = sqlite3.Row
74
+ return c
75
+
76
+
77
+ def db_init():
78
+ with _conn() as c:
79
+ c.execute(
80
+ "CREATE TABLE IF NOT EXISTS clients ("
81
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, record TEXT, "
82
+ "customer_since TEXT, last_contacted TEXT, timeline TEXT)"
83
+ )
84
+ c.execute(
85
+ "CREATE TABLE IF NOT EXISTS kb ("
86
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)"
87
+ )
88
+ n = c.execute("SELECT COUNT(*) AS n FROM clients").fetchone()["n"]
89
+ if n == 0:
90
+ _seed(c)
91
+
92
+
93
+ def _seed(c):
94
+ samples = [
95
+ (
96
+ "Sample: Dana R.",
97
+ {
98
+ "stage": "Weeks 1-4",
99
+ "goals": "Steady afternoon energy, lose 4kg in 12 weeks.",
100
+ "current_protocol": "16:8 window, 3 strength sessions a week.",
101
+ "next_step": "Review sleep log on next check-in.",
102
+ "flags": ["Knees sore after lunges, ease lower-body volume."],
103
+ "follow_ups": ["Check sleep again next week."],
104
+ },
105
+ "2026-05-20",
106
+ "2026-06-03",
107
+ [
108
+ {"date": "2026-05-20", "event": "Started Weeks 1-4 block."},
109
+ {"date": "2026-06-03", "event": "Reported 3pm energy crash and poor sleep."},
110
+ ],
111
+ ),
112
+ (
113
+ "Sample: Sam P.",
114
+ {
115
+ "stage": "Discovery Call",
116
+ "goals": "Explore working together on stress and digestion.",
117
+ "current_protocol": "",
118
+ "next_step": "Send booking link for analysis call.",
119
+ "flags": [],
120
+ "follow_ups": ["Waiting on Sam to pick a time."],
121
+ },
122
+ "2026-06-08",
123
+ "2026-06-08",
124
+ [{"date": "2026-06-08", "event": "Discovery call held, interested but undecided."}],
125
+ ),
126
+ ]
127
+ for name, rec, since, last, tl in samples:
128
+ c.execute(
129
+ "INSERT INTO clients (name, record, customer_since, last_contacted, timeline) "
130
+ "VALUES (?, ?, ?, ?, ?)",
131
+ (name, json.dumps(rec), since, last, json.dumps(tl)),
132
+ )
133
+
134
+
135
+ def list_clients():
136
+ with _conn() as c:
137
+ return [r["name"] for r in c.execute("SELECT name FROM clients ORDER BY name")]
138
+
139
+
140
+ def get_client(name):
141
+ with _conn() as c:
142
+ row = c.execute("SELECT * FROM clients WHERE name = ?", (name,)).fetchone()
143
+ if not row:
144
+ return None
145
+ return {
146
+ "name": row["name"],
147
+ "record": _loads(row["record"], dict(EMPTY_RECORD)),
148
+ "customer_since": row["customer_since"] or "",
149
+ "last_contacted": row["last_contacted"] or "",
150
+ "timeline": _loads(row["timeline"], []),
151
+ }
152
+
153
+
154
+ def all_clients():
155
+ return [get_client(n) for n in list_clients()]
156
+
157
 
158
+ def _loads(s, default):
159
+ try:
160
+ v = json.loads(s)
161
+ return v if v is not None else default
162
+ except (TypeError, json.JSONDecodeError):
163
+ return default
164
 
165
+
166
+ def ensure_client(name):
167
+ name = (name or "").strip() or "Unsorted client"
168
+ with _conn() as c:
169
+ row = c.execute("SELECT name FROM clients WHERE name = ?", (name,)).fetchone()
170
+ if not row:
171
+ c.execute(
172
+ "INSERT INTO clients (name, record, customer_since, last_contacted, timeline) "
173
+ "VALUES (?, ?, ?, ?, ?)",
174
+ (name, json.dumps(EMPTY_RECORD), _today(), "", json.dumps([])),
175
  )
176
+ return name
177
+
178
+
179
+ def save_update(name, record, event):
180
+ """Persist a record update, append a dated timeline event, stamp contact date."""
181
+ cur = get_client(name)
182
+ timeline = cur["timeline"] if cur else []
183
+ today = _today()
184
+ if event:
185
+ timeline = timeline + [{"date": today, "event": event}]
186
+ since = (cur["customer_since"] if cur and cur["customer_since"] else today)
187
+ with _conn() as c:
188
+ c.execute(
189
+ "UPDATE clients SET record = ?, last_contacted = ?, customer_since = ?, "
190
+ "timeline = ? WHERE name = ?",
191
+ (json.dumps(record, ensure_ascii=False), today, since,
192
+ json.dumps(timeline, ensure_ascii=False), name),
193
+ )
194
+
195
+
196
+ def list_kb():
197
+ with _conn() as c:
198
+ return [
199
+ {"id": r["id"], "title": r["title"], "body": r["body"]}
200
+ for r in c.execute("SELECT id, title, body FROM kb ORDER BY id DESC")
201
+ ]
202
+
203
+
204
+ def add_kb(title, body):
205
+ title, body = (title or "").strip(), (body or "").strip()
206
+ if title and body:
207
+ with _conn() as c:
208
+ c.execute("INSERT INTO kb (title, body) VALUES (?, ?)", (title, body))
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Modal calls (requests follows Modal's 303 long-poll redirect by default).
213
+ # ---------------------------------------------------------------------------
214
+ def _post(url, payload):
215
+ if not url:
216
+ raise RuntimeError(
217
+ "Model endpoint not set. Deploy modal_app.py and set EXTRACT_URL and "
218
+ "COACH_URL (Space secrets in production)."
219
  )
220
+ r = requests.post(url, json=payload, timeout=300)
221
+ r.raise_for_status()
222
+ return r.json()
223
 
224
 
225
+ def _strip_think(text):
226
+ """Nemotron is a reasoning model; drop any <think>...</think> it emits."""
227
+ text = text or ""
228
+ if "</think>" in text:
229
+ text = text.split("</think>")[-1]
230
+ return text.strip()
231
 
232
 
233
+ def _words(text):
234
+ return {w for w in re.findall(r"[a-z]{3,}", (text or "").lower()) if w not in _STOP}
235
+
236
+
237
+ def retrieve_kb(record, k=4):
238
+ entries = list_kb()
239
+ if not entries:
240
+ return []
241
+ rec_text = " ".join(
242
+ [record.get("goals", ""), record.get("current_protocol", ""), record.get("next_step", "")]
243
+ + record.get("flags", []) + record.get("follow_ups", [])
244
  )
245
+ rw = _words(rec_text)
246
+ scored = sorted(
247
+ ((len(rw & _words(e["title"] + " " + e["body"])), e) for e in entries),
248
+ key=lambda t: t[0], reverse=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  )
250
+ top = [e for s, e in scored if s > 0][:k]
251
+ return top or [e for _, e in scored][:k]
252
+
253
+
254
+ def _days_since(iso):
255
+ try:
256
+ return (dt.date.today() - dt.date.fromisoformat(iso)).days
257
+ except (TypeError, ValueError):
258
+ return None
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Rendering.
263
+ # ---------------------------------------------------------------------------
264
+ def esc(s):
265
+ return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
266
+
267
+
268
+ def _li(items, cls=""):
269
+ if not items:
270
+ return "<span class='muted'>none</span>"
271
+ c = f" class='{cls}'" if cls else ""
272
+ return f"<ul{c}>" + "".join(f"<li>{esc(x)}</li>" for x in items) + "</ul>"
273
+
274
+
275
+ def record_html(name):
276
+ cl = get_client(name) if name else None
277
+ if not cl:
278
+ return "<div class='muted'>Pick a client, or type an update in the command bar.</div>"
279
+ r = cl["record"]
280
+ last = cl["last_contacted"] or "never"
281
+ dsl = _days_since(cl["last_contacted"])
282
+ last_txt = f"{last}" + (f" ({dsl}d ago)" if dsl is not None else "")
283
+ stage = r.get("stage") or "Unsorted"
284
+ tl = "".join(
285
+ f"<div class='tl-row'><span class='tl-date'>{esc(e.get('date',''))}</span>"
286
+ f"<span class='tl-event'>{esc(e.get('event',''))}</span></div>"
287
+ for e in reversed(cl["timeline"])
288
+ ) or "<span class='muted'>no events yet</span>"
289
+ return f"""
290
+ <div class="record">
291
+ <div class="rec-head">
292
+ <span class="rec-name">{esc(cl['name'])}</span>
293
+ <span class="stage-badge">{esc(stage)}</span>
294
+ </div>
295
+ <div class="rec-dates">Customer since {esc(cl['customer_since'] or '?')} &middot; last contact {esc(last_txt)}</div>
296
+ <div class="rec-row"><span class="rec-label">Goals</span><span class="rec-val">{esc(r.get('goals','')) or '<span class=muted>not set</span>'}</span></div>
297
+ <div class="rec-row"><span class="rec-label">Protocol</span><span class="rec-val">{esc(r.get('current_protocol','')) or '<span class=muted>not set</span>'}</span></div>
298
+ <div class="rec-row"><span class="rec-label">Next step</span><span class="rec-val next">{esc(r.get('next_step','')) or '<span class=muted>not set</span>'}</span></div>
299
+ <div class="rec-block"><div class="rec-label">Watch for</div>{_li(r.get('flags',[]), 'flags')}</div>
300
+ <div class="rec-block"><div class="rec-label">Open follow-ups</div>{_li(r.get('follow_ups',[]), 'follow')}</div>
301
+ <div class="rec-block"><div class="rec-label">Timeline</div><div class="timeline">{tl}</div></div>
302
+ </div>"""
303
+
304
+
305
+ def board_html():
306
+ clients = all_clients()
307
+ cols = []
308
+ for stage in STAGES:
309
+ chips = "".join(
310
+ f"<div class='chip'>{esc(c['name'])}</div>"
311
+ for c in clients if c["record"].get("stage") == stage
312
+ )
313
+ cols.append(f"<div class='col'><div class='col-head'>{esc(stage)}</div>{chips or '<div class=muted>-</div>'}</div>")
314
+ unsorted = [c for c in clients if c["record"].get("stage") not in STAGES]
315
+ if unsorted:
316
+ chips = "".join(f"<div class='chip'>{esc(c['name'])}</div>" for c in unsorted)
317
+ cols.append(f"<div class='col'><div class='col-head'>Unsorted</div>{chips}</div>")
318
+ return f"<div class='board'>{''.join(cols)}</div>"
319
+
320
+
321
+ def needs_html():
322
+ rows = []
323
+ for c in all_clients():
324
+ reasons = []
325
+ dsl = _days_since(c["last_contacted"])
326
+ if dsl is None:
327
+ reasons.append("never contacted")
328
+ elif dsl >= STALE_DAYS:
329
+ reasons.append(f"no contact in {dsl}d")
330
+ for f in c["record"].get("follow_ups", []):
331
+ reasons.append(f"follow-up: {f}")
332
+ if reasons:
333
+ rows.append(
334
+ f"<div class='need'><span class='need-name'>{esc(c['name'])}</span>"
335
+ f"<span class='need-why'>{esc('; '.join(reasons))}</span></div>"
336
  )
337
+ if not rows:
338
+ return "<div class='muted'>Nothing needs attention. Nice.</div>"
339
+ return "".join(rows)
340
+
341
+
342
+ def kb_html():
343
+ entries = list_kb()
344
+ if not entries:
345
+ return "<div class='muted'>Empty. Methods you state in updates get suggested here, or seed one below.</div>"
346
+ return "<div class='kb-wrap'>" + "".join(
347
+ f"<div class='kb-card'><div class='kb-title'>{esc(e['title'])}</div>"
348
+ f"<div class='kb-body'>{esc(e['body'])}</div></div>" for e in entries
349
+ ) + "</div>"
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Actions.
354
+ # ---------------------------------------------------------------------------
355
+ def _refresh(active):
356
+ return record_html(active), board_html(), needs_html()
357
+
358
+
359
+ def on_command(text, active):
360
+ """The single command bar. Routes to update or query."""
361
+ text = (text or "").strip()
362
+ if not text:
363
+ out = "Type an update or a question."
364
+ return (out, record_html(active), board_html(), needs_html(),
365
+ gr.update(), gr.update(choices=[], value=[]), [], "")
366
+
367
+ route = _post(EXTRACT_URL, {"op": "route", "text": text, "clients": list_clients()})
368
+ intent = route.get("intent", "update")
369
+ if text.endswith("?"):
370
+ intent = "query" # safety net: questions end with a question mark
371
+
372
+ if intent == "query":
373
+ who = route.get("client", "")
374
+ cl = get_client(who) if who else None
375
+ if cl:
376
+ context = json.dumps(
377
+ {"client": cl["name"], "customer_since": cl["customer_since"],
378
+ "last_contacted": cl["last_contacted"], "record": cl["record"],
379
+ "timeline": cl["timeline"]}, ensure_ascii=False)
380
+ else:
381
+ roster = [
382
+ {"name": c["name"], "stage": c["record"].get("stage", ""),
383
+ "last_contacted": c["last_contacted"], "next_step": c["record"].get("next_step", ""),
384
+ "follow_ups": c["record"].get("follow_ups", [])}
385
+ for c in all_clients()
386
+ ]
387
+ context = json.dumps({"clients": roster}, ensure_ascii=False)
388
+ answer = _strip_think(_post(COACH_URL, {"op": "answer", "context": context, "question": text})["answer"])
389
+ out = f"**Q:** {esc(text)}\n\n{answer}"
390
+ return (out, record_html(active), board_html(), needs_html(),
391
+ gr.update(), gr.update(choices=[], value=[]), [], "")
392
+
393
+ # Update path.
394
+ name = ensure_client(route.get("client", ""))
395
+ existing = get_client(name)["record"]
396
+ res = _post(EXTRACT_URL, {"op": "extract", "existing": existing, "new_text": text})
397
+ record, event = res["record"], res.get("event", "")
398
+ save_update(name, record, event)
399
+ methods = _post(EXTRACT_URL, {"op": "harvest", "new_text": text})["methods"]
400
+ titles = [m["title"] for m in methods]
401
+ stage = record.get("stage") or "Unsorted"
402
+ out = (f"Filed under **{esc(name)}** ({esc(stage)})."
403
+ + (f" Logged: _{esc(event)}_" if event else "")
404
+ + (f"\n\nFound {len(methods)} reusable method(s) to save on the right." if methods else ""))
405
+ return (out, record_html(name), board_html(), needs_html(),
406
+ gr.update(choices=list_clients(), value=name),
407
+ gr.update(choices=titles, value=titles), methods, "")
408
+
409
+
410
+ def on_select_client(name):
411
+ return record_html(name)
412
+
413
+
414
+ def on_save_methods(selected, candidates):
415
+ by_title = {m["title"]: m for m in (candidates or [])}
416
+ added = 0
417
+ for t in selected or []:
418
+ m = by_title.get(t)
419
+ if m:
420
+ add_kb(m["title"], m["body"])
421
+ added += 1
422
+ return kb_html(), gr.update(choices=[], value=[]), [], f"Saved {added} method(s)."
423
+
424
+
425
+ def on_seed_kb(title, body):
426
+ add_kb(title, body)
427
+ return kb_html(), "", "", "Added to knowledgebase."
428
+
429
+
430
+ def on_brief(name, question):
431
+ if not name:
432
+ return "Pick a client first."
433
+ record = get_client(name)["record"]
434
+ kb = retrieve_kb(record)
435
+ out = _strip_think(_post(COACH_URL, {"op": "brief", "record": record, "kb": kb, "question": question})["brief"])
436
+ used = ", ".join(e["title"] for e in kb) if kb else "none yet"
437
+ return f"**Knowledgebase used:** {used}\n\n{out}"
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # UI.
442
+ # ---------------------------------------------------------------------------
443
+ CSS = """
444
+ :root { --bg:#0c0f14; --panel:#151a23; --panel2:#1b212c; --ink:#eef2f8;
445
+ --muted:#7e8aa0; --accent:#46d6a6; --accent2:#5b8cff; --line:#26303f; --warn:#f0a35e; }
446
+ .gradio-container { background:
447
+ radial-gradient(1100px 500px at 80% -10%, #16241f 0%, transparent 60%), var(--bg) !important;
448
+ color: var(--ink) !important; font-family:'Inter', system-ui, sans-serif; }
449
+ #brand { font-size:1.7rem; font-weight:800; letter-spacing:-.02em;
450
+ background:linear-gradient(90deg,#7fe9c6,#8fb0ff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
451
+ #tag { color:var(--muted); margin-top:-.35rem; font-size:.9rem; }
452
+ .muted { color:var(--muted); }
453
+ #cmdout { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:6px 14px; min-height:2.2rem; }
454
+ .record { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; }
455
+ .rec-head { display:flex; align-items:center; gap:10px; }
456
+ .rec-name { font-weight:800; font-size:1.15rem; }
457
+ .stage-badge { background:linear-gradient(90deg,#2f6b58,#33507f); color:#dffaf0; font-size:.72rem;
458
+ padding:3px 9px; border-radius:999px; letter-spacing:.04em; }
459
+ .rec-dates { color:var(--muted); font-size:.8rem; margin:4px 0 10px; }
460
+ .rec-row { display:flex; gap:10px; margin:4px 0; }
461
+ .rec-label { color:var(--accent); font-size:.74rem; text-transform:uppercase; letter-spacing:.06em; min-width:84px; padding-top:2px; }
462
+ .rec-val.next { color:#bfe0ff; font-weight:600; }
463
+ .rec-block { margin-top:10px; }
464
+ .rec-block ul { margin:4px 0 0; padding-left:18px; }
465
+ .rec-block li { margin:3px 0; color:#d4dbe8; font-size:.93rem; }
466
+ ul.flags li { color:var(--warn); } ul.follow li { color:#9fd0ff; }
467
+ .timeline { margin-top:4px; }
468
+ .tl-row { display:flex; gap:10px; font-size:.86rem; margin:2px 0; }
469
+ .tl-date { color:var(--muted); min-width:78px; } .tl-event { color:#cfd6e6; }
470
+ .board { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; }
471
+ .col { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:8px; min-width:120px; flex:1; }
472
+ .col-head { color:var(--accent); font-size:.7rem; text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }
473
+ .chip { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:5px 8px; margin:4px 0; font-size:.82rem; }
474
+ .need { display:flex; flex-direction:column; background:var(--panel2); border-left:3px solid var(--warn);
475
+ border-radius:8px; padding:7px 10px; margin:6px 0; }
476
+ .need-name { font-weight:700; font-size:.9rem; } .need-why { color:var(--warn); font-size:.8rem; }
477
+ .kb-wrap { display:flex; flex-direction:column; gap:8px; max-height:240px; overflow:auto; }
478
+ .kb-card { background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:9px 11px; }
479
+ .kb-title { font-weight:700; color:#bfe9d8; font-size:.9rem; } .kb-body { color:#c3cad8; font-size:.84rem; }
480
+ .gr-button-primary { background:linear-gradient(90deg,#3fcfa0,#4f84ff) !important; border:none !important; }
481
+ """
482
+
483
+ db_init()
484
+ _initial = (list_clients() or [None])[0]
485
+
486
+ with gr.Blocks(title="Spruce") as demo:
487
+ gr.HTML(
488
+ "<div id='brand'>🌲 Spruce</div>"
489
+ "<div id='tag'>The CRM you talk to. Type what happened and it files itself; "
490
+ "ask a question and it answers. MiniCPM3-4B writes, Nemotron-3-Nano-4B reads. Both on Modal.</div>"
491
+ )
492
+
493
+ candidates_state = gr.State([])
494
+
495
+ with gr.Row():
496
+ cmd = gr.Textbox(
497
+ label="",
498
+ placeholder="e.g. Had a call with Max, booked his analysis call Thursday, wants to drop 5kg | or: what's the update on Dana?",
499
+ lines=2, scale=5,
500
+ )
501
+ send = gr.Button("Send", variant="primary", scale=1)
502
+ cmd_out = gr.Markdown(elem_id="cmdout")
503
+
504
+ with gr.Row():
505
+ with gr.Column(scale=2):
506
+ gr.Markdown("### Client")
507
+ client = gr.Dropdown(choices=list_clients(), value=_initial, label="Active client", interactive=True)
508
+ record_view = gr.HTML(record_html(_initial))
509
+ with gr.Column(scale=2):
510
+ gr.Markdown("### Needs attention")
511
+ needs_view = gr.HTML(needs_html())
512
+ with gr.Accordion("Knowledgebase", open=False):
513
+ kb_view = gr.HTML(kb_html())
514
+ cand_pick = gr.CheckboxGroup(choices=[], label="Methods found in last update")
515
+ save_methods_btn = gr.Button("Save selected")
516
+ kb_title = gr.Textbox(label="Seed a method: title")
517
+ kb_body = gr.Textbox(label="Body", lines=2)
518
+ seed_btn = gr.Button("Add to knowledgebase")
519
+ with gr.Accordion("Brief me from my knowledgebase", open=False):
520
+ brief_q = gr.Textbox(label="Before I reply, what should I consider?", lines=1)
521
+ brief_btn = gr.Button("Brief me")
522
+ brief_out = gr.Markdown()
523
+
524
+ gr.Markdown("### Pipeline board")
525
+ board_view = gr.HTML(board_html())
526
+
527
+ # Wiring.
528
+ send.click(
529
+ on_command, [cmd, client],
530
+ [cmd_out, record_view, board_view, needs_view, client, cand_pick, candidates_state, cmd],
531
+ )
532
+ cmd.submit(
533
+ on_command, [cmd, client],
534
+ [cmd_out, record_view, board_view, needs_view, client, cand_pick, candidates_state, cmd],
535
+ )
536
+ client.change(on_select_client, client, record_view)
537
+ save_methods_btn.click(on_save_methods, [cand_pick, candidates_state], [kb_view, cand_pick, candidates_state, cmd_out])
538
+ seed_btn.click(on_seed_kb, [kb_title, kb_body], [kb_view, kb_title, kb_body, cmd_out])
539
+ brief_btn.click(on_brief, [client, brief_q], brief_out)
540
+
541
 
542
  if __name__ == "__main__":
543
+ # Gradio 6 moved css/theme from the Blocks constructor to launch().
544
+ demo.launch(css=CSS, theme=gr.themes.Base())
modal_app.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spruce backend on Modal: the model layer for an AI-native CRM a health coach
3
+ talks to instead of maintaining a Kanban board by hand.
4
+
5
+ Two genuinely small (<=4B) models, each doing the job it is good at:
6
+
7
+ * openbmb/MiniCPM3-4B -> the write path. It reads a raw natural-language
8
+ update ("had a call with Max, booked his analysis call, he wants to drop
9
+ 5kg") and does three jobs: ROUTE it to the right client (or flag a new one),
10
+ EXTRACT a structured record and a one-line timeline event, classify the
11
+ pipeline STAGE, and HARVEST any reusable method the coach states.
12
+
13
+ * nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16 -> the read/reason path. It ANSWERS
14
+ natural-language questions over the client data ("what's the update on Max?",
15
+ "who have I not contacted in a while?") and writes a grounded coaching BRIEF
16
+ from the coach's own knowledgebase. It never messages a client and never
17
+ introduces outside clinical claims.
18
+
19
+ Neither model does medicine. One turns language into structured records, the
20
+ other reasons over data the coach supplied. That keeps the small-model fit
21
+ honest.
22
+
23
+ The two need different runtimes (MiniCPM3 pins transformers 4.41; Nemotron's
24
+ hybrid Mamba needs vLLM's kernels), so they run as two separate Modal services
25
+ with their own images, each warm-loaded and scaling to zero.
26
+
27
+ Prize alignment (build-small-hackathon): Modal (both run here), OpenBMB
28
+ (MiniCPM3-4B), NVIDIA (Nemotron), Tiny Titan (both <=4B), Best Agent (route ->
29
+ update -> classify -> remind is agentic), Off-Brand (custom CRM UI).
30
+
31
+ Usage:
32
+ modal deploy modal_app.py # prints the two web endpoint URLs for the Space
33
+ """
34
+
35
+ import json
36
+ import re
37
+
38
+ import modal
39
+
40
+ MINUTES = 60
41
+
42
+ EXTRACT_MODEL = "openbmb/MiniCPM3-4B"
43
+ COACH_MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
44
+
45
+ # Elijah's client journey. The model classifies each update into one of these.
46
+ STAGES = [
47
+ "Discovery Call",
48
+ "Customer booked",
49
+ "Analysis call",
50
+ "Second call",
51
+ "Weeks 1-4",
52
+ "Weeks 5-8",
53
+ ]
54
+
55
+ # Model-maintained fields of a client record. Dates and timeline are kept by the
56
+ # cockpit, not the model (the model has no reliable clock).
57
+ EMPTY_RECORD = {
58
+ "stage": "",
59
+ "goals": "",
60
+ "current_protocol": "",
61
+ "next_step": "",
62
+ "flags": [],
63
+ "follow_ups": [],
64
+ }
65
+
66
+ # MiniCPM3 remote code targets transformers 4.41; 5.x breaks it.
67
+ extract_image = (
68
+ modal.Image.debian_slim(python_version="3.12")
69
+ .pip_install(
70
+ "torch",
71
+ "transformers==4.41.2",
72
+ "accelerate",
73
+ "sentencepiece",
74
+ "numpy",
75
+ "fastapi[standard]",
76
+ )
77
+ .env({"HF_HOME": "/cache"})
78
+ )
79
+
80
+ # Nemotron-3-Nano is a hybrid Mamba/Transformer. vLLM ships NemotronH support and
81
+ # the Mamba kernels, which plain transformers does not build cleanly.
82
+ # VLLM_USE_FLASHINFER_SAMPLER=0: we decode greedily (temperature 0), so we do not
83
+ # need FlashInfer's sampler, which would otherwise JIT-compile a CUDA kernel at
84
+ # startup and fail because the slim image has no nvcc.
85
+ coach_image = (
86
+ modal.Image.debian_slim(python_version="3.12")
87
+ .pip_install("vllm", "fastapi[standard]")
88
+ # Remove flashinfer so vLLM uses its native PyTorch sampler. flashinfer would
89
+ # otherwise JIT-compile a CUDA kernel at startup and crash (no nvcc in slim).
90
+ .run_commands("python -m pip uninstall -y flashinfer-python flashinfer || true")
91
+ .env({"HF_HOME": "/cache", "VLLM_USE_FLASHINFER_SAMPLER": "0"})
92
+ )
93
+
94
+ app = modal.App("coach-cockpit")
95
+ hf_cache = modal.Volume.from_name("coach-cockpit-cache", create_if_missing=True)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Service 1: MiniCPM3-4B. Route, extract, classify, harvest.
100
+ # ---------------------------------------------------------------------------
101
+ @app.cls(
102
+ gpu="L4",
103
+ image=extract_image,
104
+ volumes={"/cache": hf_cache},
105
+ timeout=20 * MINUTES,
106
+ scaledown_window=300,
107
+ )
108
+ class Extractor:
109
+ @modal.enter()
110
+ def load(self):
111
+ import torch
112
+ from transformers import AutoModelForCausalLM, AutoTokenizer
113
+
114
+ self.torch = torch
115
+ self.tok = AutoTokenizer.from_pretrained(EXTRACT_MODEL, trust_remote_code=True)
116
+ if self.tok.pad_token is None:
117
+ self.tok.pad_token = self.tok.eos_token
118
+ self.tok.padding_side = "left"
119
+ self.model = AutoModelForCausalLM.from_pretrained(
120
+ EXTRACT_MODEL,
121
+ trust_remote_code=True,
122
+ torch_dtype=torch.bfloat16,
123
+ device_map="cuda",
124
+ ).eval()
125
+
126
+ def _generate(self, messages, max_new_tokens=512):
127
+ prompt = self.tok.apply_chat_template(
128
+ messages, tokenize=False, add_generation_prompt=True
129
+ )
130
+ inputs = self.tok(prompt, return_tensors="pt").to("cuda")
131
+ with self.torch.no_grad():
132
+ out = self.model.generate(
133
+ **inputs,
134
+ max_new_tokens=max_new_tokens,
135
+ do_sample=False,
136
+ pad_token_id=self.tok.pad_token_id,
137
+ )
138
+ gen = out[:, inputs["input_ids"].shape[1]:]
139
+ return self.tok.decode(gen[0], skip_special_tokens=True).strip()
140
+
141
+ def _route_impl(self, text, clients):
142
+ """Decide if this is an update or a question, and which client it is about."""
143
+ roster = ", ".join(clients) if clients else "(no clients yet)"
144
+ sys = (
145
+ "You triage a health coach's inbox. Given a list of existing clients "
146
+ "and one thing the coach typed, decide:\n"
147
+ ' intent: "update" if the coach is reporting something that happened '
148
+ 'or a plan, "query" if the coach is asking a question.\n'
149
+ " client: the existing client this is about, matched from the list "
150
+ "(match partial names, e.g. 'Max' -> 'Max Mustermann'). Empty string "
151
+ "if it is a general question about no single client.\n"
152
+ " is_new: true only if intent is update and the named person is not "
153
+ "in the list.\n"
154
+ "Return ONLY a JSON object: "
155
+ '{"intent": "...", "client": "...", "is_new": true/false}.'
156
+ )
157
+ user = f"Existing clients: {roster}\n\nCoach typed: {text}"
158
+ out = self._generate(
159
+ [{"role": "system", "content": sys}, {"role": "user", "content": user}],
160
+ max_new_tokens=120,
161
+ )
162
+ obj = _extract_json(out) or {}
163
+ intent = obj.get("intent", "update")
164
+ if intent not in ("update", "query"):
165
+ intent = "update"
166
+ return {
167
+ "intent": intent,
168
+ "client": str(obj.get("client", "") or ""),
169
+ "is_new": bool(obj.get("is_new", False)),
170
+ }
171
+
172
+ def _extract_impl(self, existing, new_text):
173
+ """Merge an update into the record, classify the stage, summarize the event."""
174
+ existing = {**EMPTY_RECORD, **(existing or {})}
175
+ sys = (
176
+ "You maintain a health coach's private record on one client. You are "
177
+ "given the CURRENT record as JSON and a NEW update the coach typed. "
178
+ "Update the record using only information present in the text. Never "
179
+ "invent facts. Keep entries short.\n"
180
+ f"Classify the client's current stage as exactly one of: {STAGES}. "
181
+ "If the update does not change the stage, keep the current one.\n"
182
+ "Also write 'event': one short past-tense line summarizing what "
183
+ "happened in this update, for a timeline.\n"
184
+ "Return ONLY a JSON object with these keys:\n"
185
+ f' "stage": one of {STAGES} or "",\n'
186
+ ' "goals": string,\n'
187
+ ' "current_protocol": string,\n'
188
+ ' "next_step": string (the single next action),\n'
189
+ ' "flags": array of short strings (things to watch),\n'
190
+ ' "follow_ups": array of short strings (open promises),\n'
191
+ ' "event": string (one line, past tense).'
192
+ )
193
+ user = (
194
+ f"CURRENT record:\n{json.dumps(existing, ensure_ascii=False)}\n\n"
195
+ f"NEW update:\n{new_text}"
196
+ )
197
+ out = self._generate(
198
+ [{"role": "system", "content": sys}, {"role": "user", "content": user}]
199
+ )
200
+ obj = _extract_json(out)
201
+ record = _coerce_record(obj, existing)
202
+ event = ""
203
+ if isinstance(obj, dict):
204
+ event = str(obj.get("event", "") or "").strip()
205
+ return {"record": record, "event": event}
206
+
207
+ def _harvest_impl(self, new_text):
208
+ """Pull reusable methods the coach states, to grow the knowledgebase."""
209
+ sys = (
210
+ "You read something a health coach wrote. Extract any general method, "
211
+ "protocol, or recommendation that could be reused with other clients. "
212
+ "Ignore client-specific logistics and chit-chat. Return ONLY a JSON "
213
+ 'array of objects, each with "title" (a few words) and "body" (one or '
214
+ "two sentences). If nothing is reusable, return []."
215
+ )
216
+ out = self._generate(
217
+ [{"role": "system", "content": sys}, {"role": "user", "content": new_text}],
218
+ max_new_tokens=400,
219
+ )
220
+ items = _extract_json(out)
221
+ if not isinstance(items, list):
222
+ return []
223
+ clean = []
224
+ for it in items:
225
+ if isinstance(it, dict) and it.get("title") and it.get("body"):
226
+ clean.append({"title": str(it["title"]), "body": str(it["body"])})
227
+ return clean
228
+
229
+ @modal.method()
230
+ def route(self, text, clients):
231
+ return self._route_impl(text, clients)
232
+
233
+ @modal.method()
234
+ def extract(self, existing, new_text):
235
+ return self._extract_impl(existing, new_text)
236
+
237
+ @modal.method()
238
+ def harvest(self, new_text):
239
+ return self._harvest_impl(new_text)
240
+
241
+ @modal.fastapi_endpoint(method="POST")
242
+ def web(self, payload: dict):
243
+ """POST one of:
244
+ {"op":"route","text":"...","clients":[...]}
245
+ {"op":"extract","existing":{...},"new_text":"..."}
246
+ {"op":"harvest","new_text":"..."}"""
247
+ op = payload.get("op", "extract")
248
+ if op == "route":
249
+ return self._route_impl(payload.get("text", ""), payload.get("clients", []))
250
+ if op == "harvest":
251
+ return {"methods": self._harvest_impl(payload.get("new_text", ""))}
252
+ return self._extract_impl(payload.get("existing"), payload.get("new_text", ""))
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Service 2: Nemotron-3-Nano-4B on vLLM. Answer questions, write briefs.
257
+ # ---------------------------------------------------------------------------
258
+ @app.cls(
259
+ gpu="L4",
260
+ image=coach_image,
261
+ volumes={"/cache": hf_cache},
262
+ timeout=20 * MINUTES,
263
+ scaledown_window=300,
264
+ )
265
+ class Coach:
266
+ @modal.enter()
267
+ def load(self):
268
+ from vllm import LLM, SamplingParams
269
+
270
+ self.SamplingParams = SamplingParams
271
+ self.llm = LLM(
272
+ model=COACH_MODEL,
273
+ trust_remote_code=True,
274
+ dtype="bfloat16",
275
+ max_model_len=8192,
276
+ gpu_memory_utilization=0.92,
277
+ enforce_eager=True, # skip CUDA graph capture for a faster cold start
278
+ )
279
+
280
+ def _generate(self, messages, max_new_tokens=700):
281
+ # Nemotron-3-Nano is a reasoning model: it emits <think>...</think> before
282
+ # the answer. Give it room to think, then keep only the post-think answer.
283
+ params = self.SamplingParams(temperature=0.0, max_tokens=max_new_tokens)
284
+ outs = self.llm.chat(messages, params, use_tqdm=False)
285
+ text = outs[0].outputs[0].text.strip()
286
+ if "</think>" in text:
287
+ text = text.split("</think>")[-1].strip()
288
+ return text
289
+
290
+ def _answer_impl(self, context, question):
291
+ """Answer a natural-language question over the CRM data given as context."""
292
+ sys = (
293
+ "You are the assistant inside a health coach's CRM. Answer the coach's "
294
+ "question using ONLY the data below. Be concise and direct. If the data "
295
+ "does not contain the answer, say so plainly. Do not invent clients, "
296
+ "dates, or facts."
297
+ )
298
+ user = f"CRM DATA:\n{context}\n\nQUESTION: {question}"
299
+ return self._generate(
300
+ [{"role": "system", "content": sys}, {"role": "user", "content": user}]
301
+ )
302
+
303
+ def _brief_impl(self, record, kb, question):
304
+ """A grounded brief from the coach's own knowledgebase before they reply."""
305
+ kb_text = (
306
+ "\n".join(f"- {e.get('title','')}: {e.get('body','')}" for e in kb)
307
+ if kb
308
+ else "(no matching knowledgebase entries)"
309
+ )
310
+ sys = (
311
+ "You brief a health coach before they reply to a client. You are not "
312
+ "the coach and you do not message the client. Use ONLY the client "
313
+ "record and the coach's own knowledgebase below. Do not introduce "
314
+ "outside medical claims. Write short bullet sections:\n"
315
+ " RELEVANT METHODS: which knowledgebase entries apply and why (name them).\n"
316
+ " ALREADY NOTED: what the record already shows.\n"
317
+ " WATCH FOR: flags to respect.\n"
318
+ " TALKING POINTS: a few angles the coach could raise.\n"
319
+ "End with: 'Not medical advice. Coach reviews before sending.'"
320
+ )
321
+ user = (
322
+ f"CLIENT RECORD:\n{json.dumps(record or EMPTY_RECORD, ensure_ascii=False)}"
323
+ f"\n\nKNOWLEDGEBASE:\n{kb_text}"
324
+ f"\n\nCOACH'S QUESTION: {question or 'What should I consider before replying?'}"
325
+ )
326
+ return self._generate(
327
+ [{"role": "system", "content": sys}, {"role": "user", "content": user}]
328
+ )
329
+
330
+ @modal.method()
331
+ def answer(self, context, question):
332
+ return self._answer_impl(context, question)
333
+
334
+ @modal.method()
335
+ def brief(self, record, kb, question=""):
336
+ return self._brief_impl(record, kb, question)
337
+
338
+ @modal.fastapi_endpoint(method="POST")
339
+ def web(self, payload: dict):
340
+ """POST one of:
341
+ {"op":"answer","context":"...","question":"..."}
342
+ {"op":"brief","record":{...},"kb":[{title,body}],"question":"..."}"""
343
+ op = payload.get("op", "answer")
344
+ if op == "brief":
345
+ return {
346
+ "brief": self._brief_impl(
347
+ payload.get("record"), payload.get("kb", []), payload.get("question", "")
348
+ )
349
+ }
350
+ return {
351
+ "answer": self._answer_impl(
352
+ payload.get("context", ""), payload.get("question", "")
353
+ )
354
+ }
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Parsing helpers.
359
+ # ---------------------------------------------------------------------------
360
+ def _extract_json(text):
361
+ """Pull the first JSON object or array out of model output, defensively."""
362
+ if not text:
363
+ return None
364
+ text = re.sub(r"```(?:json)?", "", text)
365
+ start = None
366
+ for i, ch in enumerate(text):
367
+ if ch in "{[":
368
+ start = i
369
+ break
370
+ if start is None:
371
+ return None
372
+ opener = text[start]
373
+ closer = "}" if opener == "{" else "]"
374
+ depth = 0
375
+ for j in range(start, len(text)):
376
+ if text[j] == opener:
377
+ depth += 1
378
+ elif text[j] == closer:
379
+ depth -= 1
380
+ if depth == 0:
381
+ try:
382
+ return json.loads(text[start : j + 1])
383
+ except json.JSONDecodeError:
384
+ return None
385
+ return None
386
+
387
+
388
+ def _coerce_record(obj, existing):
389
+ """Force whatever the model returned into the record shape. Never raises."""
390
+ existing = {**EMPTY_RECORD, **(existing or {})}
391
+ if not isinstance(obj, dict):
392
+ return {k: existing[k] for k in EMPTY_RECORD}
393
+ out = {}
394
+ stage = str(obj.get("stage", existing.get("stage", "")) or "")
395
+ out["stage"] = stage if stage in STAGES else existing.get("stage", "")
396
+ for key in ("goals", "current_protocol", "next_step"):
397
+ out[key] = str(obj.get(key, existing.get(key, "")) or "")
398
+ for key in ("flags", "follow_ups"):
399
+ val = obj.get(key, existing.get(key, []))
400
+ if isinstance(val, str):
401
+ val = [val] if val else []
402
+ elif not isinstance(val, list):
403
+ val = list(existing.get(key, []))
404
+ out[key] = [str(x) for x in val if str(x).strip()]
405
+ return out
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Synthetic smoke test. Run with: modal run --detach modal_app.py
410
+ # (detach keeps the app alive through the slow vLLM cold start).
411
+ # ---------------------------------------------------------------------------
412
+ _SAMPLE_UPDATE = (
413
+ "Had my discovery call with Max Mustermann today. He wants to drop 5kg before "
414
+ "September and fix his afternoon energy crashes. Booked his analysis call for "
415
+ "Thursday and told him to start a food log. As a rule I have clients front-load "
416
+ "protein at breakfast when they report afternoon dips."
417
+ )
418
+
419
+
420
+ @app.local_entrypoint()
421
+ def main():
422
+ ext = Extractor()
423
+ coach = Coach()
424
+
425
+ print("=== route: who is this about, update or question? ===")
426
+ print(ext.route.remote(_SAMPLE_UPDATE, ["Dana R.", "Sam P."]))
427
+
428
+ print("\n=== extract: structured record + stage + timeline event ===")
429
+ res = ext.extract.remote(EMPTY_RECORD, _SAMPLE_UPDATE)
430
+ print(json.dumps(res, indent=2, ensure_ascii=False))
431
+
432
+ print("\n=== harvest: reusable methods for the knowledgebase ===")
433
+ print(json.dumps(ext.harvest.remote(_SAMPLE_UPDATE), indent=2, ensure_ascii=False))
434
+
435
+ print("\n=== answer: concierge Q&A over the record ===")
436
+ ctx = json.dumps({"Max Mustermann": res["record"]}, ensure_ascii=False)
437
+ print(coach.answer.remote(ctx, "What is the next step for Max?"))
requirements.txt CHANGED
@@ -1,14 +1,5 @@
1
- # ZeroGPU images already include CUDA torch/torchvision, so do not pin them here.
2
- # Match the transformers pin (and any extras like timm) to the MiniCPM-V model card
3
- # at https://huggingface.co/openbmb/MiniCPM-V-4_5 to avoid trust_remote_code load errors.
4
-
5
  gradio
6
- spaces
7
- transformers
8
- accelerate
9
- sentencepiece
10
- pillow
11
- scipy
12
- numpy
13
- matplotlib
14
- decord
 
1
+ # Spruce is a thin cockpit. Both models (MiniCPM3-4B and Nemotron-3-Nano-4B)
2
+ # run on Modal, so no torch / transformers / vllm here. The cockpit stores
3
+ # state in SQLite (stdlib) and reaches the models over HTTP with requests.
 
4
  gradio
5
+ requests