merge: T53 skip narration service

This commit is contained in:
Joseph Doherty
2026-04-26 20:12:12 -04:00
2 changed files with 248 additions and 0 deletions
+131
View File
@@ -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: ``(<new_time>:
<detail>.)``. 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"]
+117
View File
@@ -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