Compare commits
6 Commits
fbb16c86b3
...
9b45710cb1
| 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
|
||||
|
||||
from chat.web.bots import router as bots_router
|
||||
from chat.web.chat import router as chat_router
|
||||
from chat.web.kickoff import router as kickoff_router
|
||||
from chat.web.nav import router as nav_router
|
||||
from chat.web.settings import router as settings_router
|
||||
from chat.web.sse import router as sse_router
|
||||
from chat.web.turns import router as turns_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -36,6 +40,10 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.include_router(bots_router)
|
||||
app.include_router(kickoff_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(nav_router)
|
||||
app.include_router(chat_router)
|
||||
app.include_router(sse_router)
|
||||
app.include_router(turns_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -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;
|
||||
color: #1c1c1c;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
background: #fff;
|
||||
.rail {
|
||||
width: 200px;
|
||||
background: #1c1c1c;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail a { color: #fff; text-decoration: none; }
|
||||
.rail-brand {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rail ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rail li { margin: 4px 0; }
|
||||
.rail li a { display: block; padding: 6px 8px; border-radius: 3px; }
|
||||
.rail li a.active { background: #333; }
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
background: #fafafa;
|
||||
overflow: auto;
|
||||
}
|
||||
.brand { font-weight: 600; text-decoration: none; color: inherit; }
|
||||
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
|
||||
@@ -29,6 +51,13 @@ h1 { margin-top: 0; }
|
||||
.bot-form small { display: block; color: #666; margin-top: 2px; }
|
||||
.bot-list { list-style: none; padding: 0; }
|
||||
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
.chat-list { list-style: none; padding: 0; margin: 0; }
|
||||
.chat-row { border-bottom: 1px solid #eee; }
|
||||
.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; }
|
||||
.chat-row a:hover { background: #f0f0f0; }
|
||||
.chat-row-name { font-weight: 600; }
|
||||
.chat-row-snippet { font-size: 14px; }
|
||||
.chat-row-meta { font-size: 12px; }
|
||||
.muted { color: #666; }
|
||||
.error {
|
||||
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
|
||||
@@ -39,3 +68,14 @@ h1 { margin-top: 0; }
|
||||
color: #1f5c2a; border-radius: 3px;
|
||||
}
|
||||
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.chat-shell { display: flex; flex-direction: column; height: 100%; max-width: 760px; margin: 0 auto; }
|
||||
.chat-header { display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 16px; }
|
||||
.chat-header h1 { margin: 0; flex: 1; }
|
||||
.chat-meta { font-size: 13px; }
|
||||
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
|
||||
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
|
||||
.turn { margin: 12px 0; }
|
||||
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
|
||||
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
|
||||
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
|
||||
.drawer[hidden] { display: none; }
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/bots">chat</a>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}New bot - chat{% endblock %}
|
||||
{% block content %}
|
||||
<h1>New bot</h1>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}Bots - chat{% endblock %}
|
||||
{% block content %}
|
||||
<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 content %}
|
||||
<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 content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
+6
-2
@@ -66,12 +66,16 @@ def _split_traits(text: str) -> list[str]:
|
||||
@router.get("/bots", response_class=HTMLResponse)
|
||||
async def bots_list(request: Request, conn=Depends(get_conn)):
|
||||
bots = list_bots(conn)
|
||||
return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots})
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "bot_list.html", {"bots": bots, "active_nav": "bots"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bots/new", response_class=HTMLResponse)
|
||||
async def bot_form(request: Request):
|
||||
return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None})
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "bot_form.html", {"values": {}, "error": None, "active_nav": "bots"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bots/new")
|
||||
|
||||
@@ -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_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")
|
||||
|
||||
@@ -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)):
|
||||
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "settings.html", {"values": you, "saved": False}
|
||||
request,
|
||||
"settings.html",
|
||||
{"values": you, "saved": False, "active_nav": "settings"},
|
||||
)
|
||||
|
||||
|
||||
@@ -42,5 +44,7 @@ async def settings_post(
|
||||
project(conn)
|
||||
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request, "settings.html", {"values": payload, "saved": True}
|
||||
request,
|
||||
"settings.html",
|
||||
{"values": payload, "saved": True, "active_nav": "settings"},
|
||||
)
|
||||
|
||||
@@ -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