feat: kickoff prose parser via classifier
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user