feat: kickoff parse-and-confirm flow with chat creation

This commit is contained in:
Joseph Doherty
2026-04-26 12:28:05 -04:00
parent e44e2bf93f
commit fbb16c86b3
4 changed files with 616 additions and 0 deletions
+2
View File
@@ -15,6 +15,7 @@ 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
@@ -33,6 +34,7 @@ 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)
+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 %}
+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)
+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