feat: kickoff parse-and-confirm flow with chat creation
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user