feat: bot authoring form with bot_authored event
This commit is contained in:
+33
-2
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"tiktoken>=0.7",
|
||||
"jinja2>=3.1",
|
||||
"aiosqlite>=0.20",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user