"""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", ]