"""
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')}
"""
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"")
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())