287 lines
10 KiB
Python
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)
|