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