| from dataclasses import dataclass, field |
| from datetime import datetime, timezone |
| from uuid import uuid4 |
|
|
| from typing import Any, Literal |
|
|
| from pydantic import BaseModel, Field, ValidationInfo, field_validator |
|
|
| """ |
| Core data models for the puppet theater simulation. |
| |
| This module defines the structured objects used throughout a theater session, |
| including: |
| |
| - Actors and their state (goals, secrets, mood, props, memory) |
| - Director decisions that guide story progression |
| - Actor responses for each scene beat |
| - Tool requests and tool execution results |
| - Individual beats in the transcript |
| - The overall theater session state and configuration |
| |
| Pydantic models are used where validation is required for LLM-generated |
| outputs (e.g. actor responses and director decisions), while dataclasses |
| are used for runtime session state and domain entities. |
| |
| In short: this file defines the data structures that represent the |
| story, characters, dialogue, stage actions, and session state of |
| the puppet theater engine. |
| """ |
|
|
| BeatType = Literal[ |
| "setup", |
| "denial_or_contradiction", |
| "evidence_or_prop", |
| "secret_reveal", |
| "chaos_or_intervention", |
| "finale", |
| ] |
|
|
|
|
| SimpleToolValue = str | int | float | bool | None |
|
|
|
|
| class ToolRequest(BaseModel): |
| """ |
| Represents a request to call a tool. |
| |
| Pydantic automatically validates the input data when an |
| instance is created. Ensures the tool name and reason are |
| non-empty and that the reason remains concise. |
| """ |
| tool_name: str |
| arguments: Any = Field(default_factory=dict) |
| reason: str |
|
|
| @field_validator("tool_name", "reason") |
| @classmethod |
| def require_text(cls, value: str) -> str: |
| cleaned = " ".join(value.strip().split()) |
| if not cleaned: |
| raise ValueError("field must not be empty") |
| return cleaned |
|
|
| @field_validator("reason") |
| @classmethod |
| def keep_reason_short(cls, value: str) -> str: |
| if len(value) > 140: |
| raise ValueError("reason must be 140 characters or fewer") |
| return value |
|
|
|
|
| class ActorResponse(BaseModel): |
| """ |
| Represents a structured response from a puppet actor for one scene beat. |
| |
| Includes: |
| - intent: actor's short goal for this beat |
| - line: dialogue spoken on stage |
| - emotion: actor's emotional state |
| - gesture: physical action performed |
| - stage_effect: environmental/stage effect |
| - memory_update: optional note carried to future beats |
| - tool_request: optional request to use a tool |
| |
| Validators enforce length limits, remove extra whitespace, |
| and prevent required fields from being empty. |
| """ |
| intent: str = Field(default="Keep the scene moving.", description="Short visible actor intention.") |
| line: str = Field(description="Short, stage-ready puppet dialogue.") |
| emotion: str |
| gesture: str |
| stage_effect: str |
| memory_update: str = Field(default="", description="Short visible memory note from this beat.") |
| tool_request: ToolRequest | None = None |
|
|
| @field_validator("intent", "line", "emotion", "gesture", "stage_effect", "memory_update") |
| @classmethod |
| def require_text(cls, value: str, info: ValidationInfo) -> str: |
| cleaned = " ".join(value.strip().split()) |
| if not cleaned and info.field_name != "memory_update": |
| raise ValueError("field must not be empty") |
| return cleaned |
|
|
| @field_validator("intent") |
| @classmethod |
| def keep_intent_short(cls, value: str) -> str: |
| if len(value) > 90: |
| raise ValueError("intent must be 90 characters or fewer") |
| return value |
|
|
| @field_validator("line") |
| @classmethod |
| def keep_line_short(cls, value: str) -> str: |
| if not value: |
| raise ValueError("line must not be empty") |
| if len(value.split()) > 25: |
| raise ValueError("line must be 25 words or fewer") |
| if len(value) > 220: |
| raise ValueError("line must be 220 characters or fewer") |
| return value |
|
|
| @field_validator("memory_update") |
| @classmethod |
| def keep_memory_short(cls, value: str) -> str: |
| if len(value) > 140: |
| raise ValueError("memory_update must be 140 characters or fewer") |
| return value |
|
|
|
|
| class DirectorDecision(BaseModel): |
| next_speaker: str |
| beat_type: BeatType |
| instruction: str |
| stage_effect: str |
| uses_prop: bool = False |
| reveal_secret: bool = False |
| should_end_scene: bool = False |
| reason_summary: str |
|
|
| @field_validator("next_speaker", "instruction", "stage_effect", "reason_summary") |
| @classmethod |
| def require_text(cls, value: str) -> str: |
| cleaned = " ".join(value.strip().split()) |
| if not cleaned: |
| raise ValueError("field must not be empty") |
| return cleaned |
|
|
| @field_validator("instruction", "reason_summary") |
| @classmethod |
| def keep_brief(cls, value: str) -> str: |
| if len(value) > 240: |
| raise ValueError("field must be 240 characters or fewer") |
| return value |
|
|
|
|
| @dataclass |
| class Actor: |
| name: str |
| avatar: str |
| goal: str |
| secret: str |
| speaking_style: str |
| tools: list[str] = field(default_factory=list) |
| avatar_image_url: str | None = None |
| held_prop: str | None = None |
| mood: str = "ready" |
| current_goal: str | None = None |
| goal_progress: str = "Waiting for the curtain." |
| held_props: list[str] = field(default_factory=list) |
| secret_status: Literal["hidden", "hinted", "revealed", "resolved"] = "hidden" |
| recent_memory: list[str] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class Beat: |
| speaker: str |
| intent: str |
| line: str |
| emotion: str |
| gesture: str |
| stage_effect: str |
| memory_update: str = "" |
| tool_request: ToolRequest | None = None |
|
|
|
|
| @dataclass |
| class ToolResult: |
| tool_name: str |
| result: str |
| actor_name: str |
| reason: str |
| arguments: dict[str, SimpleToolValue] = field(default_factory=dict) |
| stage_effect: str | None = None |
|
|
|
|
| @dataclass |
| class TheaterSession: |
| show_title: str |
| premise: str |
| setting: str |
| actors: list[Actor] |
| backdrop_image_url: str | None = None |
| backdrop_description: str | None = None |
| session_id: str = field(default_factory=lambda: uuid4().hex[:12]) |
| created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) |
| beat_index: int = 0 |
| min_beats: int = 7 |
| target_beats: int = 10 |
| max_beats: int = 12 |
| show_length_mode: str = "standard" |
| transcript: list[Beat] = field(default_factory=list) |
| props: list[str] = field(default_factory=list) |
| latest_prop: str | None = None |
| latest_audience_action: str | None = None |
| latest_tool_result: ToolResult | None = None |
| recent_tool_results: list[ToolResult] = field(default_factory=list) |
| stage_lighting: str = "warm_spotlight" |
| director_log: list[str] = field(default_factory=list) |
| trace_events: list[dict[str, Any] | str] = field(default_factory=list) |
| finale_requested: bool = False |
| backend_name: str = "deterministic" |
| backend_model_id: str | None = None |
| backend_max_new_tokens: int = 120 |
| backend_temperature: float = 0.75 |
| director_mode: str = "deterministic" |
| |
| play_opening_curtain: bool = False |
|
|