MiniRobot / app.py
Edwin Sam
Give the coach model a clock for recency queries
9b3cb78
Raw
History Blame Contribute Delete
29 kB
"""
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 '?')} &middot; 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())