303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""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
|
|
|
|
|
|
class ChatNotFoundError(Exception):
|
|
"""Raised when a ``chat_id`` doesn't resolve to a chat row.
|
|
|
|
Distinguishes the missing-chat case from generic input-validation
|
|
failures (which still raise :class:`ValueError`). HTTP callers map
|
|
this to ``404`` and ``ValueError`` to ``400`` — replacing the
|
|
earlier ``str(exc).startswith("chat not found")`` prefix sniff
|
|
(T81) with a typed dispatch.
|
|
"""
|
|
|
|
|
|
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 :class:`ChatNotFoundError` when the chat row is missing
|
|
(HTTP ``404``) and ``ValueError`` on input-validation failure
|
|
(HTTP ``400``). Splitting the two lets the drawer route dispatch
|
|
on type instead of sniffing the error string (T81).
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise ChatNotFoundError(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 :class:`ChatNotFoundError` on missing chat (caller maps to
|
|
``404``) and ``ValueError`` on input-validation failure (caller maps
|
|
to ``400``).
|
|
"""
|
|
chat = get_chat(conn, chat_id)
|
|
if chat is None:
|
|
raise ChatNotFoundError(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__ = [
|
|
"ChatNotFoundError",
|
|
"process_elision_skip",
|
|
"process_jump_skip",
|
|
"_now_iso",
|
|
"_parse_iso_time",
|
|
]
|