diff --git a/chat/services/turn_parse.py b/chat/services/turn_parse.py new file mode 100644 index 0000000..df721dc --- /dev/null +++ b/chat/services/turn_parse.py @@ -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, + ) diff --git a/tests/test_turn_parse.py b/tests/test_turn_parse.py new file mode 100644 index 0000000..3246bd4 --- /dev/null +++ b/tests/test_turn_parse.py @@ -0,0 +1,83 @@ +import json + +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.turn_parse import ( + ParsedTurn, + TurnSegment, + parse_turn, +) + + +@pytest.mark.asyncio +async def test_parse_turn_three_segment_happy_path(): + canned = json.dumps( + { + "segments": [ + {"kind": "action", "text": "walks over"}, + {"kind": "dialogue", "text": "Hey."}, + {"kind": "ooc", "text": "player note"}, + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await parse_turn( + mock, + model="m", + prose='*walks over* "Hey." ((player note))', + ) + assert isinstance(result, ParsedTurn) + assert len(result.segments) == 3 + kinds = [s.kind for s in result.segments] + assert kinds == ["action", "dialogue", "ooc"] + texts = [s.text for s in result.segments] + assert texts == ["walks over", "Hey.", "player note"] + assert all(isinstance(s, TurnSegment) for s in result.segments) + + +@pytest.mark.asyncio +async def test_parse_turn_pure_dialogue_single_segment(): + canned = json.dumps( + { + "segments": [ + {"kind": "dialogue", "text": "Hello there"}, + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await parse_turn( + mock, + model="m", + prose='"Hello there"', + ) + assert isinstance(result, ParsedTurn) + assert len(result.segments) == 1 + assert result.segments[0].kind == "dialogue" + assert result.segments[0].text == "Hello there" + + +@pytest.mark.asyncio +async def test_parse_turn_empty_prose_short_circuits_without_classifier_call(): + # No canned responses provided — if classify() is invoked it will raise + # IndexError on the empty list. The short-circuit must prevent that. + mock = MockLLMClient(canned=[]) + result = await parse_turn(mock, model="m", prose="") + assert isinstance(result, ParsedTurn) + assert result.segments == [] + + # Whitespace-only prose must also short-circuit. + result_ws = await parse_turn(mock, model="m", prose=" \n\t ") + assert isinstance(result_ws, ParsedTurn) + assert result_ws.segments == [] + + +@pytest.mark.asyncio +async def test_parse_turn_raises_when_classifier_fails_twice(): + mock = MockLLMClient(canned=["nope", "still nope"]) + with pytest.raises(RuntimeError): + await parse_turn( + mock, + model="m", + prose='*shrugs* "whatever"', + )