Spaces:
Running
Running
| """ | |
| 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 <think>...</think> it emits.""" | |
| text = text or "" | |
| if "</think>" in text: | |
| text = text.split("</think>")[-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 "<span class='muted'>none</span>" | |
| c = f" class='{cls}'" if cls else "" | |
| return f"<ul{c}>" + "".join(f"<li>{esc(x)}</li>" for x in items) + "</ul>" | |
| def record_html(name): | |
| cl = get_client(name) if name else None | |
| if not cl: | |
| return "<div class='muted'>Pick a client, or type an update in the command bar.</div>" | |
| 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"<div class='tl-row'><span class='tl-date'>{esc(e.get('date',''))}</span>" | |
| f"<span class='tl-event'>{esc(e.get('event',''))}</span></div>" | |
| for e in reversed(cl["timeline"]) | |
| ) or "<span class='muted'>no events yet</span>" | |
| return f""" | |
| <div class="record"> | |
| <div class="rec-head"> | |
| <span class="rec-name">{esc(cl['name'])}</span> | |
| <span class="stage-badge">{esc(stage)}</span> | |
| </div> | |
| <div class="rec-dates">Customer since {esc(cl['customer_since'] or '?')} · last contact {esc(last_txt)}</div> | |
| <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> | |
| <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> | |
| <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> | |
| <div class="rec-block"><div class="rec-label">Watch for</div>{_li(r.get('flags',[]), 'flags')}</div> | |
| <div class="rec-block"><div class="rec-label">Open follow-ups</div>{_li(r.get('follow_ups',[]), 'follow')}</div> | |
| <div class="rec-block"><div class="rec-label">Timeline</div><div class="timeline">{tl}</div></div> | |
| </div>""" | |
| def board_html(): | |
| clients = all_clients() | |
| cols = [] | |
| for stage in STAGES: | |
| chips = "".join( | |
| f"<div class='chip'>{esc(c['name'])}</div>" | |
| for c in clients if c["record"].get("stage") == stage | |
| ) | |
| cols.append(f"<div class='col'><div class='col-head'>{esc(stage)}</div>{chips or '<div class=muted>-</div>'}</div>") | |
| unsorted = [c for c in clients if c["record"].get("stage") not in STAGES] | |
| if unsorted: | |
| chips = "".join(f"<div class='chip'>{esc(c['name'])}</div>" for c in unsorted) | |
| cols.append(f"<div class='col'><div class='col-head'>Unsorted</div>{chips}</div>") | |
| return f"<div class='board'>{''.join(cols)}</div>" | |
| 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"<div class='need'><span class='need-name'>{esc(c['name'])}</span>" | |
| f"<span class='need-why'>{esc('; '.join(reasons))}</span></div>" | |
| ) | |
| if not rows: | |
| return "<div class='muted'>Nothing needs attention. Nice.</div>" | |
| return "".join(rows) | |
| def kb_html(): | |
| entries = list_kb() | |
| if not entries: | |
| return "<div class='muted'>Empty. Methods you state in updates get suggested here, or seed one below.</div>" | |
| return "<div class='kb-wrap'>" + "".join( | |
| f"<div class='kb-card'><div class='kb-title'>{esc(e['title'])}</div>" | |
| f"<div class='kb-body'>{esc(e['body'])}</div></div>" for e in entries | |
| ) + "</div>" | |
| # --------------------------------------------------------------------------- | |
| # 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( | |
| "<div id='brand'>🌲 Spruce</div>" | |
| "<div id='tag'>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.</div>" | |
| ) | |
| 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( | |
| "<div id='coldhint'>First request after idle can take ~30–60s while " | |
| "MiniCPM3-4B and Nemotron-3-Nano-4B wake on Modal — then it's fast.</div>" | |
| ) | |
| 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()) | |