Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b45710cb1 | |||
| 73d8b0c092 | |||
| a0f5e818ec | |||
| 656c2558cb | |||
| e79f4d8d22 | |||
| 0c08745194 |
@@ -15,8 +15,12 @@ import chat.state.memory # noqa: F401
|
|||||||
import chat.state.world # noqa: F401
|
import chat.state.world # noqa: F401
|
||||||
|
|
||||||
from chat.web.bots import router as bots_router
|
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.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.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
|
@asynccontextmanager
|
||||||
@@ -36,6 +40,10 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|||||||
app.include_router(bots_router)
|
app.include_router(bots_router)
|
||||||
app.include_router(kickoff_router)
|
app.include_router(kickoff_router)
|
||||||
app.include_router(settings_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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
@@ -4,11 +4,33 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: #1c1c1c;
|
color: #1c1c1c;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.topbar {
|
.rail {
|
||||||
padding: 12px 24px;
|
width: 200px;
|
||||||
border-bottom: 1px solid #e5e5e5;
|
background: #1c1c1c;
|
||||||
background: #fff;
|
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; }
|
.brand { font-weight: 600; text-decoration: none; color: inherit; }
|
||||||
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
|
.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-form small { display: block; color: #666; margin-top: 2px; }
|
||||||
.bot-list { list-style: none; padding: 0; }
|
.bot-list { list-style: none; padding: 0; }
|
||||||
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
|
.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; }
|
.muted { color: #666; }
|
||||||
.error {
|
.error {
|
||||||
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
|
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
|
||||||
@@ -39,3 +68,14 @@ h1 { margin-top: 0; }
|
|||||||
color: #1f5c2a; border-radius: 3px;
|
color: #1f5c2a; border-radius: 3px;
|
||||||
}
|
}
|
||||||
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
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; }
|
||||||
|
|||||||
@@ -8,11 +8,6 @@
|
|||||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
{% block body %}{% endblock %}
|
||||||
<a class="brand" href="/bots">chat</a>
|
|
||||||
</header>
|
|
||||||
<main class="container">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block title %}New bot - chat{% endblock %}
|
{% block title %}New bot - chat{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>New bot</h1>
|
<h1>New bot</h1>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block title %}Bots - chat{% endblock %}
|
{% block title %}Bots - chat{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block title %}Confirm kickoff - chat{% endblock %}
|
{% block title %}Confirm kickoff - chat{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Confirm kickoff</h1>
|
<h1>Confirm kickoff</h1>
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block title %}Settings - chat{% endblock %}
|
{% block title %}Settings - chat{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|||||||
+6
-2
@@ -66,12 +66,16 @@ def _split_traits(text: str) -> list[str]:
|
|||||||
@router.get("/bots", response_class=HTMLResponse)
|
@router.get("/bots", response_class=HTMLResponse)
|
||||||
async def bots_list(request: Request, conn=Depends(get_conn)):
|
async def bots_list(request: Request, conn=Depends(get_conn)):
|
||||||
bots = list_bots(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)
|
@router.get("/bots/new", response_class=HTMLResponse)
|
||||||
async def bot_form(request: Request):
|
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")
|
@router.post("/bots/new")
|
||||||
|
|||||||
@@ -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
@@ -122,7 +122,9 @@ async def kickoff_get(
|
|||||||
"edge_seed_summary": parsed.edge_seed_summary,
|
"edge_seed_summary": parsed.edge_seed_summary,
|
||||||
"edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
|
"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")
|
@router.post("/bots/{bot_id}/kickoff")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
@@ -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, []))
|
||||||
@@ -18,7 +18,9 @@ router = APIRouter()
|
|||||||
async def settings_get(request: Request, conn=Depends(get_conn)):
|
async def settings_get(request: Request, conn=Depends(get_conn)):
|
||||||
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
|
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
|
||||||
return TEMPLATES.TemplateResponse(
|
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)
|
project(conn)
|
||||||
|
|
||||||
return TEMPLATES.TemplateResponse(
|
return TEMPLATES.TemplateResponse(
|
||||||
request, "settings.html", {"values": payload, "saved": True}
|
request,
|
||||||
|
"settings.html",
|
||||||
|
{"values": payload, "saved": True, "active_nav": "settings"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"',
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user