Files
chat/chat/web/kickoff.py
T
2026-04-26 12:36:20 -04:00

287 lines
10 KiB
Python

"""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, "active_nav": "bots"}
)
@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)