4 Commits

Author SHA1 Message Date
Joseph Doherty fbb16c86b3 feat: kickoff parse-and-confirm flow with chat creation 2026-04-26 12:28:05 -04:00
Joseph Doherty e44e2bf93f feat: settings page with you-entity authoring 2026-04-26 12:22:00 -04:00
Joseph Doherty 44ea627a8a feat: bot authoring form with bot_authored event 2026-04-26 12:17:06 -04:00
Joseph Doherty a5339fc1d2 feat: kickoff prose parser via classifier 2026-04-26 12:09:17 -04:00
18 changed files with 1472 additions and 2 deletions
+37 -2
View File
@@ -1,6 +1,41 @@
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
from chat.web.kickoff import router as kickoff_router
from chat.web.settings import router as settings_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.include_router(kickoff_router)
app.include_router(settings_router)
@app.get("/health")
View File
+121
View File
@@ -0,0 +1,121 @@
"""Kickoff prose parser.
Service-layer function that converts a bot's authored kickoff prose into a
structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will
wire this into the UI flow).
The classifier prompt includes only the bot context that's load-bearing for
parsing the opening scene: name, persona, the authored
``initial_relationship_to_you`` blurb, the ``you`` entity name, and the
kickoff prose itself. Other identity fields (traits, backstory, ...) are
intentionally left out — they would be noise for this extraction.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class ActivityShape(BaseModel):
"""Per-entity activity at scene start.
Maps onto Requirements §6.5: ``current_action.{verb,interruptible,
required_attention,expected_duration}`` plus posture, attention, holding.
``action_required_attention`` is left as a free-form string ("low" /
"medium" / "high" expected) rather than a Literal so the classifier has
room to vary phrasing in v1.
"""
posture: str
action_verb: str
action_interruptible: bool
action_required_attention: str # low | medium | high
action_expected_duration: str
attention: str = ""
holding: list[str] = Field(default_factory=list)
class KickoffParse(BaseModel):
"""Structured opening-scene state extracted from kickoff prose.
``container_properties`` is loose ``dict``: the classifier may emit
``moving`` / ``public`` / ``audible_range`` keys, but downstream
consumers (T13's confirm form) handle missing keys gracefully.
``initial_time_iso`` is stored as text — not validated as a datetime
here; ``chat_state.time`` stores it as a plain string.
"""
container_name: str
container_type: str
container_properties: dict
you_activity: ActivityShape
bot_activity: ActivityShape
initial_time_iso: str
edge_seed_summary: str
edge_seed_knowledge_facts: list[str]
_SYSTEM_PROMPT = (
"You are extracting structured scene state from a roleplay kickoff "
"scene description. The user provides bot context and a prose "
"description of the opening scene; you output JSON conforming to the "
"schema. Be concrete: pick a single container, single activity per "
"entity, and a sensible initial in-fiction time. Anything not stated "
"explicitly should be inferred reasonably from the prose."
)
def _build_user_prompt(
*,
bot_name: str,
bot_persona: str,
initial_relationship_to_you: str,
kickoff_prose: str,
you_name: str,
) -> str:
return (
f"BOT NAME: {bot_name}\n"
f"BOT PERSONA: {bot_persona}\n"
f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n"
f"YOU NAME: {you_name}\n"
f"KICKOFF PROSE:\n{kickoff_prose}"
)
async def parse_kickoff(
client: LLMClient,
*,
model: str,
bot_name: str,
bot_persona: str,
initial_relationship_to_you: str,
kickoff_prose: str,
you_name: str,
timeout_s: float = 10.0,
) -> KickoffParse:
"""Parse authored kickoff prose into a structured ``KickoffParse``.
Internally calls :func:`chat.llm.classify.classify` with a labeled
user prompt. Raises ``RuntimeError`` if the classifier fails twice in
a row — no default is supplied at this layer, since the caller (T13's
confirm form) is responsible for showing an error and letting the
user edit.
"""
user_prompt = _build_user_prompt(
bot_name=bot_name,
bot_persona=bot_persona,
initial_relationship_to_you=initial_relationship_to_you,
kickoff_prose=kickoff_prose,
you_name=you_name,
)
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=KickoffParse,
timeout_s=timeout_s,
)
+41
View File
@@ -0,0 +1,41 @@
* { 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;
}
.success {
padding: 8px 12px; border: 1px solid #2d7a3a; background: #eafaf0;
color: #1f5c2a; 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 %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Confirm kickoff - chat{% endblock %}
{% block content %}
<h1>Confirm kickoff</h1>
<p>Review and edit the parsed opening scene for <strong>{{ values.bot_name }}</strong>, then confirm to start the chat.</p>
<form method="post" action="/bots/{{ values.bot_id }}/kickoff" class="kickoff-form">
<fieldset>
<legend>Container</legend>
<label>
<span>name</span>
<input type="text" name="container_name" required value="{{ values.container_name|default('', true) }}">
</label>
<label>
<span>type</span>
<input type="text" name="container_type" required value="{{ values.container_type|default('', true) }}">
</label>
<label>
<span>properties (JSON)</span>
<textarea name="container_properties" rows="6">{{ values.container_properties|default('{}', true) }}</textarea>
<small>JSON object; invalid JSON falls back to <code>{}</code></small>
</label>
</fieldset>
<fieldset>
<legend>Initial in-fiction time</legend>
<label>
<span>initial_time_iso</span>
<input type="text" name="initial_time_iso" required value="{{ values.initial_time_iso|default('', true) }}">
<small>ISO 8601, e.g. <code>2026-04-26T20:00:00+00:00</code></small>
</label>
</fieldset>
<fieldset>
<legend>Your activity</legend>
<label>
<span>posture</span>
<input type="text" name="you_activity_posture" value="{{ values.you_activity_posture|default('', true) }}">
</label>
<label>
<span>action verb</span>
<input type="text" name="you_activity_action_verb" value="{{ values.you_activity_action_verb|default('', true) }}">
</label>
<label>
<span>interruptible</span>
<input type="checkbox" name="you_activity_action_interruptible"{% if values.you_activity_action_interruptible %} checked{% endif %}>
</label>
<label>
<span>required attention</span>
<input type="text" name="you_activity_action_required_attention" value="{{ values.you_activity_action_required_attention|default('low', true) }}">
<small>low / medium / high</small>
</label>
<label>
<span>expected duration</span>
<input type="text" name="you_activity_action_expected_duration" value="{{ values.you_activity_action_expected_duration|default('', true) }}">
</label>
<label>
<span>attention</span>
<input type="text" name="you_activity_attention" value="{{ values.you_activity_attention|default('', true) }}">
</label>
<label>
<span>holding (comma-separated)</span>
<input type="text" name="you_activity_holding" value="{{ values.you_activity_holding|default('', true) }}">
</label>
</fieldset>
<fieldset>
<legend>{{ values.bot_name }}'s activity</legend>
<label>
<span>posture</span>
<input type="text" name="bot_activity_posture" value="{{ values.bot_activity_posture|default('', true) }}">
</label>
<label>
<span>action verb</span>
<input type="text" name="bot_activity_action_verb" value="{{ values.bot_activity_action_verb|default('', true) }}">
</label>
<label>
<span>interruptible</span>
<input type="checkbox" name="bot_activity_action_interruptible"{% if values.bot_activity_action_interruptible %} checked{% endif %}>
</label>
<label>
<span>required attention</span>
<input type="text" name="bot_activity_action_required_attention" value="{{ values.bot_activity_action_required_attention|default('low', true) }}">
<small>low / medium / high</small>
</label>
<label>
<span>expected duration</span>
<input type="text" name="bot_activity_action_expected_duration" value="{{ values.bot_activity_action_expected_duration|default('', true) }}">
</label>
<label>
<span>attention</span>
<input type="text" name="bot_activity_attention" value="{{ values.bot_activity_attention|default('', true) }}">
</label>
<label>
<span>holding (comma-separated)</span>
<input type="text" name="bot_activity_holding" value="{{ values.bot_activity_holding|default('', true) }}">
</label>
</fieldset>
<fieldset>
<legend>Edge seed</legend>
<label>
<span>summary</span>
<textarea name="edge_seed_summary" rows="3">{{ values.edge_seed_summary|default('', true) }}</textarea>
</label>
<label>
<span>knowledge facts (one per line)</span>
<textarea name="edge_seed_knowledge_facts" rows="6">{{ values.edge_seed_knowledge_facts|default('', true) }}</textarea>
</label>
</fieldset>
<div class="actions">
<button type="submit">Confirm and start chat</button>
<a href="/bots">Cancel</a>
</div>
</form>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Settings - chat{% endblock %}
{% block content %}
<h1>Settings</h1>
{% if saved %}
<p class="success">Settings saved.</p>
{% endif %}
<form method="post" action="/settings" class="bot-form">
<label>
<span>name</span>
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
<small>required</small>
</label>
<label>
<span>pronouns</span>
<input type="text" name="pronouns" value="{{ values.pronouns|default('', true) }}">
<small>optional (e.g. they/them)</small>
</label>
<label>
<span>persona</span>
<textarea name="persona" rows="3">{{ values.persona|default('', true) }}</textarea>
<small>optional but recommended; a short description of you</small>
</label>
<button type="submit">Save settings</button>
</form>
{% 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)
+284
View File
@@ -0,0 +1,284 @@
"""Kickoff parse-and-confirm flow.
After a bot is authored, the user lands on ``/bots/<id>/kickoff``. We call the
LLM-backed ``parse_kickoff`` to extract a structured opening scene from the
authored prose and render it as an editable form. On submit, the (possibly
edited) values are turned into a sequence of events that initialize the chat,
its container, the participants' activities, an open scene, and a seed edge.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.client import LLMClient
from chat.services.kickoff import parse_kickoff
from chat.state.entities import get_bot, get_you
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
def get_llm_client(request: Request) -> LLMClient:
"""Production LLM client. Tests override this via ``app.dependency_overrides``."""
settings = request.app.state.settings
from chat.llm.featherless import FeatherlessClient
return FeatherlessClient(
api_key=settings.featherless_api_key,
base_url=settings.featherless_base_url,
)
def _parse_holding(text: str) -> list[str]:
if not text or not text.strip():
return []
return [p.strip() for p in text.split(",") if p.strip()]
def _parse_facts(text: str) -> list[str]:
if not text or not text.strip():
return []
return [line.strip() for line in text.splitlines() if line.strip()]
def _parse_properties(text: str) -> dict:
"""Parse the container_properties textarea as JSON.
Returns ``{}`` on invalid JSON rather than raising — the form is editable
and a bad value should not block the user from confirming the rest.
"""
if not text or not text.strip():
return {}
try:
loaded = json.loads(text)
return loaded if isinstance(loaded, dict) else {}
except (json.JSONDecodeError, ValueError):
return {}
@router.get("/bots/{bot_id}/kickoff", response_class=HTMLResponse)
async def kickoff_get(
bot_id: str,
request: Request,
conn=Depends(get_conn),
llm=Depends(get_llm_client),
):
bot = get_bot(conn, bot_id)
if bot is None:
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
you = get_you(conn)
you_name = you["name"] if you else "You"
settings = request.app.state.settings
parsed = await parse_kickoff(
llm,
model=settings.classifier_model,
bot_name=bot["name"],
bot_persona=bot["persona"],
initial_relationship_to_you=bot.get("initial_relationship_to_you", ""),
kickoff_prose=bot.get("kickoff_prose", ""),
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
# Render values onto the form. ``container_properties`` is shown as JSON;
# ``holding`` lists are rendered as comma-separated text; the seed
# knowledge facts are rendered one-per-line.
values = {
"bot_id": bot_id,
"bot_name": bot["name"],
"container_name": parsed.container_name,
"container_type": parsed.container_type,
"container_properties": json.dumps(parsed.container_properties, indent=2),
"initial_time_iso": parsed.initial_time_iso,
"you_activity_posture": parsed.you_activity.posture,
"you_activity_action_verb": parsed.you_activity.action_verb,
"you_activity_action_interruptible": parsed.you_activity.action_interruptible,
"you_activity_action_required_attention": parsed.you_activity.action_required_attention,
"you_activity_action_expected_duration": parsed.you_activity.action_expected_duration,
"you_activity_attention": parsed.you_activity.attention,
"you_activity_holding": ", ".join(parsed.you_activity.holding),
"bot_activity_posture": parsed.bot_activity.posture,
"bot_activity_action_verb": parsed.bot_activity.action_verb,
"bot_activity_action_interruptible": parsed.bot_activity.action_interruptible,
"bot_activity_action_required_attention": parsed.bot_activity.action_required_attention,
"bot_activity_action_expected_duration": parsed.bot_activity.action_expected_duration,
"bot_activity_attention": parsed.bot_activity.attention,
"bot_activity_holding": ", ".join(parsed.bot_activity.holding),
"edge_seed_summary": parsed.edge_seed_summary,
"edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
}
return TEMPLATES.TemplateResponse(request, "kickoff_confirm.html", {"values": values})
@router.post("/bots/{bot_id}/kickoff")
async def kickoff_post(
bot_id: str,
request: Request,
container_name: str = Form(""),
container_type: str = Form(""),
container_properties: str = Form(""),
initial_time_iso: str = Form(""),
you_activity_posture: str = Form(""),
you_activity_action_verb: str = Form(""),
you_activity_action_interruptible: str = Form(""),
you_activity_action_required_attention: str = Form("low"),
you_activity_action_expected_duration: str = Form(""),
you_activity_attention: str = Form(""),
you_activity_holding: str = Form(""),
bot_activity_posture: str = Form(""),
bot_activity_action_verb: str = Form(""),
bot_activity_action_interruptible: str = Form(""),
bot_activity_action_required_attention: str = Form("low"),
bot_activity_action_expected_duration: str = Form(""),
bot_activity_attention: str = Form(""),
bot_activity_holding: str = Form(""),
edge_seed_summary: str = Form(""),
edge_seed_knowledge_facts: str = Form(""),
conn=Depends(get_conn),
):
bot = get_bot(conn, bot_id)
if bot is None:
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
# Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset
# form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse.
if initial_time_iso.strip():
try:
datetime.fromisoformat(initial_time_iso.strip())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"invalid initial_time_iso: {initial_time_iso!r}",
)
chat_id = f"chat_{bot_id}"
# Predict the next container id so we can reference it from later events
# without needing a mid-flow projection. Containers use AUTOINCREMENT-style
# rowid, so MAX(id)+1 is safe within this single-writer transaction.
next_container_row = conn.execute(
"SELECT COALESCE(MAX(id), 0) + 1 FROM containers"
).fetchone()
container_id = next_container_row[0]
# 1. chat_created
append_event(
conn,
kind="chat_created",
payload={
"id": chat_id,
"host_bot_id": bot_id,
"initial_time": initial_time_iso,
"narrative_anchor": "Day 1",
"weather": "",
},
)
# 2. container_created
append_event(
conn,
kind="container_created",
payload={
"chat_id": chat_id,
"name": container_name,
"type": container_type,
"properties": _parse_properties(container_properties),
"parent_id": None,
},
)
you_interruptible = bool(you_activity_action_interruptible)
bot_interruptible = bool(bot_activity_action_interruptible)
# 3. activity_change for "you"
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"container_id": container_id,
"posture": you_activity_posture,
"action": {
"verb": you_activity_action_verb,
"interruptible": you_interruptible,
"required_attention": you_activity_action_required_attention,
"expected_duration": you_activity_action_expected_duration,
"started_at": initial_time_iso,
},
"attention": you_activity_attention,
"holding": _parse_holding(you_activity_holding),
"status": {},
},
)
# 4. activity_change for bot
append_event(
conn,
kind="activity_change",
payload={
"entity_id": bot_id,
"container_id": container_id,
"posture": bot_activity_posture,
"action": {
"verb": bot_activity_action_verb,
"interruptible": bot_interruptible,
"required_attention": bot_activity_action_required_attention,
"expected_duration": bot_activity_action_expected_duration,
"started_at": initial_time_iso,
},
"attention": bot_activity_attention,
"holding": _parse_holding(bot_activity_holding),
"status": {},
},
)
# 5. scene_opened
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": chat_id,
"container_id": container_id,
"started_at": initial_time_iso,
"participants": ["you", bot_id],
},
)
# 6. edge_update (seed). The seed summary is preserved as the first
# knowledge fact prefixed with ``[summary] `` — proper summary writes happen
# at scene-close (T27).
facts = _parse_facts(edge_seed_knowledge_facts)
if edge_seed_summary.strip():
facts.insert(0, f"[summary] {edge_seed_summary.strip()}")
append_event(
conn,
kind="edge_update",
payload={
"source_id": bot_id,
"target_id": "you",
"chat_id": chat_id,
"knowledge_facts": facts,
},
)
# Project all events at once. ``bot_authored`` (already in log from prior
# POST) is idempotent (INSERT OR REPLACE); the new events project cleanly
# because they're being applied for the first time.
project(conn)
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.state.entities import get_you
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
@router.get("/settings", response_class=HTMLResponse)
async def settings_get(request: Request, conn=Depends(get_conn)):
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
return TEMPLATES.TemplateResponse(
request, "settings.html", {"values": you, "saved": False}
)
@router.post("/settings", response_class=HTMLResponse)
async def settings_post(
request: Request,
name: str = Form(""),
pronouns: str = Form(""),
persona: str = Form(""),
conn=Depends(get_conn),
):
if not name.strip():
raise HTTPException(status_code=400, detail="name is required")
payload = {
"name": name.strip(),
"pronouns": pronouns.strip(),
"persona": persona.strip(),
}
append_event(conn, kind="you_authored", payload=payload)
project(conn)
return TEMPLATES.TemplateResponse(
request, "settings.html", {"values": payload, "saved": True}
)
+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"]
+131
View File
@@ -0,0 +1,131 @@
import json
import pytest
from chat.llm.mock import MockLLMClient
from chat.services.kickoff import (
ActivityShape,
KickoffParse,
parse_kickoff,
)
def _full_kickoff_json() -> str:
return json.dumps(
{
"container_name": "office bullpen, late evening",
"container_type": "office",
"container_properties": {
"moving": False,
"public": False,
"audible_range": "room",
},
"you_activity": {
"posture": "sitting at your desk",
"action_verb": "finishing emails",
"action_interruptible": True,
"action_required_attention": "low",
"action_expected_duration": "15m",
"attention": "the screen",
"holding": ["coffee mug"],
},
"bot_activity": {
"posture": "sitting at her desk",
"action_verb": "pretending to work",
"action_interruptible": True,
"action_required_attention": "low",
"action_expected_duration": "indefinite",
"attention": "you, in glances",
"holding": [],
},
"initial_time_iso": "2026-04-26T19:42:00",
"edge_seed_summary": "coworkers; aware of each other; no shared history beyond the office",
"edge_seed_knowledge_facts": [
"they work on the same floor",
"it is unusual to be the only two left",
],
}
)
@pytest.mark.asyncio
async def test_parse_kickoff_happy_path_populates_fields():
mock = MockLLMClient(canned=[_full_kickoff_json()])
result = await parse_kickoff(
mock,
model="m",
bot_name="BotA",
bot_persona="reserved colleague who quietly notices things",
initial_relationship_to_you="coworker, slight crush, never voiced",
kickoff_prose=(
"you stay late at the office; only you and BotA are there; "
"she's at her desk pretending to work"
),
you_name="You",
)
assert isinstance(result, KickoffParse)
assert result.container_name == "office bullpen, late evening"
assert result.container_type == "office"
assert isinstance(result.you_activity, ActivityShape)
assert result.you_activity.posture == "sitting at your desk"
assert result.bot_activity.action_verb == "pretending to work"
assert result.edge_seed_summary.startswith("coworkers")
assert "they work on the same floor" in result.edge_seed_knowledge_facts
assert result.initial_time_iso == "2026-04-26T19:42:00"
@pytest.mark.asyncio
async def test_parse_kickoff_applies_activity_defaults_for_missing_fields():
minimal_payload = {
"container_name": "kitchen",
"container_type": "kitchen",
"container_properties": {},
"you_activity": {
"posture": "standing",
"action_verb": "boiling water",
"action_interruptible": True,
"action_required_attention": "low",
"action_expected_duration": "5m",
},
"bot_activity": {
"posture": "leaning on the counter",
"action_verb": "scrolling phone",
"action_interruptible": True,
"action_required_attention": "low",
"action_expected_duration": "10m",
},
"initial_time_iso": "2026-04-26T08:00:00",
"edge_seed_summary": "roommates",
"edge_seed_knowledge_facts": [],
}
mock = MockLLMClient(canned=[json.dumps(minimal_payload)])
result = await parse_kickoff(
mock,
model="m",
bot_name="BotA",
bot_persona="laid-back roommate",
initial_relationship_to_you="roommates of two years",
kickoff_prose="morning in the kitchen; you're making tea while BotA scrolls her phone",
you_name="You",
)
assert result.you_activity.attention == ""
assert result.you_activity.holding == []
assert result.bot_activity.attention == ""
assert result.bot_activity.holding == []
# mutating one default must not leak into the other (default_factory check)
result.you_activity.holding.append("kettle")
assert result.bot_activity.holding == []
@pytest.mark.asyncio
async def test_parse_kickoff_raises_when_classifier_fails_twice():
mock = MockLLMClient(canned=["nope", "still nope"])
with pytest.raises(RuntimeError):
await parse_kickoff(
mock,
model="m",
bot_name="BotA",
bot_persona="x",
initial_relationship_to_you="y",
kickoff_prose="z",
you_name="You",
)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient
CANNED_PARSE = {
"container_name": "office",
"container_type": "workplace",
"container_properties": {
"public": True,
"moving": False,
"audible_range": "normal",
},
"you_activity": {
"posture": "sitting",
"action_verb": "working late",
"action_interruptible": True,
"action_required_attention": "medium",
"action_expected_duration": "an hour",
"attention": "the screen",
"holding": [],
},
"bot_activity": {
"posture": "sitting",
"action_verb": "writing email",
"action_interruptible": True,
"action_required_attention": "medium",
"action_expected_duration": "a few minutes",
"attention": "her keyboard",
"holding": [],
},
"initial_time_iso": "2026-04-26T20:00:00+00:00",
"edge_seed_summary": "BotA is your coworker.",
"edge_seed_knowledge_facts": [
"coworker",
"they sometimes stay late together",
],
}
@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"))
# Import after env is set so dependency lookup uses MockLLMClient.
from chat.web.kickoff import get_llm_client
mock = MockLLMClient(canned=[json.dumps(CANNED_PARSE)])
app.dependency_overrides[get_llm_client] = lambda: mock
with TestClient(app) as c:
c.mock_llm = mock # type: ignore[attr-defined]
yield c
app.dependency_overrides.clear()
def _author_bot(db_path: Path, bot_id: str = "bot_a") -> None:
from chat.db.connection import open_db
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": "BotA",
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": ["shy"],
"backstory": "",
"initial_relationship_to_you": "coworker",
"kickoff_prose": "you stay late at the office; she's there too",
},
)
project(conn)
def test_get_kickoff_404_when_bot_missing(client):
response = client.get("/bots/no_such_bot/kickoff")
assert response.status_code == 404
def test_get_kickoff_renders_parsed_form(client, tmp_path):
_author_bot(tmp_path / "test.db", "bot_a")
response = client.get("/bots/bot_a/kickoff")
assert response.status_code == 200
body = response.text
assert "office" in body
assert "sitting" in body
assert "working late" in body
# Mock was consumed once.
assert len(client.mock_llm._canned) == 0
def test_post_kickoff_creates_chat_and_redirects(client, tmp_path):
_author_bot(tmp_path / "test.db", "bot_a")
form_data = {
"container_name": "office",
"container_type": "workplace",
"container_properties": json.dumps(CANNED_PARSE["container_properties"]),
"initial_time_iso": "2026-04-26T20:00:00+00:00",
"you_activity_posture": "sitting",
"you_activity_action_verb": "working late",
"you_activity_action_interruptible": "on",
"you_activity_action_required_attention": "medium",
"you_activity_action_expected_duration": "an hour",
"you_activity_attention": "the screen",
"you_activity_holding": "",
"bot_activity_posture": "sitting",
"bot_activity_action_verb": "writing email",
"bot_activity_action_interruptible": "on",
"bot_activity_action_required_attention": "medium",
"bot_activity_action_expected_duration": "a few minutes",
"bot_activity_attention": "her keyboard",
"bot_activity_holding": "",
"edge_seed_summary": "BotA is your coworker.",
"edge_seed_knowledge_facts": "coworker\nthey sometimes stay late together",
}
response = client.post(
"/bots/bot_a/kickoff",
data=form_data,
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/chats/chat_bot_a"
from chat.db.connection import open_db
from chat.state.world import (
active_scene,
find_container,
get_activity,
get_chat,
)
from chat.state.edges import get_edge
with open_db(tmp_path / "test.db") as conn:
chat = get_chat(conn, "chat_bot_a")
assert chat is not None
assert chat["host_bot_id"] == "bot_a"
assert chat["time"] == "2026-04-26T20:00:00+00:00"
container = find_container(conn, "chat_bot_a", "office")
assert container is not None
assert container["type"] == "workplace"
you_act = get_activity(conn, "you")
assert you_act is not None
assert you_act["posture"] == "sitting"
assert you_act["action"]["verb"] == "working late"
bot_act = get_activity(conn, "bot_a")
assert bot_act is not None
assert bot_act["posture"] == "sitting"
assert bot_act["action"]["verb"] == "writing email"
scene = active_scene(conn, "chat_bot_a")
assert scene is not None
assert scene["ended_at"] is None
assert "you" in scene["participants"]
assert "bot_a" in scene["participants"]
edge = get_edge(conn, "bot_a", "you")
assert edge is not None
knowledge = edge["knowledge"]
assert "coworker" in knowledge
assert "they sometimes stay late together" in knowledge
# The seed summary should appear somewhere in knowledge as a v1 compromise.
assert any("BotA is your coworker" in k for k in knowledge)
def test_post_kickoff_404_when_bot_missing(client):
response = client.post(
"/bots/no_such/kickoff",
data={
"container_name": "office",
"container_type": "workplace",
"container_properties": "{}",
"initial_time_iso": "2026-04-26T20:00:00+00:00",
"you_activity_posture": "",
"you_activity_action_verb": "",
"you_activity_action_interruptible": "on",
"you_activity_action_required_attention": "low",
"you_activity_action_expected_duration": "",
"you_activity_attention": "",
"you_activity_holding": "",
"bot_activity_posture": "",
"bot_activity_action_verb": "",
"bot_activity_action_interruptible": "on",
"bot_activity_action_required_attention": "low",
"bot_activity_action_expected_duration": "",
"bot_activity_attention": "",
"bot_activity_holding": "",
"edge_seed_summary": "",
"edge_seed_knowledge_facts": "",
},
follow_redirects=False,
)
assert response.status_code == 404
+112
View File
@@ -0,0 +1,112 @@
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_settings_renders_empty(client):
response = client.get("/settings")
assert response.status_code == 200
body = response.text.lower()
assert "<form" in body
assert "name" in body
assert "pronouns" in body
assert "persona" in body
def test_post_settings_appends_event_and_renders_saved(client, tmp_path):
response = client.post(
"/settings",
data={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
assert response.status_code == 200
body = response.text.lower()
assert "saved" in body
from chat.db.connection import open_db
from chat.state.entities import get_you
with open_db(tmp_path / "test.db") as conn:
you = get_you(conn)
assert you is not None
assert you["name"] == "Me"
assert you["pronouns"] == "they/them"
assert you["persona"] == "engineer"
cur = conn.execute(
"SELECT kind, payload_json FROM event_log WHERE kind = 'you_authored'"
)
rows = cur.fetchall()
assert len(rows) == 1
def test_get_settings_pre_populates_existing(client):
post_response = client.post(
"/settings",
data={
"name": "Joseph",
"pronouns": "he/him",
"persona": "writes code",
},
)
assert post_response.status_code == 200
response = client.get("/settings")
assert response.status_code == 200
body = response.text
assert "Joseph" in body
assert "he/him" in body
assert "writes code" in body
def test_post_settings_rejects_missing_name(client):
response = client.post(
"/settings",
data={"name": "", "pronouns": "they/them", "persona": "anything"},
)
assert response.status_code == 400
def test_post_settings_overwrites_existing(client, tmp_path):
first = client.post(
"/settings",
data={"name": "First", "pronouns": "she/her", "persona": "p1"},
)
assert first.status_code == 200
second = client.post(
"/settings",
data={"name": "Second", "pronouns": "they/them", "persona": "p2"},
)
assert second.status_code == 200
from chat.db.connection import open_db
from chat.state.entities import get_you
with open_db(tmp_path / "test.db") as conn:
you = get_you(conn)
assert you is not None
assert you["name"] == "Second"
assert you["pronouns"] == "they/them"
assert you["persona"] == "p2"
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'you_authored'"
)
assert cur.fetchone()[0] == 2