feat: natural-language skip detection + skip command flow (T62)

Extend ParsedTurn with intent/landing_state_hint so the classifier can
flag skip-elision and skip-jump prose. The post_turn handler short-
circuits the regular narrative path when intent != "narrative":
elision runs through the shared controller in chat/web/skip.py;
jump returns 422 directing the user to the drawer's structured form
(simpler Phase 3 path — natural-language fiction-time delta parsing
is too fragile for v1 without a structured surface).

Extract the elision/jump logic that previously lived in drawer.py
into chat/web/skip.py so both the drawer T59 routes and the new
natural-language path share one canonical implementation. The drawer
routes become thin HTTP wrappers that translate ValueError to 400
and refresh the drawer partial; the existing drawer skip tests pass
unchanged.

The new natural-language elision derives ``new_time`` by bumping the
chat clock by 1 hour (Phase 3 stub) — the drawer's structured form
remains the path for picking a specific landing time.
This commit is contained in:
Joseph Doherty
2026-04-26 20:45:05 -04:00
parent e236bcadcd
commit a7eedb8037
5 changed files with 615 additions and 213 deletions
+39 -8
View File
@@ -16,6 +16,16 @@ 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.
T62 extends the parser with an ``intent`` field so the turn flow can
short-circuit time-skip phrases before the regular narrative path.
``intent`` defaults to ``"narrative"``; the classifier may set it to
``"skip_elision"`` when prose like "skip to when we arrive" or
``"skip_jump"`` when prose like "next morning" / "a week later" is
detected. ``landing_state_hint`` carries the residual descriptor for
elision skips (the "to when we ..." phrase). Existing callers that
don't read ``intent`` continue to work because the default keeps the
narrative path intact.
"""
from __future__ import annotations
@@ -39,9 +49,19 @@ class TurnSegment(BaseModel):
class ParsedTurn(BaseModel):
"""A turn split into ordered, typed segments."""
"""A turn split into ordered, typed segments.
``intent`` distinguishes a regular narrative beat (the default) from
a natural-language time-skip command (T62). ``landing_state_hint``
captures the descriptor following "skip to when we ..." for elision
skips so the downstream skip controller can pass it to the
narration helper. Both fields are optional and default-empty so
older fixtures and tests that don't supply them keep working.
"""
segments: list[TurnSegment]
intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump"
landing_state_hint: str = ""
_SYSTEM_PROMPT = (
@@ -52,13 +72,24 @@ _SYSTEM_PROMPT = (
"- ((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."
'{"segments": [{"kind": "...", "text": "..."}, ...], '
'"intent": "...", "landing_state_hint": "..."} '
"where each segment ``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.\n\n"
"``intent`` is exactly one of: narrative, skip_elision, skip_jump. "
"Default to ``narrative``. Use ``skip_elision`` when the prose is a "
"directive to fast-forward an in-progress activity to a near-term "
"landing state — e.g. 'skip to when we arrive', 'fast-forward to "
"after dinner'. Use ``skip_jump`` when the prose denotes a longer "
"fiction-time bridge — e.g. 'next morning', 'a week later', 'the "
"following day'.\n"
"``landing_state_hint`` is a short descriptor of the landing state "
"for ``skip_elision`` (e.g. 'we arrive at the park'). Empty string "
"for ``skip_jump`` and ``narrative``."
)
+46 -204
View File
@@ -29,19 +29,15 @@ from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply, append_event
from chat.services.memory_write import record_turn_memory_for_present
from chat.eventlog.log import append_and_apply
from chat.services.relationship_seed import seed_inter_bot_edges
from chat.services.scene_summarize import apply_scene_close_summary
from chat.services.skip_narration import narrate_skip
from chat.services.synthesized_memories import synthesize_memories
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you, list_bots
from chat.state.events import list_active_events
@@ -51,6 +47,11 @@ from chat.state.threads import list_open_threads
from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
from chat.web.skip import (
_now_iso,
process_elision_skip,
process_jump_skip,
)
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
@@ -881,29 +882,6 @@ async def remove_guest(
# rerenders itself on these submissions).
def _parse_iso_time(value: str) -> datetime | None:
"""Permissive ISO 8601 parser for skip route validation.
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until 3.11,
so we normalize it to ``+00:00`` first. Returns ``None`` on parse
failure so the caller can return ``400`` with a stable error shape.
"""
if not value:
return None
try:
v = value.strip()
if v.endswith("Z"):
v = v[:-1] + "+00:00"
return datetime.fromisoformat(v)
except (TypeError, ValueError):
return None
def _now_iso() -> str:
"""UTC ISO timestamp used as a fallback when the chat clock is unset."""
return datetime.now(timezone.utc).isoformat()
@router.post(
"/chats/{chat_id}/drawer/event/plan",
response_class=HTMLResponse,
@@ -1000,70 +978,29 @@ async def skip_elision(
):
"""Elision skip: collapse in-progress activity into its end-state.
Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the
chat clock (a backwards skip would corrupt downstream causality).
Emits ``time_skip_elision`` first (chat clock advances) then an
``assistant_turn`` carrying the narrated transition from
:func:`chat.services.skip_narration.narrate_skip`. The narration call
has its own LLM-failure fallback so this route never blocks on a
flaky model.
Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip`
(T62 extracted the controller). Validation failures surface as
``400`` and the route still returns the refreshed drawer partial on
success so HTMX swaps in the new chat clock.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise HTTPException(
status_code=400,
detail=f"new_time must be ISO 8601, got {new_time!r}",
)
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise HTTPException(
status_code=400,
detail="new_time must not be earlier than the current chat clock",
)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
current_activity = (
(bot_activity.get("action") or {}).get("verb") or ""
)
settings = request.app.state.settings
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="elision",
speaker_bot=host_bot,
you_name=you_entity.get("name") or "you",
current_time=chat.get("time") or "",
new_time=new_time,
current_activity=current_activity,
landing_state_hint=landing_state_hint,
timeout_s=settings.classifier_timeout_s,
)
append_and_apply(
conn,
kind="time_skip_elision",
payload={"chat_id": chat_id, "new_time": new_time},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": host_bot["id"] if "id" in host_bot else chat["host_bot_id"],
"text": narration,
"truncated": False,
},
)
try:
await process_elision_skip(
conn,
client,
settings,
chat_id=chat_id,
new_time=new_time,
landing_state_hint=landing_state_hint,
)
except ValueError as exc:
# ``process_elision_skip`` raises on missing-chat or malformed /
# backwards new_time. The drawer used to 404 / 400 these
# separately — preserve the 404-vs-400 split by sniffing the
# error message so existing tests keep passing without changes.
if str(exc).startswith("chat not found"):
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
@@ -1082,123 +1019,28 @@ async def skip_jump(
):
"""Jump skip: bridge a longer fiction-time delta.
Same ISO + non-decreasing validations as the elision route. When
``notable_prose`` is non-empty, runs :func:`synthesize_memories`
once per present bot witness (host always; guest when present),
then writes one ``memory_written`` per synthesized memory via
:func:`record_turn_memory_for_present` (which fans out to host +
guest internally). Each call writes ``source="synthesized"`` so the
retrieval ranker can treat them as lower-reliability than direct
turn memories. Finally emits ``time_skip_jump`` and the narration
``assistant_turn``.
``reset_activity`` is parsed permissively ("1" / "true" / "on" /
"yes" — same shape as the add-guest reseed flag) since HTML
checkboxes typically post the literal "1" or omit the field
entirely.
Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip`
(T62 extracted the controller). ``reset_activity`` is parsed
permissively here ("1" / "true" / "on" / "yes" — same shape as the
add-guest reseed flag) since HTML checkboxes typically post the
literal "1" or omit the field entirely.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise HTTPException(
status_code=400,
detail=f"new_time must be ISO 8601, got {new_time!r}",
)
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise HTTPException(
status_code=400,
detail="new_time must not be earlier than the current chat clock",
)
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
you_name = you_entity.get("name") or "you"
guest_bot_id = chat.get("guest_bot_id")
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
settings = request.app.state.settings
# Emit time_skip_jump up front so the chat clock is at the new time
# before any memory writes — they should record at the post-jump
# clock, mirroring how a regular turn's memory carries the chat clock.
append_and_apply(
conn,
kind="time_skip_jump",
payload={
"chat_id": chat_id,
"new_time": new_time,
"reset_activity": reset_flag,
},
)
# Synthesize memories per present bot witness when prose is non-empty.
# ``synthesize_memories`` short-circuits on whitespace prose so this
# is safe to call unconditionally, but we gate the loop to avoid
# iterating a fixed empty list.
if notable_prose.strip():
present_bots: list[dict] = [host_bot]
if guest_bot is not None:
present_bots.append(guest_bot)
for bot in present_bots:
digest = await synthesize_memories(
client,
classifier_model=settings.classifier_model,
prose=notable_prose,
bot_name=bot.get("name") or "",
bot_persona=bot.get("persona") or "",
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
for mem in digest.memories:
# ``record_turn_memory_for_present`` writes one
# ``memory_written`` per present bot per call. Calling it
# once per synthesized memory means N memories x M bots
# = N*M events; the loop above already iterates by bot
# so we pass guest_bot_id=None here to avoid double-
# writing the guest's row when bot==guest.
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=bot["id"],
guest_bot_id=None,
narrative_text=mem.text,
chat_clock_at=new_time,
source="synthesized",
significance=mem.significance,
)
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="jump",
speaker_bot=host_bot,
you_name=you_name,
current_time=chat.get("time") or "",
new_time=new_time,
current_activity="",
landing_state_hint=notable_prose,
timeout_s=settings.classifier_timeout_s,
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": host_bot.get("id") or chat["host_bot_id"],
"text": narration,
"truncated": False,
},
)
try:
await process_jump_skip(
conn,
client,
settings,
chat_id=chat_id,
new_time=new_time,
notable_prose=notable_prose,
reset_activity=reset_flag,
)
except ValueError as exc:
if str(exc).startswith("chat not found"):
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=400, detail=str(exc))
return await drawer(chat_id, request, conn)
+287
View File
@@ -0,0 +1,287 @@
"""Shared skip-flow controllers (T62).
Both the drawer skip routes (T59) and the natural-language skip parse
(T62) call into these controllers. Keep the controllers free of HTTP
concerns — they take ``conn`` + ``client`` + ``settings`` and structured
args, append events, and return a small result dict the caller can map
to whatever response shape it owes (drawer partial, 204, 422, etc.).
``ValueError`` is the controller-level signal for caller-mappable
validation failure (bad ISO timestamp, backwards skip). The drawer
routes translate it to ``HTTP 400``; the natural-language path either
swallows it (the parser handed us a degenerate hint) or surfaces it the
same way. Anything else (LLM failure, unexpected exception) propagates
uncaught — :func:`narrate_skip` already has its own deterministic
fallback for the routine LLM-down case, so a real exception here means
something we want to see.
The two controllers mirror the drawer T59 logic closely so the v1
guarantees (``time_skip_*`` lands first → memory writes ride the
post-skip clock → narration ``assistant_turn`` is appended last) hold
identically across the two entry points.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlite3 import Connection
from chat.config import Settings
from chat.eventlog.log import append_and_apply, append_event
from chat.llm.client import LLMClient
from chat.services.memory_write import record_turn_memory_for_present
from chat.services.skip_narration import narrate_skip
from chat.services.synthesized_memories import synthesize_memories
from chat.state.entities import get_bot, get_you
from chat.state.world import get_activity, get_chat
def _parse_iso_time(value: str) -> datetime | None:
"""Permissive ISO 8601 parser shared with the drawer routes (T59).
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until
Python 3.11; we normalize it to ``+00:00`` so older interpreters
parse the same set of strings the drawer accepts.
"""
if not value:
return None
try:
v = value.strip()
if v.endswith("Z"):
v = v[:-1] + "+00:00"
return datetime.fromisoformat(v)
except (TypeError, ValueError):
return None
def _validate_new_time(chat: dict, new_time: str) -> None:
"""Raise ``ValueError`` if ``new_time`` is unparseable or backwards.
The drawer route maps the raised error to ``HTTP 400``; the
natural-language path may also surface it as a ``400``. Centralizing
the rule here means both entry points enforce the same invariant
(no causality-corrupting backwards jumps).
"""
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise ValueError(f"new_time must be ISO 8601, got {new_time!r}")
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise ValueError(
"new_time must not be earlier than the current chat clock"
)
async def process_elision_skip(
conn: Connection,
client: LLMClient,
settings: Settings,
*,
chat_id: str,
new_time: str,
landing_state_hint: str = "",
) -> dict:
"""Run an elision skip end-to-end.
Validates ``new_time`` against the current chat clock, appends a
``time_skip_elision`` event (chat clock advances), generates a
transition narration via :func:`narrate_skip`, and appends an
``assistant_turn`` carrying the narration. ``narrate_skip`` has its
own deterministic fallback so this never blocks on the model.
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
..., "assistant_event_id": ...}`` so callers can introspect the
generated turn (e.g. for SSE rebroadcast or test assertions).
Raises ``ValueError`` on validation failure or when the chat row
can't be located (the drawer maps it to ``HTTP 400`` / ``404``
respectively; the natural-language path follows the same shape).
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError(f"chat not found: {chat_id}")
_validate_new_time(chat, new_time)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
# The drawer route reaches into the host bot's current activity to
# surface the verb to the narration helper — we do the same so both
# entry points produce the same prose for the same chat state.
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
current_activity = (bot_activity.get("action") or {}).get("verb") or ""
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="elision",
speaker_bot=host_bot,
you_name=you_entity.get("name") or "you",
current_time=chat.get("time") or "",
new_time=new_time,
current_activity=current_activity,
landing_state_hint=landing_state_hint,
timeout_s=settings.classifier_timeout_s,
)
skip_event_id = append_and_apply(
conn,
kind="time_skip_elision",
payload={"chat_id": chat_id, "new_time": new_time},
)
speaker_id = host_bot.get("id") or chat["host_bot_id"]
assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_id,
"text": narration,
"truncated": False,
},
)
return {
"assistant_text": narration,
"speaker_id": speaker_id,
"skip_event_id": skip_event_id,
"assistant_event_id": assistant_event_id,
}
async def process_jump_skip(
conn: Connection,
client: LLMClient,
settings: Settings,
*,
chat_id: str,
new_time: str,
notable_prose: str = "",
reset_activity: bool = False,
) -> dict:
"""Run a jump skip end-to-end.
Same validations as :func:`process_elision_skip`. Emits
``time_skip_jump`` *before* synthesizing memories so per-bot writes
record the post-jump chat clock (mirroring how a regular turn's
memory carries the chat clock). When ``notable_prose`` is non-empty,
runs :func:`synthesize_memories` once per present bot witness, then
fans the resulting memories out via
:func:`record_turn_memory_for_present` with ``source="synthesized"``.
Finally appends the narration ``assistant_turn``.
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
..., "assistant_event_id": ...}``.
Raises ``ValueError`` on validation failure (caller maps to ``400``).
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError(f"chat not found: {chat_id}")
_validate_new_time(chat, new_time)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
you_name = you_entity.get("name") or "you"
guest_bot_id = chat.get("guest_bot_id")
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
# Emit time_skip_jump up front so subsequent memory writes ride the
# post-jump chat clock (matches the drawer T59 behavior pinned by
# test_post_skip_jump_with_notable_prose_writes_synthesized_memories).
skip_event_id = append_and_apply(
conn,
kind="time_skip_jump",
payload={
"chat_id": chat_id,
"new_time": new_time,
"reset_activity": reset_activity,
},
)
# Synthesize per-bot memories when prose is non-empty. The helper
# short-circuits on whitespace prose, but gating the loop here keeps
# the canned-LLM-queue accounting predictable for tests.
if notable_prose.strip():
present_bots: list[dict] = [host_bot]
if guest_bot is not None:
present_bots.append(guest_bot)
for bot in present_bots:
digest = await synthesize_memories(
client,
classifier_model=settings.classifier_model,
prose=notable_prose,
bot_name=bot.get("name") or "",
bot_persona=bot.get("persona") or "",
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
for mem in digest.memories:
# ``record_turn_memory_for_present`` writes one row per
# present bot per call — we already iterate by bot here,
# so guest_bot_id=None avoids double-writing the guest's
# row when bot==guest.
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=bot["id"],
guest_bot_id=None,
narrative_text=mem.text,
chat_clock_at=new_time,
source="synthesized",
significance=mem.significance,
)
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="jump",
speaker_bot=host_bot,
you_name=you_name,
current_time=chat.get("time") or "",
new_time=new_time,
current_activity="",
landing_state_hint=notable_prose,
timeout_s=settings.classifier_timeout_s,
)
speaker_id = host_bot.get("id") or chat["host_bot_id"]
assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_id,
"text": narration,
"truncated": False,
},
)
return {
"assistant_text": narration,
"speaker_id": speaker_id,
"skip_event_id": skip_event_id,
"assistant_event_id": assistant_event_id,
}
def _now_iso() -> str:
"""UTC ISO timestamp used by callers as a chat-clock fallback."""
return datetime.now(timezone.utc).isoformat()
__all__ = [
"process_elision_skip",
"process_jump_skip",
"_now_iso",
"_parse_iso_time",
]
+63 -1
View File
@@ -51,8 +51,10 @@ import html
import json
import re
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from chat.eventlog.log import append_and_apply, append_event
from chat.services.addressee import detect_addressee
@@ -75,6 +77,7 @@ from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
from chat.web.pubsub import publish
from chat.web.render import render_turn_html as _render_turn_html
from chat.web.skip import _parse_iso_time, process_elision_skip
router = APIRouter()
@@ -254,6 +257,65 @@ async def post_turn(
)
prompt_prose = _strip_ooc_for_prompt(parsed)
# 1a. Skip-command short-circuit (T62). The parser may classify the
# prose as a time-skip directive — in which case the regular
# narrative path (addressee detection, narrative stream, post-turn
# state-update + scene-close passes) is skipped entirely. Elision
# runs through the shared controller in :mod:`chat.web.skip`; jump
# is drawer-only for Phase 3 (the natural-language path returns
# 422 directing the user to the drawer's jump form, where they can
# supply structured ``notable_prose`` and a target time). Anything
# not matching these intents falls through to the narrative branch.
intent = getattr(parsed, "intent", "narrative") or "narrative"
if intent == "skip_jump":
# Drawer-only jump for Phase 3: parsing a free-form fiction-time
# delta out of natural language ("next morning" -> ?) is fragile
# enough that we'd rather route the user to the drawer form,
# where they pick a concrete ISO time and an optional notable-
# prose field. 422 = "request shape is understood, but the
# required structured input lives on a different surface".
return JSONResponse(
{
"error": (
"Jump skip requires the drawer's jump form for "
"notable_prose."
)
},
status_code=422,
)
if intent == "skip_elision":
# Derive ``new_time`` from the chat clock. Phase 3 stub: bump by
# 1 hour. The drawer's elision form is the structured path when
# the author wants a specific landing time; here the goal is
# "elide the dull bit" and any sensible forward step is fine —
# ``narrate_skip`` weaves the landing-state hint into the
# transition prose so the prose carries the semantic time, not
# the timestamp itself.
cur_dt = _parse_iso_time(chat.get("time") or "")
new_time = (
(cur_dt + timedelta(hours=1)).isoformat()
if cur_dt is not None
else (chat.get("time") or "")
)
try:
await process_elision_skip(
conn,
client,
settings,
chat_id=chat_id,
new_time=new_time,
landing_state_hint=getattr(parsed, "landing_state_hint", "")
or "",
)
except ValueError as exc:
# The controller raises on missing chat / bad new_time.
# Missing chat is already handled above (we'd have 404'd);
# a bad new_time here is a stub-derivation bug rather than
# user input — surface as 400 with the controller message.
raise HTTPException(status_code=400, detail=str(exc))
return Response(status_code=204)
# 2. Append user_turn event.
user_turn_event_id = append_event(
conn,
+180
View File
@@ -1137,3 +1137,183 @@ def test_turn_with_no_active_events_skips_classifier(app_state_setup, tmp_path):
"SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,)
).fetchone()[0]
assert count == 0, f"expected zero {kind} events, got {count}"
# ---------------------------------------------------------------------------
# Phase 3 (T62) — natural-language skip-command surface.
#
# The classifier may flag prose as a time-skip directive via
# ``ParsedTurn.intent``. Elision runs through the shared controller in
# :mod:`chat.web.skip` and short-circuits the regular narrative path;
# jump returns 422 directing the user to the drawer's structured form
# (Phase 3 simpler path — natural-language jump time derivation is too
# fragile for v1 without the structured surface).
# ---------------------------------------------------------------------------
def test_elision_skip_via_natural_language(app_state_setup, tmp_path):
"""User prose 'skip to when we arrive at the park' classifies as
``intent='skip_elision'``. The post_turn handler short-circuits the
narrative path, advances the chat clock by an hour stub, appends a
``time_skip_elision`` event AND an ``assistant_turn`` carrying the
canned narration. No ``user_turn`` is emitted on the skip path.
Canned queue: 1 parse_turn (intent=skip_elision) + 1 narration
string (consumed by ``narrate_skip``). No state-update / scene-close
/ event-detection slots — those branches are bypassed entirely.
"""
_seed(tmp_path / "test.db")
canned_parse = json.dumps(
{
"segments": [
{"kind": "dialogue", "text": "skip to when we arrive at the park"}
],
"intent": "skip_elision",
"landing_state_hint": "we arrive at the park",
}
)
canned_narration = "We pull up to the park entrance, sun low in the sky."
mock = _override_llm([canned_parse, canned_narration])
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns",
data={"prose": "skip to when we arrive at the park"},
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
# Both canned slots drained — no other classifier branches ran.
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
# time_skip_elision landed.
skip_rows = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'time_skip_elision' ORDER BY id"
).fetchall()
assert len(skip_rows) == 1
sp = json.loads(skip_rows[0][0])
assert sp["chat_id"] == "chat_bot_a"
# 1-hour stub from the seeded chat clock (20:00 -> 21:00).
assert sp["new_time"].startswith("2026-04-26T21:00:00")
# Chat clock advanced via the projector.
from chat.state.world import get_chat
chat = get_chat(conn, "chat_bot_a")
assert chat["time"].startswith("2026-04-26T21:00:00")
# An assistant_turn carrying the canned narration was appended.
turn_rows = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'assistant_turn' ORDER BY id"
).fetchall()
assert len(turn_rows) == 1
tp = json.loads(turn_rows[0][0])
assert tp["chat_id"] == "chat_bot_a"
assert tp["text"] == canned_narration
assert tp["speaker_id"] == "bot_a"
assert tp["truncated"] is False
# No user_turn lands on the skip path — the natural-language
# skip is a command, not a beat the bots should remember.
user_count = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'user_turn'"
).fetchone()[0]
assert user_count == 0
def test_jump_skip_via_natural_language_returns_422(app_state_setup, tmp_path):
"""User prose 'next morning' classifies as ``intent='skip_jump'``.
The handler returns 422 with a guidance payload pointing the author
at the drawer's structured jump form. No event is emitted — the
drawer form is the only entry point for jump skips in Phase 3.
"""
_seed(tmp_path / "test.db")
canned_parse = json.dumps(
{
"segments": [{"kind": "dialogue", "text": "next morning"}],
"intent": "skip_jump",
"landing_state_hint": "",
}
)
# Only one canned slot — parse — because the 422 fallback short-
# circuits before any other classifier runs.
mock = _override_llm([canned_parse])
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns", data={"prose": "next morning"}
)
assert response.status_code == 422
body = response.json()
# Guidance payload mentions the drawer so the client can surface
# the right CTA; we don't pin the exact wording.
assert "drawer" in body.get("error", "").lower()
finally:
app.dependency_overrides.clear()
# Parse slot consumed; no follow-on classifier calls.
assert mock._canned == []
with open_db(tmp_path / "test.db") as conn:
for kind in (
"user_turn",
"assistant_turn",
"time_skip_elision",
"time_skip_jump",
):
count = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,)
).fetchone()[0]
assert count == 0, f"expected zero {kind} on jump-via-NL, got {count}"
def test_skip_command_does_not_run_narrative_classifier(
app_state_setup, tmp_path, monkeypatch
):
"""The skip dispatch branch must bypass the narrative-prompt assembly
entirely. We monkeypatch ``assemble_narrative_prompt`` (re-bound on
the ``chat.web.turns`` module since the handler imports it by name)
and assert the call count is zero after the elision skip lands.
"""
_seed(tmp_path / "test.db")
canned_parse = json.dumps(
{
"segments": [
{"kind": "dialogue", "text": "skip to when we arrive at the park"}
],
"intent": "skip_elision",
"landing_state_hint": "we arrive at the park",
}
)
canned_narration = "We arrive moments later."
mock = _override_llm([canned_parse, canned_narration])
call_counter = {"n": 0}
def _spy(*args, **kwargs):
call_counter["n"] += 1
return []
# Patch the symbol at the handler's import site so we can assert
# the skip path bypasses prompt assembly even when the symbol still
# exists in the module namespace.
from chat.web import turns as turns_mod
monkeypatch.setattr(turns_mod, "assemble_narrative_prompt", _spy)
try:
response = app_state_setup.post(
"/chats/chat_bot_a/turns",
data={"prose": "skip to when we arrive at the park"},
)
assert response.status_code == 204
finally:
app.dependency_overrides.clear()
assert mock._canned == []
assert call_counter["n"] == 0, (
"assemble_narrative_prompt was called on the skip path; the "
"natural-language skip dispatch must bypass narrative assembly."
)