diff --git a/chat/services/turn_parse.py b/chat/services/turn_parse.py index df721dc..9c1e778 100644 --- a/chat/services/turn_parse.py +++ b/chat/services/turn_parse.py @@ -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``." ) diff --git a/chat/web/drawer.py b/chat/web/drawer.py index ea27721..1098eb5 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -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) diff --git a/chat/web/skip.py b/chat/web/skip.py new file mode 100644 index 0000000..ccbf470 --- /dev/null +++ b/chat/web/skip.py @@ -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", +] diff --git a/chat/web/turns.py b/chat/web/turns.py index 5ef6725..5d2ff94 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -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, diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index a202e2d..80364ec 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -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." + )