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 # Emoji or short label; used if avatar_image_url is unset/invalid. goal: str secret: str speaking_style: str tools: list[str] = field(default_factory=list) avatar_image_url: str | None = None # Optional HTTPS portrait for the stage card. 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 # Optional HTTPS image layered behind stage copy. backdrop_description: str | None = None # LLM minimal art-direction text used to pick backdrop_image_url. 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" # One-shot: show opening-curtain animation on the first stage render after create. play_opening_curtain: bool = False