feat: turn input parser via classifier
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""Turn input parser.
|
||||
|
||||
Service-layer function that splits a user's authored turn into typed
|
||||
segments — ``dialogue``, ``action``, or ``ooc`` (out-of-character).
|
||||
|
||||
Per Requirements §6.1 a turn is mixed prose with three conventions:
|
||||
|
||||
- ``*action*`` (single asterisks around prose) → action segment.
|
||||
- Quoted text, or bare prose between the conventions → dialogue.
|
||||
- ``((double parens))`` → OOC, the author talking to the system rather
|
||||
than the bot. Downstream (T19) strips OOC from the prompt sent to the
|
||||
bot but keeps it in the transcript display.
|
||||
|
||||
A regex-based splitter would brittle on edge cases (unclosed asterisks,
|
||||
nested quotes, mixed punctuation), so v1 delegates the segmentation to
|
||||
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
|
||||
here: the classifier figures OOC out from ``((`` ``))`` regardless of
|
||||
config-time choice; marker-based stripping is a downstream concern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class TurnSegment(BaseModel):
|
||||
"""One classified piece of a turn.
|
||||
|
||||
``kind`` is kept as a plain ``str`` (not a ``Literal``) so an
|
||||
unexpected classifier output doesn't crash parsing — callers that
|
||||
care about specific values can check defensively.
|
||||
"""
|
||||
|
||||
kind: str # "dialogue" | "action" | "ooc"
|
||||
text: str
|
||||
|
||||
|
||||
class ParsedTurn(BaseModel):
|
||||
"""A turn split into ordered, typed segments."""
|
||||
|
||||
segments: list[TurnSegment]
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are splitting a roleplay turn into typed segments. The input "
|
||||
"is mixed prose with three conventions:\n"
|
||||
"- *text in single asterisks* is an ACTION segment.\n"
|
||||
"- \"quoted text\" or bare prose between conventions is a DIALOGUE segment.\n"
|
||||
"- ((text in double parens)) is an OOC (out-of-character) segment — "
|
||||
"the author talking to the system, not the in-fiction bot.\n\n"
|
||||
"Output a JSON object with shape "
|
||||
'{"segments": [{"kind": "...", "text": "..."}, ...]} '
|
||||
"where each ``kind`` is exactly one of: dialogue, action, ooc. "
|
||||
"Preserve the original substring text as ``text``: do not rewrite, "
|
||||
"translate, or normalize punctuation — strip only the marker "
|
||||
"characters (asterisks, surrounding quotes, double parens) so "
|
||||
"``text`` is the inner content. Emit segments in the order they "
|
||||
"appear in the input."
|
||||
)
|
||||
|
||||
|
||||
async def parse_turn(
|
||||
client: LLMClient,
|
||||
*,
|
||||
model: str,
|
||||
prose: str,
|
||||
timeout_s: float = 10.0,
|
||||
) -> ParsedTurn:
|
||||
"""Parse a user turn into typed segments.
|
||||
|
||||
Calls :func:`chat.llm.classify.classify` under the hood. Empty or
|
||||
whitespace-only prose short-circuits to an empty ``ParsedTurn``
|
||||
without an LLM call (the classifier would error on empty input
|
||||
anyway, and the result is unambiguous).
|
||||
|
||||
Raises ``RuntimeError`` if the classifier fails twice — no default
|
||||
is supplied, since the caller (T19's turn flow) is responsible for
|
||||
surfacing the error to the user.
|
||||
"""
|
||||
if not prose.strip():
|
||||
return ParsedTurn(segments=[])
|
||||
|
||||
user_prompt = f"INPUT:\n{prose}"
|
||||
return await classify(
|
||||
client,
|
||||
model=model,
|
||||
system=_SYSTEM_PROMPT,
|
||||
user=user_prompt,
|
||||
schema=ParsedTurn,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
Reference in New Issue
Block a user