6 Commits

Author SHA1 Message Date
Joseph Doherty 9b45710cb1 feat: narrative streaming via SSE with assistant_turn event 2026-04-26 13:09:31 -04:00
Joseph Doherty 73d8b0c092 feat: prompt assembly with must/should/nice trim tiers 2026-04-26 13:00:00 -04:00
Joseph Doherty a0f5e818ec feat: turn input parser via classifier 2026-04-26 12:53:34 -04:00
Joseph Doherty 656c2558cb feat: per-chat SSE channel and pub/sub 2026-04-26 12:49:41 -04:00
Joseph Doherty e79f4d8d22 feat: chat shell page rendering 2026-04-26 12:39:15 -04:00
Joseph Doherty 0c08745194 feat: top-level nav and chat list view 2026-04-26 12:36:20 -04:00
26 changed files with 2153 additions and 19 deletions
+8
View File
@@ -15,8 +15,12 @@ import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
from chat.web.chat import router as chat_router
from chat.web.kickoff import router as kickoff_router
from chat.web.nav import router as nav_router
from chat.web.settings import router as settings_router
from chat.web.sse import router as sse_router
from chat.web.turns import router as turns_router
@asynccontextmanager
@@ -36,6 +40,10 @@ 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.include_router(nav_router)
app.include_router(chat_router)
app.include_router(sse_router)
app.include_router(turns_router)
@app.get("/health")
+554
View File
@@ -0,0 +1,554 @@
"""Narrative-prompt assembly with must/should/nice trim tiers.
Implements Task 18 (Phase 1D). See Requirements §3.2 (token budgets and
trim tiers) and §6.3 (speaker prompt assembly order). The function
:func:`assemble_narrative_prompt` returns a list of
:class:`chat.llm.client.Message` objects ready to feed to
``LLMClient.generate``.
Trim policy when the assembled prompt exceeds the soft target:
- **MUST-include** (never trimmed): system / speaker identity, the
speaker→addressee edge, the activity snapshot for all present
entities, the current scene description, and the last 4 turns of
dialogue.
- **SHOULD-include** (trim when over budget): other edges of the
speaker. (Group nodes, active threads, and active events / props are
Phase 3 — skipped here.)
- **NICE-include** (trim first): retrieved memories beyond top-2,
dialogue turns beyond the last 4 (replaced with a one-line elision
placeholder), per-POV summary of the previous scene.
Token counting uses ``tiktoken.get_encoding("cl100k_base")`` per the
requirements. Mistral / Llama tokenizers diverge ~5%; we accept the
drift.
The function is intentionally deterministic (no LLM call) so it is
testable with synthetic state and so T29's regenerate flow can rebuild
prompts without re-running classifiers.
"""
from __future__ import annotations
from sqlite3 import Connection
import tiktoken
from chat.llm.client import Message
from chat.state.edges import get_edge, list_edges_for
from chat.state.entities import get_bot, get_you
from chat.state.memory import search_memories
from chat.state.world import (
active_scene,
get_activity,
get_chat,
get_container,
get_scene,
)
# Cache the encoder once at import-time. tiktoken's encoder load is
# non-trivial (~tens of ms) and the encoding is process-wide stable.
_ENCODER = tiktoken.get_encoding("cl100k_base")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _count_tokens(text: str, encoding=_ENCODER) -> int:
"""Return the cl100k_base token count for ``text`` (0 for falsy)."""
if not text:
return 0
return len(encoding.encode(text))
def _build_speaker_identity(bot: dict) -> str:
"""Render the bot identity block. Skips empty optional fields."""
lines = [f"You are {bot['name']}."]
if bot.get("persona"):
lines.append("")
lines.append("PERSONA:")
lines.append(bot["persona"])
voice_samples = bot.get("voice_samples") or []
if voice_samples:
lines.append("")
lines.append("VOICE REFERENCE:")
lines.append("\n---\n".join(voice_samples))
traits = bot.get("traits") or []
if traits:
lines.append("")
lines.append(f"TRAITS: {', '.join(traits)}")
if bot.get("backstory"):
lines.append("")
lines.append("BACKSTORY:")
lines.append(bot["backstory"])
return "\n".join(lines)
def _build_edge_block(edge: dict | None, addressee_name: str) -> str | None:
"""Render the speaker → addressee edge. Returns None when no edge exists."""
if edge is None:
return None
lines = [f"YOUR EDGE TO {addressee_name}:"]
lines.append(f"- Affinity: {edge.get('affinity', 50)}/100")
lines.append(f"- Trust: {edge.get('trust', 50)}/100")
summary = edge.get("summary") or ""
if summary:
lines.append(f"- Summary: {summary}")
knowledge = edge.get("knowledge") or []
if knowledge:
lines.append(f"- What you know about {addressee_name}:")
for fact in knowledge:
lines.append(f" * {fact}")
return "\n".join(lines)
def _build_activity_block(activities: list[dict]) -> str | None:
"""Render the activity snapshot for all present entities."""
rendered: list[str] = []
for a in activities:
if a is None:
continue
label = a.get("_display_name") or a.get("entity_id", "?")
parts: list[str] = []
posture = a.get("posture") or ""
if posture:
parts.append(posture)
action = a.get("action") or {}
verb = action.get("verb") if isinstance(action, dict) else None
if verb:
parts.append(verb)
attention = a.get("attention") or ""
if attention:
parts.append(f"attention: {attention}")
holding = a.get("holding") or []
if holding:
parts.append(f"holding: {', '.join(holding)}")
if parts:
rendered.append(f"- {label}: " + ", ".join(parts))
else:
rendered.append(f"- {label}: (no activity recorded)")
if not rendered:
return None
return "ACTIVITIES:\n" + "\n".join(rendered)
def _build_scene_block(chat: dict, container: dict | None, scene: dict | None) -> str | None:
"""Render the current-scene block. Always present when chat exists."""
lines = ["CURRENT SCENE:"]
if container is not None:
lines.append(f"- Container: {container['name']} ({container['type']})")
chat_time = chat.get("time") if chat else None
if chat_time:
lines.append(f"- Time: {chat_time}")
if scene is not None and scene.get("started_at"):
lines.append(f"- Active scene started: {scene['started_at']}")
if len(lines) == 1:
return None
return "\n".join(lines)
def _format_dialogue_turn(turn: dict) -> str:
speaker = turn.get("speaker") or "?"
text = turn.get("text") or ""
return f"{speaker}: {text}"
def _build_dialogue_block(
recent: list[dict],
earlier_summary: str | None,
) -> str | None:
"""Render the recent-dialogue block. The ``recent`` list is the
*kept* tail of the dialogue (already trimmed to the last-N turns).
``earlier_summary``, when non-None, is rendered as the first line as
``earlier: <text>`` to flag elided context.
"""
if not recent and not earlier_summary:
return None
lines = ["RECENT DIALOGUE:"]
if earlier_summary:
lines.append(f"earlier: {earlier_summary}")
for turn in recent:
lines.append(_format_dialogue_turn(turn))
return "\n".join(lines)
def _build_memories_block(memory_summaries: list[str]) -> str | None:
if not memory_summaries:
return None
lines = ["RELEVANT MEMORIES:"]
for m in memory_summaries:
lines.append(f"- {m}")
return "\n".join(lines)
def _build_other_edges_block(edges: list[dict]) -> str | None:
"""Render edges to entities other than the addressee."""
if not edges:
return None
lines = ["OTHER EDGES:"]
for e in edges:
target = e.get("_display_name") or e.get("target_id", "?")
affinity = e.get("affinity", 50)
trust = e.get("trust", 50)
lines.append(f"- {target}: affinity {affinity}/100, trust {trust}/100")
summary = e.get("summary") or ""
if summary:
lines.append(f" summary: {summary}")
return "\n".join(lines)
def _build_previous_scene_block(pov_summary: str | None) -> str | None:
if not pov_summary:
return None
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
return (
f"Continue the scene as {speaker_name}, in their voice, responding "
"naturally. Use *asterisks* for actions and quotes for dialogue. "
f"Stay in character. Do not narrate {addressee_name}'s actions or "
"thoughts."
)
def _join_blocks(blocks: list[str | None]) -> str:
"""Join non-empty blocks with double newlines."""
return "\n\n".join(b for b in blocks if b)
def _earlier_summary_placeholder(elided_count: int) -> str:
"""Phase 1 placeholder. Real summarization is a downstream concern."""
plural = "turn" if elided_count == 1 else "turns"
return f"{elided_count} earlier {plural} elided for brevity"
def _resolve_previous_scene_summary(
conn: Connection, chat_id: str, speaker_bot_id: str
) -> str | None:
"""Return ``pov_summary`` of the most recent ended scene, owned by
the speaker. None if no closed scene exists or no matching memory.
"""
row = conn.execute(
"SELECT id FROM scenes WHERE chat_id = ? AND ended_at IS NOT NULL "
"ORDER BY ended_at DESC LIMIT 1",
(chat_id,),
).fetchone()
if not row:
return None
scene_id = row[0]
mem = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = ? AND owner_id = ? "
"ORDER BY id DESC LIMIT 1",
(scene_id, speaker_bot_id),
).fetchone()
if not mem:
return None
return mem[0]
def _resolve_addressee(
conn: Connection, addressee: str, you: dict | None
) -> tuple[str, str]:
"""Return ``(addressee_id, addressee_display_name)``.
The function is permissive: ``addressee="you"`` resolves to the
you-entity (display name is its authored name, falling back to
"you" if no entity exists yet). Other ids resolve as bot ids.
"""
if addressee == "you":
name = (you or {}).get("name") or "you"
return "you", name
bot = get_bot(conn, addressee)
if bot is not None:
return addressee, bot["name"]
return addressee, addressee
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def assemble_narrative_prompt(
conn: Connection,
*,
chat_id: str,
speaker_bot_id: str,
addressee: str = "you",
user_turn_prose: str | None = None,
recent_dialogue: list[dict] | None = None,
retrieved_memory_summaries: list[str] | None = None,
budget_soft: int = 6000,
budget_hard: int = 8000,
encoding_name: str = "cl100k_base",
) -> list[Message]:
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
Returns a list of :class:`Message` objects: one ``system`` message
carrying the assembled context, optionally followed by a single
``user`` message containing ``user_turn_prose`` (when provided).
Trimming proceeds in tiers (NICE → SHOULD) once the total token
count exceeds ``budget_soft``; the function refuses to exceed
``budget_hard``. If the MUST-include block alone is already over
``budget_hard``, :class:`ValueError` is raised — the caller should
surface the failure rather than ship a malformed prompt.
"""
encoding = (
_ENCODER if encoding_name == "cl100k_base"
else tiktoken.get_encoding(encoding_name)
)
bot = get_bot(conn, speaker_bot_id)
if bot is None:
raise ValueError(f"speaker_bot_id {speaker_bot_id!r} not found")
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError(f"chat_id {chat_id!r} not found")
you = get_you(conn)
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
# ---- Build all components as text strings ------------------------------
speaker_identity = _build_speaker_identity(bot)
edge_to_addressee = _build_edge_block(
get_edge(conn, speaker_bot_id, addressee_id),
addressee_name,
)
# Activity for present entities. Phase 1: you + speaker bot. (When a
# guest is added in Phase 1+, callers that know about it can pass
# extra activities via a future hook; for now we keep it strict.)
activities: list[dict] = []
you_act = get_activity(conn, "you")
if you_act is not None:
you_act = dict(you_act)
you_act["_display_name"] = (you or {}).get("name") or "you"
activities.append(you_act)
bot_act = get_activity(conn, speaker_bot_id)
if bot_act is not None:
bot_act = dict(bot_act)
bot_act["_display_name"] = bot["name"]
activities.append(bot_act)
activity_block = _build_activity_block(activities)
container = None
if chat.get("active_scene_id"):
scene = get_scene(conn, chat["active_scene_id"])
if scene and scene.get("container_id"):
container = get_container(conn, scene["container_id"])
else:
scene = active_scene(conn, chat_id)
if container is None and scene and scene.get("container_id"):
container = get_container(conn, scene["container_id"])
scene_block = _build_scene_block(chat, container, scene)
# Other edges: speaker → non-addressee.
all_outgoing = list_edges_for(conn, speaker_bot_id)
other_edges_raw = [e for e in all_outgoing if e.get("target_id") != addressee_id]
for e in other_edges_raw:
tid = e.get("target_id")
if tid == "you":
e["_display_name"] = (you or {}).get("name") or "you"
else:
tb = get_bot(conn, tid) if tid else None
e["_display_name"] = tb["name"] if tb else (tid or "?")
other_edges_block = _build_other_edges_block(other_edges_raw)
# Memories: caller override wins; otherwise FTS5 search keyed on the
# scene's container/posture as a coarse query proxy.
if retrieved_memory_summaries is not None:
memory_summaries = list(retrieved_memory_summaries)
else:
query = (container or {}).get("name") or chat.get("narrative_anchor") or ""
memory_summaries = []
if query:
try:
hits = search_memories(conn, speaker_bot_id, "host", query, k=4)
memory_summaries = [h["pov_summary"] for h in hits]
except Exception:
memory_summaries = []
# Dialogue: caller override only (no event_log read in Phase 1).
dialogue_full = list(recent_dialogue or [])
previous_scene_summary = _resolve_previous_scene_summary(
conn, chat_id, speaker_bot_id
)
closing = _closing_instruction(bot["name"], addressee_name)
# ---- Build the MUST core ----------------------------------------------
last4 = dialogue_full[-4:] if dialogue_full else []
must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None)
must_blocks: list[str | None] = [
speaker_identity,
edge_to_addressee,
scene_block,
activity_block,
must_dialogue_block,
closing,
]
must_text = _join_blocks(must_blocks)
must_tokens = _count_tokens(must_text, encoding)
if must_tokens > budget_hard:
raise ValueError(
f"MUST-include block ({must_tokens} tokens) exceeds budget_hard "
f"({budget_hard}). Cannot assemble prompt."
)
# ---- Stage SHOULD additions, then NICE additions -----------------------
# We carry a running "components" list and rebuild the body as we go
# so token accounting reflects join-overhead. Order in the final
# prompt follows §6.3: identity → edge → other edges → scene →
# activities → previous scene summary → memories → dialogue → close.
def assemble(
*,
include_other_edges: bool,
include_previous_scene: bool,
include_memories_top_k: int,
dialogue_keep: int,
) -> tuple[str, int, list[dict]]:
# dialogue: keep the last `dialogue_keep` turns verbatim; older
# turns become an "earlier:" placeholder line.
kept_dialogue = (
dialogue_full[-dialogue_keep:] if dialogue_keep > 0 else []
)
elided = max(0, len(dialogue_full) - len(kept_dialogue))
earlier_summary = (
_earlier_summary_placeholder(elided) if elided > 0 else None
)
dialogue_block = _build_dialogue_block(kept_dialogue, earlier_summary)
memories_subset = memory_summaries[:include_memories_top_k]
memories_block = _build_memories_block(memories_subset)
prev_block = (
_build_previous_scene_block(previous_scene_summary)
if include_previous_scene else None
)
body = _join_blocks([
speaker_identity,
edge_to_addressee,
other_edges_block if include_other_edges else None,
scene_block,
activity_block,
prev_block,
memories_block,
dialogue_block,
closing,
])
return body, _count_tokens(body, encoding), kept_dialogue
# Start with the MUST baseline: last 4 turns of dialogue, no
# SHOULD/NICE extras.
baseline_keep = min(4, len(dialogue_full))
# Try the most generous configuration first; trim greedily.
nice_dialogue_keep = len(dialogue_full) # all turns, no elision
nice_memories_k = min(4, len(memory_summaries))
include_prev = previous_scene_summary is not None
include_other = other_edges_block is not None
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=include_prev,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
# If under soft, we're done.
if total <= budget_soft:
return _emit(body, user_turn_prose)
# Drop NICE in order: previous scene → memories beyond top-2 →
# older dialogue turns (collapse to 4).
if include_prev:
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
include_prev = False
if total <= budget_soft:
return _emit(body, user_turn_prose)
if nice_memories_k > 2:
nice_memories_k = 2
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
if nice_dialogue_keep > baseline_keep:
nice_dialogue_keep = baseline_keep
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
# Drop more NICE until we're under hard: memories all the way to 0.
while nice_memories_k > 0 and total > budget_hard:
nice_memories_k = max(0, nice_memories_k - 1)
body, total, _ = assemble(
include_other_edges=include_other,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
# Drop SHOULD: other edges.
if include_other and total > budget_hard:
include_other = False
body, total, _ = assemble(
include_other_edges=False,
include_previous_scene=False,
include_memories_top_k=nice_memories_k,
dialogue_keep=nice_dialogue_keep,
)
if total > budget_hard:
# We've stripped everything optional and we still overflow.
# MUST alone fits (we checked at the top), so this means our
# last-4 dialogue + must blocks together exceed hard. Fall back
# to the bare MUST core.
body = must_text
total = must_tokens
if total > budget_hard:
raise ValueError(
f"Prompt cannot fit budget_hard={budget_hard}; MUST core "
f"is {total} tokens"
)
return _emit(body, user_turn_prose)
def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]:
msgs: list[Message] = [Message(role="system", content=system_body)]
if user_turn_prose is not None:
msgs.append(Message(role="user", content=user_turn_prose))
return msgs
__all__ = ["assemble_narrative_prompt"]
+94
View File
@@ -0,0 +1,94 @@
"""Turn input parser.
Service-layer function that splits a user's authored turn into typed
segments — ``dialogue``, ``action``, or ``ooc`` (out-of-character).
Per Requirements §6.1 a turn is mixed prose with three conventions:
- ``*action*`` (single asterisks around prose) → action segment.
- Quoted text, or bare prose between the conventions → dialogue.
- ``((double parens))`` → OOC, the author talking to the system rather
than the bot. Downstream (T19) strips OOC from the prompt sent to the
bot but keeps it in the transcript display.
A regex-based splitter would brittle on edge cases (unclosed asterisks,
nested quotes, mixed punctuation), so v1 delegates the segmentation to
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
here: the classifier figures OOC out from ``((`` ``))`` regardless of
config-time choice; marker-based stripping is a downstream concern.
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class TurnSegment(BaseModel):
"""One classified piece of a turn.
``kind`` is kept as a plain ``str`` (not a ``Literal``) so an
unexpected classifier output doesn't crash parsing — callers that
care about specific values can check defensively.
"""
kind: str # "dialogue" | "action" | "ooc"
text: str
class ParsedTurn(BaseModel):
"""A turn split into ordered, typed segments."""
segments: list[TurnSegment]
_SYSTEM_PROMPT = (
"You are splitting a roleplay turn into typed segments. The input "
"is mixed prose with three conventions:\n"
"- *text in single asterisks* is an ACTION segment.\n"
"- \"quoted text\" or bare prose between conventions is a DIALOGUE segment.\n"
"- ((text in double parens)) is an OOC (out-of-character) segment — "
"the author talking to the system, not the in-fiction bot.\n\n"
"Output a JSON object with shape "
'{"segments": [{"kind": "...", "text": "..."}, ...]} '
"where each ``kind`` is exactly one of: dialogue, action, ooc. "
"Preserve the original substring text as ``text``: do not rewrite, "
"translate, or normalize punctuation — strip only the marker "
"characters (asterisks, surrounding quotes, double parens) so "
"``text`` is the inner content. Emit segments in the order they "
"appear in the input."
)
async def parse_turn(
client: LLMClient,
*,
model: str,
prose: str,
timeout_s: float = 10.0,
) -> ParsedTurn:
"""Parse a user turn into typed segments.
Calls :func:`chat.llm.classify.classify` under the hood. Empty or
whitespace-only prose short-circuits to an empty ``ParsedTurn``
without an LLM call (the classifier would error on empty input
anyway, and the result is unambiguous).
Raises ``RuntimeError`` if the classifier fails twice — no default
is supplied, since the caller (T19's turn flow) is responsible for
surfacing the error to the user.
"""
if not prose.strip():
return ParsedTurn(segments=[])
user_prompt = f"INPUT:\n{prose}"
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=ParsedTurn,
timeout_s=timeout_s,
)
+44 -4
View File
@@ -4,11 +4,33 @@ body {
margin: 0;
color: #1c1c1c;
background: #fafafa;
display: flex;
min-height: 100vh;
}
.topbar {
padding: 12px 24px;
border-bottom: 1px solid #e5e5e5;
background: #fff;
.rail {
width: 200px;
background: #1c1c1c;
color: #fff;
padding: 16px;
flex-shrink: 0;
}
.rail a { color: #fff; text-decoration: none; }
.rail-brand {
font-weight: 600;
display: block;
padding-bottom: 16px;
border-bottom: 1px solid #333;
margin-bottom: 16px;
}
.rail ul { list-style: none; padding: 0; margin: 0; }
.rail li { margin: 4px 0; }
.rail li a { display: block; padding: 6px 8px; border-radius: 3px; }
.rail li a.active { background: #333; }
.content {
flex: 1;
padding: 24px;
background: #fafafa;
overflow: auto;
}
.brand { font-weight: 600; text-decoration: none; color: inherit; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
@@ -29,6 +51,13 @@ h1 { margin-top: 0; }
.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; }
.chat-list { list-style: none; padding: 0; margin: 0; }
.chat-row { border-bottom: 1px solid #eee; }
.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; }
.chat-row a:hover { background: #f0f0f0; }
.chat-row-name { font-weight: 600; }
.chat-row-snippet { font-size: 14px; }
.chat-row-meta { font-size: 12px; }
.muted { color: #666; }
.error {
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
@@ -39,3 +68,14 @@ h1 { margin-top: 0; }
color: #1f5c2a; border-radius: 3px;
}
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.chat-shell { display: flex; flex-direction: column; height: 100%; max-width: 760px; margin: 0 auto; }
.chat-header { display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 16px; }
.chat-header h1 { margin: 0; flex: 1; }
.chat-meta { font-size: 13px; }
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
.turn { margin: 12px 0; }
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
.drawer[hidden] { display: none; }
+1 -6
View File
@@ -8,11 +8,6 @@
<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>
{% block body %}{% endblock %}
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}New bot - chat{% endblock %}
{% block content %}
<h1>New bot</h1>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Bots - chat{% endblock %}
{% block content %}
<header class="page-header">
+46
View File
@@ -0,0 +1,46 @@
{% extends "layout.html" %}
{% block title %}{{ host_bot.name }} - chat{% endblock %}
{% block content %}
<div class="chat-shell" data-chat-id="{{ chat.id }}"
hx-ext="sse"
sse-connect="/chats/{{ chat.id }}/events">
<header class="chat-header">
<h1>{{ host_bot.name }}</h1>
<div class="chat-meta muted">{{ chat.time }}</div>
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
</header>
<section class="timeline" id="timeline"
sse-swap="turn_html"
hx-swap="beforeend">
{% if not turns %}
<p class="muted">No turns yet. Start typing below.</p>
{% else %}
{% for turn in turns %}
<div class="turn turn-{{ turn.role }}">
<strong>{{ turn.speaker }}</strong>
<p>{{ turn.text }}</p>
</div>
{% endfor %}
{% endif %}
</section>
<form class="turn-input" method="post" action="/chats/{{ chat.id }}/turns">
<textarea name="prose" rows="3" placeholder="What do you say or do?"></textarea>
<button type="submit">Send</button>
</form>
<aside class="drawer" id="drawer" hidden>
<p class="muted">Drawer (read-only). T24 fills this in.</p>
</aside>
</div>
<script>
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
const drawer = document.getElementById('drawer');
const isHidden = drawer.hasAttribute('hidden');
if (isHidden) drawer.removeAttribute('hidden');
else drawer.setAttribute('hidden', '');
e.target.setAttribute('aria-expanded', String(isHidden));
});
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "layout.html" %}
{% block title %}Chats - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Chats</h1>
<a class="btn" href="/bots/new">+ New bot</a>
</header>
{% if chats %}
<ul class="chat-list">
{% for chat in chats %}
<li class="chat-row">
<a href="/chats/{{ chat.id }}">
<div class="chat-row-name">{{ chat.host_bot_name }}</div>
<div class="chat-row-snippet muted">{{ chat.last_message_snippet or '—' }}</div>
<div class="chat-row-meta muted">
<span>{{ chat.time }}</span>
{% if chat.last_played_at %}<span>· {{ chat.last_played_at }}</span>{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No chats yet. <a href="/bots/new">Create a bot</a> to start.</p>
{% endif %}
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Confirm kickoff - chat{% endblock %}
{% block content %}
<h1>Confirm kickoff</h1>
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<nav class="rail">
<a class="rail-brand" href="/chats">chat</a>
<ul>
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
</ul>
</nav>
<main class="content">
{% block content %}{% endblock %}
</main>
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "layout.html" %}
{% block title %}Settings - chat{% endblock %}
{% block content %}
<h1>Settings</h1>
+6 -2
View File
@@ -66,12 +66,16 @@ def _split_traits(text: str) -> list[str]:
@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})
return TEMPLATES.TemplateResponse(
request, "bot_list.html", {"bots": bots, "active_nav": "bots"}
)
@router.get("/bots/new", response_class=HTMLResponse)
async def bot_form(request: Request):
return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None})
return TEMPLATES.TemplateResponse(
request, "bot_form.html", {"values": {}, "error": None, "active_nav": "bots"}
)
@router.post("/bots/new")
+65
View File
@@ -0,0 +1,65 @@
"""Chat detail (shell) page.
Renders ``/chats/<id>``: the title (host bot's name), a timeline placeholder,
the user-input form, and the drawer toggle. Turn handling lives in T19; this
module only sets up the structural shell.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.state.entities import get_bot
from chat.state.world import get_chat
from chat.web.bots import get_conn
from chat.web.turns import _read_recent_dialogue
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
@router.get("/chats/{chat_id}", response_class=HTMLResponse)
async def chat_detail(chat_id: str, request: Request, conn=Depends(get_conn)):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
# Defensive: chat row references a bot that doesn't exist. Treat as 404
# rather than crashing the template render.
raise HTTPException(
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
)
# T19: render the timeline from event_log. We pull both user_turn and
# assistant_turn events for this chat, in chronological order. Each row
# is shaped ``{"speaker": ..., "text": ...}`` and the template
# discriminates roles via the speaker id (the literal "you" vs. a bot id).
raw_turns = _read_recent_dialogue(conn, chat_id, limit=200)
turns: list[dict] = []
for t in raw_turns:
if t["speaker"] == "you":
turns.append({"role": "you", "speaker": "you", "text": t["text"]})
else:
bot = get_bot(conn, t["speaker"])
label = bot["name"] if bot else t["speaker"]
turns.append({"role": "bot", "speaker": label, "text": t["text"]})
return TEMPLATES.TemplateResponse(
request,
"chat.html",
{
"chat": chat,
"host_bot": host_bot,
"turns": turns,
"active_nav": "chats",
},
)
+3 -1
View File
@@ -122,7 +122,9 @@ async def kickoff_get(
"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})
return TEMPLATES.TemplateResponse(
request, "kickoff_confirm.html", {"values": values, "active_nav": "bots"}
)
@router.post("/bots/{bot_id}/kickoff")
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.web.bots import get_conn
from chat.state.world import list_chats
from chat.state.entities import get_bot
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
@router.get("/", include_in_schema=False)
async def home():
return RedirectResponse(url="/chats", status_code=303)
@router.get("/chats", response_class=HTMLResponse)
async def chats_list(request: Request, conn=Depends(get_conn)):
chats = list_chats(conn)
# Annotate each chat with the host bot's name for display.
for ch in chats:
bot = get_bot(conn, ch["host_bot_id"])
ch["host_bot_name"] = bot["name"] if bot else ch["host_bot_id"]
# Last-message snippet and last-played-at are blank in v1; T19 fills them.
ch["last_message_snippet"] = ""
ch["last_played_at"] = None
return TEMPLATES.TemplateResponse(request, "chat_list.html", {
"chats": chats,
"active_nav": "chats",
})
+60
View File
@@ -0,0 +1,60 @@
"""In-process per-chat broadcast channel.
Each ``chat_id`` has a list of subscriber ``asyncio.Queue`` instances. T16
provides only the registry and fan-out mechanism; T19+ will publish events
(turn appends, streamed tokens, drawer updates, scene close, edge updates)
through this channel so all browser tabs viewing a chat stay in sync.
The registry is process-local: appropriate for a single-user local server.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any
# {chat_id: [queue, queue, ...]}
_subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
_lock = asyncio.Lock()
async def subscribe(chat_id: str) -> asyncio.Queue:
"""Subscribe to a chat's broadcast channel.
Returns a fresh ``asyncio.Queue`` that will receive every event published
to ``chat_id`` while the subscription is active. Callers must invoke
:func:`unsubscribe` when finished (typically on client disconnect) to
avoid leaking queues into the registry.
"""
queue: asyncio.Queue = asyncio.Queue()
async with _lock:
_subscribers[chat_id].append(queue)
return queue
async def unsubscribe(chat_id: str, queue: asyncio.Queue) -> None:
"""Remove ``queue`` from the registry; remove the chat key if empty."""
async with _lock:
if chat_id in _subscribers:
if queue in _subscribers[chat_id]:
_subscribers[chat_id].remove(queue)
if not _subscribers[chat_id]:
del _subscribers[chat_id]
async def publish(chat_id: str, event: dict[str, Any]) -> None:
"""Fan-out ``event`` to every subscriber of ``chat_id``.
The same dict reference is enqueued to all subscribers. Callers should
treat published events as immutable. Queues are unbounded for v1.
"""
async with _lock:
queues = list(_subscribers.get(chat_id, []))
for q in queues:
await q.put(event)
def subscriber_count(chat_id: str) -> int:
"""Test helper. Returns the number of active subscribers for a chat."""
return len(_subscribers.get(chat_id, []))
+6 -2
View File
@@ -18,7 +18,9 @@ router = APIRouter()
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}
request,
"settings.html",
{"values": you, "saved": False, "active_nav": "settings"},
)
@@ -42,5 +44,7 @@ async def settings_post(
project(conn)
return TEMPLATES.TemplateResponse(
request, "settings.html", {"values": payload, "saved": True}
request,
"settings.html",
{"values": payload, "saved": True, "active_nav": "settings"},
)
+88
View File
@@ -0,0 +1,88 @@
"""Server-Sent Events endpoint for per-chat live updates.
Each browser tab on ``/chats/<id>`` opens an SSE connection here. On connect:
1. We verify the chat exists (404 otherwise).
2. We subscribe to the chat's pub/sub channel.
3. We emit a ``snapshot`` event with the current state. T16 only provides a
stub payload (``{"chat_id": <id>, "ready": true}``) so the client can
confirm the channel is live; T19+ will populate it with real state.
4. We loop, awaiting events from the queue and yielding them as SSE frames.
A 15-second keepalive comment is emitted on idle to defeat intermediary
timeouts.
5. When the client disconnects we unsubscribe so the registry doesn't leak.
"""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from chat.state.world import get_chat
from chat.web.bots import get_conn
from chat.web.pubsub import subscribe, unsubscribe
router = APIRouter()
# Heartbeat cadence. Long enough to avoid chattiness; short enough that most
# HTTP intermediaries won't close an idle connection.
_KEEPALIVE_SECONDS = 15.0
def _format_sse(event: str, data: dict | str) -> bytes:
"""Format a single SSE frame: ``event: <name>\\ndata: <body>\\n\\n``.
``data`` may be a dict (JSON-serialized) or a raw string. The string
form is used for HTMX SSE swaps where the payload is an HTML
fragment that the client splices into the DOM verbatim.
"""
if isinstance(data, str):
payload = data
else:
payload = json.dumps(data)
return f"event: {event}\ndata: {payload}\n\n".encode("utf-8")
@router.get("/chats/{chat_id}/events")
async def chat_events(chat_id: str, request: Request, conn=Depends(get_conn)):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail="chat not found")
async def stream():
queue = await subscribe(chat_id)
try:
# Initial snapshot — T19 will fill in real state.
yield _format_sse("snapshot", {"chat_id": chat_id, "ready": True})
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(
queue.get(), timeout=_KEEPALIVE_SECONDS
)
except asyncio.TimeoutError:
# SSE comment line (per spec, lines starting with ":" are
# ignored by the client) — keeps the connection warm.
yield b": keepalive\n\n"
continue
# Allow publishers to set the SSE event name via "event" key;
# default to "message" if omitted. When the remaining payload
# is a single ``data`` string, send it verbatim — that lets
# turn-flow publishers ship pre-rendered HTML fragments that
# HTMX's SSE extension can swap into the DOM directly.
event = dict(event) # don't mutate the published dict
kind = event.pop("event", "message")
if set(event.keys()) == {"data"} and isinstance(
event["data"], str
):
yield _format_sse(kind, event["data"])
else:
yield _format_sse(kind, event)
finally:
await unsubscribe(chat_id, queue)
return StreamingResponse(stream(), media_type="text/event-stream")
+239
View File
@@ -0,0 +1,239 @@
"""POST ``/chats/<id>/turns`` — narrative turn flow with SSE streaming.
The turn flow strings together the pieces built in T17 (turn parser), T18
(prompt assembler), and T16 (SSE channel):
1. Parse the user's prose with the classifier into typed segments.
2. Append a ``user_turn`` event capturing both the original prose and the
parsed segments.
3. Append a placeholder ``assistant_turn_started`` marker so observers know
a response is in flight.
4. Build the narrative prompt, dropping OOC segments before they reach the
bot (per Requirements §6.1 the OOC convention is for the author to talk
to the system, not to the in-fiction bot).
5. Stream tokens from the LLM, broadcasting each chunk over the chat's SSE
channel as a ``token`` event so any subscribed browser tab sees them
arrive in real time.
6. On stream complete, append an ``assistant_turn`` event with the full
text and ``truncated=False``. Also publish a ``turn_html`` event with a
ready-to-swap HTML fragment so HTMX's SSE extension can append it to
the timeline without a page reload.
7. Return ``204 No Content`` — the SSE channel is the real conveyor of
state, not the POST response body.
Errors during streaming flip the assistant_turn's ``truncated`` flag to
``True`` and we still commit what we received. ``asyncio.CancelledError``
is treated identically and re-raised after recording the partial turn.
"""
from __future__ import annotations
import asyncio
import html
import json
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import Response
from chat.eventlog.log import append_event
from chat.services.prompt import assemble_narrative_prompt
from chat.services.turn_parse import ParsedTurn, parse_turn
from chat.state.world import get_chat
from chat.state.entities import get_bot
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
from chat.web.pubsub import publish
router = APIRouter()
def _strip_ooc_for_prompt(parsed: ParsedTurn) -> str:
"""Concatenate non-OOC segments back to a prose string for the prompt.
OOC segments (``((double parens))``) are kept in the user_turn payload
for transcript display but stripped before assembly so the bot never
sees author-to-system messages.
"""
keep = [s.text for s in parsed.segments if s.kind != "ooc"]
return " ".join(keep).strip()
def _read_recent_dialogue(conn, chat_id: str, limit: int = 200) -> list[dict]:
"""Return ``user_turn`` and ``assistant_turn`` events for ``chat_id``.
Ordered oldest-first. Skips superseded and hidden rows so regenerated
turns (T29) drop out of the rendered timeline. Each entry is shaped
``{"speaker": <id-or-"you">, "text": <prose>}`` for the prompt
assembler and the chat-detail template.
"""
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
rows = cur.fetchall()
rows.reverse() # back to chronological order
out: list[dict] = []
for _row_id, kind, payload_json in rows:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
if kind == "user_turn":
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
}
)
return out
def _render_turn_html(speaker_label: str, text: str, *, role: str) -> str:
"""Render a single turn as a small HTML fragment (escaped)."""
return (
f'<div class="turn turn-{role}">'
f"<strong>{html.escape(speaker_label)}</strong>"
f"<p>{html.escape(text)}</p>"
f"</div>"
)
@router.post("/chats/{chat_id}/turns")
async def post_turn(
chat_id: str,
request: Request,
prose: str = Form(""),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
# Defensive: chat row references a missing bot.
raise HTTPException(
status_code=404,
detail=f"host bot not found: {chat['host_bot_id']}",
)
settings = request.app.state.settings
# 1. Parse turn (classifier).
parsed = await parse_turn(
client, model=settings.classifier_model, prose=prose
)
prompt_prose = _strip_ooc_for_prompt(parsed)
# 2. Append user_turn event.
user_turn_event_id = append_event(
conn,
kind="user_turn",
payload={
"chat_id": chat_id,
"prose": prose,
"segments": [s.model_dump() for s in parsed.segments],
},
)
# 3. Append assistant_turn_started placeholder. ``user_turn``,
# ``assistant_turn_started``, and ``assistant_turn`` have no registered
# projector handlers — they live in the event_log purely for transcript
# rendering — so we don't call ``project`` here. (Re-projecting now would
# also re-run prior non-idempotent inserts like ``chat_created``.)
append_event(
conn,
kind="assistant_turn_started",
payload={
"chat_id": chat_id,
"speaker_id": host_bot["id"],
"user_turn_id": user_turn_event_id,
},
)
# 4. Build the narrative prompt.
recent = _read_recent_dialogue(conn, chat_id, limit=20)
# Drop the just-appended user turn from ``recent`` — it's passed as
# ``user_turn_prose`` to the assembler and would otherwise duplicate.
if recent and recent[-1].get("speaker") == "you":
recent = recent[:-1]
messages = assemble_narrative_prompt(
conn,
chat_id=chat_id,
speaker_bot_id=host_bot["id"],
user_turn_prose=prompt_prose if prompt_prose else None,
recent_dialogue=recent,
budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard,
)
# 5. Stream and accumulate tokens.
accumulated: list[str] = []
truncated = False
cancelled = False
try:
async for chunk in client.stream(
messages, model=settings.narrative_model
):
accumulated.append(chunk)
await publish(
chat_id,
{
"event": "token",
"text": chunk,
"speaker_id": host_bot["id"],
},
)
except asyncio.CancelledError:
# Preserve the partial output before letting the cancellation
# propagate so the transcript reflects what the user actually saw.
truncated = True
cancelled = True
except Exception:
# Surface as a truncated turn rather than losing the partial output.
truncated = True
full_text = "".join(accumulated)
# 6. Append the assistant_turn with the final text. (See note above on
# why we skip ``project`` for these transcript-only event kinds.)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": host_bot["id"],
"text": full_text,
"truncated": truncated,
"user_turn_id": user_turn_event_id,
},
)
# 7. Broadcast a JSON completion event (for JS consumers) and an HTML
# fragment event (for HTMX SSE swap-into-timeline).
await publish(
chat_id,
{
"event": "assistant_turn_complete",
"speaker_id": host_bot["id"],
"text": full_text,
"truncated": truncated,
},
)
assistant_html = _render_turn_html(
host_bot["name"], full_text, role="bot"
)
await publish(
chat_id, {"event": "turn_html", "data": assistant_html}
)
if cancelled:
# Re-raise after the partial-turn has been recorded.
raise asyncio.CancelledError
return Response(status_code=204)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
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
@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 _author_bot_and_chat(db_path: Path, bot_id: str = "bot_a") -> None:
"""Insert a bot and a chat directly via the event log (skip kickoff route)."""
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",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": f"chat_{bot_id}",
"host_bot_id": bot_id,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
def test_root_redirects_to_chats(client):
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/chats"
def test_chats_list_empty_state(client):
response = client.get("/chats")
assert response.status_code == 200
body = response.text.lower()
# Empty state should mention there are no chats yet.
assert "no chats yet" in body
def test_chats_list_renders_existing_chats(client, tmp_path):
_author_bot_and_chat(tmp_path / "test.db", "bot_a")
response = client.get("/chats")
assert response.status_code == 200
body = response.text
# The bot's display name should appear in the chat row.
assert "BotA" in body
# The chat's in-fiction time should appear in the meta.
assert "2026-04-26T20:00:00+00:00" in body
def test_existing_template_routes_still_work_with_new_layout(client):
# Smoke test the layout reshuffle didn't break the existing pages.
for path in ("/bots", "/bots/new", "/settings"):
response = client.get(path)
assert response.status_code == 200, f"{path} returned {response.status_code}"
body = response.text
# Each page should now show the persistent left-rail brand link.
assert 'class="rail"' in body or "rail-brand" in body
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
yield c
def _seed_chat(db_path: Path, bot_id: str = "bot_a", chat_id: str = "chat_bot_a") -> None:
"""Author a bot, create a chat with default state."""
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "...",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": chat_id,
"host_bot_id": bot_id,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
def test_get_chat_404_when_missing(client):
response = client.get("/chats/no_such_chat")
assert response.status_code == 404
def test_get_chat_renders_shell_with_host_bot_name(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
assert "BotA" in body
assert "<form" in body # turn input form
assert "drawer" in body.lower() # drawer present
assert "no turns yet" in body.lower() # empty timeline placeholder
def test_get_chat_includes_turn_post_action(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
assert 'action="/chats/chat_bot_a/turns"' in response.text
def test_get_chat_shows_chat_clock_time(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
assert "2026-04-26" in response.text
+255
View File
@@ -0,0 +1,255 @@
"""Tests for chat.services.prompt.assemble_narrative_prompt.
Covers Task 18 — must/should/nice trim tiers (Requirements §3.2) and
the speaker prompt assembly order (§6.3). Tests use direct event-log
seeding so the projector populates state exactly the way the runtime
will at play-time. No LLM is invoked: prompt assembly is deterministic.
"""
from __future__ import annotations
import pytest
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
import chat.state.entities # noqa: F401 (registers handlers)
import chat.state.edges # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.llm.client import Message
from chat.services.prompt import assemble_narrative_prompt
def _seed_basic(conn) -> None:
"""Seed bot, you-entity, edge, chat, container, scene, activities."""
append_event(conn, kind="bot_authored", payload={
"id": "bot_a",
"name": "Aria",
"persona": "reserved coworker who notices things",
"voice_samples": ["I — sorry, I didn't mean to.", "Right. Of course."],
"traits": ["introverted", "observant"],
"backstory": "An archivist who joined the firm last spring.",
"initial_relationship_to_you": "coworker; mild crush; never voiced",
"kickoff_prose": "you stay late at the office",
})
append_event(conn, kind="you_authored", payload={
"name": "Sam",
"pronouns": "they/them",
"persona": "tired analyst",
})
append_event(conn, kind="chat_created", payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": None,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1 evening",
"weather": "clear",
})
append_event(conn, kind="container_created", payload={
"chat_id": "chat_bot_a",
"name": "office bullpen",
"type": "workplace",
"properties": {"public": False, "moving": False, "audible_range": "room"},
})
append_event(conn, kind="edge_update", payload={
"source_id": "bot_a",
"target_id": "you",
"affinity_delta": 12,
"trust_delta": 5,
"knowledge_facts": [
"they work on the same floor",
"they've stayed late twice this week",
],
})
append_event(conn, kind="activity_change", payload={
"entity_id": "you",
"container_id": 1,
"posture": "sitting at your desk",
"action": {"verb": "finishing emails"},
"attention": "the screen",
"holding": ["coffee mug"],
})
append_event(conn, kind="activity_change", payload={
"entity_id": "bot_a",
"container_id": 1,
"posture": "sitting at her desk",
"action": {"verb": "pretending to work"},
"attention": "you, in glances",
})
append_event(conn, kind="scene_opened", payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
})
project(conn)
def test_basic_assembly_returns_system_message_with_all_must_blocks(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
assert isinstance(msgs, list)
assert len(msgs) == 1
sys_msg = msgs[0]
assert isinstance(sys_msg, Message)
assert sys_msg.role == "system"
body = sys_msg.content
# Must-include markers
assert "Aria" in body
assert "PERSONA" in body
assert "ACTIVITIES" in body
assert "CURRENT SCENE" in body
# Edge to addressee — name + numeric values (default affinity 50, +12 = 62)
assert "Sam" in body
assert "62/100" in body
def test_user_turn_appended_as_user_message(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
user_turn_prose="*looks up* Hey.",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
assert len(msgs) == 2
assert msgs[0].role == "system"
assert msgs[1].role == "user"
assert msgs[1].content == "*looks up* Hey."
def test_must_only_succeeds_with_empty_optional_blocks(tmp_path):
"""No dialogue, memories, other edges, or previous scene summary — should not raise."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=None, # default → nothing
retrieved_memory_summaries=None,
user_turn_prose=None,
)
assert len(msgs) == 1
body = msgs[0].content
# Must blocks present
assert "PERSONA" in body
assert "ACTIVITIES" in body
# Optional blocks not in body (nothing to render)
assert "OTHER EDGES" not in body
assert "PREVIOUS SCENE SUMMARY" not in body
assert "RELEVANT MEMORIES" not in body
def test_long_dialogue_keeps_last_4_verbatim_and_summarizes_earlier(tmp_path):
"""Stuff a huge dialogue history under budget pressure; older turns
must be elided to a placeholder, the last 4 verbatim, and earlier
unique markers gone.
"""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
dialogue = []
for i in range(20):
speaker = "you" if i % 2 == 0 else "bot_a"
# Each line ~250 tokens of filler => 20 turns ≈ 5000 tokens,
# which together with MUST blocks pushes over soft (1500).
dialogue.append({
"speaker": speaker,
"text": f"unique-line-marker-{i:02d} " + ("filler " * 200),
})
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=dialogue,
retrieved_memory_summaries=[],
# Soft small enough to force NICE trim but hard fits MUST + 4.
budget_soft=1200,
budget_hard=8000,
)
body = msgs[0].content
# The last 4 unique markers (16, 17, 18, 19) must be present verbatim.
for i in range(16, 20):
assert f"unique-line-marker-{i:02d}" in body, f"expected last-4 marker {i} in body"
# Older markers must be dropped (replaced by elision placeholder).
for i in range(0, 16):
assert f"unique-line-marker-{i:02d}" not in body
# An "earlier" summary line must be present.
assert "earlier" in body.lower()
# Token count of system message respects hard budget.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
assert len(enc.encode(body)) <= 8000
def test_memories_drop_to_top_2_under_budget_pressure(tmp_path):
"""4 memory summaries, each large; under tight soft budget only 2 should appear."""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
# Each ~1500 tokens of repeated text; drop tier should kick in.
long_chunk = "alpha beta gamma delta " * 400
memories = [
f"MEMORY-A {long_chunk}",
f"MEMORY-B {long_chunk}",
f"MEMORY-C {long_chunk}",
f"MEMORY-D {long_chunk}",
]
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=memories,
# Pressure: budgets that allow MUST + 2 memories but not 4.
budget_soft=4000,
budget_hard=5000,
)
body = msgs[0].content
# MEMORY-A and MEMORY-B are the top-2 and should remain; C & D dropped.
assert "MEMORY-A" in body
assert "MEMORY-B" in body
assert "MEMORY-C" not in body
assert "MEMORY-D" not in body
# Token count fits the hard budget.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
assert len(enc.encode(body)) <= 5000
def test_must_exceeds_budget_hard_raises_value_error(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_basic(conn)
with pytest.raises(ValueError):
assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
budget_soft=5,
budget_hard=10,
)
+174
View File
@@ -0,0 +1,174 @@
"""Tests for the per-chat SSE channel and pub/sub registry (T16).
Covers:
- 404 from the SSE endpoint when the chat doesn't exist.
- The first event yielded on connect is the ``snapshot`` event with correct
framing (``event: snapshot\\ndata: {...}\\n\\n``) and includes the chat id.
- ``publish`` fans out to every subscriber of a chat.
- Events published to one chat don't leak to subscribers of another chat.
Notes:
- Starlette's TestClient buffers the entire ASGI response before returning,
so it cannot read from an indefinitely-open SSE stream. The framing test
drives the route handler directly and pulls frames from the
``StreamingResponse``'s body iterator. The 404 path is still validated via
TestClient since it returns synchronously without entering the stream.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from fastapi import Request
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
yield c
def _seed_chat(
db_path: Path, bot_id: str = "bot_a", chat_id: str = "chat_bot_a"
) -> None:
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": bot_id,
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "...",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": chat_id,
"host_bot_id": bot_id,
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
def test_sse_endpoint_404_for_missing_chat(client):
response = client.get("/chats/nonexistent/events")
assert response.status_code == 404
async def test_sse_streams_initial_snapshot(client, tmp_path):
"""The first SSE frame is a correctly framed ``snapshot`` event."""
_seed_chat(tmp_path / "test.db")
# Drive the route directly. The TestClient can't iterate an open stream
# because Starlette's transport waits for the response to complete.
from chat.web.bots import get_conn
from chat.web.sse import chat_events
disconnect = asyncio.Event()
async def receive():
await disconnect.wait()
return {"type": "http.disconnect"}
scope = {
"type": "http",
"method": "GET",
"path": "/chats/chat_bot_a/events",
"headers": [],
"query_string": b"",
"app": client.app,
}
request = Request(scope, receive=receive)
conn_gen = get_conn(request)
conn = next(conn_gen)
try:
response = await chat_events("chat_bot_a", request, conn=conn)
assert response.media_type == "text/event-stream"
body_iter = response.body_iterator
first = await asyncio.wait_for(body_iter.__anext__(), timeout=2.0)
text = first.decode()
# Framing: starts with the event line, ends with the blank-line
# terminator that delimits SSE frames.
assert text.startswith("event: snapshot\n")
assert "\ndata: " in text
assert text.endswith("\n\n")
# Payload is JSON containing the chat id.
data_line = next(
line for line in text.splitlines() if line.startswith("data: ")
)
payload = json.loads(data_line[len("data: "):])
assert payload["chat_id"] == "chat_bot_a"
# Cleanly tear down the generator's ``finally`` (unsubscribe).
disconnect.set()
try:
await asyncio.wait_for(body_iter.aclose(), timeout=2.0)
except (StopAsyncIteration, RuntimeError):
pass
finally:
try:
next(conn_gen)
except StopIteration:
pass
async def test_pubsub_publish_fanout():
"""Every active subscriber of a chat receives published events."""
from chat.web.pubsub import publish, subscribe, unsubscribe
q1 = await subscribe("chat_x")
q2 = await subscribe("chat_x")
try:
await publish("chat_x", {"event": "test", "value": 1})
e1 = await asyncio.wait_for(q1.get(), timeout=1.0)
e2 = await asyncio.wait_for(q2.get(), timeout=1.0)
assert e1 == {"event": "test", "value": 1}
assert e2 == {"event": "test", "value": 1}
finally:
await unsubscribe("chat_x", q1)
await unsubscribe("chat_x", q2)
async def test_pubsub_isolated_per_chat():
"""Events published to one chat don't leak to a different chat."""
from chat.web.pubsub import publish, subscribe, unsubscribe
q_a = await subscribe("chat_a")
q_b = await subscribe("chat_b")
try:
await publish("chat_a", {"event": "for_a"})
e = await asyncio.wait_for(q_a.get(), timeout=1.0)
assert e["event"] == "for_a"
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(q_b.get(), timeout=0.1)
finally:
await unsubscribe("chat_a", q_a)
await unsubscribe("chat_b", q_b)
+177
View File
@@ -0,0 +1,177 @@
"""End-to-end turn flow (T19): user POSTs prose, server parses, streams via SSE.
Covers:
- POST ``/chats/<id>/turns`` returns 404 when the chat doesn't exist.
- A successful POST appends both a ``user_turn`` and an ``assistant_turn``
event in chronological order. The assistant payload carries the full
streamed text and ``truncated=False``.
- After a turn lands, the chat detail GET renders the user prose and the
assistant text from the event log.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
canned_parse = json.dumps(
{"segments": [{"kind": "dialogue", "text": "hello"}]}
)
canned_response = "Hi there."
# Import here so env vars are visible to the dependency lookup.
from chat.web.kickoff import get_llm_client
mock = MockLLMClient(canned=[canned_parse, canned_response])
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 _seed(db_path: Path) -> None:
"""Author a bot, create a chat, and seed enough state for prompt assembly."""
with open_db(db_path) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "thoughtful, observant",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "...",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
# Seed an edge so the prompt assembler has something to render.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"knowledge_facts": ["coworker"],
},
)
# Activity for both speakers — required by the prompt assembler.
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"posture": "sitting",
"action": {
"verb": "talking",
"interruptible": True,
"required_attention": "low",
"expected_duration": "ongoing",
},
"attention": "",
"holding": [],
"status": {},
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "bot_a",
"posture": "sitting",
"action": {
"verb": "listening",
"interruptible": True,
"required_attention": "low",
"expected_duration": "ongoing",
},
"attention": "",
"holding": [],
"status": {},
},
)
project(conn)
def test_post_turn_404_when_chat_missing(client):
response = client.post("/chats/no_such/turns", data={"prose": "hello"})
assert response.status_code == 404
def test_post_turn_appends_user_and_assistant_events(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/turns", data={"prose": "hello"}
)
assert response.status_code == 204
with open_db(tmp_path / "test.db") as conn:
cur = conn.execute(
"SELECT kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') ORDER BY id"
)
rows = cur.fetchall()
assert len(rows) == 2
assert rows[0][0] == "user_turn"
assert rows[1][0] == "assistant_turn"
user_payload = json.loads(rows[0][1])
assert user_payload["chat_id"] == "chat_bot_a"
assert user_payload["prose"] == "hello"
# Segments come from the canned classifier output.
assert any(
s.get("kind") == "dialogue" and s.get("text") == "hello"
for s in user_payload["segments"]
)
assistant_payload = json.loads(rows[1][1])
assert assistant_payload["chat_id"] == "chat_bot_a"
assert assistant_payload["speaker_id"] == "bot_a"
assert assistant_payload["text"] == "Hi there."
assert assistant_payload["truncated"] is False
def test_get_chat_renders_existing_turns(client, tmp_path):
_seed(tmp_path / "test.db")
post = client.post("/chats/chat_bot_a/turns", data={"prose": "hello"})
assert post.status_code == 204
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
assert "hello" in body
assert "Hi there." in body
+83
View File
@@ -0,0 +1,83 @@
import json
import pytest
from chat.llm.mock import MockLLMClient
from chat.services.turn_parse import (
ParsedTurn,
TurnSegment,
parse_turn,
)
@pytest.mark.asyncio
async def test_parse_turn_three_segment_happy_path():
canned = json.dumps(
{
"segments": [
{"kind": "action", "text": "walks over"},
{"kind": "dialogue", "text": "Hey."},
{"kind": "ooc", "text": "player note"},
]
}
)
mock = MockLLMClient(canned=[canned])
result = await parse_turn(
mock,
model="m",
prose='*walks over* "Hey." ((player note))',
)
assert isinstance(result, ParsedTurn)
assert len(result.segments) == 3
kinds = [s.kind for s in result.segments]
assert kinds == ["action", "dialogue", "ooc"]
texts = [s.text for s in result.segments]
assert texts == ["walks over", "Hey.", "player note"]
assert all(isinstance(s, TurnSegment) for s in result.segments)
@pytest.mark.asyncio
async def test_parse_turn_pure_dialogue_single_segment():
canned = json.dumps(
{
"segments": [
{"kind": "dialogue", "text": "Hello there"},
]
}
)
mock = MockLLMClient(canned=[canned])
result = await parse_turn(
mock,
model="m",
prose='"Hello there"',
)
assert isinstance(result, ParsedTurn)
assert len(result.segments) == 1
assert result.segments[0].kind == "dialogue"
assert result.segments[0].text == "Hello there"
@pytest.mark.asyncio
async def test_parse_turn_empty_prose_short_circuits_without_classifier_call():
# No canned responses provided — if classify() is invoked it will raise
# IndexError on the empty list. The short-circuit must prevent that.
mock = MockLLMClient(canned=[])
result = await parse_turn(mock, model="m", prose="")
assert isinstance(result, ParsedTurn)
assert result.segments == []
# Whitespace-only prose must also short-circuit.
result_ws = await parse_turn(mock, model="m", prose=" \n\t ")
assert isinstance(result_ws, ParsedTurn)
assert result_ws.segments == []
@pytest.mark.asyncio
async def test_parse_turn_raises_when_classifier_fails_twice():
mock = MockLLMClient(canned=["nope", "still nope"])
with pytest.raises(RuntimeError):
await parse_turn(
mock,
model="m",
prose='*shrugs* "whatever"',
)