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:
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user