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
+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."
)