fetch-initial-data-from-llm

#7
.gitignore CHANGED
@@ -1,6 +1,11 @@
1
  .venv/
2
  .uv-cache/
3
 
 
 
 
 
 
4
  __pycache__/
5
  *.py[cod]
6
 
 
1
  .venv/
2
  .uv-cache/
3
 
4
+ # Local secrets (HF_TOKEN, etc.); never commit
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+
9
  __pycache__/
10
  *.py[cod]
11
 
README.md CHANGED
@@ -29,6 +29,7 @@ AI Puppet Theater is a Hugging Face Gradio Space for the Build Small Hackathon,
29
  - [Model and Runtime](#model-and-runtime)
30
  - [Hackathon Targets](#hackathon-targets)
31
  - [Team](#team)
 
32
  - [Local Development](#local-development)
33
  - [Configuration](#configuration)
34
  - [Actor Model Backends](#actor-model-backends)
@@ -36,6 +37,10 @@ AI Puppet Theater is a Hugging Face Gradio Space for the Build Small Hackathon,
36
  - [Fine-Tuning Artifacts](#fine-tuning-artifacts)
37
  - [Roadmap](#roadmap)
38
 
 
 
 
 
39
  ## Live Demo
40
 
41
  - **Hugging Face Space:** [AI Puppet Theater](https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater)
@@ -256,10 +261,18 @@ HF_API_MODEL_ID=Qwen/Qwen3-4B-Instruct-2507:nscale
256
 
257
  The default model string includes the provider suffix used by Hugging Face Inference Providers. If that model is unavailable through your selected provider, use another chat-compatible model id from Hugging Face Inference Providers.
258
 
 
 
259
  Configure the token as a Hugging Face Space secret named `HF_TOKEN`. `HUGGINGFACEHUB_API_TOKEN` is also supported. Do not commit tokens to the repository, README, app code, logs, or trace output.
260
 
 
 
261
  Local deterministic mode and app launch work without `HF_TOKEN`.
262
 
 
 
 
 
263
  ### Voice modes
264
 
265
  Voice is optional. **Off** remains the default and the app works without audio support.
 
29
  - [Model and Runtime](#model-and-runtime)
30
  - [Hackathon Targets](#hackathon-targets)
31
  - [Team](#team)
32
+ - [Learning path (for contributors)](#learning-path-for-contributors)
33
  - [Local Development](#local-development)
34
  - [Configuration](#configuration)
35
  - [Actor Model Backends](#actor-model-backends)
 
37
  - [Fine-Tuning Artifacts](#fine-tuning-artifacts)
38
  - [Roadmap](#roadmap)
39
 
40
+ ## Learning path (for contributors)
41
+
42
+ If you are new to Python or LLMs and want to understand how the Gradio UI connects to the Director / actor loop, read **[docs/LEARNING_PATH.md](docs/LEARNING_PATH.md)**. It mirrors the recommended reading order for this repository and includes a UI-to-engine handler map.
43
+
44
  ## Live Demo
45
 
46
  - **Hugging Face Space:** [AI Puppet Theater](https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater)
 
261
 
262
  The default model string includes the provider suffix used by Hugging Face Inference Providers. If that model is unavailable through your selected provider, use another chat-compatible model id from Hugging Face Inference Providers.
263
 
264
+ Premise casting (title, setting, minimal **backdrop_description**, director + puppets JSON) requests up to **`HF_API_SHOW_BIBLE_MAX_TOKENS`** completion tokens (default **1024**, clamped between 256 and 4096). When **`HF_SETTING_BACKDROP_IMAGE`** is not disabled (default on) and **`HF_TOKEN`** is set, the app generates a **JPEG backdrop from the `setting` sentence** via Hugging Face **text-to-image** (model **`HF_BACKDROP_T2I_MODEL`**, default `black-forest-labs/FLUX.1-schnell`; timeout **`HF_BACKDROP_T2I_TIMEOUT`**, default 90s). If that is off or fails after an LLM cast, a follow-up **chat** completion can still pick a stock **`backdrop_image_url`** (up to **`HF_API_BACKDROP_URL_MAX_TOKENS`**, default **256**). If that JSON was truncated at 200 tokens before, the UI fell back to the default Pip/Mina/Bolt cast; raising this cap fixes incomplete responses.
265
+
266
  Configure the token as a Hugging Face Space secret named `HF_TOKEN`. `HUGGINGFACEHUB_API_TOKEN` is also supported. Do not commit tokens to the repository, README, app code, logs, or trace output.
267
 
268
+ For **local** runs, put `HF_TOKEN=...` in a file named `.env` at the **repository root** (next to `app.py`). The app loads that file on startup; you do not need to `export` it manually. A template is in [`.env.example`](.env.example).
269
+
270
  Local deterministic mode and app launch work without `HF_TOKEN`.
271
 
272
+ ### Stage backdrop
273
+
274
+ The show’s **`setting`** string (for example *“A sun-dappled forest clearing with soft grass and distant trees, warm and quiet.”*) is the narrative stage description. When **`HF_TOKEN`** is configured and **`HF_SETTING_BACKDROP_IMAGE`** is not set to `0`/`false`/`off`, the app builds a **text-to-image** prompt from that setting (wide 16:9 puppet-stage framing, calm center, no text/watermarks) and calls the Hugging Face Inference API. The returned image is stored as an **inline JPEG data URL** on the session and used as the CSS stage background. If generation is disabled, fails, or there is no token, the app falls back to an LLM-picked stock **https** URL from **`backdrop_description`**, then to **keyword-matched stock images** in `puppet_theater/session.py` (`_BACKDROP_RULES`). Set **`HF_SETTING_BACKDROP_IMAGE=0`** to skip image generation and use URL/keyword flow only.
275
+
276
  ### Voice modes
277
 
278
  Voice is optional. **Off** remains the default and the app works without audio support.
app.py CHANGED
@@ -8,6 +8,12 @@ import tempfile
8
  import time
9
  from time import sleep
10
 
 
 
 
 
 
 
11
  os.environ["GRADIO_SSR_MODE"] = "False"
12
 
13
  import gradio as gr
@@ -1145,10 +1151,21 @@ body,
1145
  display: inline-grid;
1146
  font-size: 1.7rem;
1147
  height: 3rem;
 
1148
  place-items: center;
1149
  text-align: center;
1150
  width: 3rem;
1151
  }
 
 
 
 
 
 
 
 
 
 
1152
  .actor-name {
1153
  color: #f8efe4;
1154
  font-weight: 700;
@@ -1409,6 +1426,13 @@ body,
1409
  line-height: 1.2;
1410
  margin-bottom: 0.28rem;
1411
  }
 
 
 
 
 
 
 
1412
  .agent-state-line {
1413
  color: #f8efe4;
1414
  font-size: 0.78rem;
@@ -1654,6 +1678,7 @@ body,
1654
  .actor-avatar {
1655
  font-size: 2rem;
1656
  height: 3.3rem;
 
1657
  width: 3.3rem;
1658
  }
1659
  .actor-name {
@@ -1799,6 +1824,7 @@ body,
1799
  .actor-avatar {
1800
  font-size: 1.65rem;
1801
  height: 2.55rem;
 
1802
  width: 2.55rem;
1803
  }
1804
  .actor-name {
@@ -2350,12 +2376,17 @@ def render_stage(session: TheaterSession | None) -> str:
2350
  <div class="speech-line">{escape(latest_beat.line)}</div>
2351
  </div>
2352
  """
 
 
 
 
 
2353
  actor_columns.append(
2354
  f"""
2355
  <div class="actor-column">
2356
  {bubble_html}
2357
  <div class="actor-card{active_class}">
2358
- <div class="actor-avatar">{escape(actor.avatar)}</div>
2359
  <div class="actor-name">{escape(actor.name)}</div>
2360
  {active_label}
2361
  <div class="actor-detail">{escape(role_line)}</div>
@@ -2402,10 +2433,18 @@ def render_stage(session: TheaterSession | None) -> str:
2402
  </div>
2403
  """
2404
 
 
 
 
 
 
 
 
 
2405
  return f"""
2406
  <div class="puppet-stage stage-live">
2407
  <div class="stage-valance"></div>
2408
- <div class="stage-backdrop">
2409
  {curtain_html}
2410
  <div class="stage-marquee">{escape(session.show_title)}</div>
2411
  <div class="stage-copy">
@@ -2465,10 +2504,15 @@ def render_agent_state(session: TheaterSession | None) -> str:
2465
  held = ", ".join(held_props) if held_props else "none"
2466
  recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet."
2467
  current_goal = actor.current_goal or actor.goal
 
 
 
 
 
2468
  cards.append(
2469
  f"""
2470
  <div class="agent-state-card">
2471
- <div class="agent-state-name">{escape(actor.avatar)} {escape(actor.name)}</div>
2472
  <div class="agent-state-line"><strong>Mood:</strong> {escape(actor.mood)}</div>
2473
  <div class="agent-state-line"><strong>Goal:</strong> {escape(current_goal)}</div>
2474
  <div class="agent-state-line"><strong>Props:</strong> {escape(held)}</div>
 
8
  import time
9
  from time import sleep
10
 
11
+ from dotenv import load_dotenv
12
+
13
+ # Repo-root .env (HF_TOKEN, etc.). Must run before importing puppet_theater so
14
+ # load_dotenv is not cwd-dependent (IDEs / `gradio` may use a different cwd).
15
+ load_dotenv(Path(__file__).resolve().parent / ".env")
16
+
17
  os.environ["GRADIO_SSR_MODE"] = "False"
18
 
19
  import gradio as gr
 
1151
  display: inline-grid;
1152
  font-size: 1.7rem;
1153
  height: 3rem;
1154
+ overflow: hidden;
1155
  place-items: center;
1156
  text-align: center;
1157
  width: 3rem;
1158
  }
1159
+ .actor-avatar.has-image {
1160
+ padding: 0;
1161
+ }
1162
+ .actor-avatar .actor-avatar-img {
1163
+ border-radius: 999px;
1164
+ display: block;
1165
+ height: 100%;
1166
+ object-fit: cover;
1167
+ width: 100%;
1168
+ }
1169
  .actor-name {
1170
  color: #f8efe4;
1171
  font-weight: 700;
 
1426
  line-height: 1.2;
1427
  margin-bottom: 0.28rem;
1428
  }
1429
+ .agent-state-avatar {
1430
+ border-radius: 4px;
1431
+ height: 1.15rem;
1432
+ object-fit: cover;
1433
+ vertical-align: text-bottom;
1434
+ width: 1.15rem;
1435
+ }
1436
  .agent-state-line {
1437
  color: #f8efe4;
1438
  font-size: 0.78rem;
 
1678
  .actor-avatar {
1679
  font-size: 2rem;
1680
  height: 3.3rem;
1681
+ overflow: hidden;
1682
  width: 3.3rem;
1683
  }
1684
  .actor-name {
 
1824
  .actor-avatar {
1825
  font-size: 1.65rem;
1826
  height: 2.55rem;
1827
+ overflow: hidden;
1828
  width: 2.55rem;
1829
  }
1830
  .actor-name {
 
2376
  <div class="speech-line">{escape(latest_beat.line)}</div>
2377
  </div>
2378
  """
2379
+ avatar_class = "actor-avatar has-image" if actor.avatar_image_url else "actor-avatar"
2380
+ if actor.avatar_image_url:
2381
+ avatar_inner = f'<img class="actor-avatar-img" src="{escape(actor.avatar_image_url)}" alt="" loading="lazy" />'
2382
+ else:
2383
+ avatar_inner = escape(actor.avatar)
2384
  actor_columns.append(
2385
  f"""
2386
  <div class="actor-column">
2387
  {bubble_html}
2388
  <div class="actor-card{active_class}">
2389
+ <div class="{avatar_class}">{avatar_inner}</div>
2390
  <div class="actor-name">{escape(actor.name)}</div>
2391
  {active_label}
2392
  <div class="actor-detail">{escape(role_line)}</div>
 
2433
  </div>
2434
  """
2435
 
2436
+ backdrop_style = ""
2437
+ if session.backdrop_image_url:
2438
+ u = escape(session.backdrop_image_url)
2439
+ backdrop_style = (
2440
+ f' style="background-image: linear-gradient(180deg, rgba(42,20,38,0.82) 0%, rgba(19,9,17,0.88) 62%, rgba(13,6,14,0.92) 100%), url({u}); '
2441
+ 'background-size: cover; background-position: center; background-blend-mode: multiply;"'
2442
+ )
2443
+
2444
  return f"""
2445
  <div class="puppet-stage stage-live">
2446
  <div class="stage-valance"></div>
2447
+ <div class="stage-backdrop"{backdrop_style}>
2448
  {curtain_html}
2449
  <div class="stage-marquee">{escape(session.show_title)}</div>
2450
  <div class="stage-copy">
 
2504
  held = ", ".join(held_props) if held_props else "none"
2505
  recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet."
2506
  current_goal = actor.current_goal or actor.goal
2507
+ face = (
2508
+ f'<img class="agent-state-avatar" src="{escape(actor.avatar_image_url)}" alt="" /> '
2509
+ if actor.avatar_image_url
2510
+ else f"{escape(actor.avatar)} "
2511
+ )
2512
  cards.append(
2513
  f"""
2514
  <div class="agent-state-card">
2515
+ <div class="agent-state-name">{face}{escape(actor.name)}</div>
2516
  <div class="agent-state-line"><strong>Mood:</strong> {escape(actor.mood)}</div>
2517
  <div class="agent-state-line"><strong>Goal:</strong> {escape(current_goal)}</div>
2518
  <div class="agent-state-line"><strong>Props:</strong> {escape(held)}</div>
docs/LEARNING_PATH.md ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Learning path: AI Puppet Theater
2
+
3
+ This guide walks a new contributor from Python and LLM basics through this repository so you can **trace one user action end-to-end** and **change the engine or UI safely**.
4
+
5
+ Companion docs: [AGENTS.md](../AGENTS.md) (agent and product rules), [README.md](../README.md) (features and architecture images under `assets/`).
6
+
7
+ ---
8
+
9
+ ## Phase 0: Environment and vocabulary
10
+
11
+ ### Run the app
12
+
13
+ ```bash
14
+ uv sync
15
+ uv run python app.py
16
+ # or, for reload while editing:
17
+ uv run gradio app.py
18
+ ```
19
+
20
+ Open `http://127.0.0.1:7860`. Create a show, use **Run One Beat**, throw a prop, open **Behind the Curtain**. You will see the same concepts the code names: session, beats, director log, trace.
21
+
22
+ ### Python building blocks used here
23
+
24
+ - **Modules and imports** — `app.py` imports a small public API from `puppet_theater` (see [`puppet_theater/__init__.py`](../puppet_theater/__init__.py)).
25
+ - **`dataclasses`** — mutable “world state”: [`Actor`](../puppet_theater/models.py), [`Beat`](../puppet_theater/models.py), [`TheaterSession`](../puppet_theater/models.py).
26
+ - **Pydantic `BaseModel`** — validated “messages” often produced from LLM text: [`DirectorDecision`](../puppet_theater/models.py), [`ActorResponse`](../puppet_theater/models.py), [`ToolRequest`](../puppet_theater/models.py).
27
+ - **Type hints** — e.g. `str | None`, `list[Beat]`.
28
+ - **In-place session updates** — most functions take `TheaterSession`, mutate it, and return the same object (not a copy).
29
+
30
+ ### LLM vocabulary in this project
31
+
32
+ - **Prompt** — text sent to a model; see [`puppet_theater/prompts.py`](../puppet_theater/prompts.py).
33
+ - **Structured output** — the app expects JSON-shaped answers, parses them, and validates with Pydantic. Bad JSON or timeouts → **fallback** to deterministic templates so the Space always runs.
34
+
35
+ ---
36
+
37
+ ## Phase 1: The nouns of the show (`models.py`)
38
+
39
+ Read [`puppet_theater/models.py`](../puppet_theater/models.py) end-to-end.
40
+
41
+ ### TheaterSession vs Beat
42
+
43
+ | | **`TheaterSession`** | **`Beat`** |
44
+ |---|----------------------|------------|
45
+ | **Role** | The entire live show: cast, configuration, pacing budget, transcript history, logs, trace. | One row of dialogue / stage moment in the transcript. |
46
+ | **Mutability** | Updated every audience action and every beat (beat counter, actors’ mood, etc.). | Created once per beat and appended to `session.transcript`; treat as immutable after append. |
47
+ | **Contains** | `actors`, `beat_index`, `min_beats` / `target_beats` / `max_beats`, `transcript`, `latest_prop`, `director_log`, `trace_events`, `backend_name`, `director_mode`, … | `speaker`, `intent`, `line`, `emotion`, `gesture`, `stage_effect`, optional `memory_update` and `tool_request`. |
48
+
49
+ **Checkpoint:** Explain aloud: “The session is the notebook; each beat is one line the puppets spoke.”
50
+
51
+ ### Pydantic vs dataclass in this file
52
+
53
+ - **Pydantic** — strict validation for fields that might come from model JSON (length limits, non-empty strings).
54
+ - **Dataclass** — convenient structured bags for runtime state the code controls directly.
55
+
56
+ Public exports are listed in [`puppet_theater/__init__.py`](../puppet_theater/__init__.py).
57
+
58
+ ---
59
+
60
+ ## Phase 2: Creating a show and audience actions
61
+
62
+ 1. **[`puppet_theater/session.py`](../puppet_theater/session.py)** — [`create_show_from_premise`](../puppet_theater/session.py) builds `show_title`, `setting`, the default three [`Actor`](../puppet_theater/models.py) instances, beat budget from [`resolve_show_length`](../puppet_theater/session.py), copies backend/director/temperature settings onto the session, seeds `director_log`, and records trace events (`show_created`, `actors_created`, `director_plan_created`).
63
+
64
+ 2. **[`puppet_theater/actions.py`](../puppet_theater/actions.py)** — Audience mutators:
65
+ - [`throw_prop`](../puppet_theater/actions.py) — appends to `session.props`, sets `latest_prop`, updates `latest_audience_action` and `director_log`, traces `audience_action`.
66
+ - [`summon_actor`](../puppet_theater/actions.py) — appends a new `Actor` (cap `MAX_ACTORS`), or logs a skip if full.
67
+ - [`request_finale`](../puppet_theater/actions.py) — sets `finale_requested` so the Director policy can force a finale.
68
+
69
+ **Checkpoint:** Trace “throw rubber duck” from UI → `throw_prop` → `session.latest_prop` → next Director beat may set `uses_prop=True` (see deterministic policy when `latest_prop` is set in [`DirectorPolicy.decide`](../puppet_theater/director.py)).
70
+
71
+ ---
72
+
73
+ ## Phase 3: One beat — the main pipeline (`director.py`)
74
+
75
+ Read [`puppet_theater/director.py`](../puppet_theater/director.py) with focus on:
76
+
77
+ - [`story_progress`](../puppet_theater/director.py) / [`story_phase`](../puppet_theater/director.py) — pacing helpers from `beat_index` and `target_beats`.
78
+ - [`choose_director_decision`](../puppet_theater/director.py) — branches on `session.director_mode`: `hf_api` → `HFAPIDirectorPolicy`, `openbmb` → `OpenBMBDirectorPolicy`, else [`DirectorPolicy`](../puppet_theater/director.py) (deterministic).
79
+ - [`run_one_beat`](../puppet_theater/director.py) — orchestrates one turn:
80
+ 1. Skip if `beat_index >= max_beats`.
81
+ 2. Call `choose_director_decision` → [`DirectorDecision`](../puppet_theater/models.py).
82
+ 3. Resolve speaker; optionally attach `latest_prop` if decision uses prop.
83
+ 4. Append director log lines and `add_trace_event(..., "director_decision", ...)`.
84
+ 5. [`generate_actor_response`](../puppet_theater/backends.py) (from `backends`) → [`ActorResponse`](../puppet_theater/models.py).
85
+ 6. Increment `beat_index`, build [`Beat`](../puppet_theater/models.py), append to `transcript`.
86
+ 7. [`apply_actor_state_update`](../puppet_theater/director.py) — mood, memory, secret status, held props.
87
+ 8. [`run_actor_tool_request`](../puppet_theater/tools.py) — optional tool side effects; may adjust `beat.stage_effect`.
88
+ 9. Clear `latest_prop` if consumed; more logs and trace (`beat_added`, `actor_response`, …).
89
+ 10. If finale: clamp `beat_index` to `max_beats`, set `finale_requested`, trace `scene_completed`.
90
+
91
+ - [`run_full_act`](../puppet_theater/director.py) — `while session.beat_index < session.max_beats: run_one_beat(session)`.
92
+
93
+ ```mermaid
94
+ flowchart LR
95
+ subgraph beat [run_one_beat]
96
+ D[choose_director_decision]
97
+ A[generate_actor_response]
98
+ T[append Beat to transcript]
99
+ S[apply_actor_state_update]
100
+ U[run_actor_tool_request]
101
+ end
102
+ Session[TheaterSession]
103
+ Session --> D
104
+ D --> A
105
+ A --> T
106
+ T --> S
107
+ S --> U
108
+ U --> Session
109
+ ```
110
+
111
+ **Checkpoint:** Without opening `app.py`, narrate one beat from Director decision through transcript line.
112
+
113
+ ---
114
+
115
+ ## Phase 4: Backends — where the LLM lives (`backends.py`)
116
+
117
+ Read [`puppet_theater/backends.py`](../puppet_theater/backends.py) with this map:
118
+
119
+ - **`ModelBackend`** — abstract [`generate_actor_response`](../puppet_theater/backends.py) per backend.
120
+ - **Implementations** — [`DeterministicBackend`](../puppet_theater/backends.py) (always works), [`OpenBMBTransformersBackend`](../puppet_theater/backends.py) (local Transformers; ZeroGPU hooks in [`puppet_theater/zerogpu.py`](../puppet_theater/zerogpu.py)), [`HFAPIBackend`](../puppet_theater/backends.py) (hosted inference).
121
+ - **[`generate_actor_response`](../puppet_theater/backends.py)** (module-level) — picks backend from `session.backend_name`, runs generation, [`parse_actor_output`](../puppet_theater/backends.py), optional repair, then deterministic fallback on failure (mirrors Director policies’ try/validate/fallback pattern).
122
+
123
+ **Checkpoint:** Why does the demo run without an HF token? Because `backend_name` and `director_mode` default to `deterministic`, which never calls the network.
124
+
125
+ Optional deep read: [`puppet_theater/prompts.py`](../puppet_theater/prompts.py).
126
+
127
+ ---
128
+
129
+ ## Phase 5: Tools and trace
130
+
131
+ ### Tools ([`puppet_theater/tools.py`](../puppet_theater/tools.py))
132
+
133
+ - [`ALLOWED_TOOL_NAMES`](../puppet_theater/tools.py) — allowlist: `inspect_prop`, `consult_stage_oracle`, `change_lighting`.
134
+ - [`validate_tool_request`](../puppet_theater/tools.py) — Pydantic + argument shape checks.
135
+ - [`run_actor_tool_request`](../puppet_theater/tools.py) — traces `tool_requested` / `tool_ignored` / `tool_executed` / `tool_result`, updates `session.latest_tool_result` and `recent_tool_results`.
136
+
137
+ ### Trace ([`puppet_theater/trace.py`](../puppet_theater/trace.py))
138
+
139
+ - [`add_trace_event`](../puppet_theater/trace.py) — appends sanitized dicts to `session.trace_events` (no secrets; path stripping in [`sanitize_value`](../puppet_theater/trace.py)). Each event uses the key **`event_type`** (not `type`) for the event name string.
140
+ - [`export_trace`](../puppet_theater/trace.py) / [`render_trace_json`](../puppet_theater/trace.py) / [`write_trace_json_file`](../puppet_theater/trace.py) — used by the UI for download and display.
141
+
142
+ **Checkpoint:** If you add a new `event_type` in `add_trace_event`, you can find it in the trace JSON and in Gradio components wired to `render_trace`.
143
+
144
+ ---
145
+
146
+ ## Phase 6: Gradio UI wiring (`app.py`)
147
+
148
+ Do **not** read [`app.py`](../app.py) top to bottom (it is large). Use this map.
149
+
150
+ ### `gr.Blocks` handlers → Python wrappers → `puppet_theater`
151
+
152
+ | UI control | Handler in `app.py` | Calls into `puppet_theater` |
153
+ |------------|---------------------|-----------------------------|
154
+ | Create show | [`create_show`](../app.py) (~2607) | [`create_show_from_premise`](../puppet_theater/session.py) |
155
+ | Run one beat | [`advance_one_beat`](../app.py) (~2691) | [`run_one_beat`](../puppet_theater/director.py) (after [`apply_backend_selection`](../app.py)) |
156
+ | Run full act | [`advance_full_act`](../app.py) (~2720) | Loop / yield calling [`run_one_beat`](../puppet_theater/director.py) |
157
+ | Throw prop | [`throw_audience_prop`](../app.py) (~2790) | [`throw_prop`](../puppet_theater/actions.py) |
158
+ | Summon actor | [`summon_audience_actor`](../app.py) (~2820) | [`summon_actor`](../puppet_theater/actions.py) |
159
+ | Request finale | [`request_audience_finale`](../app.py) (~2850) | [`request_finale`](../puppet_theater/actions.py) |
160
+ | Warm up OpenBMB | [`warm_up_backend`](../app.py) (~2879) | [`warm_up_openbmb`](../puppet_theater/backends.py) |
161
+ | Reset | [`reset_show`](../app.py) (~2663) | Clears state (no package call) |
162
+
163
+ Event wiring lives near **`create_button.click`**, **`run_one_button.click`**, etc. (~3119–3290 in `app.py` at time of writing; line numbers may shift).
164
+
165
+ Shared presentation pipeline: [`render_outputs`](../app.py) (~2590) refreshes stage HTML, transcript, TTS payload, director log, trace, backend panel.
166
+
167
+ **Checkpoint:** Name the handler that runs when **Run One Beat** is clicked and the single core engine function it invokes.
168
+
169
+ ---
170
+
171
+ ## Phase 7: Verify and contribute
172
+
173
+ ### Commands
174
+
175
+ ```bash
176
+ uv run python -m py_compile app.py puppet_theater/*.py
177
+ uv run pytest
178
+ ```
179
+
180
+ ### Tests to read first
181
+
182
+ - [`tests/test_director.py`](../tests/test_director.py) — Director and beat behavior.
183
+ - [`tests/conftest.py`](../tests/conftest.py) — shared fixtures.
184
+
185
+ ### First contribution ideas
186
+
187
+ - Copyedit a `director_log` string.
188
+ - Extend deterministic dialogue or stage effects in [`backends.py`](../puppet_theater/backends.py) / [`director.py`](../puppet_theater/director.py).
189
+ - Add a trace field or a test for a prop-heavy beat.
190
+ - Document an env var in [README.md](../README.md).
191
+
192
+ ### Optional advanced track
193
+
194
+ [`finetune/`](../finetune/) — LoRA training and eval scripts; separate from the live Gradio beat loop.
195
+
196
+ ---
197
+
198
+ ## Suggested schedule
199
+
200
+ | Week | Focus |
201
+ |------|--------|
202
+ | 1 | Phases 0–2 + UI play |
203
+ | 2 | Phase 3 (`run_one_beat`) until you can draw the diagram from memory |
204
+ | 3 | Phases 4–5 |
205
+ | 4 | Phase 6–7 + a small PR |
206
+
207
+ ---
208
+
209
+ ## External references
210
+
211
+ - Gradio **Blocks** and `.click()` inputs/outputs.
212
+ - Pydantic v2 **validators** (`field_validator`).
213
+ - Hugging Face **Spaces** environment variables (see README Configuration).
puppet_theater/actions.py CHANGED
@@ -1,11 +1,27 @@
 
 
 
 
1
  from puppet_theater.models import Actor, TheaterSession
 
2
  from puppet_theater.trace import add_trace_event
3
 
4
 
 
5
  MAX_ACTORS = 4
6
 
7
 
8
  def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession | None:
 
 
 
 
 
 
 
 
 
 
9
  if session is None:
10
  return None
11
 
@@ -25,10 +41,22 @@ def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession
25
 
26
 
27
  def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSession | None:
 
 
 
 
 
 
 
 
 
 
28
  if session is None:
29
  return None
30
 
31
  name = " ".join(actor_name.strip().split()) or "Mystery Guest"
 
 
32
  if len(session.actors) >= MAX_ACTORS:
33
  session.latest_audience_action = "Audience tried to summon an actor, but the stage is full."
34
  session.director_log.append(session.latest_audience_action)
@@ -42,38 +70,56 @@ def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSess
42
  )
43
  return session
44
 
45
- actor = Actor(
46
- name=name,
47
- avatar="✨",
48
- goal="Make the scene stranger without derailing the finale.",
49
- secret="Arrived with one completely unexplained cue.",
50
- speaking_style="fresh, eager, and just a little too dramatic",
51
- tools=["consult_stage_oracle"],
52
  )
53
  session.actors.append(actor)
54
- session.latest_audience_action = f"Audience summoned {name}."
 
55
  session.director_log.append(session.latest_audience_action)
 
 
 
 
 
 
 
 
 
56
  add_trace_event(
57
  session,
58
  "audience_action",
59
  audience_action="summon_actor",
60
- summoned_actor=name,
 
 
61
  action_summary=session.latest_audience_action,
62
  )
 
63
  return session
64
 
65
 
66
  def request_finale(session: TheaterSession | None) -> TheaterSession | None:
 
 
 
 
 
 
 
 
67
  if session is None:
68
  return None
69
 
70
  session.finale_requested = True
71
  session.latest_audience_action = "Audience requested the finale."
72
  session.director_log.append(session.latest_audience_action)
 
73
  add_trace_event(
74
  session,
75
  "audience_action",
76
  audience_action="request_finale",
77
  action_summary=session.latest_audience_action,
78
  )
79
- return session
 
 
1
+ # This file defines "audience interaction" functions for the puppet theater system.
2
+ # These functions allow external inputs (like audience actions) to modify an active TheaterSession,
3
+ # such as throwing props, summoning actors, or requesting the finale.
4
+
5
  from puppet_theater.models import Actor, TheaterSession
6
+ from puppet_theater.show_bible import resolve_summoned_actor_via_llm_or_default
7
  from puppet_theater.trace import add_trace_event
8
 
9
 
10
+ # Maximum number of actors allowed on stage at any time
11
  MAX_ACTORS = 4
12
 
13
 
14
  def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession | None:
15
+ """
16
+ Simulates an audience member throwing a prop onto the stage.
17
+
18
+ What it does:
19
+ - Cleans up the prop name (removes extra spaces)
20
+ - Adds it to the session's props list
21
+ - Records it as the latest prop and audience action
22
+ - Updates director logs
23
+ - Emits a trace event for analytics/debugging
24
+ """
25
  if session is None:
26
  return None
27
 
 
41
 
42
 
43
  def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSession | None:
44
+ """
45
+ Allows the audience to summon a new actor onto the stage.
46
+
47
+ Behavior:
48
+ - Cleans the actor name
49
+ - Checks if stage has space (MAX_ACTORS limit)
50
+ - If full, logs a "skipped" action
51
+ - Otherwise creates a new Actor and adds them to the session
52
+ - Records the action in logs and trace system
53
+ """
54
  if session is None:
55
  return None
56
 
57
  name = " ".join(actor_name.strip().split()) or "Mystery Guest"
58
+
59
+ # Prevent adding too many actors beyond stage limit
60
  if len(session.actors) >= MAX_ACTORS:
61
  session.latest_audience_action = "Audience tried to summon an actor, but the stage is full."
62
  session.director_log.append(session.latest_audience_action)
 
70
  )
71
  return session
72
 
73
+ actor, summon_llm_source, summon_llm_fallback_used = resolve_summoned_actor_via_llm_or_default(
74
+ session, name
 
 
 
 
 
75
  )
76
  session.actors.append(actor)
77
+
78
+ session.latest_audience_action = f"Audience summoned {actor.name}."
79
  session.director_log.append(session.latest_audience_action)
80
+ if summon_llm_source:
81
+ session.director_log.append(
82
+ f"Summoned puppet profile from {summon_llm_source} (avatar {actor.avatar!s}, tools: {', '.join(actor.tools)})."
83
+ )
84
+ elif summon_llm_fallback_used:
85
+ session.director_log.append(
86
+ "Summon LLM did not return usable JSON; used built-in guest puppet profile instead."
87
+ )
88
+
89
  add_trace_event(
90
  session,
91
  "audience_action",
92
  audience_action="summon_actor",
93
+ summoned_actor=actor.name,
94
+ summon_llm_source=summon_llm_source or "built_in",
95
+ summon_llm_fallback_used=summon_llm_fallback_used,
96
  action_summary=session.latest_audience_action,
97
  )
98
+
99
  return session
100
 
101
 
102
  def request_finale(session: TheaterSession | None) -> TheaterSession | None:
103
+ """
104
+ Marks the session as having a requested finale.
105
+
106
+ What it does:
107
+ - Sets finale_requested flag to True
108
+ - Updates latest audience action and director logs
109
+ - Emits a trace event for analytics/debugging
110
+ """
111
  if session is None:
112
  return None
113
 
114
  session.finale_requested = True
115
  session.latest_audience_action = "Audience requested the finale."
116
  session.director_log.append(session.latest_audience_action)
117
+
118
  add_trace_event(
119
  session,
120
  "audience_action",
121
  audience_action="request_finale",
122
  action_summary=session.latest_audience_action,
123
  )
124
+
125
+ return session
puppet_theater/backdrop_gen.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate a stage backdrop image from the session `setting` sentence via Hugging Face Inference (text-to-image).
3
+
4
+ The image prompt is built from the setting text (the narrative stage description), plus a short prefix so
5
+ the result stays wide, puppet-friendly, and low-clutter. Output is a JPEG data URL for use in CSS
6
+ `background-image: url(...)`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import io
13
+ import logging
14
+ import os
15
+ from typing import Any
16
+
17
+ from puppet_theater.backends import _hf_api_token
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _DEFAULT_T2I_MODEL = "black-forest-labs/FLUX.1-schnell"
22
+ _DEFAULT_T2I_TIMEOUT = 90.0
23
+ _MAX_SETTING_CHARS = 900
24
+ _MAX_OUTPUT_WIDTH = 1152
25
+
26
+
27
+ def setting_backdrop_t2i_enabled() -> bool:
28
+ """When True, premise flow prefers HF text-to-image from `setting` over stock URL LLM (see session)."""
29
+ raw = os.getenv("HF_SETTING_BACKDROP_IMAGE", "1").strip().lower()
30
+ if raw in {"0", "false", "no", "off"}:
31
+ return False
32
+ return bool(_hf_api_token())
33
+
34
+
35
+ def build_setting_text_to_image_prompt(setting: str) -> str:
36
+ """Compose the text-to-image prompt from the show setting (f-string over setting text)."""
37
+ s = " ".join(setting.strip().split())
38
+ if len(s) > _MAX_SETTING_CHARS:
39
+ s = s[:_MAX_SETTING_CHARS].rsplit(" ", 1)[0].rstrip(",;:")
40
+ return (
41
+ "Wide cinematic 16:9 stage backdrop for puppet theater. "
42
+ "Soft painterly or photographic look, cohesive mood, gentle depth. "
43
+ "Keep the center area calm and visually simple so puppets in the foreground stay readable. "
44
+ "No text, no watermark, no logos, no UI, no subtitles. "
45
+ f"Scene and atmosphere: {s}"
46
+ )
47
+
48
+
49
+ def try_setting_backdrop_data_url(setting: str) -> tuple[str | None, dict[str, Any]]:
50
+ """
51
+ Call HF text-to-image with prompt derived from `setting`. Returns (data URL or None, metadata dict).
52
+ """
53
+ meta: dict[str, Any] = {"model": os.getenv("HF_BACKDROP_T2I_MODEL", _DEFAULT_T2I_MODEL).strip() or _DEFAULT_T2I_MODEL}
54
+ if not setting_backdrop_t2i_enabled():
55
+ meta["skipped"] = "disabled_or_no_token"
56
+ return None, meta
57
+ token = _hf_api_token()
58
+ if not token:
59
+ meta["skipped"] = "no_token"
60
+ return None, meta
61
+
62
+ prompt = build_setting_text_to_image_prompt(setting)
63
+ meta["prompt_char_len"] = len(prompt)
64
+ neg = (
65
+ "text, watermark, signature, logo, subtitle, ui, frame, border, "
66
+ "dense crowd, many faces, extreme clutter, harsh noise"
67
+ )
68
+
69
+ timeout = float(os.getenv("HF_BACKDROP_T2I_TIMEOUT", str(_DEFAULT_T2I_TIMEOUT)))
70
+ timeout = max(15.0, min(timeout, 180.0))
71
+
72
+ try:
73
+ from huggingface_hub import InferenceClient
74
+ from PIL import Image
75
+ except ImportError as exc:
76
+ meta["error"] = f"missing_dependency:{exc}"
77
+ return None, meta
78
+
79
+ client = InferenceClient(token=token, timeout=timeout)
80
+ model = str(meta["model"])
81
+
82
+ try:
83
+ image = client.text_to_image(
84
+ prompt,
85
+ negative_prompt=neg,
86
+ model=model,
87
+ width=1152,
88
+ height=648,
89
+ )
90
+ except Exception as first:
91
+ meta["first_try_error"] = str(first)[:400]
92
+ try:
93
+ image = client.text_to_image(prompt, negative_prompt=neg, model=model)
94
+ except Exception as second:
95
+ meta["error"] = str(second)[:500]
96
+ logger.warning("setting_backdrop_t2i: failed model=%r err=%s", model, meta["error"])
97
+ return None, meta
98
+
99
+ if not isinstance(image, Image.Image):
100
+ meta["error"] = "unexpected_image_type"
101
+ return None, meta
102
+
103
+ image = image.convert("RGB")
104
+ w, h = image.size
105
+ if w > _MAX_OUTPUT_WIDTH and w > 0:
106
+ nh = max(1, int(h * (_MAX_OUTPUT_WIDTH / float(w))))
107
+ image = image.resize((_MAX_OUTPUT_WIDTH, nh), Image.Resampling.LANCZOS)
108
+
109
+ buf = io.BytesIO()
110
+ image.save(buf, format="JPEG", quality=86, optimize=True)
111
+ raw = buf.getvalue()
112
+ meta["jpeg_bytes"] = len(raw)
113
+ b64 = base64.standard_b64encode(raw).decode("ascii")
114
+ data_url = f"data:image/jpeg;base64,{b64}"
115
+ meta["ok"] = True
116
+ logger.info(
117
+ "setting_backdrop_t2i: ok model=%r jpeg_bytes=%s",
118
+ model,
119
+ meta["jpeg_bytes"],
120
+ )
121
+ return data_url, meta
122
+
123
+
124
+ def backdrop_url_for_trace(url: str | None) -> str:
125
+ """Avoid megabyte-long data URLs inside trace JSON."""
126
+ if not url:
127
+ return ""
128
+ if url.startswith("data:image"):
129
+ return "data:image/jpeg;base64,...(generated)"
130
+ return url[:400]
puppet_theater/backends.py CHANGED
@@ -22,6 +22,15 @@ DEFAULT_HF_API_MODEL_ID = DEFAULT_HF_API_MODEL
22
  OPENBMB_MAX_NEW_TOKENS = 80
23
  OPENBMB_TEMPERATURE = 0.8
24
  HF_API_ACTOR_MAX_TOKENS = 120
 
 
 
 
 
 
 
 
 
25
  HF_API_DIRECTOR_MAX_TOKENS = 180
26
  HF_API_ACTOR_TEMPERATURE = 0.75
27
  HF_API_DIRECTOR_TEMPERATURE = 0.35
@@ -362,6 +371,7 @@ class HFAPIBackend(ModelBackend):
362
  *,
363
  max_tokens: int | None = None,
364
  temperature: float | None = None,
 
365
  ) -> str:
366
  token = _hf_api_token()
367
  if not token:
@@ -378,13 +388,15 @@ class HFAPIBackend(ModelBackend):
378
  raise RuntimeError("huggingface_hub is not installed") from exc
379
 
380
  client = InferenceClient(token=token, timeout=self.timeout_seconds)
 
 
381
  try:
382
  output = client.chat.completions.create(
383
  model=self.model_id,
384
  messages=[
385
  {
386
  "role": "system",
387
- "content": "You are a puppet theater generation backend. Return valid JSON only.",
388
  },
389
  {"role": "user", "content": prompt},
390
  ],
 
22
  OPENBMB_MAX_NEW_TOKENS = 80
23
  OPENBMB_TEMPERATURE = 0.8
24
  HF_API_ACTOR_MAX_TOKENS = 120
25
+ # Show-bible JSON (title, setting, 3 roles, URLs) needs a much larger completion budget than one actor line.
26
+ HF_API_SHOW_BIBLE_MAX_TOKENS = int(os.getenv("HF_API_SHOW_BIBLE_MAX_TOKENS", "1024"))
27
+ HF_API_SHOW_BIBLE_MAX_TOKENS = max(256, min(HF_API_SHOW_BIBLE_MAX_TOKENS, 4096))
28
+ # Summoned single-actor JSON is smaller than full show bible but still needs headroom vs actor-line defaults.
29
+ HF_API_SUMMON_ACTOR_MAX_TOKENS = int(os.getenv("HF_API_SUMMON_ACTOR_MAX_TOKENS", "512"))
30
+ HF_API_SUMMON_ACTOR_MAX_TOKENS = max(128, min(HF_API_SUMMON_ACTOR_MAX_TOKENS, 1024))
31
+
32
+ HF_API_BACKDROP_URL_MAX_TOKENS = int(os.getenv("HF_API_BACKDROP_URL_MAX_TOKENS", "256"))
33
+ HF_API_BACKDROP_URL_MAX_TOKENS = max(64, min(HF_API_BACKDROP_URL_MAX_TOKENS, 512))
34
  HF_API_DIRECTOR_MAX_TOKENS = 180
35
  HF_API_ACTOR_TEMPERATURE = 0.75
36
  HF_API_DIRECTOR_TEMPERATURE = 0.35
 
371
  *,
372
  max_tokens: int | None = None,
373
  temperature: float | None = None,
374
+ system_message: str | None = None,
375
  ) -> str:
376
  token = _hf_api_token()
377
  if not token:
 
388
  raise RuntimeError("huggingface_hub is not installed") from exc
389
 
390
  client = InferenceClient(token=token, timeout=self.timeout_seconds)
391
+ sys_content = system_message or "You are a puppet theater generation backend. Return valid JSON only."
392
+
393
  try:
394
  output = client.chat.completions.create(
395
  model=self.model_id,
396
  messages=[
397
  {
398
  "role": "system",
399
+ "content": sys_content,
400
  },
401
  {"role": "user", "content": prompt},
402
  ],
puppet_theater/director.py CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  from collections import Counter
2
  from dataclasses import dataclass
3
  import json
@@ -21,6 +25,7 @@ from puppet_theater.trace import add_trace_event
21
  from puppet_theater.tools import run_actor_tool_request
22
 
23
 
 
24
  BEAT_ARC = [
25
  "setup",
26
  "denial_or_contradiction",
@@ -30,15 +35,18 @@ BEAT_ARC = [
30
  "finale",
31
  ]
32
 
 
33
  STORY_PHASES = ["opening", "complication", "reveal", "chaos", "finale"]
34
 
35
 
36
  def story_progress(session: TheaterSession) -> float:
 
37
  target_beats = max(1, session.target_beats)
38
  return min(1.0, max(0.0, session.beat_index / target_beats))
39
 
40
 
41
  def story_phase(session: TheaterSession) -> str:
 
42
  progress = story_progress(session)
43
  if progress < 0.20:
44
  return "opening"
@@ -52,6 +60,7 @@ def story_phase(session: TheaterSession) -> str:
52
 
53
 
54
  def should_force_finale(session: TheaterSession) -> bool:
 
55
  return (
56
  session.finale_requested
57
  or session.beat_index >= session.target_beats - 1
@@ -61,6 +70,7 @@ def should_force_finale(session: TheaterSession) -> bool:
61
 
62
  @dataclass(frozen=True)
63
  class DirectorGeneration:
 
64
  decision: DirectorDecision
65
  director_mode: str
66
  model_id: str | None
@@ -72,9 +82,12 @@ class DirectorGeneration:
72
 
73
 
74
  class DirectorPolicy:
 
75
  def decide(self, session: TheaterSession) -> DirectorDecision:
76
  phase = story_phase(session)
77
  progress = story_progress(session)
 
 
78
  if should_force_finale(session):
79
  speaker = self._choose_speaker(session)
80
  return DirectorDecision(
@@ -86,6 +99,7 @@ class DirectorPolicy:
86
  reason_summary="Finale requested, target reached, or final beat reached; closing cleanly.",
87
  )
88
 
 
89
  if session.latest_prop is not None:
90
  speaker = self._choose_speaker(session)
91
  return DirectorDecision(
@@ -97,6 +111,7 @@ class DirectorPolicy:
97
  reason_summary=f"Latest audience prop {session.latest_prop} should affect the next beat.",
98
  )
99
 
 
100
  summoned_actor = self._unused_summoned_actor(session)
101
  if summoned_actor is not None:
102
  return DirectorDecision(
@@ -107,9 +122,11 @@ class DirectorPolicy:
107
  reason_summary=f"Newly summoned actor {summoned_actor.name} has not spoken yet.",
108
  )
109
 
 
110
  beat_type = self._beat_type_for_phase(phase, session)
111
  speaker = self._choose_speaker(session)
112
  reveal_secret = beat_type == "secret_reveal"
 
113
  return DirectorDecision(
114
  next_speaker=speaker.name,
115
  beat_type=beat_type,
@@ -121,16 +138,20 @@ class DirectorPolicy:
121
  )
122
 
123
  def _choose_speaker(self, session: TheaterSession) -> Actor:
 
124
  latest_speaker = session.transcript[-1].speaker if session.transcript else None
125
  counts = Counter(beat.speaker for beat in session.transcript)
126
  candidates = [actor for actor in session.actors if actor.name != latest_speaker] or session.actors
127
  target_index = session.beat_index % len(session.actors)
128
  indexed_actor = session.actors[target_index]
 
129
  if indexed_actor in candidates and counts[indexed_actor.name] <= min(counts[actor.name] for actor in candidates):
130
  return indexed_actor
 
131
  return min(candidates, key=lambda actor: (counts[actor.name], session.actors.index(actor)))
132
 
133
  def _unused_summoned_actor(self, session: TheaterSession) -> Actor | None:
 
134
  spoken = {beat.speaker for beat in session.transcript}
135
  for actor in session.actors[3:]:
136
  if actor.name not in spoken:
@@ -138,6 +159,7 @@ class DirectorPolicy:
138
  return None
139
 
140
  def _instruction_for_beat(self, beat_type: str, reveal_secret: bool) -> str:
 
141
  if reveal_secret:
142
  return "Reveal your secret directly, but keep it playful and stage-ready."
143
  return {
@@ -149,6 +171,7 @@ class DirectorPolicy:
149
  }.get(beat_type, "Keep the scene moving with one short line.")
150
 
151
  def _effect_for_beat(self, beat_type: str) -> str:
 
152
  return {
153
  "setup": "warm_spotlight",
154
  "denial_or_contradiction": "quick_blackout",
@@ -159,6 +182,7 @@ class DirectorPolicy:
159
  }[beat_type]
160
 
161
  def _beat_type_for_phase(self, phase: str, session: TheaterSession) -> str:
 
162
  if phase == "opening":
163
  return "setup" if session.beat_index == 0 else "denial_or_contradiction"
164
  if phase == "complication":
@@ -171,6 +195,7 @@ class DirectorPolicy:
171
 
172
 
173
  class OpenBMBDirectorPolicy:
 
174
  def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
175
  self.fallback_policy = fallback_policy or DirectorPolicy()
176
 
@@ -181,16 +206,19 @@ class OpenBMBDirectorPolicy:
181
  max_new_tokens=session.backend_max_new_tokens,
182
  temperature=session.backend_temperature,
183
  )
 
184
  if not isinstance(backend, OpenBMBTransformersBackend):
185
  return self._fallback(session, "backend_unavailable", start_time)
186
 
187
  prompt = build_director_prompt(session)
 
188
  try:
189
  raw_output = backend._generate_text(prompt)
190
  except Exception as exc:
191
  return self._fallback(session, "backend_error", start_time, _summarize_error(exc), backend.model_id)
192
 
193
  decision, validation_status = parse_director_decision(raw_output, session)
 
194
  if decision is not None:
195
  return DirectorGeneration(
196
  decision=decision,
@@ -202,11 +230,13 @@ class OpenBMBDirectorPolicy:
202
  latency_ms=_elapsed_ms(start_time),
203
  )
204
 
 
205
  repair_prompt = (
206
  f"{prompt}\n\nPrevious output failed validation with status {validation_status}.\n"
207
  "Return only valid JSON matching the required schema. No markdown. No extra keys.\n"
208
  f"Previous output: {raw_output}"
209
  )
 
210
  try:
211
  repair_output = backend._generate_text(repair_prompt)
212
  except Exception as exc:
@@ -219,6 +249,7 @@ class OpenBMBDirectorPolicy:
219
  )
220
 
221
  decision, repair_status = parse_director_decision(repair_output, session)
 
222
  if decision is not None:
223
  return DirectorGeneration(
224
  decision=decision,
@@ -259,6 +290,7 @@ class OpenBMBDirectorPolicy:
259
 
260
 
261
  class HFAPIDirectorPolicy:
 
262
  def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
263
  self.fallback_policy = fallback_policy or DirectorPolicy()
264
 
@@ -269,10 +301,12 @@ class HFAPIDirectorPolicy:
269
  max_new_tokens=HF_API_DIRECTOR_MAX_TOKENS,
270
  temperature=HF_API_DIRECTOR_TEMPERATURE,
271
  )
 
272
  if not isinstance(backend, HFAPIBackend):
273
  return self._fallback(session, "backend_unavailable", start_time)
274
 
275
  prompt = build_director_prompt(session)
 
276
  try:
277
  raw_output = backend._generate_text(
278
  prompt,
@@ -285,6 +319,7 @@ class HFAPIDirectorPolicy:
285
  return generation
286
 
287
  decision, validation_status = parse_director_decision(raw_output, session)
 
288
  if decision is not None:
289
  generation = DirectorGeneration(
290
  decision=decision,
@@ -303,6 +338,7 @@ class HFAPIDirectorPolicy:
303
  "Return only valid JSON matching the required schema. No markdown. No extra keys.\n"
304
  f"Previous output: {raw_output}"
305
  )
 
306
  try:
307
  repair_output = backend._generate_text(
308
  repair_prompt,
@@ -321,6 +357,7 @@ class HFAPIDirectorPolicy:
321
  return generation
322
 
323
  decision, repair_status = parse_director_decision(repair_output, session)
 
324
  if decision is not None:
325
  generation = DirectorGeneration(
326
  decision=decision,
@@ -365,10 +402,12 @@ class HFAPIDirectorPolicy:
365
 
366
 
367
  def choose_director_decision(session: TheaterSession) -> DirectorGeneration:
 
368
  if session.director_mode == "hf_api":
369
  return HFAPIDirectorPolicy().decide(session)
370
  if session.director_mode == "openbmb":
371
  return OpenBMBDirectorPolicy().decide(session)
 
372
  decision = DirectorPolicy().decide(session)
373
  return DirectorGeneration(
374
  decision=decision,
@@ -382,6 +421,7 @@ def choose_director_decision(session: TheaterSession) -> DirectorGeneration:
382
 
383
 
384
  def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
 
385
  if session is None:
386
  return None
387
 
@@ -397,10 +437,13 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
397
 
398
  current_phase = story_phase(session)
399
  current_progress = story_progress(session)
 
400
  director_generation = choose_director_decision(session)
401
  decision = director_generation.decision
 
402
  speaker = _actor_by_name(session, decision.next_speaker)
403
  prop = session.latest_prop if decision.uses_prop else None
 
404
  if prop is not None:
405
  speaker.held_prop = prop
406
 
@@ -408,18 +451,22 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
408
  "Director decision: "
409
  f"{decision.beat_type} for {speaker.name}; {decision.reason_summary}"
410
  )
 
411
  session.director_log.append(
412
  f"Story phase {current_phase} at {current_progress:.0%} progress "
413
  f"(min={session.min_beats}, target={session.target_beats}, max={session.max_beats})."
414
  )
 
415
  session.director_log.append(
416
  "Director mode "
417
  f"{director_generation.director_mode} "
418
  f"({director_generation.validation_status}, fallback={director_generation.fallback_used}, "
419
  f"latency={director_generation.latency_ms}ms)."
420
  )
 
421
  if director_generation.model_id:
422
  session.director_log.append(f"Director model id: {director_generation.model_id}.")
 
423
  if director_generation.error:
424
  session.director_log.append(f"Director fallback reason: {director_generation.error}.")
425
  add_trace_event(
@@ -467,6 +514,7 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
467
  )
468
 
469
  backend_generation = generate_actor_response(session, decision, speaker, prop)
 
470
  session.beat_index += 1
471
 
472
  response = backend_generation.response
@@ -480,9 +528,13 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
480
  memory_update=response.memory_update,
481
  tool_request=response.tool_request,
482
  )
 
483
  session.transcript.append(beat)
 
484
  state_update = apply_actor_state_update(session, speaker, response, decision, prop)
 
485
  tool_result = run_actor_tool_request(session, speaker, response.tool_request)
 
486
  if tool_result is not None and tool_result.stage_effect:
487
  beat.stage_effect = tool_result.stage_effect
488
 
@@ -599,15 +651,18 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
599
 
600
 
601
  def run_full_act(session: TheaterSession | None) -> TheaterSession | None:
 
602
  if session is None:
603
  return None
604
 
605
  while session.beat_index < session.max_beats:
606
  run_one_beat(session)
 
607
  return session
608
 
609
 
610
  def _actor_by_name(session: TheaterSession, actor_name: str) -> Actor:
 
611
  for actor in session.actors:
612
  if actor.name == actor_name:
613
  return actor
@@ -621,6 +676,7 @@ def apply_actor_state_update(
621
  decision: DirectorDecision,
622
  prop: str | None,
623
  ) -> dict[str, str]:
 
624
  intent = _short_public_text(getattr(response, "intent", "") or "Respond to the Director's cue.", speaker, 90)
625
  memory_update = _short_public_text(getattr(response, "memory_update", "") or "", speaker, 140)
626
  emotion = _short_public_text(getattr(response, "emotion", "") or "focused", speaker, 40)
@@ -649,6 +705,7 @@ def apply_actor_state_update(
649
 
650
 
651
  def build_director_prompt(session: TheaterSession) -> str:
 
652
  actor_profiles = "\n".join(
653
  "- "
654
  f"{actor.name}: goal={actor.goal}; style={actor.speaking_style}; "
 
1
+ # This file is the core “director + runtime engine” of the puppet theater system.
2
+ # It controls story progression, selects speakers, validates AI decisions,
3
+ # runs one beat at a time, and manages both deterministic and LLM-based directors.
4
+
5
  from collections import Counter
6
  from dataclasses import dataclass
7
  import json
 
25
  from puppet_theater.tools import run_actor_tool_request
26
 
27
 
28
+ # Allowed narrative beats (types of actions that can happen in the story)
29
  BEAT_ARC = [
30
  "setup",
31
  "denial_or_contradiction",
 
35
  "finale",
36
  ]
37
 
38
+ # High-level story phases derived from progress
39
  STORY_PHASES = ["opening", "complication", "reveal", "chaos", "finale"]
40
 
41
 
42
  def story_progress(session: TheaterSession) -> float:
43
+ # Returns normalized progress (0.0 to 1.0) based on beat progression
44
  target_beats = max(1, session.target_beats)
45
  return min(1.0, max(0.0, session.beat_index / target_beats))
46
 
47
 
48
  def story_phase(session: TheaterSession) -> str:
49
+ # Converts numeric progress into narrative phase buckets
50
  progress = story_progress(session)
51
  if progress < 0.20:
52
  return "opening"
 
60
 
61
 
62
  def should_force_finale(session: TheaterSession) -> bool:
63
+ # Determines if story should end early due to constraints or user request
64
  return (
65
  session.finale_requested
66
  or session.beat_index >= session.target_beats - 1
 
70
 
71
  @dataclass(frozen=True)
72
  class DirectorGeneration:
73
+ # Wrapper around a director decision plus metadata about how it was generated
74
  decision: DirectorDecision
75
  director_mode: str
76
  model_id: str | None
 
82
 
83
 
84
  class DirectorPolicy:
85
+ # Deterministic rule-based director (fallback when no AI is used)
86
  def decide(self, session: TheaterSession) -> DirectorDecision:
87
  phase = story_phase(session)
88
  progress = story_progress(session)
89
+
90
+ # Force finale conditions override everything else
91
  if should_force_finale(session):
92
  speaker = self._choose_speaker(session)
93
  return DirectorDecision(
 
99
  reason_summary="Finale requested, target reached, or final beat reached; closing cleanly.",
100
  )
101
 
102
+ # Audience prop takes priority in narrative
103
  if session.latest_prop is not None:
104
  speaker = self._choose_speaker(session)
105
  return DirectorDecision(
 
111
  reason_summary=f"Latest audience prop {session.latest_prop} should affect the next beat.",
112
  )
113
 
114
+ # Newly summoned actors should get priority spotlight
115
  summoned_actor = self._unused_summoned_actor(session)
116
  if summoned_actor is not None:
117
  return DirectorDecision(
 
122
  reason_summary=f"Newly summoned actor {summoned_actor.name} has not spoken yet.",
123
  )
124
 
125
+ # Default storytelling logic based on phase
126
  beat_type = self._beat_type_for_phase(phase, session)
127
  speaker = self._choose_speaker(session)
128
  reveal_secret = beat_type == "secret_reveal"
129
+
130
  return DirectorDecision(
131
  next_speaker=speaker.name,
132
  beat_type=beat_type,
 
138
  )
139
 
140
  def _choose_speaker(self, session: TheaterSession) -> Actor:
141
+ # Picks next actor fairly based on usage + rotation
142
  latest_speaker = session.transcript[-1].speaker if session.transcript else None
143
  counts = Counter(beat.speaker for beat in session.transcript)
144
  candidates = [actor for actor in session.actors if actor.name != latest_speaker] or session.actors
145
  target_index = session.beat_index % len(session.actors)
146
  indexed_actor = session.actors[target_index]
147
+
148
  if indexed_actor in candidates and counts[indexed_actor.name] <= min(counts[actor.name] for actor in candidates):
149
  return indexed_actor
150
+
151
  return min(candidates, key=lambda actor: (counts[actor.name], session.actors.index(actor)))
152
 
153
  def _unused_summoned_actor(self, session: TheaterSession) -> Actor | None:
154
+ # Returns summoned actors who haven't spoken yet
155
  spoken = {beat.speaker for beat in session.transcript}
156
  for actor in session.actors[3:]:
157
  if actor.name not in spoken:
 
159
  return None
160
 
161
  def _instruction_for_beat(self, beat_type: str, reveal_secret: bool) -> str:
162
+ # Maps beat type to actor instruction
163
  if reveal_secret:
164
  return "Reveal your secret directly, but keep it playful and stage-ready."
165
  return {
 
171
  }.get(beat_type, "Keep the scene moving with one short line.")
172
 
173
  def _effect_for_beat(self, beat_type: str) -> str:
174
+ # Maps beat type to stage visual effect
175
  return {
176
  "setup": "warm_spotlight",
177
  "denial_or_contradiction": "quick_blackout",
 
182
  }[beat_type]
183
 
184
  def _beat_type_for_phase(self, phase: str, session: TheaterSession) -> str:
185
+ # Chooses beat type based on story phase progression
186
  if phase == "opening":
187
  return "setup" if session.beat_index == 0 else "denial_or_contradiction"
188
  if phase == "complication":
 
195
 
196
 
197
  class OpenBMBDirectorPolicy:
198
+ # Director powered by OpenBMB transformer backend
199
  def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
200
  self.fallback_policy = fallback_policy or DirectorPolicy()
201
 
 
206
  max_new_tokens=session.backend_max_new_tokens,
207
  temperature=session.backend_temperature,
208
  )
209
+
210
  if not isinstance(backend, OpenBMBTransformersBackend):
211
  return self._fallback(session, "backend_unavailable", start_time)
212
 
213
  prompt = build_director_prompt(session)
214
+
215
  try:
216
  raw_output = backend._generate_text(prompt)
217
  except Exception as exc:
218
  return self._fallback(session, "backend_error", start_time, _summarize_error(exc), backend.model_id)
219
 
220
  decision, validation_status = parse_director_decision(raw_output, session)
221
+
222
  if decision is not None:
223
  return DirectorGeneration(
224
  decision=decision,
 
230
  latency_ms=_elapsed_ms(start_time),
231
  )
232
 
233
+ # Repair attempt when output is invalid
234
  repair_prompt = (
235
  f"{prompt}\n\nPrevious output failed validation with status {validation_status}.\n"
236
  "Return only valid JSON matching the required schema. No markdown. No extra keys.\n"
237
  f"Previous output: {raw_output}"
238
  )
239
+
240
  try:
241
  repair_output = backend._generate_text(repair_prompt)
242
  except Exception as exc:
 
249
  )
250
 
251
  decision, repair_status = parse_director_decision(repair_output, session)
252
+
253
  if decision is not None:
254
  return DirectorGeneration(
255
  decision=decision,
 
290
 
291
 
292
  class HFAPIDirectorPolicy:
293
+ # Director using HuggingFace API backend
294
  def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
295
  self.fallback_policy = fallback_policy or DirectorPolicy()
296
 
 
301
  max_new_tokens=HF_API_DIRECTOR_MAX_TOKENS,
302
  temperature=HF_API_DIRECTOR_TEMPERATURE,
303
  )
304
+
305
  if not isinstance(backend, HFAPIBackend):
306
  return self._fallback(session, "backend_unavailable", start_time)
307
 
308
  prompt = build_director_prompt(session)
309
+
310
  try:
311
  raw_output = backend._generate_text(
312
  prompt,
 
319
  return generation
320
 
321
  decision, validation_status = parse_director_decision(raw_output, session)
322
+
323
  if decision is not None:
324
  generation = DirectorGeneration(
325
  decision=decision,
 
338
  "Return only valid JSON matching the required schema. No markdown. No extra keys.\n"
339
  f"Previous output: {raw_output}"
340
  )
341
+
342
  try:
343
  repair_output = backend._generate_text(
344
  repair_prompt,
 
357
  return generation
358
 
359
  decision, repair_status = parse_director_decision(repair_output, session)
360
+
361
  if decision is not None:
362
  generation = DirectorGeneration(
363
  decision=decision,
 
402
 
403
 
404
  def choose_director_decision(session: TheaterSession) -> DirectorGeneration:
405
+ # Router that selects deterministic vs AI directors
406
  if session.director_mode == "hf_api":
407
  return HFAPIDirectorPolicy().decide(session)
408
  if session.director_mode == "openbmb":
409
  return OpenBMBDirectorPolicy().decide(session)
410
+
411
  decision = DirectorPolicy().decide(session)
412
  return DirectorGeneration(
413
  decision=decision,
 
421
 
422
 
423
  def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
424
+ # Executes one full story cycle: director → actor → state update → logs
425
  if session is None:
426
  return None
427
 
 
437
 
438
  current_phase = story_phase(session)
439
  current_progress = story_progress(session)
440
+
441
  director_generation = choose_director_decision(session)
442
  decision = director_generation.decision
443
+
444
  speaker = _actor_by_name(session, decision.next_speaker)
445
  prop = session.latest_prop if decision.uses_prop else None
446
+
447
  if prop is not None:
448
  speaker.held_prop = prop
449
 
 
451
  "Director decision: "
452
  f"{decision.beat_type} for {speaker.name}; {decision.reason_summary}"
453
  )
454
+
455
  session.director_log.append(
456
  f"Story phase {current_phase} at {current_progress:.0%} progress "
457
  f"(min={session.min_beats}, target={session.target_beats}, max={session.max_beats})."
458
  )
459
+
460
  session.director_log.append(
461
  "Director mode "
462
  f"{director_generation.director_mode} "
463
  f"({director_generation.validation_status}, fallback={director_generation.fallback_used}, "
464
  f"latency={director_generation.latency_ms}ms)."
465
  )
466
+
467
  if director_generation.model_id:
468
  session.director_log.append(f"Director model id: {director_generation.model_id}.")
469
+
470
  if director_generation.error:
471
  session.director_log.append(f"Director fallback reason: {director_generation.error}.")
472
  add_trace_event(
 
514
  )
515
 
516
  backend_generation = generate_actor_response(session, decision, speaker, prop)
517
+
518
  session.beat_index += 1
519
 
520
  response = backend_generation.response
 
528
  memory_update=response.memory_update,
529
  tool_request=response.tool_request,
530
  )
531
+
532
  session.transcript.append(beat)
533
+
534
  state_update = apply_actor_state_update(session, speaker, response, decision, prop)
535
+
536
  tool_result = run_actor_tool_request(session, speaker, response.tool_request)
537
+
538
  if tool_result is not None and tool_result.stage_effect:
539
  beat.stage_effect = tool_result.stage_effect
540
 
 
651
 
652
 
653
  def run_full_act(session: TheaterSession | None) -> TheaterSession | None:
654
+ # Runs the full play until completion
655
  if session is None:
656
  return None
657
 
658
  while session.beat_index < session.max_beats:
659
  run_one_beat(session)
660
+
661
  return session
662
 
663
 
664
  def _actor_by_name(session: TheaterSession, actor_name: str) -> Actor:
665
+ # Lookup actor safely, fallback to rotation if missing
666
  for actor in session.actors:
667
  if actor.name == actor_name:
668
  return actor
 
676
  decision: DirectorDecision,
677
  prop: str | None,
678
  ) -> dict[str, str]:
679
+ # Updates actor state: mood, memory, secret status, goals
680
  intent = _short_public_text(getattr(response, "intent", "") or "Respond to the Director's cue.", speaker, 90)
681
  memory_update = _short_public_text(getattr(response, "memory_update", "") or "", speaker, 140)
682
  emotion = _short_public_text(getattr(response, "emotion", "") or "focused", speaker, 40)
 
705
 
706
 
707
  def build_director_prompt(session: TheaterSession) -> str:
708
+ # Builds LLM prompt with full story context
709
  actor_profiles = "\n".join(
710
  "- "
711
  f"{actor.name}: goal={actor.goal}; style={actor.speaking_style}; "
puppet_theater/models.py CHANGED
@@ -6,6 +6,27 @@ from typing import Any, Literal
6
 
7
  from pydantic import BaseModel, Field, ValidationInfo, field_validator
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  BeatType = Literal[
11
  "setup",
@@ -21,6 +42,13 @@ SimpleToolValue = str | int | float | bool | None
21
 
22
 
23
  class ToolRequest(BaseModel):
 
 
 
 
 
 
 
24
  tool_name: str
25
  arguments: Any = Field(default_factory=dict)
26
  reason: str
@@ -42,6 +70,21 @@ class ToolRequest(BaseModel):
42
 
43
 
44
  class ActorResponse(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  intent: str = Field(default="Keep the scene moving.", description="Short visible actor intention.")
46
  line: str = Field(description="Short, stage-ready puppet dialogue.")
47
  emotion: str
@@ -113,11 +156,12 @@ class DirectorDecision(BaseModel):
113
  @dataclass
114
  class Actor:
115
  name: str
116
- avatar: str
117
  goal: str
118
  secret: str
119
  speaking_style: str
120
  tools: list[str] = field(default_factory=list)
 
121
  held_prop: str | None = None
122
  mood: str = "ready"
123
  current_goal: str | None = None
@@ -155,6 +199,8 @@ class TheaterSession:
155
  premise: str
156
  setting: str
157
  actors: list[Actor]
 
 
158
  session_id: str = field(default_factory=lambda: uuid4().hex[:12])
159
  created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
160
  beat_index: int = 0
 
6
 
7
  from pydantic import BaseModel, Field, ValidationInfo, field_validator
8
 
9
+ """
10
+ Core data models for the puppet theater simulation.
11
+
12
+ This module defines the structured objects used throughout a theater session,
13
+ including:
14
+
15
+ - Actors and their state (goals, secrets, mood, props, memory)
16
+ - Director decisions that guide story progression
17
+ - Actor responses for each scene beat
18
+ - Tool requests and tool execution results
19
+ - Individual beats in the transcript
20
+ - The overall theater session state and configuration
21
+
22
+ Pydantic models are used where validation is required for LLM-generated
23
+ outputs (e.g. actor responses and director decisions), while dataclasses
24
+ are used for runtime session state and domain entities.
25
+
26
+ In short: this file defines the data structures that represent the
27
+ story, characters, dialogue, stage actions, and session state of
28
+ the puppet theater engine.
29
+ """
30
 
31
  BeatType = Literal[
32
  "setup",
 
42
 
43
 
44
  class ToolRequest(BaseModel):
45
+ """
46
+ Represents a request to call a tool.
47
+
48
+ Pydantic automatically validates the input data when an
49
+ instance is created. Ensures the tool name and reason are
50
+ non-empty and that the reason remains concise.
51
+ """
52
  tool_name: str
53
  arguments: Any = Field(default_factory=dict)
54
  reason: str
 
70
 
71
 
72
  class ActorResponse(BaseModel):
73
+ """
74
+ Represents a structured response from a puppet actor for one scene beat.
75
+
76
+ Includes:
77
+ - intent: actor's short goal for this beat
78
+ - line: dialogue spoken on stage
79
+ - emotion: actor's emotional state
80
+ - gesture: physical action performed
81
+ - stage_effect: environmental/stage effect
82
+ - memory_update: optional note carried to future beats
83
+ - tool_request: optional request to use a tool
84
+
85
+ Validators enforce length limits, remove extra whitespace,
86
+ and prevent required fields from being empty.
87
+ """
88
  intent: str = Field(default="Keep the scene moving.", description="Short visible actor intention.")
89
  line: str = Field(description="Short, stage-ready puppet dialogue.")
90
  emotion: str
 
156
  @dataclass
157
  class Actor:
158
  name: str
159
+ avatar: str # Emoji or short label; used if avatar_image_url is unset/invalid.
160
  goal: str
161
  secret: str
162
  speaking_style: str
163
  tools: list[str] = field(default_factory=list)
164
+ avatar_image_url: str | None = None # Optional HTTPS portrait for the stage card.
165
  held_prop: str | None = None
166
  mood: str = "ready"
167
  current_goal: str | None = None
 
199
  premise: str
200
  setting: str
201
  actors: list[Actor]
202
+ backdrop_image_url: str | None = None # Optional HTTPS image layered behind stage copy.
203
+ backdrop_description: str | None = None # LLM minimal art-direction text used to pick backdrop_image_url.
204
  session_id: str = field(default_factory=lambda: uuid4().hex[:12])
205
  created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
206
  beat_index: int = 0
puppet_theater/session.py CHANGED
@@ -1,16 +1,45 @@
 
 
 
 
 
 
1
  from puppet_theater.models import Actor, TheaterSession
 
 
 
 
 
 
 
 
 
 
 
2
  from puppet_theater.trace import add_trace_event
3
 
 
4
 
 
 
 
5
  SHOW_LENGTH_PRESETS: dict[str, tuple[int, int, int]] = {
6
  "short": (5, 7, 8),
7
  "standard": (7, 10, 12),
8
  "extended": (10, 14, 16),
9
  }
 
 
10
  DEFAULT_SHOW_LENGTH = "standard"
11
 
12
 
13
  def resolve_show_length(show_length: str | None = None) -> tuple[str, int, int, int]:
 
 
 
 
 
 
14
  normalized = (show_length or DEFAULT_SHOW_LENGTH).strip().lower()
15
  if normalized not in SHOW_LENGTH_PRESETS:
16
  normalized = DEFAULT_SHOW_LENGTH
@@ -19,11 +48,25 @@ def resolve_show_length(show_length: str | None = None) -> tuple[str, int, int,
19
 
20
 
21
  def _clean_premise(premise: str) -> str:
 
 
 
 
22
  cleaned = " ".join(premise.strip().split())
23
  return cleaned or "A mysterious puppet show with no premise"
24
 
25
 
26
  def _title_from_premise(premise: str) -> str:
 
 
 
 
 
 
 
 
 
 
27
  words = [word.strip(".,!?;:()[]{}\"'") for word in premise.split()]
28
  keywords = [word.title() for word in words if len(word.strip(".,!?;:()[]{}\"'")) > 3]
29
  if not keywords:
@@ -32,6 +75,16 @@ def _title_from_premise(premise: str) -> str:
32
 
33
 
34
  def _setting_from_premise(premise: str) -> str:
 
 
 
 
 
 
 
 
 
 
35
  lowered = premise.lower()
36
  if "moon" in lowered or "space" in lowered or "star" in lowered:
37
  return "a cardboard moon base with glittery stars and a squeaky hatch"
@@ -44,21 +97,110 @@ def _setting_from_premise(premise: str) -> str:
44
  return "a pocket-sized improv stage with painted flats and a wobbly spotlight"
45
 
46
 
47
- def create_show_from_premise(
48
- premise: str,
49
- backend_name: str = "deterministic",
50
- backend_model_id: str | None = None,
51
- backend_max_new_tokens: int = 120,
52
- backend_temperature: float = 0.75,
53
- director_mode: str = "deterministic",
54
- show_length: str = DEFAULT_SHOW_LENGTH,
55
- ) -> TheaterSession:
56
- cleaned_premise = _clean_premise(premise)
57
- show_title = _title_from_premise(cleaned_premise)
58
- setting = _setting_from_premise(cleaned_premise)
59
- active_show_length, min_beats, target_beats, max_beats = resolve_show_length(show_length)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- actors = [
 
 
 
62
  Actor(
63
  name="Pip the Director",
64
  avatar="🎬",
@@ -85,23 +227,279 @@ def create_show_from_premise(
85
  ),
86
  ]
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  supported_generation_modes = {"deterministic", "openbmb", "hf_api", "local_lora", "local_gguf"}
89
  active_backend = backend_name if backend_name in supported_generation_modes else "deterministic"
90
  active_director_mode = director_mode if director_mode in supported_generation_modes else "deterministic"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  model_note = f" ({backend_model_id})" if backend_model_id else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  director_log = [
93
  f"Director created a {active_show_length} show plan.",
94
  f"Active backend: {active_backend}{model_note}.",
95
  f"Director mode: {active_director_mode}.",
96
  f"Show length: min {min_beats}, target {target_beats}, max {max_beats} beats.",
97
- f"Setting selected: {setting}.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  "Three puppet actors are waiting for the first beat.",
99
  ]
 
 
100
  session = TheaterSession(
101
  show_title=show_title,
102
  premise=cleaned_premise,
103
  setting=setting,
104
  actors=actors,
 
 
105
  beat_index=0,
106
  min_beats=min_beats,
107
  target_beats=target_beats,
@@ -121,6 +519,29 @@ def create_show_from_premise(
121
  director_mode=active_director_mode,
122
  play_opening_curtain=True,
123
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  add_trace_event(
125
  session,
126
  "show_created",
@@ -133,9 +554,13 @@ def create_show_from_premise(
133
  max_beats=max_beats,
134
  actor_count=len(actors),
135
  validation_status="valid",
136
- fallback_used=False,
 
 
137
  )
 
138
  add_trace_event(session, "actors_created", actor_count=len(actors))
 
139
  add_trace_event(
140
  session,
141
  "director_plan_created",
@@ -143,4 +568,5 @@ def create_show_from_premise(
143
  story_phase="opening",
144
  reason_summary=f"{active_show_length.title()} progress-based show plan created.",
145
  )
146
- return session
 
 
1
+ # This file builds a "puppet theater show session" from a given story premise.
2
+ # It converts a short idea (premise) into a structured theatrical session with actors,
3
+ # a setting, and metadata used to simulate a puppet show performance.
4
+
5
+ import logging
6
+
7
  from puppet_theater.models import Actor, TheaterSession
8
+ from puppet_theater.backdrop_gen import (
9
+ backdrop_url_for_trace,
10
+ setting_backdrop_t2i_enabled,
11
+ try_setting_backdrop_data_url,
12
+ )
13
+ from puppet_theater.show_bible import (
14
+ invoke_show_bible_llm,
15
+ llm_backend_order,
16
+ parse_show_bible_response,
17
+ resolve_backdrop_image_url_via_llm,
18
+ )
19
  from puppet_theater.trace import add_trace_event
20
 
21
+ logger = logging.getLogger(__name__)
22
 
23
+ # These are predefined show length configurations.
24
+ # Each tuple represents: (minimum beats, target beats, maximum beats)
25
+ # "beats" can be thought of as story steps or scene turns in the puppet show.
26
  SHOW_LENGTH_PRESETS: dict[str, tuple[int, int, int]] = {
27
  "short": (5, 7, 8),
28
  "standard": (7, 10, 12),
29
  "extended": (10, 14, 16),
30
  }
31
+
32
+ # Default show length used when no preference is provided
33
  DEFAULT_SHOW_LENGTH = "standard"
34
 
35
 
36
  def resolve_show_length(show_length: str | None = None) -> tuple[str, int, int, int]:
37
+ """
38
+ Converts a show length label (like "short", "standard", "extended")
39
+ into actual numeric constraints (min, target, max beats).
40
+
41
+ If the input is invalid or None, it falls back to DEFAULT_SHOW_LENGTH.
42
+ """
43
  normalized = (show_length or DEFAULT_SHOW_LENGTH).strip().lower()
44
  if normalized not in SHOW_LENGTH_PRESETS:
45
  normalized = DEFAULT_SHOW_LENGTH
 
48
 
49
 
50
  def _clean_premise(premise: str) -> str:
51
+ """
52
+ Cleans up the input premise by removing extra spaces and normalizing it.
53
+ If the premise is empty after cleanup, returns a fallback default story idea.
54
+ """
55
  cleaned = " ".join(premise.strip().split())
56
  return cleaned or "A mysterious puppet show with no premise"
57
 
58
 
59
  def _title_from_premise(premise: str) -> str:
60
+ """
61
+ Generates a show title based on important words from the premise.
62
+
63
+ - Splits the premise into words
64
+ - Removes punctuation
65
+ - Picks words longer than 3 characters
66
+ - Capitalizes them and uses up to 4 words for the title
67
+
68
+ If no good words exist, returns a default title.
69
+ """
70
  words = [word.strip(".,!?;:()[]{}\"'") for word in premise.split()]
71
  keywords = [word.title() for word in words if len(word.strip(".,!?;:()[]{}\"'")) > 3]
72
  if not keywords:
 
75
 
76
 
77
  def _setting_from_premise(premise: str) -> str:
78
+ """
79
+ Chooses a stage setting based on keywords in the premise.
80
+
81
+ This is a simple rule-based system:
82
+ - space/moon/star → space-themed stage
83
+ - castle/dragon/wizard → fantasy castle stage
84
+ - detective/mystery → noir detective setting
85
+ - kitchen/chef/toaster → kitchen stage
86
+ - otherwise → generic puppet stage
87
+ """
88
  lowered = premise.lower()
89
  if "moon" in lowered or "space" in lowered or "star" in lowered:
90
  return "a cardboard moon base with glittery stars and a squeaky hatch"
 
97
  return "a pocket-sized improv stage with painted flats and a wobbly spotlight"
98
 
99
 
100
+ # Wide Unsplash images (curated IDs) used only when the LLM omits or invalidates backdrop_image_url
101
+ # (see create_show_from_premise: llm_backdrop_url or keyword fallback).
102
+ _DEFAULT_BACKDROP_URL = (
103
+ "https://images.unsplash.com/photo-1578662996442-48f60103fc96"
104
+ "?auto=format&fit=crop&w=1600&q=80"
105
+ )
106
+
107
+ _BACKDROP_RULES: tuple[tuple[tuple[str, ...], str], ...] = (
108
+ (
109
+ (
110
+ "moon",
111
+ "space",
112
+ "star",
113
+ "orbit",
114
+ "galaxy",
115
+ "mars",
116
+ "alien",
117
+ "planet",
118
+ "rocket",
119
+ "astronaut",
120
+ "comet",
121
+ "lunar",
122
+ "cosmos",
123
+ ),
124
+ "https://images.unsplash.com/photo-1516339901601-2e1b62dc0c45?auto=format&fit=crop&w=1600&q=80",
125
+ ),
126
+ (
127
+ ("kitchen", "cook", "chef", "toast", "oven", "recipe", "pan", "stove", "fridge", "cupcake", "tea"),
128
+ "https://images.unsplash.com/photo-1556912173-3c541015bf3b?auto=format&fit=crop&w=1600&q=80",
129
+ ),
130
+ (
131
+ ("castle", "dragon", "wizard", "knight", "sword", "enchant", "fairy", "throne", "dungeon"),
132
+ "https://images.unsplash.com/photo-1518173946689-a94480f4bd0e?auto=format&fit=crop&w=1600&q=80",
133
+ ),
134
+ (
135
+ (
136
+ "detective",
137
+ "mystery",
138
+ "noir",
139
+ "crime",
140
+ "alley",
141
+ "shadow",
142
+ "clue",
143
+ "murder",
144
+ "case file",
145
+ "interrogat",
146
+ ),
147
+ "https://images.unsplash.com/photo-1428908728789-d2de25dbd4e0?auto=format&fit=crop&w=1600&q=80",
148
+ ),
149
+ (
150
+ ("ocean", "sea", "beach", "wave", "sail", "island", "submarine", "whale", "harbor", "pirate"),
151
+ "https://images.unsplash.com/photo-1505118380757-91f5f5632ce0?auto=format&fit=crop&w=1600&q=80",
152
+ ),
153
+ (
154
+ ("forest", "wood", "tree", "cabin", "hike", "camp", "bear", "owl", "moss"),
155
+ "https://images.unsplash.com/photo-1448375260088-37575c2fbe04?auto=format&fit=crop&w=1600&q=80",
156
+ ),
157
+ (
158
+ ("school", "classroom", "student", "teacher", "homework", "blackboard", "campus"),
159
+ "https://images.unsplash.com/photo-1580582932707-520aed937a7e?auto=format&fit=crop&w=1600&q=80",
160
+ ),
161
+ (
162
+ ("library", "book", "scroll", "archive", "museum", "gallery"),
163
+ "https://images.unsplash.com/photo-1507842217343-303bb9a4036a?auto=format&fit=crop&w=1600&q=80",
164
+ ),
165
+ (
166
+ ("circus", "carnival", "tent", "acrobat", "clown", "trapeze"),
167
+ "https://images.unsplash.com/photo-1508807526345-15e9b5f4eaff?auto=format&fit=crop&w=1600&q=80",
168
+ ),
169
+ (
170
+ ("desert", "cactus", "dune", "mirage", "oasis"),
171
+ "https://images.unsplash.com/photo-1509316785289-025f5b846b35?auto=format&fit=crop&w=1600&q=80",
172
+ ),
173
+ (
174
+ ("winter", "snow", "blizzard", "frost", "igloo", "icicle", "snowfall"),
175
+ "https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=1600&q=80",
176
+ ),
177
+ (
178
+ ("hospital", "doctor", "nurse", "clinic", "surgery", "medic"),
179
+ "https://images.unsplash.com/photo-1519494026892-80bbd2d6fd0d?auto=format&fit=crop&w=1600&q=80",
180
+ ),
181
+ (
182
+ ("train", "station", "locomotive", "railway", "subway"),
183
+ "https://images.unsplash.com/photo-1474487548417-781cb71445bb?auto=format&fit=crop&w=1600&q=80",
184
+ ),
185
+ )
186
+
187
+
188
+ def _backdrop_url_from_premise_and_setting(premise: str, setting: str) -> str:
189
+ """
190
+ Fallback when the show bible did not yield a usable backdrop_image_url.
191
+
192
+ Matches keywords in premise + setting and returns a curated wide image, else _DEFAULT_BACKDROP_URL.
193
+ """
194
+ hay = f"{premise} {setting}".lower()
195
+ for keywords, url in _BACKDROP_RULES:
196
+ if any(k in hay for k in keywords):
197
+ return url
198
+ return _DEFAULT_BACKDROP_URL
199
 
200
+
201
+ def _default_cast() -> list[Actor]:
202
+ """Fallback puppets when no LLM cast is available."""
203
+ return [
204
  Actor(
205
  name="Pip the Director",
206
  avatar="🎬",
 
227
  ),
228
  ]
229
 
230
+
231
+ _REMINDER_SUFFIX = (
232
+ "\n\nReminder: respond with exactly one JSON object using only these top-level keys: "
233
+ "show_title, setting, backdrop_description, director, puppet_actors. "
234
+ "backdrop_description: two short sentences, minimal uncluttered background for puppets (see Rules). "
235
+ "The director and each puppet entry must include name, avatar, goal, secret, speaking_style, tools."
236
+ )
237
+
238
+
239
+ def _resolve_show_content_from_llm_or_defaults(
240
+ cleaned_premise: str,
241
+ backend_name: str,
242
+ director_mode: str,
243
+ backend_max_new_tokens: int,
244
+ backend_temperature: float,
245
+ ) -> tuple[str, str, str | None, list[Actor], str | None, bool, list[dict[str, object]], str | None]:
246
+ """
247
+ Returns show_title, setting, backdrop_image_url, actors, llm_source_or_none, cast_fallback_used,
248
+ cast_attempt_log, and backdrop_description (LLM minimal art direction, or None).
249
+
250
+ cast_fallback_used is True only when at least one LLM-capable backend was tried and none
251
+ returned a valid show bible (so the built-in cast and heuristic title/setting are used).
252
+ """
253
+ fallback_title = _title_from_premise(cleaned_premise)
254
+ fallback_setting = _setting_from_premise(cleaned_premise)
255
+ default_actors = _default_cast()
256
+ candidates = llm_backend_order(director_mode, backend_name)
257
+ attempt_log: list[dict[str, object]] = []
258
+
259
+ if not candidates:
260
+ logger.info(
261
+ "premise_cast: no LLM backends in order (director_mode=%r backend_name=%r); "
262
+ "using heuristic title=%r heuristic_setting_chars=%s",
263
+ director_mode,
264
+ backend_name,
265
+ fallback_title,
266
+ len(fallback_setting),
267
+ )
268
+ return fallback_title, fallback_setting, None, default_actors, None, False, attempt_log, None
269
+
270
+ logger.info(
271
+ "premise_cast: trying backends %s (premise_len=%s max_new_tokens=%s temp=%s)",
272
+ candidates,
273
+ len(cleaned_premise),
274
+ backend_max_new_tokens,
275
+ backend_temperature,
276
+ )
277
+
278
+ for mode in candidates:
279
+ suffixes: tuple[str, ...] = ("", _REMINDER_SUFFIX) if mode in {"local_lora", "local_gguf"} else ("",)
280
+ for suffix in suffixes:
281
+ entry: dict[str, object] = {
282
+ "backend": mode,
283
+ "with_schema_reminder": bool(suffix),
284
+ }
285
+ try:
286
+ raw = invoke_show_bible_llm(
287
+ mode,
288
+ cleaned_premise,
289
+ max_new_tokens=backend_max_new_tokens,
290
+ temperature=backend_temperature,
291
+ extra_user_suffix=suffix,
292
+ )
293
+ entry["raw_char_len"] = len(raw)
294
+ entry["raw_preview"] = raw[:450]
295
+ parsed = parse_show_bible_response(raw)
296
+ entry["parsed_ok"] = parsed is not None
297
+ if parsed is None:
298
+ attempt_log.append(entry)
299
+ logger.info(
300
+ "premise_cast: parse_miss backend=%r reminder=%s raw_len=%s raw_head=%r",
301
+ mode,
302
+ bool(suffix),
303
+ len(raw),
304
+ raw[:240],
305
+ )
306
+ continue
307
+ title, setting, backdrop_desc, actors = parsed
308
+ entry["resolved_show_title"] = title
309
+ entry["resolved_actor_names"] = [a.name for a in actors]
310
+ entry["backdrop_description"] = (backdrop_desc or "")[:320]
311
+ if setting_backdrop_t2i_enabled():
312
+ entry["backdrop_url_resolution"] = {"deferred": "hf_setting_text_to_image"}
313
+ entry["has_backdrop_url"] = False
314
+ attempt_log.append(entry)
315
+ logger.info(
316
+ "premise_cast: ok backend=%r title=%r setting_len=%s actors=%s backdrop_url=deferred_t2i",
317
+ mode,
318
+ title,
319
+ len(setting),
320
+ [a.name for a in actors],
321
+ )
322
+ return title, setting, None, actors, mode, False, attempt_log, backdrop_desc
323
+ backdrop, bmeta = resolve_backdrop_image_url_via_llm(
324
+ mode,
325
+ cleaned_premise,
326
+ title,
327
+ setting,
328
+ backdrop_desc or "",
329
+ max_new_tokens=backend_max_new_tokens,
330
+ temperature=backend_temperature,
331
+ )
332
+ entry["backdrop_url_resolution"] = bmeta
333
+ entry["has_backdrop_url"] = bool(backdrop)
334
+ attempt_log.append(entry)
335
+ logger.info(
336
+ "premise_cast: ok backend=%r title=%r setting_len=%s actors=%s backdrop_url=%s",
337
+ mode,
338
+ title,
339
+ len(setting),
340
+ [a.name for a in actors],
341
+ "yes" if backdrop else "no",
342
+ )
343
+ return title, setting, backdrop, actors, mode, False, attempt_log, backdrop_desc
344
+ except Exception as exc:
345
+ entry["error"] = str(exc)[:500]
346
+ attempt_log.append(entry)
347
+ logger.warning(
348
+ "premise_cast: exception backend=%r reminder=%s err=%r",
349
+ mode,
350
+ bool(suffix),
351
+ str(exc)[:400],
352
+ )
353
+ continue
354
+
355
+ logger.warning(
356
+ "premise_cast: all attempts failed; fallback title=%r actors=%s (see trace premise_cast_resolved)",
357
+ fallback_title,
358
+ [a.name for a in default_actors],
359
+ )
360
+ return fallback_title, fallback_setting, None, default_actors, None, True, attempt_log, None
361
+
362
+
363
+ def create_show_from_premise(
364
+ premise: str,
365
+ backend_name: str = "deterministic",
366
+ backend_model_id: str | None = None,
367
+ backend_max_new_tokens: int = 120,
368
+ backend_temperature: float = 0.75,
369
+ director_mode: str = "deterministic",
370
+ show_length: str = DEFAULT_SHOW_LENGTH,
371
+ ) -> TheaterSession:
372
+ """
373
+ Main function that creates a full TheaterSession from a simple premise.
374
+
375
+ Steps it performs:
376
+ 1. Cleans the input premise
377
+ 2. Resolves show length into beats (story structure size)
378
+ 3. When director or actor backend is an LLM (hf_api, openbmb, local_lora, local_gguf), asks it for
379
+ show title, setting, backdrop_description (minimal art direction), and three roles (director + two
380
+ puppets) with optional portrait URLs. When HF text-to-image is enabled, the backdrop is generated
381
+ from the setting sentence next; otherwise a second LLM call picks a stock https URL from that
382
+ description. Deterministic cast uses heuristic title/setting only.
383
+ 4. Backdrop priority: HF text-to-image from `setting` (when enabled + token), else LLM stock URL (if
384
+ cast succeeded without T2I deferral), else keyword stock images from premise + setting.
385
+ 5. Builds a TheaterSession object with all metadata
386
+ 6. Adds trace events for debugging/analytics
387
+
388
+ Returns:
389
+ TheaterSession: A fully initialized puppet theater session
390
+ """
391
+
392
+ cleaned_premise = _clean_premise(premise)
393
+ active_show_length, min_beats, target_beats, max_beats = resolve_show_length(show_length)
394
+
395
  supported_generation_modes = {"deterministic", "openbmb", "hf_api", "local_lora", "local_gguf"}
396
  active_backend = backend_name if backend_name in supported_generation_modes else "deterministic"
397
  active_director_mode = director_mode if director_mode in supported_generation_modes else "deterministic"
398
+
399
+ (
400
+ show_title,
401
+ setting,
402
+ llm_backdrop_url,
403
+ actors,
404
+ cast_llm_source,
405
+ cast_fallback_used,
406
+ cast_attempt_log,
407
+ backdrop_description,
408
+ ) = _resolve_show_content_from_llm_or_defaults(
409
+ cleaned_premise,
410
+ active_backend,
411
+ active_director_mode,
412
+ backend_max_new_tokens,
413
+ backend_temperature,
414
+ )
415
+
416
+ backdrop_image_url: str | None = None
417
+ backdrop_image_source = "premise_keyword_fallback"
418
+ setting_t2i_meta: dict[str, object] | None = None
419
+
420
+ if setting_backdrop_t2i_enabled():
421
+ backdrop_image_url, setting_t2i_meta = try_setting_backdrop_data_url(setting)
422
+ if backdrop_image_url:
423
+ backdrop_image_source = "hf_setting_text_to_image"
424
+
425
+ if not backdrop_image_url:
426
+ llm_url = llm_backdrop_url
427
+ if llm_url is None and cast_llm_source and setting_backdrop_t2i_enabled():
428
+ llm_url, bmeta = resolve_backdrop_image_url_via_llm(
429
+ cast_llm_source,
430
+ cleaned_premise,
431
+ show_title,
432
+ setting,
433
+ backdrop_description or "",
434
+ max_new_tokens=backend_max_new_tokens,
435
+ temperature=backend_temperature,
436
+ )
437
+ if cast_attempt_log:
438
+ cast_attempt_log[-1]["backdrop_url_resolution_after_t2i_fail"] = bmeta
439
+ cast_attempt_log[-1]["has_backdrop_url_after_t2i_fail"] = bool(llm_url)
440
+ if llm_url:
441
+ backdrop_image_url = llm_url
442
+ backdrop_image_source = "llm_from_description"
443
+ else:
444
+ backdrop_image_url = _backdrop_url_from_premise_and_setting(cleaned_premise, setting)
445
+ backdrop_image_source = "premise_keyword_fallback"
446
+
447
  model_note = f" ({backend_model_id})" if backend_model_id else ""
448
+
449
+ cast_note = (
450
+ f"Cast and title from {cast_llm_source} show bible."
451
+ if cast_llm_source
452
+ else (
453
+ "Cast and title use built-in defaults (no LLM-capable engine selected for casting)."
454
+ if not llm_backend_order(active_director_mode, active_backend)
455
+ else "Cast and title use built-in defaults (LLM show bible failed or was invalid)."
456
+ )
457
+ )
458
+ if backdrop_image_source == "hf_setting_text_to_image":
459
+ backdrop_note = "Backdrop: HF text-to-image from the setting sentence (wide stage, puppet-safe prompt)."
460
+ elif backdrop_image_source == "llm_from_description":
461
+ backdrop_note = "Backdrop: LLM stock image URL from minimal backdrop_description (two-step flow)."
462
+ else:
463
+ backdrop_note = "Backdrop: keyword stock image fallback (T2I/URL unavailable or disabled)."
464
+
465
+ cast_roll = " · ".join(
466
+ f"{a.name} ({a.avatar})" + (f" [img]" if a.avatar_image_url else "")
467
+ for a in actors
468
+ )
469
+
470
+ # Internal log for debugging how the director created the session
471
  director_log = [
472
  f"Director created a {active_show_length} show plan.",
473
  f"Active backend: {active_backend}{model_note}.",
474
  f"Director mode: {active_director_mode}.",
475
  f"Show length: min {min_beats}, target {target_beats}, max {max_beats} beats.",
476
+ cast_note,
477
+ f"Resolved show_title: {show_title}",
478
+ f"Resolved setting: {setting}",
479
+ f"Resolved cast: {cast_roll}",
480
+ f"Premise cast source: {cast_llm_source or 'deterministic/heuristic'}; LLM cast fallback used: {str(cast_fallback_used).lower()}.",
481
+ (
482
+ f"Backdrop description (LLM): {backdrop_description}"
483
+ if backdrop_description
484
+ else "Backdrop description (LLM): (omitted; URL step used setting text as art direction)."
485
+ ),
486
+ backdrop_note,
487
+ (
488
+ "Backdrop image: inline JPEG from setting (HF text-to-image)."
489
+ if (backdrop_image_url or "").startswith("data:image")
490
+ else f"Backdrop image URL: {backdrop_image_url or '(none)'}"
491
+ ),
492
  "Three puppet actors are waiting for the first beat.",
493
  ]
494
+
495
+ # Create the main theater session object that holds the entire show state
496
  session = TheaterSession(
497
  show_title=show_title,
498
  premise=cleaned_premise,
499
  setting=setting,
500
  actors=actors,
501
+ backdrop_image_url=backdrop_image_url,
502
+ backdrop_description=backdrop_description,
503
  beat_index=0,
504
  min_beats=min_beats,
505
  target_beats=target_beats,
 
519
  director_mode=active_director_mode,
520
  play_opening_curtain=True,
521
  )
522
+
523
+ # Logging trace events for debugging/analytics pipeline
524
+ add_trace_event(
525
+ session,
526
+ "premise_cast_resolved",
527
+ reason_summary=(
528
+ f"title={show_title!r}; source={cast_llm_source or 'deterministic'}; "
529
+ f"fallback={cast_fallback_used}; attempts={len(cast_attempt_log)}"
530
+ ),
531
+ resolved_show_title=show_title,
532
+ resolved_setting=setting,
533
+ resolved_actor_names=[a.name for a in actors],
534
+ resolved_actor_avatars=[a.avatar for a in actors],
535
+ backdrop_image_url=backdrop_url_for_trace(backdrop_image_url),
536
+ backdrop_image_source=backdrop_image_source,
537
+ backdrop_description=(backdrop_description or "")[:400],
538
+ setting_backdrop_t2i=setting_t2i_meta,
539
+ premise_cast_source=cast_llm_source or "deterministic",
540
+ cast_fallback_used=cast_fallback_used,
541
+ cast_attempts=cast_attempt_log,
542
+ llm_candidate_order=llm_backend_order(active_director_mode, active_backend),
543
+ )
544
+
545
  add_trace_event(
546
  session,
547
  "show_created",
 
554
  max_beats=max_beats,
555
  actor_count=len(actors),
556
  validation_status="valid",
557
+ fallback_used=cast_fallback_used,
558
+ premise_cast_source=cast_llm_source or "deterministic",
559
+ backdrop_image_configured=bool(backdrop_image_url),
560
  )
561
+
562
  add_trace_event(session, "actors_created", actor_count=len(actors))
563
+
564
  add_trace_event(
565
  session,
566
  "director_plan_created",
 
568
  story_phase="opening",
569
  reason_summary=f"{active_show_length.title()} progress-based show plan created.",
570
  )
571
+
572
+ return session
puppet_theater/show_bible.py ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LLM-assisted "show bible": title, setting, optional backdrop image, director + two puppets.
2
+ # Used at session creation when the director and/or actor backend is an LLM
3
+ # (hf_api, openbmb, local_lora, local_gguf); falls back to deterministic defaults if parsing fails.
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ from dataclasses import replace
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from puppet_theater.backends import (
14
+ HFAPIBackend,
15
+ HF_API_BACKDROP_URL_MAX_TOKENS,
16
+ HF_API_SHOW_BIBLE_MAX_TOKENS,
17
+ HF_API_SUMMON_ACTOR_MAX_TOKENS,
18
+ LocalGGUFActorBackend,
19
+ LocalLoRAActorBackend,
20
+ OpenBMBTransformersBackend,
21
+ _run_with_timeout,
22
+ format_chatml,
23
+ get_backend,
24
+ )
25
+ from puppet_theater.models import Actor, TheaterSession
26
+ from puppet_theater.tools import ALLOWED_TOOL_NAMES
27
+
28
+ _SHOW_BIBLE_INSTRUCTIONS = """You are casting a three-character puppet improv for "AI Puppet Theater".
29
+ Return ONE compact JSON object only. No markdown. No keys beyond the schema.
30
+
31
+ Schema:
32
+ {
33
+ "show_title": "short catchy title, max 8 words",
34
+ "setting": "one vivid sentence describing the stage backdrop and mood",
35
+ "backdrop_description": "two short sentences (see Rules)",
36
+ "director": {
37
+ "name": "director puppet name (include a playful title)",
38
+ "avatar": "single emoji",
39
+ "avatar_image_url": "https portrait URL or empty string",
40
+ "goal": "one sentence",
41
+ "secret": "one sentence, playful",
42
+ "speaking_style": "short phrase",
43
+ "tools": ["subset of: change_lighting, consult_stage_oracle, inspect_prop"]
44
+ },
45
+ "puppet_actors": [
46
+ {
47
+ "name": "...",
48
+ "avatar": "emoji",
49
+ "avatar_image_url": "https or empty",
50
+ "goal": "...",
51
+ "secret": "...",
52
+ "speaking_style": "...",
53
+ "tools": ["..."]
54
+ },
55
+ {
56
+ "name": "...",
57
+ "avatar": "emoji",
58
+ "avatar_image_url": "https or empty",
59
+ "goal": "...",
60
+ "secret": "...",
61
+ "speaking_style": "...",
62
+ "tools": ["..."]
63
+ }
64
+ ]
65
+ }
66
+
67
+ Rules:
68
+ - director.tools must include change_lighting or consult_stage_oracle (at least one).
69
+ - puppet_actors must have **at least two** entries (exactly two preferred). Never put the director inside puppet_actors; never add a third puppet there if you already have a top-level director. Extra puppets beyond two are ignored by the app.
70
+ - Names must be unique across all three characters.
71
+ - Keep every string field under 200 characters (backdrop_description may be up to ~320 characters).
72
+ - backdrop_description: REQUIRED. Describe a **minimal** wide-stage photographic background for puppets: soft light, simple shapes or gentle gradients, lots of calm empty space in the **center** for characters, **low detail**, **no crowds or faces**, **no text or logos**, **no busy fine patterns**. Mood may echo the premise but stay restrained so puppets stay readable. Do not include URLs here.
73
+ - avatar_image_url may be https://api.dicebear.com/7.x/avataaars/svg?seed=ENCODED_NAME or empty.
74
+
75
+ Premise:
76
+ """
77
+
78
+ SHOW_BIBLE_SYSTEM_MESSAGE = (
79
+ "You are generating show metadata for AI Puppet Theater. "
80
+ "Return only one valid JSON object. No markdown. No commentary."
81
+ )
82
+
83
+ BACKDROP_URL_SYSTEM_MESSAGE = (
84
+ "You return one valid JSON object for AI Puppet Theater: only the key backdrop_image_url. "
85
+ "No markdown. No commentary."
86
+ )
87
+
88
+ # Any backend that can run a one-shot text completion for casting (same family as actor engine).
89
+ _LLM_CAST_BACKENDS = frozenset({"hf_api", "openbmb", "local_lora", "local_gguf"})
90
+
91
+
92
+ def llm_backend_order(director_mode: str, backend_name: str) -> list[str]:
93
+ """Prefer director LLM first, then actor backend, deduplicated."""
94
+ order: list[str] = []
95
+ for mode in (director_mode, backend_name):
96
+ key = (mode or "").strip().lower()
97
+ if key in _LLM_CAST_BACKENDS and key not in order:
98
+ order.append(key)
99
+ return order
100
+
101
+
102
+ # Image hosts where we upgrade http→https so model output still counts as LLM backdrop.
103
+ _HTTP_TO_HTTPS_IMAGE_HOSTS = frozenset(
104
+ {
105
+ "images.unsplash.com",
106
+ "plus.unsplash.com",
107
+ "cdn.pixabay.com",
108
+ "upload.wikimedia.org",
109
+ }
110
+ )
111
+
112
+
113
+ def _netloc_host(netloc: str) -> str:
114
+ host = netloc.split("@")[-1]
115
+ return host.split(":")[0].lower()
116
+
117
+
118
+ def _strip_trailing_junk(url: str) -> str:
119
+ u = url.strip()
120
+ junk = ').,];}\'" \n\t'
121
+ while u and u[-1] in junk:
122
+ u = u[:-1]
123
+ return u
124
+
125
+
126
+ def validate_https_url(raw: Any) -> str | None:
127
+ if raw is None:
128
+ return None
129
+ url = _strip_trailing_junk(str(raw).strip())
130
+ if not url:
131
+ return None
132
+ lower = url.lower()
133
+ if lower.startswith("http://"):
134
+ host = _netloc_host(urlparse(url).netloc)
135
+ if host in _HTTP_TO_HTTPS_IMAGE_HOSTS:
136
+ url = "https://" + url[7:]
137
+ parsed = urlparse(url)
138
+ if parsed.scheme != "https" or not parsed.netloc:
139
+ return None
140
+ if "@" in parsed.netloc:
141
+ return None
142
+ return url
143
+
144
+
145
+ def _salvage_show_bible_backdrop_url(raw_text: str) -> str | None:
146
+ """
147
+ Recover a backdrop URL when the JSON field is missing/invalid but the model
148
+ still emitted a usable https image link in the completion.
149
+ """
150
+ patterns = (
151
+ r"https://images\.unsplash\.com/photo-[\w-]+(?:\?[\w%.&=+-]*)?",
152
+ r"https://plus\.unsplash\.com/premium_photo-[\w-]+(?:\?[\w%.&=+-]*)?",
153
+ r"https://cdn\.pixabay\.com/photo/[\w/.-]+(?:\?[\w%.&=+-]*)?",
154
+ r"https://upload\.wikimedia\.org/wikipedia/commons/[\w/.%-]+",
155
+ )
156
+ for pat in patterns:
157
+ m = re.search(pat, raw_text)
158
+ if m:
159
+ got = validate_https_url(m.group(0))
160
+ if got:
161
+ return got
162
+ return None
163
+
164
+
165
+ def _extract_json_object(text: str) -> dict[str, Any] | None:
166
+ cleaned = text.strip()
167
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, re.IGNORECASE)
168
+ if fence:
169
+ cleaned = fence.group(1).strip()
170
+ start = cleaned.find("{")
171
+ end = cleaned.rfind("}")
172
+ if start == -1 or end <= start:
173
+ return None
174
+ try:
175
+ data = json.loads(cleaned[start : end + 1])
176
+ except json.JSONDecodeError:
177
+ return None
178
+ return data if isinstance(data, dict) else None
179
+
180
+
181
+ def _filter_tools(raw: Any) -> list[str]:
182
+ if not isinstance(raw, list):
183
+ return []
184
+ out: list[str] = []
185
+ for item in raw:
186
+ name = str(item).strip()
187
+ if name in ALLOWED_TOOL_NAMES and name not in out:
188
+ out.append(name)
189
+ return out
190
+
191
+
192
+ def _clip_avatar_emoji(s: str) -> str:
193
+ t = " ".join(s.strip().split())
194
+ if not t:
195
+ return "🎭"
196
+ return t[:16]
197
+
198
+
199
+ def _actor_from_dict(blob: Any, default_tools: list[str]) -> Actor | None:
200
+ if not isinstance(blob, dict):
201
+ return None
202
+ name = " ".join(str(blob.get("name", "")).strip().split())
203
+ if not name:
204
+ return None
205
+ goal = " ".join(str(blob.get("goal", "")).strip().split()) or "Stay in character."
206
+ secret = " ".join(str(blob.get("secret", "")).strip().split()) or "Has a tiny backstage secret."
207
+ style = " ".join(str(blob.get("speaking_style", "")).strip().split()) or "playful and theatrical"
208
+ tools = _filter_tools(blob.get("tools"))
209
+ if not tools:
210
+ tools = list(default_tools)
211
+ avatar = _clip_avatar_emoji(str(blob.get("avatar", "🎭")))
212
+ img = validate_https_url(blob.get("avatar_image_url"))
213
+ return Actor(
214
+ name=name,
215
+ avatar=avatar,
216
+ goal=goal[:220],
217
+ secret=secret[:220],
218
+ speaking_style=style[:160],
219
+ tools=tools,
220
+ avatar_image_url=img,
221
+ )
222
+
223
+
224
+ def build_backdrop_url_user_content(
225
+ premise: str,
226
+ show_title: str,
227
+ setting: str,
228
+ backdrop_description: str,
229
+ ) -> str:
230
+ prem = " ".join(premise.strip().split())[:900]
231
+ tit = " ".join(show_title.strip().split())[:120]
232
+ st = " ".join(setting.strip().split())[:400]
233
+ desc = " ".join(backdrop_description.strip().split())[:500]
234
+ return (
235
+ "Choose one real https image URL for a puppet stage backdrop (wide landscape).\n\n"
236
+ "Follow this art direction closely (minimal, readable behind puppets):\n"
237
+ f"{desc}\n\n"
238
+ "Context:\n"
239
+ f"- Premise: {prem}\n"
240
+ f"- Show title: {tit}\n"
241
+ f"- Setting (narrative): {st}\n\n"
242
+ "Rules for the image:\n"
243
+ "- https only; prefer images.unsplash.com or plus.unsplash.com; also allowed: cdn.pixabay.com, upload.wikimedia.org.\n"
244
+ "- Calm, uncluttered, generous empty space toward the center for puppets; no crowds, faces, signage, or busy textures.\n"
245
+ "- Return ONE JSON object only, no markdown, no extra keys:\n"
246
+ ' {"backdrop_image_url":"https://images.unsplash.com/photo-..."}\n'
247
+ "- Use a genuine URL pattern; prefer well-known generic landscape/studio/sky photos if unsure."
248
+ )
249
+
250
+
251
+ def parse_backdrop_url_response(raw_text: str) -> str | None:
252
+ data = _extract_json_object(raw_text)
253
+ if data:
254
+ u = validate_https_url(data.get("backdrop_image_url"))
255
+ if u:
256
+ return u
257
+ for alt in ("backdrop_url", "background_image_url"):
258
+ u = validate_https_url(data.get(alt))
259
+ if u:
260
+ return u
261
+ return _salvage_show_bible_backdrop_url(raw_text)
262
+
263
+
264
+ def resolve_backdrop_image_url_via_llm(
265
+ backend_name: str,
266
+ premise: str,
267
+ show_title: str,
268
+ setting: str,
269
+ backdrop_description: str,
270
+ *,
271
+ max_new_tokens: int | None = None,
272
+ temperature: float | None = None,
273
+ ) -> tuple[str | None, dict[str, object]]:
274
+ """
275
+ Second LLM step: turn minimal backdrop_description (+ premise/setting) into a validated image URL.
276
+ """
277
+ meta: dict[str, object] = {}
278
+ desc = " ".join(backdrop_description.strip().split())
279
+ if not desc:
280
+ desc = " ".join(setting.strip().split())
281
+ meta["description_fallback"] = "setting"
282
+ else:
283
+ meta["description_fallback"] = None
284
+ try:
285
+ raw = invoke_backdrop_image_url_llm(
286
+ backend_name,
287
+ premise=premise,
288
+ show_title=show_title,
289
+ setting=setting,
290
+ backdrop_description=desc,
291
+ max_new_tokens=max_new_tokens,
292
+ temperature=temperature,
293
+ )
294
+ meta["raw_char_len"] = len(raw)
295
+ meta["raw_preview"] = raw[:400]
296
+ url = parse_backdrop_url_response(raw)
297
+ meta["parsed_ok"] = bool(url)
298
+ return url, meta
299
+ except Exception as exc:
300
+ meta["error"] = str(exc)[:500]
301
+ return None, meta
302
+
303
+
304
+ def parse_show_bible_response(raw_text: str) -> tuple[str, str, str | None, list[Actor]] | None:
305
+ data = _extract_json_object(raw_text)
306
+ if not data:
307
+ return None
308
+ title = " ".join(str(data.get("show_title", "")).strip().split())
309
+ setting = " ".join(str(data.get("setting", "")).strip().split())
310
+ if not title or not setting:
311
+ return None
312
+ raw_desc: Any = data.get("backdrop_description") or data.get("stage_backdrop_description")
313
+ backdrop_description = " ".join(str(raw_desc or "").strip().split()) or None
314
+ if backdrop_description and len(backdrop_description) > 360:
315
+ backdrop_description = backdrop_description[:360].rsplit(" ", 1)[0].rstrip(",;:") or backdrop_description[:360]
316
+ director_blob: Any
317
+ puppets: Any
318
+ raw_actors = data.get("actors")
319
+ if isinstance(raw_actors, list) and len(raw_actors) == 3 and all(isinstance(x, dict) for x in raw_actors):
320
+ director_blob = raw_actors[0]
321
+ puppets = raw_actors[1:3]
322
+ else:
323
+ director_blob = data.get("director")
324
+ puppets = data.get("puppet_actors")
325
+ if not isinstance(director_blob, dict) or not isinstance(puppets, list) or len(puppets) < 2:
326
+ return None
327
+ # Models often emit three puppets plus a separate director; we only stage director + two puppets.
328
+ if len(puppets) > 2:
329
+ puppets = puppets[:2]
330
+ director = _actor_from_dict(director_blob, ["change_lighting", "consult_stage_oracle"])
331
+ p0 = _actor_from_dict(puppets[0], ["consult_stage_oracle", "change_lighting"])
332
+ p1 = _actor_from_dict(puppets[1], ["inspect_prop", "change_lighting"])
333
+ if director is None or p0 is None or p1 is None:
334
+ return None
335
+ names = {director.name.lower(), p0.name.lower(), p1.name.lower()}
336
+ if len(names) != 3:
337
+ return None
338
+ actors = [director, p0, p1]
339
+ return title[:120], setting[:400], backdrop_description, actors
340
+
341
+
342
+ def invoke_show_bible_llm(
343
+ backend_name: str,
344
+ premise: str,
345
+ *,
346
+ max_new_tokens: int | None = None,
347
+ temperature: float | None = None,
348
+ extra_user_suffix: str = "",
349
+ ) -> str:
350
+ """Run one completion; raises on failure."""
351
+ user_content = _SHOW_BIBLE_INSTRUCTIONS + premise.strip() + extra_user_suffix
352
+ budget = max_new_tokens if max_new_tokens is not None else 256
353
+ temp = float(temperature) if temperature is not None else 0.55
354
+
355
+ backend = get_backend(backend_name)
356
+ if isinstance(backend, HFAPIBackend):
357
+ return backend._generate_text(
358
+ user_content,
359
+ max_tokens=HF_API_SHOW_BIBLE_MAX_TOKENS,
360
+ temperature=0.35 if temperature is None else temp,
361
+ )
362
+
363
+ if isinstance(backend, OpenBMBTransformersBackend):
364
+ prev_tokens = backend.max_new_tokens
365
+ prev_temp = backend.temperature
366
+ ot = max(96, min(budget, 160))
367
+ backend.configure(max_new_tokens=ot, temperature=temp)
368
+ try:
369
+ return backend._generate_text(user_content)
370
+ finally:
371
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
372
+
373
+ if isinstance(backend, LocalLoRAActorBackend):
374
+ prev_tokens = backend.max_new_tokens
375
+ prev_temp = backend.temperature
376
+ mt = max(200, min(budget, 320))
377
+ backend.configure(max_new_tokens=mt, temperature=temp)
378
+ messages = [
379
+ {"role": "system", "content": SHOW_BIBLE_SYSTEM_MESSAGE},
380
+ {"role": "user", "content": user_content},
381
+ ]
382
+ try:
383
+ backend._load()
384
+ return _run_with_timeout(
385
+ lambda: backend._generate_text(messages),
386
+ backend.timeout_seconds,
387
+ "Local LoRA show bible generation timed out",
388
+ )
389
+ finally:
390
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
391
+
392
+ if isinstance(backend, LocalGGUFActorBackend):
393
+ prev_tokens = backend.max_new_tokens
394
+ prev_temp = backend.temperature
395
+ mt = max(200, min(budget, 320))
396
+ backend.configure(max_new_tokens=mt, temperature=temp)
397
+ messages = [
398
+ {"role": "system", "content": SHOW_BIBLE_SYSTEM_MESSAGE},
399
+ {"role": "user", "content": user_content},
400
+ ]
401
+ prompt = format_chatml(messages)
402
+ try:
403
+ backend._load()
404
+ return _run_with_timeout(
405
+ lambda: backend._generate_text(prompt),
406
+ backend.timeout_seconds,
407
+ "Local GGUF show bible generation timed out",
408
+ )
409
+ finally:
410
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
411
+
412
+ raise RuntimeError(f"Backend {backend_name!r} cannot run show bible LLM")
413
+
414
+
415
+ def invoke_backdrop_image_url_llm(
416
+ backend_name: str,
417
+ *,
418
+ premise: str,
419
+ show_title: str,
420
+ setting: str,
421
+ backdrop_description: str,
422
+ max_new_tokens: int | None = None,
423
+ temperature: float | None = None,
424
+ ) -> str:
425
+ """One completion: JSON with backdrop_image_url only. Raises on failure."""
426
+ user_content = build_backdrop_url_user_content(premise, show_title, setting, backdrop_description)
427
+ budget = max_new_tokens if max_new_tokens is not None else 256
428
+ temp = float(temperature) if temperature is not None else 0.45
429
+
430
+ backend = get_backend(backend_name)
431
+
432
+ if isinstance(backend, HFAPIBackend):
433
+ return backend._generate_text(
434
+ user_content,
435
+ max_tokens=HF_API_BACKDROP_URL_MAX_TOKENS,
436
+ system_message=BACKDROP_URL_SYSTEM_MESSAGE,
437
+ temperature=0.25 if temperature is None else min(float(temp), 0.55),
438
+ )
439
+
440
+ if isinstance(backend, OpenBMBTransformersBackend):
441
+ prev_tokens = backend.max_new_tokens
442
+ prev_temp = backend.temperature
443
+ ot = max(64, min(budget, 140))
444
+ backend.configure(max_new_tokens=ot, temperature=min(temp, 0.45))
445
+ try:
446
+ return backend._generate_text(
447
+ f"{BACKDROP_URL_SYSTEM_MESSAGE}\n\n{user_content}",
448
+ )
449
+ finally:
450
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
451
+
452
+ if isinstance(backend, LocalLoRAActorBackend):
453
+ prev_tokens = backend.max_new_tokens
454
+ prev_temp = backend.temperature
455
+ mt = max(96, min(budget, 220))
456
+ backend.configure(max_new_tokens=mt, temperature=min(temp, 0.45))
457
+ messages = [
458
+ {"role": "system", "content": BACKDROP_URL_SYSTEM_MESSAGE},
459
+ {"role": "user", "content": user_content},
460
+ ]
461
+ try:
462
+ backend._load()
463
+ return _run_with_timeout(
464
+ lambda: backend._generate_text(messages),
465
+ backend.timeout_seconds,
466
+ "Local LoRA backdrop URL generation timed out",
467
+ )
468
+ finally:
469
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
470
+
471
+ if isinstance(backend, LocalGGUFActorBackend):
472
+ prev_tokens = backend.max_new_tokens
473
+ prev_temp = backend.temperature
474
+ mt = max(96, min(budget, 220))
475
+ backend.configure(max_new_tokens=mt, temperature=min(temp, 0.45))
476
+ messages = [
477
+ {"role": "system", "content": BACKDROP_URL_SYSTEM_MESSAGE},
478
+ {"role": "user", "content": user_content},
479
+ ]
480
+ prompt = format_chatml(messages)
481
+ try:
482
+ backend._load()
483
+ return _run_with_timeout(
484
+ lambda: backend._generate_text(prompt),
485
+ backend.timeout_seconds,
486
+ "Local GGUF backdrop URL generation timed out",
487
+ )
488
+ finally:
489
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
490
+
491
+ raise RuntimeError(f"Backend {backend_name!r} cannot run backdrop URL LLM")
492
+
493
+
494
+ SUMMON_ACTOR_SYSTEM_MESSAGE = (
495
+ "You are casting one new puppet for AI Puppet Theater. "
496
+ "Return only one valid JSON object. No markdown. No commentary."
497
+ )
498
+
499
+ _SUMMON_ACTOR_SCHEMA = """
500
+ Return ONE compact JSON object only. No markdown. Allowed top-level keys only:
501
+ {
502
+ "name": "unique puppet stage name (must not match any name already on stage)",
503
+ "avatar": "single emoji",
504
+ "avatar_image_url": "https portrait URL or empty string",
505
+ "goal": "one sentence",
506
+ "secret": "one sentence, playful",
507
+ "speaking_style": "short phrase",
508
+ "tools": ["subset of: change_lighting, consult_stage_oracle, inspect_prop"]
509
+ }
510
+
511
+ Rules:
512
+ - tools must be non-empty and only use allowed tool names.
513
+ - name must be different from every name in "Names already on stage".
514
+ - Incorporate the audience's suggested name or spirit, but you may refine it for the stage.
515
+ - Keep every string field under 200 characters.
516
+ - avatar_image_url may be https://api.dicebear.com/7.x/avataaars/svg?seed=ENCODED_NAME or empty.
517
+
518
+ Show context:
519
+ """
520
+
521
+ _SUMMON_REMINDER_SUFFIX = (
522
+ "\n\nReminder: respond with one JSON object only, keys "
523
+ "name, avatar, avatar_image_url, goal, secret, speaking_style, tools — no intent/line/emotion keys."
524
+ )
525
+
526
+
527
+ def build_summon_actor_user_content(session: TheaterSession, audience_suggested_name: str) -> str:
528
+ cast = ", ".join(a.name for a in session.actors) or "(none)"
529
+ label = audience_suggested_name.strip() or "Mystery Guest"
530
+ return (
531
+ f"{_SUMMON_ACTOR_SCHEMA}"
532
+ f"premise: {session.premise}\n"
533
+ f"show_title: {session.show_title}\n"
534
+ f"setting: {session.setting}\n"
535
+ f"Names already on stage: {cast}\n"
536
+ f"Audience asked to summon (suggested name): {label}\n"
537
+ )
538
+
539
+
540
+ def parse_summoned_actor_response(raw_text: str) -> Actor | None:
541
+ data = _extract_json_object(raw_text)
542
+ if not data:
543
+ return None
544
+ blob: Any = data.get("summoned_actor")
545
+ if blob is None:
546
+ blob = data.get("actor")
547
+ if blob is None and isinstance(data.get("name"), str):
548
+ blob = data
549
+ if not isinstance(blob, dict):
550
+ return None
551
+ return _actor_from_dict(blob, ["consult_stage_oracle", "inspect_prop", "change_lighting"])
552
+
553
+
554
+ def _unique_actor_name(actor: Actor, taken_lower: set[str], audience_label: str) -> Actor:
555
+ if actor.name.lower() not in taken_lower:
556
+ return actor
557
+ base = " ".join(audience_label.split()) or actor.name
558
+ for i in range(2, 14):
559
+ candidate = f"{base} {i}"
560
+ if candidate.lower() not in taken_lower:
561
+ return replace(actor, name=candidate)
562
+ return replace(actor, name=f"{base} the Wanderer")
563
+
564
+
565
+ def invoke_summon_actor_llm(
566
+ backend_name: str,
567
+ user_content: str,
568
+ *,
569
+ max_new_tokens: int | None = None,
570
+ temperature: float | None = None,
571
+ extra_user_suffix: str = "",
572
+ ) -> str:
573
+ """One completion for summoned-actor JSON; raises on failure."""
574
+ text = user_content + extra_user_suffix
575
+ budget = max_new_tokens if max_new_tokens is not None else 256
576
+ temp = float(temperature) if temperature is not None else 0.55
577
+ backend = get_backend(backend_name)
578
+
579
+ if isinstance(backend, HFAPIBackend):
580
+ return backend._generate_text(
581
+ text,
582
+ max_tokens=HF_API_SUMMON_ACTOR_MAX_TOKENS,
583
+ temperature=0.35 if temperature is None else temp,
584
+ )
585
+
586
+ if isinstance(backend, OpenBMBTransformersBackend):
587
+ prev_tokens = backend.max_new_tokens
588
+ prev_temp = backend.temperature
589
+ ot = max(96, min(budget, 160))
590
+ backend.configure(max_new_tokens=ot, temperature=temp)
591
+ try:
592
+ return backend._generate_text(text)
593
+ finally:
594
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
595
+
596
+ if isinstance(backend, LocalLoRAActorBackend):
597
+ prev_tokens = backend.max_new_tokens
598
+ prev_temp = backend.temperature
599
+ mt = max(180, min(budget, 320))
600
+ backend.configure(max_new_tokens=mt, temperature=temp)
601
+ messages = [
602
+ {"role": "system", "content": SUMMON_ACTOR_SYSTEM_MESSAGE},
603
+ {"role": "user", "content": text},
604
+ ]
605
+ try:
606
+ backend._load()
607
+ return _run_with_timeout(
608
+ lambda: backend._generate_text(messages),
609
+ backend.timeout_seconds,
610
+ "Local LoRA summon actor generation timed out",
611
+ )
612
+ finally:
613
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
614
+
615
+ if isinstance(backend, LocalGGUFActorBackend):
616
+ prev_tokens = backend.max_new_tokens
617
+ prev_temp = backend.temperature
618
+ mt = max(180, min(budget, 320))
619
+ backend.configure(max_new_tokens=mt, temperature=temp)
620
+ messages = [
621
+ {"role": "system", "content": SUMMON_ACTOR_SYSTEM_MESSAGE},
622
+ {"role": "user", "content": text},
623
+ ]
624
+ prompt = format_chatml(messages)
625
+ try:
626
+ backend._load()
627
+ return _run_with_timeout(
628
+ lambda: backend._generate_text(prompt),
629
+ backend.timeout_seconds,
630
+ "Local GGUF summon actor generation timed out",
631
+ )
632
+ finally:
633
+ backend.configure(max_new_tokens=prev_tokens, temperature=prev_temp)
634
+
635
+ raise RuntimeError(f"Backend {backend_name!r} cannot run summon actor LLM")
636
+
637
+
638
+ def resolve_summoned_actor_via_llm_or_default(
639
+ session: TheaterSession,
640
+ audience_suggested_name: str,
641
+ ) -> tuple[Actor, str | None, bool]:
642
+ """
643
+ Returns (actor, llm_backend_used_or_none, summon_llm_fallback_used).
644
+
645
+ summon_llm_fallback_used is True when an LLM backend was tried and parsing failed for all attempts.
646
+ """
647
+ label = " ".join(audience_suggested_name.strip().split()) or "Mystery Guest"
648
+ taken_lower = {a.name.lower() for a in session.actors}
649
+ default = Actor(
650
+ name=label,
651
+ avatar="✨",
652
+ goal="Make the scene stranger without derailing the finale.",
653
+ secret="Arrived with one completely unexplained cue.",
654
+ speaking_style="fresh, eager, and just a little too dramatic",
655
+ tools=["consult_stage_oracle"],
656
+ )
657
+
658
+ candidates = llm_backend_order(session.director_mode, session.backend_name)
659
+ if not candidates:
660
+ return default, None, False
661
+
662
+ user = build_summon_actor_user_content(session, label)
663
+ for mode in candidates:
664
+ suffixes: tuple[str, ...] = (
665
+ ("", _SUMMON_REMINDER_SUFFIX) if mode in {"local_lora", "local_gguf"} else ("",)
666
+ )
667
+ for suffix in suffixes:
668
+ try:
669
+ raw = invoke_summon_actor_llm(
670
+ mode,
671
+ user,
672
+ max_new_tokens=session.backend_max_new_tokens,
673
+ temperature=session.backend_temperature,
674
+ extra_user_suffix=suffix,
675
+ )
676
+ actor = parse_summoned_actor_response(raw)
677
+ if actor is None:
678
+ continue
679
+ actor = _unique_actor_name(actor, taken_lower, label)
680
+ if actor.name.lower() in taken_lower:
681
+ continue
682
+ return actor, mode, False
683
+ except Exception:
684
+ continue
685
+
686
+ return default, None, True
puppet_theater/trace.py CHANGED
@@ -9,6 +9,31 @@ from typing import Any
9
 
10
  from puppet_theater.models import TheaterSession
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  APP_NAME = "AI Puppet Theater"
14
  TRACE_VERSION = "1.0"
@@ -41,6 +66,15 @@ _PRIVATE_PATH_PATTERNS = [
41
 
42
 
43
  def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
 
 
 
 
 
 
 
 
 
44
  event: dict[str, Any] = {
45
  "event_type": sanitize_value(event_type),
46
  "type": sanitize_value(event_type),
@@ -56,6 +90,12 @@ def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) ->
56
 
57
 
58
  def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
 
 
 
 
 
 
59
  if session is None:
60
  return None
61
 
@@ -86,6 +126,12 @@ def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
86
 
87
 
88
  def render_trace_json(session: TheaterSession | None) -> str:
 
 
 
 
 
 
89
  payload = export_trace(session)
90
  if payload is None:
91
  return "No trace events yet."
@@ -93,6 +139,12 @@ def render_trace_json(session: TheaterSession | None) -> str:
93
 
94
 
95
  def write_trace_json_file(session: TheaterSession | None) -> str | None:
 
 
 
 
 
 
96
  payload = export_trace(session)
97
  if payload is None:
98
  return None
@@ -107,6 +159,12 @@ def write_trace_json_file(session: TheaterSession | None) -> str | None:
107
 
108
 
109
  def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
 
 
 
 
 
 
110
  normalized: list[dict[str, Any]] = []
111
  for index, raw_event in enumerate(session.trace_events, start=1):
112
  if isinstance(raw_event, dict):
@@ -125,6 +183,12 @@ def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
125
 
126
 
127
  def render_trace_summary(session: TheaterSession | None, max_events: int = 14) -> str:
 
 
 
 
 
 
128
  if session is None:
129
  return "No trace events yet."
130
 
@@ -153,6 +217,13 @@ def render_trace_summary(session: TheaterSession | None, max_events: int = 14) -
153
 
154
 
155
  def _normalize_event_dict(raw_event: dict[str, Any], index: int) -> dict[str, Any]:
 
 
 
 
 
 
 
156
  cleaned = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
157
  event_type = str(cleaned.pop("event_type", cleaned.pop("type", "event")) or "event")
158
  normalized: dict[str, Any] = {
@@ -254,10 +325,22 @@ def _normalize_event_dict(raw_event: dict[str, Any], index: int) -> dict[str, An
254
 
255
 
256
  def _event_summary(event: dict[str, Any]) -> str:
 
 
 
 
 
 
257
  event_type = str(event.get("event_type") or "event")
258
  if event_type == "show_created":
259
  backend = _backend_label(event)
260
  return f"Show created with {backend}."
 
 
 
 
 
 
261
  if event_type == "actors_created":
262
  return f"{event.get('actor_count', 'Actors')} actors created."
263
  if event_type == "director_decision":
@@ -298,6 +381,14 @@ def _event_summary(event: dict[str, Any]) -> str:
298
 
299
 
300
  def sanitize_value(value: Any) -> Any:
 
 
 
 
 
 
 
 
301
  if isinstance(value, dict):
302
  return {
303
  str(key): sanitize_value(item)
@@ -314,6 +405,18 @@ def sanitize_value(value: Any) -> Any:
314
 
315
 
316
  def _sanitize_text(value: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
317
  text = " ".join(value.split())
318
  if "Traceback (most recent call last)" in text:
319
  text = "Error details redacted"
@@ -321,10 +424,6 @@ def _sanitize_text(value: str) -> str:
321
  if not _is_sensitive_key(key) or not secret:
322
  continue
323
  text = text.replace(secret, "[redacted]")
324
- for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
325
- secret = os.getenv(secret_name)
326
- if secret:
327
- text = text.replace(secret, "[redacted]")
328
  text = re.sub(
329
  r"\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*\S+",
330
  r"\1=[redacted]",
@@ -336,6 +435,12 @@ def _sanitize_text(value: str) -> str:
336
 
337
 
338
  def _backend_label(event: dict[str, Any]) -> str:
 
 
 
 
 
 
339
  metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
340
  backend = metadata.get("backend_name") or metadata.get("director_mode") or event.get("backend_name") or "backend"
341
  model = metadata.get("model_id") or event.get("model_id")
@@ -343,17 +448,37 @@ def _backend_label(event: dict[str, Any]) -> str:
343
 
344
 
345
  def _tool_label(tool_request: Any) -> str:
 
 
 
 
 
346
  if isinstance(tool_request, dict):
347
  return str(tool_request.get("tool_name") or "tool")
348
  return "tool"
349
 
350
 
351
  def _is_sensitive_key(key: str) -> bool:
 
 
 
 
 
 
 
 
 
352
  lowered = key.lower()
353
  return any(marker in lowered for marker in ("token", "secret", "password", "credential", "api_key", "access_key"))
354
 
355
 
356
  def _model_id_for_mode(mode: str | None) -> str | None:
 
 
 
 
 
 
357
  if mode == "openbmb":
358
  return os.getenv("OPENBMB_MODEL_ID", "openbmb/MiniCPM5-1B")
359
  if mode == "hf_api":
 
9
 
10
  from puppet_theater.models import TheaterSession
11
 
12
+ """
13
+ Trace logging and export system for the AI Puppet Theater.
14
+
15
+ This module is responsible for collecting, sanitizing, normalizing, and exporting
16
+ runtime execution traces of a theater session.
17
+
18
+ It captures all important system events such as:
19
+ - Actor responses and intents
20
+ - Director decisions
21
+ - Tool usage (requests and results)
22
+ - Audience interactions
23
+ - Backend and validation metadata
24
+ - Errors and fallback behavior
25
+
26
+ Key responsibilities:
27
+ 1. Add structured trace events during a TheaterSession.
28
+ 2. Sanitize sensitive information (secrets, tokens, file paths).
29
+ 3. Normalize heterogeneous event formats into a consistent schema.
30
+ 4. Generate human-readable summaries for debugging and inspection.
31
+ 5. Export full session traces as JSON (in-memory or file-based).
32
+
33
+ This module is primarily used for debugging, observability, and replay
34
+ of AI-driven theater sessions.
35
+ """
36
+
37
 
38
  APP_NAME = "AI Puppet Theater"
39
  TRACE_VERSION = "1.0"
 
66
 
67
 
68
  def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
69
+ """
70
+ Adds a structured event to the session trace log.
71
+
72
+ Automatically attaches metadata such as timestamp and step number,
73
+ and sanitizes all values before storing them in the session.
74
+
75
+ Used to record runtime events like actor responses, director decisions,
76
+ tool calls, and system actions.
77
+ """
78
  event: dict[str, Any] = {
79
  "event_type": sanitize_value(event_type),
80
  "type": sanitize_value(event_type),
 
90
 
91
 
92
  def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
93
+ """
94
+ Builds a complete structured snapshot of the current theater session.
95
+
96
+ Includes session metadata, backend configuration, and all normalized
97
+ trace events in a consistent export format suitable for logging or analysis.
98
+ """
99
  if session is None:
100
  return None
101
 
 
126
 
127
 
128
  def render_trace_json(session: TheaterSession | None) -> str:
129
+ """
130
+ Converts the exported trace into a pretty-printed JSON string.
131
+
132
+ Useful for debugging, logs, or displaying the full session state
133
+ in a human-readable format.
134
+ """
135
  payload = export_trace(session)
136
  if payload is None:
137
  return "No trace events yet."
 
139
 
140
 
141
  def write_trace_json_file(session: TheaterSession | None) -> str | None:
142
+ """
143
+ Writes the full trace export to a temporary JSON file.
144
+
145
+ The file is stored in the system temp directory and named using the
146
+ session ID. Used for downloading or offline inspection of traces.
147
+ """
148
  payload = export_trace(session)
149
  if payload is None:
150
  return None
 
159
 
160
 
161
  def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
162
+ """
163
+ Converts raw trace events into a standardized format.
164
+
165
+ Handles both structured dict events and legacy string events,
166
+ ensuring all events conform to a consistent schema for downstream use.
167
+ """
168
  normalized: list[dict[str, Any]] = []
169
  for index, raw_event in enumerate(session.trace_events, start=1):
170
  if isinstance(raw_event, dict):
 
183
 
184
 
185
  def render_trace_summary(session: TheaterSession | None, max_events: int = 14) -> str:
186
+ """
187
+ Generates a human-readable summary of recent trace events.
188
+
189
+ Useful for quick debugging or UI display. Shows only the most recent
190
+ events with concise one-line explanations.
191
+ """
192
  if session is None:
193
  return "No trace events yet."
194
 
 
217
 
218
 
219
  def _normalize_event_dict(raw_event: dict[str, Any], index: int) -> dict[str, Any]:
220
+ """
221
+ Normalizes a raw event dictionary into a structured trace format.
222
+
223
+ Extracts known fields, groups related metadata (director decisions,
224
+ tool usage, actor updates), and ensures output conforms to the
225
+ standard event schema.
226
+ """
227
  cleaned = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
228
  event_type = str(cleaned.pop("event_type", cleaned.pop("type", "event")) or "event")
229
  normalized: dict[str, Any] = {
 
325
 
326
 
327
  def _event_summary(event: dict[str, Any]) -> str:
328
+ """
329
+ Generates a short human-readable description of a trace event.
330
+
331
+ Used in summaries and logs to quickly understand what happened
332
+ without inspecting full event data.
333
+ """
334
  event_type = str(event.get("event_type") or "event")
335
  if event_type == "show_created":
336
  backend = _backend_label(event)
337
  return f"Show created with {backend}."
338
+ if event_type == "premise_cast_resolved":
339
+ title = event.get("resolved_show_title") or event.get("show_title") or "?"
340
+ source = event.get("premise_cast_source") or "?"
341
+ fb = event.get("cast_fallback_used")
342
+ n = len(event.get("cast_attempts") or []) if isinstance(event.get("cast_attempts"), list) else 0
343
+ return f"Premise cast: title={title!r} source={source} fallback={fb} attempts={n}."
344
  if event_type == "actors_created":
345
  return f"{event.get('actor_count', 'Actors')} actors created."
346
  if event_type == "director_decision":
 
381
 
382
 
383
  def sanitize_value(value: Any) -> Any:
384
+ """
385
+ Recursively sanitizes values to remove sensitive or noisy information.
386
+
387
+ - Removes secrets, tokens, and credentials
388
+ - Redacts file paths
389
+ - Trims long strings
390
+ - Cleans nested structures (dicts, lists, tuples)
391
+ """
392
  if isinstance(value, dict):
393
  return {
394
  str(key): sanitize_value(item)
 
405
 
406
 
407
  def _sanitize_text(value: str) -> str:
408
+ """
409
+ Cleans and redacts sensitive information from a string.
410
+
411
+ Removes:
412
+ - Extra whitespace
413
+ - Stack traces
414
+ - Environment secrets
415
+ - API keys and credentials
416
+ - Private file system paths
417
+
418
+ Also truncates long text for safe logging.
419
+ """
420
  text = " ".join(value.split())
421
  if "Traceback (most recent call last)" in text:
422
  text = "Error details redacted"
 
424
  if not _is_sensitive_key(key) or not secret:
425
  continue
426
  text = text.replace(secret, "[redacted]")
 
 
 
 
427
  text = re.sub(
428
  r"\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*\S+",
429
  r"\1=[redacted]",
 
435
 
436
 
437
  def _backend_label(event: dict[str, Any]) -> str:
438
+ """
439
+ Builds a readable label for backend configuration.
440
+
441
+ Combines backend name and model ID (if available) to help identify
442
+ which model/engine produced an event.
443
+ """
444
  metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
445
  backend = metadata.get("backend_name") or metadata.get("director_mode") or event.get("backend_name") or "backend"
446
  model = metadata.get("model_id") or event.get("model_id")
 
448
 
449
 
450
  def _tool_label(tool_request: Any) -> str:
451
+ """
452
+ Extracts a readable tool name from a tool request structure.
453
+
454
+ Used in logs and summaries for tool-related events.
455
+ """
456
  if isinstance(tool_request, dict):
457
  return str(tool_request.get("tool_name") or "tool")
458
  return "tool"
459
 
460
 
461
  def _is_sensitive_key(key: str) -> bool:
462
+ """
463
+ Determines whether a key name contains sensitive information.
464
+
465
+ Used to prevent logging of secrets like:
466
+ - tokens
467
+ - passwords
468
+ - API keys
469
+ - credentials
470
+ """
471
  lowered = key.lower()
472
  return any(marker in lowered for marker in ("token", "secret", "password", "credential", "api_key", "access_key"))
473
 
474
 
475
  def _model_id_for_mode(mode: str | None) -> str | None:
476
+ """
477
+ Resolves model identifiers based on backend mode.
478
+
479
+ Maps execution modes (e.g., openbmb, hf_api) to their corresponding
480
+ model IDs for trace metadata.
481
+ """
482
  if mode == "openbmb":
483
  return os.getenv("OPENBMB_MODEL_ID", "openbmb/MiniCPM5-1B")
484
  if mode == "hf_api":
puppet_theater/zerogpu.py CHANGED
@@ -1,9 +1,13 @@
1
  import os
 
2
  from typing import Callable, TypeVar
3
 
 
4
 
5
- F = TypeVar("F", bound=Callable)
6
 
 
 
 
7
 
8
  def _env_flag(name: str, default: bool = False) -> bool:
9
  value = os.getenv(name)
@@ -27,7 +31,7 @@ LAST_GPU_FALLBACK_REASON: str | None = (
27
  if USE_ZEROGPU and not SPACES_AVAILABLE
28
  else None
29
  )
30
-
31
 
32
  def gpu_decorator(func: F) -> F:
33
  if not ZEROGPU_GPU_ACTIVE or spaces is None:
 
1
  import os
2
+ from pathlib import Path
3
  from typing import Callable, TypeVar
4
 
5
+ from dotenv import load_dotenv
6
 
 
7
 
8
+ F = TypeVar("F", bound=Callable)
9
+ # Repo root (parent of puppet_theater/), not cwd — matches app.py and tests.
10
+ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
11
 
12
  def _env_flag(name: str, default: bool = False) -> bool:
13
  value = os.getenv(name)
 
31
  if USE_ZEROGPU and not SPACES_AVAILABLE
32
  else None
33
  )
34
+ import torch
35
 
36
  def gpu_decorator(func: F) -> F:
37
  if not ZEROGPU_GPU_ACTIVE or spaces is None:
pyproject.toml CHANGED
@@ -12,6 +12,7 @@ dependencies = [
12
  "peft==0.19.1",
13
  "pydantic<=2.12.5",
14
  "spaces==0.50.4",
 
15
  "torch==2.8.0",
16
  "transformers==5.10.2",
17
  ]
 
12
  "peft==0.19.1",
13
  "pydantic<=2.12.5",
14
  "spaces==0.50.4",
15
+ "python-dotenv>=1.2.2",
16
  "torch==2.8.0",
17
  "transformers==5.10.2",
18
  ]
tests/test_director.py CHANGED
@@ -32,11 +32,12 @@ def test_director_prompt_includes_audience_context_and_schema() -> None:
32
 
33
  prompt = build_director_prompt(session)
34
 
35
- assert "The Moon Detective Interrogates Suspicious" in prompt
 
36
  assert "tiny crown" in prompt
37
  assert "Audience summoned Professor Button." in prompt
38
  assert "Professor Button" in prompt
39
- assert "Has already misplaced the final cue card." in prompt
40
  assert "Minimum beats before normal ending: 7" in prompt
41
  assert "Target beats: 10" in prompt
42
  assert "Maximum beats: 12" in prompt
 
32
 
33
  prompt = build_director_prompt(session)
34
 
35
+ assert session.show_title in prompt
36
+ assert "A moon detective interrogates a suspicious toaster" in prompt
37
  assert "tiny crown" in prompt
38
  assert "Audience summoned Professor Button." in prompt
39
  assert "Professor Button" in prompt
40
+ assert any(actor.secret in prompt for actor in session.actors)
41
  assert "Minimum beats before normal ending: 7" in prompt
42
  assert "Target beats: 10" in prompt
43
  assert "Maximum beats: 12" in prompt
tests/test_show_bible_parse.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression: show bible JSON shape variants from real models."""
2
+
3
+ from puppet_theater.show_bible import parse_show_bible_response
4
+
5
+
6
+ def test_parse_accepts_three_puppet_actors_plus_director() -> None:
7
+ """HF often returns director + three puppet_actors; we keep director + first two puppets."""
8
+ raw = """
9
+ {
10
+ "show_title": "Benny's Birthday Bash",
11
+ "setting": "A sunny meadow with soft grass.",
12
+ "backdrop_description": "Soft sky, open center.",
13
+ "director": {
14
+ "name": "Puppet Master Pippa",
15
+ "avatar": "🎭",
16
+ "avatar_image_url": "",
17
+ "goal": "Guide the scene.",
18
+ "secret": "Secret one.",
19
+ "speaking_style": "Whimsical",
20
+ "tools": ["consult_stage_oracle", "change_lighting"]
21
+ },
22
+ "puppet_actors": [
23
+ {
24
+ "name": "Benny",
25
+ "avatar": "🧸",
26
+ "avatar_image_url": "",
27
+ "goal": "Find cake.",
28
+ "secret": "Secret two.",
29
+ "speaking_style": "Hopeful",
30
+ "tools": ["change_lighting"]
31
+ },
32
+ {
33
+ "name": "Rabbit Rosie",
34
+ "avatar": "🐰",
35
+ "avatar_image_url": "",
36
+ "goal": "Help Benny.",
37
+ "secret": "Secret three.",
38
+ "speaking_style": "Bouncy",
39
+ "tools": ["consult_stage_oracle"]
40
+ },
41
+ {
42
+ "name": "Turtle Tim",
43
+ "avatar": "🐢",
44
+ "avatar_image_url": "",
45
+ "goal": "Slow wisdom.",
46
+ "secret": "Secret four.",
47
+ "speaking_style": "Calm",
48
+ "tools": ["inspect_prop"]
49
+ }
50
+ ]
51
+ }
52
+ """
53
+ out = parse_show_bible_response(raw)
54
+ assert out is not None
55
+ title, setting, desc, actors = out
56
+ assert title == "Benny's Birthday Bash"
57
+ assert "meadow" in setting.lower()
58
+ assert desc and "center" in desc.lower()
59
+ assert len(actors) == 3
60
+ assert actors[0].name == "Puppet Master Pippa"
61
+ assert actors[1].name == "Benny"
62
+ assert actors[2].name == "Rabbit Rosie"
uv.lock CHANGED
@@ -38,6 +38,7 @@ dependencies = [
38
  { name = "huggingface-hub" },
39
  { name = "peft" },
40
  { name = "pydantic" },
 
41
  { name = "spaces" },
42
  { name = "torch" },
43
  { name = "transformers" },
@@ -58,6 +59,7 @@ requires-dist = [
58
  { name = "peft", specifier = "==0.19.1" },
59
  { name = "pydantic", specifier = "<=2.12.5" },
60
  { name = "spaces", specifier = "==0.50.4" },
 
61
  { name = "torch", specifier = "==2.8.0" },
62
  { name = "transformers", specifier = "==5.10.2" },
63
  ]
@@ -1260,6 +1262,15 @@ wheels = [
1260
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1261
  ]
1262
 
 
 
 
 
 
 
 
 
 
1263
  [[package]]
1264
  name = "python-multipart"
1265
  version = "0.0.32"
 
38
  { name = "huggingface-hub" },
39
  { name = "peft" },
40
  { name = "pydantic" },
41
+ { name = "python-dotenv" },
42
  { name = "spaces" },
43
  { name = "torch" },
44
  { name = "transformers" },
 
59
  { name = "peft", specifier = "==0.19.1" },
60
  { name = "pydantic", specifier = "<=2.12.5" },
61
  { name = "spaces", specifier = "==0.50.4" },
62
+ { name = "python-dotenv", specifier = ">=1.2.2" },
63
  { name = "torch", specifier = "==2.8.0" },
64
  { name = "transformers", specifier = "==5.10.2" },
65
  ]
 
1262
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1263
  ]
1264
 
1265
+ [[package]]
1266
+ name = "python-dotenv"
1267
+ version = "1.2.2"
1268
+ source = { registry = "https://pypi.org/simple" }
1269
+ sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
1270
+ wheels = [
1271
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
1272
+ ]
1273
+
1274
  [[package]]
1275
  name = "python-multipart"
1276
  version = "0.0.32"