feat: kickoff prose parser via classifier

This commit is contained in:
Joseph Doherty
2026-04-26 12:09:17 -04:00
parent ec344064f1
commit a5339fc1d2
3 changed files with 252 additions and 0 deletions
View File
+121
View File
@@ -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,
)
+131
View File
@@ -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",
)