diff --git a/chat/services/__init__.py b/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/services/kickoff.py b/chat/services/kickoff.py new file mode 100644 index 0000000..591b09c --- /dev/null +++ b/chat/services/kickoff.py @@ -0,0 +1,121 @@ +"""Kickoff prose parser. + +Service-layer function that converts a bot's authored kickoff prose into a +structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will +wire this into the UI flow). + +The classifier prompt includes only the bot context that's load-bearing for +parsing the opening scene: name, persona, the authored +``initial_relationship_to_you`` blurb, the ``you`` entity name, and the +kickoff prose itself. Other identity fields (traits, backstory, ...) are +intentionally left out โ€” they would be noise for this extraction. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class ActivityShape(BaseModel): + """Per-entity activity at scene start. + + Maps onto Requirements ยง6.5: ``current_action.{verb,interruptible, + required_attention,expected_duration}`` plus posture, attention, holding. + ``action_required_attention`` is left as a free-form string ("low" / + "medium" / "high" expected) rather than a Literal so the classifier has + room to vary phrasing in v1. + """ + + posture: str + action_verb: str + action_interruptible: bool + action_required_attention: str # low | medium | high + action_expected_duration: str + attention: str = "" + holding: list[str] = Field(default_factory=list) + + +class KickoffParse(BaseModel): + """Structured opening-scene state extracted from kickoff prose. + + ``container_properties`` is loose ``dict``: the classifier may emit + ``moving`` / ``public`` / ``audible_range`` keys, but downstream + consumers (T13's confirm form) handle missing keys gracefully. + ``initial_time_iso`` is stored as text โ€” not validated as a datetime + here; ``chat_state.time`` stores it as a plain string. + """ + + container_name: str + container_type: str + container_properties: dict + you_activity: ActivityShape + bot_activity: ActivityShape + initial_time_iso: str + edge_seed_summary: str + edge_seed_knowledge_facts: list[str] + + +_SYSTEM_PROMPT = ( + "You are extracting structured scene state from a roleplay kickoff " + "scene description. The user provides bot context and a prose " + "description of the opening scene; you output JSON conforming to the " + "schema. Be concrete: pick a single container, single activity per " + "entity, and a sensible initial in-fiction time. Anything not stated " + "explicitly should be inferred reasonably from the prose." +) + + +def _build_user_prompt( + *, + bot_name: str, + bot_persona: str, + initial_relationship_to_you: str, + kickoff_prose: str, + you_name: str, +) -> str: + return ( + f"BOT NAME: {bot_name}\n" + f"BOT PERSONA: {bot_persona}\n" + f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n" + f"YOU NAME: {you_name}\n" + f"KICKOFF PROSE:\n{kickoff_prose}" + ) + + +async def parse_kickoff( + client: LLMClient, + *, + model: str, + bot_name: str, + bot_persona: str, + initial_relationship_to_you: str, + kickoff_prose: str, + you_name: str, + timeout_s: float = 10.0, +) -> KickoffParse: + """Parse authored kickoff prose into a structured ``KickoffParse``. + + Internally calls :func:`chat.llm.classify.classify` with a labeled + user prompt. Raises ``RuntimeError`` if the classifier fails twice in + a row โ€” no default is supplied at this layer, since the caller (T13's + confirm form) is responsible for showing an error and letting the + user edit. + """ + user_prompt = _build_user_prompt( + bot_name=bot_name, + bot_persona=bot_persona, + initial_relationship_to_you=initial_relationship_to_you, + kickoff_prose=kickoff_prose, + you_name=you_name, + ) + return await classify( + client, + model=model, + system=_SYSTEM_PROMPT, + user=user_prompt, + schema=KickoffParse, + timeout_s=timeout_s, + ) diff --git a/tests/test_kickoff.py b/tests/test_kickoff.py new file mode 100644 index 0000000..c3dfc14 --- /dev/null +++ b/tests/test_kickoff.py @@ -0,0 +1,131 @@ +import json +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.kickoff import ( + ActivityShape, + KickoffParse, + parse_kickoff, +) + + +def _full_kickoff_json() -> str: + return json.dumps( + { + "container_name": "office bullpen, late evening", + "container_type": "office", + "container_properties": { + "moving": False, + "public": False, + "audible_range": "room", + }, + "you_activity": { + "posture": "sitting at your desk", + "action_verb": "finishing emails", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "15m", + "attention": "the screen", + "holding": ["coffee mug"], + }, + "bot_activity": { + "posture": "sitting at her desk", + "action_verb": "pretending to work", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "indefinite", + "attention": "you, in glances", + "holding": [], + }, + "initial_time_iso": "2026-04-26T19:42:00", + "edge_seed_summary": "coworkers; aware of each other; no shared history beyond the office", + "edge_seed_knowledge_facts": [ + "they work on the same floor", + "it is unusual to be the only two left", + ], + } + ) + + +@pytest.mark.asyncio +async def test_parse_kickoff_happy_path_populates_fields(): + mock = MockLLMClient(canned=[_full_kickoff_json()]) + result = await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="reserved colleague who quietly notices things", + initial_relationship_to_you="coworker, slight crush, never voiced", + kickoff_prose=( + "you stay late at the office; only you and BotA are there; " + "she's at her desk pretending to work" + ), + you_name="You", + ) + assert isinstance(result, KickoffParse) + assert result.container_name == "office bullpen, late evening" + assert result.container_type == "office" + assert isinstance(result.you_activity, ActivityShape) + assert result.you_activity.posture == "sitting at your desk" + assert result.bot_activity.action_verb == "pretending to work" + assert result.edge_seed_summary.startswith("coworkers") + assert "they work on the same floor" in result.edge_seed_knowledge_facts + assert result.initial_time_iso == "2026-04-26T19:42:00" + + +@pytest.mark.asyncio +async def test_parse_kickoff_applies_activity_defaults_for_missing_fields(): + minimal_payload = { + "container_name": "kitchen", + "container_type": "kitchen", + "container_properties": {}, + "you_activity": { + "posture": "standing", + "action_verb": "boiling water", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "5m", + }, + "bot_activity": { + "posture": "leaning on the counter", + "action_verb": "scrolling phone", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "10m", + }, + "initial_time_iso": "2026-04-26T08:00:00", + "edge_seed_summary": "roommates", + "edge_seed_knowledge_facts": [], + } + mock = MockLLMClient(canned=[json.dumps(minimal_payload)]) + result = await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="laid-back roommate", + initial_relationship_to_you="roommates of two years", + kickoff_prose="morning in the kitchen; you're making tea while BotA scrolls her phone", + you_name="You", + ) + assert result.you_activity.attention == "" + assert result.you_activity.holding == [] + assert result.bot_activity.attention == "" + assert result.bot_activity.holding == [] + # mutating one default must not leak into the other (default_factory check) + result.you_activity.holding.append("kettle") + assert result.bot_activity.holding == [] + + +@pytest.mark.asyncio +async def test_parse_kickoff_raises_when_classifier_fails_twice(): + mock = MockLLMClient(canned=["nope", "still nope"]) + with pytest.raises(RuntimeError): + await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="x", + initial_relationship_to_you="y", + kickoff_prose="z", + you_name="You", + )