Spaces:
Sleeping
Sleeping
Rebuild as Spruce: AI-native CRM for a health coach
Browse filesTwo 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.
- .gitignore +4 -0
- README.md +68 -6
- app.py +519 -145
- modal_app.py +437 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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: '
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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
|
| 28 |
-
import
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
import gradio as gr
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 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 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
)
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
)
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
-
def
|
| 78 |
-
"""
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
return
|
| 83 |
|
| 84 |
|
| 85 |
-
def
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
if __name__ == "__main__":
|
| 170 |
-
|
|
|
|
|
|
| 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("&", "&").replace("<", "<").replace(">", ">")
|
| 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 '?')} · 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 |
-
#
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
gradio
|
| 6 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|