From c2144cd9df8eae6bf4a89c7e6b0b66d1de08e76d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:10:42 -0400 Subject: [PATCH] feat: skip narration service (T53) --- chat/services/skip_narration.py | 131 ++++++++++++++++++++++++++++++++ tests/test_skip_narration.py | 117 ++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 chat/services/skip_narration.py create mode 100644 tests/test_skip_narration.py diff --git a/chat/services/skip_narration.py b/chat/services/skip_narration.py new file mode 100644 index 0000000..4590a7b --- /dev/null +++ b/chat/services/skip_narration.py @@ -0,0 +1,131 @@ +"""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, + ) + 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"] diff --git a/tests/test_skip_narration.py b/tests/test_skip_narration.py new file mode 100644 index 0000000..577ddee --- /dev/null +++ b/tests/test_skip_narration.py @@ -0,0 +1,117 @@ +"""Skip narration service tests (T53). + +The skip-narration service generates short transition prose between an +in-progress moment and a post-skip moment. Two flavors: + +* ``elision`` — collapses an in-progress activity to its expected + end-state in 1-2 sentences (e.g. "skip ahead to when we arrive"). +* ``jump`` — bridges a longer fiction-time delta in 2-3 sentences + (e.g. "next morning", "a week later"). + +Output is free-form prose, not structured JSON, so the service goes +through ``client.generate`` directly rather than the classifier path. +A deterministic template fallback fires on any LLM failure so the skip +flow never blocks even when the model is down. +""" + +from __future__ import annotations + +from typing import AsyncIterator, Sequence + +import pytest + +from chat.llm.client import Message +from chat.llm.mock import MockLLMClient +from chat.services.skip_narration import narrate_skip + + +_SPEAKER = { + "id": "bot1", + "name": "Aria", + "persona": "thoughtful, observant", +} + + +@pytest.mark.asyncio +async def test_narrate_elision_returns_classifier_output(): + canned = ( + "She closes her laptop and slings her bag over her shoulder. " + "The office shrinks behind her as she steps into the late " + "afternoon light." + ) + mock = MockLLMClient(canned=[canned]) + result = await narrate_skip( + mock, + narrative_model="x", + skip_kind="elision", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="3:42 PM", + new_time="5:10 PM", + current_activity="finishing up at her desk", + landing_state_hint="walking out into the parking lot", + ) + assert "office" in result or result == canned + + +@pytest.mark.asyncio +async def test_narrate_jump_returns_classifier_output(): + canned = ( + "Morning light spills through the kitchen window. The coffee " + "maker hums. She's already at the table, scrolling her phone." + ) + mock = MockLLMClient(canned=[canned]) + result = await narrate_skip( + mock, + narrative_model="x", + skip_kind="jump", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="late evening", + new_time="next morning", + current_activity="winding down for the night", + landing_state_hint="having coffee in the kitchen", + ) + assert result + lower = result.lower() + assert "morning" in lower or "coffee" in lower + + +class _RaisingMock: + """Mock LLMClient whose ``generate`` always raises. + + ``MockLLMClient.generate`` raises ``IndexError`` once the canned + list is empty, but the test wants a clear, unambiguous failure + regardless of canned-list state, so we ship a tiny dedicated mock + instead. + """ + + async def generate( + self, messages: Sequence[Message], *, model: str, **params + ) -> str: + raise RuntimeError("LLM is down") + + async def stream( + self, messages: Sequence[Message], *, model: str, **params + ) -> AsyncIterator[str]: + raise RuntimeError("LLM is down") + yield # pragma: no cover - make this a generator + + +@pytest.mark.asyncio +async def test_narrate_falls_back_on_generation_failure(): + new_time = "next morning" + result = await narrate_skip( + _RaisingMock(), + narrative_model="x", + skip_kind="jump", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="late evening", + new_time=new_time, + current_activity="winding down for the night", + landing_state_hint="having coffee in the kitchen", + ) + # Fallback template includes the new_time so callers can see *what* + # we skipped to even when the LLM never answered. + assert new_time in result