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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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