"""Skip narration service (T53). Generates brief transition prose for elision and jump skips. Skips come in two flavors that read very differently: * **Elision** — collapses an in-progress activity into its expected end-state in 1-2 sentences, narrated from the speaker bot's POV. Example: "skip ahead to when we arrive" while the characters are driving — output describes pulling into the lot. * **Jump** — bridges a longer fiction-time delta ("next morning", "a week later") in 2-3 sentences, setting the scene at the new time. Output is free-form prose, not structured JSON, so this service calls ``client.generate`` directly rather than going through the classifier path used by, e.g., :mod:`chat.services.scene_summarize`. A deterministic template fallback fires on any LLM failure so the skip flow keeps moving even when the model is down — important because skips are a UI-blocking operation; we'd rather show a parenthetical sentence than hang the chat indefinitely. """ from __future__ import annotations from chat.llm.client import LLMClient, Message _ELISION_SYSTEM = ( "You write a brief 1-2 sentence transition that elides the time " "between an in-progress activity and its expected end-state, " "narrated from the speaker's POV. Keep it grounded and concrete. " "Do not invent new events or characters." ) _JUMP_SYSTEM = ( "You write a brief 2-3 sentence transition narrating a jump in " "fiction time (e.g., 'next morning', 'a week later'), narrated " "from the speaker's POV. Set the scene at the new time. Keep it " "grounded — no invented major events. If a landing-state hint is " "provided, weave it in naturally." ) async def narrate_skip( client: LLMClient, *, narrative_model: str, skip_kind: str, speaker_bot: dict, you_name: str, current_time: str, new_time: str, current_activity: str, landing_state_hint: str = "", timeout_s: float = 60.0, ) -> str: """Generate brief transition prose for a time skip. ``skip_kind`` is ``"elision"`` or ``"jump"``; any other value short- circuits to the deterministic fallback (defensive — callers shouldn't be inventing new kinds without updating this service). Returns plain text. Never raises: any LLM error, an empty/blank result, or an unknown ``skip_kind`` falls back to a parenthetical template like ``"(next morning: having coffee in the kitchen.)"`` so the skip UI always has *something* to render. """ fallback = _build_fallback( skip_kind=skip_kind, new_time=new_time, current_activity=current_activity, landing_state_hint=landing_state_hint, ) if skip_kind not in ("elision", "jump"): return fallback system = _ELISION_SYSTEM if skip_kind == "elision" else _JUMP_SYSTEM user = ( f"Speaker: {speaker_bot.get('name', 'speaker')}\n" f"Persona: {speaker_bot.get('persona', '')}\n" f"Other party: {you_name}\n" f"Current time: {current_time}\n" f"New time: {new_time}\n" f"Current activity: {current_activity}\n" ) if landing_state_hint: user += f"Landing state hint: {landing_state_hint}\n" try: result = await client.generate( [ Message(role="system", content=system), Message(role="user", content=user), ], model=narrative_model, max_tokens=200, temperature=0.7, timeout_s=timeout_s, ) text = (result or "").strip() if not text: return fallback return text except Exception: # Any failure — network blip, timeout, mock raising in tests — # collapses to the deterministic template so the skip pipeline # is never blocked on the LLM being available. return fallback def _build_fallback( *, skip_kind: str, new_time: str, current_activity: str, landing_state_hint: str, ) -> str: """Deterministic parenthetical narration used when the LLM fails. Both flavors render the same shape today: ``(: .)``. They're separated as branches to make it easy to diverge later (e.g. an elision-specific template) without churning the call site or the public signature. """ detail = landing_state_hint or current_activity or "moments later" if skip_kind == "elision": return f"({new_time}: {detail}.)" return f"({new_time}: {detail}.)" __all__ = ["narrate_skip"]