""" Spruce: an AI-native CRM a health coach talks to instead of maintaining a board. The coach types (later: speaks) one line into the command bar. Spruce decides whether it is an update or a question: * Update -> MiniCPM3-4B (on Modal) routes it to the right client (creating one if needed), extracts a structured record, classifies the pipeline stage, and logs a dated timeline event. It also harvests any reusable method the coach stated, to grow a knowledgebase. * Question -> Nemotron-3-Nano-4B (on Modal) answers from the client data ("what's the update on Max?", "who have I not contacted lately?"). A "Needs attention" panel surfaces stale clients and open follow-ups so nothing is dropped. A stage board gives the visual overview as a secondary view. The models never message a client and never invent clinical claims. Two endpoints, both from modal_app.py: EXTRACT_URL : MiniCPM3-4B (route / extract / harvest) COACH_URL : Nemotron-3-Nano-4B (answer / brief) Set them as Space secrets in production. """ import datetime as dt import json import os import re import sqlite3 import threading import gradio as gr import requests def _load_dotenv(path=".env"): """Minimal .env loader so local dev can keep EXTRACT_URL / COACH_URL / TURSO_* in one file. On Hugging Face Spaces these come from Space secrets instead, so a missing .env is fine.""" try: with open(path) as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, val = line.split("=", 1) os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) except FileNotFoundError: pass _load_dotenv() EXTRACT_URL = os.environ.get("EXTRACT_URL", "").rstrip("/") COACH_URL = os.environ.get("COACH_URL", "").rstrip("/") DB_PATH = os.environ.get("CORNERMAN_DB", "spruce.db") STALE_DAYS = int(os.environ.get("SPRUCE_STALE_DAYS", "7")) # Persistence. With TURSO_DATABASE_URL set we use a libSQL embedded replica that # syncs to Turso, so client data survives the Space's ephemeral filesystem. Without # it we fall back to a plain local SQLite file (fine for self-host / local dev). TURSO_DATABASE_URL = os.environ.get("TURSO_DATABASE_URL", "").strip() TURSO_AUTH_TOKEN = os.environ.get("TURSO_AUTH_TOKEN", "").strip() USE_TURSO = bool(TURSO_DATABASE_URL) if USE_TURSO: import libsql_experimental as libsql STAGES = [ "Discovery Call", "Customer booked", "Analysis call", "Second call", "Weeks 1-4", "Weeks 5-8", ] EMPTY_RECORD = { "stage": "", "goals": "", "current_protocol": "", "next_step": "", "flags": [], "follow_ups": [], } _STOP = set( "the a an and or of to in on for with is are was were be been being this that " "these those it its as at by from he she they you i we client coach about into " "after before over under more most some any not no do does did has have had will " "would can could should still your their them his her my our up out".split() ) def _today(): return dt.date.today().isoformat() # --------------------------------------------------------------------------- # Storage. # --------------------------------------------------------------------------- _WRITE_RE = re.compile(r"^\s*(insert|update|delete|replace|create|drop|alter)", re.I) class _Row: """Detached, dict- and index-accessible row. Mirrors the sqlite3.Row surface the app relies on (row["name"]) for both the sqlite3 and libSQL backends, and stays valid after its connection closes.""" __slots__ = ("_cols", "_vals") def __init__(self, cols, vals): self._cols = cols self._vals = vals def __getitem__(self, key): if isinstance(key, str): return self._vals[self._cols.index(key)] return self._vals[key] class _CursorWrap: def __init__(self, cur): self._cur = cur self._cols = [d[0] for d in cur.description] if cur.description else [] def fetchone(self): row = self._cur.fetchone() return _Row(self._cols, list(row)) if row is not None else None def fetchall(self): return [_Row(self._cols, list(r)) for r in self._cur.fetchall()] def __iter__(self): return iter(self.fetchall()) class _ConnWrap: """One connection surface over sqlite3 or a libSQL embedded replica: dict rows and `with`-block commit. For Turso it pulls the latest on open when asked, and pushes only after a write, so reads stay local-fast and writes stay durable.""" def __init__(self, sync=False): self._dirty = False if USE_TURSO: self._raw = libsql.connect( DB_PATH, sync_url=TURSO_DATABASE_URL, auth_token=TURSO_AUTH_TOKEN ) if sync: try: self._raw.sync() # pull latest from Turso into the local replica except Exception: pass # offline / first run; the local replica still serves reads else: self._raw = sqlite3.connect(DB_PATH) def execute(self, sql, params=()): if _WRITE_RE.match(sql): self._dirty = True return _CursorWrap(self._raw.execute(sql, params)) def commit(self): self._raw.commit() if USE_TURSO and self._dirty: try: self._raw.sync() # push the write back to Turso except Exception: pass self._dirty = False def sync(self): if USE_TURSO: try: self._raw.sync() except Exception: pass def __enter__(self): return self def __exit__(self, exc_type, exc, tb): if exc_type is None: self.commit() try: self._raw.close() except Exception: pass return False def _conn(sync=False): return _ConnWrap(sync=sync) def db_init(): with _conn(sync=True) as c: c.execute( "CREATE TABLE IF NOT EXISTS clients (" "id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, record TEXT, " "customer_since TEXT, last_contacted TEXT, timeline TEXT)" ) c.execute( "CREATE TABLE IF NOT EXISTS kb (" "id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)" ) # Settle the schema into the local replica (push DDL, pull frames back) # before we read it, so a first-ever boot doesn't race the embedded replica. c.commit() c.sync() n = c.execute("SELECT COUNT(*) AS n FROM clients").fetchone()["n"] if n == 0: _seed(c) def _seed(c): samples = [ ( "Sample: Dana R.", { "stage": "Weeks 1-4", "goals": "Steady afternoon energy, lose 4kg in 12 weeks.", "current_protocol": "16:8 window, 3 strength sessions a week.", "next_step": "Review sleep log on next check-in.", "flags": ["Knees sore after lunges, ease lower-body volume."], "follow_ups": ["Check sleep again next week."], }, "2026-05-20", "2026-06-03", [ {"date": "2026-05-20", "event": "Started Weeks 1-4 block."}, {"date": "2026-06-03", "event": "Reported 3pm energy crash and poor sleep."}, ], ), ( "Sample: Sam P.", { "stage": "Discovery Call", "goals": "Explore working together on stress and digestion.", "current_protocol": "", "next_step": "Send booking link for analysis call.", "flags": [], "follow_ups": ["Waiting on Sam to pick a time."], }, "2026-06-08", "2026-06-08", [{"date": "2026-06-08", "event": "Discovery call held, interested but undecided."}], ), ] for name, rec, since, last, tl in samples: c.execute( "INSERT INTO clients (name, record, customer_since, last_contacted, timeline) " "VALUES (?, ?, ?, ?, ?)", (name, json.dumps(rec), since, last, json.dumps(tl)), ) def list_clients(): with _conn() as c: return [r["name"] for r in c.execute("SELECT name FROM clients ORDER BY name")] def get_client(name): with _conn() as c: row = c.execute("SELECT * FROM clients WHERE name = ?", (name,)).fetchone() if not row: return None return { "name": row["name"], "record": _loads(row["record"], dict(EMPTY_RECORD)), "customer_since": row["customer_since"] or "", "last_contacted": row["last_contacted"] or "", "timeline": _loads(row["timeline"], []), } def all_clients(): return [get_client(n) for n in list_clients()] def _loads(s, default): try: v = json.loads(s) return v if v is not None else default except (TypeError, json.JSONDecodeError): return default def ensure_client(name): name = (name or "").strip() or "Unsorted client" with _conn() as c: row = c.execute("SELECT name FROM clients WHERE name = ?", (name,)).fetchone() if not row: c.execute( "INSERT INTO clients (name, record, customer_since, last_contacted, timeline) " "VALUES (?, ?, ?, ?, ?)", (name, json.dumps(EMPTY_RECORD), _today(), "", json.dumps([])), ) return name def save_update(name, record, event): """Persist a record update, append a dated timeline event, stamp contact date.""" cur = get_client(name) timeline = cur["timeline"] if cur else [] today = _today() if event: timeline = timeline + [{"date": today, "event": event}] since = (cur["customer_since"] if cur and cur["customer_since"] else today) with _conn() as c: c.execute( "UPDATE clients SET record = ?, last_contacted = ?, customer_since = ?, " "timeline = ? WHERE name = ?", (json.dumps(record, ensure_ascii=False), today, since, json.dumps(timeline, ensure_ascii=False), name), ) def list_kb(): with _conn() as c: return [ {"id": r["id"], "title": r["title"], "body": r["body"]} for r in c.execute("SELECT id, title, body FROM kb ORDER BY id DESC") ] def add_kb(title, body): title, body = (title or "").strip(), (body or "").strip() if title and body: with _conn() as c: c.execute("INSERT INTO kb (title, body) VALUES (?, ?)", (title, body)) # --------------------------------------------------------------------------- # Modal calls (requests follows Modal's 303 long-poll redirect by default). # --------------------------------------------------------------------------- def _post(url, payload): if not url: raise RuntimeError( "Model endpoint not set. Deploy modal_app.py and set EXTRACT_URL and " "COACH_URL (Space secrets in production)." ) r = requests.post(url, json=payload, timeout=300) r.raise_for_status() return r.json() def _strip_think(text): """Nemotron is a reasoning model; drop any ... it emits.""" text = text or "" if "" in text: text = text.split("")[-1] return text.strip() def _words(text): return {w for w in re.findall(r"[a-z]{3,}", (text or "").lower()) if w not in _STOP} def retrieve_kb(record, k=4): entries = list_kb() if not entries: return [] rec_text = " ".join( [record.get("goals", ""), record.get("current_protocol", ""), record.get("next_step", "")] + record.get("flags", []) + record.get("follow_ups", []) ) rw = _words(rec_text) scored = sorted( ((len(rw & _words(e["title"] + " " + e["body"])), e) for e in entries), key=lambda t: t[0], reverse=True, ) top = [e for s, e in scored if s > 0][:k] return top or [e for _, e in scored][:k] def _days_since(iso): try: return (dt.date.today() - dt.date.fromisoformat(iso)).days except (TypeError, ValueError): return None # --------------------------------------------------------------------------- # Rendering. # --------------------------------------------------------------------------- def esc(s): return str(s).replace("&", "&").replace("<", "<").replace(">", ">") def _li(items, cls=""): if not items: return "none" c = f" class='{cls}'" if cls else "" return f"" + "".join(f"
  • {esc(x)}
  • " for x in items) + "" def record_html(name): cl = get_client(name) if name else None if not cl: return "
    Pick a client, or type an update in the command bar.
    " r = cl["record"] last = cl["last_contacted"] or "never" dsl = _days_since(cl["last_contacted"]) last_txt = f"{last}" + (f" ({dsl}d ago)" if dsl is not None else "") stage = r.get("stage") or "Unsorted" tl = "".join( f"
    {esc(e.get('date',''))}" f"{esc(e.get('event',''))}
    " for e in reversed(cl["timeline"]) ) or "no events yet" return f"""
    {esc(cl['name'])} {esc(stage)}
    Customer since {esc(cl['customer_since'] or '?')} · last contact {esc(last_txt)}
    Goals{esc(r.get('goals','')) or 'not set'}
    Protocol{esc(r.get('current_protocol','')) or 'not set'}
    Next step{esc(r.get('next_step','')) or 'not set'}
    Watch for
    {_li(r.get('flags',[]), 'flags')}
    Open follow-ups
    {_li(r.get('follow_ups',[]), 'follow')}
    Timeline
    {tl}
    """ def board_html(): clients = all_clients() cols = [] for stage in STAGES: chips = "".join( f"
    {esc(c['name'])}
    " for c in clients if c["record"].get("stage") == stage ) cols.append(f"
    {esc(stage)}
    {chips or '
    -
    '}
    ") unsorted = [c for c in clients if c["record"].get("stage") not in STAGES] if unsorted: chips = "".join(f"
    {esc(c['name'])}
    " for c in unsorted) cols.append(f"
    Unsorted
    {chips}
    ") return f"
    {''.join(cols)}
    " def needs_html(): rows = [] for c in all_clients(): reasons = [] dsl = _days_since(c["last_contacted"]) if dsl is None: reasons.append("never contacted") elif dsl >= STALE_DAYS: reasons.append(f"no contact in {dsl}d") for f in c["record"].get("follow_ups", []): reasons.append(f"follow-up: {f}") if reasons: rows.append( f"
    {esc(c['name'])}" f"{esc('; '.join(reasons))}
    " ) if not rows: return "
    Nothing needs attention. Nice.
    " return "".join(rows) def kb_html(): entries = list_kb() if not entries: return "
    Empty. Methods you state in updates get suggested here, or seed one below.
    " return "
    " + "".join( f"
    {esc(e['title'])}
    " f"
    {esc(e['body'])}
    " for e in entries ) + "
    " # --------------------------------------------------------------------------- # Actions. # --------------------------------------------------------------------------- def _refresh(active): return record_html(active), board_html(), needs_html() def on_command(text, active): """The single command bar. Routes to update or query, with graceful failure if a model is cold or times out so a live demo never surfaces a raw stack trace.""" text = (text or "").strip() if not text: out = "Type an update or a question." return (out, record_html(active), board_html(), needs_html(), gr.update(), gr.update(choices=[], value=[]), [], "") try: return _handle_command(text, active) except Exception: out = ("⚠️ The model is waking up or the request timed out. The first request " "after idle can take ~30–60s while the models load on Modal — give it a " "moment and send again.") # Keep the text in the box so the coach can just retry. return (out, record_html(active), board_html(), needs_html(), gr.update(), gr.update(choices=[], value=[]), [], text) def _handle_command(text, active): route = _post(EXTRACT_URL, {"op": "route", "text": text, "clients": list_clients()}) intent = route.get("intent", "update") if text.endswith("?"): intent = "query" # safety net: questions end with a question mark if intent == "query": who = route.get("client", "") cl = get_client(who) if who else None # The model has no clock, so hand it today's date, each client's days since # contact, and the stale threshold — otherwise "who haven't I contacted in a # while?" can't be answered (it would have to guess at recency). if cl: context = json.dumps( {"today": _today(), "client": cl["name"], "customer_since": cl["customer_since"], "last_contacted": cl["last_contacted"], "days_since_contact": _days_since(cl["last_contacted"]), "record": cl["record"], "timeline": cl["timeline"]}, ensure_ascii=False) else: roster = [ {"name": c["name"], "stage": c["record"].get("stage", ""), "last_contacted": c["last_contacted"], "days_since_contact": _days_since(c["last_contacted"]), "next_step": c["record"].get("next_step", ""), "follow_ups": c["record"].get("follow_ups", [])} for c in all_clients() ] context = json.dumps( {"today": _today(), "stale_after_days": STALE_DAYS, "clients": roster}, ensure_ascii=False) answer = _strip_think(_post(COACH_URL, {"op": "answer", "context": context, "question": text})["answer"]) out = f"**Q:** {esc(text)}\n\n{answer}" return (out, record_html(active), board_html(), needs_html(), gr.update(), gr.update(choices=[], value=[]), [], "") # Update path. name = ensure_client(route.get("client", "")) existing = get_client(name)["record"] res = _post(EXTRACT_URL, {"op": "extract", "existing": existing, "new_text": text}) record, event = res["record"], res.get("event", "") save_update(name, record, event) methods = _post(EXTRACT_URL, {"op": "harvest", "new_text": text})["methods"] titles = [m["title"] for m in methods] stage = record.get("stage") or "Unsorted" out = (f"Filed under **{esc(name)}** ({esc(stage)})." + (f" Logged: _{esc(event)}_" if event else "") + (f"\n\nFound {len(methods)} reusable method(s) to save on the right." if methods else "")) return (out, record_html(name), board_html(), needs_html(), gr.update(choices=list_clients(), value=name), gr.update(choices=titles, value=titles), methods, "") def on_select_client(name): return record_html(name) def on_save_methods(selected, candidates): by_title = {m["title"]: m for m in (candidates or [])} added = 0 for t in selected or []: m = by_title.get(t) if m: add_kb(m["title"], m["body"]) added += 1 return kb_html(), gr.update(choices=[], value=[]), [], f"Saved {added} method(s)." def on_seed_kb(title, body): add_kb(title, body) return kb_html(), "", "", "Added to knowledgebase." def on_brief(name, question): if not name: return "Pick a client first." try: record = get_client(name)["record"] kb = retrieve_kb(record) out = _strip_think(_post(COACH_URL, {"op": "brief", "record": record, "kb": kb, "question": question})["brief"]) used = ", ".join(e["title"] for e in kb) if kb else "none yet" return f"**Knowledgebase used:** {used}\n\n{out}" except Exception: return ("⚠️ The coach model is waking up or timed out. The first request after " "idle can take ~30–60s on Modal — give it a moment and try again.") # --------------------------------------------------------------------------- # UI. # --------------------------------------------------------------------------- CSS = """ :root { --bg:#0c0f14; --panel:#151a23; --panel2:#1b212c; --ink:#eef2f8; --muted:#7e8aa0; --accent:#46d6a6; --accent2:#5b8cff; --line:#26303f; --warn:#f0a35e; } .gradio-container { background: radial-gradient(1100px 500px at 80% -10%, #16241f 0%, transparent 60%), var(--bg) !important; color: var(--ink) !important; font-family:'Inter', system-ui, sans-serif; } #brand { font-size:1.7rem; font-weight:800; letter-spacing:-.02em; background:linear-gradient(90deg,#7fe9c6,#8fb0ff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; } #tag { color:var(--muted); margin-top:-.35rem; font-size:.9rem; } .muted { color:var(--muted); } #cmdout { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:6px 14px; min-height:2.2rem; } #coldhint { color:var(--muted); font-size:.78rem; margin:2px 2px 8px; } .record { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; } .rec-head { display:flex; align-items:center; gap:10px; } .rec-name { font-weight:800; font-size:1.15rem; } .stage-badge { background:linear-gradient(90deg,#2f6b58,#33507f); color:#dffaf0; font-size:.72rem; padding:3px 9px; border-radius:999px; letter-spacing:.04em; } .rec-dates { color:var(--muted); font-size:.8rem; margin:4px 0 10px; } .rec-row { display:flex; gap:10px; margin:4px 0; } .rec-label { color:var(--accent); font-size:.74rem; text-transform:uppercase; letter-spacing:.06em; min-width:84px; padding-top:2px; } .rec-val.next { color:#bfe0ff; font-weight:600; } .rec-block { margin-top:10px; } .rec-block ul { margin:4px 0 0; padding-left:18px; } .rec-block li { margin:3px 0; color:#d4dbe8; font-size:.93rem; } ul.flags li { color:var(--warn); } ul.follow li { color:#9fd0ff; } .timeline { margin-top:4px; } .tl-row { display:flex; gap:10px; font-size:.86rem; margin:2px 0; } .tl-date { color:var(--muted); min-width:78px; } .tl-event { color:#cfd6e6; } .board { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; } .col { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:8px; min-width:120px; flex:1; } .col-head { color:var(--accent); font-size:.7rem; text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; } .chip { background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:5px 8px; margin:4px 0; font-size:.82rem; } .need { display:flex; flex-direction:column; background:var(--panel2); border-left:3px solid var(--warn); border-radius:8px; padding:7px 10px; margin:6px 0; } .need-name { font-weight:700; font-size:.9rem; } .need-why { color:var(--warn); font-size:.8rem; } .kb-wrap { display:flex; flex-direction:column; gap:8px; max-height:240px; overflow:auto; } .kb-card { background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:9px 11px; } .kb-title { font-weight:700; color:#bfe9d8; font-size:.9rem; } .kb-body { color:#c3cad8; font-size:.84rem; } .gr-button-primary { background:linear-gradient(90deg,#3fcfa0,#4f84ff) !important; border:none !important; } """ def _prewarm(): """Fire one cheap request at each model on boot so the first real interaction is warm, not a 30–60s cold start. Best-effort and off the request path: runs in a daemon thread, and any failure (endpoint unset, still loading) is ignored.""" for url, payload in ( (EXTRACT_URL, {"op": "route", "text": "hello", "clients": []}), (COACH_URL, {"op": "answer", "context": "{}", "question": "hello"}), ): if not url: continue try: requests.post(url, json=payload, timeout=180) except Exception: pass db_init() threading.Thread(target=_prewarm, daemon=True).start() _initial = (list_clients() or [None])[0] with gr.Blocks(title="Spruce") as demo: gr.HTML( "
    🌲 Spruce
    " "
    The CRM you talk to. Type what happened and it files itself; " "ask a question and it answers. MiniCPM3-4B writes, Nemotron-3-Nano-4B reads. Both on Modal.
    " ) candidates_state = gr.State([]) with gr.Row(): cmd = gr.Textbox( label="", placeholder="e.g. Had a call with Max, booked his analysis call Thursday, wants to drop 5kg | or: what's the update on Dana?", lines=2, scale=5, ) send = gr.Button("Send", variant="primary", scale=1) gr.Examples( examples=[ ["Had a discovery call with Max, wants to drop 5kg before September and fix afternoon energy crashes. Booked his analysis call Thursday. I always have clients front-load protein at breakfast when energy dips."], ["Dana reported her 3pm energy crash is gone after the protein change. Moving her into weeks 5-8."], ["What's the update on Dana?"], ["Who have I not contacted in a while?"], ], inputs=cmd, label="Try one (click to load, then Send):", ) gr.HTML( "
    First request after idle can take ~30–60s while " "MiniCPM3-4B and Nemotron-3-Nano-4B wake on Modal — then it's fast.
    " ) cmd_out = gr.Markdown(elem_id="cmdout") with gr.Row(): with gr.Column(scale=2): gr.Markdown("### Client") client = gr.Dropdown(choices=list_clients(), value=_initial, label="Active client", interactive=True) record_view = gr.HTML(record_html(_initial)) with gr.Column(scale=2): gr.Markdown("### Needs attention") needs_view = gr.HTML(needs_html()) with gr.Accordion("Knowledgebase", open=False): kb_view = gr.HTML(kb_html()) cand_pick = gr.CheckboxGroup(choices=[], label="Methods found in last update") save_methods_btn = gr.Button("Save selected") kb_title = gr.Textbox(label="Seed a method: title") kb_body = gr.Textbox(label="Body", lines=2) seed_btn = gr.Button("Add to knowledgebase") with gr.Accordion("Brief me from my knowledgebase", open=False): brief_q = gr.Textbox(label="Before I reply, what should I consider?", lines=1) brief_btn = gr.Button("Brief me") brief_out = gr.Markdown() gr.Markdown("### Pipeline board") board_view = gr.HTML(board_html()) # Wiring. send.click( on_command, [cmd, client], [cmd_out, record_view, board_view, needs_view, client, cand_pick, candidates_state, cmd], ) cmd.submit( on_command, [cmd, client], [cmd_out, record_view, board_view, needs_view, client, cand_pick, candidates_state, cmd], ) client.change(on_select_client, client, record_view) save_methods_btn.click(on_save_methods, [cand_pick, candidates_state], [kb_view, cand_pick, candidates_state, cmd_out]) seed_btn.click(on_seed_kb, [kb_title, kb_body], [kb_view, kb_title, kb_body, cmd_out]) brief_btn.click(on_brief, [client, brief_q], brief_out) if __name__ == "__main__": # Gradio 6 moved css/theme from the Blocks constructor to launch(). demo.launch(css=CSS, theme=gr.themes.Base())