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
|
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
|
||||||
here: the classifier figures OOC out from ``((`` ``))`` regardless of
|
here: the classifier figures OOC out from ``((`` ``))`` regardless of
|
||||||
config-time choice; marker-based stripping is a downstream concern.
|
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
|
from __future__ import annotations
|
||||||
@@ -39,9 +49,19 @@ class TurnSegment(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ParsedTurn(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]
|
segments: list[TurnSegment]
|
||||||
|
intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump"
|
||||||
|
landing_state_hint: str = ""
|
||||||
|
|
||||||
|
|
||||||
_SYSTEM_PROMPT = (
|
_SYSTEM_PROMPT = (
|
||||||
@@ -52,13 +72,24 @@ _SYSTEM_PROMPT = (
|
|||||||
"- ((text in double parens)) is an OOC (out-of-character) segment — "
|
"- ((text in double parens)) is an OOC (out-of-character) segment — "
|
||||||
"the author talking to the system, not the in-fiction bot.\n\n"
|
"the author talking to the system, not the in-fiction bot.\n\n"
|
||||||
"Output a JSON object with shape "
|
"Output a JSON object with shape "
|
||||||
'{"segments": [{"kind": "...", "text": "..."}, ...]} '
|
'{"segments": [{"kind": "...", "text": "..."}, ...], '
|
||||||
"where each ``kind`` is exactly one of: dialogue, action, ooc. "
|
'"intent": "...", "landing_state_hint": "..."} '
|
||||||
"Preserve the original substring text as ``text``: do not rewrite, "
|
"where each segment ``kind`` is exactly one of: dialogue, action, "
|
||||||
"translate, or normalize punctuation — strip only the marker "
|
"ooc. Preserve the original substring text as ``text``: do not "
|
||||||
"characters (asterisks, surrounding quotes, double parens) so "
|
"rewrite, translate, or normalize punctuation — strip only the "
|
||||||
"``text`` is the inner content. Emit segments in the order they "
|
"marker characters (asterisks, surrounding quotes, double parens) "
|
||||||
"appear in the input."
|
"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 json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from chat.eventlog.log import append_and_apply, append_event
|
from chat.eventlog.log import append_and_apply
|
||||||
from chat.services.memory_write import record_turn_memory_for_present
|
|
||||||
from chat.services.relationship_seed import seed_inter_bot_edges
|
from chat.services.relationship_seed import seed_inter_bot_edges
|
||||||
from chat.services.scene_summarize import apply_scene_close_summary
|
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.edges import get_edge
|
||||||
from chat.state.entities import get_bot, get_you, list_bots
|
from chat.state.entities import get_bot, get_you, list_bots
|
||||||
from chat.state.events import list_active_events
|
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.state.world import active_scene, get_activity, get_chat, get_container
|
||||||
from chat.web.bots import get_conn
|
from chat.web.bots import get_conn
|
||||||
from chat.web.kickoff import get_llm_client
|
from chat.web.kickoff import get_llm_client
|
||||||
|
from chat.web.skip import (
|
||||||
|
_now_iso,
|
||||||
|
process_elision_skip,
|
||||||
|
process_jump_skip,
|
||||||
|
)
|
||||||
|
|
||||||
TEMPLATES = Jinja2Templates(
|
TEMPLATES = Jinja2Templates(
|
||||||
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
directory=str(Path(__file__).resolve().parent.parent / "templates")
|
||||||
@@ -881,29 +882,6 @@ async def remove_guest(
|
|||||||
# rerenders itself on these submissions).
|
# 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(
|
@router.post(
|
||||||
"/chats/{chat_id}/drawer/event/plan",
|
"/chats/{chat_id}/drawer/event/plan",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
@@ -1000,70 +978,29 @@ async def skip_elision(
|
|||||||
):
|
):
|
||||||
"""Elision skip: collapse in-progress activity into its end-state.
|
"""Elision skip: collapse in-progress activity into its end-state.
|
||||||
|
|
||||||
Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the
|
Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip`
|
||||||
chat clock (a backwards skip would corrupt downstream causality).
|
(T62 extracted the controller). Validation failures surface as
|
||||||
Emits ``time_skip_elision`` first (chat clock advances) then an
|
``400`` and the route still returns the refreshed drawer partial on
|
||||||
``assistant_turn`` carrying the narrated transition from
|
success so HTMX swaps in the new chat clock.
|
||||||
: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.
|
|
||||||
"""
|
"""
|
||||||
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
|
settings = request.app.state.settings
|
||||||
narration = await narrate_skip(
|
try:
|
||||||
client,
|
await process_elision_skip(
|
||||||
narrative_model=settings.narrative_model,
|
conn,
|
||||||
skip_kind="elision",
|
client,
|
||||||
speaker_bot=host_bot,
|
settings,
|
||||||
you_name=you_entity.get("name") or "you",
|
chat_id=chat_id,
|
||||||
current_time=chat.get("time") or "",
|
new_time=new_time,
|
||||||
new_time=new_time,
|
landing_state_hint=landing_state_hint,
|
||||||
current_activity=current_activity,
|
)
|
||||||
landing_state_hint=landing_state_hint,
|
except ValueError as exc:
|
||||||
timeout_s=settings.classifier_timeout_s,
|
# ``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
|
||||||
append_and_apply(
|
# error message so existing tests keep passing without changes.
|
||||||
conn,
|
if str(exc).startswith("chat not found"):
|
||||||
kind="time_skip_elision",
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
payload={"chat_id": chat_id, "new_time": new_time},
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
)
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return await drawer(chat_id, request, conn)
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
@@ -1082,123 +1019,28 @@ async def skip_jump(
|
|||||||
):
|
):
|
||||||
"""Jump skip: bridge a longer fiction-time delta.
|
"""Jump skip: bridge a longer fiction-time delta.
|
||||||
|
|
||||||
Same ISO + non-decreasing validations as the elision route. When
|
Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip`
|
||||||
``notable_prose`` is non-empty, runs :func:`synthesize_memories`
|
(T62 extracted the controller). ``reset_activity`` is parsed
|
||||||
once per present bot witness (host always; guest when present),
|
permissively here ("1" / "true" / "on" / "yes" — same shape as the
|
||||||
then writes one ``memory_written`` per synthesized memory via
|
add-guest reseed flag) since HTML checkboxes typically post the
|
||||||
:func:`record_turn_memory_for_present` (which fans out to host +
|
literal "1" or omit the field entirely.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
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")
|
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
|
settings = request.app.state.settings
|
||||||
|
try:
|
||||||
# Emit time_skip_jump up front so the chat clock is at the new time
|
await process_jump_skip(
|
||||||
# before any memory writes — they should record at the post-jump
|
conn,
|
||||||
# clock, mirroring how a regular turn's memory carries the chat clock.
|
client,
|
||||||
append_and_apply(
|
settings,
|
||||||
conn,
|
chat_id=chat_id,
|
||||||
kind="time_skip_jump",
|
new_time=new_time,
|
||||||
payload={
|
notable_prose=notable_prose,
|
||||||
"chat_id": chat_id,
|
reset_activity=reset_flag,
|
||||||
"new_time": new_time,
|
)
|
||||||
"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))
|
||||||
# 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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return await drawer(chat_id, request, conn)
|
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 json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
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.eventlog.log import append_and_apply, append_event
|
||||||
from chat.services.addressee import detect_addressee
|
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.kickoff import get_llm_client
|
||||||
from chat.web.pubsub import publish
|
from chat.web.pubsub import publish
|
||||||
from chat.web.render import render_turn_html as _render_turn_html
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -254,6 +257,65 @@ async def post_turn(
|
|||||||
)
|
)
|
||||||
prompt_prose = _strip_ooc_for_prompt(parsed)
|
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.
|
# 2. Append user_turn event.
|
||||||
user_turn_event_id = append_event(
|
user_turn_event_id = append_event(
|
||||||
conn,
|
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,)
|
"SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert count == 0, f"expected zero {kind} events, got {count}"
|
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