Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb16c86b3 | |||
| e44e2bf93f | |||
| 44ea627a8a | |||
| a5339fc1d2 |
+37
-2
@@ -1,6 +1,41 @@
|
|||||||
from fastapi import FastAPI
|
from __future__ import annotations
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
app = FastAPI(title="chat")
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from chat.config import load_settings
|
||||||
|
from chat.db.migrate import apply_migrations
|
||||||
|
|
||||||
|
# Trigger handler registration:
|
||||||
|
import chat.state.entities # noqa: F401
|
||||||
|
import chat.state.edges # noqa: F401
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = load_settings()
|
||||||
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
apply_migrations(settings.db_path)
|
||||||
|
app.state.settings = settings
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="chat", lifespan=lifespan)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Kickoff prose parser.
|
||||||
|
|
||||||
|
Service-layer function that converts a bot's authored kickoff prose into a
|
||||||
|
structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will
|
||||||
|
wire this into the UI flow).
|
||||||
|
|
||||||
|
The classifier prompt includes only the bot context that's load-bearing for
|
||||||
|
parsing the opening scene: name, persona, the authored
|
||||||
|
``initial_relationship_to_you`` blurb, the ``you`` entity name, and the
|
||||||
|
kickoff prose itself. Other identity fields (traits, backstory, ...) are
|
||||||
|
intentionally left out — they would be noise for this extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from chat.llm.classify import classify
|
||||||
|
from chat.llm.client import LLMClient
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityShape(BaseModel):
|
||||||
|
"""Per-entity activity at scene start.
|
||||||
|
|
||||||
|
Maps onto Requirements §6.5: ``current_action.{verb,interruptible,
|
||||||
|
required_attention,expected_duration}`` plus posture, attention, holding.
|
||||||
|
``action_required_attention`` is left as a free-form string ("low" /
|
||||||
|
"medium" / "high" expected) rather than a Literal so the classifier has
|
||||||
|
room to vary phrasing in v1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
posture: str
|
||||||
|
action_verb: str
|
||||||
|
action_interruptible: bool
|
||||||
|
action_required_attention: str # low | medium | high
|
||||||
|
action_expected_duration: str
|
||||||
|
attention: str = ""
|
||||||
|
holding: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KickoffParse(BaseModel):
|
||||||
|
"""Structured opening-scene state extracted from kickoff prose.
|
||||||
|
|
||||||
|
``container_properties`` is loose ``dict``: the classifier may emit
|
||||||
|
``moving`` / ``public`` / ``audible_range`` keys, but downstream
|
||||||
|
consumers (T13's confirm form) handle missing keys gracefully.
|
||||||
|
``initial_time_iso`` is stored as text — not validated as a datetime
|
||||||
|
here; ``chat_state.time`` stores it as a plain string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
container_name: str
|
||||||
|
container_type: str
|
||||||
|
container_properties: dict
|
||||||
|
you_activity: ActivityShape
|
||||||
|
bot_activity: ActivityShape
|
||||||
|
initial_time_iso: str
|
||||||
|
edge_seed_summary: str
|
||||||
|
edge_seed_knowledge_facts: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = (
|
||||||
|
"You are extracting structured scene state from a roleplay kickoff "
|
||||||
|
"scene description. The user provides bot context and a prose "
|
||||||
|
"description of the opening scene; you output JSON conforming to the "
|
||||||
|
"schema. Be concrete: pick a single container, single activity per "
|
||||||
|
"entity, and a sensible initial in-fiction time. Anything not stated "
|
||||||
|
"explicitly should be inferred reasonably from the prose."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_prompt(
|
||||||
|
*,
|
||||||
|
bot_name: str,
|
||||||
|
bot_persona: str,
|
||||||
|
initial_relationship_to_you: str,
|
||||||
|
kickoff_prose: str,
|
||||||
|
you_name: str,
|
||||||
|
) -> str:
|
||||||
|
return (
|
||||||
|
f"BOT NAME: {bot_name}\n"
|
||||||
|
f"BOT PERSONA: {bot_persona}\n"
|
||||||
|
f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n"
|
||||||
|
f"YOU NAME: {you_name}\n"
|
||||||
|
f"KICKOFF PROSE:\n{kickoff_prose}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_kickoff(
|
||||||
|
client: LLMClient,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
bot_name: str,
|
||||||
|
bot_persona: str,
|
||||||
|
initial_relationship_to_you: str,
|
||||||
|
kickoff_prose: str,
|
||||||
|
you_name: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> KickoffParse:
|
||||||
|
"""Parse authored kickoff prose into a structured ``KickoffParse``.
|
||||||
|
|
||||||
|
Internally calls :func:`chat.llm.classify.classify` with a labeled
|
||||||
|
user prompt. Raises ``RuntimeError`` if the classifier fails twice in
|
||||||
|
a row — no default is supplied at this layer, since the caller (T13's
|
||||||
|
confirm form) is responsible for showing an error and letting the
|
||||||
|
user edit.
|
||||||
|
"""
|
||||||
|
user_prompt = _build_user_prompt(
|
||||||
|
bot_name=bot_name,
|
||||||
|
bot_persona=bot_persona,
|
||||||
|
initial_relationship_to_you=initial_relationship_to_you,
|
||||||
|
kickoff_prose=kickoff_prose,
|
||||||
|
you_name=you_name,
|
||||||
|
)
|
||||||
|
return await classify(
|
||||||
|
client,
|
||||||
|
model=model,
|
||||||
|
system=_SYSTEM_PROMPT,
|
||||||
|
user=user_prompt,
|
||||||
|
schema=KickoffParse,
|
||||||
|
timeout_s=timeout_s,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
color: #1c1c1c;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 600; text-decoration: none; color: inherit; }
|
||||||
|
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
|
||||||
|
h1 { margin-top: 0; }
|
||||||
|
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.btn, button {
|
||||||
|
display: inline-block; padding: 8px 14px;
|
||||||
|
border: 1px solid #444; background: #1c1c1c; color: #fff;
|
||||||
|
border-radius: 4px; text-decoration: none; cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.bot-form label { display: block; margin-bottom: 14px; }
|
||||||
|
.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.bot-form input[type=text], .bot-form textarea {
|
||||||
|
width: 100%; padding: 6px 8px; font: inherit;
|
||||||
|
border: 1px solid #ccc; border-radius: 3px; background: #fff;
|
||||||
|
}
|
||||||
|
.bot-form small { display: block; color: #666; margin-top: 2px; }
|
||||||
|
.bot-list { list-style: none; padding: 0; }
|
||||||
|
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
.error {
|
||||||
|
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
|
||||||
|
color: #a00; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
padding: 8px 12px; border: 1px solid #2d7a3a; background: #eafaf0;
|
||||||
|
color: #1f5c2a; border-radius: 3px;
|
||||||
|
}
|
||||||
|
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}chat{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/bots">chat</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New bot - chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>New bot</h1>
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/bots/new" class="bot-form">
|
||||||
|
<label>
|
||||||
|
<span>id</span>
|
||||||
|
<input type="text" name="id" required value="{{ values.id|default('', true) }}">
|
||||||
|
<small>slug-like identifier (e.g. <code>bot_a</code>, <code>alice_office</code>)</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>name</span>
|
||||||
|
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>persona</span>
|
||||||
|
<textarea name="persona" rows="4" required>{{ values.persona|default('', true) }}</textarea>
|
||||||
|
<small>a short description, ~3-5 lines</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>voice samples</span>
|
||||||
|
<textarea name="voice_samples" rows="6">{{ values.voice_samples|default('', true) }}</textarea>
|
||||||
|
<small>1-3 samples, separated by a line containing only <code>---</code></small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>traits</span>
|
||||||
|
<textarea name="traits" rows="3">{{ values.traits|default('', true) }}</textarea>
|
||||||
|
<small>comma- or newline-separated; 3-15 typical</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>backstory</span>
|
||||||
|
<textarea name="backstory" rows="6">{{ values.backstory|default('', true) }}</textarea>
|
||||||
|
<small>100-500 words target</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>initial relationship to you</span>
|
||||||
|
<textarea name="initial_relationship_to_you" rows="3" required>{{ values.initial_relationship_to_you|default('', true) }}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>kickoff prose</span>
|
||||||
|
<textarea name="kickoff_prose" rows="4" required>{{ values.kickoff_prose|default('', true) }}</textarea>
|
||||||
|
<small>a short opening scene; parsed in the next step</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Save bot</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Bots - chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Bots</h1>
|
||||||
|
<a class="btn" href="/bots/new">+ New bot</a>
|
||||||
|
</header>
|
||||||
|
{% if bots %}
|
||||||
|
<ul class="bot-list">
|
||||||
|
{% for bot in bots %}
|
||||||
|
<li><a href="/bots/{{ bot.id }}">{{ bot.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No bots yet. <a href="/bots/new">Create your first bot.</a></p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings - chat{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
{% if saved %}
|
||||||
|
<p class="success">Settings saved.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/settings" class="bot-form">
|
||||||
|
<label>
|
||||||
|
<span>name</span>
|
||||||
|
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
|
||||||
|
<small>required</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>pronouns</span>
|
||||||
|
<input type="text" name="pronouns" value="{{ values.pronouns|default('', true) }}">
|
||||||
|
<small>optional (e.g. they/them)</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>persona</span>
|
||||||
|
<textarea name="persona" rows="3">{{ values.persona|default('', true) }}</textarea>
|
||||||
|
<small>optional but recommended; a short description of you</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Save settings</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
from chat.state.entities import list_bots
|
||||||
|
|
||||||
|
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose")
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn(request: Request):
|
||||||
|
settings = request.app.state.settings
|
||||||
|
db_path: Path = settings.db_path
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_voice_samples(text: str) -> list[str]:
|
||||||
|
if not text or not text.strip():
|
||||||
|
return []
|
||||||
|
# Split on a line containing only "---" (with optional surrounding whitespace).
|
||||||
|
parts: list[str] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip() == "---":
|
||||||
|
if buf:
|
||||||
|
parts.append("\n".join(buf).strip())
|
||||||
|
buf = []
|
||||||
|
continue
|
||||||
|
buf.append(line)
|
||||||
|
if buf:
|
||||||
|
parts.append("\n".join(buf).strip())
|
||||||
|
return [p for p in parts if p]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_traits(text: str) -> list[str]:
|
||||||
|
if not text or not text.strip():
|
||||||
|
return []
|
||||||
|
items: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
items.extend(p.strip() for p in line.split(","))
|
||||||
|
else:
|
||||||
|
items.append(line)
|
||||||
|
return [t for t in items if t]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bots", response_class=HTMLResponse)
|
||||||
|
async def bots_list(request: Request, conn=Depends(get_conn)):
|
||||||
|
bots = list_bots(conn)
|
||||||
|
return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bots/new", response_class=HTMLResponse)
|
||||||
|
async def bot_form(request: Request):
|
||||||
|
return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bots/new")
|
||||||
|
async def bot_create(
|
||||||
|
request: Request,
|
||||||
|
id: str = Form(""),
|
||||||
|
name: str = Form(""),
|
||||||
|
persona: str = Form(""),
|
||||||
|
voice_samples: str = Form(""),
|
||||||
|
traits: str = Form(""),
|
||||||
|
backstory: str = Form(""),
|
||||||
|
initial_relationship_to_you: str = Form(""),
|
||||||
|
kickoff_prose: str = Form(""),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
values = {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"persona": persona,
|
||||||
|
"voice_samples": voice_samples,
|
||||||
|
"traits": traits,
|
||||||
|
"backstory": backstory,
|
||||||
|
"initial_relationship_to_you": initial_relationship_to_you,
|
||||||
|
"kickoff_prose": kickoff_prose,
|
||||||
|
}
|
||||||
|
missing = [f for f in REQUIRED_FIELDS if not values[f].strip()]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": id.strip(),
|
||||||
|
"name": name.strip(),
|
||||||
|
"persona": persona.strip(),
|
||||||
|
"voice_samples": _split_voice_samples(voice_samples),
|
||||||
|
"traits": _split_traits(traits),
|
||||||
|
"backstory": backstory.strip(),
|
||||||
|
"initial_relationship_to_you": initial_relationship_to_you.strip(),
|
||||||
|
"kickoff_prose": kickoff_prose.strip(),
|
||||||
|
}
|
||||||
|
append_event(conn, kind="bot_authored", payload=payload)
|
||||||
|
project(conn)
|
||||||
|
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
|
||||||
@@ -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,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
from chat.state.entities import get_you
|
||||||
|
from chat.web.bots import get_conn
|
||||||
|
|
||||||
|
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_get(request: Request, conn=Depends(get_conn)):
|
||||||
|
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request, "settings.html", {"values": you, "saved": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_post(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(""),
|
||||||
|
pronouns: str = Form(""),
|
||||||
|
persona: str = Form(""),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
if not name.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": name.strip(),
|
||||||
|
"pronouns": pronouns.strip(),
|
||||||
|
"persona": persona.strip(),
|
||||||
|
}
|
||||||
|
append_event(conn, kind="you_authored", payload=payload)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
return TEMPLATES.TemplateResponse(
|
||||||
|
request, "settings.html", {"values": payload, "saved": True}
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"tiktoken>=0.7",
|
"tiktoken>=0.7",
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
"aiosqlite>=0.20",
|
"aiosqlite>=0.20",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@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"))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_new_bot_form_renders(client):
|
||||||
|
response = client.get("/bots/new")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text.lower()
|
||||||
|
assert "<form" in body
|
||||||
|
assert "name" in body
|
||||||
|
assert "persona" in body
|
||||||
|
assert "kickoff" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_new_bot_appends_event_and_redirects(client, tmp_path):
|
||||||
|
response = client.post(
|
||||||
|
"/bots/new",
|
||||||
|
data={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "thoughtful, observant",
|
||||||
|
"voice_samples": "first sample\n---\nsecond sample",
|
||||||
|
"traits": "shy, quick to anger",
|
||||||
|
"backstory": "grew up in a small town",
|
||||||
|
"initial_relationship_to_you": "coworker",
|
||||||
|
"kickoff_prose": "you stay late at the office",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/bots/bot_a/kickoff"
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_bot
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
bot = get_bot(conn, "bot_a")
|
||||||
|
assert bot is not None
|
||||||
|
assert bot["name"] == "BotA"
|
||||||
|
assert bot["voice_samples"] == ["first sample", "second sample"]
|
||||||
|
assert bot["traits"] == ["shy", "quick to anger"]
|
||||||
|
assert bot["backstory"] == "grew up in a small town"
|
||||||
|
assert bot["initial_relationship_to_you"] == "coworker"
|
||||||
|
assert bot["kickoff_prose"] == "you stay late at the office"
|
||||||
|
|
||||||
|
# Confirm event was actually appended (state goes through event log).
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT kind, payload_json FROM event_log WHERE kind = 'bot_authored'"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_new_bot_rejects_missing_required(client):
|
||||||
|
response = client.post(
|
||||||
|
"/bots/new",
|
||||||
|
data={"id": "bot_b"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_bots_list_renders(client):
|
||||||
|
response = client.get("/bots")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_new_bot_empty_traits_parses_to_empty_list(client, tmp_path):
|
||||||
|
response = client.post(
|
||||||
|
"/bots/new",
|
||||||
|
data={
|
||||||
|
"id": "bot_c",
|
||||||
|
"name": "BotC",
|
||||||
|
"persona": "stoic",
|
||||||
|
"voice_samples": "",
|
||||||
|
"traits": "",
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "stranger",
|
||||||
|
"kickoff_prose": "the rain begins",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_bot
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
bot = get_bot(conn, "bot_c")
|
||||||
|
assert bot is not None
|
||||||
|
assert bot["voice_samples"] == []
|
||||||
|
assert bot["traits"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_new_bot_traits_split_by_newlines(client, tmp_path):
|
||||||
|
response = client.post(
|
||||||
|
"/bots/new",
|
||||||
|
data={
|
||||||
|
"id": "bot_d",
|
||||||
|
"name": "BotD",
|
||||||
|
"persona": "curious",
|
||||||
|
"voice_samples": "",
|
||||||
|
"traits": "calm\nthoughtful\nguarded",
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "neighbor",
|
||||||
|
"kickoff_prose": "morning light",
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_bot
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
bot = get_bot(conn, "bot_d")
|
||||||
|
assert bot is not None
|
||||||
|
assert bot["traits"] == ["calm", "thoughtful", "guarded"]
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chat.llm.mock import MockLLMClient
|
||||||
|
from chat.services.kickoff import (
|
||||||
|
ActivityShape,
|
||||||
|
KickoffParse,
|
||||||
|
parse_kickoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_kickoff_json() -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"container_name": "office bullpen, late evening",
|
||||||
|
"container_type": "office",
|
||||||
|
"container_properties": {
|
||||||
|
"moving": False,
|
||||||
|
"public": False,
|
||||||
|
"audible_range": "room",
|
||||||
|
},
|
||||||
|
"you_activity": {
|
||||||
|
"posture": "sitting at your desk",
|
||||||
|
"action_verb": "finishing emails",
|
||||||
|
"action_interruptible": True,
|
||||||
|
"action_required_attention": "low",
|
||||||
|
"action_expected_duration": "15m",
|
||||||
|
"attention": "the screen",
|
||||||
|
"holding": ["coffee mug"],
|
||||||
|
},
|
||||||
|
"bot_activity": {
|
||||||
|
"posture": "sitting at her desk",
|
||||||
|
"action_verb": "pretending to work",
|
||||||
|
"action_interruptible": True,
|
||||||
|
"action_required_attention": "low",
|
||||||
|
"action_expected_duration": "indefinite",
|
||||||
|
"attention": "you, in glances",
|
||||||
|
"holding": [],
|
||||||
|
},
|
||||||
|
"initial_time_iso": "2026-04-26T19:42:00",
|
||||||
|
"edge_seed_summary": "coworkers; aware of each other; no shared history beyond the office",
|
||||||
|
"edge_seed_knowledge_facts": [
|
||||||
|
"they work on the same floor",
|
||||||
|
"it is unusual to be the only two left",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_kickoff_happy_path_populates_fields():
|
||||||
|
mock = MockLLMClient(canned=[_full_kickoff_json()])
|
||||||
|
result = await parse_kickoff(
|
||||||
|
mock,
|
||||||
|
model="m",
|
||||||
|
bot_name="BotA",
|
||||||
|
bot_persona="reserved colleague who quietly notices things",
|
||||||
|
initial_relationship_to_you="coworker, slight crush, never voiced",
|
||||||
|
kickoff_prose=(
|
||||||
|
"you stay late at the office; only you and BotA are there; "
|
||||||
|
"she's at her desk pretending to work"
|
||||||
|
),
|
||||||
|
you_name="You",
|
||||||
|
)
|
||||||
|
assert isinstance(result, KickoffParse)
|
||||||
|
assert result.container_name == "office bullpen, late evening"
|
||||||
|
assert result.container_type == "office"
|
||||||
|
assert isinstance(result.you_activity, ActivityShape)
|
||||||
|
assert result.you_activity.posture == "sitting at your desk"
|
||||||
|
assert result.bot_activity.action_verb == "pretending to work"
|
||||||
|
assert result.edge_seed_summary.startswith("coworkers")
|
||||||
|
assert "they work on the same floor" in result.edge_seed_knowledge_facts
|
||||||
|
assert result.initial_time_iso == "2026-04-26T19:42:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_kickoff_applies_activity_defaults_for_missing_fields():
|
||||||
|
minimal_payload = {
|
||||||
|
"container_name": "kitchen",
|
||||||
|
"container_type": "kitchen",
|
||||||
|
"container_properties": {},
|
||||||
|
"you_activity": {
|
||||||
|
"posture": "standing",
|
||||||
|
"action_verb": "boiling water",
|
||||||
|
"action_interruptible": True,
|
||||||
|
"action_required_attention": "low",
|
||||||
|
"action_expected_duration": "5m",
|
||||||
|
},
|
||||||
|
"bot_activity": {
|
||||||
|
"posture": "leaning on the counter",
|
||||||
|
"action_verb": "scrolling phone",
|
||||||
|
"action_interruptible": True,
|
||||||
|
"action_required_attention": "low",
|
||||||
|
"action_expected_duration": "10m",
|
||||||
|
},
|
||||||
|
"initial_time_iso": "2026-04-26T08:00:00",
|
||||||
|
"edge_seed_summary": "roommates",
|
||||||
|
"edge_seed_knowledge_facts": [],
|
||||||
|
}
|
||||||
|
mock = MockLLMClient(canned=[json.dumps(minimal_payload)])
|
||||||
|
result = await parse_kickoff(
|
||||||
|
mock,
|
||||||
|
model="m",
|
||||||
|
bot_name="BotA",
|
||||||
|
bot_persona="laid-back roommate",
|
||||||
|
initial_relationship_to_you="roommates of two years",
|
||||||
|
kickoff_prose="morning in the kitchen; you're making tea while BotA scrolls her phone",
|
||||||
|
you_name="You",
|
||||||
|
)
|
||||||
|
assert result.you_activity.attention == ""
|
||||||
|
assert result.you_activity.holding == []
|
||||||
|
assert result.bot_activity.attention == ""
|
||||||
|
assert result.bot_activity.holding == []
|
||||||
|
# mutating one default must not leak into the other (default_factory check)
|
||||||
|
result.you_activity.holding.append("kettle")
|
||||||
|
assert result.bot_activity.holding == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_kickoff_raises_when_classifier_fails_twice():
|
||||||
|
mock = MockLLMClient(canned=["nope", "still nope"])
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await parse_kickoff(
|
||||||
|
mock,
|
||||||
|
model="m",
|
||||||
|
bot_name="BotA",
|
||||||
|
bot_persona="x",
|
||||||
|
initial_relationship_to_you="y",
|
||||||
|
kickoff_prose="z",
|
||||||
|
you_name="You",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@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"))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_settings_renders_empty(client):
|
||||||
|
response = client.get("/settings")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text.lower()
|
||||||
|
assert "<form" in body
|
||||||
|
assert "name" in body
|
||||||
|
assert "pronouns" in body
|
||||||
|
assert "persona" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_settings_appends_event_and_renders_saved(client, tmp_path):
|
||||||
|
response = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={
|
||||||
|
"name": "Me",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"persona": "engineer",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text.lower()
|
||||||
|
assert "saved" in body
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_you
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
you = get_you(conn)
|
||||||
|
assert you is not None
|
||||||
|
assert you["name"] == "Me"
|
||||||
|
assert you["pronouns"] == "they/them"
|
||||||
|
assert you["persona"] == "engineer"
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT kind, payload_json FROM event_log WHERE kind = 'you_authored'"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_settings_pre_populates_existing(client):
|
||||||
|
post_response = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={
|
||||||
|
"name": "Joseph",
|
||||||
|
"pronouns": "he/him",
|
||||||
|
"persona": "writes code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert post_response.status_code == 200
|
||||||
|
|
||||||
|
response = client.get("/settings")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
assert "Joseph" in body
|
||||||
|
assert "he/him" in body
|
||||||
|
assert "writes code" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_settings_rejects_missing_name(client):
|
||||||
|
response = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={"name": "", "pronouns": "they/them", "persona": "anything"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_settings_overwrites_existing(client, tmp_path):
|
||||||
|
first = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={"name": "First", "pronouns": "she/her", "persona": "p1"},
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
second = client.post(
|
||||||
|
"/settings",
|
||||||
|
data={"name": "Second", "pronouns": "they/them", "persona": "p2"},
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.state.entities import get_you
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
you = get_you(conn)
|
||||||
|
assert you is not None
|
||||||
|
assert you["name"] == "Second"
|
||||||
|
assert you["pronouns"] == "they/them"
|
||||||
|
assert you["persona"] == "p2"
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log WHERE kind = 'you_authored'"
|
||||||
|
)
|
||||||
|
assert cur.fetchone()[0] == 2
|
||||||
Reference in New Issue
Block a user