feat: bot authoring form with bot_authored event

This commit is contained in:
Joseph Doherty
2026-04-26 12:17:06 -04:00
parent a5339fc1d2
commit 44ea627a8a
9 changed files with 411 additions and 2 deletions
+33 -2
View File
@@ -1,6 +1,37 @@
from fastapi import FastAPI
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
app = FastAPI(title="chat")
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from chat.config import load_settings
from chat.db.migrate import apply_migrations
# Trigger handler registration:
import chat.state.entities # noqa: F401
import chat.state.edges # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
apply_migrations(settings.db_path)
app.state.settings = settings
yield
app = FastAPI(title="chat", lifespan=lifespan)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(bots_router)
@app.get("/health")
+37
View File
@@ -0,0 +1,37 @@
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0;
color: #1c1c1c;
background: #fafafa;
}
.topbar {
padding: 12px 24px;
border-bottom: 1px solid #e5e5e5;
background: #fff;
}
.brand { font-weight: 600; text-decoration: none; color: inherit; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
h1 { margin-top: 0; }
.page-header { display: flex; align-items: center; justify-content: space-between; }
.btn, button {
display: inline-block; padding: 8px 14px;
border: 1px solid #444; background: #1c1c1c; color: #fff;
border-radius: 4px; text-decoration: none; cursor: pointer;
font: inherit;
}
.bot-form label { display: block; margin-bottom: 14px; }
.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; }
.bot-form input[type=text], .bot-form textarea {
width: 100%; padding: 6px 8px; font: inherit;
border: 1px solid #ccc; border-radius: 3px; background: #fff;
}
.bot-form small { display: block; color: #666; margin-top: 2px; }
.bot-list { list-style: none; padding: 0; }
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
.muted { color: #666; }
.error {
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
color: #a00; border-radius: 3px;
}
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}chat{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/bots">chat</a>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}New bot - chat{% endblock %}
{% block content %}
<h1>New bot</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/bots/new" class="bot-form">
<label>
<span>id</span>
<input type="text" name="id" required value="{{ values.id|default('', true) }}">
<small>slug-like identifier (e.g. <code>bot_a</code>, <code>alice_office</code>)</small>
</label>
<label>
<span>name</span>
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
</label>
<label>
<span>persona</span>
<textarea name="persona" rows="4" required>{{ values.persona|default('', true) }}</textarea>
<small>a short description, ~3-5 lines</small>
</label>
<label>
<span>voice samples</span>
<textarea name="voice_samples" rows="6">{{ values.voice_samples|default('', true) }}</textarea>
<small>1-3 samples, separated by a line containing only <code>---</code></small>
</label>
<label>
<span>traits</span>
<textarea name="traits" rows="3">{{ values.traits|default('', true) }}</textarea>
<small>comma- or newline-separated; 3-15 typical</small>
</label>
<label>
<span>backstory</span>
<textarea name="backstory" rows="6">{{ values.backstory|default('', true) }}</textarea>
<small>100-500 words target</small>
</label>
<label>
<span>initial relationship to you</span>
<textarea name="initial_relationship_to_you" rows="3" required>{{ values.initial_relationship_to_you|default('', true) }}</textarea>
</label>
<label>
<span>kickoff prose</span>
<textarea name="kickoff_prose" rows="4" required>{{ values.kickoff_prose|default('', true) }}</textarea>
<small>a short opening scene; parsed in the next step</small>
</label>
<button type="submit">Save bot</button>
</form>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Bots - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Bots</h1>
<a class="btn" href="/bots/new">+ New bot</a>
</header>
{% if bots %}
<ul class="bot-list">
{% for bot in bots %}
<li><a href="/bots/{{ bot.id }}">{{ bot.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No bots yet. <a href="/bots/new">Create your first bot.</a></p>
{% endif %}
{% endblock %}
View File
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.state.entities import list_bots
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose")
def get_conn(request: Request):
settings = request.app.state.settings
db_path: Path = settings.db_path
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
finally:
conn.close()
def _split_voice_samples(text: str) -> list[str]:
if not text or not text.strip():
return []
# Split on a line containing only "---" (with optional surrounding whitespace).
parts: list[str] = []
buf: list[str] = []
for line in text.splitlines():
if line.strip() == "---":
if buf:
parts.append("\n".join(buf).strip())
buf = []
continue
buf.append(line)
if buf:
parts.append("\n".join(buf).strip())
return [p for p in parts if p]
def _split_traits(text: str) -> list[str]:
if not text or not text.strip():
return []
items: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
if "," in line:
items.extend(p.strip() for p in line.split(","))
else:
items.append(line)
return [t for t in items if t]
@router.get("/bots", response_class=HTMLResponse)
async def bots_list(request: Request, conn=Depends(get_conn)):
bots = list_bots(conn)
return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots})
@router.get("/bots/new", response_class=HTMLResponse)
async def bot_form(request: Request):
return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None})
@router.post("/bots/new")
async def bot_create(
request: Request,
id: str = Form(""),
name: str = Form(""),
persona: str = Form(""),
voice_samples: str = Form(""),
traits: str = Form(""),
backstory: str = Form(""),
initial_relationship_to_you: str = Form(""),
kickoff_prose: str = Form(""),
conn=Depends(get_conn),
):
values = {
"id": id,
"name": name,
"persona": persona,
"voice_samples": voice_samples,
"traits": traits,
"backstory": backstory,
"initial_relationship_to_you": initial_relationship_to_you,
"kickoff_prose": kickoff_prose,
}
missing = [f for f in REQUIRED_FIELDS if not values[f].strip()]
if missing:
raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}")
payload = {
"id": id.strip(),
"name": name.strip(),
"persona": persona.strip(),
"voice_samples": _split_voice_samples(voice_samples),
"traits": _split_traits(traits),
"backstory": backstory.strip(),
"initial_relationship_to_you": initial_relationship_to_you.strip(),
"kickoff_prose": kickoff_prose.strip(),
}
append_event(conn, kind="bot_authored", payload=payload)
project(conn)
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"tiktoken>=0.7",
"jinja2>=3.1",
"aiosqlite>=0.20",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from chat.app import app
@pytest.fixture
def client(tmp_path, monkeypatch):
config_path = tmp_path / "config.toml"
config_path.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path))
monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db"))
with TestClient(app) as c:
yield c
def test_get_new_bot_form_renders(client):
response = client.get("/bots/new")
assert response.status_code == 200
body = response.text.lower()
assert "<form" in body
assert "name" in body
assert "persona" in body
assert "kickoff" in body
def test_post_new_bot_appends_event_and_redirects(client, tmp_path):
response = client.post(
"/bots/new",
data={
"id": "bot_a",
"name": "BotA",
"persona": "thoughtful, observant",
"voice_samples": "first sample\n---\nsecond sample",
"traits": "shy, quick to anger",
"backstory": "grew up in a small town",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "you stay late at the office",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/bots/bot_a/kickoff"
from chat.db.connection import open_db
from chat.state.entities import get_bot
with open_db(tmp_path / "test.db") as conn:
bot = get_bot(conn, "bot_a")
assert bot is not None
assert bot["name"] == "BotA"
assert bot["voice_samples"] == ["first sample", "second sample"]
assert bot["traits"] == ["shy", "quick to anger"]
assert bot["backstory"] == "grew up in a small town"
assert bot["initial_relationship_to_you"] == "coworker"
assert bot["kickoff_prose"] == "you stay late at the office"
# Confirm event was actually appended (state goes through event log).
cur = conn.execute(
"SELECT kind, payload_json FROM event_log WHERE kind = 'bot_authored'"
)
rows = cur.fetchall()
assert len(rows) == 1
def test_post_new_bot_rejects_missing_required(client):
response = client.post(
"/bots/new",
data={"id": "bot_b"},
follow_redirects=False,
)
assert response.status_code == 400
def test_get_bots_list_renders(client):
response = client.get("/bots")
assert response.status_code == 200
def test_post_new_bot_empty_traits_parses_to_empty_list(client, tmp_path):
response = client.post(
"/bots/new",
data={
"id": "bot_c",
"name": "BotC",
"persona": "stoic",
"voice_samples": "",
"traits": "",
"backstory": "",
"initial_relationship_to_you": "stranger",
"kickoff_prose": "the rain begins",
},
follow_redirects=False,
)
assert response.status_code == 303
from chat.db.connection import open_db
from chat.state.entities import get_bot
with open_db(tmp_path / "test.db") as conn:
bot = get_bot(conn, "bot_c")
assert bot is not None
assert bot["voice_samples"] == []
assert bot["traits"] == []
def test_post_new_bot_traits_split_by_newlines(client, tmp_path):
response = client.post(
"/bots/new",
data={
"id": "bot_d",
"name": "BotD",
"persona": "curious",
"voice_samples": "",
"traits": "calm\nthoughtful\nguarded",
"backstory": "",
"initial_relationship_to_you": "neighbor",
"kickoff_prose": "morning light",
},
follow_redirects=False,
)
assert response.status_code == 303
from chat.db.connection import open_db
from chat.state.entities import get_bot
with open_db(tmp_path / "test.db") as conn:
bot = get_bot(conn, "bot_d")
assert bot is not None
assert bot["traits"] == ["calm", "thoughtful", "guarded"]