feat: skip narration service (T53)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user