diff --git a/chat/app.py b/chat/app.py index 82e49ad..4251290 100644 --- a/chat/app.py +++ b/chat/app.py @@ -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) diff --git a/chat/templates/kickoff_confirm.html b/chat/templates/kickoff_confirm.html new file mode 100644 index 0000000..d830f39 --- /dev/null +++ b/chat/templates/kickoff_confirm.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Confirm kickoff - chat{% endblock %} +{% block content %} +

Confirm kickoff

+

Review and edit the parsed opening scene for {{ values.bot_name }}, then confirm to start the chat.

+ +
+ +
+ Container + + + +
+ +
+ Initial in-fiction time + +
+ +
+ Your activity + + + + + + + +
+ +
+ {{ values.bot_name }}'s activity + + + + + + + +
+ +
+ Edge seed + + +
+ +
+ + Cancel +
+
+{% endblock %} diff --git a/chat/web/kickoff.py b/chat/web/kickoff.py new file mode 100644 index 0000000..4edfba9 --- /dev/null +++ b/chat/web/kickoff.py @@ -0,0 +1,284 @@ +"""Kickoff parse-and-confirm flow. + +After a bot is authored, the user lands on ``/bots//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) diff --git a/tests/test_kickoff_confirm.py b/tests/test_kickoff_confirm.py new file mode 100644 index 0000000..ef308cb --- /dev/null +++ b/tests/test_kickoff_confirm.py @@ -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