feat: scene close on hard signals with manual override

This commit is contained in:
Joseph Doherty
2026-04-26 13:46:14 -04:00
parent db3005fc17
commit 0997562e75
7 changed files with 482 additions and 1 deletions
+100
View File
@@ -0,0 +1,100 @@
"""Scene-close hard-signal detection (T26).
A small classifier service that decides whether the user's prose narrates
a hard signal that should close the active scene. Hard signals (per
Requirements §7.2):
* Container change parsed from prose ("we drove to the park", "we stepped
outside").
* Explicit user pattern signaling end ("we're done here", "fade out",
"scene end").
NOT close signals:
* Brief activity changes within the same container ("I sit down").
* Future plans ("let's go to the park later").
The service returns a :class:`SceneCloseDecision`. The default on classifier
failure is ``should_close=False`` so the turn flow keeps moving — closing
on a misfire would be more disruptive than missing a real signal, and the
manual button in the drawer is always available as a fallback.
Phase 2/3 will introduce automatic re-opening with the new container; for
T26 the close is one-way and the next user turn operates without an active
scene (the prompt assembler already tolerates this).
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class SceneCloseDecision(BaseModel):
"""Classifier verdict for scene-close detection.
``new_container_hint`` is captured opportunistically when the close
signal is a container change, but T26 doesn't act on it — Phase 2/3
handles automatic re-opening at the new location.
"""
should_close: bool = False
reason: str = ""
new_container_hint: str = ""
_SYSTEM = (
"You decide whether a roleplay scene should close based on the user's "
"prose.\n"
"Close signals (return should_close=true):\n"
"- The prose narrates a CONTAINER CHANGE (moving to a different place, "
'e.g. "we drove to the park", "we stepped outside").\n'
"- The prose has an EXPLICIT USER PATTERN signaling end "
'("we\'re done here", "fade out", "scene end").\n'
"\n"
"DO NOT close on:\n"
"- Brief activity changes within the same place "
'(e.g. "I sit down" — same room).\n'
"- Future plans "
'("let\'s go to the park later" — not yet).\n'
"\n"
'Reply JSON: {"should_close": bool, "reason": str (short), '
'"new_container_hint": str (optional name)}.'
)
async def detect_scene_close(
client: LLMClient,
*,
model: str,
prose: str,
current_container_name: str,
timeout_s: float = 10.0,
) -> SceneCloseDecision:
"""Run the scene-close classifier on a single user turn.
The current container name is passed in so the prompt can reason about
"different place" relative to the active scene rather than guessing.
On classifier failure (parse error twice), the returned decision is the
safe ``should_close=False`` default.
"""
user = (
f"CURRENT CONTAINER: {current_container_name}\n"
f"\n"
f"PROSE:\n{prose}\n"
f"\n"
f"Decide whether to close the scene."
)
return await classify(
client,
model=model,
system=_SYSTEM,
user=user,
schema=SceneCloseDecision,
default=SceneCloseDecision(
should_close=False, reason="fallback", new_container_hint=""
),
timeout_s=timeout_s,
)