This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitignore +0 -16
  2. AGENTS.md +0 -8
  3. README.md +2 -394
  4. app.py +0 -0
  5. assets/architecture-agent-loop.mmd +0 -26
  6. assets/architecture-agent-loop.png +0 -0
  7. assets/architecture-runtime-fallbacks.mmd +0 -36
  8. assets/architecture-runtime-fallbacks.png +0 -0
  9. assets/architecture-system-flow.mmd +0 -37
  10. assets/architecture-system-flow.png +0 -0
  11. assets/puppeteer-config.json +0 -3
  12. docs/LEARNING_PATH.md +0 -213
  13. finetune/README.md +0 -714
  14. finetune/data/.gitignore +0 -1
  15. finetune/data_samples/actor_eval_prompts.jsonl +0 -40
  16. finetune/data_samples/actor_sft_v0_sample.jsonl +0 -32
  17. finetune/data_samples/actor_sft_v1_sample.jsonl +0 -32
  18. finetune/dataset_cards/actor_sft_README.md +0 -109
  19. finetune/external_seeds/.gitkeep +0 -1
  20. finetune/modal_train_actor_lora.py +0 -309
  21. finetune/model_cards/actor_gguf_README.md +0 -116
  22. finetune/model_cards/actor_lora_v0_README.md +0 -119
  23. finetune/requirements-train.txt +0 -11
  24. finetune/scripts/audit_actor_sft.py +0 -334
  25. finetune/scripts/convert_actor_merged_to_gguf.sh +0 -120
  26. finetune/scripts/eval_actor_gguf.py +0 -487
  27. finetune/scripts/eval_minicpm5_actor_lora.py +0 -595
  28. finetune/scripts/generate_actor_sft_v0.py +0 -1760
  29. finetune/scripts/merge_actor_lora.py +0 -92
  30. finetune/scripts/prepare_gguf_publish.py +0 -144
  31. finetune/scripts/publish_actor_lora_adapter.py +0 -111
  32. finetune/scripts/publish_actor_sft_dataset.py +0 -106
  33. finetune/scripts/train_minicpm5_actor_lora.py +0 -347
  34. finetune/scripts/validate_actor_sft.py +0 -344
  35. puppet_theater/__init__.py +3 -49
  36. puppet_theater/actions.py +13 -84
  37. puppet_theater/backdrop_gen.py +0 -152
  38. puppet_theater/backends.py +48 -1052
  39. puppet_theater/director.py +21 -809
  40. puppet_theater/models.py +15 -173
  41. puppet_theater/prompts.py +6 -49
  42. puppet_theater/session.py +26 -497
  43. puppet_theater/show_bible.py +0 -686
  44. puppet_theater/tools.py +0 -205
  45. puppet_theater/trace.py +0 -486
  46. puppet_theater/zerogpu.py +0 -112
  47. pyproject.toml +4 -9
  48. requirements.txt +11 -86
  49. scripts/test_hf_api.py +0 -70
  50. tests/conftest.py +0 -7
.gitignore CHANGED
@@ -1,11 +1,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
 
@@ -13,14 +8,3 @@ __pycache__/
13
  .ruff_cache/
14
 
15
  .DS_Store
16
-
17
- finetune/data/*.jsonl
18
- finetune/external_seeds/*
19
- !finetune/external_seeds/.gitkeep
20
- finetune/outputs/
21
- finetune/outputs/gguf/
22
- finetune/eval_outputs/
23
- finetune/publish_gguf/
24
- finetune/minicpm5-actor-lora/
25
- finetune/minicpm5-actor-lora-v1/
26
- *.gguf
 
1
  .venv/
2
  .uv-cache/
3
 
 
 
 
 
 
4
  __pycache__/
5
  *.py[cod]
6
 
 
8
  .ruff_cache/
9
 
10
  .DS_Store
 
 
 
 
 
 
 
 
 
 
 
AGENTS.md CHANGED
@@ -84,14 +84,6 @@ Run the app locally:
84
  uv run python app.py
85
  ```
86
 
87
- For **live reload** while you edit `app.py` (including the `CUSTOM_CSS` string), use the Gradio CLI instead of `python` directly. It watches the project directory and restarts the server when the file changes:
88
-
89
- ```bash
90
- uv run gradio app.py
91
- ```
92
-
93
- The Blocks instance is named `app`; Gradio discovers it automatically. If you ever need to pin the name explicitly, use `uv run gradio app.py --demo-name app`.
94
-
95
  Check syntax:
96
 
97
  ```bash
 
84
  uv run python app.py
85
  ```
86
 
 
 
 
 
 
 
 
 
87
  Check syntax:
88
 
89
  ```bash
README.md CHANGED
@@ -6,400 +6,8 @@ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
9
- python_version: "3.10"
10
  pinned: false
11
- tags:
12
- - gradio
13
- - build-small-hackathon
14
- - track:wood
15
- - sponsor:openbmb
16
- - sponsor:modal
17
- - achievement:welltuned
18
- - achievement:llama
19
- - achievement:sharing
20
- - achievement:fieldnotes
21
  ---
22
 
23
- # AI Puppet Theater
24
-
25
- Create a tiny AI puppet show, then interrupt it from the audience.
26
-
27
- AI Puppet Theater is a Hugging Face Gradio Space for the Build Small Hackathon Thousand Token Wood track. The app turns a short user premise into a compact puppet show: it casts characters, lets a Director manage pacing, gives actors intent and state, accepts audience interruptions, performs stage effects, and exports a sanitized trace of the scene.
28
-
29
- ## Table of Contents
30
-
31
- - [Live Demo](#live-demo)
32
- - [Current Status](#current-status)
33
- - [Recommended Demo Settings](#recommended-demo-settings)
34
- - [What It Does](#what-it-does)
35
- - [Project Artifacts](#project-artifacts)
36
- - [Small-Model Highlight](#small-model-highlight)
37
- - [Hackathon Fit](#hackathon-fit)
38
- - [How to Demo](#how-to-demo)
39
- - [Architecture](#architecture)
40
- - [Agent Architecture](#agent-architecture)
41
- - [Features](#features)
42
- - [Model and Runtime](#model-and-runtime)
43
- - [Hackathon Targets](#hackathon-targets)
44
- - [Team](#team)
45
- - [Credits](#credits)
46
- - [Learning path (for contributors)](#learning-path-for-contributors)
47
- - [Local Development](#local-development)
48
- - [Configuration](#configuration)
49
- - [Actor Model Backends](#actor-model-backends)
50
- - [Trace Export](#trace-export)
51
- - [Fine-Tuning Artifacts](#fine-tuning-artifacts)
52
- - [Roadmap](#roadmap)
53
-
54
- ## Learning path (for contributors)
55
-
56
- 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.
57
-
58
- ## Live demo
59
-
60
- * Space: [AI Puppet Theater](https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater)
61
- * Demo video: [YouTube walkthrough](https://youtu.be/wPalf_qCOHk)
62
- * Product blog: [AI Puppet Theater: From Premise to Puppet Show](https://huggingface.co/blog/build-small-hackathon/ai-puppet-theater)
63
- * Fine-tuning blog: [Teaching a 1B Model to Speak Puppet JSON](https://huggingface.co/blog/build-small-hackathon/teaching-1b-model-puppet-json)
64
- * Social posts: [LinkedIn](https://www.linkedin.com/posts/shubham-setia-b69a3a110_for-the-hugging-face-gradio-build-small-share-7471972637037056000-l0DV/?utm_source=share&utm_medium=member_desktop&rcm=ACoAABvq9QEB7eiw2Cl9rPzMaDxa6-5XAAgVbAo) · [X/Twitter](https://x.com/shubhamsetia12/status/2066202877673238669?s=20)
65
-
66
- ![AI Puppet Theater demo clip showing a stage generated from a premise](https://cdn-uploads.huggingface.co/production/uploads/67b99348b353ed1b4e5d4587/Bf2mFCbMvPMeH_PXWOQtB.gif)
67
-
68
- ## Current Status
69
-
70
- Playable final-submission demo. The Space, demo video, product blog, fine-tuning blog, social posts, Actor SFT dataset, LoRA adapter, and GGUF model are published.
71
-
72
- ## Recommended Demo Settings
73
-
74
- - **Backend:** HF API, or Deterministic fallback if the model path is unavailable
75
- - **Show length:** Standard
76
- - **Voice:** Browser TTS, or Edge TTS if available
77
- - **Flow:** use **Run One Beat** first, throw a prop, then open **Behind the Curtain**
78
-
79
- ## What It Does
80
-
81
- - Creates a short puppet show from a user premise.
82
- - Casts distinct puppet actors with goals, moods, memories, secrets, speaking styles, and optional tools.
83
- - Uses a Director agent to choose the next speaker, beat type, pacing, stage effect, prop usage, secret reveals, and finale timing.
84
- - Lets the audience throw props, summon an actor, or request a finale while the show is running.
85
- - Renders an animated Gradio stage with active-speaker emphasis, transcript, Agent State, Director log, Browser TTS, optional Edge TTS, and trace export.
86
- - Falls back to deterministic generation whenever model calls fail, keeping the demo runnable without setup.
87
-
88
- ## Project artifacts
89
-
90
- * Actor SFT dataset: [AI-Puppet-Theater-Actor-SFT](https://huggingface.co/datasets/build-small-hackathon/AI-Puppet-Theater-Actor-SFT)
91
- * Actor LoRA adapter: [AI-Puppet-Theater-MiniCPM5-Actor-LoRA](https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA)
92
- * Actor GGUF model: [AI-Puppet-Theater-MiniCPM5-Actor-GGUF](https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF)
93
- * Base model: [openbmb/MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B)
94
- * Field Notes writeups: [product blog](https://huggingface.co/blog/build-small-hackathon/ai-puppet-theater) and [fine-tuning blog](https://huggingface.co/blog/build-small-hackathon/teaching-1b-model-puppet-json)
95
- * Agent design notes: [AGENTS.md](./AGENTS.md)
96
-
97
- ## Small-model highlight
98
-
99
- AI Puppet Theater was built for the Build Small Hackathon with small-model constraints in mind.
100
-
101
- The main fine-tuned Actor experiment uses [`openbmb/MiniCPM5-1B`](https://huggingface.co/openbmb/MiniCPM5-1B), a 1B-parameter model. The project publishes the Actor SFT dataset, a MiniCPM5 Actor LoRA adapter, and a GGUF version for local `llama.cpp` inference experiments.
102
-
103
- Model-backed paths used by the app stay under the 32B limit:
104
-
105
- * `openbmb/MiniCPM5-1B`: 1B base model for Actor fine-tuning.
106
- * `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA`: LoRA adapter for the Actor JSON task.
107
- * `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF`: local GGUF Actor model for `llama.cpp`.
108
- * `Qwen/Qwen3-4B-Instruct-2507`: hosted model-backed path when configured.
109
-
110
- The Space also keeps a deterministic fallback path for reliable no-token demos.
111
-
112
- ## Hackathon fit
113
-
114
- AI Puppet Theater was built for the Build Small Hackathon's Thousand Token Wood track as a small, playful Gradio app. The core idea is not another open-ended chatbot: a Director agent orchestrates a bounded puppet show, Actor agents return structured JSON beats, and audience actions become part of the show state.
115
-
116
- The project stays within the small-model constraint. The main Actor fine-tuning experiment uses `openbmb/MiniCPM5-1B`, a 1B-parameter model, and publishes both a LoRA adapter and a GGUF version for local `llama.cpp` inference experiments. The hosted model-backed path uses `Qwen/Qwen3-4B-Instruct-2507`, which is also under the 32B limit.
117
-
118
- The runtime treats model output as a proposal, not as trusted app state. Actor outputs are validated, optionally repaired, sanitized, traced, and backed by deterministic fallback so the stage can keep running even when a model returns invalid JSON.
119
-
120
- ## How to demo
121
-
122
- 1. Enter a premise.
123
- 2. Click `Create Show`.
124
- 3. Use `Run One Beat` to watch the Director/Actor loop advance.
125
- 4. Throw a prop, summon an Actor, or request a finale from the audience controls.
126
- 5. Open Behind the Curtain to inspect Actor state, Director log, backend status, validation, tool calls, and sanitized trace.
127
-
128
- ## Architecture
129
-
130
- The diagrams below are rendered PNGs from Mermaid sources in `assets/` so they display correctly on Hugging Face Spaces.
131
- Model outputs are treated as validated proposals; deterministic fallbacks keep the show bounded.
132
-
133
- ### System Flow
134
-
135
- ![AI Puppet Theater system flow](assets/architecture-system-flow.png)
136
-
137
- ### Agent Loop
138
-
139
- ![AI Puppet Theater agent loop](assets/architecture-agent-loop.png)
140
-
141
- ### Runtime and Fallbacks
142
-
143
- ![AI Puppet Theater runtime and fallbacks](assets/architecture-runtime-fallbacks.png)
144
-
145
- ## Agent Architecture
146
-
147
- **Director agent:** chooses story phase, next speaker, beat type, stage effect, whether to use a prop, whether to reveal a secret, and when to end the scene. The Director can run through HF API, the experimental local OpenBMB runtime path, or deterministic policy.
148
-
149
- **Actor agents:** respond as individual puppets with persona, goal, mood, memory, intent, secrets, gesture, emotion, and stage-ready short lines.
150
-
151
- **Theatrical tools:** support validated actor actions: `inspect_prop`, `consult_stage_oracle`, and `change_lighting`. Tool calls are used to make the performance feel agentic, not to add hidden complexity.
152
-
153
- **Trace and fallbacks:** every run records sanitized public events for Director decisions, actor outputs, tool results, audience actions, validation, latency, state updates, and fallback reasons. The app avoids exposing tokens, private paths, raw tracebacks, hidden reasoning, or unrevealed actor secrets.
154
-
155
- ## Features
156
-
157
- - Dynamic act length and progress-based pacing.
158
- - LLM-assisted show bible for title, setting, backdrop description, Director, and puppet cast when a model backend is selected.
159
- - LLM Director for speaker selection, beat planning, and finale control.
160
- - Actor intent, memory, mood, state, goals, and secrets.
161
- - Audience actions: prop throws, actor summons, and finale requests.
162
- - Validated theatrical tools for prop inspection, stage-oracle hints, and lighting changes.
163
- - Backdrop generation path: HF text-to-image from setting, LLM-picked stock URL fallback, then keyword stock fallback.
164
- - Browser TTS with character voices or narrator mode.
165
- - Optional Edge TTS character voices with graceful fallback.
166
- - Actor engines: deterministic, HF API, local OpenBMB, local LoRA, and local GGUF.
167
- - Director modes: deterministic, HF API, and local OpenBMB.
168
- - Behind the Curtain log, Agent State panel, and downloadable trace JSON.
169
-
170
- ## Model and Runtime
171
-
172
- This project targets the hackathon's small-model spirit: model-backed paths should stay within the **32B parameter limit**.
173
-
174
- - **HF API backend:** `Qwen/Qwen3-4B-Instruct-2507:nscale`
175
- - **Local LoRA Actor backend:** the published `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA` adapter on `openbmb/MiniCPM5-1B`
176
- - **Local GGUF Actor backend:** the published `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF` model through `llama.cpp` / `llama-cpp-python`
177
- - **Experimental local / ZeroGPU runtime path:** optional OpenBMB Transformers backend, prepared for ZeroGPU when enabled
178
- - **Optional image generation:** stage backdrops may use `black-forest-labs/FLUX.1-schnell`; this is an optional visual path and may not be size-detected by the hackathon checker
179
- - **Fallback:** deterministic Director and actor generation, requiring no token or model download
180
-
181
- Deterministic mode is the safest no-token demo path. HF API mode is the recommended hosted model-backed path when `HF_TOKEN` is configured. The local LoRA, GGUF, OpenBMB, and ZeroGPU paths load lazily and may be slow or unavailable depending on Space hardware, dependencies, and model cache state. Model-backed dialogue paths are configured with models under the 32B constraint.
182
-
183
- ## Hackathon Targets
184
-
185
- * Thousand Token Wood track: playful, weird, AI-powered interactive story/toy experience.
186
- * Tiny Titan fit: the main fine-tuned Actor path is based on `openbmb/MiniCPM5-1B`, a 1B-parameter model.
187
- * OpenBMB sponsor: the Actor fine-tuning path uses `openbmb/MiniCPM5-1B`.
188
- * Modal sponsor: Modal was used for the LoRA fine-tuning workflow.
189
- * Well-Tuned: the project includes a published MiniCPM5-1B Actor LoRA adapter trained for structured Actor JSON beats.
190
- * Llama Champion: the project includes a merged/quantized GGUF Actor model evaluated with `llama.cpp`.
191
- * Sharing is Caring: the project publishes the Actor SFT dataset and supports sanitized trace export.
192
- * Field Notes: the published product and fine-tuning blogs document what was built, what changed during the model experiments, and what was learned.
193
-
194
- ## Submission notes
195
-
196
- * The Space runs on ZeroGPU. We manually checked the account's Space count for the hackathon limit.
197
- * We do not claim full off-grid behavior for the hosted Space because optional hosted model and image-generation paths may be configured.
198
- * The project does include a local GGUF Actor model for `llama.cpp` inference experiments.
199
-
200
- ## Team
201
-
202
- - [Shubham Setia](https://huggingface.co/ShubhamSetia)
203
- - [Riya Bagaria](https://huggingface.co/Bagariariya)
204
-
205
- ## Credits
206
-
207
- * [OpenBMB](https://huggingface.co/openbmb) for `MiniCPM5-1B`, the base model used for the Actor fine-tuning path.
208
- * [Modal](https://modal.com/) for making the LoRA training workflow practical on CUDA.
209
- * [Black Forest Labs](https://huggingface.co/black-forest-labs) for `FLUX.1-schnell`, used for optional backdrop generation.
210
-
211
- ## Local Development
212
-
213
- Install dependencies:
214
-
215
- ```bash
216
- uv sync
217
- ```
218
-
219
- Run the app locally:
220
-
221
- ```bash
222
- uv run python app.py
223
- ```
224
-
225
- For live reload while editing `app.py`, use the Gradio CLI:
226
-
227
- ```bash
228
- uv run gradio app.py
229
- ```
230
-
231
- The Blocks instance is named `app`; Gradio discovers it automatically. If you need to pin the name explicitly:
232
-
233
- ```bash
234
- uv run gradio app.py --demo-name app
235
- ```
236
-
237
- Check syntax:
238
-
239
- ```bash
240
- uv run python -m py_compile app.py puppet_theater/*.py
241
- ```
242
-
243
- Generate Hugging Face Space requirements:
244
-
245
- ```bash
246
- uv pip compile pyproject.toml -o requirements.txt
247
- ```
248
-
249
- If tests are added or changed:
250
-
251
- ```bash
252
- uv run pytest
253
- ```
254
-
255
- ## Configuration
256
-
257
- ### Running modes
258
-
259
- Deterministic mode is the default and requires no model token or local model download. Use it for the fastest, most reliable demo path.
260
-
261
- The optional local OpenBMB runtime path can generate actor lines or Director decisions locally, but it is experimental and may be slow on CPU Spaces because the model must be loaded into the Space process.
262
-
263
- The optional local LoRA and GGUF actor paths use the fine-tuned MiniCPM5 Actor artifacts. They load lazily only when selected, validate every Actor JSON response, and fall back to deterministic actor generation on dependency, download, timeout, schema, or repetition failures.
264
-
265
- The optional Hugging Face API backend uses remote inference through Hugging Face Inference Providers instead of downloading a model locally. It falls back to deterministic generation when a token is missing, the model is unavailable, the API times out, or the model returns invalid JSON.
266
-
267
- ## Actor Model Backends
268
-
269
- The **Actor Engine** selector supports:
270
-
271
- - **Deterministic:** default and recommended for hosted demo reliability.
272
- - **Local LoRA Actor model:** loads the published `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA` adapter on `openbmb/MiniCPM5-1B` with `transformers` and `peft`.
273
- - **Local GGUF Actor model:** loads a local quantized GGUF through `llama-cpp-python`; if `ACTOR_GGUF_MODEL_PATH` is not set, it downloads `minicpm5-actor-q4_k_m.gguf` from `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF`.
274
- - **Local OpenBMB:** the existing experimental local OpenBMB Transformers actor backend.
275
- - **Hugging Face API:** the existing remote actor backend using Hugging Face Inference Providers.
276
-
277
- The LoRA runtime libraries are included in the app runtime dependencies. The Space `requirements.txt` also includes `llama-cpp-python` through the upstream prebuilt CUDA wheel index so the GGUF backend can use GPU offload when the Space image has a compatible wheel. Model weights are still loaded lazily: the first selected LoRA or GGUF beat downloads/loads the model and keeps the loaded backend object in memory for the app lifecycle. If a selected backend fails, times out, errors, or returns invalid JSON, runtime output goes through first-JSON extraction, Actor JSON sanitization, validation, and then falls back to the deterministic actor generator.
278
-
279
- Local model defaults are set for demo reliability: LoRA uses `ACTOR_LORA_DEVICE=auto`, GGUF uses `ACTOR_GGUF_N_GPU_LAYERS=-1`, and both local actor backends default to a 120 second generation timeout.
280
-
281
- Local LoRA example:
282
-
283
- ```bash
284
- ACTOR_MODEL_BACKEND=local_lora \
285
- ACTOR_LORA_BASE_MODEL=openbmb/MiniCPM5-1B \
286
- ACTOR_LORA_ADAPTER=build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA \
287
- uv run python app.py
288
- ```
289
-
290
- Local GGUF example:
291
-
292
- ```bash
293
- ACTOR_MODEL_BACKEND=local_gguf \
294
- uv run python app.py
295
- ```
296
-
297
- `requirements.txt` uses the upstream prebuilt CUDA 12.1 wheel index for `llama-cpp-python` to avoid source builds where possible on the current GPU Space. If the Space CUDA/runtime image changes, replace `/cu121` with the matching CUDA wheel index from the `llama-cpp-python` docs, such as `/cu124`, `/cu125`, `/cu130`, or `/cu132`. For a CPU Space, replace `/cu121` with `/cpu` and set `ACTOR_GGUF_N_GPU_LAYERS=0`. If no compatible prebuilt wheel exists for the Space image, pip may still fall back to a slow native build.
298
-
299
- To force a local file instead of downloading from the Hugging Face GGUF repo:
300
-
301
- ```bash
302
- ACTOR_MODEL_BACKEND=local_gguf \
303
- ACTOR_GGUF_MODEL_PATH=finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf \
304
- uv run python app.py
305
- ```
306
-
307
- To point at a different GGUF repo or filename:
308
-
309
- ```bash
310
- ACTOR_MODEL_BACKEND=local_gguf \
311
- ACTOR_GGUF_REPO_ID=build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF \
312
- ACTOR_GGUF_FILENAME=minicpm5-actor-q4_k_m.gguf \
313
- uv run python app.py
314
- ```
315
-
316
- Limitations:
317
-
318
- - Local LoRA needs enough GPU or CPU memory for `openbmb/MiniCPM5-1B` plus the adapter.
319
- - Local GGUF needs `llama-cpp-python` and enough CPU/GPU memory for the quantized file; by default it downloads the configured GGUF from Hugging Face Hub, or uses `ACTOR_GGUF_MODEL_PATH` when set. The GGUF file is not committed to git.
320
- - Hosted Spaces may use fallback unless a model backend is explicitly configured.
321
- - GGUF is prompted with ChatML, matching the best eval format used for the Q4_K_M actor model.
322
-
323
- ### Hugging Face API
324
-
325
- Set `HF_API_MODEL_ID` to choose the remote model:
326
-
327
- ```bash
328
- HF_API_MODEL_ID=Qwen/Qwen3-4B-Instruct-2507:nscale
329
- ```
330
-
331
- 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.
332
-
333
- 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`; optional provider **`HF_BACKDROP_T2I_PROVIDER`**; timeout **`HF_BACKDROP_T2I_TIMEOUT`**, default 90s; default stage-matched size **1152x512**). 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.
334
-
335
- 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.
336
-
337
- 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.
338
-
339
- Local deterministic mode and app launch work without `HF_TOKEN`.
340
-
341
- ### Stage backdrop
342
-
343
- 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. Set **`HF_BACKDROP_T2I_PROVIDER`** to pin a provider such as `nscale` or `fal-ai`; leave it empty for provider auto-routing. 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.
344
-
345
- ### Voice modes
346
-
347
- Voice is optional. **Off** remains the default and the app works without audio support.
348
-
349
- Browser TTS modes use the browser's built-in `SpeechSynthesis` API:
350
-
351
- - **Browser TTS: Character Voices** gives each actor a deterministic browser voice style.
352
- - **Browser TTS: Narrator Only** reads every latest line with the narrator style.
353
-
354
- **Edge TTS: Character Voices** is experimental. It uses the optional `edge-tts` package to generate an MP3 for only the latest actor or tool line, then plays it through the Edge TTS audio player. Actor slots map to stable Edge voices, and repeated lines are cached in a temporary directory so they are not regenerated every time. Old generated files are cleaned up automatically.
355
-
356
- If `edge-tts` is unavailable, network generation fails, or the service times out, the app shows a short friendly status and keeps the show running. Use Browser TTS or Off as the fallback. Edge TTS does not expose raw tracebacks, local cache paths, tokens, or secrets in the UI or trace output.
357
-
358
- ### ZeroGPU local inference
359
-
360
- This Space keeps `sdk: gradio`; it does not require Docker for ZeroGPU. To experiment with the local OpenBMB runtime path on ZeroGPU, set the Space hardware to ZeroGPU in the Hugging Face Space settings and set:
361
-
362
- ```bash
363
- USE_ZEROGPU=true
364
- ```
365
-
366
- `USE_ZEROGPU` defaults to `false`, so the app still starts locally and on regular CPU Spaces without requesting ZeroGPU. When enabled and the `spaces` package is available, local OpenBMB generation runs through a `@spaces.GPU(duration=30)` function. The app does not load local models at startup.
367
-
368
- The Space requirements are resolved for Python 3.10 compatibility because ZeroGPU builds may use a Python 3.10 base image. The local project also supports Python 3.11. The runtime pins `torch==2.8.0`, which is the preferred ZeroGPU-compatible Torch version for this Space. `llama-cpp-python` is included in `requirements.txt` through its prebuilt CUDA wheel index for GGUF support on the current GPU Space; for CPU Space hardware, switch that index to `/cpu` and set `ACTOR_GGUF_N_GPU_LAYERS=0`. If the Space image cannot use the selected wheel, pip may fall back to a slow source build. If Hugging Face reports a different supported Torch version for ZeroGPU, update `pyproject.toml`, regenerate `requirements.txt`, and refresh `uv.lock`.
369
-
370
- If ZeroGPU is not enabled, `spaces` is unavailable, local model loading fails, CUDA is unavailable inside the GPU function, or model output is invalid, the show falls back gracefully. The Hugging Face API backend remains selectable and is the recommended fallback when you have `HF_TOKEN`; deterministic mode remains the no-token safety path.
371
-
372
- The **Backend** panel reports ZeroGPU enabled status, `spaces` availability, the Torch version, whether CUDA was visible inside the GPU function after a local generation attempt, and the latest fallback reason.
373
-
374
- ## Trace Export
375
-
376
- Every show keeps a lightweight agent trace in memory for the current Gradio session. The collapsed **Behind the Curtain** panel shows a compact human-readable trace summary with a copy button, plus the Director log. The collapsed **Trace / Debug** panel shows the full JSON preview.
377
-
378
- After creating a show, use **Download Trace JSON** to save `ai-puppet-theater-trace-<session_id>.json`. The file contains a stable envelope with `app_name`, `trace_version`, `session_id`, `created_at`, premise, title, setting, `show_length_mode`, min/target/max beats, actor backend, Director mode, model id when used, and normalized events.
379
-
380
- Trace events use a consistent public shape with `event_type`, `timestamp` or `step`, beat/story phase metadata, speaker, Director decisions, actor intent, actor memory updates, tool requests and results, audience actions, backend/model metadata, latency, validation status, fallback usage, and short fallback reasons where available.
381
-
382
- Traces are sanitized so they are safe to publish later as a dataset: they do not include Hugging Face tokens, environment variable values, private local paths, raw tracebacks, hidden reasoning, or private secrets. Errors are reduced to short summaries and model failures are recorded as fallback events.
383
-
384
- ## Fine-Tuning Artifacts
385
-
386
- Fine-tuning scripts, publishing helpers, and model/dataset card templates live under `finetune/`. They are supporting project artifacts and are not part of the Space runtime.
387
-
388
- - **Fine-tuning README:** [finetune/README.md](finetune/README.md)
389
- - **Actor SFT dataset repo:** [AI-Puppet-Theater-Actor-SFT](https://huggingface.co/datasets/build-small-hackathon/AI-Puppet-Theater-Actor-SFT)
390
- - **MiniCPM5 Actor LoRA repo:** [AI-Puppet-Theater-MiniCPM5-Actor-LoRA](https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA)
391
- - **MiniCPM5 Actor GGUF repo:** [AI-Puppet-Theater-MiniCPM5-Actor-GGUF](https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF)
392
-
393
- The current best adapter candidate is the v0 MiniCPM5 Actor LoRA. It was merged into `openbmb/MiniCPM5-1B`, converted to GGUF, and quantized as `Q4_K_M` for llama.cpp testing.
394
-
395
- Best GGUF eval used `llama-completion` with `-no-cnv`, reasoning disabled, and `chatml` prompt formatting. On 40 Actor eval prompts, the Q4_K_M GGUF reached 39/40 extracted JSON parse, 39/40 exact top-level schema, 39/40 sanitized Actor JSON usable, and 0/40 interactive markers.
396
-
397
- The v1 hardening run added useful validation/eval tooling but did not outperform v0 on full eval. Runtime use should still keep first-JSON extraction, Actor JSON validation, tool-request sanitization, and deterministic fallback.
398
-
399
- ## Roadmap
400
-
401
- * Publish a small sanitized trace dataset if time allows.
402
- * Add more tested example prompts after live demo validation.
403
- * Improve Actor SFT examples and compare local LoRA, GGUF, and hosted inference paths more systematically.
404
- * Improve local GGUF inference UX and document recommended `llama.cpp` runtime presets.
405
- * Continue polishing the stage UI and audience interaction loop.
 
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
9
+ python_version: "3.11"
10
  pinned: false
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
+ AI Puppet Theater is a public Gradio Space for building short interactive puppet shows from a user premise.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
assets/architecture-agent-loop.mmd DELETED
@@ -1,26 +0,0 @@
1
- %%{init: {"theme": "base", "themeVariables": {"background": "#fffdf7", "primaryTextColor": "#222222", "actorBkg": "#fff2b8", "actorBorder": "#2c2c2c", "actorTextColor": "#222222", "activationBkgColor": "#d9f3df", "activationBorderColor": "#2c2c2c", "signalColor": "#565656", "signalTextColor": "#222222", "labelBoxBkgColor": "#dcecff", "labelBoxBorderColor": "#2c2c2c", "loopTextColor": "#222222", "fontFamily": "Arial, sans-serif"}}}%%
2
- sequenceDiagram
3
- participant U as Audience
4
- participant S as Gradio Stage
5
- participant D as Director Agent
6
- participant A as Actor Agent
7
- participant V as Validator
8
- participant T as Theatrical Tools
9
- participant L as Trace Log
10
-
11
- U->>S: Create show or interrupt
12
- S->>D: Current session state
13
- D->>D: Choose phase, speaker, beat, effect
14
- D->>A: Beat instruction
15
- A->>V: Structured Actor JSON
16
- V-->>A: Validated or deterministic fallback
17
- A->>A: Update intent, mood, memory, secrets
18
- A->>T: Optional validated tool request
19
- T-->>A: Tool result
20
- A-->>S: Line, emotion, gesture, effect
21
- S-->>U: Render puppet beat and voice
22
- D-->>L: Director decision
23
- V-->>L: Validation and fallback status
24
- A-->>L: Actor response and state update
25
- T-->>L: Tool result
26
- S-->>L: Audience and render events
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
assets/architecture-agent-loop.png DELETED
Binary file (36 kB)
 
assets/architecture-runtime-fallbacks.mmd DELETED
@@ -1,36 +0,0 @@
1
- %%{init: {"theme": "base", "themeVariables": {"background": "#fffdf7", "primaryTextColor": "#222222", "fontFamily": "Arial, sans-serif", "lineColor": "#565656"}}}%%
2
- flowchart TD
3
- M["Selected backend mode"] --> API["HF API<br/>Qwen/Qwen3-4B"]
4
- M --> LoRA["Local LoRA Actor<br/>MiniCPM5 adapter"]
5
- M --> GGUF["Local GGUF Actor<br/>llama.cpp runtime"]
6
- M --> Local["Local / ZeroGPU<br/>OpenBMB"]
7
- M --> D["Deterministic mode<br/>no token required"]
8
-
9
- API --> V["Extract first JSON<br/>sanitize<br/>validate schema"]
10
- LoRA --> V
11
- GGUF --> V
12
- Local --> V
13
- D --> V
14
-
15
- API -->|"missing token<br/>timeout<br/>invalid JSON"| F["Deterministic fallback"]
16
- LoRA -->|"dependency/load failure<br/>timeout<br/>invalid JSON"| F
17
- GGUF -->|"download/load failure<br/>timeout<br/>invalid JSON"| F
18
- Local -->|"GPU unavailable<br/>load failure<br/>invalid output"| F
19
- V -->|"invalid after repair"| F
20
- F --> V
21
-
22
- V -->|"valid"| R["Stage beat<br/>state update<br/>optional tool result"]
23
- R --> T["Sanitized trace export"]
24
-
25
- classDef stage fill:#fff2b8,stroke:#2c2c2c,color:#222222,stroke-width:2px;
26
- classDef model fill:#dcecff,stroke:#2c2c2c,color:#222222,stroke-width:2px;
27
- classDef runtime fill:#ece8ff,stroke:#2c2c2c,color:#222222,stroke-width:2px;
28
- classDef fallback fill:#fbdada,stroke:#2c2c2c,color:#222222,stroke-width:2px;
29
- classDef validate fill:#d9f3df,stroke:#2c2c2c,color:#222222,stroke-width:2px;
30
- classDef trace fill:#f9e1ee,stroke:#2c2c2c,color:#222222,stroke-width:2px;
31
- class M runtime;
32
- class API,LoRA,GGUF,Local model;
33
- class D,F fallback;
34
- class V validate;
35
- class R stage;
36
- class T trace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
assets/architecture-runtime-fallbacks.png DELETED
Binary file (43.1 kB)
 
assets/architecture-system-flow.mmd DELETED
@@ -1,37 +0,0 @@
1
- %%{init: {"theme": "base", "themeVariables": {"background": "#fffdf7", "primaryTextColor": "#222222", "fontFamily": "Arial, sans-serif", "lineColor": "#565656"}}}%%
2
- flowchart TD
3
- U["User premise<br/>+ audience actions"] --> B["Show Builder"]
4
- B --> Bible["Show Bible<br/>title, setting, cast"]
5
- Bible --> Backdrop["Backdrop Resolver<br/>HF T2I, LLM URL, keyword fallback"]
6
- Backdrop --> D["Director Agent"]
7
- D --> A["Actor Agents"]
8
- A --> Validate["Validate + repair<br/>Actor JSON"]
9
- Validate --> Tools["Theatrical Tools<br/>prop, oracle, lighting"]
10
- Tools --> Stage["Stage Renderer<br/>+ TTS"]
11
- Stage --> Output["Puppet Stage<br/>Transcript<br/>Agent State"]
12
-
13
- D --> Trace["Behind the Curtain Trace"]
14
- A --> Trace
15
- Validate --> Trace
16
- Tools --> Trace
17
- Stage --> Trace
18
-
19
- HF["HF API<br/>Qwen/Qwen3-4B-Instruct-2507:nscale"] --> Runtime["Runtime + fallback layer"]
20
- Local["Local actors<br/>LoRA, GGUF, OpenBMB"] --> Runtime
21
- ZG["ZeroGPU<br/>OpenBMB path"] --> Runtime
22
- Fallback["Deterministic<br/>fallback"] --> Runtime
23
- Runtime --> Bible
24
- Runtime --> D
25
- Runtime --> A
26
-
27
- classDef stage fill:#fff2b8,stroke:#2c2c2c,color:#222222,stroke-width:2px;
28
- classDef agent fill:#d9f3df,stroke:#2c2c2c,color:#222222,stroke-width:2px;
29
- classDef tool fill:#dcecff,stroke:#2c2c2c,color:#222222,stroke-width:2px;
30
- classDef trace fill:#f9e1ee,stroke:#2c2c2c,color:#222222,stroke-width:2px;
31
- classDef runtime fill:#ece8ff,stroke:#2c2c2c,color:#222222,stroke-width:2px;
32
- class U,B,Bible,Backdrop,Stage,Output stage;
33
- class D,A agent;
34
- class Tools,Validate tool;
35
- class Trace trace;
36
- class Runtime,HF,Local,ZG runtime;
37
- class Fallback runtime;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
assets/architecture-system-flow.png DELETED
Binary file (53.8 kB)
 
assets/puppeteer-config.json DELETED
@@ -1,3 +0,0 @@
1
- {
2
- "args": ["--no-sandbox", "--disable-setuid-sandbox"]
3
- }
 
 
 
 
docs/LEARNING_PATH.md DELETED
@@ -1,213 +0,0 @@
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).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/README.md DELETED
@@ -1,714 +0,0 @@
1
- # Actor SFT Dataset V0
2
-
3
- This directory contains the first-pass dataset tooling for fine-tuning an AI Puppet Theater Actor agent. It prepares chat-style supervised fine-tuning rows for the target model `openbmb/MiniCPM5-1B`; it does not train, quantize, publish, or integrate a model into the Space.
4
-
5
- The goal is to teach the Actor agent to return one compact, valid JSON object for a single puppet beat. The output shape matches the planned actor contract:
6
-
7
- ```json
8
- {
9
- "intent": "inspect_prop",
10
- "line": "This rubber duck squeaks exactly like a guilty witness.",
11
- "emotion": "investigative",
12
- "gesture": "leans toward the glowing prop",
13
- "stage_effect": "prop_table_glow",
14
- "memory_update": "Noted that the rubber duck behaved like evidence.",
15
- "tool_request": {
16
- "tool": "inspect_prop",
17
- "args": {"prop": "rubber duck"},
18
- "reason": "The prop may reveal a stage clue."
19
- }
20
- }
21
- ```
22
-
23
- `memory_update` may be `null` when no useful memory should be saved. `tool_request` may also be `null`. Allowed tools are `inspect_prop`, `consult_stage_oracle`, and `change_lighting`.
24
-
25
- ## Schema
26
-
27
- Each SFT row is JSONL with:
28
-
29
- ```json
30
- {
31
- "id": "actor-sft-v0-000001",
32
- "source_mix": ["synthetic_v0", "deterministic_templates", "ai_puppet_theater_runtime_schema"],
33
- "row_type": "prop_inspection",
34
- "messages": [
35
- {"role": "system", "content": "You are an Actor agent in AI Puppet Theater..."},
36
- {"role": "user", "content": "premise: ...\nshow_state JSON: ...\nactor JSON: ...\ndirector_instruction: ..."},
37
- {"role": "assistant", "content": "{\"intent\":\"...\",\"line\":\"...\"}"}
38
- ]
39
- }
40
- ```
41
-
42
- The assistant message content is a serialized JSON object, not a nested object, so it is directly usable as a chat SFT completion.
43
-
44
- Rows created from optional local seed files also include:
45
-
46
- ```json
47
- {
48
- "source_dataset": "G-reen/TheatreLM-v2.1-Characters",
49
- "transformation": "seeded_synthetic_actor_json"
50
- }
51
- ```
52
-
53
- The raw external text is used only to seed premise, persona, setting, memory, prop, and Director-instruction material. Assistant completions are still generated by this repo's validated Actor JSON templates.
54
-
55
- Row types in v0:
56
-
57
- - `normal_reaction`
58
- - `prop_inspection`
59
- - `oracle_consult`
60
- - `lighting_change`
61
- - `memory_callback`
62
- - `secret_hint_or_reveal`
63
- - `finale`
64
- - `comedic_confusion`
65
-
66
- ## Generate
67
-
68
- Run from the repository root:
69
-
70
- ```bash
71
- python finetune/scripts/generate_actor_sft_v0.py
72
- ```
73
-
74
- Generation is deterministic by default using a fixed seed, so the sample and eval files can be regenerated reproducibly. The synthetic-v0 pool currently uses 40 premises, 25 actor profiles, 30+ props, 10+ moods, 10+ stage/lighting states, multiple line templates per row type, multiple memory update templates, and multiple Director instructions per row type.
75
-
76
- Optional external seed material can be provided as local JSONL files:
77
-
78
- - `finetune/external_seeds/theatrelm_sample.jsonl`
79
- - `finetune/external_seeds/rpgpt_sample.jsonl`
80
-
81
- These files are optional. If they are absent, generation prints a short message and continues with synthetic-only data. To use another local path:
82
-
83
- ```bash
84
- python finetune/scripts/generate_actor_sft_v0.py \
85
- --theatrelm-seed-path /path/to/theatrelm_sample.jsonl \
86
- --rpgpt-seed-path /path/to/rpgpt_sample.jsonl
87
- ```
88
-
89
- The supported seed sources are:
90
-
91
- - [G-reen/TheatreLM-v2.1-Characters](https://huggingface.co/datasets/G-reen/TheatreLM-v2.1-Characters)
92
- - [practical-dreamer/RPGPT_PublicDomain-alpaca](https://huggingface.co/datasets/practical-dreamer/RPGPT_PublicDomain-alpaca)
93
-
94
- Raw external rows are not committed. `finetune/external_seeds/` is gitignored except for `.gitkeep`. Seeded rows include source metadata and should be credited when the full generated dataset is published separately as a Hugging Face Dataset repo.
95
-
96
- External seed ingestion applies simple demo-safety filters and skips empty, malformed, very long, sexually explicit, heavily profane, or graphically violent rows. The filter is intentionally conservative and does not replace manual review before publishing.
97
-
98
- Default output:
99
-
100
- - `finetune/data/actor_sft_v0.jsonl`
101
- - `finetune/data/actor_sft_v0_train.jsonl`
102
- - `finetune/data/actor_sft_v0_val.jsonl`
103
- - `finetune/data_samples/actor_sft_v0_sample.jsonl`
104
- - `finetune/data_samples/actor_eval_prompts.jsonl`
105
-
106
- The full generated dataset under `finetune/data/` is gitignored. Keep only the small sample, eval prompt files, scripts, and docs in this repo.
107
-
108
- ### Targeted V1 Hardening Dataset
109
-
110
- V1 is a targeted synthetic hardening set for the v0 Actor LoRA failure modes seen in eval. It keeps v0 reproducible and adds a separate dataset version with more oracle, prop, lighting, and memory callback rows.
111
-
112
- Generate v1:
113
-
114
- ```bash
115
- python finetune/scripts/generate_actor_sft_v0.py --version v1
116
- ```
117
-
118
- Default v1 output:
119
-
120
- - `finetune/data/actor_sft_v1.jsonl`
121
- - `finetune/data/actor_sft_v1_train.jsonl`
122
- - `finetune/data/actor_sft_v1_val.jsonl`
123
- - `finetune/data_samples/actor_sft_v1_sample.jsonl`
124
-
125
- V1 defaults to 2,200 rows. Use `--rows` if you want a different size in the accepted 2,000-2,500 range:
126
-
127
- ```bash
128
- python finetune/scripts/generate_actor_sft_v0.py --version v1 --rows 2400
129
- ```
130
-
131
- V1 hardening examples enforce the same seven assistant top-level fields and over-sample:
132
-
133
- - clean `consult_stage_oracle` calls with only `tool`, `args.question`, and `reason`
134
- - clean `inspect_prop` calls with only `tool`, `args.prop`, and `reason`
135
- - clean `change_lighting` calls with only `tool`, `args.mood`, and `reason`
136
- - memory callbacks that include `line` and do not copy input/state fields into assistant JSON
137
- - non-finale reactions that avoid `deliver_finale` and `final_bow_lights`
138
- - single serialized JSON objects with no markdown, continuation, duplicate object, or copied state fields
139
-
140
- ## Validate
141
-
142
- Run:
143
-
144
- ```bash
145
- python finetune/scripts/validate_actor_sft.py finetune/data/actor_sft_v0.jsonl
146
- ```
147
-
148
- Validate v1:
149
-
150
- ```bash
151
- python finetune/scripts/validate_actor_sft.py finetune/data/actor_sft_v1.jsonl
152
- ```
153
-
154
- The validator checks row shape, chat roles, assistant JSON parsing, exact assistant fields, line length, allowed row types, tool request shape, and allowed tools. It prints total rows, valid and invalid counts, train/val counts when sibling split files are present, row type distribution, and tool distribution.
155
-
156
- For v1, validation also rejects `change_lighting` args that do not use the current app runtime key `mood`, extra tool keys, copied state/input fields such as `show_state`, `recent_transcript`, `held_props`, `latest_prop`, `latest_audience_action`, or `tool_results`, and finale intent/effects outside finale context.
157
-
158
- ## Strict Local Audit
159
-
160
- Before retraining after a regression, run the stricter local audit. It checks that assistant completions are exactly one JSON object, detects duplicate top-level keys, rejects missing or extra assistant fields, rejects copied state/input fields, validates strict tool shapes, and checks finale intent/effects against `show_state`.
161
-
162
- ```bash
163
- python finetune/scripts/audit_actor_sft.py finetune/data/actor_sft_v1.jsonl
164
- python finetune/scripts/audit_actor_sft.py finetune/data/actor_sft_v0.jsonl
165
- ```
166
-
167
- The audit intentionally follows the current app runtime tool schema. At the time of writing, `change_lighting` expects `args: {"mood": "..."}` in `puppet_theater/tools.py`.
168
-
169
- ## Synthetic-v0 Status
170
-
171
- This is a deterministic synthetic v0 dataset. It is intentionally small, safe, and template-driven so the pipeline can be reviewed before model training. It covers no-tool responses, prop inspection, oracle consultation, lighting changes, memory callbacks, secret hints/reveals, finales, and comedic confusion.
172
-
173
- The full generated dataset is expected to be published separately as a Hugging Face Dataset repo after review. This codebase keeps only reproducible scripts plus small committed examples. If optional external seeds are used in a published dataset, credit the source datasets above.
174
-
175
- ## Modal LoRA Training
176
-
177
- The first training target is a LoRA/QLoRA adapter for `openbmb/MiniCPM5-1B`. LoRA trains a small adapter on top of the base model instead of fully retraining all model weights. The first output is an adapter directory. Later tasks can merge that adapter into the base model and convert or quantize the merged model to GGUF for llama.cpp.
178
-
179
- Training dependencies are isolated in `finetune/requirements-train.txt`; the main Gradio app does not import them.
180
-
181
- ### Setup
182
-
183
- Install and authenticate Modal locally:
184
-
185
- ```bash
186
- pip install modal
187
- modal setup
188
- ```
189
-
190
- Generate the local train/val files before launching Modal:
191
-
192
- ```bash
193
- python finetune/scripts/generate_actor_sft_v0.py
194
- python finetune/scripts/validate_actor_sft.py finetune/data/actor_sft_v0.jsonl
195
- ```
196
-
197
- The Modal wrapper mounts the local `finetune/` directory into the job image, including the generated `finetune/data/actor_sft_v0_train.jsonl` and `finetune/data/actor_sft_v0_val.jsonl` files. Outputs and model cache live in a persistent Modal Volume named `ai-puppet-theater-finetune`.
198
-
199
- `openbmb/MiniCPM5-1B` may be publicly downloadable. If Hugging Face access is required, create a Modal secret containing `HF_TOKEN` and opt in to using it:
200
-
201
- ```bash
202
- modal secret create huggingface-secret HF_TOKEN=hf_...
203
- MODAL_HF_SECRET_NAME=huggingface-secret modal run finetune/modal_train_actor_lora.py::smoke_test
204
- ```
205
-
206
- Do not commit tokens or put them in dataset files.
207
-
208
- ### Smoke Test
209
-
210
- Run a tiny Modal training job that uses only 20 train rows and 10 eval rows:
211
-
212
- ```bash
213
- modal run finetune/modal_train_actor_lora.py::smoke_test
214
- ```
215
-
216
- The smoke test defaults to an `A10` GPU. To change the GPU class:
217
-
218
- ```bash
219
- MODAL_GPU=A10G modal run finetune/modal_train_actor_lora.py::smoke_test
220
- ```
221
-
222
- The smoke-test adapter is saved in the Modal Volume under:
223
-
224
- ```text
225
- /vol/outputs/minicpm5-actor-lora-smoke
226
- ```
227
-
228
- ### Full Run
229
-
230
- Run against the full generated train/val split:
231
-
232
- ```bash
233
- modal run finetune/modal_train_actor_lora.py::train_full
234
- ```
235
-
236
- Default training settings:
237
-
238
- - model: `openbmb/MiniCPM5-1B`
239
- - max sequence length: `1024`
240
- - epochs: `2`
241
- - learning rate: `2e-4`
242
- - LoRA rank/alpha/dropout: `16` / `32` / `0.05`
243
- - per-device train batch size: `2`
244
- - gradient accumulation: `8`
245
- - save/eval strategy: `epoch`
246
- - seed: `42`
247
- - QLoRA: enabled by default
248
-
249
- The full adapter is saved in the Modal Volume under:
250
-
251
- ```text
252
- /vol/outputs/minicpm5-actor-lora
253
- ```
254
-
255
- ### V1 Hardening Run
256
-
257
- Generate and validate the targeted v1 dataset first:
258
-
259
- ```bash
260
- python finetune/scripts/generate_actor_sft_v0.py --version v1
261
- python finetune/scripts/validate_actor_sft.py finetune/data/actor_sft_v1.jsonl
262
- ```
263
-
264
- Train a separate v1 adapter on Modal without deleting or overwriting the v0 adapter:
265
-
266
- ```bash
267
- modal run finetune/modal_train_actor_lora.py::train_v1
268
- ```
269
-
270
- This uses:
271
-
272
- - train file: `finetune/data/actor_sft_v1_train.jsonl`
273
- - val file: `finetune/data/actor_sft_v1_val.jsonl`
274
- - base model: `openbmb/MiniCPM5-1B`
275
- - epochs: `2`
276
- - output dir: `/vol/outputs/minicpm5-actor-lora-v1`
277
-
278
- The downloaded/local adapter name should be:
279
-
280
- ```text
281
- finetune/outputs/minicpm5-actor-lora-v1
282
- ```
283
-
284
- ### Retrieve Outputs
285
-
286
- Use the Modal volume CLI to download the trained adapter directory from the persistent Volume:
287
-
288
- ```bash
289
- mkdir -p finetune
290
- cd finetune
291
- modal volume get --force ai-puppet-theater-finetune /outputs/minicpm5-actor-lora
292
- cd ..
293
- ```
294
-
295
- This writes the Modal directory `/vol/outputs/minicpm5-actor-lora` to local `finetune/outputs/minicpm5-actor-lora`. Passing `finetune/outputs/minicpm5-actor-lora` as the local destination can fail if that directory already exists.
296
-
297
- After download, confirm the adapter files are present locally. Full runs may have adapter files at the output root and/or inside epoch checkpoints such as `checkpoint-158`:
298
-
299
- ```bash
300
- ls finetune/outputs/minicpm5-actor-lora
301
- find finetune/outputs/minicpm5-actor-lora -name adapter_config.json -o -name adapter_model.safetensors
302
- ```
303
-
304
- The source directory in Modal is `/vol/outputs/minicpm5-actor-lora`; the local destination is `finetune/outputs/minicpm5-actor-lora`. Local adapter outputs under `finetune/outputs/` are gitignored.
305
-
306
- Retrieve the v1 adapter similarly:
307
-
308
- ```bash
309
- mkdir -p finetune
310
- cd finetune
311
- modal volume get --force ai-puppet-theater-finetune /outputs/minicpm5-actor-lora-v1
312
- cd ..
313
- ```
314
-
315
- This writes `/vol/outputs/minicpm5-actor-lora-v1` to local `finetune/outputs/minicpm5-actor-lora-v1`.
316
-
317
- ### Local Script
318
-
319
- The training script can also run directly in a CUDA environment with the training requirements installed:
320
-
321
- ```bash
322
- pip install -r finetune/requirements-train.txt
323
- python finetune/scripts/train_minicpm5_actor_lora.py \
324
- --train_file finetune/data/actor_sft_v0_train.jsonl \
325
- --val_file finetune/data/actor_sft_v0_val.jsonl \
326
- --output_dir finetune/outputs/minicpm5-actor-lora \
327
- --max_train_samples 20 \
328
- --max_eval_samples 10 \
329
- --epochs 0.05
330
- ```
331
-
332
- The Mac local machine is best used for repo work and later GGUF/llama.cpp testing. The intended training path is Modal/CUDA, not the Hugging Face Space runtime.
333
-
334
- ## Adapter Eval
335
-
336
- Before merging, quantizing, publishing, or integrating the adapter, run generation against the held-out Actor eval prompts and validate whether responses are clean Actor JSON.
337
-
338
- Default local eval command:
339
-
340
- ```bash
341
- python finetune/scripts/eval_minicpm5_actor_lora.py \
342
- --adapter_dir finetune/minicpm5-actor-lora \
343
- --eval_file finetune/data_samples/actor_eval_prompts.jsonl \
344
- --output_file finetune/eval_outputs/minicpm5_actor_lora_eval.jsonl
345
- ```
346
-
347
- Quick local smoke eval:
348
-
349
- ```bash
350
- python finetune/scripts/eval_minicpm5_actor_lora.py --limit 3
351
- ```
352
-
353
- The script loads `openbmb/MiniCPM5-1B`, applies the local LoRA adapter from `finetune/minicpm5-actor-lora`, generates short deterministic responses with `temperature=0.0` and `max_new_tokens=192` by default, validates JSON shape/tool calls/line length, prints aggregate metrics, and writes detailed generations to:
354
-
355
- ```text
356
- finetune/eval_outputs/minicpm5_actor_lora_eval.jsonl
357
- ```
358
-
359
- Eval distinguishes JSON and schema quality levels:
360
-
361
- - **Raw clean JSON** means the model returned exactly one JSON object and no extra text.
362
- - **Extracted usable JSON** means the evaluator found and parsed the first complete balanced JSON object, even if the model continued with extra text afterwards.
363
- - **Has required fields** means the parsed object includes usable `intent`, `line`, `emotion`, `gesture`, `stage_effect`, `memory_update`, and `tool_request` fields.
364
- - **Exact top-level schema** means the parsed object has only those seven top-level fields.
365
- - **Sanitized actor JSON usable** means extra top-level fields were dropped and tool args were normalized into the runtime schema while preserving the required Actor output.
366
-
367
- Detailed eval rows keep `assistant_text`, extracted `json_text`, parsed JSON, `sanitized_actor_json`, and validation diagnostics such as `parse_error`, `clean_json_error`, `tool_error`, `missing_required_fields`, `extra_top_level_field_names`, and `forbidden_top_level_field_names`. Forbidden top-level fields include copied persona/state fields such as `speaking_style`, `show_state`, `recent_transcript`, `tool_results`, `setting`, and `story_phase`.
368
-
369
- Tool requests are validated strictly and also sanitized for demo/runtime reliability. For example, `inspect_prop` keeps only `{"prop": ...}`, `consult_stage_oracle` keeps only `{"question": ...}`, and `change_lighting` keeps the current runtime shape `{"mood": ...}`. Generated `change_lighting` aliases such as `{"lighting": ...}` or `{"color": ...}` are normalized to `{"mood": ...}` for app compatibility. Tool-name aliases such as `consult oracle` and `consult_oracle` are normalized to `consult_stage_oracle` for diagnostics. If a generated tool request has extra args or a normalizable tool name but can be reduced safely, the eval output records `sanitization_needed: true`, preserves `original_tool_request`, and writes `sanitized_tool_request`. Unparsed outputs are not counted as sanitized usable.
370
-
371
- For experiments, `--append_eval_suffix` adds a stricter reminder to return exactly one JSON object with only the seven Actor keys and not copy input fields. It is off by default because eval metrics should reflect the adapter's normal prompt behavior, and some adapters become less stable with extra instruction text.
372
-
373
- If local Mac inference is too slow or unsupported, run eval on Modal/CUDA from the repository root. This uses the local adapter directory mounted into the Modal image and writes eval details to the persistent Modal Volume:
374
-
375
- ```bash
376
- cd /Users/shubhamsetia/learn/huggingface_hackathon/AI-Puppet-Theater
377
- modal run finetune/modal_train_actor_lora.py::eval_adapter --limit 3
378
- ```
379
-
380
- Run the full eval by omitting `--limit`:
381
-
382
- ```bash
383
- modal run finetune/modal_train_actor_lora.py::eval_adapter
384
- ```
385
-
386
- Evaluate the v1 adapter stored in the Modal Volume:
387
-
388
- ```bash
389
- cd /Users/shubhamsetia/learn/huggingface_hackathon/AI-Puppet-Theater
390
- modal run finetune/modal_train_actor_lora.py::eval_adapter_v1 --limit 10
391
- ```
392
-
393
- Run the full v1 eval by omitting `--limit`:
394
-
395
- ```bash
396
- modal run finetune/modal_train_actor_lora.py::eval_adapter_v1
397
- ```
398
-
399
- The Modal eval output path is:
400
-
401
- ```text
402
- /vol/eval_outputs/minicpm5_actor_lora_eval.jsonl
403
- ```
404
-
405
- The Modal v1 eval output path is:
406
-
407
- ```text
408
- /vol/eval_outputs/minicpm5_actor_lora_v1_eval.jsonl
409
- ```
410
-
411
- Evaluate the merged v0 model after running `merge_v0`:
412
-
413
- ```bash
414
- modal run finetune/modal_train_actor_lora.py::eval_merged_v0 --limit 10
415
- ```
416
-
417
- The Modal merged-model eval output path is:
418
-
419
- ```text
420
- /vol/eval_outputs/minicpm5_actor_merged_v0_eval.jsonl
421
- ```
422
-
423
- Download it locally with:
424
-
425
- ```bash
426
- mkdir -p finetune/eval_outputs
427
- modal volume get --force ai-puppet-theater-finetune \
428
- /eval_outputs/minicpm5_actor_lora_eval.jsonl \
429
- finetune/eval_outputs/minicpm5_actor_lora_eval.jsonl
430
- ```
431
-
432
- Download the v1 eval output locally with:
433
-
434
- ```bash
435
- mkdir -p finetune/eval_outputs
436
- modal volume get --force ai-puppet-theater-finetune \
437
- /eval_outputs/minicpm5_actor_lora_v1_eval.jsonl \
438
- finetune/eval_outputs/minicpm5_actor_lora_v1_eval.jsonl
439
- ```
440
-
441
- Download the merged-model eval output locally with:
442
-
443
- ```bash
444
- mkdir -p finetune/eval_outputs
445
- modal volume get --force ai-puppet-theater-finetune \
446
- /eval_outputs/minicpm5_actor_merged_v0_eval.jsonl \
447
- finetune/eval_outputs/minicpm5_actor_merged_v0_eval.jsonl
448
- ```
449
-
450
- Keep this as eval only: do not merge, convert to GGUF, publish, or integrate the model in this step.
451
-
452
- ## Merge LoRA Adapter
453
-
454
- The current best Actor adapter is v0:
455
-
456
- - local adapter path: `finetune/minicpm5-actor-lora/`
457
- - published adapter: `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA`
458
-
459
- Merge v0 into `openbmb/MiniCPM5-1B` to create a standalone Hugging Face model folder for later GGUF conversion:
460
-
461
- ```bash
462
- python finetune/scripts/merge_actor_lora.py
463
- ```
464
-
465
- Default merge inputs and output:
466
-
467
- - base model: `openbmb/MiniCPM5-1B`
468
- - adapter dir: `finetune/minicpm5-actor-lora`
469
- - output dir: `finetune/outputs/minicpm5-actor-merged`
470
-
471
- The script saves the merged model with `safe_serialization=True`, saves tokenizer files, and writes `merge_manifest.json`. Use `--dtype float16`, `--dtype bfloat16`, or `--dtype float32` to control merge dtype.
472
-
473
- If local memory is tight, run the Modal merge entrypoint instead. It merges the v0 adapter from `/vol/outputs/minicpm5-actor-lora` and writes the merged model to `/vol/outputs/minicpm5-actor-merged`:
474
-
475
- ```bash
476
- modal run finetune/modal_train_actor_lora.py::merge_v0
477
- ```
478
-
479
- This step only creates a merged Hugging Face model folder. It does not convert to GGUF, quantize, publish, or integrate the model into the Space.
480
-
481
- ## GGUF Conversion
482
-
483
- After merging the v0 Actor LoRA adapter, convert the standalone Hugging Face model folder to GGUF for later llama.cpp testing. Do not commit generated `.gguf` files; `finetune/outputs/gguf/` and `*.gguf` are gitignored.
484
-
485
- Install and build llama.cpp locally on Mac outside this repo, for example as a sibling directory:
486
-
487
- ```bash
488
- cd ..
489
- git clone https://github.com/ggml-org/llama.cpp.git
490
- cd llama.cpp
491
- cmake -B build
492
- cmake --build build --config Release -j
493
- cd ../AI-Puppet-Theater
494
- ```
495
-
496
- If you already have llama.cpp somewhere else, set `LLAMA_CPP_DIR` when running the conversion script.
497
-
498
- Make sure the merged model is available locally at:
499
-
500
- ```text
501
- finetune/outputs/minicpm5-actor-merged
502
- ```
503
-
504
- If it was merged on Modal, download it first:
505
-
506
- ```bash
507
- mkdir -p finetune
508
- cd finetune
509
- modal volume get --force ai-puppet-theater-finetune /outputs/minicpm5-actor-merged
510
- cd ..
511
- ```
512
-
513
- Convert only to f16 GGUF:
514
-
515
- ```bash
516
- LLAMA_CPP_DIR=../llama.cpp \
517
- MERGED_MODEL_DIR=finetune/outputs/minicpm5-actor-merged \
518
- GGUF_OUT_DIR=finetune/outputs/gguf \
519
- OUTTYPE=f16 \
520
- PYTHON_BIN="uv run --with sentencepiece python" \
521
- bash finetune/scripts/convert_actor_merged_to_gguf.sh convert
522
- ```
523
-
524
- This writes:
525
-
526
- ```text
527
- finetune/outputs/gguf/minicpm5-actor-f16.gguf
528
- ```
529
-
530
- Quantize to the default `Q4_K_M` GGUF:
531
-
532
- ```bash
533
- LLAMA_CPP_DIR=../llama.cpp \
534
- GGUF_OUT_DIR=finetune/outputs/gguf \
535
- OUTTYPE=f16 \
536
- QUANT_TYPE=Q4_K_M \
537
- bash finetune/scripts/convert_actor_merged_to_gguf.sh quantize
538
- ```
539
-
540
- This writes:
541
-
542
- ```text
543
- finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf
544
- ```
545
-
546
- Run conversion and quantization together:
547
-
548
- ```bash
549
- LLAMA_CPP_DIR=../llama.cpp \
550
- PYTHON_BIN="uv run --with sentencepiece python" \
551
- bash finetune/scripts/convert_actor_merged_to_gguf.sh all
552
- ```
553
-
554
- For automated GGUF eval, prefer `llama-completion` with `-no-cnv`. This local llama.cpp build reports that `--no-conversation` is not supported by `llama-cli` and asks callers to use `llama-completion` for non-interactive one-shot generation.
555
-
556
- Quick llama-completion smoke test:
557
-
558
- ```bash
559
- ../llama.cpp/build/bin/llama-completion \
560
- -m finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf \
561
- -no-cnv \
562
- -n 192 \
563
- --temp 0 \
564
- --reasoning off \
565
- --reasoning-budget 0 \
566
- -p 'SYSTEM: You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable.
567
-
568
- USER: premise: A moon mayor denies stealing the town last spoon
569
- show_state JSON: {"story_phase":"complication","latest_prop":"silver spoon","finale_requested":false}
570
- actor JSON: {"name":"Mina Moonbutton","mood":"curious","tools":["inspect_prop"]}
571
- director_instruction: Inspect the latest prop and keep the line short.
572
-
573
- Return exactly one JSON object with exactly these keys: intent, line, emotion, gesture, stage_effect, memory_update, tool_request. Do not omit stage_effect.
574
-
575
- ASSISTANT JSON:'
576
- ```
577
-
578
- Run the local GGUF eval harness against the same Actor eval prompts:
579
-
580
- ```bash
581
- python finetune/scripts/eval_actor_gguf.py \
582
- --llama_bin ../llama.cpp/build/bin/llama-completion \
583
- --model finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf \
584
- --limit 3 \
585
- --prompt_format simple_json
586
- ```
587
-
588
- Run a 10-prompt comparison across supported prompt formats:
589
-
590
- ```bash
591
- python finetune/scripts/eval_actor_gguf.py \
592
- --llama_bin ../llama.cpp/build/bin/llama-completion \
593
- --model finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf \
594
- --limit 10 \
595
- --prompt_format auto
596
- ```
597
-
598
- Supported GGUF prompt formats are `raw`, `simple_json`, `chatml`, and `system_user_assistant`. The `auto` mode runs all of them and prints metrics per format. The eval details are written to:
599
-
600
- ```text
601
- finetune/eval_outputs/minicpm5_actor_gguf_eval.jsonl
602
- ```
603
-
604
- The GGUF eval harness uses the same first-balanced-JSON extraction and Actor JSON sanitizer metrics as the Transformers eval. It uses one-shot command arguments, not interactive stdin. It probes `llama-completion --help` and passes `--reasoning off`, `--reasoning-budget 0`, and stop strings only when the installed binary supports those flags.
605
-
606
- For this build, the harness prefers `-no-cnv` when available and falls back to `--no-conversation` only if the binary advertises that flag. It prints progress for each row, captures stdout/stderr in the eval JSONL, and records runtime marker diagnostics. If a GGUF output uses `tool_request: []`, strict `tool_request` remains false, but the eval sanitizer may convert that empty list to `null` and count it as `sanitization_needed` for runtime usability.
607
-
608
- If outputs contain `[Start thinking]`, repeated `JSON:` prefixes, assistant continuations, `available commands:`, `chat template is available, enabling conversation mode`, `please use llama-completion instead`, or interactive prompts like `>`, treat that as a prompt/template or binary mismatch and compare the prompt formats before judging the merged model itself. `--llama_cli` remains as a backward-compatible alias for `--llama_bin`, but `llama-cli` may enter interactive mode for this model/build and should be used only for manual smoke checks. Do not publish the GGUF until llama.cpp eval is acceptable.
609
-
610
- Expected shape:
611
-
612
- ```json
613
- {"intent":"inspect_prop","line":"This silver spoon squeaks like a guilty witness.","emotion":"investigative","gesture":"leans toward the glowing prop","stage_effect":"prop_table_glow","memory_update":"Noted the silver spoon as evidence.","tool_request":{"tool":"inspect_prop","args":{"prop":"silver spoon"},"reason":"The prop may reveal one concrete stage clue."}}
614
- ```
615
-
616
- If conversion fails due to MiniCPM5 architecture or tokenizer support, capture the exact `convert_hf_to_gguf.py` error. The likely next step is updating to the latest llama.cpp or using an alternate conversion path once MiniCPM5 support is available.
617
-
618
- ## Publishing GGUF
619
-
620
- The existing LoRA adapter publisher is separate from GGUF publishing and should not be reused for the quantized llama.cpp artifact. The LoRA publisher uploads adapter files such as `adapter_model.safetensors` and `adapter_config.json`; the GGUF path stages the standalone quantized file plus a GGUF-specific model card.
621
-
622
- Target GGUF model repo:
623
-
624
- ```text
625
- build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF
626
- ```
627
-
628
- Prepare a local publish directory without uploading anything:
629
-
630
- ```bash
631
- python finetune/scripts/prepare_gguf_publish.py --clean
632
- ```
633
-
634
- Dry-run the publish prep plan:
635
-
636
- ```bash
637
- python finetune/scripts/prepare_gguf_publish.py --dry_run
638
- ```
639
-
640
- The prep script creates:
641
-
642
- ```text
643
- finetune/publish_gguf/
644
- README.md
645
- minicpm5-actor-q4_k_m.gguf
646
- publish_manifest.json
647
- eval/minicpm5_actor_gguf_eval.jsonl # if the eval file exists locally
648
- ```
649
-
650
- The script does not call Hugging Face APIs and does not run `hf upload`. When you are ready to publish manually, run:
651
-
652
- ```bash
653
- hf repo create build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF --type model --public
654
- hf upload build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF finetune/publish_gguf . --repo-type model --commit-message "Add Q4_K_M GGUF actor model"
655
- ```
656
-
657
- After publishing, verify the repo at:
658
-
659
- ```text
660
- https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF
661
- ```
662
-
663
- ## Hugging Face Publishing
664
-
665
- Publishing scripts are provided for the Actor SFT dataset and current best MiniCPM5 Actor LoRA adapter. They default to dry-run mode and do not upload unless you explicitly pass `--no-dry_run` with Hugging Face authentication available.
666
-
667
- Install or use an environment with `huggingface_hub` available. It is included in `finetune/requirements-train.txt`.
668
-
669
- Authenticate only when you are ready to upload:
670
-
671
- ```bash
672
- hf auth login
673
- ```
674
-
675
- Dry-run the dataset publish plan:
676
-
677
- ```bash
678
- python finetune/scripts/publish_actor_sft_dataset.py --dry_run
679
- ```
680
-
681
- Dry-run the adapter publish plan:
682
-
683
- ```bash
684
- python finetune/scripts/publish_actor_lora_adapter.py \
685
- --adapter_dir finetune/minicpm5-actor-lora \
686
- --dry_run
687
- ```
688
-
689
- The adapter script uploads only the deployable adapter files by default. Checkpoint directories and optimizer state are skipped unless you explicitly pass `--include_checkpoints`.
690
-
691
- Default target repos are placeholders and may need to change based on org permissions:
692
-
693
- - dataset: `build-small-hackathon/AI-Puppet-Theater-Actor-SFT`
694
- - model: `build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA`
695
-
696
- Actual upload commands, when intentionally publishing:
697
-
698
- ```bash
699
- python finetune/scripts/publish_actor_sft_dataset.py \
700
- --repo_id build-small-hackathon/AI-Puppet-Theater-Actor-SFT \
701
- --no-dry_run
702
-
703
- python finetune/scripts/publish_actor_lora_adapter.py \
704
- --repo_id build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA \
705
- --adapter_dir finetune/minicpm5-actor-lora \
706
- --no-dry_run
707
- ```
708
-
709
- The scripts fail clearly without `HF_TOKEN` or `HUGGINGFACEHUB_API_TOKEN` for real uploads. They upload the prepared cards from:
710
-
711
- - `finetune/dataset_cards/actor_sft_README.md`
712
- - `finetune/model_cards/actor_lora_v0_README.md`
713
-
714
- After publishing, paste the actual dataset/model URLs into the main Space README. The v0 adapter is currently the best candidate; v1 remains a hardening experiment and tooling path unless a later eval shows it outperforming v0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/data/.gitignore DELETED
@@ -1 +0,0 @@
1
- *.jsonl
 
 
finetune/data_samples/actor_eval_prompts.jsonl DELETED
@@ -1,40 +0,0 @@
1
- {"id":"actor-eval-v0-001","source_mix":["synthetic_v0_eval_prompts"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A clockwork whale forgets which ocean is on stage\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Duchess Doodle\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Clockwork Whale Forgets Which\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"opening\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Recovering from a missed entrance with dignity.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"A previous clue pointed stage left.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"hidden\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: React to the premise and move the scene forward in one short line."}]}
2
- {"id":"actor-eval-v0-002","source_mix":["synthetic_v0_eval_prompts"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The sun forgets its entrance and asks the moon for notes\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw tiny ladder onto the stage.\",\"latest_prop\":\"tiny ladder\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Forgets Entrance Asks Moon\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[\"tiny ladder\"],\"mood\":\"grand\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"hidden\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice."}]}
3
- {"id":"actor-eval-v0-003","source_mix":["synthetic_v0_eval_prompts"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Orville Oddsock\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Lola Lampshade\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"lamp\",\"current_goal\":\"Reveal the truth with tasteful illumination.\",\"goal\":\"Reveal the truth with tasteful illumination.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"wistful\",\"name\":\"Lola Lampshade\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"A previous clue pointed stage left.\"],\"secret\":\"Overhears everything said near warm lighting.\",\"secret_status\":\"hidden\",\"speaking_style\":\"glowing, elegant, and dryly observant\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Ask the stage oracle for one playful clue."}]}
4
- {"id":"actor-eval-v0-004","source_mix":["synthetic_v0_eval_prompts"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pirate parrot opens a school for dramatic pauses\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Duchess Doodle\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Bolt McJiggle\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Pirate Parrot Opens School\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"toolbox\",\"current_goal\":\"Turn every problem into a practical stage gag.\",\"goal\":\"Turn every problem into a practical stage gag.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Bolt McJiggle\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\",\"A previous clue pointed stage left.\"],\"secret\":\"Is secretly building a confetti finale backstage.\",\"secret_status\":\"hidden\",\"speaking_style\":\"punchy, practical, and full of suspicious confidence\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Shift the lights to match the emotional turn."}]}
5
- {"id":"actor-eval-v0-005","source_mix":["synthetic_v0_eval_prompts"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Detectives investigate why the castle keeps applauding\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The paper moon pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Lola Lampshade\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Detectives Investigate Castle Keeps\",\"stage_lighting\":\"confetti_rustle\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"lamp\",\"current_goal\":\"Reveal the truth with tasteful illumination.\",\"goal\":\"Reveal the truth with tasteful illumination.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"delighted\",\"name\":\"Lola Lampshade\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Overhears everything said near warm lighting.\",\"secret_status\":\"hidden\",\"speaking_style\":\"glowing, elegant, and dryly observant\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording."}]}
6
- {"id":"actor-eval-v0-006","source_mix":["synthetic_v0_eval_prompts"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The circus cannonball wants a quieter desk job\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"ribbon compass\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Otto Origami\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Circus Cannonball Wants Quieter\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"Someone bowed before the reveal was ready.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"hinted\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Let the secret complicate the current beat."}]}
7
- {"id":"actor-eval-v0-007","source_mix":["synthetic_v0_eval_prompts"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A nervous cloud applies to become a thunderstorm\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Silas Sockdolager\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Juniper Jingle\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Nervous Cloud Applies Become\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"bell\",\"current_goal\":\"Turn awkward pauses into musical cues.\",\"goal\":\"Turn awkward pauses into musical cues.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Juniper Jingle\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Rings whenever someone lies politely.\",\"secret_status\":\"hidden\",\"speaking_style\":\"bright, rhythmic, and suspiciously tuneful\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Signal the curtain clearly and let the ensemble win."}]}
8
- {"id":"actor-eval-v0-008","source_mix":["synthetic_v0_eval_prompts"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Orville Oddsock\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"A trapdoor sighed but did not open.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hidden\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Make a wrong conclusion that gives the next actor something usable."}]}
9
- {"id":"actor-eval-v0-009","source_mix":["synthetic_v0_eval_prompts"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Two umbrellas debate who caused the indoor rainstorm\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Gus Gingham\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Umbrellas Debate Caused Indoor\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"hidden\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: React to the latest clue with one playable stage choice."}]}
10
- {"id":"actor-eval-v0-010","source_mix":["synthetic_v0_eval_prompts"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw ribbon compass onto the stage.\",\"latest_prop\":\"ribbon compass\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Juniper Jingle\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Mina Moonbutton\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"crescent moon\",\"current_goal\":\"Find the emotional truth hiding inside the premise.\",\"goal\":\"Find the emotional truth hiding inside the premise.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[\"ribbon compass\"],\"mood\":\"delighted\",\"name\":\"Mina Moonbutton\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Believes every prop is personally judging her.\",\"secret_status\":\"hidden\",\"speaking_style\":\"earnest, poetic, and prone to dramatic pauses\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Inspect the prop without slowing the show."}]}
11
- {"id":"actor-eval-v0-011","source_mix":["synthetic_v0_eval_prompts"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pancake knight guards a syrup drawbridge at dawn\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Bolt McJiggle\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Tula Teaspoon\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Pancake Knight Guards Syrup\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spoon\",\"current_goal\":\"Measure the exact amount of mystery in each beat.\",\"goal\":\"Measure the exact amount of mystery in each beat.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Tula Teaspoon\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Recognizes the missing spoon from a family portrait.\",\"secret_status\":\"hidden\",\"speaking_style\":\"polite, precise, and quietly dramatic\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Consult the oracle about the next theatrical turn."}]}
12
- {"id":"actor-eval-v0-012","source_mix":["synthetic_v0_eval_prompts"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: An elevator only travels to dramatic reveals\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Petra Popcorn\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Elevator Only Travels Dramatic\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"hidden\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Shift the lights to match the emotional turn."}]}
13
- {"id":"actor-eval-v0-013","source_mix":["synthetic_v0_eval_prompts"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A haunted teacup wants to direct the school musical\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The silver spoon pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Marigold Mumble\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Ivy Inkblot\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Haunted Teacup Wants Direct\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ink\",\"current_goal\":\"Turn every mistake into official evidence.\",\"goal\":\"Turn every mistake into official evidence.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Ivy Inkblot\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"A previous clue pointed stage left.\"],\"secret\":\"Can rewrite labels when nobody watches.\",\"secret_status\":\"hidden\",\"speaking_style\":\"inky, clever, and theatrically legalistic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Recall one earlier clue and connect it to this moment."}]}
14
- {"id":"actor-eval-v0-014","source_mix":["synthetic_v0_eval_prompts"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"origami crown\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Captain Taffeta\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Orville Oddsock\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"sock\",\"current_goal\":\"Find the missing pair in every mystery.\",\"goal\":\"Find the missing pair in every mystery.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"determined\",\"name\":\"Orville Oddsock\",\"recent_memory\":[\"A trapdoor sighed but did not open.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Believes the laundry basket is a prophet.\",\"secret_status\":\"hinted\",\"speaking_style\":\"loopy, sincere, and oddly persuasive\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Let the secret complicate the current beat."}]}
15
- {"id":"actor-eval-v0-015","source_mix":["synthetic_v0_eval_prompts"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hidden\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Tie the scene together in a clean curtain-call line."}]}
16
- {"id":"actor-eval-v0-016","source_mix":["synthetic_v0_eval_prompts"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Silas Sockdolager\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Finch Feltcap\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"hat\",\"current_goal\":\"Keep secrets tucked safely under the brim.\",\"goal\":\"Keep secrets tucked safely under the brim.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Finch Feltcap\",\"recent_memory\":[\"A summoned actor entered carrying yesterday's clue.\",\"A previous clue pointed stage left.\"],\"secret\":\"The brim contains three emergency finales.\",\"secret_status\":\"hidden\",\"speaking_style\":\"dapper, soft-spoken, and evasive\",\"tools\":[\"consult_stage_oracle\",\"inspect_prop\"]}\ndirector_instruction: Make the confusion clear, playful, and safe."}]}
17
- {"id":"actor-eval-v0-017","source_mix":["synthetic_v0_eval_prompts"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A snow globe mayor bans winter until further notice\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Mossy Crank\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Ivy Inkblot\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Snow Globe Mayor Bans\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ink\",\"current_goal\":\"Turn every mistake into official evidence.\",\"goal\":\"Turn every mistake into official evidence.\",\"goal_progress\":\"Recovering from a missed entrance with dignity.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Ivy Inkblot\",\"recent_memory\":[\"Someone bowed before the reveal was ready.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Can rewrite labels when nobody watches.\",\"secret_status\":\"hidden\",\"speaking_style\":\"inky, clever, and theatrically legalistic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: React to the premise and move the scene forward in one short line."}]}
18
- {"id":"actor-eval-v0-018","source_mix":["synthetic_v0_eval_prompts"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A painted door refuses to open without a compliment\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw tomato scroll onto the stage.\",\"latest_prop\":\"tomato scroll\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Painted Door Refuses Open\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[\"tomato scroll\"],\"mood\":\"startled\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hidden\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Handle the prop theatrically and leave one question open."}]}
19
- {"id":"actor-eval-v0-019","source_mix":["synthetic_v0_eval_prompts"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A wizard's laundry basket predicts tomorrow's weather\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Duchess Doodle\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Tula Teaspoon\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Wizard'S Laundry Basket Predicts\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spoon\",\"current_goal\":\"Measure the exact amount of mystery in each beat.\",\"goal\":\"Measure the exact amount of mystery in each beat.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"determined\",\"name\":\"Tula Teaspoon\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Recognizes the missing spoon from a family portrait.\",\"secret_status\":\"hidden\",\"speaking_style\":\"polite, precise, and quietly dramatic\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Use the oracle to connect the premise and latest confusion."}]}
20
- {"id":"actor-eval-v0-020","source_mix":["synthetic_v0_eval_prompts"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A library book refuses to return from vacation\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Zelda Zipper\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Gus Gingham\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Library Book Refuses Return\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"tablecloth\",\"current_goal\":\"Keep everyone civil while the table collapses.\",\"goal\":\"Keep everyone civil while the table collapses.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Gus Gingham\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Was once mistaken for the royal flag.\",\"secret_status\":\"hidden\",\"speaking_style\":\"homespun, patient, and quietly ridiculous\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Shift the lights to match the emotional turn."}]}
21
- {"id":"actor-eval-v0-021","source_mix":["synthetic_v0_eval_prompts"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A clockwork whale forgets which ocean is on stage\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The glitter crown pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Duchess Doodle\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Clockwork Whale Forgets Which\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"paintbrush\",\"current_goal\":\"Make the backdrop agree with her version of events.\",\"goal\":\"Make the backdrop agree with her version of events.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Duchess Doodle\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Painted herself into last week's mystery.\",\"secret_status\":\"hidden\",\"speaking_style\":\"ornate, colorful, and lightly bossy\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording."}]}
22
- {"id":"actor-eval-v0-022","source_mix":["synthetic_v0_eval_prompts"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The bakery's rolling pin claims it solved the mystery\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"glass button\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Pip the Director\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Bakery'S Rolling Claims Solved\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"wistful\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hinted\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Give the audience a secret-shaped clue, not a long confession."}]}
23
- {"id":"actor-eval-v0-023","source_mix":["synthetic_v0_eval_prompts"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Pip the Director\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"mischievous\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hidden\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Give the scene a satisfying button without adding a new problem."}]}
24
- {"id":"actor-eval-v0-024","source_mix":["synthetic_v0_eval_prompts"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pirate parrot opens a school for dramatic pauses\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Otto Origami\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Pirate Parrot Opens School\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Recovering from a missed entrance with dignity.\",\"held_props\":[],\"mood\":\"nervous\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hidden\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Escalate confusion briefly, then leave room for the next actor."}]}
25
- {"id":"actor-eval-v0-025","source_mix":["synthetic_v0_eval_prompts"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: An elevator only travels to dramatic reveals\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Silas Sockdolager\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Ivy Inkblot\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Elevator Only Travels Dramatic\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ink\",\"current_goal\":\"Turn every mistake into official evidence.\",\"goal\":\"Turn every mistake into official evidence.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Ivy Inkblot\",\"recent_memory\":[\"The curtains whispered that the finale was hiding nearby.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Can rewrite labels when nobody watches.\",\"secret_status\":\"hidden\",\"speaking_style\":\"inky, clever, and theatrically legalistic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Answer the previous beat with theatrical confidence."}]}
26
- {"id":"actor-eval-v0-026","source_mix":["synthetic_v0_eval_prompts"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A detective mailbox investigates missing love letters\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw clockwork seashell onto the stage.\",\"latest_prop\":\"clockwork seashell\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Tula Teaspoon\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Detective Mailbox Investigates Missing\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spoon\",\"current_goal\":\"Measure the exact amount of mystery in each beat.\",\"goal\":\"Measure the exact amount of mystery in each beat.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[\"clockwork seashell\"],\"mood\":\"bold\",\"name\":\"Tula Teaspoon\",\"recent_memory\":[\"The orchestra coughed during the important pause.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Recognizes the missing spoon from a family portrait.\",\"secret_status\":\"hidden\",\"speaking_style\":\"polite, precise, and quietly dramatic\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Use the latest prop as evidence and request inspection if useful."}]}
27
- {"id":"actor-eval-v0-027","source_mix":["synthetic_v0_eval_prompts"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Gus Gingham\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Pip the Director\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hidden\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Use an oracle question to sharpen the scene's mystery."}]}
28
- {"id":"actor-eval-v0-028","source_mix":["synthetic_v0_eval_prompts"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Two umbrellas debate who caused the indoor rainstorm\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Umbrellas Debate Caused Indoor\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Recovering from a missed entrance with dignity.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The orchestra coughed during the important pause.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"hidden\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Change the lighting to make the actor's intention visible."}]}
29
- {"id":"actor-eval-v0-029","source_mix":["synthetic_v0_eval_prompts"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The tomato scroll pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Otto Origami\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"paper crane\",\"current_goal\":\"Fold messy clues into surprising shapes.\",\"goal\":\"Fold messy clues into surprising shapes.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"mischievous\",\"name\":\"Otto Origami\",\"recent_memory\":[\"The orchestra coughed during the important pause.\",\"Someone bowed before the reveal was ready.\"],\"secret\":\"Was once a very important ransom note.\",\"secret_status\":\"hidden\",\"speaking_style\":\"delicate, precise, and gently mysterious\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording."}]}
30
- {"id":"actor-eval-v0-030","source_mix":["synthetic_v0_eval_prompts"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"silver spoon\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Duchess Doodle\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"paintbrush\",\"current_goal\":\"Make the backdrop agree with her version of events.\",\"goal\":\"Make the backdrop agree with her version of events.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Duchess Doodle\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"A shadow crossed the stage wearing tap shoes.\"],\"secret\":\"Painted herself into last week's mystery.\",\"secret_status\":\"hinted\",\"speaking_style\":\"ornate, colorful, and lightly bossy\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Make the secret theatrical, safe, and easy to perform."}]}
31
- {"id":"actor-eval-v0-031","source_mix":["synthetic_v0_eval_prompts"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The lighthouse insists it saw a submarine wearing a hat\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Lola Lampshade\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Finch Feltcap\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Lighthouse Insists Submarine Wearing\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"hat\",\"current_goal\":\"Keep secrets tucked safely under the brim.\",\"goal\":\"Keep secrets tucked safely under the brim.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"nervous\",\"name\":\"Finch Feltcap\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\",\"A previous clue pointed stage left.\"],\"secret\":\"The brim contains three emergency finales.\",\"secret_status\":\"hidden\",\"speaking_style\":\"dapper, soft-spoken, and evasive\",\"tools\":[\"consult_stage_oracle\",\"inspect_prop\"]}\ndirector_instruction: Tie the scene together in a clean curtain-call line."}]}
32
- {"id":"actor-eval-v0-032","source_mix":["synthetic_v0_eval_prompts"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A blanket fort declares independence from bedtime\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Velvet Crumb\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Blanket Fort Declares Independence\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Tie every contradiction into a decorative bow.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hidden\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Confuse one clue with another, then recover enough to continue."}]}
33
- {"id":"actor-eval-v0-033","source_mix":["synthetic_v0_eval_prompts"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The royal gardener grows clues instead of roses\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Bolt McJiggle\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Royal Gardener Grows Clues\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"delighted\",\"name\":\"Pip the Director\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The audience threw a prop during the contradiction.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hidden\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Name what changed on stage and invite the next beat."}]}
34
- {"id":"actor-eval-v0-034","source_mix":["synthetic_v0_eval_prompts"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A lantern insists the darkness stole its punchline\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw wind-up key onto the stage.\",\"latest_prop\":\"wind-up key\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Ivy Inkblot\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Lantern Insists Darkness Stole\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[\"wind-up key\"],\"mood\":\"mischievous\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"The curtains whispered that the finale was hiding nearby.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hidden\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Treat the prop like a clue that changes the scene."}]}
35
- {"id":"actor-eval-v0-035","source_mix":["synthetic_v0_eval_prompts"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A detective mailbox investigates missing love letters\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Ruby Ruckus\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Tula Teaspoon\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Detective Mailbox Investigates Missing\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spoon\",\"current_goal\":\"Measure the exact amount of mystery in each beat.\",\"goal\":\"Measure the exact amount of mystery in each beat.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"mischievous\",\"name\":\"Tula Teaspoon\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Recognizes the missing spoon from a family portrait.\",\"secret_status\":\"hidden\",\"speaking_style\":\"polite, precise, and quietly dramatic\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Ask the oracle something specific enough to guide the next beat."}]}
36
- {"id":"actor-eval-v0-036","source_mix":["synthetic_v0_eval_prompts"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A scarecrow runs a midnight advice booth for crows\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Velvet Crumb\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Scarecrow Runs Midnight Advice\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Tie every contradiction into a decorative bow.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"The audience threw a prop during the contradiction.\",\"Someone bowed before the reveal was ready.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hidden\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Change the lighting to make the actor's intention visible."}]}
37
- {"id":"actor-eval-v0-037","source_mix":["synthetic_v0_eval_prompts"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The lighthouse insists it saw a submarine wearing a hat\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The felt mustache pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Captain Taffeta\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Lighthouse Insists Submarine Wearing\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"sailboat\",\"current_goal\":\"Steer every scene through theatrical weather.\",\"goal\":\"Steer every scene through theatrical weather.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Captain Taffeta\",\"recent_memory\":[\"A previous clue pointed stage left.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Cannot tell port from stage left.\",\"secret_status\":\"hidden\",\"speaking_style\":\"booming, nautical, and cheerfully mistaken\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Use one remembered stage effect as evidence."}]}
38
- {"id":"actor-eval-v0-038","source_mix":["synthetic_v0_eval_prompts"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A snow globe mayor bans winter until further notice\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"paper fan\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Snow Globe Mayor Bans\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hinted\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Reveal only what the scene can use immediately."}]}
39
- {"id":"actor-eval-v0-039","source_mix":["synthetic_v0_eval_prompts"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A paper airplane delivers invitations to the wrong century\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Mina Moonbutton\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Ivy Inkblot\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Paper Airplane Delivers Invitations\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ink\",\"current_goal\":\"Turn every mistake into official evidence.\",\"goal\":\"Turn every mistake into official evidence.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Ivy Inkblot\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Can rewrite labels when nobody watches.\",\"secret_status\":\"hidden\",\"speaking_style\":\"inky, clever, and theatrically legalistic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Give the scene a satisfying button without adding a new problem."}]}
40
- {"id":"actor-eval-v0-040","source_mix":["synthetic_v0_eval_prompts"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A shy volcano hosts a talent show for umbrellas\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Silas Sockdolager\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Volcano Hosts Talent Show\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"megaphone\",\"current_goal\":\"Deliver the biggest line in the smallest voice.\",\"goal\":\"Deliver the biggest line in the smallest voice.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"flustered\",\"name\":\"Silas Sockdolager\",\"recent_memory\":[\"The curtains whispered that the finale was hiding nearby.\",\"A shadow crossed the stage wearing tap shoes.\"],\"secret\":\"Lost his indoor voice under the orchestra pit.\",\"secret_status\":\"hidden\",\"speaking_style\":\"grand, booming, and suddenly tiny\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Let the misunderstanding create motion without breaking the scene."}]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/data_samples/actor_sft_v0_sample.jsonl DELETED
@@ -1,32 +0,0 @@
1
- {"id":"actor-sft-v0-000913","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A soup ladle discovers a secret passage under dinner\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Gus Gingham\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Juniper Jingle\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Soup Ladle Discovers Secret\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"bell\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Turn awkward pauses into musical cues.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Juniper Jingle\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Rings whenever someone lies politely.\",\"secret_status\":\"hidden\",\"speaking_style\":\"bright, rhythmic, and suspiciously tuneful\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Make the next problem clear without solving it yet."},{"role":"assistant","content":"{\"intent\":\"clarify_problem\",\"line\":\"This tiny soup problem just became legally theatrical.\",\"emotion\":\"delighted\",\"gesture\":\"taps chin with one tiny puppet hand\",\"stage_effect\":\"warm_spotlight\",\"memory_update\":null,\"tool_request\":null}"}]}
2
- {"id":"actor-sft-v0-000434","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A wizard's laundry basket predicts tomorrow's weather\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw origami crown onto the stage.\",\"latest_prop\":\"origami crown\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Zelda Zipper\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Wizard'S Laundry Basket Predicts\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[\"origami crown\"],\"mood\":\"delighted\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hidden\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"I inspect this origami crown and find theatrical fingerprints everywhere.\",\"emotion\":\"triumphant\",\"gesture\":\"leans toward the glowing prop\",\"stage_effect\":\"prop_table_glow\",\"memory_update\":\"Noted that the origami crown behaved like evidence.\",\"tool_request\":null}"}]}
3
- {"id":"actor-sft-v0-000179","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The royal gardener grows clues instead of roses\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Captain Taffeta\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Royal Gardener Grows Clues\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"complication\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"sailboat\",\"current_goal\":\"Steer every scene through theatrical weather.\",\"goal\":\"Steer every scene through theatrical weather.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"determined\",\"name\":\"Captain Taffeta\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Cannot tell port from stage left.\",\"secret_status\":\"hidden\",\"speaking_style\":\"booming, nautical, and cheerfully mistaken\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Consult the oracle about the next theatrical turn."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, point our tiny shoes toward the useful mystery.\",\"emotion\":\"hopeful\",\"gesture\":\"peers upward as the oracle haze gathers\",\"stage_effect\":\"blue_spotlight\",\"memory_update\":\"Kept the oracle clue visible for the scene.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"Which clue wants the spotlight?\"},\"reason\":\"The oracle can sharpen the next beat.\"}}"}]}
4
- {"id":"actor-sft-v0-000988","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A nervous toaster auditions for a royal banquet\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Rafi Ribbon\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Nervous Toaster Auditions Royal\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"chaos\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"hidden\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Use a lighting cue as stage business, not decoration."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Cue golden lights; this nervous accusation deserves sparkle.\",\"emotion\":\"commanding\",\"gesture\":\"shades eyes as the lamps change\",\"stage_effect\":\"moonlit_lighting\",\"memory_update\":\"Saved the new lighting cue for the next actor.\",\"tool_request\":null}"}]}
5
- {"id":"actor-sft-v0-001389","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A shoelace detective solves crimes nobody can tie together\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"The accordion suitcase pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Pip the Director\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Shoelace Detective Solves Crimes\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"reveal\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hinted\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"I recall that glow; it followed the guilty pause.\",\"emotion\":\"remembering\",\"gesture\":\"points stage left with sudden recognition\",\"stage_effect\":\"memory_echo\",\"memory_update\":\"Brought back the earlier curtain whisper.\",\"tool_request\":null}"}]}
6
- {"id":"actor-sft-v0-000566","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The sun forgets its entrance and asks the moon for notes\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"rubber duck\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Orville Oddsock\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Forgets Entrance Asks Moon\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"delighted\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"hinted\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Give the audience a secret-shaped clue, not a long confession."},{"role":"assistant","content":"{\"intent\":\"hint_secret\",\"line\":\"My secret squeaks louder whenever the rubber duck gets nervous.\",\"emotion\":\"relieved\",\"gesture\":\"leans close to the front row\",\"stage_effect\":\"single_spotlight\",\"memory_update\":\"Marked the secret as publicly useful.\",\"tool_request\":null}"}]}
7
- {"id":"actor-sft-v0-001175","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A soup ladle discovers a secret passage under dinner\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Mossy Crank\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Captain Taffeta\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Soup Ladle Discovers Secret\",\"stage_lighting\":\"confetti_rustle\",\"story_phase\":\"finale\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"sailboat\",\"current_goal\":\"Steer every scene through theatrical weather.\",\"goal\":\"Steer every scene through theatrical weather.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"mischievous\",\"name\":\"Captain Taffeta\",\"recent_memory\":[\"A previous clue pointed stage left.\",\"Someone bowed before the reveal was ready.\"],\"secret\":\"Cannot tell port from stage left.\",\"secret_status\":\"revealed\",\"speaking_style\":\"booming, nautical, and cheerfully mistaken\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: End with a short button and a bow."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"We solved the soup wobble and saved the spotlight.\",\"emotion\":\"proud\",\"gesture\":\"sweeps a tiny hat toward the audience\",\"stage_effect\":\"curtain_fall\",\"memory_update\":\"Scene resolved with a clean bow.\",\"tool_request\":null}"}]}
8
- {"id":"actor-sft-v0-001120","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Velvet Crumb\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Mossy Crank\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"gear\",\"current_goal\":\"Fix the scene using questionable machinery.\",\"goal\":\"Fix the scene using questionable machinery.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Mossy Crank\",\"recent_memory\":[\"The audience threw a prop during the contradiction.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Built a trapdoor that only opens emotionally.\",\"secret_status\":\"hinted\",\"speaking_style\":\"grumbly, mechanical, and secretly tender\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Confuse one clue with another, then recover enough to continue."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"I thought the cardboard clue was a hat, but it was Tuesday.\",\"emotion\":\"panicked\",\"gesture\":\"spins once and faces the wrong flat\",\"stage_effect\":\"squeaky_floor\",\"memory_update\":\"Logged the mistake as playable chaos.\",\"tool_request\":null}"}]}
9
- {"id":"actor-sft-v0-001297","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A detective mailbox investigates missing love letters\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Detective Mailbox Investigates Missing\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"nervous\",\"name\":\"Pip the Director\",\"recent_memory\":[\"Someone bowed before the reveal was ready.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hinted\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Make the next problem clear without solving it yet."},{"role":"assistant","content":"{\"intent\":\"react_to_event\",\"line\":\"This tiny detective problem just became legally theatrical.\",\"emotion\":\"curious\",\"gesture\":\"tilts head under the warm spotlight\",\"stage_effect\":\"painted_flat_wobble\",\"memory_update\":null,\"tool_request\":null}"}]}
10
- {"id":"actor-sft-v0-001106","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Two umbrellas debate who caused the indoor rainstorm\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw velvet potato onto the stage.\",\"latest_prop\":\"velvet potato\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Nora Needlewhistle\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Umbrellas Debate Caused Indoor\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[\"velvet potato\"],\"mood\":\"suspicious\",\"name\":\"Pip the Director\",\"recent_memory\":[\"A trapdoor sighed but did not open.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hidden\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"The velvet potato points stage left, which feels legally important.\",\"emotion\":\"startled\",\"gesture\":\"presents the clue on both tiny palms\",\"stage_effect\":\"tiny_chime\",\"memory_update\":\"Noted that the velvet potato behaved like evidence.\",\"tool_request\":null}"}]}
11
- {"id":"actor-sft-v0-000107","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Two umbrellas debate who caused the indoor rainstorm\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Silas Sockdolager\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Umbrellas Debate Caused Indoor\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"complication\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"megaphone\",\"current_goal\":\"Deliver the biggest line in the smallest voice.\",\"goal\":\"Deliver the biggest line in the smallest voice.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Silas Sockdolager\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Lost his indoor voice under the orchestra pit.\",\"secret_status\":\"hidden\",\"speaking_style\":\"grand, booming, and suddenly tiny\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Invite a mysterious clue without making the scene too serious."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"I request one clue, preferably with dramatic lighting.\",\"emotion\":\"mystified\",\"gesture\":\"raises both hands toward the stage rafters\",\"stage_effect\":\"blue_spotlight\",\"memory_update\":\"Kept the oracle clue visible for the scene.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"Which clue wants the spotlight?\"},\"reason\":\"The oracle can sharpen the next beat.\"}}"}]}
12
- {"id":"actor-sft-v0-000596","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Captain Taffeta\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Benny Breadcrumb\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"bread\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Leave a trail of clues nobody can ignore.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Benny Breadcrumb\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Ate the map corner during rehearsal.\",\"secret_status\":\"hidden\",\"speaking_style\":\"crumbly, earnest, and accidentally useful\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Use a lighting cue as stage business, not decoration."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Cue golden lights; this cardboard accusation deserves sparkle.\",\"emotion\":\"dramatic\",\"gesture\":\"points to the spotlight with grand certainty\",\"stage_effect\":\"golden_lighting\",\"memory_update\":\"Remembered the lighting shift as an emotional cue.\",\"tool_request\":null}"}]}
13
- {"id":"actor-sft-v0-001309","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A train conductor schedules a parade inside a suitcase\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"The tin trumpet pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Benny Breadcrumb\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Train Conductor Schedules Parade\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"reveal\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"bread\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Leave a trail of clues nobody can ignore.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Benny Breadcrumb\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\"],\"secret\":\"Ate the map corner during rehearsal.\",\"secret_status\":\"hinted\",\"speaking_style\":\"crumbly, earnest, and accidentally useful\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Connect an earlier audience action to the current clue."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"The trapdoor sighed before, and now the tin trumpet answers.\",\"emotion\":\"wistful\",\"gesture\":\"taps forehead with a tiny puppet finger\",\"stage_effect\":\"soft_reprise\",\"memory_update\":\"Recalled the earlier stage-left clue.\",\"tool_request\":null}"}]}
14
- {"id":"actor-sft-v0-000990","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The lighthouse insists it saw a submarine wearing a hat\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"porcelain whistle\",\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Benny Breadcrumb\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Lighthouse Insists Submarine Wearing\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"reveal\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"delighted\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hinted\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Give the audience a secret-shaped clue, not a long confession."},{"role":"assistant","content":"{\"intent\":\"hint_secret\",\"line\":\"Fine, my lighthouse secret rehearsed with the missing clue.\",\"emotion\":\"relieved\",\"gesture\":\"covers mouth with one felt hand\",\"stage_effect\":\"secret_chime\",\"memory_update\":\"Secret moved from hint to reveal.\",\"tool_request\":null}"}]}
15
- {"id":"actor-sft-v0-000807","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A lantern insists the darkness stole its punchline\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Bolt McJiggle\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Lantern Insists Darkness Stole\",\"stage_lighting\":\"confetti_rustle\",\"story_phase\":\"finale\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"A trapdoor sighed but did not open.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"resolved\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: End with a short button and a bow."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"The lantern clue is home, and we bow.\",\"emotion\":\"resolved\",\"gesture\":\"joins hands with the nearest puppet\",\"stage_effect\":\"final_bow_lights\",\"memory_update\":\"Scene resolved with a clean bow.\",\"tool_request\":null}"}]}
16
- {"id":"actor-sft-v0-000016","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A library book refuses to return from vacation\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Juniper Jingle\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Mina Moonbutton\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Library Book Refuses Return\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"crescent moon\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Find the emotional truth hiding inside the premise.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Mina Moonbutton\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Believes every prop is personally judging her.\",\"secret_status\":\"hinted\",\"speaking_style\":\"earnest, poetic, and prone to dramatic pauses\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Make a wrong conclusion that gives the next actor something usable."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"I thought the library clue was a hat, but it was Tuesday.\",\"emotion\":\"flustered\",\"gesture\":\"looks under a tiny hat for answers\",\"stage_effect\":\"squeaky_floor\",\"memory_update\":\"Logged the mistake as playable chaos.\",\"tool_request\":null}"}]}
17
- {"id":"actor-sft-v0-001145","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pirate parrot opens a school for dramatic pauses\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Marigold Mumble\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Pirate Parrot Opens School\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"opening\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Make the next problem clear without solving it yet."},{"role":"assistant","content":"{\"intent\":\"clarify_problem\",\"line\":\"Everyone stay dramatic; the pirate clue is taking attendance.\",\"emotion\":\"delighted\",\"gesture\":\"taps chin with one tiny puppet hand\",\"stage_effect\":\"soft_drumroll\",\"memory_update\":\"Noted the first clear suspicion.\",\"tool_request\":null}"}]}
18
- {"id":"actor-sft-v0-001098","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw accordion suitcase onto the stage.\",\"latest_prop\":\"accordion suitcase\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Gus Gingham\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Bolt McJiggle\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"toolbox\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Turn every problem into a practical stage gag.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[\"accordion suitcase\"],\"mood\":\"suspicious\",\"name\":\"Bolt McJiggle\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Is secretly building a confetti finale backstage.\",\"secret_status\":\"hinted\",\"speaking_style\":\"punchy, practical, and full of suspicious confidence\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Turn the prop into a concrete clue and keep the line short."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"Hold still, accordion suitcase; your glitter is confessing under pressure.\",\"emotion\":\"startled\",\"gesture\":\"leans toward the glowing prop\",\"stage_effect\":\"magnifier_sparkle\",\"memory_update\":\"Saved the prop clue for the Director.\",\"tool_request\":{\"tool\":\"inspect_prop\",\"args\":{\"prop\":\"accordion suitcase\"},\"reason\":\"The prop may reveal a stage clue.\"}}"}]}
19
- {"id":"actor-sft-v0-000819","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pancake knight guards a syrup drawbridge at dawn\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Ruby Ruckus\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Juniper Jingle\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Pancake Knight Guards Syrup\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"bell\",\"current_goal\":\"Turn awkward pauses into musical cues.\",\"goal\":\"Turn awkward pauses into musical cues.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Juniper Jingle\",\"recent_memory\":[\"A previous clue pointed stage left.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Rings whenever someone lies politely.\",\"secret_status\":\"hinted\",\"speaking_style\":\"bright, rhythmic, and suspiciously tuneful\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Use the oracle to connect the premise and latest confusion."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, tell us which bow is hiding the truth.\",\"emotion\":\"hopeful\",\"gesture\":\"listens closely to the whispering curtains\",\"stage_effect\":\"oracle_haze\",\"memory_update\":\"Noted that the oracle pointed toward the spotlight.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"What should we notice next?\"},\"reason\":\"The oracle can sharpen the next beat.\"}}"}]}
20
- {"id":"actor-sft-v0-001164","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Otto Origami\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Mina Moonbutton\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"chaos\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"crescent moon\",\"current_goal\":\"Find the emotional truth hiding inside the premise.\",\"goal\":\"Find the emotional truth hiding inside the premise.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Mina Moonbutton\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Believes every prop is personally judging her.\",\"secret_status\":\"hidden\",\"speaking_style\":\"earnest, poetic, and prone to dramatic pauses\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Cue lights dramatically while keeping the line speakable."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Dim the corners; puppet secrets dislike excellent visibility.\",\"emotion\":\"commanding\",\"gesture\":\"shades eyes as the lamps change\",\"stage_effect\":\"golden_lighting\",\"memory_update\":\"Saved the new lighting cue for the next actor.\",\"tool_request\":{\"tool\":\"change_lighting\",\"args\":{\"mood\":\"warm suspicion\"},\"reason\":\"Lighting should clarify the emotional turn.\"}}"}]}
21
- {"id":"actor-sft-v0-000301","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A shy volcano hosts a talent show for umbrellas\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[{\"result\":\"The glitter crown pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Benny Breadcrumb\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Volcano Hosts Talent Show\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Tie every contradiction into a decorative bow.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hidden\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"The same squeak appeared when the backdrop changed color.\",\"emotion\":\"wistful\",\"gesture\":\"taps forehead with a tiny puppet finger\",\"stage_effect\":\"stage_left_glimmer\",\"memory_update\":\"Linked the old glow to the current suspicion.\",\"tool_request\":null}"}]}
22
- {"id":"actor-sft-v0-000102","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A robot gardener teaches flowers to bow on cue\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"origami crown\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Captain Taffeta\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Robot Gardener Teaches Flowers\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"sailboat\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Steer every scene through theatrical weather.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Captain Taffeta\",\"recent_memory\":[\"The audience threw a prop during the contradiction.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Cannot tell port from stage left.\",\"secret_status\":\"revealed\",\"speaking_style\":\"booming, nautical, and cheerfully mistaken\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Reveal a playful secret and keep the scene moving."},{"role":"assistant","content":"{\"intent\":\"reveal_secret\",\"line\":\"My secret squeaks louder whenever the origami crown gets nervous.\",\"emotion\":\"relieved\",\"gesture\":\"leans close to the front row\",\"stage_effect\":\"curtain_rustle\",\"memory_update\":\"Saved the reveal as a new complication.\",\"tool_request\":null}"}]}
23
- {"id":"actor-sft-v0-000223","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A shy volcano hosts a talent show for umbrellas\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Orville Oddsock\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Petra Popcorn\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Volcano Hosts Talent Show\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"popcorn\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Make the audience reaction part of the plot.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Petra Popcorn\",\"recent_memory\":[\"The orchestra coughed during the important pause.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Can predict heckles three kernels early.\",\"secret_status\":\"resolved\",\"speaking_style\":\"poppy, fast, and delighted by chaos\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Signal the curtain clearly and let the ensemble win."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"Final bow, tiny friends; the volcano mystery exits smiling.\",\"emotion\":\"joyful\",\"gesture\":\"joins hands with the nearest puppet\",\"stage_effect\":\"confetti_rustle\",\"memory_update\":\"Scene resolved with a clean bow.\",\"tool_request\":null}"}]}
24
- {"id":"actor-sft-v0-000400","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Ivy Inkblot\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"A summoned actor entered carrying yesterday's clue.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hidden\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Make a wrong conclusion that gives the next actor something usable."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"I object, unless that polite noise was my cue.\",\"emotion\":\"goofy\",\"gesture\":\"drops an imaginary cue card\",\"stage_effect\":\"hat_tumble\",\"memory_update\":\"Confusion briefly raised the stakes.\",\"tool_request\":null}"}]}
25
- {"id":"actor-sft-v0-000337","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The lighthouse insists it saw a submarine wearing a hat\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Captain Taffeta\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Lighthouse Insists Submarine Wearing\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[],\"mood\":\"delighted\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"hidden\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Name what changed on stage and invite the next beat."},{"role":"assistant","content":"{\"intent\":\"react_to_event\",\"line\":\"The lighthouse backdrop leaned closer, and I respect its commitment.\",\"emotion\":\"suspicious\",\"gesture\":\"steps toward the painted backdrop\",\"stage_effect\":\"soft_drumroll\",\"memory_update\":\"Noted the first clear suspicion.\",\"tool_request\":null}"}]}
26
- {"id":"actor-sft-v0-001170","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw candle stub onto the stage.\",\"latest_prop\":\"candle stub\",\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Juniper Jingle\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Gus Gingham\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"tablecloth\",\"current_goal\":\"Keep everyone civil while the table collapses.\",\"goal\":\"Keep everyone civil while the table collapses.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[\"candle stub\"],\"mood\":\"hopeful\",\"name\":\"Gus Gingham\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Was once mistaken for the royal flag.\",\"secret_status\":\"hinted\",\"speaking_style\":\"homespun, patient, and quietly ridiculous\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"Behold, the candle stub is sweating under the footlights.\",\"emotion\":\"triumphant\",\"gesture\":\"holds the prop up to the footlights\",\"stage_effect\":\"tiny_chime\",\"memory_update\":\"Noted that the candle stub behaved like evidence.\",\"tool_request\":null}"}]}
27
- {"id":"actor-sft-v0-001211","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: Detectives investigate why the castle keeps applauding\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Lola Lampshade\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Detectives Investigate Castle Keeps\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"complication\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"lamp\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Reveal the truth with tasteful illumination.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"nervous\",\"name\":\"Lola Lampshade\",\"recent_memory\":[\"A summoned actor entered carrying yesterday's clue.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Overhears everything said near warm lighting.\",\"secret_status\":\"hidden\",\"speaking_style\":\"glowing, elegant, and dryly observant\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Ask the oracle something specific enough to guide the next beat."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, tell us which bow is hiding the truth.\",\"emotion\":\"hopeful\",\"gesture\":\"listens closely to the whispering curtains\",\"stage_effect\":\"oracle_haze\",\"memory_update\":\"Remembered the oracle's clue for the next beat.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"What should we notice next?\"},\"reason\":\"The oracle can sharpen the next beat.\"}}"}]}
28
- {"id":"actor-sft-v0-000612","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The town orchestra loses its conductor inside a trumpet\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Ivy Inkblot\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Finch Feltcap\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Town Orchestra Loses Conductor\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"chaos\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"hat\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Keep secrets tucked safely under the brim.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"flustered\",\"name\":\"Finch Feltcap\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"The brim contains three emergency finales.\",\"secret_status\":\"hidden\",\"speaking_style\":\"dapper, soft-spoken, and evasive\",\"tools\":[\"consult_stage_oracle\",\"inspect_prop\"]}\ndirector_instruction: Cue lights dramatically while keeping the line speakable."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Lights to moonlit town; my eyebrows need proper shadows.\",\"emotion\":\"dramatic\",\"gesture\":\"shades eyes as the lamps change\",\"stage_effect\":\"moonlit_lighting\",\"memory_update\":\"Saved the new lighting cue for the next actor.\",\"tool_request\":null}"}]}
29
- {"id":"actor-sft-v0-000597","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The village well starts returning everyone else's wishes\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"The toy hourglass pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Village Well Starts Returning\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Bring back a prior stage detail in one clear line."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"That old clue returns wearing suspiciously fresh tap shoes.\",\"emotion\":\"alert\",\"gesture\":\"clutches chest as the old clue returns\",\"stage_effect\":\"soft_reprise\",\"memory_update\":\"Recalled the earlier stage-left clue.\",\"tool_request\":null}"}]}
30
- {"id":"actor-sft-v0-000246","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The lighthouse insists it saw a submarine wearing a hat\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"tin trumpet\",\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Gus Gingham\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Lighthouse Insists Submarine Wearing\",\"stage_lighting\":\"final_bow_lights\",\"story_phase\":\"reveal\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"determined\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"revealed\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Reveal a playful secret and keep the scene moving."},{"role":"assistant","content":"{\"intent\":\"reveal_secret\",\"line\":\"The tin trumpet knows me, and that is inconvenient.\",\"emotion\":\"nervous\",\"gesture\":\"covers mouth with one felt hand\",\"stage_effect\":\"curtain_rustle\",\"memory_update\":\"Saved the reveal as a new complication.\",\"tool_request\":null}"}]}
31
- {"id":"actor-sft-v0-000447","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A paper airplane delivers invitations to the wrong century\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Lola Lampshade\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Benny Breadcrumb\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Paper Airplane Delivers Invitations\",\"stage_lighting\":\"confetti_rustle\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"bread\",\"current_goal\":\"Use the audience interruption without losing pacing.\",\"goal\":\"Leave a trail of clues nobody can ignore.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Benny Breadcrumb\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Ate the map corner during rehearsal.\",\"secret_status\":\"resolved\",\"speaking_style\":\"crumbly, earnest, and accidentally useful\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: End with a short button and a bow."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"We found the paper truth and bow together.\",\"emotion\":\"grand\",\"gesture\":\"holds a proud curtain-call pose\",\"stage_effect\":\"curtain_fall\",\"memory_update\":\"Scene resolved with a clean bow.\",\"tool_request\":null}"}]}
32
- {"id":"actor-sft-v0-000440","source_mix":["synthetic_v0","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The bakery's rolling pin claims it solved the mystery\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":9,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Pip the Director\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Zelda Zipper\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Bakery'S Rolling Claims Solved\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"chaos\",\"target_beats\":7}\nactor JSON: {\"avatar\":\"zipper\",\"current_goal\":\"Open hidden compartments in the plot.\",\"goal\":\"Open hidden compartments in the plot.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"flustered\",\"name\":\"Zelda Zipper\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Knows which pocket holds the missing clue.\",\"secret_status\":\"hidden\",\"speaking_style\":\"quick, clipped, and conspiratorial\",\"tools\":[\"inspect_prop\"]}\ndirector_instruction: Escalate confusion briefly, then leave room for the next actor."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"The spotlight blinked twice, so I blamed the bakery's.\",\"emotion\":\"goofy\",\"gesture\":\"freezes midstep with baffled dignity\",\"stage_effect\":\"quick_blackout\",\"memory_update\":\"Confusion briefly raised the stakes.\",\"tool_request\":null}"}]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/data_samples/actor_sft_v1_sample.jsonl DELETED
@@ -1,32 +0,0 @@
1
- {"id":"actor-sft-v1-001692","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The circus cannonball wants a quieter desk job\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Zelda Zipper\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Circus Cannonball Wants Quieter\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"complication\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"determined\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Make the next problem clear without solving it yet. Move opening, complication, or chaos forward without ending the show."},{"role":"assistant","content":"{\"intent\":\"react_to_event\",\"line\":\"The circus problem just winked, so we investigate politely.\",\"emotion\":\"determined\",\"gesture\":\"points both felt hands at the curtain\",\"stage_effect\":\"stage_left_glimmer\",\"memory_update\":\"Saved the current stage problem.\",\"tool_request\":null}"}]}
2
- {"id":"actor-sft-v1-000438","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The village well starts returning everyone else's wishes\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw painted teacup onto the stage.\",\"latest_prop\":\"painted teacup\",\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Village Well Starts Returning\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"complication\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[\"painted teacup\"],\"mood\":\"determined\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hinted\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\",\"inspect_prop\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice. Keep the prop call strict: tool, args.prop, and reason only."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"The painted teacup has crumbs of motive all over it.\",\"emotion\":\"triumphant\",\"gesture\":\"squints at the prop with stitched suspicion\",\"stage_effect\":\"tiny_chime\",\"memory_update\":\"Logged the painted teacup as useful evidence.\",\"tool_request\":{\"tool\":\"inspect_prop\",\"args\":{\"prop\":\"painted teacup\"},\"reason\":\"The Director needs one clean prop detail.\"}}"}]}
3
- {"id":"actor-sft-v1-000898","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A nervous toaster auditions for a royal banquet\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Otto Origami\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Ivy Inkblot\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Nervous Toaster Auditions Royal\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"ink\",\"current_goal\":\"Turn every mistake into official evidence.\",\"goal\":\"Turn every mistake into official evidence.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"curious\",\"name\":\"Ivy Inkblot\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Can rewrite labels when nobody watches.\",\"secret_status\":\"hidden\",\"speaking_style\":\"inky, clever, and theatrically legalistic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Ask the stage oracle for one playful clue. Ask one question through consult_stage_oracle and stop after the JSON object."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, which nervous clue deserves the next spotlight?\",\"emotion\":\"awed\",\"gesture\":\"raises both hands toward the stage rafters\",\"stage_effect\":\"curtain_whisper\",\"memory_update\":\"Kept the oracle clue visible for the scene.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"What should change before the next beat?\"},\"reason\":\"The next beat needs a single stage-safe hint.\"}}"}]}
4
- {"id":"actor-sft-v1-000904","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Pip the Director\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Mossy Crank\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"gear\",\"current_goal\":\"Fix the scene using questionable machinery.\",\"goal\":\"Fix the scene using questionable machinery.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"wistful\",\"name\":\"Mossy Crank\",\"recent_memory\":[\"Someone bowed before the reveal was ready.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Built a trapdoor that only opens emotionally.\",\"secret_status\":\"hidden\",\"speaking_style\":\"grumbly, mechanical, and secretly tender\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Shift the lights to match the emotional turn. Use change_lighting with args.mood only, matching the current app runtime."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Cue moonlit lighting; the polite clue needs edges.\",\"emotion\":\"dramatic\",\"gesture\":\"points to the spotlight with grand certainty\",\"stage_effect\":\"moonlit_lighting\",\"memory_update\":\"Remembered the lighting shift as an emotional cue.\",\"tool_request\":{\"tool\":\"change_lighting\",\"args\":{\"mood\":\"blue_spotlight\"},\"reason\":\"A strict lighting change keeps the stage state usable.\"}}"}]}
5
- {"id":"actor-sft-v1-001098","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A polite comet asks permission to crash the tea party\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"satin envelope\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"The satin envelope pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Ruby Ruckus\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Rafi Ribbon\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Polite Comet Asks Permission\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"ribbon\",\"current_goal\":\"Tie every contradiction into a decorative bow.\",\"goal\":\"Tie every contradiction into a decorative bow.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[\"satin envelope\"],\"mood\":\"confident\",\"name\":\"Rafi Ribbon\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\"],\"secret\":\"Keeps emergency applause in his sleeve.\",\"secret_status\":\"hinted\",\"speaking_style\":\"smooth, charming, and neatly dramatic\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording. Use memory as inspiration, not as copied output fields."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"I remember that glow; it followed this polite clue.\",\"emotion\":\"suddenly certain\",\"gesture\":\"points stage left with sudden recognition\",\"stage_effect\":\"memory_echo\",\"memory_update\":\"Linked the old stage detail to the current prop.\",\"tool_request\":null}"}]}
6
- {"id":"actor-sft-v1-000078","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A scarecrow runs a midnight advice booth for crows\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"silk bookmark\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Benny Breadcrumb\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Bolt McJiggle\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Scarecrow Runs Midnight Advice\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"toolbox\",\"current_goal\":\"Turn every problem into a practical stage gag.\",\"goal\":\"Turn every problem into a practical stage gag.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Bolt McJiggle\",\"recent_memory\":[\"The prop table glowed before anyone touched it.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Is secretly building a confetti finale backstage.\",\"secret_status\":\"revealed\",\"speaking_style\":\"punchy, practical, and full of suspicious confidence\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Give the audience a secret-shaped clue, not a long confession. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"reveal_secret\",\"line\":\"I hid the scarecrow clue where applause would look.\",\"emotion\":\"theatrical\",\"gesture\":\"steps carefully into the single spotlight\",\"stage_effect\":\"secret_chime\",\"memory_update\":\"Marked the secret as publicly useful.\",\"tool_request\":null}"}]}
7
- {"id":"actor-sft-v1-000368","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A tiny museum hires a shadow as night security\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Benny Breadcrumb\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Tiny Museum Hires Shadow\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"finale\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Pip the Director\",\"recent_memory\":[\"Someone bowed before the reveal was ready.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"revealed\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Give the scene a satisfying button without adding a new problem. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"tiny mystery solved, bows aligned, curtain forgiving everyone.\",\"emotion\":\"grand\",\"gesture\":\"bows deeply beneath the falling curtain\",\"stage_effect\":\"final_bow_lights\",\"memory_update\":\"Remembered the finale as complete.\",\"tool_request\":null}"}]}
8
- {"id":"actor-sft-v1-000287","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The sun forgets its entrance and asks the moon for notes\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Bolt McJiggle\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Juniper Jingle\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Forgets Entrance Asks Moon\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"chaos\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"bell\",\"current_goal\":\"Turn awkward pauses into musical cues.\",\"goal\":\"Turn awkward pauses into musical cues.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Juniper Jingle\",\"recent_memory\":[\"The smallest prop seemed unusually confident.\",\"The audience threw a prop during the contradiction.\"],\"secret\":\"Rings whenever someone lies politely.\",\"secret_status\":\"hidden\",\"speaking_style\":\"bright, rhythmic, and suspiciously tuneful\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Misread one stage detail in a funny but harmless way. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"Nobody panic; I interrogated the forgets backdrop.\",\"emotion\":\"confused\",\"gesture\":\"looks under a tiny hat for answers\",\"stage_effect\":\"hat_tumble\",\"memory_update\":\"Saved the misunderstanding as stage business.\",\"tool_request\":null}"}]}
9
- {"id":"actor-sft-v1-000477","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The sun forgets its entrance and asks the moon for notes\nshow_state JSON: {\"beat_index\":7,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Quincy Quill\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Duchess Doodle\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Forgets Entrance Asks Moon\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"paintbrush\",\"current_goal\":\"Make the backdrop agree with her version of events.\",\"goal\":\"Make the backdrop agree with her version of events.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"bold\",\"name\":\"Duchess Doodle\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Painted herself into last week's mystery.\",\"secret_status\":\"hidden\",\"speaking_style\":\"ornate, colorful, and lightly bossy\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Clarify your actor's stance while preserving the joke. Move opening, complication, or chaos forward without ending the show."},{"role":"assistant","content":"{\"intent\":\"react_to_event\",\"line\":\"Everyone stay dramatic; the forgets clue is taking attendance.\",\"emotion\":\"curious\",\"gesture\":\"tilts head under the warm spotlight\",\"stage_effect\":\"warm_spotlight\",\"memory_update\":\"Saved the current stage problem.\",\"tool_request\":null}"}]}
10
- {"id":"actor-sft-v1-001493","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A moon mayor denies stealing the town's last spoon\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw satin envelope onto the stage.\",\"latest_prop\":\"satin envelope\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Everyone stay dramatic; the stage clue is taking attendance.\",\"speaker\":\"Nora Needlewhistle\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Moon Mayor Denies Stealing\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[\"satin envelope\"],\"mood\":\"delighted\",\"name\":\"Pip the Director\",\"recent_memory\":[\"The audience threw a prop during the contradiction.\",\"A trapdoor sighed but did not open.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hidden\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\",\"inspect_prop\"]}\ndirector_instruction: Treat the prop like a clue that changes the scene. Return one inspect_prop request only; args must contain only prop."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"The satin envelope points stage left, which feels legally important.\",\"emotion\":\"startled\",\"gesture\":\"presents the clue on both tiny palms\",\"stage_effect\":\"magnifier_sparkle\",\"memory_update\":\"Noted that the satin envelope behaved like evidence.\",\"tool_request\":{\"tool\":\"inspect_prop\",\"args\":{\"prop\":\"satin envelope\"},\"reason\":\"The prop may reveal one concrete stage clue.\"}}"}]}
11
- {"id":"actor-sft-v1-000961","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The sun forgets its entrance and asks the moon for notes\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Gus Gingham\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Forgets Entrance Asks Moon\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"tablecloth\",\"current_goal\":\"Keep everyone civil while the table collapses.\",\"goal\":\"Keep everyone civil while the table collapses.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Gus Gingham\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Was once mistaken for the royal flag.\",\"secret_status\":\"hidden\",\"speaking_style\":\"homespun, patient, and quietly ridiculous\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Consult the oracle about the next theatrical turn. Return one oracle tool request only; do not include status, result, notes, or tool_results."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Stage oracle, guide this forgets mystery toward one clue.\",\"emotion\":\"mystified\",\"gesture\":\"peers upward as the oracle haze gathers\",\"stage_effect\":\"blue_spotlight\",\"memory_update\":\"Noted that the oracle pointed toward the spotlight.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"What should change before the next beat?\"},\"reason\":\"A precise oracle question keeps the scene focused.\"}}"}]}
12
- {"id":"actor-sft-v1-001961","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: An elevator only travels to dramatic reveals\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Pip the Director\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Elevator Only Travels Dramatic\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"chaos\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"The final bow was briefly visible in the wings.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Shift the lights to match the emotional turn. Keep the lighting call strict: tool, args.mood, and reason only."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Set stormy lighting; our tiny suspicion just saluted.\",\"emotion\":\"bold\",\"gesture\":\"shades eyes as the lamps change\",\"stage_effect\":\"stormy_lighting\",\"memory_update\":\"Marked the spotlight change as a clue.\",\"tool_request\":{\"tool\":\"change_lighting\",\"args\":{\"mood\":\"oracle_haze\"},\"reason\":\"The lighting cue clarifies the emotional turn.\"}}"}]}
13
- {"id":"actor-sft-v1-002042","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A wizard's laundry basket predicts tomorrow's weather\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"cardboard telescope\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"The cardboard telescope pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Petra Popcorn\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Marigold Mumble\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Wizard'S Laundry Basket Predicts\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"flower\",\"current_goal\":\"Say the emotional truth almost clearly enough.\",\"goal\":\"Say the emotional truth almost clearly enough.\",\"goal_progress\":\"Keeping one eye on the restless curtains.\",\"held_props\":[\"cardboard telescope\"],\"mood\":\"determined\",\"name\":\"Marigold Mumble\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Only speaks plainly during blackouts.\",\"secret_status\":\"hinted\",\"speaking_style\":\"gentle, tangled, and unexpectedly wise\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Bring back a prior stage detail in one clear line. Use memory as inspiration, not as copied output fields."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"That memory returns neatly, carrying the wizard's clue.\",\"emotion\":\"remembering\",\"gesture\":\"clutches chest as the old clue returns\",\"stage_effect\":\"stage_left_glimmer\",\"memory_update\":\"Saved the returned memory as useful evidence.\",\"tool_request\":null}"}]}
14
- {"id":"actor-sft-v1-000734","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A paper airplane delivers invitations to the wrong century\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"cardboard telescope\",\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Velvet Crumb\"},{\"line\":\"I followed the stage clue into my sleeve.\",\"speaker\":\"Silas Sockdolager\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Paper Airplane Delivers Invitations\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"reveal\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"megaphone\",\"current_goal\":\"Deliver the biggest line in the smallest voice.\",\"goal\":\"Deliver the biggest line in the smallest voice.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"mischievous\",\"name\":\"Silas Sockdolager\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"The curtains whispered that the finale was hiding nearby.\"],\"secret\":\"Lost his indoor voice under the orchestra pit.\",\"secret_status\":\"revealed\",\"speaking_style\":\"grand, booming, and suddenly tiny\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Let the secret complicate the current beat. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"reveal_secret\",\"line\":\"I hid the paper clue where applause would look.\",\"emotion\":\"nervous\",\"gesture\":\"covers mouth with one felt hand\",\"stage_effect\":\"single_spotlight\",\"memory_update\":\"Marked the secret as publicly useful.\",\"tool_request\":null}"}]}
15
- {"id":"actor-sft-v1-001264","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A mirror refuses to reflect anyone without stage presence\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Captain Taffeta\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Nora Needlewhistle\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Mirror Refuses Reflect Anyone\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"needle\",\"current_goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal\":\"Stitch loose clues into a tidy stage pattern.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"flustered\",\"name\":\"Nora Needlewhistle\",\"recent_memory\":[\"The backdrop changed color after the audience heckled.\",\"A bell rang twice when the secret was mentioned.\"],\"secret\":\"Once sewed the curtain shut during a finale.\",\"secret_status\":\"resolved\",\"speaking_style\":\"nimble, exact, and full of tiny warnings\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Give the scene a satisfying button without adding a new problem. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"Every mirror secret has curtseyed; the curtain may rest.\",\"emotion\":\"proud\",\"gesture\":\"bows deeply beneath the falling curtain\",\"stage_effect\":\"confetti_rustle\",\"memory_update\":\"Saved the ending as resolved.\",\"tool_request\":null}"}]}
16
- {"id":"actor-sft-v1-001071","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A nervous cloud applies to become a thunderstorm\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Ivy Inkblot\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Lola Lampshade\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Nervous Cloud Applies Become\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"chaos\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"lamp\",\"current_goal\":\"Reveal the truth with tasteful illumination.\",\"goal\":\"Reveal the truth with tasteful illumination.\",\"goal_progress\":\"Carrying a small suspicion toward center stage.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Lola Lampshade\",\"recent_memory\":[\"The curtains whispered that the finale was hiding nearby.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Overhears everything said near warm lighting.\",\"secret_status\":\"hidden\",\"speaking_style\":\"glowing, elegant, and dryly observant\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Let the misunderstanding create motion without breaking the scene. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"I thought the nervous clue was a hat, but it was Tuesday.\",\"emotion\":\"panicked\",\"gesture\":\"drops an imaginary cue card\",\"stage_effect\":\"quick_blackout\",\"memory_update\":\"Remembered the wrong clue for later comedy.\",\"tool_request\":null}"}]}
17
- {"id":"actor-sft-v1-001405","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A library book refuses to return from vacation\nshow_state JSON: {\"beat_index\":1,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Ivy Inkblot\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a pocket-sized stage with painted flats and a wobbly spotlight\",\"show_title\":\"The Library Book Refuses Return\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"opening\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"A summoned actor entered carrying yesterday's clue.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Make the next problem clear without solving it yet. Move opening, complication, or chaos forward without ending the show."},{"role":"assistant","content":"{\"intent\":\"clarify_problem\",\"line\":\"The library backdrop leaned closer, and I respect its commitment.\",\"emotion\":\"suspicious\",\"gesture\":\"tilts head under the warm spotlight\",\"stage_effect\":\"painted_flat_wobble\",\"memory_update\":\"Saved the current stage problem.\",\"tool_request\":null}"}]}
18
- {"id":"actor-sft-v1-001558","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A train conductor schedules a parade inside a suitcase\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw toy hourglass onto the stage.\",\"latest_prop\":\"toy hourglass\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Marigold Mumble\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Captain Taffeta\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Train Conductor Schedules Parade\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"sailboat\",\"current_goal\":\"Steer every scene through theatrical weather.\",\"goal\":\"Steer every scene through theatrical weather.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[\"toy hourglass\"],\"mood\":\"ready\",\"name\":\"Captain Taffeta\",\"recent_memory\":[\"The curtains whispered that the finale was hiding nearby.\",\"A shadow crossed the stage wearing tap shoes.\"],\"secret\":\"Cannot tell port from stage left.\",\"secret_status\":\"hinted\",\"speaking_style\":\"booming, nautical, and cheerfully mistaken\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\",\"inspect_prop\"]}\ndirector_instruction: Handle the prop theatrically and leave one question open. Return one inspect_prop request only; args must contain only prop."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"This toy hourglass squeaks exactly like a guilty witness.\",\"emotion\":\"focused\",\"gesture\":\"leans toward the glowing prop\",\"stage_effect\":\"magnifier_sparkle\",\"memory_update\":\"Saved the prop clue for the Director.\",\"tool_request\":{\"tool\":\"inspect_prop\",\"args\":{\"prop\":\"toy hourglass\"},\"reason\":\"The prop may reveal one concrete stage clue.\"}}"}]}
19
- {"id":"actor-sft-v1-001537","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The circus cannonball wants a quieter desk job\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Finch Feltcap\"},{\"line\":\"The stage map is upside down, unless we are the map.\",\"speaker\":\"Pip the Director\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Circus Cannonball Wants Quieter\",\"stage_lighting\":\"blue_spotlight\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"clapperboard\",\"current_goal\":\"Keep the scene moving toward a crisp finale.\",\"goal\":\"Keep the scene moving toward a crisp finale.\",\"goal_progress\":\"Recovering from a missed entrance with dignity.\",\"held_props\":[],\"mood\":\"wistful\",\"name\":\"Pip the Director\",\"recent_memory\":[\"A previous clue pointed stage left.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Has already misplaced the final cue card.\",\"secret_status\":\"hinted\",\"speaking_style\":\"brisk, theatrical, and slightly overconfident\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Ask the oracle something specific enough to guide the next beat. Ask one question through consult_stage_oracle and stop after the JSON object."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, should the velcro star or curtain speak next?\",\"emotion\":\"hopeful\",\"gesture\":\"raises both hands toward the stage rafters\",\"stage_effect\":\"curtain_whisper\",\"memory_update\":\"Saved the oracle question as a mystery thread.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"Which clue should the next actor follow?\"},\"reason\":\"A precise oracle question keeps the scene focused.\"}}"}]}
20
- {"id":"actor-sft-v1-002008","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The village well starts returning everyone else's wishes\nshow_state JSON: {\"beat_index\":2,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Mina Moonbutton\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Marigold Mumble\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Village Well Starts Returning\",\"stage_lighting\":\"moonlit_lighting\",\"story_phase\":\"chaos\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"flower\",\"current_goal\":\"Say the emotional truth almost clearly enough.\",\"goal\":\"Say the emotional truth almost clearly enough.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Marigold Mumble\",\"recent_memory\":[\"A trapdoor sighed but did not open.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Only speaks plainly during blackouts.\",\"secret_status\":\"hidden\",\"speaking_style\":\"gentle, tangled, and unexpectedly wise\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Let the light change signal a clear emotional pivot. Cue one strict lighting change and do not add status or result fields."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Set stormy lighting; our tiny suspicion just saluted.\",\"emotion\":\"dramatic\",\"gesture\":\"sweeps an arm across the footlights\",\"stage_effect\":\"single_spotlight\",\"memory_update\":\"Marked the spotlight change as a clue.\",\"tool_request\":{\"tool\":\"change_lighting\",\"args\":{\"mood\":\"stormy_lighting\"},\"reason\":\"The lighting cue clarifies the emotional turn.\"}}"}]}
21
- {"id":"actor-sft-v1-001418","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A paper airplane delivers invitations to the wrong century\nshow_state JSON: {\"beat_index\":5,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"cardboard snowflake\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"The cardboard snowflake pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"Let us follow the stage wobble before it complains.\",\"speaker\":\"Mina Moonbutton\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Professor Buttonhook\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Paper Airplane Delivers Invitations\",\"stage_lighting\":\"quick_blackout\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"spectacles\",\"current_goal\":\"Explain chaos as if it were in the syllabus.\",\"goal\":\"Explain chaos as if it were in the syllabus.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[\"cardboard snowflake\"],\"mood\":\"delighted\",\"name\":\"Professor Buttonhook\",\"recent_memory\":[\"A previous clue pointed stage left.\"],\"secret\":\"Never learned how trapdoors work.\",\"secret_status\":\"hinted\",\"speaking_style\":\"precise, fussy, and accidentally grand\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Use recent memory to make the scene feel continuous. Recall memory in the line only; do not copy show_state, recent_transcript, held_props, or memory fields."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"I remember that glow; it followed this paper clue.\",\"emotion\":\"remembering\",\"gesture\":\"points stage left with sudden recognition\",\"stage_effect\":\"memory_echo\",\"memory_update\":\"Connected the remembered clue to this beat.\",\"tool_request\":null}"}]}
22
- {"id":"actor-sft-v1-001422","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"silk bookmark\",\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Zelda Zipper\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Orville Oddsock\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"reveal\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"sock\",\"current_goal\":\"Find the missing pair in every mystery.\",\"goal\":\"Find the missing pair in every mystery.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[],\"mood\":\"confident\",\"name\":\"Orville Oddsock\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Believes the laundry basket is a prophet.\",\"secret_status\":\"hinted\",\"speaking_style\":\"loopy, sincere, and oddly persuasive\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Reveal a playful secret and keep the scene moving. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"hint_secret\",\"line\":\"The silk bookmark knows me, and that is inconvenient.\",\"emotion\":\"confessional\",\"gesture\":\"covers mouth with one felt hand\",\"stage_effect\":\"single_spotlight\",\"memory_update\":\"Marked the secret as publicly useful.\",\"tool_request\":null}"}]}
23
- {"id":"actor-sft-v1-000416","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage backdrop leaned closer, and I respect its commitment.\",\"speaker\":\"Rafi Ribbon\"},{\"line\":\"Nobody panic; I interrogated the stage backdrop.\",\"speaker\":\"Silas Sockdolager\"}],\"setting\":\"a rainy cardboard alley lit by one dramatic desk lamp\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"megaphone\",\"current_goal\":\"Deliver the biggest line in the smallest voice.\",\"goal\":\"Deliver the biggest line in the smallest voice.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"hopeful\",\"name\":\"Silas Sockdolager\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"The orchestra coughed during the important pause.\"],\"secret\":\"Lost his indoor voice under the orchestra pit.\",\"secret_status\":\"revealed\",\"speaking_style\":\"grand, booming, and suddenly tiny\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: Give the scene a satisfying button without adding a new problem. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"We solved the puppet wobble and saved the spotlight.\",\"emotion\":\"grand\",\"gesture\":\"sweeps a tiny hat toward the audience\",\"stage_effect\":\"confetti_rustle\",\"memory_update\":\"Closed the central mystery for the curtain.\",\"tool_request\":null}"}]}
24
- {"id":"actor-sft-v1-000191","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The circus cannonball wants a quieter desk job\nshow_state JSON: {\"beat_index\":0,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Captain Taffeta\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Velvet Crumb\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Circus Cannonball Wants Quieter\",\"stage_lighting\":\"single_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"cupcake\",\"current_goal\":\"Make every clue sound like dessert.\",\"goal\":\"Make every clue sound like dessert.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Velvet Crumb\",\"recent_memory\":[\"The orchestra coughed during the important pause.\",\"The prop table glowed before anyone touched it.\"],\"secret\":\"Can hear the curtains whisper reviews.\",\"secret_status\":\"hidden\",\"speaking_style\":\"warm, dramatic, and gently mischievous\",\"tools\":[\"inspect_prop\",\"consult_stage_oracle\"]}\ndirector_instruction: Confuse one clue with another, then recover enough to continue. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"Nobody panic; I interrogated the circus backdrop.\",\"emotion\":\"confused\",\"gesture\":\"looks under a tiny hat for answers\",\"stage_effect\":\"hat_tumble\",\"memory_update\":\"Confusion briefly raised the stakes.\",\"tool_request\":null}"}]}
25
- {"id":"actor-sft-v1-001452","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"normal_reaction","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A train conductor schedules a parade inside a suitcase\nshow_state JSON: {\"beat_index\":6,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Tula Teaspoon\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Silas Sockdolager\"}],\"setting\":\"a tiny kitchen counter where every appliance has stage fright\",\"show_title\":\"The Train Conductor Schedules Parade\",\"stage_lighting\":\"oracle_haze\",\"story_phase\":\"complication\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"megaphone\",\"current_goal\":\"Deliver the biggest line in the smallest voice.\",\"goal\":\"Deliver the biggest line in the smallest voice.\",\"goal_progress\":\"Turning a contradiction into stage business.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Silas Sockdolager\",\"recent_memory\":[\"The spotlight blinked whenever someone said clue.\",\"The backdrop changed color after the audience heckled.\"],\"secret\":\"Lost his indoor voice under the orchestra pit.\",\"secret_status\":\"hinted\",\"speaking_style\":\"grand, booming, and suddenly tiny\",\"tools\":[\"change_lighting\",\"consult_stage_oracle\"]}\ndirector_instruction: React to the premise and move the scene forward in one short line. Move opening, complication, or chaos forward without ending the show."},{"role":"assistant","content":"{\"intent\":\"react_to_event\",\"line\":\"Let us follow the train wobble before it complains.\",\"emotion\":\"curious\",\"gesture\":\"taps chin with one tiny puppet hand\",\"stage_effect\":\"stage_left_glimmer\",\"memory_update\":\"Marked the new clue as active.\",\"tool_request\":null}"}]}
26
- {"id":"actor-sft-v1-000580","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"prop_inspection","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A blanket fort declares independence from bedtime\nshow_state JSON: {\"beat_index\":8,\"finale_requested\":false,\"latest_audience_action\":\"Audience threw button bouquet onto the stage.\",\"latest_prop\":\"button bouquet\",\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"The stage problem just winked, so we investigate politely.\",\"speaker\":\"Rafi Ribbon\"},{\"line\":\"I thought the stage clue was a hat, but it was Tuesday.\",\"speaker\":\"Orville Oddsock\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Blanket Fort Declares Independence\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"complication\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"sock\",\"current_goal\":\"Find the missing pair in every mystery.\",\"goal\":\"Find the missing pair in every mystery.\",\"goal_progress\":\"Looking for a laugh that still serves the story.\",\"held_props\":[\"button bouquet\"],\"mood\":\"nervous\",\"name\":\"Orville Oddsock\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"The smallest prop seemed unusually confident.\"],\"secret\":\"Believes the laundry basket is a prophet.\",\"secret_status\":\"hidden\",\"speaking_style\":\"loopy, sincere, and oddly persuasive\",\"tools\":[\"consult_stage_oracle\",\"inspect_prop\"]}\ndirector_instruction: Make the prop feel important enough for the Director to notice. Inspect exactly the latest prop and do not add result or notes fields."},{"role":"assistant","content":"{\"intent\":\"inspect_prop\",\"line\":\"I dust the button bouquet and discover suspicious applause.\",\"emotion\":\"focused\",\"gesture\":\"presents the clue on both tiny palms\",\"stage_effect\":\"tiny_chime\",\"memory_update\":\"Saved the prop clue for the Director.\",\"tool_request\":{\"tool\":\"inspect_prop\",\"args\":{\"prop\":\"button bouquet\"},\"reason\":\"The prop may reveal one concrete stage clue.\"}}"}]}
27
- {"id":"actor-sft-v1-002017","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"oracle_consult","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":3,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"I bow to the stage door, and it applauds.\",\"speaker\":\"Mina Moonbutton\"}],\"setting\":\"a paper harbor with blue ribbons for waves\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"prop_table_glow\",\"story_phase\":\"complication\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"crescent moon\",\"current_goal\":\"Find the emotional truth hiding inside the premise.\",\"goal\":\"Find the emotional truth hiding inside the premise.\",\"goal_progress\":\"Testing whether the audience interruption is useful.\",\"held_props\":[],\"mood\":\"ready\",\"name\":\"Mina Moonbutton\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Believes every prop is personally judging her.\",\"secret_status\":\"hidden\",\"speaking_style\":\"earnest, poetic, and prone to dramatic pauses\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Invite a mysterious clue without making the scene too serious. Keep the oracle call strict: tool, args.question, and reason only."},{"role":"assistant","content":"{\"intent\":\"consult_oracle\",\"line\":\"Oracle, which puppet clue deserves the next spotlight?\",\"emotion\":\"awed\",\"gesture\":\"raises both hands toward the stage rafters\",\"stage_effect\":\"blue_spotlight\",\"memory_update\":\"Saved the oracle question as a mystery thread.\",\"tool_request\":{\"tool\":\"consult_stage_oracle\",\"args\":{\"question\":\"Which clue should the next actor follow?\"},\"reason\":\"The oracle can supply one useful next clue.\"}}"}]}
28
- {"id":"actor-sft-v1-000504","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"lighting_change","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A cardboard courtroom tries a missing birthday candle\nshow_state JSON: {\"beat_index\":9,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":null,\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Nora Needlewhistle\"},{\"line\":\"The stage evidence is backwards, or my shoes narrate.\",\"speaker\":\"Mina Moonbutton\"}],\"setting\":\"a velvet courtroom with a squeaky judge's bench\",\"show_title\":\"The Cardboard Courtroom Tries Missing\",\"stage_lighting\":\"golden_lighting\",\"story_phase\":\"chaos\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"crescent moon\",\"current_goal\":\"Find the emotional truth hiding inside the premise.\",\"goal\":\"Find the emotional truth hiding inside the premise.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"suspicious\",\"name\":\"Mina Moonbutton\",\"recent_memory\":[\"The painted door demanded a compliment earlier.\",\"The final bow was briefly visible in the wings.\"],\"secret\":\"Believes every prop is personally judging her.\",\"secret_status\":\"hidden\",\"speaking_style\":\"earnest, poetic, and prone to dramatic pauses\",\"tools\":[\"consult_stage_oracle\",\"change_lighting\"]}\ndirector_instruction: Shift the lights to match the emotional turn. Keep the lighting call strict: tool, args.mood, and reason only."},{"role":"assistant","content":"{\"intent\":\"change_lighting\",\"line\":\"Bring golden light so the cardboard truth can enter.\",\"emotion\":\"composed\",\"gesture\":\"snaps one felt hand toward the light booth\",\"stage_effect\":\"prop_table_glow\",\"memory_update\":\"Saved the new lighting cue for the next actor.\",\"tool_request\":{\"tool\":\"change_lighting\",\"args\":{\"mood\":\"memory_echo\"},\"reason\":\"The lighting cue clarifies the emotional turn.\"}}"}]}
29
- {"id":"actor-sft-v1-001179","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"memory_callback","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A pirate parrot opens a school for dramatic pauses\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"porcelain whistle\",\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[{\"result\":\"The porcelain whistle pointed stage left.\",\"stage_effect\":\"prop_table_glow\",\"tool\":\"inspect_prop\"}],\"recent_transcript\":[{\"line\":\"This stage scene smells like mystery and nervous paint.\",\"speaker\":\"Professor Buttonhook\"},{\"line\":\"The spotlight blinked twice, so I blamed the stage.\",\"speaker\":\"Juniper Jingle\"}],\"setting\":\"a toy library where the shelves whisper stage directions\",\"show_title\":\"The Pirate Parrot Opens School\",\"stage_lighting\":\"memory_echo\",\"story_phase\":\"reveal\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"bell\",\"current_goal\":\"Turn awkward pauses into musical cues.\",\"goal\":\"Turn awkward pauses into musical cues.\",\"goal_progress\":\"Preparing a clean turn toward the finale.\",\"held_props\":[\"porcelain whistle\"],\"mood\":\"ready\",\"name\":\"Juniper Jingle\",\"recent_memory\":[\"The stage oracle warned everyone about small objects.\"],\"secret\":\"Rings whenever someone lies politely.\",\"secret_status\":\"hinted\",\"speaking_style\":\"bright, rhythmic, and suspiciously tuneful\",\"tools\":[\"consult_stage_oracle\"]}\ndirector_instruction: Echo a prior beat without repeating the same wording. Include the required line field and keep memory_update short or null."},{"role":"assistant","content":"{\"intent\":\"recall_memory\",\"line\":\"That memory returns neatly, carrying the pirate clue.\",\"emotion\":\"alert\",\"gesture\":\"clutches chest as the old clue returns\",\"stage_effect\":\"stage_left_glimmer\",\"memory_update\":\"Linked the old stage detail to the current prop.\",\"tool_request\":null}"}]}
30
- {"id":"actor-sft-v1-001150","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"secret_hint_or_reveal","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A nervous toaster auditions for a royal banquet\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":null,\"latest_prop\":\"tin trumpet\",\"max_beats\":14,\"min_beats\":9,\"recent_tool_results\":[{\"result\":\"Secrets knock twice before entering.\",\"stage_effect\":\"oracle_haze\",\"tool\":\"consult_stage_oracle\"}],\"recent_transcript\":[{\"line\":\"This tiny stage problem just became legally theatrical.\",\"speaker\":\"Lola Lampshade\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a cardboard moon base with glitter stars and a squeaky hatch\",\"show_title\":\"The Nervous Toaster Auditions Royal\",\"stage_lighting\":\"stormy_lighting\",\"story_phase\":\"reveal\",\"target_beats\":12}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Trying to make the prop matter.\",\"held_props\":[],\"mood\":\"flustered\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"A bell rang twice when the secret was mentioned.\",\"The painted door demanded a compliment earlier.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hinted\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Reveal a playful secret and keep the scene moving. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"hint_secret\",\"line\":\"My secret squeaks louder whenever the tin trumpet gets nervous.\",\"emotion\":\"theatrical\",\"gesture\":\"steps carefully into the single spotlight\",\"stage_effect\":\"secret_chime\",\"memory_update\":\"Saved the reveal as a new complication.\",\"tool_request\":null}"}]}
31
- {"id":"actor-sft-v1-001472","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"finale","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: The puppet mayor appoints a potato as royal advisor\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":true,\"latest_audience_action\":\"Audience requested a finale.\",\"latest_prop\":null,\"max_beats\":12,\"min_beats\":7,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I hear stage suspense tapping behind the painted flats.\",\"speaker\":\"Duchess Doodle\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Mossy Crank\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Puppet Mayor Appoints Potato\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"finale\",\"target_beats\":10}\nactor JSON: {\"avatar\":\"gear\",\"current_goal\":\"Fix the scene using questionable machinery.\",\"goal\":\"Fix the scene using questionable machinery.\",\"goal_progress\":\"Waiting for the next cue.\",\"held_props\":[],\"mood\":\"startled\",\"name\":\"Mossy Crank\",\"recent_memory\":[\"A shadow crossed the stage wearing tap shoes.\",\"A summoned actor entered carrying yesterday's clue.\"],\"secret\":\"Built a trapdoor that only opens emotionally.\",\"secret_status\":\"resolved\",\"speaking_style\":\"grumbly, mechanical, and secretly tender\",\"tools\":[\"inspect_prop\",\"change_lighting\"]}\ndirector_instruction: Tie the scene together in a clean curtain-call line. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"deliver_finale\",\"line\":\"puppet mystery solved, bows aligned, curtain forgiving everyone.\",\"emotion\":\"joyful\",\"gesture\":\"joins hands with the nearest puppet\",\"stage_effect\":\"confetti_rustle\",\"memory_update\":\"Scene resolved with a clean bow.\",\"tool_request\":null}"}]}
32
- {"id":"actor-sft-v1-002095","source_mix":["synthetic_v1","targeted_hardening","deterministic_templates","ai_puppet_theater_runtime_schema"],"row_type":"comedic_confusion","messages":[{"role":"system","content":"You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},{"role":"user","content":"premise: A scarecrow runs a midnight advice booth for crows\nshow_state JSON: {\"beat_index\":4,\"finale_requested\":false,\"latest_audience_action\":\"Audience heckled: That clue is wearing a hat.\",\"latest_prop\":null,\"max_beats\":10,\"min_beats\":5,\"recent_tool_results\":[],\"recent_transcript\":[{\"line\":\"I trust this stage spotlight only when it stops coughing.\",\"speaker\":\"Petra Popcorn\"},{\"line\":\"I object, unless that stage noise was my cue.\",\"speaker\":\"Ruby Ruckus\"}],\"setting\":\"a shoebox castle with velvet curtains and one suspicious tower\",\"show_title\":\"The Scarecrow Runs Midnight Advice\",\"stage_lighting\":\"warm_spotlight\",\"story_phase\":\"chaos\",\"target_beats\":8}\nactor JSON: {\"avatar\":\"drum\",\"current_goal\":\"Escalate the scene exactly one beat too far.\",\"goal\":\"Escalate the scene exactly one beat too far.\",\"goal_progress\":\"Following the clue across the painted flats.\",\"held_props\":[],\"mood\":\"grand\",\"name\":\"Ruby Ruckus\",\"recent_memory\":[\"A summoned actor entered carrying yesterday's clue.\",\"The spotlight blinked whenever someone said clue.\"],\"secret\":\"Keeps a tiny cymbal for emergencies.\",\"secret_status\":\"hidden\",\"speaking_style\":\"rowdy, warm, and rhythmically suspicious\",\"tools\":[\"change_lighting\"]}\ndirector_instruction: Make a wrong conclusion that gives the next actor something usable. Return exactly one JSON object with the seven actor fields."},{"role":"assistant","content":"{\"intent\":\"comic_confusion\",\"line\":\"Nobody panic; I interrogated the scarecrow backdrop.\",\"emotion\":\"goofy\",\"gesture\":\"freezes midstep with baffled dignity\",\"stage_effect\":\"quick_blackout\",\"memory_update\":\"Saved the misunderstanding as stage business.\",\"tool_request\":null}"}]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/dataset_cards/actor_sft_README.md DELETED
@@ -1,109 +0,0 @@
1
- ---
2
- license: other
3
- task_categories:
4
- - text-generation
5
- language:
6
- - en
7
- tags:
8
- - synthetic
9
- - supervised-fine-tuning
10
- - agents
11
- - json
12
- - gradio
13
- - hackathon
14
- pretty_name: AI Puppet Theater Actor SFT
15
- ---
16
-
17
- # AI Puppet Theater Actor SFT
18
-
19
- Synthetic supervised fine-tuning data for the Actor agent in **AI Puppet Theater / The Runaway Puppet Show**.
20
-
21
- The dataset teaches a small language model to respond to a single puppet-theater beat with one compact JSON object. It is intended for hackathon prototyping, schema following, and local adapter experiments, not as a general storytelling or chat dataset.
22
-
23
- ## Schema
24
-
25
- Each row is chat-style JSONL:
26
-
27
- ```json
28
- {
29
- "id": "actor-sft-v0-000001",
30
- "source_mix": ["synthetic_v0", "deterministic_templates", "ai_puppet_theater_runtime_schema"],
31
- "row_type": "prop_inspection",
32
- "messages": [
33
- {"role": "system", "content": "You are an Actor agent in AI Puppet Theater..."},
34
- {"role": "user", "content": "premise: ...\nshow_state JSON: ...\nactor JSON: ...\ndirector_instruction: ..."},
35
- {"role": "assistant", "content": "{\"intent\":\"inspect_prop\",\"line\":\"...\"}"}
36
- ]
37
- }
38
- ```
39
-
40
- The assistant content is a serialized JSON object with exactly:
41
-
42
- ```json
43
- {
44
- "intent": "inspect_prop",
45
- "line": "This rubber duck squeaks exactly like a guilty witness.",
46
- "emotion": "investigative",
47
- "gesture": "leans toward the glowing prop",
48
- "stage_effect": "prop_table_glow",
49
- "memory_update": "Noted that the rubber duck behaved like evidence.",
50
- "tool_request": {
51
- "tool": "inspect_prop",
52
- "args": {"prop": "rubber duck"},
53
- "reason": "The prop may reveal a stage clue."
54
- }
55
- }
56
- ```
57
-
58
- `memory_update` may be `null`. `tool_request` may be `null`.
59
-
60
- Allowed tools:
61
-
62
- - `inspect_prop` with `args: {"prop": "..."}`
63
- - `consult_stage_oracle` with `args: {"question": "..."}`
64
- - `change_lighting` with `args: {"mood": "..."}` to match the current AI Puppet Theater runtime
65
-
66
- ## Versions
67
-
68
- ### v0
69
-
70
- `actor_sft_v0` is the first-pass schema and behavior-contract dataset. It is the source for the current best MiniCPM5 Actor LoRA adapter candidate.
71
-
72
- ### v1
73
-
74
- `actor_sft_v1` is a targeted hardening experiment with more oracle, prop, lighting, and memory rows. It improved local validation tooling and schema audit coverage, but the v1 adapter experiment did not outperform v0 on full eval at the time this card was written.
75
-
76
- ## Generation
77
-
78
- Rows are generated by deterministic templates in the AI Puppet Theater repo. The generator uses fixed seeds, multiple premises, actor profiles, props, moods, stage effects, line templates, memory-update templates, and Director instructions.
79
-
80
- Optional local seed ingestion exists for TheatreLM/RPGPT-style rows, but raw external rows are not committed here. If optional seeds are used, they only seed premise, persona, setting, memory, prop, and Director-instruction material. Assistant completions remain generated in the validated Actor JSON schema.
81
-
82
- Potential optional seed sources:
83
-
84
- - [G-reen/TheatreLM-v2.1-Characters](https://huggingface.co/datasets/G-reen/TheatreLM-v2.1-Characters)
85
- - [practical-dreamer/RPGPT_PublicDomain-alpaca](https://huggingface.co/datasets/practical-dreamer/RPGPT_PublicDomain-alpaca)
86
-
87
- ## Intended Use
88
-
89
- - Fine-tuning small models to emit Actor-agent JSON for AI Puppet Theater.
90
- - Testing structured output, tool request shape, short theatrical lines, and demo-safe puppet responses.
91
- - Hackathon prototyping and reproducible dataset generation.
92
-
93
- ## Limitations
94
-
95
- - Synthetic and template-driven; it does not teach rich long-form theater writing.
96
- - Memory arcs are shallow and row-local.
97
- - Director planning is out of scope; this dataset is Actor-only.
98
- - Runtime still needs JSON validation, sanitization, and deterministic fallback.
99
- - Demo-safe filters are simple and do not replace manual review.
100
-
101
- ## License
102
-
103
- TODO: confirm the final project and dataset license before publishing. If optional external seeds are used in a published dataset, credit and comply with the source dataset licenses.
104
-
105
- ## Related Artifacts
106
-
107
- - Space: https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater
108
- - Dataset repo placeholder: https://huggingface.co/datasets/build-small-hackathon/AI-Puppet-Theater-Actor-SFT
109
- - Model repo placeholder: https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/external_seeds/.gitkeep DELETED
@@ -1 +0,0 @@
1
-
 
 
finetune/modal_train_actor_lora.py DELETED
@@ -1,309 +0,0 @@
1
- """Modal entrypoint for MiniCPM5-1B Actor LoRA/QLoRA training.
2
-
3
- Run from the repo root with Modal installed and authenticated:
4
-
5
- modal run finetune/modal_train_actor_lora.py::smoke_test
6
- modal run finetune/modal_train_actor_lora.py::train_full
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import os
12
- from pathlib import Path
13
-
14
-
15
- APP_NAME = "ai-puppet-theater-actor-lora"
16
- VOLUME_NAME = "ai-puppet-theater-finetune"
17
- REMOTE_WORKDIR = Path("/root/ai-puppet-theater")
18
- REMOTE_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-lora")
19
- REMOTE_V1_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-lora-v1")
20
- REMOTE_MERGED_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-merged")
21
- REMOTE_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_lora_eval.jsonl")
22
- REMOTE_V1_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_lora_v1_eval.jsonl")
23
- REMOTE_MERGED_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_merged_v0_eval.jsonl")
24
- REMOTE_CACHE_DIR = Path("/vol/cache")
25
-
26
-
27
- try:
28
- import modal
29
- except ImportError: # Keeps local imports clean when Modal is not installed.
30
- modal = None
31
-
32
-
33
- def _require_modal():
34
- if modal is None:
35
- raise RuntimeError("modal is not installed. Install it with `pip install modal` and run `modal setup`.")
36
- return modal
37
-
38
-
39
- if modal is not None:
40
- app = modal.App(APP_NAME)
41
- volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
42
- hf_secret_name = os.getenv("MODAL_HF_SECRET_NAME")
43
- hf_secrets = [modal.Secret.from_name(hf_secret_name)] if hf_secret_name else []
44
- image = (
45
- modal.Image.debian_slim(python_version="3.11")
46
- .apt_install("git")
47
- .pip_install_from_requirements("finetune/requirements-train.txt")
48
- .env(
49
- {
50
- "HF_HOME": str(REMOTE_CACHE_DIR / "huggingface"),
51
- "TRANSFORMERS_CACHE": str(REMOTE_CACHE_DIR / "huggingface" / "transformers"),
52
- "HF_HUB_CACHE": str(REMOTE_CACHE_DIR / "huggingface" / "hub"),
53
- }
54
- )
55
- .add_local_dir("finetune", remote_path=str(REMOTE_WORKDIR / "finetune"))
56
- )
57
- else:
58
- app = None
59
- volume = None
60
- image = None
61
-
62
-
63
- def training_args(
64
- *,
65
- max_train_samples: int | None = None,
66
- max_eval_samples: int | None = None,
67
- epochs: float = 2.0,
68
- output_subdir: str = "minicpm5-actor-lora",
69
- dataset_version: str = "v0",
70
- extra_args: list[str] | None = None,
71
- ) -> list[str]:
72
- output_dir = REMOTE_OUTPUT_DIR.parent / output_subdir
73
- args = [
74
- "--model_name",
75
- os.getenv("MODEL_NAME", "openbmb/MiniCPM5-1B"),
76
- "--train_file",
77
- str(REMOTE_WORKDIR / f"finetune/data/actor_sft_{dataset_version}_train.jsonl"),
78
- "--val_file",
79
- str(REMOTE_WORKDIR / f"finetune/data/actor_sft_{dataset_version}_val.jsonl"),
80
- "--output_dir",
81
- str(output_dir),
82
- "--epochs",
83
- str(epochs),
84
- "--max_seq_length",
85
- os.getenv("MAX_SEQ_LENGTH", "1024"),
86
- "--per_device_train_batch_size",
87
- os.getenv("PER_DEVICE_TRAIN_BATCH_SIZE", "2"),
88
- "--gradient_accumulation_steps",
89
- os.getenv("GRADIENT_ACCUMULATION_STEPS", "8"),
90
- ]
91
- if max_train_samples is not None:
92
- args.extend(["--max_train_samples", str(max_train_samples)])
93
- if max_eval_samples is not None:
94
- args.extend(["--max_eval_samples", str(max_eval_samples)])
95
- if extra_args:
96
- args.extend(extra_args)
97
- return args
98
-
99
-
100
- if modal is not None:
101
-
102
- @app.function(
103
- image=image,
104
- gpu=os.getenv("MODAL_GPU", "A10"),
105
- timeout=60 * 60 * 6,
106
- volumes={"/vol": volume},
107
- secrets=hf_secrets,
108
- )
109
- def run_training(args: list[str]) -> str:
110
- import subprocess
111
- import sys
112
-
113
- cmd = [sys.executable, str(REMOTE_WORKDIR / "finetune/scripts/train_minicpm5_actor_lora.py"), *args]
114
- subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True)
115
- volume.commit()
116
- output_dir = args[args.index("--output_dir") + 1]
117
- return output_dir
118
-
119
-
120
- @app.local_entrypoint()
121
- def smoke_test() -> None:
122
- output_dir = run_training.remote(
123
- training_args(
124
- max_train_samples=20,
125
- max_eval_samples=10,
126
- epochs=float(os.getenv("SMOKE_EPOCHS", "0.05")),
127
- output_subdir="minicpm5-actor-lora-smoke",
128
- extra_args=["--save_strategy", "no", "--eval_strategy", "no"],
129
- )
130
- )
131
- print(f"Smoke-test adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}")
132
-
133
-
134
- @app.local_entrypoint()
135
- def train_full() -> None:
136
- output_dir = run_training.remote(training_args())
137
- print(f"Adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}")
138
-
139
-
140
- @app.local_entrypoint()
141
- def train_v1() -> None:
142
- output_dir = run_training.remote(
143
- training_args(
144
- epochs=float(os.getenv("EPOCHS", "2.0")),
145
- output_subdir="minicpm5-actor-lora-v1",
146
- dataset_version="v1",
147
- )
148
- )
149
- print(f"Actor v1 adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}")
150
-
151
-
152
- @app.function(
153
- image=image,
154
- gpu=os.getenv("MODAL_GPU", "A10"),
155
- timeout=60 * 60,
156
- volumes={"/vol": volume},
157
- secrets=hf_secrets,
158
- )
159
- def run_eval(limit: int | None = None) -> str:
160
- return run_eval_for_adapter(
161
- adapter_dir=REMOTE_WORKDIR / "finetune/minicpm5-actor-lora",
162
- output_file=REMOTE_EVAL_OUTPUT_FILE,
163
- limit=limit,
164
- )
165
-
166
-
167
- @app.function(
168
- image=image,
169
- gpu=os.getenv("MODAL_GPU", "A10"),
170
- timeout=60 * 60,
171
- volumes={"/vol": volume},
172
- secrets=hf_secrets,
173
- )
174
- def run_eval_v1(limit: int | None = None) -> str:
175
- return run_eval_for_adapter(
176
- adapter_dir=REMOTE_V1_OUTPUT_DIR,
177
- output_file=REMOTE_V1_EVAL_OUTPUT_FILE,
178
- limit=limit,
179
- )
180
-
181
-
182
- @app.function(
183
- image=image,
184
- gpu=os.getenv("MODAL_GPU", "A10"),
185
- timeout=60 * 60,
186
- volumes={"/vol": volume},
187
- secrets=hf_secrets,
188
- )
189
- def run_eval_merged_v0(limit: int | None = None) -> str:
190
- import subprocess
191
- import sys
192
-
193
- cmd = [
194
- sys.executable,
195
- str(REMOTE_WORKDIR / "finetune/scripts/eval_minicpm5_actor_lora.py"),
196
- "--base_model",
197
- str(REMOTE_MERGED_OUTPUT_DIR),
198
- "--merged_model",
199
- "--eval_file",
200
- str(REMOTE_WORKDIR / "finetune/data_samples/actor_eval_prompts.jsonl"),
201
- "--output_file",
202
- str(REMOTE_MERGED_EVAL_OUTPUT_FILE),
203
- ]
204
- if limit is not None:
205
- cmd.extend(["--limit", str(limit)])
206
- subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True)
207
- volume.commit()
208
- return str(REMOTE_MERGED_EVAL_OUTPUT_FILE)
209
-
210
-
211
- def run_eval_for_adapter(adapter_dir: Path, output_file: Path, limit: int | None = None) -> str:
212
- import subprocess
213
- import sys
214
-
215
- cmd = [
216
- sys.executable,
217
- str(REMOTE_WORKDIR / "finetune/scripts/eval_minicpm5_actor_lora.py"),
218
- "--adapter_dir",
219
- str(adapter_dir),
220
- "--eval_file",
221
- str(REMOTE_WORKDIR / "finetune/data_samples/actor_eval_prompts.jsonl"),
222
- "--output_file",
223
- str(output_file),
224
- ]
225
- if limit is not None:
226
- cmd.extend(["--limit", str(limit)])
227
- subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True)
228
- volume.commit()
229
- return str(output_file)
230
-
231
-
232
- @app.local_entrypoint()
233
- def eval_adapter(limit: int | None = None) -> None:
234
- output_file = run_eval.remote(limit)
235
- print(f"Eval output saved in Modal Volume {VOLUME_NAME}: {output_file}")
236
-
237
-
238
- @app.local_entrypoint()
239
- def eval_adapter_v1(limit: int | None = None) -> None:
240
- output_file = run_eval_v1.remote(limit)
241
- print(f"Actor v1 eval output saved in Modal Volume {VOLUME_NAME}: {output_file}")
242
-
243
-
244
- @app.local_entrypoint()
245
- def eval_merged_v0(limit: int | None = None) -> None:
246
- output_file = run_eval_merged_v0.remote(limit)
247
- print(f"Merged Actor v0 eval output saved in Modal Volume {VOLUME_NAME}: {output_file}")
248
-
249
-
250
- @app.function(
251
- image=image,
252
- gpu=os.getenv("MODAL_GPU", "A10"),
253
- timeout=60 * 60 * 2,
254
- volumes={"/vol": volume},
255
- secrets=hf_secrets,
256
- )
257
- def run_merge_v0() -> str:
258
- import subprocess
259
- import sys
260
-
261
- cmd = [
262
- sys.executable,
263
- str(REMOTE_WORKDIR / "finetune/scripts/merge_actor_lora.py"),
264
- "--base_model",
265
- os.getenv("MODEL_NAME", "openbmb/MiniCPM5-1B"),
266
- "--adapter_dir",
267
- str(REMOTE_OUTPUT_DIR),
268
- "--output_dir",
269
- str(REMOTE_MERGED_OUTPUT_DIR),
270
- ]
271
- subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True)
272
- volume.commit()
273
- return str(REMOTE_MERGED_OUTPUT_DIR)
274
-
275
-
276
- @app.local_entrypoint()
277
- def merge_v0() -> None:
278
- output_dir = run_merge_v0.remote()
279
- print(f"Merged Actor v0 model saved in Modal Volume {VOLUME_NAME}: {output_dir}")
280
-
281
-
282
- else:
283
-
284
- def smoke_test() -> None:
285
- _require_modal()
286
-
287
-
288
- def train_full() -> None:
289
- _require_modal()
290
-
291
-
292
- def train_v1() -> None:
293
- _require_modal()
294
-
295
-
296
- def eval_adapter(limit: int | None = None) -> None:
297
- _require_modal()
298
-
299
-
300
- def eval_adapter_v1(limit: int | None = None) -> None:
301
- _require_modal()
302
-
303
-
304
- def eval_merged_v0(limit: int | None = None) -> None:
305
- _require_modal()
306
-
307
-
308
- def merge_v0() -> None:
309
- _require_modal()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/model_cards/actor_gguf_README.md DELETED
@@ -1,116 +0,0 @@
1
- ---
2
- license: apache-2.0
3
- base_model: openbmb/MiniCPM5-1B
4
- library_name: gguf
5
- tags:
6
- - gguf
7
- - llama.cpp
8
- - MiniCPM
9
- - puppet-theater
10
- - text-generation
11
- ---
12
-
13
- # AI Puppet Theater MiniCPM5 Actor GGUF
14
-
15
- Q4_K_M GGUF export of the AI Puppet Theater Actor model.
16
-
17
- This model was fine-tuned from `openbmb/MiniCPM5-1B` using LoRA/QLoRA Actor SFT, merged back into the base model, converted to GGUF, and quantized for llama.cpp.
18
-
19
- It is designed to generate one short Actor JSON object for AI Puppet Theater. It is not a general assistant model.
20
-
21
- ## Related Artifacts
22
-
23
- - Dataset: https://huggingface.co/datasets/build-small-hackathon/AI-Puppet-Theater-Actor-SFT
24
- - LoRA adapter: https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA
25
- - Space: https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater
26
-
27
- ## Intended Use
28
-
29
- Generate one Actor response for a single AI Puppet Theater beat. The runtime should pass a premise, show state JSON, actor JSON, and director instruction, then validate the returned JSON before using it.
30
-
31
- Expected top-level schema:
32
-
33
- ```json
34
- {
35
- "intent": "react_to_event",
36
- "line": "A short puppet line tied to the scene.",
37
- "emotion": "curious",
38
- "gesture": "tilts head toward the stage lights",
39
- "stage_effect": "spotlight_glow",
40
- "memory_update": null,
41
- "tool_request": null
42
- }
43
- ```
44
-
45
- `tool_request` may be `null` or an object using one of the AI Puppet Theater tool schemas:
46
-
47
- - `inspect_prop`: `{"prop": "..."}`
48
- - `consult_stage_oracle`: `{"question": "..."}`
49
- - `change_lighting`: `{"mood": "..."}`
50
-
51
- ## Recommended llama.cpp Usage
52
-
53
- Use `llama-completion`, not interactive `llama-cli`, for one-shot Actor JSON generation. For the local build used during evaluation, `-no-cnv` was required to avoid conversation mode.
54
-
55
- Recommended settings:
56
-
57
- - binary: `llama-completion`
58
- - mode: `-no-cnv`
59
- - reasoning: `--reasoning off --reasoning-budget 0`
60
- - prompt format: `chatml`
61
- - temperature: `0`
62
-
63
- Example:
64
-
65
- ```bash
66
- llama-completion \
67
- -m minicpm5-actor-q4_k_m.gguf \
68
- -no-cnv \
69
- -n 160 \
70
- --temp 0 \
71
- --reasoning off \
72
- --reasoning-budget 0 \
73
- -p '<|im_start|>system
74
- You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable.
75
- <|im_end|>
76
- <|im_start|>user
77
- premise: A moon mayor denies stealing the town last spoon
78
- show_state JSON: {"story_phase":"complication","latest_prop":"silver spoon","finale_requested":false}
79
- actor JSON: {"name":"Mina Moonbutton","mood":"curious","tools":["inspect_prop"]}
80
- director_instruction: Inspect the latest prop and keep the line short.
81
-
82
- Return exactly one JSON object with exactly these keys: intent, line, emotion, gesture, stage_effect, memory_update, tool_request. Do not omit stage_effect.
83
- <|im_end|>
84
- <|im_start|>assistant
85
- '
86
- ```
87
-
88
- ## GGUF Eval Summary
89
-
90
- Full GGUF eval used `llama-completion` with `-no-cnv`, reasoning disabled, and `chatml` prompt formatting.
91
-
92
- - prompt format: `chatml`
93
- - total generations: 40
94
- - extracted JSON parse: 39/40, 97.5%
95
- - has required fields: 39/40, 97.5%
96
- - exact top-level schema: 39/40, 97.5%
97
- - sanitized Actor JSON usable: 39/40, 97.5%
98
- - strict `tool_request`: 35/40, 87.5%
99
- - sanitized `tool_request` usable: 39/40, 97.5%
100
- - line length pass: 39/40, 97.5%
101
- - interactive marker seen: 0/40, 0.0%
102
- - runtime error: 0/40, 0.0%
103
- - timeout: 0/40, 0.0%
104
- - `[Start thinking]` seen: 0/40, 0.0%
105
-
106
- ## Runtime Notes
107
-
108
- Use first-balanced-JSON extraction, Actor JSON schema validation, tool request sanitization, and deterministic fallback in the application runtime. This is important because local LLM outputs can include extra text, malformed tool arguments, or occasional missing fields.
109
-
110
- ## Caveats
111
-
112
- - Demo/hackathon fine-tune.
113
- - Synthetic Actor SFT data; not broad creative-writing training.
114
- - Actor-only behavior; Director planning is handled by the app.
115
- - Not a general chat or instruction model.
116
- - Validate all tool calls before execution.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/model_cards/actor_lora_v0_README.md DELETED
@@ -1,119 +0,0 @@
1
- ---
2
- license: other
3
- base_model: openbmb/MiniCPM5-1B
4
- library_name: peft
5
- tags:
6
- - lora
7
- - qlora
8
- - json
9
- - agents
10
- - synthetic-data
11
- - hackathon
12
- ---
13
-
14
- # AI Puppet Theater MiniCPM5 Actor LoRA
15
-
16
- LoRA/QLoRA adapter for `openbmb/MiniCPM5-1B`, fine-tuned to act as an **Actor agent** for AI Puppet Theater.
17
-
18
- The adapter is trained to produce one short, theatrical, speakable Actor JSON object for a single puppet-show beat. It is not a general chat model.
19
-
20
- ## Base Model
21
-
22
- - Base model: `openbmb/MiniCPM5-1B`
23
- - Method: LoRA/QLoRA supervised fine-tuning
24
- - Dataset: AI Puppet Theater Actor SFT synthetic dataset
25
- - Current best candidate: v0 adapter
26
-
27
- ## Intended Output Schema
28
-
29
- ```json
30
- {
31
- "intent": "inspect_prop",
32
- "line": "This rubber duck squeaks exactly like a guilty witness.",
33
- "emotion": "investigative",
34
- "gesture": "leans toward the glowing prop",
35
- "stage_effect": "prop_table_glow",
36
- "memory_update": "Noted that the rubber duck behaved like evidence.",
37
- "tool_request": {
38
- "tool": "inspect_prop",
39
- "args": {"prop": "rubber duck"},
40
- "reason": "The prop may reveal a stage clue."
41
- }
42
- }
43
- ```
44
-
45
- `tool_request` may be `null`. Current AI Puppet Theater runtime tool schemas:
46
-
47
- - `inspect_prop`: `{"prop": "..."}`
48
- - `consult_stage_oracle`: `{"question": "..."}`
49
- - `change_lighting`: `{"mood": "..."}`
50
-
51
- ## Eval Summary
52
-
53
- The v0 adapter is the current best candidate for the hackathon demo. The v1 hardening experiment added useful audit/eval tooling and corrected schema alignment, but did not outperform v0 on full eval at the time this card was written.
54
-
55
- Runtime should use JSON extraction, strict validation, sanitized Actor JSON, tool-argument normalization, and deterministic fallback.
56
-
57
- Recent v1 smoke eval after schema metric split showed why sanitizer support matters:
58
-
59
- - raw clean JSON: 9/10
60
- - extracted JSON parse: 10/10
61
- - has required fields: 9/10
62
- - exact top-level schema: 6/10
63
- - sanitized actor JSON usable: 9/10
64
-
65
- ## Example Prompt Shape
66
-
67
- ```text
68
- premise: A moon mayor denies stealing the town's last spoon
69
- show_state JSON: {"story_phase":"complication","latest_prop":"silver spoon",...}
70
- actor JSON: {"name":"Mina Moonbutton","goal":"Find the emotional truth...",...}
71
- director_instruction: Use the latest prop as evidence and request inspection if useful.
72
- ```
73
-
74
- Expected assistant response:
75
-
76
- ```json
77
- {"intent":"inspect_prop","line":"This silver spoon squeaks exactly like a guilty witness.","emotion":"investigative","gesture":"leans toward the glowing prop","stage_effect":"prop_table_glow","memory_update":"Noted that the silver spoon behaved like evidence.","tool_request":{"tool":"inspect_prop","args":{"prop":"silver spoon"},"reason":"The prop may reveal one concrete stage clue."}}
78
- ```
79
-
80
- ## Usage Example
81
-
82
- ```python
83
- from peft import PeftModel
84
- from transformers import AutoModelForCausalLM, AutoTokenizer
85
-
86
- base_model = "openbmb/MiniCPM5-1B"
87
- adapter_id = "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA"
88
-
89
- tokenizer = AutoTokenizer.from_pretrained(adapter_id, trust_remote_code=True)
90
- base = AutoModelForCausalLM.from_pretrained(base_model, device_map="auto", trust_remote_code=True)
91
- model = PeftModel.from_pretrained(base, adapter_id)
92
-
93
- messages = [
94
- {"role": "system", "content": "You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."},
95
- {"role": "user", "content": "premise: ...\nshow_state JSON: {...}\nactor JSON: {...}\ndirector_instruction: ..."},
96
- ]
97
- prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
98
- inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
99
- outputs = model.generate(**inputs, max_new_tokens=192, do_sample=False)
100
- print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True))
101
- ```
102
-
103
- ## Limitations
104
-
105
- - Synthetic dataset; not a broad creative-writing model.
106
- - Actor-only behavior; Director planning is handled elsewhere.
107
- - Can emit extra top-level fields or copied state fields; use runtime sanitizer and fallback.
108
- - Tool calls must be validated before execution.
109
- - Demo-safe filters are simple and do not replace product-level safety review.
110
-
111
- ## Related Artifacts
112
-
113
- - Space: https://huggingface.co/spaces/build-small-hackathon/AI-Puppet-Theater
114
- - Dataset repo placeholder: https://huggingface.co/datasets/build-small-hackathon/AI-Puppet-Theater-Actor-SFT
115
- - Model repo placeholder: https://huggingface.co/build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA
116
-
117
- ## License
118
-
119
- TODO: confirm the final project, base model, and adapter license before publishing.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/requirements-train.txt DELETED
@@ -1,11 +0,0 @@
1
- accelerate>=0.34.0
2
- bitsandbytes>=0.43.3
3
- datasets>=2.20.0
4
- huggingface-hub>=0.24.0
5
- modal>=0.64.0
6
- peft>=0.12.0
7
- safetensors>=0.4.4
8
- sentencepiece>=0.2.0
9
- torch>=2.4.0
10
- transformers>=4.44.0
11
- trl>=0.10.1
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/audit_actor_sft.py DELETED
@@ -1,334 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Strict local audit for Actor SFT assistant JSON completions."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- from collections import Counter
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- DEFAULT_PATH = Path("finetune/data/actor_sft_v1.jsonl")
14
- REQUIRED_FIELDS = [
15
- "intent",
16
- "line",
17
- "emotion",
18
- "gesture",
19
- "stage_effect",
20
- "memory_update",
21
- "tool_request",
22
- ]
23
- REQUIRED_FIELD_SET = set(REQUIRED_FIELDS)
24
- FORBIDDEN_TOP_LEVEL_FIELDS = {
25
- "memory_record",
26
- "memory_effect",
27
- "recent_transcript",
28
- "show_state",
29
- "held_props",
30
- "mood",
31
- "name",
32
- "latest_prop",
33
- "latest_audience_action",
34
- "tool_results",
35
- "status",
36
- "result",
37
- "notes",
38
- "current_show_phase",
39
- }
40
- ALLOWED_TOOLS = {"inspect_prop", "consult_stage_oracle", "change_lighting"}
41
- SUMMARY_ISSUES = [
42
- "assistant_invalid_json",
43
- "assistant_non_object_json",
44
- "assistant_json_does_not_start_with_object",
45
- "assistant_json_does_not_end_with_object",
46
- "assistant_contains_markdown",
47
- "assistant_continuation_detected",
48
- "assistant_extra_whitespace_before_or_after_json",
49
- "missing_required_fields",
50
- "extra_top_level_fields",
51
- "duplicate_top_level_keys",
52
- "forbidden_top_level_fields",
53
- "line_missing_or_empty",
54
- "tool_request_keys_not_exact",
55
- "tool_request_not_object_or_null",
56
- "tool_request_invalid_tool",
57
- "tool_request_args_not_object",
58
- "tool_request_args_not_exact_for_inspect_prop",
59
- "tool_request_args_not_exact_for_consult_stage_oracle",
60
- "tool_request_args_not_exact_for_change_lighting",
61
- "tool_request_reason_missing_or_empty",
62
- "finale_output_outside_finale_context",
63
- "finale_context_unavailable",
64
- ]
65
-
66
- # Match the current app runtime schema in puppet_theater/tools.py.
67
- TOOL_ARGS = {
68
- "inspect_prop": {"prop"},
69
- "consult_stage_oracle": {"question"},
70
- "change_lighting": {"mood"},
71
- }
72
-
73
-
74
- def main() -> None:
75
- parser = argparse.ArgumentParser(description=__doc__)
76
- parser.add_argument("path", nargs="?", type=Path, default=DEFAULT_PATH)
77
- parser.add_argument("--max-examples", type=int, default=20)
78
- args = parser.parse_args()
79
-
80
- stats = audit_file(args.path, args.max_examples)
81
- print_summary(args.path, stats)
82
- if stats["failure_rows"]:
83
- raise SystemExit(1)
84
-
85
-
86
- def audit_file(path: Path, max_examples: int) -> dict[str, Any]:
87
- stats: dict[str, Any] = {
88
- "total_rows": 0,
89
- "failure_rows": 0,
90
- "issue_counts": Counter(),
91
- "row_type_counts": Counter(),
92
- "tool_counts": Counter(),
93
- "examples": [],
94
- }
95
- with path.open("r", encoding="utf-8") as handle:
96
- for line_number, line in enumerate(handle, start=1):
97
- if not line.strip():
98
- continue
99
- stats["total_rows"] += 1
100
- row_errors: list[str] = []
101
- try:
102
- row = json.loads(line)
103
- except json.JSONDecodeError as exc:
104
- row_errors.append(f"row_invalid_json: {exc}")
105
- record_row_errors(stats, max_examples, line_number, None, None, row_errors)
106
- continue
107
- row_id = row.get("id") if isinstance(row, dict) else None
108
- row_type = row.get("row_type") if isinstance(row, dict) else None
109
- if isinstance(row_type, str):
110
- stats["row_type_counts"][row_type] += 1
111
- assistant_content = extract_assistant_content(row, row_errors)
112
- show_state = extract_show_state(row)
113
- if assistant_content is None:
114
- record_row_errors(stats, max_examples, line_number, row_id, row_type, row_errors)
115
- continue
116
-
117
- row_errors.extend(validate_exact_json_text(assistant_content))
118
- parsed, duplicates, parse_error = parse_json_object_with_duplicate_keys(assistant_content)
119
- if parse_error is not None:
120
- row_errors.append(f"assistant_invalid_json: {parse_error}")
121
- elif not isinstance(parsed, dict):
122
- row_errors.append("assistant_non_object_json")
123
- else:
124
- row_errors.extend(validate_assistant_object(parsed, duplicates, show_state))
125
- tool_request = parsed.get("tool_request")
126
- if isinstance(tool_request, dict):
127
- stats["tool_counts"][tool_request.get("tool", "invalid")] += 1
128
- elif tool_request is None:
129
- stats["tool_counts"]["none"] += 1
130
- else:
131
- stats["tool_counts"]["invalid"] += 1
132
- record_row_errors(stats, max_examples, line_number, row_id, row_type, row_errors)
133
- return stats
134
-
135
-
136
- def extract_assistant_content(row: Any, errors: list[str]) -> str | None:
137
- if not isinstance(row, dict):
138
- errors.append("row_non_object")
139
- return None
140
- messages = row.get("messages")
141
- if not isinstance(messages, list) or len(messages) < 3:
142
- errors.append("messages_missing_assistant")
143
- return None
144
- assistant_message = messages[2]
145
- if not isinstance(assistant_message, dict) or assistant_message.get("role") != "assistant":
146
- errors.append("assistant_message_invalid")
147
- return None
148
- content = assistant_message.get("content")
149
- if not isinstance(content, str):
150
- errors.append("assistant_content_not_string")
151
- return None
152
- return content
153
-
154
-
155
- def validate_exact_json_text(content: str) -> list[str]:
156
- errors: list[str] = []
157
- stripped = content.strip()
158
- if content != stripped:
159
- errors.append("assistant_extra_whitespace_before_or_after_json")
160
- if not stripped.startswith("{"):
161
- errors.append("assistant_json_does_not_start_with_object")
162
- if not stripped.endswith("}"):
163
- errors.append("assistant_json_does_not_end_with_object")
164
- if "```" in stripped or stripped.startswith("`"):
165
- errors.append("assistant_contains_markdown")
166
- lowered = stripped.lower()
167
- if "\nassistant" in lowered or "### assistant" in lowered or "<|assistant" in lowered:
168
- errors.append("assistant_continuation_detected")
169
- return errors
170
-
171
-
172
- def parse_json_object_with_duplicate_keys(content: str) -> tuple[Any, list[str], str | None]:
173
- duplicate_keys: list[str] = []
174
-
175
- def hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
176
- seen: set[str] = set()
177
- for key, _value in pairs:
178
- if key in seen and key not in duplicate_keys:
179
- duplicate_keys.append(key)
180
- seen.add(key)
181
- return dict(pairs)
182
-
183
- try:
184
- return json.loads(content, object_pairs_hook=hook), duplicate_keys, None
185
- except json.JSONDecodeError as exc:
186
- return None, duplicate_keys, str(exc)
187
-
188
-
189
- def validate_assistant_object(value: dict[str, Any], duplicate_keys: list[str], show_state: dict[str, Any] | None) -> list[str]:
190
- errors: list[str] = []
191
- keys = set(value)
192
- missing = sorted(REQUIRED_FIELD_SET - keys)
193
- extra = sorted(keys - REQUIRED_FIELD_SET)
194
- forbidden = sorted(FORBIDDEN_TOP_LEVEL_FIELDS & keys)
195
- if missing:
196
- errors.append(f"missing_required_fields={missing}")
197
- if extra:
198
- errors.append(f"extra_top_level_fields={extra}")
199
- if duplicate_keys:
200
- errors.append(f"duplicate_top_level_keys={sorted(duplicate_keys)}")
201
- if forbidden:
202
- errors.append(f"forbidden_top_level_fields={forbidden}")
203
- if "line" in value and (not isinstance(value["line"], str) or not value["line"].strip()):
204
- errors.append("line_missing_or_empty")
205
- errors.extend(validate_tool_request(value.get("tool_request")))
206
- if value.get("intent") == "deliver_finale" or value.get("stage_effect") == "final_bow_lights":
207
- errors.extend(validate_finale_context(show_state))
208
- return errors
209
-
210
-
211
- def validate_tool_request(value: Any) -> list[str]:
212
- if value is None:
213
- return []
214
- if not isinstance(value, dict):
215
- return ["tool_request_not_object_or_null"]
216
- errors: list[str] = []
217
- keys = set(value)
218
- if keys != {"tool", "args", "reason"}:
219
- errors.append(f"tool_request_keys_not_exact={sorted(keys)}")
220
- tool = value.get("tool")
221
- if tool not in ALLOWED_TOOLS:
222
- errors.append(f"tool_request_invalid_tool={tool!r}")
223
- return errors
224
- args = value.get("args")
225
- if not isinstance(args, dict):
226
- errors.append("tool_request_args_not_object")
227
- return errors
228
- expected_args = TOOL_ARGS[tool]
229
- if set(args) != expected_args:
230
- errors.append(f"tool_request_args_not_exact_for_{tool}: expected={sorted(expected_args)} got={sorted(args)}")
231
- for key, arg_value in args.items():
232
- if not isinstance(arg_value, str) or not arg_value.strip():
233
- errors.append(f"tool_request_arg_invalid={key}")
234
- reason = value.get("reason")
235
- if not isinstance(reason, str) or not reason.strip():
236
- errors.append("tool_request_reason_missing_or_empty")
237
- return errors
238
-
239
-
240
- def validate_finale_context(show_state: dict[str, Any] | None) -> list[str]:
241
- if show_state is None:
242
- return ["finale_context_unavailable"]
243
- if show_state.get("story_phase") == "finale" or show_state.get("finale_requested") is True:
244
- return []
245
- return ["finale_output_outside_finale_context"]
246
-
247
-
248
- def extract_show_state(row: Any) -> dict[str, Any] | None:
249
- if not isinstance(row, dict):
250
- return None
251
- messages = row.get("messages")
252
- if not isinstance(messages, list) or len(messages) < 2 or not isinstance(messages[1], dict):
253
- return None
254
- content = messages[1].get("content")
255
- if not isinstance(content, str):
256
- return None
257
- marker = "show_state JSON:"
258
- next_marker = "\nactor JSON:"
259
- if marker not in content:
260
- return None
261
- start = content.index(marker) + len(marker)
262
- end = content.find(next_marker, start)
263
- raw_json = content[start:end if end != -1 else None].strip()
264
- try:
265
- value = json.loads(raw_json)
266
- except json.JSONDecodeError:
267
- return None
268
- return value if isinstance(value, dict) else None
269
-
270
-
271
- def record_row_errors(
272
- stats: dict[str, Any],
273
- max_examples: int,
274
- line_number: int,
275
- row_id: Any,
276
- row_type: Any,
277
- errors: list[str],
278
- ) -> None:
279
- if not errors:
280
- return
281
- stats["failure_rows"] += 1
282
- for error in errors:
283
- stats["issue_counts"][issue_name(error)] += 1
284
- if len(stats["examples"]) < max_examples:
285
- stats["examples"].append(
286
- {
287
- "line_number": line_number,
288
- "id": row_id,
289
- "row_type": row_type,
290
- "errors": errors,
291
- }
292
- )
293
-
294
-
295
- def issue_name(error: str) -> str:
296
- return error.split("=", 1)[0].split(":", 1)[0]
297
-
298
-
299
- def print_summary(path: Path, stats: dict[str, Any]) -> None:
300
- print(f"file: {path}")
301
- print(f"total rows: {stats['total_rows']}")
302
- print(f"strict failure rows: {stats['failure_rows']}")
303
- print_distribution("row_type distribution", stats["row_type_counts"])
304
- print_distribution("tool_request distribution", stats["tool_counts"])
305
- print_issue_distribution(stats["issue_counts"])
306
- if stats["examples"]:
307
- print("examples:")
308
- for example in stats["examples"]:
309
- print(
310
- f"- line {example['line_number']} id={example['id']} "
311
- f"row_type={example['row_type']}: {'; '.join(example['errors'])}"
312
- )
313
-
314
-
315
- def print_distribution(title: str, values: Counter) -> None:
316
- print(f"{title}:")
317
- if not values:
318
- print(" none")
319
- return
320
- for key, count in sorted(values.items()):
321
- print(f" {key}: {count}")
322
-
323
-
324
- def print_issue_distribution(values: Counter) -> None:
325
- print("issue distribution:")
326
- for key in SUMMARY_ISSUES:
327
- print(f" {key}: {values.get(key, 0)}")
328
- extra_keys = sorted(key for key in values if key not in SUMMARY_ISSUES)
329
- for key in extra_keys:
330
- print(f" {key}: {values[key]}")
331
-
332
-
333
- if __name__ == "__main__":
334
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/convert_actor_merged_to_gguf.sh DELETED
@@ -1,120 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- ACTION="${1:-all}"
5
-
6
- LLAMA_CPP_DIR="${LLAMA_CPP_DIR:-../llama.cpp}"
7
- MERGED_MODEL_DIR="${MERGED_MODEL_DIR:-finetune/outputs/minicpm5-actor-merged}"
8
- GGUF_OUT_DIR="${GGUF_OUT_DIR:-finetune/outputs/gguf}"
9
- OUTTYPE="${OUTTYPE:-f16}"
10
- QUANT_TYPE="${QUANT_TYPE:-Q4_K_M}"
11
- PYTHON_BIN="${PYTHON_BIN:-python}"
12
-
13
- F16_GGUF="${GGUF_OUT_DIR}/minicpm5-actor-${OUTTYPE}.gguf"
14
- QUANT_GGUF="${GGUF_OUT_DIR}/minicpm5-actor-$(echo "${QUANT_TYPE}" | tr '[:upper:]' '[:lower:]').gguf"
15
- CONVERT_SCRIPT="${LLAMA_CPP_DIR}/convert_hf_to_gguf.py"
16
-
17
- usage() {
18
- cat <<EOF
19
- Usage: $0 [convert|quantize|all]
20
-
21
- Environment overrides:
22
- LLAMA_CPP_DIR Path to llama.cpp checkout. Default: ../llama.cpp
23
- MERGED_MODEL_DIR Merged HF model dir. Default: finetune/outputs/minicpm5-actor-merged
24
- GGUF_OUT_DIR GGUF output dir. Default: finetune/outputs/gguf
25
- OUTTYPE convert_hf_to_gguf.py outtype. Default: f16
26
- QUANT_TYPE llama.cpp quantization type. Default: Q4_K_M
27
- PYTHON_BIN Python command for conversion. Default: python
28
-
29
- Outputs:
30
- ${F16_GGUF}
31
- ${QUANT_GGUF}
32
- EOF
33
- }
34
-
35
- find_quantize_bin() {
36
- if [[ -x "${LLAMA_CPP_DIR}/build/bin/llama-quantize" ]]; then
37
- printf '%s\n' "${LLAMA_CPP_DIR}/build/bin/llama-quantize"
38
- return 0
39
- fi
40
- if [[ -x "${LLAMA_CPP_DIR}/llama-quantize" ]]; then
41
- printf '%s\n' "${LLAMA_CPP_DIR}/llama-quantize"
42
- return 0
43
- fi
44
- if command -v llama-quantize >/dev/null 2>&1; then
45
- command -v llama-quantize
46
- return 0
47
- fi
48
- return 1
49
- }
50
-
51
- require_merged_model() {
52
- if [[ ! -d "${MERGED_MODEL_DIR}" ]]; then
53
- echo "error: merged model directory not found: ${MERGED_MODEL_DIR}" >&2
54
- echo "Run merge first, or set MERGED_MODEL_DIR=/path/to/merged/model." >&2
55
- exit 1
56
- fi
57
- }
58
-
59
- require_converter() {
60
- if [[ ! -f "${CONVERT_SCRIPT}" ]]; then
61
- echo "error: llama.cpp converter not found: ${CONVERT_SCRIPT}" >&2
62
- echo "Set LLAMA_CPP_DIR=/path/to/llama.cpp or install/build llama.cpp first." >&2
63
- exit 1
64
- fi
65
- }
66
-
67
- convert_model() {
68
- require_merged_model
69
- require_converter
70
- mkdir -p "${GGUF_OUT_DIR}"
71
- echo "Converting merged model to GGUF:"
72
- echo " input: ${MERGED_MODEL_DIR}"
73
- echo " output: ${F16_GGUF}"
74
- echo " python: ${PYTHON_BIN}"
75
- read -r -a python_cmd <<< "${PYTHON_BIN}"
76
- "${python_cmd[@]}" "${CONVERT_SCRIPT}" "${MERGED_MODEL_DIR}" \
77
- --outfile "${F16_GGUF}" \
78
- --outtype "${OUTTYPE}"
79
- }
80
-
81
- quantize_model() {
82
- local quantize_bin
83
- quantize_bin="$(find_quantize_bin)" || {
84
- echo "error: llama-quantize not found." >&2
85
- echo "Build llama.cpp, or set LLAMA_CPP_DIR=/path/to/llama.cpp." >&2
86
- exit 1
87
- }
88
- if [[ ! -f "${F16_GGUF}" ]]; then
89
- echo "error: source GGUF not found: ${F16_GGUF}" >&2
90
- echo "Run '$0 convert' first, or set OUTTYPE/GGUF_OUT_DIR to match your file." >&2
91
- exit 1
92
- fi
93
- mkdir -p "${GGUF_OUT_DIR}"
94
- echo "Quantizing GGUF:"
95
- echo " input: ${F16_GGUF}"
96
- echo " output: ${QUANT_GGUF}"
97
- echo " type: ${QUANT_TYPE}"
98
- "${quantize_bin}" "${F16_GGUF}" "${QUANT_GGUF}" "${QUANT_TYPE}"
99
- }
100
-
101
- case "${ACTION}" in
102
- convert)
103
- convert_model
104
- ;;
105
- quantize)
106
- quantize_model
107
- ;;
108
- all)
109
- convert_model
110
- quantize_model
111
- ;;
112
- -h|--help|help)
113
- usage
114
- ;;
115
- *)
116
- echo "error: unknown action: ${ACTION}" >&2
117
- usage >&2
118
- exit 2
119
- ;;
120
- esac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/eval_actor_gguf.py DELETED
@@ -1,487 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Evaluate an Actor GGUF model through llama.cpp prompt formats."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import re
9
- import shutil
10
- import subprocess
11
- import sys
12
- from pathlib import Path
13
- from typing import Any
14
-
15
- from eval_minicpm5_actor_lora import load_eval_rows, validate_generation
16
-
17
-
18
- DEFAULT_MODEL = Path("finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf")
19
- DEFAULT_LLAMA_BIN = Path("../llama.cpp/build/bin/llama-completion")
20
- DEFAULT_EVAL_FILE = Path("finetune/data_samples/actor_eval_prompts.jsonl")
21
- DEFAULT_OUTPUT_FILE = Path("finetune/eval_outputs/minicpm5_actor_gguf_eval.jsonl")
22
- PROMPT_FORMATS = ["raw", "simple_json", "chatml", "system_user_assistant"]
23
- STOP_STRINGS = [
24
- "\nUSER:",
25
- "\nSYSTEM:",
26
- "\nASSISTANT:",
27
- "\nJSON:",
28
- "[Start thinking]",
29
- "</s>",
30
- ]
31
- LOG_LINE_RE = re.compile(
32
- r"^\s*(?:\d+\.\d+\.\d+\s+[A-Z]\s+)?(?:llama_|ggml_|common_|sampling_|sampler|main:|system_info:|perf:)"
33
- )
34
- METRIC_KEYS = [
35
- "raw_clean_json",
36
- "extracted_json_parse",
37
- "missing_required_fields_success",
38
- "exact_top_level_schema_success",
39
- "extra_top_level_fields",
40
- "forbidden_top_level_fields",
41
- "sanitized_actor_json_usable",
42
- "strict_tool_request",
43
- "sanitized_tool_request_usable",
44
- "line_length_pass",
45
- "interactive_marker_seen",
46
- "runtime_error",
47
- "timeout",
48
- ]
49
- METRIC_LABELS = {
50
- "raw_clean_json": "raw clean JSON",
51
- "extracted_json_parse": "extracted JSON parse",
52
- "missing_required_fields_success": "has required fields",
53
- "exact_top_level_schema_success": "exact top-level schema",
54
- "extra_top_level_fields": "extra top-level fields",
55
- "forbidden_top_level_fields": "forbidden top-level fields",
56
- "sanitized_actor_json_usable": "sanitized actor JSON usable",
57
- "strict_tool_request": "strict tool_request",
58
- "sanitized_tool_request_usable": "sanitized tool_request usable",
59
- "line_length_pass": "line length pass",
60
- "interactive_marker_seen": "interactive marker seen",
61
- "runtime_error": "runtime error",
62
- "timeout": "timeout",
63
- }
64
- SCHEMA_REMINDER = (
65
- "Return exactly one JSON object with exactly these keys: "
66
- "intent, line, emotion, gesture, stage_effect, memory_update, tool_request. "
67
- "Do not omit stage_effect. Do not include markdown, commentary, copied input fields, or another assistant turn. "
68
- "Stop after the JSON object."
69
- )
70
- JSON_SCHEMA_EXAMPLE = (
71
- '{"intent":"react_to_event","line":"A short puppet line tied to the scene.",'
72
- '"emotion":"curious","gesture":"tilts head toward the stage lights",'
73
- '"stage_effect":"spotlight_glow","memory_update":null,"tool_request":null}'
74
- )
75
- INTERACTIVE_MARKERS = [
76
- "interactive mode on",
77
- "available commands:",
78
- "chat template is available, enabling conversation mode",
79
- "\n>",
80
- "please use llama-completion instead",
81
- "[Start thinking]",
82
- ]
83
-
84
-
85
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
86
- parser = argparse.ArgumentParser(description=__doc__)
87
- parser.add_argument("--model", type=Path, default=DEFAULT_MODEL)
88
- parser.add_argument(
89
- "--llama_bin",
90
- "--llama_cli",
91
- dest="llama_bin",
92
- type=Path,
93
- default=DEFAULT_LLAMA_BIN,
94
- help="Path to llama.cpp completion binary. Defaults to llama-completion; --llama_cli is a backward-compatible alias.",
95
- )
96
- parser.add_argument("--eval_file", type=Path, default=DEFAULT_EVAL_FILE)
97
- parser.add_argument("--output_file", type=Path, default=DEFAULT_OUTPUT_FILE)
98
- parser.add_argument("--limit", type=int, default=None)
99
- parser.add_argument("--n_predict", type=int, default=160)
100
- parser.add_argument("--temperature", type=float, default=0.0)
101
- parser.add_argument("--top_p", type=float, default=0.9)
102
- parser.add_argument(
103
- "--prompt_format",
104
- choices=["auto", *PROMPT_FORMATS],
105
- default="raw",
106
- help="Use one prompt format, or auto to run every supported format.",
107
- )
108
- parser.add_argument(
109
- "--timeout_seconds",
110
- type=int,
111
- default=120,
112
- help="Per-prompt llama.cpp timeout.",
113
- )
114
- parser.add_argument(
115
- "--no_stop_strings",
116
- action="store_true",
117
- help="Skip best-effort llama.cpp reverse-prompt stop strings.",
118
- )
119
- return parser.parse_args(argv)
120
-
121
-
122
- def main(argv: list[str] | None = None) -> None:
123
- args = parse_args(argv)
124
- run_eval(args)
125
-
126
-
127
- def run_eval(args: argparse.Namespace) -> None:
128
- model_path = args.model.expanduser()
129
- if not model_path.exists():
130
- raise SystemExit(
131
- f"GGUF model not found: {model_path}\n"
132
- "Create it first with finetune/scripts/convert_actor_merged_to_gguf.sh."
133
- )
134
- if not model_path.is_file():
135
- raise SystemExit(f"GGUF model path is not a file: {model_path}")
136
- llama_bin = resolve_llama_bin(args.llama_bin)
137
- if not args.eval_file.exists():
138
- raise SystemExit(f"Eval prompt file not found: {args.eval_file}")
139
- binary_help = read_binary_help(llama_bin)
140
- binary_capabilities = detect_binary_capabilities(binary_help)
141
- if llama_bin.name == "llama-cli":
142
- print(
143
- "warning: llama-cli may enter interactive mode for this llama.cpp build; "
144
- "prefer llama-completion for one-shot GGUF eval.",
145
- file=sys.stderr,
146
- )
147
-
148
- rows = load_eval_rows(args.eval_file, args.limit)
149
- formats = PROMPT_FORMATS if args.prompt_format == "auto" else [args.prompt_format]
150
- args.output_file.parent.mkdir(parents=True, exist_ok=True)
151
-
152
- counts_by_format = {prompt_format: new_metric_counts() for prompt_format in formats}
153
- totals_by_format = {prompt_format: 0 for prompt_format in formats}
154
-
155
- with args.output_file.open("w", encoding="utf-8") as handle:
156
- for row_index, row in enumerate(rows, start=1):
157
- for prompt_format in formats:
158
- row_id = row.get("id", f"eval-{row_index:03d}")
159
- print(f"evaluating prompt_format={prompt_format} row_id={row_id}", flush=True)
160
- prompt = build_prompt(row["messages"], prompt_format)
161
- raw_output, command_info = run_llama_completion(
162
- llama_bin=llama_bin,
163
- model_path=model_path,
164
- prompt=prompt,
165
- n_predict=args.n_predict,
166
- temperature=args.temperature,
167
- top_p=args.top_p,
168
- use_stop_strings=not args.no_stop_strings,
169
- timeout_seconds=args.timeout_seconds,
170
- capabilities=binary_capabilities,
171
- )
172
- validation = validate_generation(raw_output)
173
- validation = apply_gguf_empty_tool_list_sanitization(validation)
174
- validation["start_thinking_seen"] = "[Start thinking]" in raw_output
175
- validation["interactive_marker_seen"] = has_interactive_marker(raw_output) or has_interactive_marker(command_info["stderr"])
176
- validation["runtime_error"] = bool(command_info["runtime_error"])
177
- validation["timeout"] = bool(command_info["timeout"])
178
- for metric in METRIC_KEYS:
179
- counts_by_format[prompt_format][metric] += int(validation[metric])
180
- counts_by_format[prompt_format]["start_thinking_seen"] += int(validation["start_thinking_seen"])
181
- if validation["interactive_marker_seen"]:
182
- print(
183
- f"warning: interactive marker seen for row {row.get('id', row_index)} "
184
- f"with prompt_format={prompt_format}",
185
- file=sys.stderr,
186
- )
187
- if validation["runtime_error"]:
188
- print(
189
- f"warning: llama.cpp runtime error for row {row_id} "
190
- f"with prompt_format={prompt_format}: {command_info['runtime_error']}",
191
- file=sys.stderr,
192
- flush=True,
193
- )
194
- totals_by_format[prompt_format] += 1
195
-
196
- detail = {
197
- "id": row_id,
198
- "row_type": row.get("row_type"),
199
- "prompt_format": prompt_format,
200
- "prompt": prompt,
201
- "raw_output": raw_output,
202
- "raw_stdout": command_info["stdout"],
203
- "raw_stderr": command_info["stderr"],
204
- "json_text": validation["json_text"],
205
- "parsed_json": validation["parsed_json"],
206
- "sanitized_actor_json": validation["sanitized_actor_json"],
207
- "validation": {
208
- key: value
209
- for key, value in validation.items()
210
- if key
211
- not in {
212
- "parsed_json",
213
- "sanitized_actor_json",
214
- "json_text",
215
- "original_tool_request",
216
- "sanitized_tool_request",
217
- }
218
- },
219
- "original_tool_request": validation["original_tool_request"],
220
- "sanitized_tool_request": validation["sanitized_tool_request"],
221
- "llama_cpp": command_info,
222
- }
223
- handle.write(json.dumps(detail, ensure_ascii=True, separators=(",", ":")) + "\n")
224
-
225
- for prompt_format in formats:
226
- print_format_summary(prompt_format, totals_by_format[prompt_format], counts_by_format[prompt_format])
227
- print(f"wrote detailed generations to: {args.output_file}")
228
-
229
-
230
- def resolve_llama_bin(raw_path: Path) -> Path:
231
- expanded = raw_path.expanduser()
232
- if expanded.exists():
233
- if expanded.is_file():
234
- return expanded
235
- raise SystemExit(f"llama.cpp binary path is not a file: {expanded}")
236
- found = shutil.which(str(raw_path))
237
- if found:
238
- return Path(found)
239
- raise SystemExit(
240
- f"llama.cpp binary not found: {raw_path}\n"
241
- "Pass --llama_bin /absolute/path/to/llama.cpp/build/bin/llama-completion."
242
- )
243
-
244
-
245
- def build_prompt(messages: list[dict[str, str]], prompt_format: str) -> str:
246
- system, user = split_system_user(messages)
247
- if prompt_format == "raw":
248
- return f"{system.strip()}\n\n{user.strip()}\n\n{SCHEMA_REMINDER}\nRequired shape:\n{JSON_SCHEMA_EXAMPLE}\n\nAssistant JSON:\n"
249
- if prompt_format == "simple_json":
250
- return (
251
- f"System: {system.strip()}\n\n"
252
- f"User:\n{user.strip()}\n\n"
253
- f"Output contract: {SCHEMA_REMINDER}\n\n"
254
- f"Required JSON shape:\n{JSON_SCHEMA_EXAMPLE}\n\n"
255
- "JSON:\n"
256
- )
257
- if prompt_format == "chatml":
258
- return (
259
- f"<|im_start|>system\n{system.strip()}\n<|im_end|>\n"
260
- f"<|im_start|>user\n{user.strip()}\n\n{SCHEMA_REMINDER}\nRequired JSON shape:\n{JSON_SCHEMA_EXAMPLE}\n<|im_end|>\n"
261
- "<|im_start|>assistant\n"
262
- )
263
- if prompt_format == "system_user_assistant":
264
- return (
265
- f"### SYSTEM\n{system.strip()}\n\n"
266
- f"### USER\n{user.strip()}\n\n{SCHEMA_REMINDER}\nRequired JSON shape:\n{JSON_SCHEMA_EXAMPLE}\n\n"
267
- "### ASSISTANT\n"
268
- )
269
- raise ValueError(f"Unsupported prompt format: {prompt_format}")
270
-
271
-
272
- def split_system_user(messages: list[dict[str, str]]) -> tuple[str, str]:
273
- system_parts = [message["content"] for message in messages if message.get("role") == "system"]
274
- user_parts = [message["content"] for message in messages if message.get("role") == "user"]
275
- system = "\n\n".join(system_parts)
276
- user = "\n\n".join(user_parts)
277
- if not system:
278
- system = "You are an Actor agent in AI Puppet Theater. Return only one valid JSON object."
279
- if not user:
280
- raise ValueError("Eval row is missing a user message")
281
- return system, user
282
-
283
-
284
- def run_llama_completion(
285
- *,
286
- llama_bin: Path,
287
- model_path: Path,
288
- prompt: str,
289
- n_predict: int,
290
- temperature: float,
291
- top_p: float,
292
- use_stop_strings: bool,
293
- timeout_seconds: int,
294
- capabilities: dict[str, bool],
295
- ) -> tuple[str, dict[str, Any]]:
296
- base_command = [
297
- str(llama_bin),
298
- "-m",
299
- str(model_path),
300
- "-p",
301
- prompt,
302
- "-n",
303
- str(n_predict),
304
- "--temp",
305
- str(temperature),
306
- "--top-p",
307
- str(top_p),
308
- ]
309
- optional_args: list[str] = []
310
- if capabilities["no_conversation_short"]:
311
- optional_args.append("-no-cnv")
312
- elif capabilities["no_conversation_long"]:
313
- optional_args.append("--no-conversation")
314
- if capabilities["no_display_prompt"]:
315
- optional_args.append("--no-display-prompt")
316
- if capabilities["reasoning"]:
317
- optional_args.extend(["--reasoning", "off"])
318
- if capabilities["reasoning_budget"]:
319
- optional_args.extend(["--reasoning-budget", "0"])
320
- if use_stop_strings and capabilities["reverse_prompt"]:
321
- optional_args.extend(["--reverse-prompt", ",".join(STOP_STRINGS)])
322
-
323
- result = run_command(base_command + optional_args, timeout_seconds)
324
- used_optional_args = True
325
- if result["returncode"] != 0 and looks_like_cli_option_error(result["stderr"]):
326
- result = run_command(base_command, timeout_seconds)
327
- used_optional_args = False
328
-
329
- runtime_error = None
330
- if result["timeout"]:
331
- runtime_error = f"timeout_after_{timeout_seconds}_seconds"
332
- elif result["returncode"] != 0:
333
- runtime_error = f"nonzero_exit_{result['returncode']}"
334
-
335
- output = extract_candidate_generation(result["stdout"], prompt)
336
- return output.strip(), {
337
- "path": str(llama_bin),
338
- "command": base_command + (optional_args if used_optional_args else []),
339
- "used_optional_args": used_optional_args,
340
- "capabilities": capabilities,
341
- "returncode": result["returncode"],
342
- "timeout": result["timeout"],
343
- "runtime_error": runtime_error,
344
- "stdout": result["stdout"].strip(),
345
- "stderr": result["stderr"].strip(),
346
- }
347
-
348
-
349
- def read_binary_help(llama_bin: Path) -> str:
350
- result = subprocess.run(
351
- [str(llama_bin), "--help"],
352
- check=False,
353
- capture_output=True,
354
- text=True,
355
- timeout=20,
356
- )
357
- return f"{result.stdout}\n{result.stderr}"
358
-
359
-
360
- def detect_binary_capabilities(help_text: str) -> dict[str, bool]:
361
- return {
362
- "no_conversation_short": "-no-cnv" in help_text,
363
- "no_conversation_long": "--no-conversation" in help_text,
364
- "no_display_prompt": "--no-display-prompt" in help_text,
365
- "reasoning": "--reasoning" in help_text,
366
- "reasoning_budget": "--reasoning-budget" in help_text,
367
- "reverse_prompt": "--reverse-prompt" in help_text or "-r," in help_text or "-r " in help_text,
368
- }
369
-
370
-
371
- def run_command(command: list[str], timeout_seconds: int) -> dict[str, Any]:
372
- try:
373
- result = subprocess.run(
374
- command,
375
- check=False,
376
- capture_output=True,
377
- text=True,
378
- timeout=timeout_seconds,
379
- )
380
- except subprocess.TimeoutExpired as exc:
381
- return {
382
- "returncode": 124,
383
- "stdout": exc.stdout or "",
384
- "stderr": exc.stderr or f"timed out after {timeout_seconds} seconds",
385
- "timeout": True,
386
- }
387
- return {
388
- "returncode": result.returncode,
389
- "stdout": result.stdout,
390
- "stderr": result.stderr,
391
- "timeout": False,
392
- }
393
-
394
-
395
- def looks_like_cli_option_error(stderr: str) -> bool:
396
- lowered = stderr.lower()
397
- option_markers = [
398
- "unknown argument",
399
- "unknown option",
400
- "unrecognized option",
401
- "invalid argument",
402
- ]
403
- optional_flag_markers = ["no-cnv", "no-conversation", "no-display-prompt", "reverse-prompt", "reasoning", "reasoning-budget"]
404
- return any(marker in lowered for marker in option_markers) and any(marker in lowered for marker in optional_flag_markers)
405
-
406
-
407
- def extract_candidate_generation(stdout: str, prompt: str) -> str:
408
- output = stdout.replace("\r\n", "\n")
409
- if output.startswith(prompt):
410
- output = output[len(prompt) :]
411
- lines = []
412
- for line in output.splitlines():
413
- if LOG_LINE_RE.search(line):
414
- continue
415
- lines.append(line)
416
- cleaned = "\n".join(lines).strip()
417
- json_start = cleaned.find("{")
418
- if json_start > 0:
419
- prefix = cleaned[:json_start].strip()
420
- if prefix.upper() in {"JSON:", "ASSISTANT JSON:", "ASSISTANT:"} or prefix.endswith("JSON:"):
421
- cleaned = cleaned[json_start:]
422
- return cleaned
423
-
424
-
425
- def apply_gguf_empty_tool_list_sanitization(validation: dict[str, Any]) -> dict[str, Any]:
426
- parsed_json = validation.get("parsed_json")
427
- if not isinstance(parsed_json, dict) or parsed_json.get("tool_request") != []:
428
- return validation
429
-
430
- sanitized_actor_json = validation.get("sanitized_actor_json")
431
- if isinstance(sanitized_actor_json, dict):
432
- sanitized_actor_json = dict(sanitized_actor_json)
433
- sanitized_actor_json["tool_request"] = None
434
- validation["sanitized_actor_json"] = sanitized_actor_json
435
- if validation["missing_required_fields_success"]:
436
- validation["sanitized_actor_json_usable"] = True
437
-
438
- validation["sanitized_tool_request"] = None
439
- validation["sanitized_tool_request_usable"] = True
440
- validation["strict_tool_request"] = False
441
- validation["sanitization_needed"] = True
442
- validation["actor_sanitization_needed"] = True
443
- validation["tool_error"] = "tool_request_empty_list_sanitized_to_null"
444
- return validation
445
-
446
-
447
- def new_metric_counts() -> dict[str, int]:
448
- counts = {key: 0 for key in METRIC_KEYS}
449
- counts["start_thinking_seen"] = 0
450
- return counts
451
-
452
-
453
- def print_format_summary(prompt_format: str, total: int, metric_counts: dict[str, int]) -> None:
454
- print(f"prompt_format: {prompt_format}")
455
- print(f"total generations: {total}")
456
- for key in METRIC_KEYS:
457
- count = metric_counts[key]
458
- rate = count / total if total else 0.0
459
- print(f"{METRIC_LABELS[key]}: {count}/{total} ({rate:.1%})")
460
- start_count = metric_counts["start_thinking_seen"]
461
- start_rate = start_count / total if total else 0.0
462
- print(f"[Start thinking] seen: {start_count}/{total} ({start_rate:.1%})")
463
-
464
-
465
- def has_interactive_marker(text: str) -> bool:
466
- lowered = text.lower()
467
- return any(marker in lowered for marker in INTERACTIVE_MARKERS)
468
-
469
-
470
- def format_command(command: list[str]) -> str:
471
- return " ".join(shlex_quote(part) for part in command)
472
-
473
-
474
- def shlex_quote(value: str) -> str:
475
- if not value:
476
- return "''"
477
- safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+-=.,/:@%")
478
- if all(char in safe_chars for char in value):
479
- return value
480
- return "'" + value.replace("'", "'\"'\"'") + "'"
481
-
482
-
483
- if __name__ == "__main__":
484
- try:
485
- main()
486
- except KeyboardInterrupt:
487
- sys.exit(130)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/eval_minicpm5_actor_lora.py DELETED
@@ -1,595 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Evaluate a MiniCPM5 Actor adapter or merged model on Actor SFT eval prompts."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import random
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- DEFAULT_BASE_MODEL = "openbmb/MiniCPM5-1B"
14
- DEFAULT_ADAPTER_DIR = Path("finetune/minicpm5-actor-lora")
15
- DEFAULT_EVAL_FILE = Path("finetune/data_samples/actor_eval_prompts.jsonl")
16
- DEFAULT_OUTPUT_FILE = Path("finetune/eval_outputs/minicpm5_actor_lora_eval.jsonl")
17
- EVAL_RESPONSE_SUFFIX = (
18
- "Return exactly one JSON object with only these top-level keys: "
19
- "intent, line, emotion, gesture, stage_effect, memory_update, tool_request. "
20
- "Do not include speaking_style, show_state, recent_transcript, or any copied input fields. "
21
- "Stop after the JSON object."
22
- )
23
- REQUIRED_FIELDS = [
24
- "intent",
25
- "line",
26
- "emotion",
27
- "gesture",
28
- "stage_effect",
29
- "memory_update",
30
- "tool_request",
31
- ]
32
- ALLOWED_TOOLS = {"inspect_prop", "consult_stage_oracle", "change_lighting"}
33
- TOOL_ARGS = {
34
- "inspect_prop": {"prop"},
35
- "consult_stage_oracle": {"question"},
36
- "change_lighting": {"mood"},
37
- }
38
- TOOL_ALIASES = {
39
- "consult oracle": "consult_stage_oracle",
40
- "consult_oracle": "consult_stage_oracle",
41
- }
42
- FORBIDDEN_TOP_LEVEL_FIELDS = {
43
- "speaking_style",
44
- "memory_record",
45
- "memory_effect",
46
- "recent_transcript",
47
- "show_state",
48
- "held_props",
49
- "mood",
50
- "name",
51
- "latest_prop",
52
- "latest_audience_action",
53
- "tool_results",
54
- "status",
55
- "result",
56
- "notes",
57
- "current_show_phase",
58
- "setting",
59
- "story_phase",
60
- "target_beats",
61
- "max_beats",
62
- }
63
-
64
-
65
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
66
- parser = argparse.ArgumentParser(description=__doc__)
67
- parser.add_argument("--base_model", default=DEFAULT_BASE_MODEL)
68
- parser.add_argument("--adapter_dir", type=Path, default=DEFAULT_ADAPTER_DIR)
69
- parser.add_argument("--merged_model", action="store_true", help="Load --base_model directly and skip PeftModel adapter loading.")
70
- parser.add_argument("--eval_file", type=Path, default=DEFAULT_EVAL_FILE)
71
- parser.add_argument("--output_file", type=Path, default=DEFAULT_OUTPUT_FILE)
72
- parser.add_argument("--max_new_tokens", type=int, default=192)
73
- parser.add_argument("--temperature", type=float, default=0.0)
74
- parser.add_argument("--top_p", type=float, default=0.9)
75
- parser.add_argument("--limit", type=int, default=None)
76
- parser.add_argument("--seed", type=int, default=42)
77
- parser.add_argument("--device_map", default="auto")
78
- parser.add_argument("--torch_dtype", default="auto", choices=["auto", "float16", "bfloat16", "float32"])
79
- parser.add_argument("--load_in_4bit", action="store_true")
80
- parser.add_argument("--trust_remote_code", action=argparse.BooleanOptionalAction, default=True)
81
- parser.add_argument(
82
- "--append_eval_suffix",
83
- action=argparse.BooleanOptionalAction,
84
- default=False,
85
- help="Append a strict one-JSON-object reminder to eval prompts. Useful for experiments, off by default.",
86
- )
87
- return parser.parse_args(argv)
88
-
89
-
90
- def main(argv: list[str] | None = None) -> None:
91
- args = parse_args(argv)
92
- run_eval(args)
93
-
94
-
95
- def run_eval(args: argparse.Namespace) -> None:
96
- import torch
97
- from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
98
-
99
- if not args.eval_file.exists():
100
- raise FileNotFoundError(f"Eval file does not exist: {args.eval_file}")
101
- if not args.merged_model and not args.adapter_dir.exists():
102
- raise FileNotFoundError(f"Adapter directory does not exist: {args.adapter_dir}")
103
-
104
- random.seed(args.seed)
105
- if torch.cuda.is_available():
106
- torch.cuda.manual_seed_all(args.seed)
107
-
108
- tokenizer_source = args.base_model
109
- if not args.merged_model and (args.adapter_dir / "tokenizer_config.json").exists():
110
- tokenizer_source = args.adapter_dir
111
- tokenizer = AutoTokenizer.from_pretrained(
112
- tokenizer_source,
113
- trust_remote_code=args.trust_remote_code,
114
- use_fast=True,
115
- )
116
- if tokenizer.pad_token is None:
117
- tokenizer.pad_token = tokenizer.eos_token
118
-
119
- model_kwargs: dict[str, Any] = {
120
- "trust_remote_code": args.trust_remote_code,
121
- "device_map": args.device_map,
122
- }
123
- dtype = resolve_torch_dtype(args.torch_dtype, torch)
124
- if dtype is not None:
125
- model_kwargs["torch_dtype"] = dtype
126
- if args.load_in_4bit:
127
- model_kwargs["quantization_config"] = BitsAndBytesConfig(
128
- load_in_4bit=True,
129
- bnb_4bit_use_double_quant=True,
130
- bnb_4bit_quant_type="nf4",
131
- bnb_4bit_compute_dtype=torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16,
132
- )
133
-
134
- model = AutoModelForCausalLM.from_pretrained(args.base_model, **model_kwargs)
135
- if not args.merged_model:
136
- from peft import PeftModel
137
-
138
- model = PeftModel.from_pretrained(model, args.adapter_dir)
139
- model.eval()
140
-
141
- rows = load_eval_rows(args.eval_file, args.limit)
142
- args.output_file.parent.mkdir(parents=True, exist_ok=True)
143
- metric_counts = {
144
- "raw_clean_json": 0,
145
- "extracted_json_parse": 0,
146
- "missing_required_fields_success": 0,
147
- "exact_top_level_schema_success": 0,
148
- "extra_top_level_fields": 0,
149
- "forbidden_top_level_fields": 0,
150
- "sanitized_actor_json_usable": 0,
151
- "strict_tool_request": 0,
152
- "sanitized_tool_request_usable": 0,
153
- "line_length_pass": 0,
154
- }
155
-
156
- with args.output_file.open("w", encoding="utf-8") as handle:
157
- for index, row in enumerate(rows, start=1):
158
- prompt_text = format_messages_as_prompt(row["messages"], tokenizer, args.append_eval_suffix)
159
- inputs = tokenizer(prompt_text, return_tensors="pt")
160
- inputs = move_inputs_to_model(inputs, model)
161
- generation_kwargs = {
162
- "max_new_tokens": args.max_new_tokens,
163
- "pad_token_id": tokenizer.pad_token_id,
164
- "eos_token_id": tokenizer.eos_token_id,
165
- "do_sample": args.temperature > 0,
166
- }
167
- if args.temperature > 0:
168
- generation_kwargs["temperature"] = args.temperature
169
- generation_kwargs["top_p"] = args.top_p
170
-
171
- with torch.inference_mode():
172
- output_ids = model.generate(**inputs, **generation_kwargs)
173
- new_tokens = output_ids[0][inputs["input_ids"].shape[-1] :]
174
- assistant_text = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
175
- validation = validate_generation(assistant_text)
176
- for metric in metric_counts:
177
- metric_counts[metric] += int(validation[metric])
178
- detail = {
179
- "id": row.get("id", f"eval-{index:03d}"),
180
- "row_type": row.get("row_type"),
181
- "messages": row["messages"],
182
- "assistant_text": assistant_text,
183
- "json_text": validation["json_text"],
184
- "parsed_json": validation["parsed_json"],
185
- "sanitized_actor_json": validation["sanitized_actor_json"],
186
- "original_tool_request": validation["original_tool_request"],
187
- "sanitized_tool_request": validation["sanitized_tool_request"],
188
- "validation": {
189
- key: value
190
- for key, value in validation.items()
191
- if key
192
- not in {
193
- "parsed_json",
194
- "sanitized_actor_json",
195
- "json_text",
196
- "original_tool_request",
197
- "sanitized_tool_request",
198
- }
199
- },
200
- }
201
- handle.write(json.dumps(detail, ensure_ascii=True, separators=(",", ":")) + "\n")
202
-
203
- print_summary(len(rows), metric_counts, args.output_file)
204
-
205
-
206
- def load_eval_rows(path: Path, limit: int | None) -> list[dict[str, Any]]:
207
- rows: list[dict[str, Any]] = []
208
- with path.open("r", encoding="utf-8") as handle:
209
- for line_number, line in enumerate(handle, start=1):
210
- if not line.strip():
211
- continue
212
- row = json.loads(line)
213
- if not isinstance(row.get("messages"), list):
214
- raise ValueError(f"Row {line_number} in {path} is missing messages")
215
- rows.append(row)
216
- if limit is not None and len(rows) >= limit:
217
- break
218
- if not rows:
219
- raise ValueError(f"No eval rows loaded from {path}")
220
- return rows
221
-
222
-
223
- def format_messages_as_prompt(messages: list[dict[str, str]], tokenizer: Any, append_eval_suffix: bool = False) -> str:
224
- normalized_messages = [{"role": message["role"], "content": message["content"]} for message in messages]
225
- if append_eval_suffix:
226
- normalized_messages = add_eval_response_suffix(normalized_messages)
227
- if hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template:
228
- return tokenizer.apply_chat_template(
229
- normalized_messages,
230
- tokenize=False,
231
- add_generation_prompt=True,
232
- )
233
- return manual_prompt_template(normalized_messages)
234
-
235
-
236
- def manual_prompt_template(messages: list[dict[str, str]]) -> str:
237
- parts = []
238
- for message in messages:
239
- role = message["role"].strip().upper()
240
- parts.append(f"### {role}\n{message['content'].strip()}")
241
- parts.append("### ASSISTANT\n")
242
- return "\n\n".join(parts)
243
-
244
-
245
- def add_eval_response_suffix(messages: list[dict[str, str]]) -> list[dict[str, str]]:
246
- updated = [dict(message) for message in messages]
247
- for index in range(len(updated) - 1, -1, -1):
248
- if updated[index]["role"] == "user":
249
- content = updated[index]["content"].rstrip()
250
- if EVAL_RESPONSE_SUFFIX not in content:
251
- updated[index]["content"] = f"{content}\n\n{EVAL_RESPONSE_SUFFIX}"
252
- return updated
253
- updated.append({"role": "user", "content": EVAL_RESPONSE_SUFFIX})
254
- return updated
255
-
256
-
257
- def validate_generation(text: str) -> dict[str, Any]:
258
- json_text = first_balanced_json(text)
259
- parsed_json = None
260
- parse_error = None
261
- try:
262
- parsed_json = json.loads(json_text) if json_text is not None else None
263
- except json.JSONDecodeError as exc:
264
- parse_error = str(exc)
265
-
266
- raw_clean_json = (
267
- parsed_json is not None
268
- and json_text is not None
269
- and text.strip() == json_text
270
- and "```" not in text
271
- )
272
- clean_json_error = clean_json_diagnostic(text, json_text, parsed_json)
273
- schema_validation = validate_actor_schema(parsed_json)
274
- original_tool_request = parsed_json.get("tool_request") if isinstance(parsed_json, dict) else None
275
- tool_validation = (
276
- validate_and_sanitize_tool_request(original_tool_request)
277
- if isinstance(parsed_json, dict)
278
- else tool_validation_result(False, False, False, "json_not_parsed", None, False, None)
279
- )
280
- sanitized_actor_json = sanitize_actor_json(parsed_json, tool_validation["sanitized_tool_request"])
281
- sanitized_actor_json_usable = (
282
- sanitized_actor_json is not None
283
- and schema_validation["missing_required_fields_success"]
284
- and tool_validation["sanitized_tool_request_usable"]
285
- )
286
- actor_sanitization_needed = bool(
287
- sanitized_actor_json is not None
288
- and isinstance(parsed_json, dict)
289
- and (
290
- schema_validation["extra_top_level_fields"]
291
- or schema_validation["forbidden_top_level_fields"]
292
- or sanitized_actor_json != {field: parsed_json.get(field) for field in REQUIRED_FIELDS}
293
- )
294
- )
295
- line_length_pass = validate_line_length(parsed_json.get("line")) if isinstance(parsed_json, dict) else False
296
- return {
297
- "raw_clean_json": raw_clean_json,
298
- "extracted_json_parse": parsed_json is not None,
299
- "required_fields_success": schema_validation["exact_top_level_schema_success"],
300
- "missing_required_fields_success": schema_validation["missing_required_fields_success"],
301
- "exact_top_level_schema_success": schema_validation["exact_top_level_schema_success"],
302
- "extra_top_level_fields": bool(schema_validation["extra_top_level_fields"]),
303
- "extra_top_level_field_names": schema_validation["extra_top_level_fields"],
304
- "forbidden_top_level_fields": bool(schema_validation["forbidden_top_level_fields"]),
305
- "forbidden_top_level_field_names": schema_validation["forbidden_top_level_fields"],
306
- "missing_required_fields": schema_validation["missing_required_fields"],
307
- "strict_tool_request": tool_validation["strict_tool_request"],
308
- "sanitized_tool_request_usable": tool_validation["sanitized_tool_request_usable"],
309
- "sanitized_actor_json_usable": sanitized_actor_json_usable,
310
- "sanitization_needed": tool_validation["sanitization_needed"] or actor_sanitization_needed,
311
- "actor_sanitization_needed": actor_sanitization_needed,
312
- "tool_name_normalized": tool_validation["tool_name_normalized"],
313
- "normalized_tool_name": tool_validation["normalized_tool_name"],
314
- "line_length_pass": line_length_pass,
315
- "no_markdown": "```" not in text and not text.strip().startswith("`"),
316
- "parse_error": parse_error or (None if json_text else "no_balanced_json_object_found"),
317
- "tool_error": tool_validation["tool_error"],
318
- "clean_json_error": clean_json_error,
319
- "json_text": json_text,
320
- "parsed_json": parsed_json,
321
- "sanitized_actor_json": sanitized_actor_json,
322
- "original_tool_request": original_tool_request,
323
- "sanitized_tool_request": tool_validation["sanitized_tool_request"],
324
- }
325
-
326
-
327
- def first_balanced_json(text: str) -> str | None:
328
- """Return the first complete JSON object from raw model output."""
329
- stripped = text.strip()
330
- if not stripped:
331
- return None
332
- start = stripped.find("{")
333
- if start == -1:
334
- return None
335
- depth = 0
336
- in_string = False
337
- escape = False
338
- for index in range(start, len(stripped)):
339
- char = stripped[index]
340
- if escape:
341
- escape = False
342
- continue
343
- if char == "\\" and in_string:
344
- escape = True
345
- continue
346
- if char == '"':
347
- in_string = not in_string
348
- continue
349
- if in_string:
350
- continue
351
- if char == "{":
352
- depth += 1
353
- elif char == "}":
354
- depth -= 1
355
- if depth == 0:
356
- return stripped[start : index + 1]
357
- return None
358
-
359
-
360
- def clean_json_diagnostic(text: str, json_text: str | None, parsed_json: Any) -> str | None:
361
- stripped = text.strip()
362
- if parsed_json is None:
363
- return "json_parse_failed"
364
- if "```" in stripped or stripped.startswith("`"):
365
- return "markdown_or_code_fence_present"
366
- if json_text is None:
367
- return "no_json_extracted"
368
- if stripped != json_text:
369
- if stripped.startswith(json_text):
370
- return "extra_text_after_json"
371
- if stripped.endswith(json_text):
372
- return "extra_text_before_json"
373
- return "extra_text_around_json"
374
- return None
375
-
376
-
377
- def validate_actor_schema(value: Any) -> dict[str, Any]:
378
- if not isinstance(value, dict):
379
- return {
380
- "missing_required_fields_success": False,
381
- "exact_top_level_schema_success": False,
382
- "missing_required_fields": REQUIRED_FIELDS,
383
- "extra_top_level_fields": [],
384
- "forbidden_top_level_fields": [],
385
- }
386
- keys = set(value)
387
- missing_required_fields = [field for field in REQUIRED_FIELDS if field not in keys]
388
- extra_top_level_fields = sorted(keys - set(REQUIRED_FIELDS))
389
- forbidden_top_level_fields = sorted(keys & FORBIDDEN_TOP_LEVEL_FIELDS)
390
- missing_required_fields_success = not missing_required_fields and validate_required_field_values(value)
391
- return {
392
- "missing_required_fields_success": missing_required_fields_success,
393
- "exact_top_level_schema_success": missing_required_fields_success and not extra_top_level_fields,
394
- "missing_required_fields": missing_required_fields,
395
- "extra_top_level_fields": extra_top_level_fields,
396
- "forbidden_top_level_fields": forbidden_top_level_fields,
397
- }
398
-
399
-
400
- def validate_required_field_values(value: Any) -> bool:
401
- if not isinstance(value, dict):
402
- return False
403
- for key in REQUIRED_FIELDS:
404
- if key in {"memory_update", "tool_request"}:
405
- continue
406
- if not isinstance(value[key], str) or not value[key].strip():
407
- return False
408
- if value["memory_update"] is not None and not isinstance(value["memory_update"], str):
409
- return False
410
- return True
411
-
412
-
413
- def sanitize_actor_json(value: Any, sanitized_tool_request: dict[str, Any] | None) -> dict[str, Any] | None:
414
- if not isinstance(value, dict):
415
- return None
416
- sanitized: dict[str, Any] = {}
417
- for field in REQUIRED_FIELDS:
418
- if field == "tool_request":
419
- sanitized[field] = sanitized_tool_request
420
- elif field in value:
421
- sanitized[field] = value[field]
422
- return sanitized
423
-
424
-
425
- def validate_and_sanitize_tool_request(value: Any) -> dict[str, Any]:
426
- if value is None:
427
- return {
428
- "strict_tool_request": True,
429
- "sanitized_tool_request_usable": True,
430
- "sanitization_needed": False,
431
- "tool_name_normalized": False,
432
- "normalized_tool_name": None,
433
- "tool_error": None,
434
- "sanitized_tool_request": None,
435
- }
436
- if not isinstance(value, dict) or set(value) != {"tool", "args", "reason"}:
437
- return tool_validation_result(False, False, False, "tool_request_schema_invalid", None, False, None)
438
- normalized_tool = normalize_tool_name(value["tool"])
439
- tool_name_normalized = normalized_tool != value["tool"]
440
- if normalized_tool not in ALLOWED_TOOLS:
441
- return tool_validation_result(
442
- False,
443
- False,
444
- False,
445
- "tool_request_tool_invalid",
446
- None,
447
- tool_name_normalized,
448
- normalized_tool,
449
- )
450
- if not isinstance(value["reason"], str) or not value["reason"].strip():
451
- return tool_validation_result(False, False, False, "tool_request_reason_invalid", None, tool_name_normalized, normalized_tool)
452
- args = value["args"]
453
- if not isinstance(args, dict):
454
- return tool_validation_result(False, False, False, "tool_request_args_invalid", None, tool_name_normalized, normalized_tool)
455
-
456
- sanitized_args, sanitize_error = sanitize_tool_args(normalized_tool, args)
457
- sanitized = None
458
- if sanitized_args is not None:
459
- sanitized = {
460
- "tool": normalized_tool,
461
- "args": sanitized_args,
462
- "reason": " ".join(value["reason"].strip().split()),
463
- }
464
- strict_args = (
465
- sanitized_args is not None
466
- and not tool_name_normalized
467
- and set(args) == set(sanitized_args)
468
- and args == sanitized_args
469
- )
470
- strict_tool_request = strict_args and all(isinstance(arg_value, str) and arg_value.strip() for arg_value in args.values())
471
- sanitization_needed = sanitized is not None and sanitized != value
472
- if strict_tool_request:
473
- tool_error = None
474
- elif sanitized is not None:
475
- if tool_name_normalized and sanitization_needed:
476
- tool_error = "tool_request_normalized_and_sanitized"
477
- elif tool_name_normalized:
478
- tool_error = "tool_request_normalized"
479
- else:
480
- tool_error = "tool_request_sanitized"
481
- else:
482
- tool_error = sanitize_error or "tool_request_unusable"
483
- return tool_validation_result(
484
- strict_tool_request,
485
- sanitized is not None,
486
- sanitization_needed,
487
- tool_error,
488
- sanitized,
489
- tool_name_normalized,
490
- normalized_tool,
491
- )
492
-
493
-
494
- def normalize_tool_name(value: Any) -> str:
495
- cleaned = " ".join(str(value).strip().split())
496
- return TOOL_ALIASES.get(cleaned.lower(), cleaned)
497
-
498
-
499
- def sanitize_tool_args(tool: str, args: dict[str, Any]) -> tuple[dict[str, str] | None, str | None]:
500
- if tool == "inspect_prop":
501
- prop = clean_arg_value(args.get("prop"))
502
- if prop is None:
503
- return None, "inspect_prop_missing_prop"
504
- return {"prop": prop}, None
505
- if tool == "consult_stage_oracle":
506
- question = clean_arg_value(args.get("question"))
507
- if question is None:
508
- return None, "consult_stage_oracle_missing_question"
509
- return {"question": question}, None
510
- if tool == "change_lighting":
511
- mood = clean_arg_value(args.get("mood"))
512
- if mood is not None:
513
- return {"mood": mood}, None
514
- lighting = clean_arg_value(args.get("lighting"))
515
- if lighting is not None:
516
- return {"mood": lighting}, None
517
- color = clean_arg_value(args.get("color"))
518
- if color is not None:
519
- return {"mood": color}, None
520
- return None, "change_lighting_missing_mood"
521
- return None, "tool_request_tool_invalid"
522
-
523
-
524
- def clean_arg_value(value: Any) -> str | None:
525
- if value is None:
526
- return None
527
- cleaned = " ".join(str(value).strip().split())
528
- return cleaned or None
529
-
530
-
531
- def tool_validation_result(
532
- strict_tool_request: bool,
533
- sanitized_tool_request_usable: bool,
534
- sanitization_needed: bool,
535
- tool_error: str | None,
536
- sanitized_tool_request: dict[str, Any] | None,
537
- tool_name_normalized: bool,
538
- normalized_tool_name: str | None,
539
- ) -> dict[str, Any]:
540
- return {
541
- "strict_tool_request": strict_tool_request,
542
- "sanitized_tool_request_usable": sanitized_tool_request_usable,
543
- "sanitization_needed": sanitization_needed,
544
- "tool_name_normalized": tool_name_normalized,
545
- "normalized_tool_name": normalized_tool_name,
546
- "tool_error": tool_error,
547
- "sanitized_tool_request": sanitized_tool_request,
548
- }
549
-
550
-
551
- def validate_line_length(value: Any) -> bool:
552
- return isinstance(value, str) and 0 < len(value.split()) <= 24
553
-
554
-
555
- def resolve_torch_dtype(raw_value: str, torch: Any) -> Any:
556
- if raw_value == "auto":
557
- return None
558
- return {
559
- "float16": torch.float16,
560
- "bfloat16": torch.bfloat16,
561
- "float32": torch.float32,
562
- }[raw_value]
563
-
564
-
565
- def move_inputs_to_model(inputs: Any, model: Any) -> Any:
566
- try:
567
- device = next(model.parameters()).device
568
- except StopIteration:
569
- return inputs
570
- return {key: value.to(device) for key, value in inputs.items()}
571
-
572
-
573
- def print_summary(total: int, metric_counts: dict[str, int], output_file: Path) -> None:
574
- print(f"total prompts: {total}")
575
- labels = {
576
- "raw_clean_json": "raw clean JSON",
577
- "extracted_json_parse": "extracted JSON parse",
578
- "missing_required_fields_success": "has required fields",
579
- "exact_top_level_schema_success": "exact top-level schema",
580
- "extra_top_level_fields": "extra top-level fields",
581
- "forbidden_top_level_fields": "forbidden top-level fields",
582
- "sanitized_actor_json_usable": "sanitized actor JSON usable",
583
- "strict_tool_request": "strict tool_request",
584
- "sanitized_tool_request_usable": "sanitized tool_request usable",
585
- "line_length_pass": "line length pass",
586
- }
587
- for key, label in labels.items():
588
- count = metric_counts[key]
589
- rate = count / total if total else 0.0
590
- print(f"{label}: {count}/{total} ({rate:.1%})")
591
- print(f"wrote detailed generations to: {output_file}")
592
-
593
-
594
- if __name__ == "__main__":
595
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/generate_actor_sft_v0.py DELETED
@@ -1,1760 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Generate synthetic Actor SFT data for AI Puppet Theater.
3
-
4
- The output is deterministic so the small committed sample can be refreshed
5
- without depending on an external model.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import json
12
- import random
13
- from pathlib import Path
14
- from typing import Any
15
-
16
-
17
- DEFAULT_SEED = 20260613
18
- THEATRELM_DATASET_ID = "G-reen/TheatreLM-v2.1-Characters"
19
- RPGPT_DATASET_ID = "practical-dreamer/RPGPT_PublicDomain-alpaca"
20
- DEFAULT_THEATRELM_SEED_PATH = Path("finetune/external_seeds/theatrelm_sample.jsonl")
21
- DEFAULT_RPGPT_SEED_PATH = Path("finetune/external_seeds/rpgpt_sample.jsonl")
22
- MAX_SEED_TEXT_CHARS = 9000
23
- SYSTEM_MESSAGE = (
24
- "You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. "
25
- "No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."
26
- )
27
-
28
- OUTPUT_FIELDS = [
29
- "intent",
30
- "line",
31
- "emotion",
32
- "gesture",
33
- "stage_effect",
34
- "memory_update",
35
- "tool_request",
36
- ]
37
-
38
- INTENTS = [
39
- "react_to_event",
40
- "clarify_problem",
41
- "inspect_prop",
42
- "consult_oracle",
43
- "change_lighting",
44
- "recall_memory",
45
- "hint_secret",
46
- "reveal_secret",
47
- "deliver_finale",
48
- "comic_confusion",
49
- ]
50
-
51
- ROW_TYPES = [
52
- "normal_reaction",
53
- "prop_inspection",
54
- "oracle_consult",
55
- "lighting_change",
56
- "memory_callback",
57
- "secret_hint_or_reveal",
58
- "finale",
59
- "comedic_confusion",
60
- ]
61
-
62
- TOOLS = {
63
- "inspect_prop": "prop",
64
- "consult_stage_oracle": "question",
65
- "change_lighting": "mood",
66
- }
67
-
68
- V1_ROW_TYPES = [
69
- "oracle_consult",
70
- "oracle_consult",
71
- "oracle_consult",
72
- "prop_inspection",
73
- "prop_inspection",
74
- "prop_inspection",
75
- "lighting_change",
76
- "lighting_change",
77
- "lighting_change",
78
- "memory_callback",
79
- "memory_callback",
80
- "normal_reaction",
81
- "normal_reaction",
82
- "secret_hint_or_reveal",
83
- "comedic_confusion",
84
- "finale",
85
- ]
86
-
87
- V1_SAFE_LIGHTING = [
88
- "moonlit_lighting",
89
- "golden_lighting",
90
- "stormy_lighting",
91
- "blue_spotlight",
92
- "warm_spotlight",
93
- "single_spotlight",
94
- "prop_table_glow",
95
- "oracle_haze",
96
- "memory_echo",
97
- "quick_blackout",
98
- ]
99
-
100
- V1_ORACLE_QUESTIONS = [
101
- "Which clue should the next actor follow?",
102
- "What does the tiny prop reveal next?",
103
- "Which stage change matters most now?",
104
- "How should we connect the memory to the clue?",
105
- "What harmless secret should enter the spotlight?",
106
- "Which curtain whisper points toward the truth?",
107
- "What should change before the next beat?",
108
- "Which audience action belongs in the scene?",
109
- ]
110
-
111
- V1_TOOL_REASONS = {
112
- "inspect_prop": [
113
- "The prop may reveal one concrete stage clue.",
114
- "A strict prop inspection keeps the beat playable.",
115
- "The Director needs one clean prop detail.",
116
- ],
117
- "consult_stage_oracle": [
118
- "The oracle can supply one useful next clue.",
119
- "A precise oracle question keeps the scene focused.",
120
- "The next beat needs a single stage-safe hint.",
121
- ],
122
- "change_lighting": [
123
- "The lighting cue clarifies the emotional turn.",
124
- "A strict lighting change keeps the stage state usable.",
125
- "The beat needs a clear visible stage shift.",
126
- ],
127
- }
128
-
129
- SEED_FIELD_GROUPS = {
130
- "theatrelm": [
131
- "character_name",
132
- "character_summary",
133
- "character_card",
134
- "setting_summarized",
135
- "setting",
136
- "story_outline",
137
- "story_introduction",
138
- "lorebook",
139
- ],
140
- "rpgpt": [
141
- "instruction",
142
- "input",
143
- "output",
144
- "character",
145
- "scenario",
146
- "description",
147
- ],
148
- }
149
-
150
- SAFETY_BLOCKLIST = {
151
- "explicit sexual content": [
152
- "porn",
153
- "pornographic",
154
- "explicit sex",
155
- "sexual intercourse",
156
- "rape",
157
- "incest",
158
- "blowjob",
159
- "handjob",
160
- "orgasm",
161
- "masturbat",
162
- "nude",
163
- "nudity",
164
- "erotic",
165
- "fetish",
166
- ],
167
- "heavy profanity": [
168
- "fuck",
169
- "fucking",
170
- "motherfucker",
171
- "cunt",
172
- "bitch",
173
- "asshole",
174
- "shithead",
175
- ],
176
- "graphic violence": [
177
- "gore",
178
- "gory",
179
- "dismember",
180
- "dismembered",
181
- "decapitat",
182
- "disembowel",
183
- "mutilat",
184
- "bloodbath",
185
- "torture",
186
- "viscera",
187
- "entrails",
188
- ],
189
- }
190
-
191
- PREMISES = [
192
- "A moon mayor denies stealing the town's last spoon",
193
- "A nervous toaster auditions for a royal banquet",
194
- "Detectives investigate why the castle keeps applauding",
195
- "A dragon opens a bakery for very tiny clouds",
196
- "The lighthouse insists it saw a submarine wearing a hat",
197
- "A haunted teacup wants to direct the school musical",
198
- "A wizard's laundry basket predicts tomorrow's weather",
199
- "A robot gardener teaches flowers to bow on cue",
200
- "Three pirates argue over a map drawn by a sandwich",
201
- "A library book refuses to return from vacation",
202
- "The sun forgets its entrance and asks the moon for notes",
203
- "A train conductor schedules a parade inside a suitcase",
204
- "A shy volcano hosts a talent show for umbrellas",
205
- "A cardboard courtroom tries a missing birthday candle",
206
- "The bakery's rolling pin claims it solved the mystery",
207
- "An elevator only travels to dramatic reveals",
208
- "A clockwork whale forgets which ocean is on stage",
209
- "A scarecrow runs a midnight advice booth for crows",
210
- "A snow globe mayor bans winter until further notice",
211
- "Two umbrellas debate who caused the indoor rainstorm",
212
- "A violin case claims it can hear tomorrow's applause",
213
- "A tiny museum hires a shadow as night security",
214
- "A polite comet asks permission to crash the tea party",
215
- "The village well starts returning everyone else's wishes",
216
- "A jealous spotlight auditions for the hero's role",
217
- "A detective mailbox investigates missing love letters",
218
- "A pancake knight guards a syrup drawbridge at dawn",
219
- "The town orchestra loses its conductor inside a trumpet",
220
- "A mirror refuses to reflect anyone without stage presence",
221
- "A pirate parrot opens a school for dramatic pauses",
222
- "The royal gardener grows clues instead of roses",
223
- "A blanket fort declares independence from bedtime",
224
- "A nervous cloud applies to become a thunderstorm",
225
- "A shoelace detective solves crimes nobody can tie together",
226
- "The puppet mayor appoints a potato as royal advisor",
227
- "A paper airplane delivers invitations to the wrong century",
228
- "A lantern insists the darkness stole its punchline",
229
- "The circus cannonball wants a quieter desk job",
230
- "A soup ladle discovers a secret passage under dinner",
231
- "A painted door refuses to open without a compliment",
232
- ]
233
-
234
- SETTINGS = [
235
- "a pocket-sized stage with painted flats and a wobbly spotlight",
236
- "a shoebox castle with velvet curtains and one suspicious tower",
237
- "a cardboard moon base with glitter stars and a squeaky hatch",
238
- "a tiny kitchen counter where every appliance has stage fright",
239
- "a rainy cardboard alley lit by one dramatic desk lamp",
240
- "a paper harbor with blue ribbons for waves",
241
- "a toy library where the shelves whisper stage directions",
242
- "a velvet courtroom with a squeaky judge's bench",
243
- ]
244
-
245
- ACTORS = [
246
- {
247
- "name": "Pip the Director",
248
- "avatar": "clapperboard",
249
- "goal": "Keep the scene moving toward a crisp finale.",
250
- "secret": "Has already misplaced the final cue card.",
251
- "speaking_style": "brisk, theatrical, and slightly overconfident",
252
- "tools": ["change_lighting", "consult_stage_oracle"],
253
- },
254
- {
255
- "name": "Mina Moonbutton",
256
- "avatar": "crescent moon",
257
- "goal": "Find the emotional truth hiding inside the premise.",
258
- "secret": "Believes every prop is personally judging her.",
259
- "speaking_style": "earnest, poetic, and prone to dramatic pauses",
260
- "tools": ["consult_stage_oracle", "change_lighting"],
261
- },
262
- {
263
- "name": "Bolt McJiggle",
264
- "avatar": "toolbox",
265
- "goal": "Turn every problem into a practical stage gag.",
266
- "secret": "Is secretly building a confetti finale backstage.",
267
- "speaking_style": "punchy, practical, and full of suspicious confidence",
268
- "tools": ["inspect_prop", "change_lighting"],
269
- },
270
- {
271
- "name": "Velvet Crumb",
272
- "avatar": "cupcake",
273
- "goal": "Make every clue sound like dessert.",
274
- "secret": "Can hear the curtains whisper reviews.",
275
- "speaking_style": "warm, dramatic, and gently mischievous",
276
- "tools": ["inspect_prop", "consult_stage_oracle"],
277
- },
278
- {
279
- "name": "Professor Buttonhook",
280
- "avatar": "spectacles",
281
- "goal": "Explain chaos as if it were in the syllabus.",
282
- "secret": "Never learned how trapdoors work.",
283
- "speaking_style": "precise, fussy, and accidentally grand",
284
- "tools": ["consult_stage_oracle"],
285
- },
286
- {
287
- "name": "Nora Needlewhistle",
288
- "avatar": "needle",
289
- "goal": "Stitch loose clues into a tidy stage pattern.",
290
- "secret": "Once sewed the curtain shut during a finale.",
291
- "speaking_style": "nimble, exact, and full of tiny warnings",
292
- "tools": ["inspect_prop", "change_lighting"],
293
- },
294
- {
295
- "name": "Captain Taffeta",
296
- "avatar": "sailboat",
297
- "goal": "Steer every scene through theatrical weather.",
298
- "secret": "Cannot tell port from stage left.",
299
- "speaking_style": "booming, nautical, and cheerfully mistaken",
300
- "tools": ["consult_stage_oracle", "change_lighting"],
301
- },
302
- {
303
- "name": "Juniper Jingle",
304
- "avatar": "bell",
305
- "goal": "Turn awkward pauses into musical cues.",
306
- "secret": "Rings whenever someone lies politely.",
307
- "speaking_style": "bright, rhythmic, and suspiciously tuneful",
308
- "tools": ["consult_stage_oracle"],
309
- },
310
- {
311
- "name": "Mossy Crank",
312
- "avatar": "gear",
313
- "goal": "Fix the scene using questionable machinery.",
314
- "secret": "Built a trapdoor that only opens emotionally.",
315
- "speaking_style": "grumbly, mechanical, and secretly tender",
316
- "tools": ["inspect_prop", "change_lighting"],
317
- },
318
- {
319
- "name": "Duchess Doodle",
320
- "avatar": "paintbrush",
321
- "goal": "Make the backdrop agree with her version of events.",
322
- "secret": "Painted herself into last week's mystery.",
323
- "speaking_style": "ornate, colorful, and lightly bossy",
324
- "tools": ["change_lighting"],
325
- },
326
- {
327
- "name": "Rafi Ribbon",
328
- "avatar": "ribbon",
329
- "goal": "Tie every contradiction into a decorative bow.",
330
- "secret": "Keeps emergency applause in his sleeve.",
331
- "speaking_style": "smooth, charming, and neatly dramatic",
332
- "tools": ["inspect_prop", "consult_stage_oracle"],
333
- },
334
- {
335
- "name": "Zelda Zipper",
336
- "avatar": "zipper",
337
- "goal": "Open hidden compartments in the plot.",
338
- "secret": "Knows which pocket holds the missing clue.",
339
- "speaking_style": "quick, clipped, and conspiratorial",
340
- "tools": ["inspect_prop"],
341
- },
342
- {
343
- "name": "Gus Gingham",
344
- "avatar": "tablecloth",
345
- "goal": "Keep everyone civil while the table collapses.",
346
- "secret": "Was once mistaken for the royal flag.",
347
- "speaking_style": "homespun, patient, and quietly ridiculous",
348
- "tools": ["change_lighting", "consult_stage_oracle"],
349
- },
350
- {
351
- "name": "Lola Lampshade",
352
- "avatar": "lamp",
353
- "goal": "Reveal the truth with tasteful illumination.",
354
- "secret": "Overhears everything said near warm lighting.",
355
- "speaking_style": "glowing, elegant, and dryly observant",
356
- "tools": ["change_lighting", "consult_stage_oracle"],
357
- },
358
- {
359
- "name": "Benny Breadcrumb",
360
- "avatar": "bread",
361
- "goal": "Leave a trail of clues nobody can ignore.",
362
- "secret": "Ate the map corner during rehearsal.",
363
- "speaking_style": "crumbly, earnest, and accidentally useful",
364
- "tools": ["inspect_prop"],
365
- },
366
- {
367
- "name": "Ivy Inkblot",
368
- "avatar": "ink",
369
- "goal": "Turn every mistake into official evidence.",
370
- "secret": "Can rewrite labels when nobody watches.",
371
- "speaking_style": "inky, clever, and theatrically legalistic",
372
- "tools": ["inspect_prop", "consult_stage_oracle"],
373
- },
374
- {
375
- "name": "Orville Oddsock",
376
- "avatar": "sock",
377
- "goal": "Find the missing pair in every mystery.",
378
- "secret": "Believes the laundry basket is a prophet.",
379
- "speaking_style": "loopy, sincere, and oddly persuasive",
380
- "tools": ["consult_stage_oracle"],
381
- },
382
- {
383
- "name": "Petra Popcorn",
384
- "avatar": "popcorn",
385
- "goal": "Make the audience reaction part of the plot.",
386
- "secret": "Can predict heckles three kernels early.",
387
- "speaking_style": "poppy, fast, and delighted by chaos",
388
- "tools": ["change_lighting"],
389
- },
390
- {
391
- "name": "Silas Sockdolager",
392
- "avatar": "megaphone",
393
- "goal": "Deliver the biggest line in the smallest voice.",
394
- "secret": "Lost his indoor voice under the orchestra pit.",
395
- "speaking_style": "grand, booming, and suddenly tiny",
396
- "tools": ["change_lighting", "consult_stage_oracle"],
397
- },
398
- {
399
- "name": "Tula Teaspoon",
400
- "avatar": "spoon",
401
- "goal": "Measure the exact amount of mystery in each beat.",
402
- "secret": "Recognizes the missing spoon from a family portrait.",
403
- "speaking_style": "polite, precise, and quietly dramatic",
404
- "tools": ["inspect_prop"],
405
- },
406
- {
407
- "name": "Finch Feltcap",
408
- "avatar": "hat",
409
- "goal": "Keep secrets tucked safely under the brim.",
410
- "secret": "The brim contains three emergency finales.",
411
- "speaking_style": "dapper, soft-spoken, and evasive",
412
- "tools": ["consult_stage_oracle", "inspect_prop"],
413
- },
414
- {
415
- "name": "Marigold Mumble",
416
- "avatar": "flower",
417
- "goal": "Say the emotional truth almost clearly enough.",
418
- "secret": "Only speaks plainly during blackouts.",
419
- "speaking_style": "gentle, tangled, and unexpectedly wise",
420
- "tools": ["change_lighting"],
421
- },
422
- {
423
- "name": "Quincy Quill",
424
- "avatar": "quill",
425
- "goal": "Annotate the chaos before it escapes.",
426
- "secret": "Has footnoted the villain's monologue already.",
427
- "speaking_style": "scholarly, brisk, and prone to footnotes",
428
- "tools": ["inspect_prop", "consult_stage_oracle"],
429
- },
430
- {
431
- "name": "Ruby Ruckus",
432
- "avatar": "drum",
433
- "goal": "Escalate the scene exactly one beat too far.",
434
- "secret": "Keeps a tiny cymbal for emergencies.",
435
- "speaking_style": "rowdy, warm, and rhythmically suspicious",
436
- "tools": ["change_lighting"],
437
- },
438
- {
439
- "name": "Otto Origami",
440
- "avatar": "paper crane",
441
- "goal": "Fold messy clues into surprising shapes.",
442
- "secret": "Was once a very important ransom note.",
443
- "speaking_style": "delicate, precise, and gently mysterious",
444
- "tools": ["inspect_prop", "consult_stage_oracle"],
445
- },
446
- ]
447
-
448
- PROPS = [
449
- "rubber duck",
450
- "glitter crown",
451
- "tomato scroll",
452
- "silver spoon",
453
- "paper lantern",
454
- "mystery egg",
455
- "tiny ladder",
456
- "velvet map",
457
- "wind-up key",
458
- "painted teacup",
459
- "cardboard telescope",
460
- "squeaky gavel",
461
- "accordion suitcase",
462
- "clockwork seashell",
463
- "paper moon",
464
- "velvet potato",
465
- "tin trumpet",
466
- "lace handkerchief",
467
- "wooden thunderbolt",
468
- "glass button",
469
- "feather duster",
470
- "tiny umbrella",
471
- "brass doorknob",
472
- "origami crown",
473
- "ribbon compass",
474
- "felt mustache",
475
- "porcelain whistle",
476
- "toy anchor",
477
- "silk bookmark",
478
- "painted keyhole",
479
- "candle stub",
480
- "miniature tambourine",
481
- "cardboard snowflake",
482
- "rubber stamp",
483
- "velcro star",
484
- "paper fan",
485
- "satin envelope",
486
- "toy hourglass",
487
- "button bouquet",
488
- ]
489
-
490
- MEMORIES = [
491
- "The spotlight blinked whenever someone said clue.",
492
- "The audience threw a prop during the contradiction.",
493
- "The curtains whispered that the finale was hiding nearby.",
494
- "A previous clue pointed stage left.",
495
- "The smallest prop seemed unusually confident.",
496
- "Someone bowed before the reveal was ready.",
497
- "The orchestra coughed during the important pause.",
498
- "A trapdoor sighed but did not open.",
499
- "The backdrop changed color after the audience heckled.",
500
- "A summoned actor entered carrying yesterday's clue.",
501
- "The prop table glowed before anyone touched it.",
502
- "A bell rang twice when the secret was mentioned.",
503
- "The painted door demanded a compliment earlier.",
504
- "A shadow crossed the stage wearing tap shoes.",
505
- "The final bow was briefly visible in the wings.",
506
- "The stage oracle warned everyone about small objects.",
507
- ]
508
-
509
- MOODS = [
510
- "ready",
511
- "curious",
512
- "nervous",
513
- "bold",
514
- "delighted",
515
- "suspicious",
516
- "wistful",
517
- "confident",
518
- "flustered",
519
- "determined",
520
- "mischievous",
521
- "hopeful",
522
- "startled",
523
- "grand",
524
- ]
525
-
526
- STAGE_LIGHTING_STATES = [
527
- "warm_spotlight",
528
- "moonlit_lighting",
529
- "golden_lighting",
530
- "stormy_lighting",
531
- "blue_spotlight",
532
- "single_spotlight",
533
- "confetti_rustle",
534
- "quick_blackout",
535
- "memory_echo",
536
- "oracle_haze",
537
- "prop_table_glow",
538
- "final_bow_lights",
539
- ]
540
-
541
- GOAL_PROGRESS = [
542
- "Waiting for the next cue.",
543
- "Following the clue across the painted flats.",
544
- "Trying to make the prop matter.",
545
- "Preparing a clean turn toward the finale.",
546
- "Keeping one eye on the restless curtains.",
547
- "Looking for a laugh that still serves the story.",
548
- "Testing whether the audience interruption is useful.",
549
- "Carrying a small suspicion toward center stage.",
550
- "Recovering from a missed entrance with dignity.",
551
- "Turning a contradiction into stage business.",
552
- ]
553
-
554
- EMOTIONS = {
555
- "normal_reaction": ["curious", "determined", "suspicious", "delighted"],
556
- "prop_inspection": ["investigative", "startled", "focused", "triumphant"],
557
- "oracle_consult": ["awed", "mystified", "reverent", "hopeful"],
558
- "lighting_change": ["commanding", "dramatic", "bold", "composed"],
559
- "memory_callback": ["remembering", "suddenly certain", "wistful", "alert"],
560
- "secret_hint_or_reveal": ["confessional", "nervous", "relieved", "theatrical"],
561
- "finale": ["proud", "joyful", "resolved", "grand"],
562
- "comedic_confusion": ["confused", "flustered", "goofy", "panicked"],
563
- }
564
-
565
- GESTURES = {
566
- "normal_reaction": [
567
- "taps chin with one tiny puppet hand",
568
- "steps toward the painted backdrop",
569
- "points both felt hands at the curtain",
570
- "tilts head under the warm spotlight",
571
- ],
572
- "prop_inspection": [
573
- "leans toward the glowing prop",
574
- "holds the prop up to the footlights",
575
- "squints at the prop with stitched suspicion",
576
- "presents the clue on both tiny palms",
577
- ],
578
- "oracle_consult": [
579
- "raises both hands toward the stage rafters",
580
- "listens closely to the whispering curtains",
581
- "kneels beside the spotlight",
582
- "peers upward as the oracle haze gathers",
583
- ],
584
- "lighting_change": [
585
- "snaps one felt hand toward the light booth",
586
- "sweeps an arm across the footlights",
587
- "shades eyes as the lamps change",
588
- "points to the spotlight with grand certainty",
589
- ],
590
- "memory_callback": [
591
- "taps forehead with a tiny puppet finger",
592
- "points stage left with sudden recognition",
593
- "retraces small steps across the stage",
594
- "clutches chest as the old clue returns",
595
- ],
596
- "secret_hint_or_reveal": [
597
- "leans close to the front row",
598
- "covers mouth with one felt hand",
599
- "steps carefully into the single spotlight",
600
- "unfolds a tiny note with trembling fingers",
601
- ],
602
- "finale": [
603
- "bows deeply beneath the falling curtain",
604
- "joins hands with the nearest puppet",
605
- "sweeps a tiny hat toward the audience",
606
- "holds a proud curtain-call pose",
607
- ],
608
- "comedic_confusion": [
609
- "spins once and faces the wrong flat",
610
- "drops an imaginary cue card",
611
- "looks under a tiny hat for answers",
612
- "freezes midstep with baffled dignity",
613
- ],
614
- }
615
-
616
- STAGE_EFFECTS = {
617
- "normal_reaction": ["warm_spotlight", "soft_drumroll", "painted_flat_wobble"],
618
- "prop_inspection": ["prop_table_glow", "tiny_chime", "magnifier_sparkle"],
619
- "oracle_consult": ["oracle_haze", "curtain_whisper", "blue_spotlight"],
620
- "lighting_change": ["moonlit_lighting", "golden_lighting", "stormy_lighting"],
621
- "memory_callback": ["memory_echo", "stage_left_glimmer", "soft_reprise"],
622
- "secret_hint_or_reveal": ["single_spotlight", "curtain_rustle", "secret_chime"],
623
- "finale": ["curtain_fall", "confetti_rustle", "final_bow_lights"],
624
- "comedic_confusion": ["quick_blackout", "squeaky_floor", "hat_tumble"],
625
- }
626
-
627
- DIRECTOR_INSTRUCTIONS = {
628
- "normal_reaction": [
629
- "React to the premise and move the scene forward in one short line.",
630
- "Answer the previous beat with theatrical confidence.",
631
- "Make the next problem clear without solving it yet.",
632
- "Name what changed on stage and invite the next beat.",
633
- "Clarify your actor's stance while preserving the joke.",
634
- "React to the latest clue with one playable stage choice.",
635
- ],
636
- "prop_inspection": [
637
- "Use the latest prop as evidence and request inspection if useful.",
638
- "Treat the prop like a clue that changes the scene.",
639
- "Inspect the prop without slowing the show.",
640
- "Make the prop feel important enough for the Director to notice.",
641
- "Turn the prop into a concrete clue and keep the line short.",
642
- "Handle the prop theatrically and leave one question open.",
643
- ],
644
- "oracle_consult": [
645
- "Ask the stage oracle for one playful clue.",
646
- "Consult the oracle about the next theatrical turn.",
647
- "Use an oracle question to sharpen the scene's mystery.",
648
- "Ask the oracle something specific enough to guide the next beat.",
649
- "Use the oracle to connect the premise and latest confusion.",
650
- "Invite a mysterious clue without making the scene too serious.",
651
- ],
652
- "lighting_change": [
653
- "Shift the lights to match the emotional turn.",
654
- "Request a lighting change that makes the beat clearer.",
655
- "Cue lights dramatically while keeping the line speakable.",
656
- "Change the lighting to make the actor's intention visible.",
657
- "Use a lighting cue as stage business, not decoration.",
658
- "Let the light change signal a clear emotional pivot.",
659
- ],
660
- "memory_callback": [
661
- "Recall one earlier clue and connect it to this moment.",
662
- "Use recent memory to make the scene feel continuous.",
663
- "Bring back a prior stage detail in one clear line.",
664
- "Connect an earlier audience action to the current clue.",
665
- "Use one remembered stage effect as evidence.",
666
- "Echo a prior beat without repeating the same wording.",
667
- ],
668
- "secret_hint_or_reveal": [
669
- "Hint at or reveal your secret without exposing hidden reasoning.",
670
- "Reveal a playful secret and keep the scene moving.",
671
- "Let the secret complicate the current beat.",
672
- "Give the audience a secret-shaped clue, not a long confession.",
673
- "Reveal only what the scene can use immediately.",
674
- "Make the secret theatrical, safe, and easy to perform.",
675
- ],
676
- "finale": [
677
- "Tie the scene together in a clean curtain-call line.",
678
- "End with a short button and a bow.",
679
- "Resolve the central joke and welcome the curtain.",
680
- "Close the loose thread with one speakable final line.",
681
- "Give the scene a satisfying button without adding a new problem.",
682
- "Signal the curtain clearly and let the ensemble win.",
683
- ],
684
- "comedic_confusion": [
685
- "Misread one stage detail in a funny but harmless way.",
686
- "Escalate confusion briefly, then leave room for the next actor.",
687
- "Make the confusion clear, playful, and safe.",
688
- "Confuse one clue with another, then recover enough to continue.",
689
- "Make a wrong conclusion that gives the next actor something usable.",
690
- "Let the misunderstanding create motion without breaking the scene.",
691
- ],
692
- }
693
-
694
- LINE_TEMPLATES = {
695
- "normal_reaction": [
696
- "The {premise_focus} problem just winked, so we investigate politely.",
697
- "I trust this {premise_focus} spotlight only when it stops coughing.",
698
- "Everyone stay dramatic; the {premise_focus} clue is taking attendance.",
699
- "This {premise_focus} scene smells like mystery and nervous paint.",
700
- "The {premise_focus} backdrop leaned closer, and I respect its commitment.",
701
- "This tiny {premise_focus} problem just became legally theatrical.",
702
- "I hear {premise_focus} suspense tapping behind the painted flats.",
703
- "Let us follow the {premise_focus} wobble before it complains.",
704
- ],
705
- "prop_inspection": [
706
- "This {prop} squeaks exactly like a guilty witness.",
707
- "Hold still, {prop}; your glitter is confessing under pressure.",
708
- "The {prop} points stage left, which feels legally important.",
709
- "I inspect this {prop} and find theatrical fingerprints everywhere.",
710
- "This {prop} contains one clue and several opinions.",
711
- "Behold, the {prop} is sweating under the footlights.",
712
- "The {prop} has crumbs of motive all over it.",
713
- "I dust the {prop} and discover suspicious applause.",
714
- ],
715
- "oracle_consult": [
716
- "Oracle, should we follow the tiny clue or the louder curtain?",
717
- "I ask the stage oracle why the spotlight keeps blinking.",
718
- "Great oracle, please translate this silence into one clue.",
719
- "Oracle, tell us which bow is hiding the truth.",
720
- "Oracle, which prop is pretending to be innocent tonight?",
721
- "Stage oracle, why did the curtains gasp before us?",
722
- "Oracle, point our tiny shoes toward the useful mystery.",
723
- "I request one clue, preferably with dramatic lighting.",
724
- ],
725
- "lighting_change": [
726
- "Lights to moonlit {premise_focus}; my eyebrows need proper shadows.",
727
- "Cue golden lights; this {premise_focus} accusation deserves sparkle.",
728
- "Make it stormy, because {premise_focus} subtlety missed its entrance.",
729
- "Shift the lights; the {premise_focus} truth looks better in blue.",
730
- "Dim the corners; {premise_focus} secrets dislike excellent visibility.",
731
- "Bring up blue before my {premise_focus} suspicion loses posture.",
732
- "Warm the spotlight; this {premise_focus} apology needs softer edges.",
733
- "Flash the footlights, because the {premise_focus} clue saluted.",
734
- ],
735
- "memory_callback": [
736
- "Wait, the curtain whispered that clue before intermission.",
737
- "I remember the stage-left glimmer, and it remembers me.",
738
- "That old clue returns wearing suspiciously fresh tap shoes.",
739
- "Earlier, the smallest prop bowed like it knew everything.",
740
- "The trapdoor sighed before, and now the {prop} answers.",
741
- "I recall that glow; it followed the guilty pause.",
742
- "The same squeak appeared when the backdrop changed color.",
743
- "Our earlier clue just returned with better timing.",
744
- ],
745
- "secret_hint_or_reveal": [
746
- "Fine, my {premise_focus} secret rehearsed with the missing clue.",
747
- "I admit it: the {premise_focus} cue card trusted me last.",
748
- "My secret squeaks louder whenever the {prop} gets nervous.",
749
- "I know why {premise_focus} curtains whisper; I taught them vowels.",
750
- "My {premise_focus} secret is small, but wears enormous shoes.",
751
- "I hid the {premise_focus} clue where applause would look.",
752
- "The {prop} knows me, and that is inconvenient.",
753
- "I promised the {premise_focus} backdrop I would reveal this gently.",
754
- ],
755
- "finale": [
756
- "{premise_focus} mystery solved, bows aligned, curtain forgiving everyone.",
757
- "We found the {premise_focus} truth and bow together.",
758
- "Let confetti fall; this tiny {premise_focus} chaos earned applause.",
759
- "{premise_focus} case closed, hearts open, curtain down.",
760
- "The {premise_focus} clue is home, and we bow.",
761
- "Every {premise_focus} secret has curtseyed; the curtain may rest.",
762
- "We solved the {premise_focus} wobble and saved the spotlight.",
763
- "Final bow, tiny friends; the {premise_focus} mystery exits smiling.",
764
- ],
765
- "comedic_confusion": [
766
- "I thought the {premise_focus} clue was a hat, but it was Tuesday.",
767
- "Nobody panic; I interrogated the {premise_focus} backdrop.",
768
- "The {premise_focus} map is upside down, unless we are the map.",
769
- "I bow to the {premise_focus} door, and it applauds.",
770
- "I followed the {premise_focus} clue into my sleeve.",
771
- "The spotlight blinked twice, so I blamed the {premise_focus}.",
772
- "I object, unless that {premise_focus} noise was my cue.",
773
- "The {premise_focus} evidence is backwards, or my shoes narrate.",
774
- ],
775
- }
776
-
777
- MEMORY_UPDATE_TEMPLATES = {
778
- "normal_reaction": [
779
- None,
780
- "Noted the first clear suspicion.",
781
- "Marked the new stage problem.",
782
- "Saved the backdrop's strange reaction.",
783
- ],
784
- "prop_inspection": [
785
- "Noted that the {prop} behaved like evidence.",
786
- "Remembered the {prop}'s suspicious stage-left clue.",
787
- "Logged the {prop} as useful evidence.",
788
- "Saved the prop clue for the Director.",
789
- ],
790
- "oracle_consult": [
791
- "Remembered the oracle's clue for the next beat.",
792
- "Saved the oracle question as a mystery thread.",
793
- "Noted that the oracle pointed toward the spotlight.",
794
- "Kept the oracle clue visible for the scene.",
795
- ],
796
- "lighting_change": [
797
- "Remembered the lighting shift as an emotional cue.",
798
- "Noted that the lights changed the scene's mood.",
799
- "Saved the new lighting cue for the next actor.",
800
- "Marked the spotlight change as a clue.",
801
- ],
802
- "memory_callback": [
803
- "Recalled the earlier stage-left clue.",
804
- "Connected a prior prop clue to this moment.",
805
- "Brought back the earlier curtain whisper.",
806
- "Linked the old glow to the current suspicion.",
807
- ],
808
- "secret_hint_or_reveal": [
809
- "Secret moved from hint to reveal.",
810
- "Remembered that the secret now affects the scene.",
811
- "Saved the reveal as a new complication.",
812
- "Marked the secret as publicly useful.",
813
- ],
814
- "finale": [
815
- "Scene resolved with a clean bow.",
816
- "Remembered the finale as complete.",
817
- "Closed the central mystery for the curtain.",
818
- "Saved the ending as resolved.",
819
- ],
820
- "comedic_confusion": [
821
- "Confusion briefly raised the stakes.",
822
- "Logged the mistake as playable chaos.",
823
- "Remembered the wrong clue for later comedy.",
824
- "Saved the misunderstanding as stage business.",
825
- ],
826
- }
827
-
828
-
829
- def main() -> None:
830
- parser = argparse.ArgumentParser(description=__doc__)
831
- parser.add_argument("--rows", type=int, default=None, help="Rows to generate; defaults to 1400 for v0 and 2200 for v1.")
832
- parser.add_argument("--version", choices=["v0", "v1"], default="v0", help="Dataset version to generate.")
833
- parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
834
- parser.add_argument("--val-ratio", type=float, default=0.1)
835
- parser.add_argument("--sample-rows", type=int, default=32)
836
- parser.add_argument("--eval-prompts", type=int, default=40)
837
- parser.add_argument("--data-dir", type=Path, default=Path("finetune/data"))
838
- parser.add_argument("--sample-dir", type=Path, default=Path("finetune/data_samples"))
839
- parser.add_argument(
840
- "--theatrelm-seed-path",
841
- type=Path,
842
- default=DEFAULT_THEATRELM_SEED_PATH,
843
- help="Optional local TheatreLM-style JSONL seed file.",
844
- )
845
- parser.add_argument(
846
- "--rpgpt-seed-path",
847
- type=Path,
848
- default=DEFAULT_RPGPT_SEED_PATH,
849
- help="Optional local RPGPT-style JSONL seed file.",
850
- )
851
- parser.add_argument("--max-seed-rows", type=int, default=300, help="Maximum accepted rows per external seed file.")
852
- args = parser.parse_args()
853
-
854
- if args.rows is None:
855
- args.rows = 2200 if args.version == "v1" else 1400
856
- if args.version == "v0" and not 1200 <= args.rows <= 1600:
857
- raise SystemExit("--rows must stay between 1200 and 1600 for synthetic-v0.")
858
- if args.version == "v1" and not 2000 <= args.rows <= 2500:
859
- raise SystemExit("--rows must stay between 2000 and 2500 for targeted synthetic-v1.")
860
- if not 0.05 <= args.val_ratio <= 0.25:
861
- raise SystemExit("--val-ratio must be between 0.05 and 0.25.")
862
-
863
- rng = random.Random(args.seed)
864
- args.data_dir.mkdir(parents=True, exist_ok=True)
865
- args.sample_dir.mkdir(parents=True, exist_ok=True)
866
-
867
- if args.version == "v1":
868
- synthetic_rows = [build_v1_row(index, rng) for index in range(args.rows)]
869
- seed_rows = []
870
- else:
871
- synthetic_rows = [build_row(index, rng) for index in range(args.rows)]
872
- seed_rows = build_external_seed_rows(
873
- rng=rng,
874
- start_index=len(synthetic_rows),
875
- theatrelm_path=args.theatrelm_seed_path,
876
- rpgpt_path=args.rpgpt_seed_path,
877
- max_seed_rows=args.max_seed_rows,
878
- )
879
- rows = synthetic_rows + seed_rows
880
- rng.shuffle(rows)
881
- val_count = max(1, round(len(rows) * args.val_ratio))
882
- val_rows = rows[:val_count]
883
- train_rows = rows[val_count:]
884
-
885
- dataset_path = args.data_dir / f"actor_sft_{args.version}.jsonl"
886
- train_path = args.data_dir / f"actor_sft_{args.version}_train.jsonl"
887
- val_path = args.data_dir / f"actor_sft_{args.version}_val.jsonl"
888
- sample_path = args.sample_dir / f"actor_sft_{args.version}_sample.jsonl"
889
-
890
- write_jsonl(dataset_path, rows)
891
- write_jsonl(train_path, train_rows)
892
- write_jsonl(val_path, val_rows)
893
- write_jsonl(sample_path, select_sample_rows(rows, args.sample_rows))
894
- if args.version == "v0":
895
- write_jsonl(args.sample_dir / "actor_eval_prompts.jsonl", build_eval_prompts(args.eval_prompts, rng))
896
-
897
- print(f"wrote {len(rows)} rows to {dataset_path}")
898
- print(f"wrote {len(train_rows)} train rows and {len(val_rows)} val rows")
899
- print(f"wrote sample to {sample_path}")
900
- if args.version == "v0":
901
- print(f"wrote eval prompts to {args.sample_dir / 'actor_eval_prompts.jsonl'}")
902
- if seed_rows:
903
- print(f"added {len(seed_rows)} external-seeded rows")
904
-
905
-
906
- def build_row(index: int, rng: random.Random) -> dict:
907
- row_type = ROW_TYPES[index % len(ROW_TYPES)]
908
- premise = rng.choice(PREMISES)
909
- setting = rng.choice(SETTINGS)
910
- actor = rng.choice(ACTORS)
911
- beat_index = rng.randint(0, 9)
912
- target_beats = rng.choice([7, 10, 12])
913
- prop = rng.choice(PROPS)
914
- memory = rng.choice(MEMORIES)
915
- story_phase = phase_for_row_type(row_type, beat_index, target_beats)
916
- director_instruction = rng.choice(DIRECTOR_INSTRUCTIONS[row_type])
917
- show_state = {
918
- "show_title": title_from_premise(premise),
919
- "setting": setting,
920
- "beat_index": beat_index,
921
- "min_beats": max(5, target_beats - 3),
922
- "target_beats": target_beats,
923
- "max_beats": target_beats + 2,
924
- "story_phase": story_phase,
925
- "latest_prop": prop if row_type in {"prop_inspection", "secret_hint_or_reveal"} else None,
926
- "latest_audience_action": audience_action_for(row_type, prop),
927
- "stage_lighting": rng.choice(STAGE_LIGHTING_STATES),
928
- "recent_transcript": recent_transcript(actor["name"], rng),
929
- "recent_tool_results": recent_tool_results(row_type, prop),
930
- "finale_requested": row_type == "finale",
931
- }
932
- actor_state = {
933
- **actor,
934
- "mood": rng.choice(MOODS),
935
- "current_goal": rng.choice([actor["goal"], "Use the audience interruption without losing pacing."]),
936
- "goal_progress": rng.choice(GOAL_PROGRESS),
937
- "held_props": [prop] if row_type == "prop_inspection" else [],
938
- "secret_status": secret_status_for(row_type, rng),
939
- "recent_memory": [memory] if row_type == "memory_callback" else rng.sample(MEMORIES, k=2),
940
- }
941
- assistant = build_assistant(row_type, premise, prop, actor_state, rng)
942
- return {
943
- "id": f"actor-sft-v0-{index + 1:06d}",
944
- "source_mix": ["synthetic_v0", "deterministic_templates", "ai_puppet_theater_runtime_schema"],
945
- "row_type": row_type,
946
- "messages": [
947
- {"role": "system", "content": SYSTEM_MESSAGE},
948
- {
949
- "role": "user",
950
- "content": build_user_message(premise, show_state, actor_state, director_instruction),
951
- },
952
- {"role": "assistant", "content": serialize_assistant(assistant)},
953
- ],
954
- }
955
-
956
-
957
- def build_v1_row(index: int, rng: random.Random) -> dict:
958
- row_type = V1_ROW_TYPES[index % len(V1_ROW_TYPES)]
959
- premise = rng.choice(PREMISES)
960
- setting = rng.choice(SETTINGS)
961
- actor = ensure_actor_has_tool(rng.choice(ACTORS), row_type)
962
- beat_index = rng.randint(0, 9)
963
- target_beats = rng.choice([8, 10, 12])
964
- prop = rng.choice(PROPS)
965
- memory = rng.choice(MEMORIES)
966
- story_phase = v1_phase_for_row_type(row_type, beat_index, target_beats)
967
- finale_requested = row_type == "finale"
968
- director_instruction = v1_director_instruction(row_type, rng)
969
- show_state = {
970
- "show_title": title_from_premise(premise),
971
- "setting": setting,
972
- "beat_index": beat_index,
973
- "min_beats": max(5, target_beats - 3),
974
- "target_beats": target_beats,
975
- "max_beats": target_beats + 2,
976
- "story_phase": story_phase,
977
- "latest_prop": prop if row_type in {"prop_inspection", "secret_hint_or_reveal", "memory_callback"} else None,
978
- "latest_audience_action": audience_action_for(row_type, prop),
979
- "stage_lighting": rng.choice(V1_SAFE_LIGHTING),
980
- "recent_transcript": recent_transcript(actor["name"], rng),
981
- "recent_tool_results": recent_tool_results(row_type, prop),
982
- "finale_requested": finale_requested,
983
- }
984
- actor_state = {
985
- **actor,
986
- "mood": rng.choice(MOODS),
987
- "current_goal": actor["goal"],
988
- "goal_progress": rng.choice(GOAL_PROGRESS),
989
- "held_props": [prop] if row_type in {"prop_inspection", "memory_callback"} else [],
990
- "secret_status": secret_status_for(row_type, rng),
991
- "recent_memory": [memory] if row_type == "memory_callback" else rng.sample(MEMORIES, k=2),
992
- }
993
- assistant = build_v1_assistant(row_type, premise, prop, actor_state, story_phase, finale_requested, rng)
994
- return {
995
- "id": f"actor-sft-v1-{index + 1:06d}",
996
- "source_mix": [
997
- "synthetic_v1",
998
- "targeted_hardening",
999
- "deterministic_templates",
1000
- "ai_puppet_theater_runtime_schema",
1001
- ],
1002
- "row_type": row_type,
1003
- "messages": [
1004
- {"role": "system", "content": SYSTEM_MESSAGE},
1005
- {
1006
- "role": "user",
1007
- "content": build_user_message(premise, show_state, actor_state, director_instruction),
1008
- },
1009
- {"role": "assistant", "content": serialize_assistant(assistant)},
1010
- ],
1011
- }
1012
-
1013
-
1014
- def ensure_actor_has_tool(actor: dict, row_type: str) -> dict:
1015
- required_tool = {
1016
- "prop_inspection": "inspect_prop",
1017
- "oracle_consult": "consult_stage_oracle",
1018
- "lighting_change": "change_lighting",
1019
- }.get(row_type)
1020
- if required_tool is None or required_tool in actor["tools"]:
1021
- return dict(actor)
1022
- updated = dict(actor)
1023
- updated["tools"] = [*actor["tools"], required_tool]
1024
- return updated
1025
-
1026
-
1027
- def v1_phase_for_row_type(row_type: str, beat_index: int, target_beats: int) -> str:
1028
- if row_type == "finale":
1029
- return "finale"
1030
- if row_type in {"secret_hint_or_reveal", "memory_callback"}:
1031
- return "reveal"
1032
- if row_type in {"comedic_confusion", "lighting_change"}:
1033
- return "chaos"
1034
- if row_type in {"prop_inspection", "oracle_consult"}:
1035
- return "complication"
1036
- return phase_for_row_type(row_type, beat_index, target_beats)
1037
-
1038
-
1039
- def v1_director_instruction(row_type: str, rng: random.Random) -> str:
1040
- base = rng.choice(DIRECTOR_INSTRUCTIONS[row_type])
1041
- guardrails = {
1042
- "oracle_consult": [
1043
- "Return one oracle tool request only; do not include status, result, notes, or tool_results.",
1044
- "Ask one question through consult_stage_oracle and stop after the JSON object.",
1045
- "Keep the oracle call strict: tool, args.question, and reason only.",
1046
- ],
1047
- "prop_inspection": [
1048
- "Return one inspect_prop request only; args must contain only prop.",
1049
- "Inspect exactly the latest prop and do not add result or notes fields.",
1050
- "Keep the prop call strict: tool, args.prop, and reason only.",
1051
- ],
1052
- "lighting_change": [
1053
- "Use change_lighting with args.mood only, matching the current app runtime.",
1054
- "Cue one strict lighting change and do not add status or result fields.",
1055
- "Keep the lighting call strict: tool, args.mood, and reason only.",
1056
- ],
1057
- "memory_callback": [
1058
- "Recall memory in the line only; do not copy show_state, recent_transcript, held_props, or memory fields.",
1059
- "Include the required line field and keep memory_update short or null.",
1060
- "Use memory as inspiration, not as copied output fields.",
1061
- ],
1062
- "normal_reaction": [
1063
- "Do not use deliver_finale or final_bow_lights unless finale_requested is true.",
1064
- "Move opening, complication, or chaos forward without ending the show.",
1065
- "Return exactly the seven actor fields and no copied state fields.",
1066
- ],
1067
- }
1068
- return f"{base} {rng.choice(guardrails.get(row_type, ['Return exactly one JSON object with the seven actor fields.']))}"
1069
-
1070
-
1071
- def build_v1_assistant(
1072
- row_type: str,
1073
- premise: str,
1074
- prop: str,
1075
- actor: dict,
1076
- story_phase: str,
1077
- finale_requested: bool,
1078
- rng: random.Random,
1079
- ) -> dict:
1080
- assistant = build_assistant(row_type, premise, prop, actor, rng)
1081
- assistant["intent"] = v1_intent_for(row_type, actor, story_phase, finale_requested, rng)
1082
- assistant["line"] = v1_line_for(row_type, premise, prop, rng)
1083
- assistant["stage_effect"] = v1_stage_effect_for(row_type, story_phase, finale_requested, rng)
1084
- assistant["memory_update"] = v1_memory_update_for(row_type, prop, rng)
1085
- assistant["tool_request"] = v1_tool_request_for(row_type, prop, rng)
1086
- return {field: assistant[field] for field in OUTPUT_FIELDS}
1087
-
1088
-
1089
- def v1_intent_for(row_type: str, actor: dict, story_phase: str, finale_requested: bool, rng: random.Random) -> str:
1090
- if row_type == "normal_reaction":
1091
- return rng.choice(["react_to_event", "clarify_problem"])
1092
- if row_type == "finale" and (story_phase == "finale" or finale_requested):
1093
- return "deliver_finale"
1094
- if row_type == "secret_hint_or_reveal":
1095
- return "reveal_secret" if actor.get("secret_status") == "revealed" else "hint_secret"
1096
- return {
1097
- "prop_inspection": "inspect_prop",
1098
- "oracle_consult": "consult_oracle",
1099
- "lighting_change": "change_lighting",
1100
- "memory_callback": "recall_memory",
1101
- "comedic_confusion": "comic_confusion",
1102
- "finale": "react_to_event",
1103
- }[row_type]
1104
-
1105
-
1106
- def v1_line_for(row_type: str, premise: str, prop: str, rng: random.Random) -> str:
1107
- premise_focus = premise_keyword(premise)
1108
- templates = {
1109
- **LINE_TEMPLATES,
1110
- "oracle_consult": [
1111
- "Oracle, which {premise_focus} clue deserves the next spotlight?",
1112
- "Stage oracle, guide this {premise_focus} mystery toward one clue.",
1113
- "Oracle, should the {prop} or curtain speak next?",
1114
- "I ask the oracle for one clean {premise_focus} hint.",
1115
- ],
1116
- "memory_callback": [
1117
- "The old curtain whisper points back to the {prop}.",
1118
- "I remember that glow; it followed this {premise_focus} clue.",
1119
- "Earlier, the {prop} bowed before the truth arrived.",
1120
- "That memory returns neatly, carrying the {premise_focus} clue.",
1121
- ],
1122
- "lighting_change": [
1123
- "Cue moonlit lighting; the {premise_focus} clue needs edges.",
1124
- "Shift to blue; this {prop} looks nervous in daylight.",
1125
- "Bring golden light so the {premise_focus} truth can enter.",
1126
- "Set stormy lighting; our tiny suspicion just saluted.",
1127
- ],
1128
- }
1129
- return rng.choice(templates[row_type]).format(prop=prop, thing=prop.split()[-1], premise_focus=premise_focus)
1130
-
1131
-
1132
- def v1_stage_effect_for(row_type: str, story_phase: str, finale_requested: bool, rng: random.Random) -> str:
1133
- if row_type == "normal_reaction" and story_phase != "finale" and not finale_requested:
1134
- return rng.choice(["warm_spotlight", "soft_drumroll", "painted_flat_wobble", "stage_left_glimmer"])
1135
- if row_type == "lighting_change":
1136
- return rng.choice(V1_SAFE_LIGHTING)
1137
- return rng.choice(STAGE_EFFECTS[row_type])
1138
-
1139
-
1140
- def v1_memory_update_for(row_type: str, prop: str, rng: random.Random) -> str | None:
1141
- if row_type == "normal_reaction":
1142
- return rng.choice([None, "Saved the current stage problem.", "Marked the new clue as active."])
1143
- if row_type == "memory_callback":
1144
- return rng.choice(
1145
- [
1146
- "Connected the remembered clue to this beat.",
1147
- "Saved the returned memory as useful evidence.",
1148
- "Linked the old stage detail to the current prop.",
1149
- ]
1150
- )
1151
- return memory_update_for(row_type, prop, rng)
1152
-
1153
-
1154
- def v1_tool_request_for(row_type: str, prop: str, rng: random.Random) -> dict | None:
1155
- if row_type == "prop_inspection":
1156
- return {
1157
- "tool": "inspect_prop",
1158
- "args": {"prop": prop},
1159
- "reason": rng.choice(V1_TOOL_REASONS["inspect_prop"]),
1160
- }
1161
- if row_type == "oracle_consult":
1162
- return {
1163
- "tool": "consult_stage_oracle",
1164
- "args": {"question": rng.choice(V1_ORACLE_QUESTIONS)},
1165
- "reason": rng.choice(V1_TOOL_REASONS["consult_stage_oracle"]),
1166
- }
1167
- if row_type == "lighting_change":
1168
- return {
1169
- "tool": "change_lighting",
1170
- "args": {"mood": rng.choice(V1_SAFE_LIGHTING)},
1171
- "reason": rng.choice(V1_TOOL_REASONS["change_lighting"]),
1172
- }
1173
- return None
1174
-
1175
-
1176
- def build_external_seed_rows(
1177
- *,
1178
- rng: random.Random,
1179
- start_index: int,
1180
- theatrelm_path: Path,
1181
- rpgpt_path: Path,
1182
- max_seed_rows: int,
1183
- ) -> list[dict]:
1184
- if max_seed_rows < 0:
1185
- raise SystemExit("--max-seed-rows must be 0 or greater.")
1186
-
1187
- rows: list[dict] = []
1188
- rows.extend(
1189
- build_seed_rows_from_path(
1190
- path=theatrelm_path,
1191
- seed_kind="theatrelm",
1192
- dataset_id=THEATRELM_DATASET_ID,
1193
- row_prefix="theatrelm",
1194
- rng=rng,
1195
- start_index=start_index + len(rows),
1196
- max_seed_rows=max_seed_rows,
1197
- )
1198
- )
1199
- rows.extend(
1200
- build_seed_rows_from_path(
1201
- path=rpgpt_path,
1202
- seed_kind="rpgpt",
1203
- dataset_id=RPGPT_DATASET_ID,
1204
- row_prefix="rpgpt",
1205
- rng=rng,
1206
- start_index=start_index + len(rows),
1207
- max_seed_rows=max_seed_rows,
1208
- )
1209
- )
1210
- return rows
1211
-
1212
-
1213
- def build_seed_rows_from_path(
1214
- *,
1215
- path: Path,
1216
- seed_kind: str,
1217
- dataset_id: str,
1218
- row_prefix: str,
1219
- rng: random.Random,
1220
- start_index: int,
1221
- max_seed_rows: int,
1222
- ) -> list[dict]:
1223
- if max_seed_rows == 0:
1224
- print(f"external seed ingestion disabled for {dataset_id} (--max-seed-rows=0)")
1225
- return []
1226
- if not path.exists():
1227
- print(f"optional seed file not found for {dataset_id}: {path}; continuing synthetic-only for that source")
1228
- return []
1229
-
1230
- rows: list[dict] = []
1231
- skipped = 0
1232
- malformed = 0
1233
- for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
1234
- if len(rows) >= max_seed_rows:
1235
- break
1236
- if not raw_line.strip():
1237
- continue
1238
- try:
1239
- seed = json.loads(raw_line)
1240
- except json.JSONDecodeError:
1241
- malformed += 1
1242
- continue
1243
- transformed = transform_seed_row(
1244
- seed=seed,
1245
- seed_kind=seed_kind,
1246
- dataset_id=dataset_id,
1247
- row_prefix=row_prefix,
1248
- row_number=start_index + len(rows) + 1,
1249
- source_line=line_number,
1250
- rng=rng,
1251
- )
1252
- if transformed is None:
1253
- skipped += 1
1254
- continue
1255
- rows.append(transformed)
1256
-
1257
- print(
1258
- f"loaded {len(rows)} external-seeded rows from {path} "
1259
- f"({dataset_id}; skipped={skipped}, malformed={malformed})"
1260
- )
1261
- return rows
1262
-
1263
-
1264
- def transform_seed_row(
1265
- *,
1266
- seed: Any,
1267
- seed_kind: str,
1268
- dataset_id: str,
1269
- row_prefix: str,
1270
- row_number: int,
1271
- source_line: int,
1272
- rng: random.Random,
1273
- ) -> dict | None:
1274
- if not isinstance(seed, dict):
1275
- return None
1276
-
1277
- extracted = extract_seed_material(seed, seed_kind)
1278
- if extracted is None:
1279
- return None
1280
-
1281
- row_type = choose_seed_row_type(extracted, rng)
1282
- premise = extracted["premise"]
1283
- setting = extracted["setting"]
1284
- actor = build_seed_actor(extracted, rng)
1285
- prop = choose_seed_prop(extracted, rng)
1286
- beat_index = rng.randint(0, 9)
1287
- target_beats = rng.choice([7, 10, 12])
1288
- director_instruction = choose_seed_director_instruction(row_type, extracted, rng)
1289
- show_state = {
1290
- "show_title": title_from_premise(premise),
1291
- "setting": setting,
1292
- "beat_index": beat_index,
1293
- "min_beats": max(5, target_beats - 3),
1294
- "target_beats": target_beats,
1295
- "max_beats": target_beats + 2,
1296
- "story_phase": phase_for_row_type(row_type, beat_index, target_beats),
1297
- "latest_prop": prop if row_type in {"prop_inspection", "secret_hint_or_reveal"} else None,
1298
- "latest_audience_action": audience_action_for(row_type, prop),
1299
- "stage_lighting": rng.choice(STAGE_LIGHTING_STATES),
1300
- "recent_transcript": recent_transcript(actor["name"], rng),
1301
- "recent_tool_results": recent_tool_results(row_type, prop),
1302
- "finale_requested": row_type == "finale",
1303
- "seed_source": dataset_id,
1304
- }
1305
- actor_state = {
1306
- **actor,
1307
- "mood": rng.choice(MOODS),
1308
- "current_goal": actor["goal"],
1309
- "goal_progress": rng.choice(GOAL_PROGRESS),
1310
- "held_props": [prop] if row_type == "prop_inspection" else [],
1311
- "secret_status": secret_status_for(row_type, rng),
1312
- "recent_memory": build_seed_memories(extracted, rng),
1313
- }
1314
- assistant = build_assistant(row_type, premise, prop, actor_state, rng)
1315
- return {
1316
- "id": f"actor-sft-v0-{row_prefix}-{row_number:06d}",
1317
- "source_mix": [
1318
- "synthetic_v0",
1319
- f"{seed_kind}_seed",
1320
- "deterministic_templates",
1321
- "ai_puppet_theater_runtime_schema",
1322
- ],
1323
- "source_dataset": dataset_id,
1324
- "transformation": "seeded_synthetic_actor_json",
1325
- "row_type": row_type,
1326
- "messages": [
1327
- {"role": "system", "content": SYSTEM_MESSAGE},
1328
- {
1329
- "role": "user",
1330
- "content": build_user_message(premise, show_state, actor_state, director_instruction),
1331
- },
1332
- {"role": "assistant", "content": serialize_assistant(assistant)},
1333
- ],
1334
- }
1335
-
1336
-
1337
- def extract_seed_material(seed: dict[str, Any], seed_kind: str) -> dict[str, str] | None:
1338
- fields = SEED_FIELD_GROUPS[seed_kind]
1339
- text_parts = [clean_seed_text(seed.get(field)) for field in fields if clean_seed_text(seed.get(field))]
1340
- joined_text = " ".join(text_parts)
1341
- if not joined_text or should_skip_seed_text(joined_text):
1342
- return None
1343
-
1344
- if seed_kind == "theatrelm":
1345
- character_name = clean_seed_text(seed.get("character_name")) or "Seeded Stage Guest"
1346
- character_summary = first_available_seed_text(
1347
- seed,
1348
- ["character_summary", "character_card", "lorebook"],
1349
- "A dramatic guest with a theatrical secret.",
1350
- )
1351
- setting = first_available_seed_text(
1352
- seed,
1353
- ["setting_summarized", "setting"],
1354
- "a borrowed stage with painted flats and restless curtains",
1355
- )
1356
- premise = first_available_seed_text(
1357
- seed,
1358
- ["story_outline", "story_introduction", "setting_summarized", "setting"],
1359
- f"{character_name} arrives with a stage mystery",
1360
- )
1361
- source_memory = first_available_seed_text(seed, ["story_introduction", "lorebook"], character_summary)
1362
- else:
1363
- character_name = clean_seed_text(seed.get("character")) or "Seeded Stage Guest"
1364
- character_summary = first_available_seed_text(
1365
- seed,
1366
- ["description", "character", "input"],
1367
- "A public-domain adventurer adapted into a puppet actor.",
1368
- )
1369
- setting = first_available_seed_text(
1370
- seed,
1371
- ["scenario", "input"],
1372
- "a tabletop adventure stage with cardboard scenery",
1373
- )
1374
- premise = first_available_seed_text(
1375
- seed,
1376
- ["instruction", "scenario", "input"],
1377
- f"{character_name} faces a strange public-domain stage problem",
1378
- )
1379
- source_memory = first_available_seed_text(seed, ["output", "scenario", "input"], character_summary)
1380
-
1381
- return {
1382
- "character_name": shorten_text(character_name, 60),
1383
- "character_summary": shorten_text(character_summary, 220),
1384
- "setting": seed_setting_to_stage(setting),
1385
- "premise": seed_premise_to_puppet_show(premise),
1386
- "source_memory": shorten_text(source_memory, 160),
1387
- }
1388
-
1389
-
1390
- def clean_seed_text(value: Any) -> str:
1391
- if value is None:
1392
- return ""
1393
- if isinstance(value, str):
1394
- return " ".join(value.strip().split())
1395
- if isinstance(value, list):
1396
- return " ".join(clean_seed_text(item) for item in value if clean_seed_text(item))
1397
- if isinstance(value, dict):
1398
- return " ".join(clean_seed_text(item) for item in value.values() if clean_seed_text(item))
1399
- return " ".join(str(value).strip().split())
1400
-
1401
-
1402
- def first_available_seed_text(seed: dict[str, Any], fields: list[str], fallback: str) -> str:
1403
- for field in fields:
1404
- text = clean_seed_text(seed.get(field))
1405
- if text:
1406
- return text
1407
- return fallback
1408
-
1409
-
1410
- def should_skip_seed_text(text: str) -> bool:
1411
- if len(text) > MAX_SEED_TEXT_CHARS:
1412
- return True
1413
- lowered = text.lower()
1414
- return any(term in lowered for terms in SAFETY_BLOCKLIST.values() for term in terms)
1415
-
1416
-
1417
- def seed_premise_to_puppet_show(text: str) -> str:
1418
- cleaned = shorten_text(text, 150).rstrip(".")
1419
- if not cleaned:
1420
- return "A seeded puppet guest brings a mystery to the tiny stage"
1421
- if cleaned.lower().startswith(("a ", "an ", "the ")):
1422
- return cleaned
1423
- return f"A puppet scene where {cleaned[0].lower()}{cleaned[1:]}"
1424
-
1425
-
1426
- def seed_setting_to_stage(text: str) -> str:
1427
- cleaned = shorten_text(text, 150).rstrip(".")
1428
- if not cleaned:
1429
- return "a borrowed stage with painted flats and restless curtains"
1430
- return f"a puppet-stage version of {cleaned[0].lower()}{cleaned[1:]}"
1431
-
1432
-
1433
- def build_seed_actor(extracted: dict[str, str], rng: random.Random) -> dict:
1434
- name = puppet_name_from_seed(extracted["character_name"], rng)
1435
- return {
1436
- "name": name,
1437
- "avatar": rng.choice(["mask", "scroll", "lantern", "book", "compass", "crown", "feather"]),
1438
- "goal": seed_goal_from_summary(extracted["character_summary"]),
1439
- "secret": seed_secret_from_summary(extracted["character_summary"]),
1440
- "speaking_style": rng.choice(
1441
- [
1442
- "adapted, theatrical, and slightly mysterious",
1443
- "storybook, earnest, and stage-ready",
1444
- "adventurous, concise, and puppet-bright",
1445
- "dramatic, careful, and safe for a tiny audience",
1446
- ]
1447
- ),
1448
- "tools": rng.choice(
1449
- [
1450
- ["inspect_prop"],
1451
- ["consult_stage_oracle"],
1452
- ["change_lighting"],
1453
- ["inspect_prop", "consult_stage_oracle"],
1454
- ["consult_stage_oracle", "change_lighting"],
1455
- ]
1456
- ),
1457
- }
1458
-
1459
-
1460
- def puppet_name_from_seed(raw_name: str, rng: random.Random) -> str:
1461
- words = [word.strip(".,!?;:()[]{}\"'") for word in raw_name.split() if word.strip(".,!?;:()[]{}\"'")]
1462
- if not words:
1463
- return rng.choice(["Seedling Marquee", "Borrowed Bow", "Pagefoot Lantern"])
1464
- base = " ".join(words[:2])
1465
- if len(base) < 3 or base.lower() in {"user", "assistant", "character"}:
1466
- return rng.choice(["Seedling Marquee", "Borrowed Bow", "Pagefoot Lantern"])
1467
- return shorten_text(base, 40)
1468
-
1469
-
1470
- def seed_goal_from_summary(summary: str) -> str:
1471
- focus = premise_keyword(summary)
1472
- return f"Turn the seeded {focus} detail into playable stage business."
1473
-
1474
-
1475
- def seed_secret_from_summary(summary: str) -> str:
1476
- focus = premise_keyword(summary)
1477
- return f"Knows one hidden {focus} clue but reveals it only when the Director asks."
1478
-
1479
-
1480
- def build_seed_memories(extracted: dict[str, str], rng: random.Random) -> list[str]:
1481
- seed_memory = extracted.get("source_memory", "")
1482
- memory = f"Seed memory: {shorten_text(seed_memory, 100).rstrip('.')}" if seed_memory else rng.choice(MEMORIES)
1483
- return [memory, rng.choice(MEMORIES)]
1484
-
1485
-
1486
- def choose_seed_prop(extracted: dict[str, str], rng: random.Random) -> str:
1487
- keyword = premise_keyword(" ".join([extracted["premise"], extracted["character_summary"], extracted["setting"]]))
1488
- if keyword and keyword != "stage":
1489
- return f"{keyword} token"
1490
- return rng.choice(PROPS)
1491
-
1492
-
1493
- def choose_seed_row_type(extracted: dict[str, str], rng: random.Random) -> str:
1494
- text = " ".join(extracted.values()).lower()
1495
- if any(word in text for word in ["oracle", "prophecy", "prophet", "vision", "foretell"]):
1496
- return "oracle_consult"
1497
- if any(word in text for word in ["secret", "hidden", "disguise", "mystery"]):
1498
- return rng.choice(["secret_hint_or_reveal", "memory_callback"])
1499
- if any(word in text for word in ["battle", "quest", "journey", "adventure"]):
1500
- return rng.choice(["prop_inspection", "normal_reaction", "lighting_change"])
1501
- return rng.choice(ROW_TYPES)
1502
-
1503
-
1504
- def choose_seed_director_instruction(row_type: str, extracted: dict[str, str], rng: random.Random) -> str:
1505
- base = rng.choice(DIRECTOR_INSTRUCTIONS[row_type])
1506
- focus = premise_keyword(extracted["premise"])
1507
- return f"{base} Use the seeded {focus} detail as inspiration, not raw dialogue."
1508
-
1509
-
1510
- def shorten_text(text: str, limit: int) -> str:
1511
- cleaned = " ".join(text.strip().split())
1512
- if len(cleaned) <= limit:
1513
- return cleaned
1514
- truncated = cleaned[: limit - 1].rsplit(" ", 1)[0]
1515
- return truncated or cleaned[:limit]
1516
-
1517
-
1518
- def build_eval_prompts(count: int, rng: random.Random) -> list[dict]:
1519
- rows = []
1520
- for index in range(count):
1521
- row_type = ROW_TYPES[index % len(ROW_TYPES)]
1522
- premise = rng.choice(PREMISES)
1523
- prop = rng.choice(PROPS)
1524
- actor = rng.choice(ACTORS)
1525
- show_state = {
1526
- "show_title": title_from_premise(premise),
1527
- "setting": rng.choice(SETTINGS),
1528
- "beat_index": index % 10,
1529
- "min_beats": 7,
1530
- "target_beats": 10,
1531
- "max_beats": 12,
1532
- "story_phase": phase_for_row_type(row_type, index % 10, 10),
1533
- "latest_prop": prop if row_type in {"prop_inspection", "secret_hint_or_reveal"} else None,
1534
- "latest_audience_action": audience_action_for(row_type, prop),
1535
- "stage_lighting": rng.choice(STAGE_LIGHTING_STATES),
1536
- "recent_transcript": recent_transcript(actor["name"], rng),
1537
- "recent_tool_results": recent_tool_results(row_type, prop),
1538
- "finale_requested": row_type == "finale",
1539
- }
1540
- actor_state = {
1541
- **actor,
1542
- "mood": rng.choice(MOODS),
1543
- "current_goal": actor["goal"],
1544
- "goal_progress": rng.choice(GOAL_PROGRESS),
1545
- "held_props": [prop] if row_type == "prop_inspection" else [],
1546
- "secret_status": "hinted" if row_type == "secret_hint_or_reveal" else "hidden",
1547
- "recent_memory": rng.sample(MEMORIES, k=2),
1548
- }
1549
- rows.append(
1550
- {
1551
- "id": f"actor-eval-v0-{index + 1:03d}",
1552
- "source_mix": ["synthetic_v0_eval_prompts"],
1553
- "row_type": row_type,
1554
- "messages": [
1555
- {"role": "system", "content": SYSTEM_MESSAGE},
1556
- {
1557
- "role": "user",
1558
- "content": build_user_message(
1559
- premise,
1560
- show_state,
1561
- actor_state,
1562
- rng.choice(DIRECTOR_INSTRUCTIONS[row_type]),
1563
- ),
1564
- },
1565
- ],
1566
- }
1567
- )
1568
- return rows
1569
-
1570
-
1571
- def select_sample_rows(rows: list[dict], count: int) -> list[dict]:
1572
- grouped = {row_type: [row for row in rows if row["row_type"] == row_type] for row_type in ROW_TYPES}
1573
- sample: list[dict] = []
1574
- while len(sample) < count and any(grouped.values()):
1575
- for row_type in ROW_TYPES:
1576
- if grouped[row_type] and len(sample) < count:
1577
- sample.append(grouped[row_type].pop(0))
1578
- return sample
1579
-
1580
-
1581
- def build_user_message(premise: str, show_state: dict, actor: dict, director_instruction: str) -> str:
1582
- return "\n".join(
1583
- [
1584
- f"premise: {premise}",
1585
- f"show_state JSON: {json.dumps(show_state, sort_keys=True, separators=(',', ':'))}",
1586
- f"actor JSON: {json.dumps(actor, sort_keys=True, separators=(',', ':'))}",
1587
- f"director_instruction: {director_instruction}",
1588
- ]
1589
- )
1590
-
1591
-
1592
- def build_assistant(row_type: str, premise: str, prop: str, actor: dict, rng: random.Random) -> dict:
1593
- premise_focus = premise_keyword(premise)
1594
- line = rng.choice(LINE_TEMPLATES[row_type]).format(
1595
- prop=prop,
1596
- thing=prop.split()[-1],
1597
- premise_focus=premise_focus,
1598
- )
1599
- memory_update = memory_update_for(row_type, prop, rng)
1600
- tool_request = tool_request_for(row_type, prop, actor, rng)
1601
- return {
1602
- "intent": intent_for(row_type, actor, rng),
1603
- "line": line,
1604
- "emotion": rng.choice(EMOTIONS[row_type]),
1605
- "gesture": rng.choice(GESTURES[row_type]),
1606
- "stage_effect": rng.choice(STAGE_EFFECTS[row_type]),
1607
- "memory_update": memory_update,
1608
- "tool_request": tool_request,
1609
- }
1610
-
1611
-
1612
- def tool_request_for(row_type: str, prop: str, actor: dict, rng: random.Random) -> dict | None:
1613
- if row_type == "prop_inspection" and "inspect_prop" in actor["tools"]:
1614
- return {"tool": "inspect_prop", "args": {"prop": prop}, "reason": "The prop may reveal a stage clue."}
1615
- if row_type == "oracle_consult" and "consult_stage_oracle" in actor["tools"]:
1616
- return {
1617
- "tool": "consult_stage_oracle",
1618
- "args": {"question": rng.choice(["Which clue wants the spotlight?", "What should we notice next?"])},
1619
- "reason": "The oracle can sharpen the next beat.",
1620
- }
1621
- if row_type == "lighting_change" and "change_lighting" in actor["tools"]:
1622
- return {
1623
- "tool": "change_lighting",
1624
- "args": {"mood": rng.choice(["moonlit mystery", "golden reveal", "stormy confusion", "warm suspicion", "blue apology"])},
1625
- "reason": "Lighting should clarify the emotional turn.",
1626
- }
1627
- return None
1628
-
1629
-
1630
- def serialize_assistant(value: dict) -> str:
1631
- ordered = {field: value[field] for field in OUTPUT_FIELDS}
1632
- return json.dumps(ordered, ensure_ascii=True, separators=(",", ":"))
1633
-
1634
-
1635
- def write_jsonl(path: Path, rows: list[dict]) -> None:
1636
- with path.open("w", encoding="utf-8") as handle:
1637
- for row in rows:
1638
- handle.write(json.dumps(row, ensure_ascii=True, separators=(",", ":")) + "\n")
1639
-
1640
-
1641
- def title_from_premise(premise: str) -> str:
1642
- words = [word.strip(".,!?;:()[]{}\"'") for word in premise.split()]
1643
- keywords = [word.title() for word in words if len(word.strip(".,!?;:()[]{}\"'")) > 3]
1644
- return f"The {' '.join(keywords[:4])}" if keywords else "The Tiny Improv"
1645
-
1646
-
1647
- def phase_for_row_type(row_type: str, beat_index: int, target_beats: int) -> str:
1648
- if row_type in {"finale"}:
1649
- return "finale"
1650
- if row_type in {"secret_hint_or_reveal", "memory_callback"}:
1651
- return "reveal"
1652
- if row_type in {"comedic_confusion", "lighting_change"}:
1653
- return "chaos"
1654
- if row_type in {"prop_inspection", "oracle_consult"}:
1655
- return "complication"
1656
- progress = beat_index / max(1, target_beats)
1657
- if progress < 0.2:
1658
- return "opening"
1659
- if progress < 0.65:
1660
- return "complication"
1661
- return "chaos"
1662
-
1663
-
1664
- def audience_action_for(row_type: str, prop: str) -> str | None:
1665
- if row_type == "prop_inspection":
1666
- return f"Audience threw {prop} onto the stage."
1667
- if row_type == "comedic_confusion":
1668
- return "Audience heckled: That clue is wearing a hat."
1669
- if row_type == "finale":
1670
- return "Audience requested a finale."
1671
- return None
1672
-
1673
-
1674
- def recent_transcript(actor_name: str, rng: random.Random) -> list[dict]:
1675
- speakers = [name for name in [a["name"] for a in ACTORS] if name != actor_name]
1676
- return [
1677
- {
1678
- "speaker": rng.choice(speakers),
1679
- "line": rng.choice(LINE_TEMPLATES["normal_reaction"]).format(
1680
- prop="prop",
1681
- thing="clue",
1682
- premise_focus="stage",
1683
- ),
1684
- },
1685
- {
1686
- "speaker": actor_name,
1687
- "line": rng.choice(LINE_TEMPLATES["comedic_confusion"]).format(
1688
- prop="prop",
1689
- thing="clue",
1690
- premise_focus="stage",
1691
- ),
1692
- },
1693
- ]
1694
-
1695
-
1696
- def recent_tool_results(row_type: str, prop: str) -> list[dict]:
1697
- if row_type == "memory_callback":
1698
- return [{"tool": "inspect_prop", "result": f"The {prop} pointed stage left.", "stage_effect": "prop_table_glow"}]
1699
- if row_type == "secret_hint_or_reveal":
1700
- return [{"tool": "consult_stage_oracle", "result": "Secrets knock twice before entering.", "stage_effect": "oracle_haze"}]
1701
- return []
1702
-
1703
-
1704
- def secret_status_for(row_type: str, rng: random.Random) -> str:
1705
- if row_type == "secret_hint_or_reveal":
1706
- return rng.choice(["hinted", "revealed"])
1707
- if row_type == "finale":
1708
- return rng.choice(["revealed", "resolved"])
1709
- return rng.choice(["hidden", "hinted"])
1710
-
1711
-
1712
- def memory_update_for(row_type: str, prop: str, rng: random.Random) -> str | None:
1713
- template = rng.choice(MEMORY_UPDATE_TEMPLATES[row_type])
1714
- if template is None:
1715
- return None
1716
- return template.format(prop=prop)
1717
-
1718
-
1719
- def intent_for(row_type: str, actor: dict, rng: random.Random) -> str:
1720
- if row_type == "normal_reaction":
1721
- return rng.choice(["react_to_event", "clarify_problem"])
1722
- if row_type == "secret_hint_or_reveal":
1723
- return "reveal_secret" if actor.get("secret_status") == "revealed" else "hint_secret"
1724
- return {
1725
- "prop_inspection": "inspect_prop",
1726
- "oracle_consult": "consult_oracle",
1727
- "lighting_change": "change_lighting",
1728
- "memory_callback": "recall_memory",
1729
- "finale": "deliver_finale",
1730
- "comedic_confusion": "comic_confusion",
1731
- }[row_type]
1732
-
1733
-
1734
- def premise_keyword(premise: str) -> str:
1735
- stopwords = {
1736
- "about",
1737
- "after",
1738
- "asks",
1739
- "become",
1740
- "because",
1741
- "before",
1742
- "inside",
1743
- "into",
1744
- "keeps",
1745
- "last",
1746
- "that",
1747
- "their",
1748
- "this",
1749
- "until",
1750
- "wearing",
1751
- "which",
1752
- "with",
1753
- }
1754
- words = [word.strip(".,!?;:()[]{}\"'").lower() for word in premise.split()]
1755
- candidates = [word for word in words if len(word) > 3 and word not in stopwords]
1756
- return candidates[0] if candidates else "stage"
1757
-
1758
-
1759
- if __name__ == "__main__":
1760
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/merge_actor_lora.py DELETED
@@ -1,92 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Merge the current best Actor LoRA adapter into MiniCPM5-1B."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- from datetime import datetime, timezone
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- DEFAULT_BASE_MODEL = "openbmb/MiniCPM5-1B"
14
- DEFAULT_ADAPTER_DIR = Path("finetune/minicpm5-actor-lora")
15
- DEFAULT_OUTPUT_DIR = Path("finetune/outputs/minicpm5-actor-merged")
16
-
17
-
18
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
19
- parser = argparse.ArgumentParser(description=__doc__)
20
- parser.add_argument("--base_model", default=DEFAULT_BASE_MODEL)
21
- parser.add_argument("--adapter_dir", type=Path, default=DEFAULT_ADAPTER_DIR)
22
- parser.add_argument("--output_dir", type=Path, default=DEFAULT_OUTPUT_DIR)
23
- parser.add_argument("--dtype", choices=["bfloat16", "float16", "float32"], default="bfloat16")
24
- parser.add_argument("--trust_remote_code", action=argparse.BooleanOptionalAction, default=True)
25
- return parser.parse_args(argv)
26
-
27
-
28
- def main(argv: list[str] | None = None) -> None:
29
- args = parse_args(argv)
30
- merge_lora(args)
31
-
32
-
33
- def merge_lora(args: argparse.Namespace) -> None:
34
- import torch
35
- from peft import PeftModel
36
- from transformers import AutoModelForCausalLM, AutoTokenizer
37
-
38
- if not args.adapter_dir.exists():
39
- raise FileNotFoundError(f"Adapter directory does not exist: {args.adapter_dir}")
40
-
41
- args.output_dir.mkdir(parents=True, exist_ok=True)
42
- dtype = resolve_torch_dtype(args.dtype, torch)
43
- print(f"Loading base model: {args.base_model}")
44
- base_model = AutoModelForCausalLM.from_pretrained(
45
- args.base_model,
46
- torch_dtype=dtype,
47
- device_map="auto",
48
- trust_remote_code=args.trust_remote_code,
49
- )
50
- print(f"Loading adapter: {args.adapter_dir}")
51
- model = PeftModel.from_pretrained(base_model, args.adapter_dir)
52
- print("Merging LoRA adapter into base model")
53
- merged_model = model.merge_and_unload()
54
- print(f"Saving merged model to: {args.output_dir}")
55
- merged_model.save_pretrained(str(args.output_dir), safe_serialization=True)
56
-
57
- tokenizer_source = args.adapter_dir if (args.adapter_dir / "tokenizer_config.json").exists() else args.base_model
58
- tokenizer = AutoTokenizer.from_pretrained(
59
- tokenizer_source,
60
- trust_remote_code=args.trust_remote_code,
61
- use_fast=True,
62
- )
63
- tokenizer.save_pretrained(str(args.output_dir))
64
- write_merge_manifest(args.output_dir, args)
65
- print(f"Saved merged model to {args.output_dir}")
66
-
67
-
68
- def resolve_torch_dtype(raw_dtype: str, torch: Any) -> Any:
69
- return {
70
- "bfloat16": torch.bfloat16,
71
- "float16": torch.float16,
72
- "float32": torch.float32,
73
- }[raw_dtype]
74
-
75
-
76
- def write_merge_manifest(output_dir: Path, args: argparse.Namespace) -> None:
77
- manifest = {
78
- "base_model": args.base_model,
79
- "adapter_path": str(args.adapter_dir),
80
- "output_dir": str(args.output_dir),
81
- "timestamp": datetime.now(timezone.utc).isoformat(),
82
- "dtype": args.dtype,
83
- "trust_remote_code": args.trust_remote_code,
84
- "note": "v0 is the current best Actor LoRA candidate; v1 was useful for audit/eval tooling but did not outperform v0 on full eval.",
85
- }
86
- with (output_dir / "merge_manifest.json").open("w", encoding="utf-8") as handle:
87
- json.dump(manifest, handle, ensure_ascii=True, indent=2)
88
- handle.write("\n")
89
-
90
-
91
- if __name__ == "__main__":
92
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/prepare_gguf_publish.py DELETED
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Prepare local GGUF files for manual Hugging Face publishing.
3
-
4
- This script performs no network calls and never runs `hf upload`.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import json
11
- import shutil
12
- from datetime import datetime, timezone
13
- from pathlib import Path
14
- from typing import Any
15
-
16
-
17
- DEFAULT_GGUF_FILE = Path("finetune/outputs/gguf/minicpm5-actor-q4_k_m.gguf")
18
- DEFAULT_OUTPUT_DIR = Path("finetune/publish_gguf")
19
- DEFAULT_MODEL_CARD = Path("finetune/model_cards/actor_gguf_README.md")
20
- DEFAULT_EVAL_FILE = Path("finetune/eval_outputs/minicpm5_actor_gguf_eval.jsonl")
21
- DEFAULT_REPO_ID = "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF"
22
- COMMIT_MESSAGE = "Add Q4_K_M GGUF actor model"
23
-
24
-
25
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
26
- parser = argparse.ArgumentParser(description=__doc__)
27
- parser.add_argument("--gguf_file", type=Path, default=DEFAULT_GGUF_FILE)
28
- parser.add_argument("--output_dir", type=Path, default=DEFAULT_OUTPUT_DIR)
29
- parser.add_argument("--model_card", type=Path, default=DEFAULT_MODEL_CARD)
30
- parser.add_argument("--eval_file", type=Path, default=DEFAULT_EVAL_FILE)
31
- parser.add_argument("--repo_id", default=DEFAULT_REPO_ID)
32
- parser.add_argument("--dry_run", action="store_true", help="Print the plan without copying files.")
33
- parser.add_argument("--clean", action="store_true", help="Remove output_dir before preparing files.")
34
- return parser.parse_args(argv)
35
-
36
-
37
- def main(argv: list[str] | None = None) -> None:
38
- args = parse_args(argv)
39
- plan = build_plan(args)
40
- print_plan(plan, args.dry_run)
41
- print_manual_publish_commands(args.repo_id, args.output_dir)
42
- if args.dry_run:
43
- return
44
- prepare_files(args, plan)
45
-
46
-
47
- def build_plan(args: argparse.Namespace) -> dict[str, Any]:
48
- gguf_file = args.gguf_file
49
- model_card = args.model_card
50
- eval_file = args.eval_file
51
- if not gguf_file.exists():
52
- raise SystemExit(f"GGUF file does not exist: {gguf_file}")
53
- if not gguf_file.is_file():
54
- raise SystemExit(f"GGUF path is not a file: {gguf_file}")
55
- if not model_card.exists():
56
- raise SystemExit(f"Model card does not exist: {model_card}")
57
-
58
- files = [
59
- {"source": gguf_file, "dest": args.output_dir / gguf_file.name, "required": True},
60
- {"source": model_card, "dest": args.output_dir / "README.md", "required": True},
61
- ]
62
- eval_present = eval_file.exists() and eval_file.is_file()
63
- if eval_present:
64
- files.append(
65
- {
66
- "source": eval_file,
67
- "dest": args.output_dir / "eval" / eval_file.name,
68
- "required": False,
69
- }
70
- )
71
- return {
72
- "repo_id": args.repo_id,
73
- "output_dir": args.output_dir,
74
- "files": files,
75
- "eval_present": eval_present,
76
- }
77
-
78
-
79
- def print_plan(plan: dict[str, Any], dry_run: bool) -> None:
80
- if dry_run:
81
- print("DRY RUN: no files will be copied and no network calls will be made.")
82
- else:
83
- print("Preparing local GGUF publish directory. No network calls will be made.")
84
- print(f"repo_id: {plan['repo_id']}")
85
- print(f"output_dir: {plan['output_dir']}")
86
- print("files:")
87
- for file_plan in plan["files"]:
88
- required = "required" if file_plan["required"] else "optional"
89
- print(f" {file_plan['source']} -> {file_plan['dest']} ({required})")
90
-
91
-
92
- def prepare_files(args: argparse.Namespace, plan: dict[str, Any]) -> None:
93
- output_dir = args.output_dir
94
- if args.clean and output_dir.exists():
95
- shutil.rmtree(output_dir)
96
- output_dir.mkdir(parents=True, exist_ok=True)
97
-
98
- copied_files: list[dict[str, Any]] = []
99
- for file_plan in plan["files"]:
100
- source = file_plan["source"]
101
- dest = file_plan["dest"]
102
- dest.parent.mkdir(parents=True, exist_ok=True)
103
- shutil.copy2(source, dest)
104
- copied_files.append(
105
- {
106
- "source": str(source),
107
- "path": str(dest.relative_to(output_dir)),
108
- "bytes": dest.stat().st_size,
109
- "required": file_plan["required"],
110
- }
111
- )
112
- print(f"copied {source} -> {dest}")
113
-
114
- manifest = {
115
- "repo_id": args.repo_id,
116
- "created_at": datetime.now(timezone.utc).isoformat(),
117
- "output_dir": str(output_dir),
118
- "source_gguf_file": str(args.gguf_file),
119
- "model_card": str(args.model_card),
120
- "eval_file": str(args.eval_file) if plan["eval_present"] else None,
121
- "files": copied_files,
122
- "notes": [
123
- "Prep-only local staging directory.",
124
- "No Hugging Face API calls or uploads were performed by this script.",
125
- "Q4_K_M GGUF is the current llama.cpp artifact for the AI Puppet Theater Actor model.",
126
- ],
127
- }
128
- manifest_path = output_dir / "publish_manifest.json"
129
- manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
130
- print(f"wrote {manifest_path}")
131
- print(f"prepared GGUF publish directory: {output_dir}")
132
-
133
-
134
- def print_manual_publish_commands(repo_id: str, output_dir: Path) -> None:
135
- print("\nManual publish commands, not executed by this script:")
136
- print(f"hf repo create {repo_id} --type model --public")
137
- print(
138
- f'hf upload {repo_id} {output_dir} . --repo-type model '
139
- f'--commit-message "{COMMIT_MESSAGE}"'
140
- )
141
-
142
-
143
- if __name__ == "__main__":
144
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/publish_actor_lora_adapter.py DELETED
@@ -1,111 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Publish the MiniCPM5 Actor LoRA adapter to Hugging Face Hub.
3
-
4
- Dry-run mode performs no network calls and is safe to run without HF auth.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import os
11
- from pathlib import Path
12
-
13
-
14
- DEFAULT_REPO_ID = "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA"
15
- DEFAULT_ADAPTER_DIR = Path("finetune/minicpm5-actor-lora")
16
- DEFAULT_CARD = Path("finetune/model_cards/actor_lora_v0_README.md")
17
- PREFERRED_FILES = [
18
- "adapter_config.json",
19
- "adapter_model.safetensors",
20
- "training_manifest.json",
21
- "tokenizer.json",
22
- "tokenizer_config.json",
23
- "special_tokens_map.json",
24
- "generation_config.json",
25
- "README.md",
26
- ]
27
-
28
-
29
- def parse_args() -> argparse.Namespace:
30
- parser = argparse.ArgumentParser(description=__doc__)
31
- parser.add_argument("--repo_id", default=DEFAULT_REPO_ID)
32
- parser.add_argument("--adapter_dir", type=Path, default=DEFAULT_ADAPTER_DIR)
33
- parser.add_argument("--card", type=Path, default=DEFAULT_CARD)
34
- parser.add_argument("--private", action="store_true")
35
- parser.add_argument("--include_checkpoints", action="store_true", help="Also upload checkpoint-* directories. Off by default.")
36
- parser.add_argument("--dry_run", action=argparse.BooleanOptionalAction, default=True)
37
- return parser.parse_args()
38
-
39
-
40
- def main() -> None:
41
- args = parse_args()
42
- files = collect_files(args.adapter_dir, args.card, args.include_checkpoints)
43
- if args.dry_run:
44
- print_plan(args.repo_id, args.private, args.adapter_dir, files)
45
- return
46
- publish_model(args.repo_id, args.private, files)
47
-
48
-
49
- def collect_files(adapter_dir: Path, card: Path, include_checkpoints: bool) -> list[tuple[Path, str]]:
50
- files: list[tuple[Path, str]] = []
51
- if card.exists():
52
- files.append((card, "README.md"))
53
- else:
54
- print(f"warning: model card missing: {card}")
55
- if not adapter_dir.exists():
56
- print(f"warning: adapter directory missing: {adapter_dir}")
57
- return files
58
-
59
- seen_repo_paths = {"README.md"} if card.exists() else set()
60
- for name in PREFERRED_FILES:
61
- path = adapter_dir / name
62
- if path.exists() and name not in seen_repo_paths:
63
- files.append((path, name))
64
- seen_repo_paths.add(name)
65
- elif name in {"adapter_config.json", "adapter_model.safetensors"} and not path.exists():
66
- print(f"warning: required adapter file missing at output root: {path}")
67
-
68
- if include_checkpoints:
69
- for path in sorted(adapter_dir.glob("checkpoint-*/*")):
70
- if path.is_file():
71
- repo_path = str(path.relative_to(adapter_dir))
72
- if repo_path not in seen_repo_paths:
73
- files.append((path, repo_path))
74
- seen_repo_paths.add(repo_path)
75
- return files
76
-
77
-
78
- def print_plan(repo_id: str, private: bool, adapter_dir: Path, files: list[tuple[Path, str]]) -> None:
79
- print("DRY RUN: no Hugging Face network calls or uploads will be performed.")
80
- print(f"repo_type: model")
81
- print(f"repo_id: {repo_id}")
82
- print(f"private: {private}")
83
- print(f"adapter_dir: {adapter_dir}")
84
- print("files:")
85
- for local_path, repo_path in files:
86
- print(f" {local_path} -> {repo_path}")
87
-
88
-
89
- def publish_model(repo_id: str, private: bool, files: list[tuple[Path, str]]) -> None:
90
- token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
91
- if not token:
92
- raise SystemExit("HF_TOKEN or HUGGINGFACEHUB_API_TOKEN is required for a real upload. Re-run with --dry_run to inspect the plan.")
93
- if not files:
94
- raise SystemExit("No files found to upload.")
95
- from huggingface_hub import HfApi
96
-
97
- api = HfApi(token=token)
98
- api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
99
- for local_path, repo_path in files:
100
- api.upload_file(
101
- path_or_fileobj=str(local_path),
102
- path_in_repo=repo_path,
103
- repo_id=repo_id,
104
- repo_type="model",
105
- )
106
- print(f"uploaded {local_path} -> {repo_path}")
107
- print(f"model publish complete: https://huggingface.co/{repo_id}")
108
-
109
-
110
- if __name__ == "__main__":
111
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/publish_actor_sft_dataset.py DELETED
@@ -1,106 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Publish the AI Puppet Theater Actor SFT dataset to Hugging Face Hub.
3
-
4
- Dry-run mode performs no network calls and is safe to run without HF auth.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import argparse
10
- import os
11
- from pathlib import Path
12
-
13
-
14
- DEFAULT_REPO_ID = "build-small-hackathon/AI-Puppet-Theater-Actor-SFT"
15
- DEFAULT_DATASET_DIR = Path("finetune/data")
16
- DEFAULT_SAMPLE_DIR = Path("finetune/data_samples")
17
- DEFAULT_CARD = Path("finetune/dataset_cards/actor_sft_README.md")
18
- DATASET_FILES = [
19
- "actor_sft_v0.jsonl",
20
- "actor_sft_v0_train.jsonl",
21
- "actor_sft_v0_val.jsonl",
22
- "actor_sft_v1.jsonl",
23
- "actor_sft_v1_train.jsonl",
24
- "actor_sft_v1_val.jsonl",
25
- ]
26
- SAMPLE_FILES = [
27
- "actor_sft_v0_sample.jsonl",
28
- "actor_sft_v1_sample.jsonl",
29
- "actor_eval_prompts.jsonl",
30
- ]
31
-
32
-
33
- def parse_args() -> argparse.Namespace:
34
- parser = argparse.ArgumentParser(description=__doc__)
35
- parser.add_argument("--repo_id", default=DEFAULT_REPO_ID)
36
- parser.add_argument("--dataset_dir", type=Path, default=DEFAULT_DATASET_DIR)
37
- parser.add_argument("--sample_dir", type=Path, default=DEFAULT_SAMPLE_DIR)
38
- parser.add_argument("--card", type=Path, default=DEFAULT_CARD)
39
- parser.add_argument("--private", action="store_true")
40
- parser.add_argument("--dry_run", action=argparse.BooleanOptionalAction, default=True)
41
- return parser.parse_args()
42
-
43
-
44
- def main() -> None:
45
- args = parse_args()
46
- files = collect_files(args.dataset_dir, args.sample_dir, args.card)
47
- if args.dry_run:
48
- print_plan(args.repo_id, args.private, files)
49
- return
50
- publish_dataset(args.repo_id, args.private, files)
51
-
52
-
53
- def collect_files(dataset_dir: Path, sample_dir: Path, card: Path) -> list[tuple[Path, str]]:
54
- files: list[tuple[Path, str]] = []
55
- if card.exists():
56
- files.append((card, "README.md"))
57
- else:
58
- print(f"warning: dataset card missing: {card}")
59
- for name in DATASET_FILES:
60
- path = dataset_dir / name
61
- if path.exists():
62
- files.append((path, f"data/{name}"))
63
- else:
64
- print(f"warning: dataset file missing: {path}")
65
- for name in SAMPLE_FILES:
66
- path = sample_dir / name
67
- if path.exists():
68
- files.append((path, f"data_samples/{name}"))
69
- else:
70
- print(f"warning: sample file missing: {path}")
71
- return files
72
-
73
-
74
- def print_plan(repo_id: str, private: bool, files: list[tuple[Path, str]]) -> None:
75
- print("DRY RUN: no Hugging Face network calls or uploads will be performed.")
76
- print(f"repo_type: dataset")
77
- print(f"repo_id: {repo_id}")
78
- print(f"private: {private}")
79
- print("files:")
80
- for local_path, repo_path in files:
81
- print(f" {local_path} -> {repo_path}")
82
-
83
-
84
- def publish_dataset(repo_id: str, private: bool, files: list[tuple[Path, str]]) -> None:
85
- token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
86
- if not token:
87
- raise SystemExit("HF_TOKEN or HUGGINGFACEHUB_API_TOKEN is required for a real upload. Re-run with --dry_run to inspect the plan.")
88
- if not files:
89
- raise SystemExit("No files found to upload.")
90
- from huggingface_hub import HfApi
91
-
92
- api = HfApi(token=token)
93
- api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True)
94
- for local_path, repo_path in files:
95
- api.upload_file(
96
- path_or_fileobj=str(local_path),
97
- path_in_repo=repo_path,
98
- repo_id=repo_id,
99
- repo_type="dataset",
100
- )
101
- print(f"uploaded {local_path} -> {repo_path}")
102
- print(f"dataset publish complete: https://huggingface.co/datasets/{repo_id}")
103
-
104
-
105
- if __name__ == "__main__":
106
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/train_minicpm5_actor_lora.py DELETED
@@ -1,347 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Fine-tune MiniCPM5-1B for AI Puppet Theater Actor JSON with LoRA/QLoRA.
3
-
4
- This script is intentionally separate from the Gradio app runtime. Heavy
5
- training dependencies are imported inside training functions so the module can
6
- be imported in lightweight environments.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import json
13
- from pathlib import Path
14
- from typing import Any
15
-
16
-
17
- DEFAULT_MODEL_NAME = "openbmb/MiniCPM5-1B"
18
- DEFAULT_TRAIN_FILE = Path("finetune/data/actor_sft_v0_train.jsonl")
19
- DEFAULT_VAL_FILE = Path("finetune/data/actor_sft_v0_val.jsonl")
20
- DEFAULT_OUTPUT_DIR = Path("finetune/outputs/minicpm5-actor-lora")
21
- DEFAULT_TARGET_MODULES = [
22
- "q_proj",
23
- "k_proj",
24
- "v_proj",
25
- "o_proj",
26
- "gate_proj",
27
- "up_proj",
28
- "down_proj",
29
- ]
30
-
31
-
32
- def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
33
- parser = argparse.ArgumentParser(description=__doc__)
34
- parser.add_argument("--model_name", default=DEFAULT_MODEL_NAME)
35
- parser.add_argument("--train_file", type=Path, default=DEFAULT_TRAIN_FILE)
36
- parser.add_argument("--val_file", type=Path, default=DEFAULT_VAL_FILE)
37
- parser.add_argument("--output_dir", type=Path, default=DEFAULT_OUTPUT_DIR)
38
- parser.add_argument("--max_seq_length", type=int, default=1024)
39
- parser.add_argument("--epochs", type=float, default=2.0)
40
- parser.add_argument("--learning_rate", type=float, default=2e-4)
41
- parser.add_argument("--lora_rank", type=int, default=16)
42
- parser.add_argument("--lora_alpha", type=int, default=32)
43
- parser.add_argument("--lora_dropout", type=float, default=0.05)
44
- parser.add_argument("--per_device_train_batch_size", type=int, default=2)
45
- parser.add_argument("--per_device_eval_batch_size", type=int, default=2)
46
- parser.add_argument("--gradient_accumulation_steps", type=int, default=8)
47
- parser.add_argument("--max_train_samples", type=int, default=None)
48
- parser.add_argument("--max_eval_samples", type=int, default=None)
49
- parser.add_argument("--seed", type=int, default=42)
50
- parser.add_argument("--logging_steps", type=int, default=5)
51
- parser.add_argument("--save_strategy", default="epoch")
52
- parser.add_argument("--eval_strategy", default="epoch")
53
- parser.add_argument("--warmup_ratio", type=float, default=0.03)
54
- parser.add_argument("--weight_decay", type=float, default=0.0)
55
- parser.add_argument("--gradient_checkpointing", action=argparse.BooleanOptionalAction, default=True)
56
- parser.add_argument("--use_qlora", action=argparse.BooleanOptionalAction, default=True)
57
- parser.add_argument("--trust_remote_code", action=argparse.BooleanOptionalAction, default=True)
58
- parser.add_argument("--target_modules", default=",".join(DEFAULT_TARGET_MODULES))
59
- parser.add_argument("--report_to", default="none")
60
- parser.add_argument("--push_to_hub", action="store_true")
61
- parser.add_argument("--hub_model_id", default=None)
62
- return parser.parse_args(argv)
63
-
64
-
65
- def main(argv: list[str] | None = None) -> None:
66
- args = parse_args(argv)
67
- train(args)
68
-
69
-
70
- def train(args: argparse.Namespace) -> None:
71
- import torch
72
- from datasets import Dataset
73
- from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
74
- from transformers import (
75
- AutoModelForCausalLM,
76
- AutoTokenizer,
77
- BitsAndBytesConfig,
78
- DataCollatorForLanguageModeling,
79
- Trainer,
80
- TrainingArguments,
81
- set_seed,
82
- )
83
- from trl import SFTTrainer
84
-
85
- set_seed(args.seed)
86
- args.output_dir.mkdir(parents=True, exist_ok=True)
87
-
88
- tokenizer = AutoTokenizer.from_pretrained(
89
- args.model_name,
90
- trust_remote_code=args.trust_remote_code,
91
- use_fast=True,
92
- )
93
- if tokenizer.pad_token is None:
94
- tokenizer.pad_token = tokenizer.eos_token
95
- tokenizer.padding_side = "right"
96
-
97
- train_rows = load_jsonl_rows(args.train_file, args.max_train_samples)
98
- eval_rows = load_jsonl_rows(args.val_file, args.max_eval_samples)
99
- train_dataset = Dataset.from_list(
100
- [{"text": format_messages_as_chat(row["messages"], tokenizer)} for row in train_rows]
101
- )
102
- eval_dataset = Dataset.from_list(
103
- [{"text": format_messages_as_chat(row["messages"], tokenizer)} for row in eval_rows]
104
- )
105
-
106
- bf16 = bool(torch.cuda.is_available() and torch.cuda.is_bf16_supported())
107
- fp16 = bool(torch.cuda.is_available() and not bf16)
108
- quantization_config = None
109
- model_kwargs: dict[str, Any] = {
110
- "trust_remote_code": args.trust_remote_code,
111
- "torch_dtype": torch.bfloat16 if bf16 else torch.float16,
112
- "device_map": "auto",
113
- }
114
- if args.use_qlora:
115
- quantization_config = BitsAndBytesConfig(
116
- load_in_4bit=True,
117
- bnb_4bit_use_double_quant=True,
118
- bnb_4bit_quant_type="nf4",
119
- bnb_4bit_compute_dtype=torch.bfloat16 if bf16 else torch.float16,
120
- )
121
- model_kwargs["quantization_config"] = quantization_config
122
-
123
- model = AutoModelForCausalLM.from_pretrained(args.model_name, **model_kwargs)
124
- if args.use_qlora:
125
- model = prepare_model_for_kbit_training(
126
- model,
127
- use_gradient_checkpointing=args.gradient_checkpointing,
128
- )
129
- if args.gradient_checkpointing:
130
- model.gradient_checkpointing_enable()
131
- model.config.use_cache = False
132
-
133
- lora_config = LoraConfig(
134
- r=args.lora_rank,
135
- lora_alpha=args.lora_alpha,
136
- lora_dropout=args.lora_dropout,
137
- bias="none",
138
- task_type="CAUSAL_LM",
139
- target_modules=parse_target_modules(args.target_modules),
140
- )
141
-
142
- training_args_kwargs = {
143
- "output_dir": str(args.output_dir),
144
- "num_train_epochs": args.epochs,
145
- "learning_rate": args.learning_rate,
146
- "per_device_train_batch_size": args.per_device_train_batch_size,
147
- "per_device_eval_batch_size": args.per_device_eval_batch_size,
148
- "gradient_accumulation_steps": args.gradient_accumulation_steps,
149
- "logging_steps": args.logging_steps,
150
- "save_strategy": args.save_strategy,
151
- "warmup_ratio": args.warmup_ratio,
152
- "weight_decay": args.weight_decay,
153
- "bf16": bf16,
154
- "fp16": fp16,
155
- "report_to": [] if args.report_to == "none" else args.report_to.split(","),
156
- "remove_unused_columns": False,
157
- "seed": args.seed,
158
- "push_to_hub": args.push_to_hub,
159
- "hub_model_id": args.hub_model_id,
160
- }
161
- training_args_kwargs[evaluation_arg_name(TrainingArguments)] = args.eval_strategy
162
- training_args = TrainingArguments(**training_args_kwargs)
163
-
164
- try:
165
- trainer = build_sft_trainer(
166
- SFTTrainer=SFTTrainer,
167
- model=model,
168
- tokenizer=tokenizer,
169
- args=training_args,
170
- train_dataset=train_dataset,
171
- eval_dataset=eval_dataset,
172
- peft_config=lora_config,
173
- max_seq_length=args.max_seq_length,
174
- )
175
- except TypeError as exc:
176
- print(f"Falling back to transformers.Trainer because SFTTrainer API did not match: {exc}")
177
- model = get_peft_model(model, lora_config)
178
- trainer = build_causal_lm_trainer(
179
- Trainer=Trainer,
180
- DataCollatorForLanguageModeling=DataCollatorForLanguageModeling,
181
- model=model,
182
- tokenizer=tokenizer,
183
- args=training_args,
184
- train_dataset=train_dataset,
185
- eval_dataset=eval_dataset,
186
- max_seq_length=args.max_seq_length,
187
- )
188
- trainer.train()
189
- trainer.save_model(str(args.output_dir))
190
- tokenizer.save_pretrained(str(args.output_dir))
191
- write_training_manifest(args.output_dir, args, len(train_rows), len(eval_rows), bf16, quantization_config is not None)
192
- print(f"Saved LoRA adapter to {args.output_dir}")
193
-
194
-
195
- def load_jsonl_rows(path: Path, limit: int | None = None) -> list[dict[str, Any]]:
196
- if not path.exists():
197
- raise FileNotFoundError(f"Dataset file does not exist: {path}")
198
- rows: list[dict[str, Any]] = []
199
- with path.open("r", encoding="utf-8") as handle:
200
- for line_number, line in enumerate(handle, start=1):
201
- if not line.strip():
202
- continue
203
- row = json.loads(line)
204
- if not isinstance(row.get("messages"), list):
205
- raise ValueError(f"Row {line_number} in {path} is missing messages")
206
- rows.append(row)
207
- if limit is not None and len(rows) >= limit:
208
- break
209
- if not rows:
210
- raise ValueError(f"No rows loaded from {path}")
211
- return rows
212
-
213
-
214
- def format_messages_as_chat(messages: list[dict[str, str]], tokenizer: Any) -> str:
215
- normalized_messages = [{"role": message["role"], "content": message["content"]} for message in messages]
216
- if hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template:
217
- return tokenizer.apply_chat_template(
218
- normalized_messages,
219
- tokenize=False,
220
- add_generation_prompt=False,
221
- )
222
- return manual_chat_template(normalized_messages)
223
-
224
-
225
- def manual_chat_template(messages: list[dict[str, str]]) -> str:
226
- parts = []
227
- for message in messages:
228
- role = message["role"].strip().upper()
229
- content = message["content"].strip()
230
- parts.append(f"### {role}\n{content}")
231
- return "\n\n".join(parts) + "\n"
232
-
233
-
234
- def parse_target_modules(raw_value: str) -> list[str]:
235
- values = [value.strip() for value in raw_value.split(",") if value.strip()]
236
- if not values:
237
- raise ValueError("--target_modules must include at least one module name")
238
- return values
239
-
240
-
241
- def evaluation_arg_name(training_arguments_cls: Any) -> str:
242
- field_names = getattr(training_arguments_cls, "__dataclass_fields__", {})
243
- if "eval_strategy" in field_names:
244
- return "eval_strategy"
245
- return "evaluation_strategy"
246
-
247
-
248
- def build_sft_trainer(
249
- *,
250
- SFTTrainer: Any,
251
- model: Any,
252
- tokenizer: Any,
253
- args: Any,
254
- train_dataset: Any,
255
- eval_dataset: Any,
256
- peft_config: Any,
257
- max_seq_length: int,
258
- ) -> Any:
259
- """Handle small TRL SFTTrainer API differences across versions."""
260
- base_kwargs = {
261
- "model": model,
262
- "args": args,
263
- "train_dataset": train_dataset,
264
- "eval_dataset": eval_dataset,
265
- "peft_config": peft_config,
266
- "dataset_text_field": "text",
267
- "max_seq_length": max_seq_length,
268
- }
269
- try:
270
- return SFTTrainer(tokenizer=tokenizer, **base_kwargs)
271
- except TypeError:
272
- try:
273
- return SFTTrainer(processing_class=tokenizer, **base_kwargs)
274
- except TypeError:
275
- base_kwargs.pop("max_seq_length", None)
276
- base_kwargs.pop("dataset_text_field", None)
277
- return SFTTrainer(processing_class=tokenizer, **base_kwargs)
278
-
279
-
280
- def build_causal_lm_trainer(
281
- *,
282
- Trainer: Any,
283
- DataCollatorForLanguageModeling: Any,
284
- model: Any,
285
- tokenizer: Any,
286
- args: Any,
287
- train_dataset: Any,
288
- eval_dataset: Any,
289
- max_seq_length: int,
290
- ) -> Any:
291
- tokenized_train = tokenize_text_dataset(train_dataset, tokenizer, max_seq_length)
292
- tokenized_eval = tokenize_text_dataset(eval_dataset, tokenizer, max_seq_length)
293
- data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
294
- return Trainer(
295
- model=model,
296
- args=args,
297
- train_dataset=tokenized_train,
298
- eval_dataset=tokenized_eval,
299
- data_collator=data_collator,
300
- tokenizer=tokenizer,
301
- )
302
-
303
-
304
- def tokenize_text_dataset(dataset: Any, tokenizer: Any, max_seq_length: int) -> Any:
305
- def tokenize_batch(batch: dict[str, list[str]]) -> dict[str, Any]:
306
- return tokenizer(
307
- batch["text"],
308
- truncation=True,
309
- max_length=max_seq_length,
310
- padding=False,
311
- )
312
-
313
- return dataset.map(tokenize_batch, batched=True, remove_columns=["text"])
314
-
315
-
316
- def write_training_manifest(
317
- output_dir: Path,
318
- args: argparse.Namespace,
319
- train_rows: int,
320
- eval_rows: int,
321
- bf16: bool,
322
- qlora: bool,
323
- ) -> None:
324
- manifest = {
325
- "model_name": args.model_name,
326
- "train_file": str(args.train_file),
327
- "val_file": str(args.val_file),
328
- "train_rows": train_rows,
329
- "eval_rows": eval_rows,
330
- "max_seq_length": args.max_seq_length,
331
- "epochs": args.epochs,
332
- "learning_rate": args.learning_rate,
333
- "lora_rank": args.lora_rank,
334
- "lora_alpha": args.lora_alpha,
335
- "lora_dropout": args.lora_dropout,
336
- "qlora": qlora,
337
- "bf16": bf16,
338
- "seed": args.seed,
339
- }
340
- (output_dir / "training_manifest.json").write_text(
341
- json.dumps(manifest, indent=2, sort_keys=True) + "\n",
342
- encoding="utf-8",
343
- )
344
-
345
-
346
- if __name__ == "__main__":
347
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
finetune/scripts/validate_actor_sft.py DELETED
@@ -1,344 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Validate AI Puppet Theater Actor SFT JSONL files."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- from collections import Counter
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- REQUIRED_TOP_LEVEL = {"id", "source_mix", "row_type", "messages"}
14
- OPTIONAL_TOP_LEVEL = {"source_dataset", "transformation"}
15
- ALLOWED_TOP_LEVEL = REQUIRED_TOP_LEVEL | OPTIONAL_TOP_LEVEL
16
- ALLOWED_SOURCE_DATASETS = {
17
- "G-reen/TheatreLM-v2.1-Characters",
18
- "practical-dreamer/RPGPT_PublicDomain-alpaca",
19
- }
20
- ALLOWED_ROW_TYPES = {
21
- "normal_reaction",
22
- "prop_inspection",
23
- "oracle_consult",
24
- "lighting_change",
25
- "memory_callback",
26
- "secret_hint_or_reveal",
27
- "finale",
28
- "comedic_confusion",
29
- }
30
- OUTPUT_FIELDS = [
31
- "intent",
32
- "line",
33
- "emotion",
34
- "gesture",
35
- "stage_effect",
36
- "memory_update",
37
- "tool_request",
38
- ]
39
- ALLOWED_INTENTS = {
40
- "react_to_event",
41
- "clarify_problem",
42
- "inspect_prop",
43
- "consult_oracle",
44
- "change_lighting",
45
- "recall_memory",
46
- "hint_secret",
47
- "reveal_secret",
48
- "deliver_finale",
49
- "comic_confusion",
50
- }
51
- ALLOWED_TOOLS = {"inspect_prop", "consult_stage_oracle", "change_lighting"}
52
- TOOL_ARGS = {
53
- "inspect_prop": {"prop"},
54
- "consult_stage_oracle": {"question"},
55
- "change_lighting": {"mood"},
56
- }
57
- V1_TOOL_ARGS = {
58
- "inspect_prop": {"prop"},
59
- "consult_stage_oracle": {"question"},
60
- "change_lighting": {"mood"},
61
- }
62
- V1_BANNED_ASSISTANT_FIELDS = {
63
- "memory_record",
64
- "memory_effect",
65
- "recent_transcript",
66
- "show_state",
67
- "held_props",
68
- "mood",
69
- "name",
70
- "latest_prop",
71
- "latest_audience_action",
72
- "tool_results",
73
- }
74
- BANNED_TERMS = {
75
- "fuck",
76
- "shit",
77
- "bitch",
78
- "asshole",
79
- "bloodbath",
80
- "gore",
81
- "dismember",
82
- "suicide",
83
- }
84
-
85
-
86
- def main() -> None:
87
- parser = argparse.ArgumentParser(description=__doc__)
88
- parser.add_argument("path", type=Path)
89
- parser.add_argument("--max-errors", type=int, default=25)
90
- args = parser.parse_args()
91
-
92
- stats = validate_file(args.path, args.max_errors)
93
- print(f"file: {args.path}")
94
- print(f"total rows: {stats['total']}")
95
- print(f"valid rows: {stats['valid']}")
96
- print(f"invalid rows: {stats['invalid']}")
97
- print_distribution("row_type distribution", stats["row_types"])
98
- print_distribution("tool_request distribution", stats["tools"])
99
- stem = args.path.stem
100
- if stem.endswith("_train"):
101
- split_stem = stem[:-6]
102
- elif stem.endswith("_val"):
103
- split_stem = stem[:-4]
104
- else:
105
- split_stem = stem
106
- train_path = args.path.with_name(f"{split_stem}_train.jsonl")
107
- val_path = args.path.with_name(f"{split_stem}_val.jsonl")
108
- if train_path.exists() or val_path.exists():
109
- print(f"train rows: {count_lines(train_path) if train_path.exists() else 0}")
110
- print(f"val rows: {count_lines(val_path) if val_path.exists() else 0}")
111
- if stats["errors"]:
112
- print("errors:")
113
- for error in stats["errors"]:
114
- print(f"- {error}")
115
- if stats["invalid"]:
116
- raise SystemExit(1)
117
-
118
-
119
- def validate_file(path: Path, max_errors: int) -> dict[str, Any]:
120
- stats: dict[str, Any] = {
121
- "total": 0,
122
- "valid": 0,
123
- "invalid": 0,
124
- "row_types": Counter(),
125
- "tools": Counter(),
126
- "errors": [],
127
- }
128
- with path.open("r", encoding="utf-8") as handle:
129
- for line_number, line in enumerate(handle, start=1):
130
- if not line.strip():
131
- continue
132
- stats["total"] += 1
133
- try:
134
- row = json.loads(line)
135
- except json.JSONDecodeError as exc:
136
- add_error(stats, max_errors, line_number, f"row JSON parse failed: {exc}")
137
- continue
138
- errors = validate_row(row, dataset_version_for(path, row))
139
- if errors:
140
- stats["invalid"] += 1
141
- for error in errors:
142
- add_error(stats, max_errors, line_number, error)
143
- continue
144
- assistant = json.loads(row["messages"][2]["content"])
145
- stats["valid"] += 1
146
- stats["row_types"][row["row_type"]] += 1
147
- tool_request = assistant["tool_request"]
148
- stats["tools"][tool_request["tool"] if tool_request else "none"] += 1
149
- return stats
150
-
151
-
152
- def dataset_version_for(path: Path, row: Any) -> str:
153
- if "v1" in path.name:
154
- return "v1"
155
- if isinstance(row, dict):
156
- row_id = str(row.get("id", ""))
157
- source_mix = row.get("source_mix", [])
158
- if row_id.startswith("actor-sft-v1-") or "synthetic_v1" in source_mix:
159
- return "v1"
160
- return "v0"
161
-
162
-
163
- def validate_row(row: Any, version: str = "v0") -> list[str]:
164
- errors: list[str] = []
165
- if not isinstance(row, dict):
166
- return ["row must be an object"]
167
- unknown_top_level = set(row) - ALLOWED_TOP_LEVEL
168
- missing_top_level = REQUIRED_TOP_LEVEL - set(row)
169
- if unknown_top_level:
170
- errors.append(f"unknown top-level keys: {sorted(unknown_top_level)}")
171
- if missing_top_level:
172
- errors.append(f"missing top-level keys: {sorted(missing_top_level)}")
173
- return errors
174
- if not isinstance(row["id"], str) or not row["id"].strip():
175
- errors.append("id must be a non-empty string")
176
- if row["row_type"] not in ALLOWED_ROW_TYPES:
177
- errors.append(f"unknown row_type: {row['row_type']!r}")
178
- if not isinstance(row["source_mix"], list) or not all(isinstance(item, str) and item for item in row["source_mix"]):
179
- errors.append("source_mix must be a non-empty list of strings")
180
- if "source_dataset" in row:
181
- if row["source_dataset"] not in ALLOWED_SOURCE_DATASETS:
182
- errors.append(f"source_dataset must be one of {sorted(ALLOWED_SOURCE_DATASETS)}")
183
- if row["source_dataset"] == "G-reen/TheatreLM-v2.1-Characters" and "theatrelm_seed" not in row["source_mix"]:
184
- errors.append("TheatreLM seeded rows must include theatrelm_seed in source_mix")
185
- if row["source_dataset"] == "practical-dreamer/RPGPT_PublicDomain-alpaca" and "rpgpt_seed" not in row["source_mix"]:
186
- errors.append("RPGPT seeded rows must include rpgpt_seed in source_mix")
187
- if "transformation" in row and row["transformation"] != "seeded_synthetic_actor_json":
188
- errors.append("transformation must be seeded_synthetic_actor_json when present")
189
- if ("source_dataset" in row) != ("transformation" in row):
190
- errors.append("source_dataset and transformation must appear together")
191
- messages = row["messages"]
192
- if not isinstance(messages, list) or len(messages) != 3:
193
- errors.append("messages must contain exactly system, user, assistant messages")
194
- return errors
195
- expected_roles = ["system", "user", "assistant"]
196
- for index, expected_role in enumerate(expected_roles):
197
- message = messages[index]
198
- if not isinstance(message, dict):
199
- errors.append(f"message {index} must be an object")
200
- continue
201
- if set(message) != {"role", "content"}:
202
- errors.append(f"message {index} must contain only role and content")
203
- if message.get("role") != expected_role:
204
- errors.append(f"message {index} role must be {expected_role}")
205
- if not isinstance(message.get("content"), str) or not message["content"].strip():
206
- errors.append(f"message {index} content must be non-empty text")
207
- user_content = messages[1]["content"] if isinstance(messages[1], dict) else ""
208
- for marker in ["premise:", "show_state JSON:", "actor JSON:", "director_instruction:"]:
209
- if marker not in user_content:
210
- errors.append(f"user message missing {marker}")
211
- errors.extend(validate_assistant_content(messages[2]["content"], row, version, user_content))
212
- return errors
213
-
214
-
215
- def validate_assistant_content(content: str, row: dict[str, Any] | None = None, version: str = "v0", user_content: str = "") -> list[str]:
216
- errors: list[str] = []
217
- try:
218
- value = json.loads(content)
219
- except json.JSONDecodeError as exc:
220
- return [f"assistant content JSON parse failed: {exc}"]
221
- if not isinstance(value, dict):
222
- return ["assistant content must parse to an object"]
223
- keys = list(value)
224
- if keys != OUTPUT_FIELDS:
225
- errors.append(f"assistant keys must be exactly {OUTPUT_FIELDS}; got {keys}")
226
- if version == "v1":
227
- copied_fields = sorted(V1_BANNED_ASSISTANT_FIELDS & set(value))
228
- if copied_fields:
229
- errors.append(f"assistant must not copy state/input fields: {copied_fields}")
230
- for field in OUTPUT_FIELDS:
231
- if field not in value:
232
- continue
233
- if field == "tool_request":
234
- continue
235
- if field == "memory_update" and value[field] is None:
236
- continue
237
- if not isinstance(value[field], str):
238
- errors.append(f"{field} must be a string")
239
- continue
240
- if not value[field].strip():
241
- errors.append(f"{field} must not be empty")
242
- line = value.get("line", "")
243
- if isinstance(line, str):
244
- words = line.split()
245
- if not 6 <= len(words) <= 18:
246
- errors.append(f"line must be 6-18 words; got {len(words)}")
247
- lowered = line.lower()
248
- banned = sorted(term for term in BANNED_TERMS if term in lowered)
249
- if banned:
250
- errors.append(f"line contains banned terms: {banned}")
251
- intent = value.get("intent", "")
252
- if isinstance(intent, str) and intent not in ALLOWED_INTENTS:
253
- errors.append(f"intent must be one of {sorted(ALLOWED_INTENTS)}")
254
- gesture = value.get("gesture", "")
255
- if isinstance(gesture, str) and "_" in gesture:
256
- errors.append("gesture must be a short theatrical phrase, not an enum-like token")
257
- memory_update = value.get("memory_update", "")
258
- if isinstance(memory_update, str):
259
- if not memory_update.strip():
260
- errors.append("memory_update must be null or non-empty text")
261
- if len(memory_update) > 140:
262
- errors.append("memory_update must be 140 characters or fewer")
263
- elif memory_update is not None:
264
- errors.append("memory_update must be null or a string")
265
- if version == "v1" and row is not None:
266
- errors.extend(validate_v1_finale_context(value, row, user_content))
267
- tool_request = value.get("tool_request")
268
- errors.extend(validate_tool_request(tool_request, version))
269
- return errors
270
-
271
-
272
- def validate_v1_finale_context(value: dict[str, Any], row: dict[str, Any], user_content: str) -> list[str]:
273
- if value.get("intent") != "deliver_finale" and value.get("stage_effect") != "final_bow_lights":
274
- return []
275
- show_state = extract_show_state(user_content)
276
- finale_requested = bool(show_state.get("finale_requested")) if isinstance(show_state, dict) else False
277
- story_phase = show_state.get("story_phase") if isinstance(show_state, dict) else None
278
- if row.get("row_type") == "finale" or finale_requested or story_phase == "finale":
279
- return []
280
- return ["deliver_finale/final_bow_lights only allowed for finale row, finale_requested, or story_phase finale"]
281
-
282
-
283
- def extract_show_state(user_content: str) -> dict[str, Any] | None:
284
- marker = "show_state JSON:"
285
- next_marker = "\nactor JSON:"
286
- if marker not in user_content:
287
- return None
288
- start = user_content.index(marker) + len(marker)
289
- end = user_content.find(next_marker, start)
290
- raw_json = user_content[start:end if end != -1 else None].strip()
291
- try:
292
- value = json.loads(raw_json)
293
- except json.JSONDecodeError:
294
- return None
295
- return value if isinstance(value, dict) else None
296
-
297
-
298
- def validate_tool_request(value: Any, version: str = "v0") -> list[str]:
299
- if value is None:
300
- return []
301
- if not isinstance(value, dict):
302
- return ["tool_request must be null or an object"]
303
- if set(value) != {"tool", "args", "reason"}:
304
- return ["tool_request must contain exactly tool, args, and reason"]
305
- tool = value["tool"]
306
- if tool not in ALLOWED_TOOLS:
307
- return [f"tool_request tool must be one of {sorted(ALLOWED_TOOLS)}"]
308
- if not isinstance(value["reason"], str) or not value["reason"].strip():
309
- return ["tool_request reason must be non-empty text"]
310
- if len(value["reason"]) > 140:
311
- return ["tool_request reason must be 140 characters or fewer"]
312
- args = value["args"]
313
- if not isinstance(args, dict):
314
- return ["tool_request args must be an object"]
315
- tool_args = V1_TOOL_ARGS if version == "v1" else TOOL_ARGS
316
- if set(args) != tool_args[tool]:
317
- return [f"tool_request args for {tool} must be exactly {sorted(tool_args[tool])}"]
318
- for arg_value in args.values():
319
- if not isinstance(arg_value, str) or not arg_value.strip():
320
- return ["tool_request arg values must be non-empty strings"]
321
- if len(arg_value) > 120:
322
- return ["tool_request arg values must be 120 characters or fewer"]
323
- return []
324
-
325
-
326
- def add_error(stats: dict[str, Any], max_errors: int, line_number: int, message: str) -> None:
327
- stats["invalid"] += 1 if message.startswith("row JSON parse failed") else 0
328
- if len(stats["errors"]) < max_errors:
329
- stats["errors"].append(f"line {line_number}: {message}")
330
-
331
-
332
- def count_lines(path: Path) -> int:
333
- with path.open("r", encoding="utf-8") as handle:
334
- return sum(1 for line in handle if line.strip())
335
-
336
-
337
- def print_distribution(title: str, values: Counter) -> None:
338
- print(f"{title}:")
339
- for key, count in sorted(values.items()):
340
- print(f" {key}: {count}")
341
-
342
-
343
- if __name__ == "__main__":
344
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/__init__.py CHANGED
@@ -1,16 +1,7 @@
1
  from puppet_theater.actions import request_finale, summon_actor, throw_prop
2
  from puppet_theater.backends import (
3
- DEFAULT_HF_API_MODEL,
4
- DEFAULT_HF_API_MODEL_ID,
5
- DEFAULT_ACTOR_LORA_ADAPTER,
6
- DEFAULT_ACTOR_LORA_BASE_MODEL,
7
- DEFAULT_ACTOR_GGUF_FILENAME,
8
- DEFAULT_ACTOR_GGUF_REPO_ID,
9
  DEFAULT_OPENBMB_MODEL_ID,
10
  DeterministicBackend,
11
- HFAPIBackend,
12
- LocalGGUFActorBackend,
13
- LocalLoRAActorBackend,
14
  ModelBackend,
15
  OpenBMBTransformersBackend,
16
  generate_actor_response,
@@ -18,65 +9,28 @@ from puppet_theater.backends import (
18
  parse_actor_output,
19
  warm_up_openbmb,
20
  )
21
- from puppet_theater.director import (
22
- BEAT_ARC,
23
- DirectorGeneration,
24
- DirectorPolicy,
25
- HFAPIDirectorPolicy,
26
- OpenBMBDirectorPolicy,
27
- choose_director_decision,
28
- run_full_act,
29
- run_one_beat,
30
- story_phase,
31
- story_progress,
32
- )
33
- from puppet_theater.models import Actor, ActorResponse, Beat, DirectorDecision, TheaterSession, ToolRequest, ToolResult
34
- from puppet_theater.session import DEFAULT_SHOW_LENGTH, SHOW_LENGTH_PRESETS, create_show_from_premise, resolve_show_length
35
- from puppet_theater.tools import ALLOWED_TOOL_NAMES, run_actor_tool_request, validate_tool_request
36
 
37
  __all__ = [
38
  "Actor",
39
  "ActorResponse",
40
- "ALLOWED_TOOL_NAMES",
41
  "BEAT_ARC",
42
  "Beat",
43
- "DEFAULT_HF_API_MODEL",
44
- "DEFAULT_HF_API_MODEL_ID",
45
- "DEFAULT_ACTOR_LORA_ADAPTER",
46
- "DEFAULT_ACTOR_LORA_BASE_MODEL",
47
- "DEFAULT_ACTOR_GGUF_FILENAME",
48
- "DEFAULT_ACTOR_GGUF_REPO_ID",
49
  "DEFAULT_OPENBMB_MODEL_ID",
50
- "DEFAULT_SHOW_LENGTH",
51
  "DeterministicBackend",
52
- "DirectorDecision",
53
- "DirectorGeneration",
54
- "DirectorPolicy",
55
- "HFAPIBackend",
56
- "HFAPIDirectorPolicy",
57
- "LocalGGUFActorBackend",
58
- "LocalLoRAActorBackend",
59
  "ModelBackend",
60
- "OpenBMBDirectorPolicy",
61
  "OpenBMBTransformersBackend",
62
- "SHOW_LENGTH_PRESETS",
63
  "TheaterSession",
64
- "ToolRequest",
65
- "ToolResult",
66
  "create_show_from_premise",
67
- "choose_director_decision",
68
  "generate_actor_response",
69
  "get_backend_status",
70
  "parse_actor_output",
71
  "request_finale",
72
- "resolve_show_length",
73
- "run_actor_tool_request",
74
  "run_full_act",
75
  "run_one_beat",
76
- "story_phase",
77
- "story_progress",
78
  "summon_actor",
79
  "throw_prop",
80
- "validate_tool_request",
81
  "warm_up_openbmb",
82
  ]
 
1
  from puppet_theater.actions import request_finale, summon_actor, throw_prop
2
  from puppet_theater.backends import (
 
 
 
 
 
 
3
  DEFAULT_OPENBMB_MODEL_ID,
4
  DeterministicBackend,
 
 
 
5
  ModelBackend,
6
  OpenBMBTransformersBackend,
7
  generate_actor_response,
 
9
  parse_actor_output,
10
  warm_up_openbmb,
11
  )
12
+ from puppet_theater.director import BEAT_ARC, run_full_act, run_one_beat
13
+ from puppet_theater.models import Actor, ActorResponse, Beat, TheaterSession
14
+ from puppet_theater.session import create_show_from_premise
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  __all__ = [
17
  "Actor",
18
  "ActorResponse",
 
19
  "BEAT_ARC",
20
  "Beat",
 
 
 
 
 
 
21
  "DEFAULT_OPENBMB_MODEL_ID",
 
22
  "DeterministicBackend",
 
 
 
 
 
 
 
23
  "ModelBackend",
 
24
  "OpenBMBTransformersBackend",
 
25
  "TheaterSession",
 
 
26
  "create_show_from_premise",
 
27
  "generate_actor_response",
28
  "get_backend_status",
29
  "parse_actor_output",
30
  "request_finale",
 
 
31
  "run_full_act",
32
  "run_one_beat",
 
 
33
  "summon_actor",
34
  "throw_prop",
 
35
  "warm_up_openbmb",
36
  ]
puppet_theater/actions.py CHANGED
@@ -1,27 +1,10 @@
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
 
@@ -30,96 +13,42 @@ def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession
30
  session.latest_prop = prop
31
  session.latest_audience_action = f"Audience threw {prop} onto the stage."
32
  session.director_log.append(session.latest_audience_action)
33
- add_trace_event(
34
- session,
35
- "audience_action",
36
- audience_action="throw_prop",
37
- prop=prop,
38
- action_summary=session.latest_audience_action,
39
- )
40
  return session
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)
63
- add_trace_event(
64
- session,
65
- "audience_action",
66
- audience_action="summon_actor",
67
- validation_status="skipped_stage_full",
68
- fallback_used=False,
69
- action_summary=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
 
 
 
 
 
1
  from puppet_theater.models import Actor, TheaterSession
 
 
2
 
3
 
 
4
  MAX_ACTORS = 4
5
 
6
 
7
  def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession | None:
 
 
 
 
 
 
 
 
 
 
8
  if session is None:
9
  return None
10
 
 
13
  session.latest_prop = prop
14
  session.latest_audience_action = f"Audience threw {prop} onto the stage."
15
  session.director_log.append(session.latest_audience_action)
16
+ session.trace_events.append(f"prop_thrown:{prop}")
 
 
 
 
 
 
17
  return session
18
 
19
 
20
  def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSession | None:
 
 
 
 
 
 
 
 
 
 
21
  if session is None:
22
  return None
23
 
24
  name = " ".join(actor_name.strip().split()) or "Mystery Guest"
 
 
25
  if len(session.actors) >= MAX_ACTORS:
26
  session.latest_audience_action = "Audience tried to summon an actor, but the stage is full."
27
  session.director_log.append(session.latest_audience_action)
28
+ session.trace_events.append("actor_summon_skipped:stage_full")
 
 
 
 
 
 
 
29
  return session
30
 
31
+ actor = Actor(
32
+ name=name,
33
+ avatar="✨",
34
+ goal="Make the scene stranger without derailing the finale.",
35
+ secret="Arrived with one completely unexplained cue.",
36
+ speaking_style="fresh, eager, and just a little too dramatic",
37
+ tools=["entrance_cue"],
38
  )
39
  session.actors.append(actor)
40
+ session.latest_audience_action = f"Audience summoned {name}."
 
41
  session.director_log.append(session.latest_audience_action)
42
+ session.trace_events.append(f"actor_summoned:{name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return session
44
 
45
 
46
  def request_finale(session: TheaterSession | None) -> TheaterSession | None:
 
 
 
 
 
 
 
 
47
  if session is None:
48
  return None
49
 
50
  session.finale_requested = True
51
  session.latest_audience_action = "Audience requested the finale."
52
  session.director_log.append(session.latest_audience_action)
53
+ session.trace_events.append("finale_requested")
54
+ return session
 
 
 
 
 
 
 
puppet_theater/backdrop_gen.py DELETED
@@ -1,152 +0,0 @@
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
- # Matches the rendered stage-backdrop area: the stage is 1200x600 at max width,
24
- # with valance/floorboards leaving a little over a 2:1 backdrop region.
25
- _DEFAULT_T2I_WIDTH = 1152
26
- _DEFAULT_T2I_HEIGHT = 512
27
- _MAX_SETTING_CHARS = 900
28
- _MAX_OUTPUT_WIDTH = 1152
29
-
30
-
31
- def setting_backdrop_t2i_enabled() -> bool:
32
- """When True, premise flow prefers HF text-to-image from `setting` over stock URL LLM (see session)."""
33
- raw = os.getenv("HF_SETTING_BACKDROP_IMAGE", "1").strip().lower()
34
- if raw in {"0", "false", "no", "off"}:
35
- return False
36
- return bool(_hf_api_token())
37
-
38
-
39
- def build_setting_text_to_image_prompt(setting: str) -> str:
40
- """Compose the text-to-image prompt from the show setting (f-string over setting text)."""
41
- s = " ".join(setting.strip().split())
42
- if len(s) > _MAX_SETTING_CHARS:
43
- s = s[:_MAX_SETTING_CHARS].rsplit(" ", 1)[0].rstrip(",;:")
44
- return (
45
- "Wide cinematic 16:9 stage backdrop for puppet theater. "
46
- "Soft painterly or photographic look, cohesive mood, gentle depth. "
47
- "Keep the center area calm and visually simple so puppets in the foreground stay readable. "
48
- "No text, no watermark, no logos, no UI, no subtitles. "
49
- f"Scene and atmosphere: {s}"
50
- )
51
-
52
-
53
- def _env_dimension(name: str, default: int) -> int:
54
- raw = os.getenv(name, "").strip()
55
- try:
56
- value = int(raw) if raw else default
57
- except ValueError:
58
- value = default
59
- value = max(256, min(value, 1536))
60
- return value - (value % 16)
61
-
62
-
63
- def try_setting_backdrop_data_url(setting: str) -> tuple[str | None, dict[str, Any]]:
64
- """
65
- Call HF text-to-image with prompt derived from `setting`. Returns (data URL or None, metadata dict).
66
- """
67
- provider = os.getenv("HF_BACKDROP_T2I_PROVIDER", "").strip() or None
68
- width = _env_dimension("HF_BACKDROP_T2I_WIDTH", _DEFAULT_T2I_WIDTH)
69
- height = _env_dimension("HF_BACKDROP_T2I_HEIGHT", _DEFAULT_T2I_HEIGHT)
70
- meta: dict[str, Any] = {
71
- "model": os.getenv("HF_BACKDROP_T2I_MODEL", _DEFAULT_T2I_MODEL).strip() or _DEFAULT_T2I_MODEL,
72
- "provider": provider or "auto",
73
- "width": width,
74
- "height": height,
75
- }
76
- if not setting_backdrop_t2i_enabled():
77
- meta["skipped"] = "disabled_or_no_token"
78
- return None, meta
79
- token = _hf_api_token()
80
- if not token:
81
- meta["skipped"] = "no_token"
82
- return None, meta
83
-
84
- prompt = build_setting_text_to_image_prompt(setting)
85
- meta["prompt_char_len"] = len(prompt)
86
- neg = (
87
- "text, watermark, signature, logo, subtitle, ui, frame, border, "
88
- "dense crowd, many faces, extreme clutter, harsh noise"
89
- )
90
-
91
- timeout = float(os.getenv("HF_BACKDROP_T2I_TIMEOUT", str(_DEFAULT_T2I_TIMEOUT)))
92
- timeout = max(15.0, min(timeout, 180.0))
93
-
94
- try:
95
- from huggingface_hub import InferenceClient
96
- from PIL import Image
97
- except ImportError as exc:
98
- meta["error"] = f"missing_dependency:{exc}"
99
- return None, meta
100
-
101
- client = InferenceClient(token=token, timeout=timeout, provider=provider)
102
- model = str(meta["model"])
103
-
104
- try:
105
- image = client.text_to_image(
106
- prompt,
107
- negative_prompt=neg,
108
- model=model,
109
- width=width,
110
- height=height,
111
- )
112
- except Exception as first:
113
- meta["first_try_error"] = str(first)[:400]
114
- try:
115
- image = client.text_to_image(prompt, negative_prompt=neg, model=model)
116
- except Exception as second:
117
- meta["error"] = str(second)[:500]
118
- logger.warning("setting_backdrop_t2i: failed model=%r err=%s", model, meta["error"])
119
- return None, meta
120
-
121
- if not isinstance(image, Image.Image):
122
- meta["error"] = "unexpected_image_type"
123
- return None, meta
124
-
125
- image = image.convert("RGB")
126
- w, h = image.size
127
- if w > _MAX_OUTPUT_WIDTH and w > 0:
128
- nh = max(1, int(h * (_MAX_OUTPUT_WIDTH / float(w))))
129
- image = image.resize((_MAX_OUTPUT_WIDTH, nh), Image.Resampling.LANCZOS)
130
-
131
- buf = io.BytesIO()
132
- image.save(buf, format="JPEG", quality=86, optimize=True)
133
- raw = buf.getvalue()
134
- meta["jpeg_bytes"] = len(raw)
135
- b64 = base64.standard_b64encode(raw).decode("ascii")
136
- data_url = f"data:image/jpeg;base64,{b64}"
137
- meta["ok"] = True
138
- logger.info(
139
- "setting_backdrop_t2i: ok model=%r jpeg_bytes=%s",
140
- model,
141
- meta["jpeg_bytes"],
142
- )
143
- return data_url, meta
144
-
145
-
146
- def backdrop_url_for_trace(url: str | None) -> str:
147
- """Avoid megabyte-long data URLs inside trace JSON."""
148
- if not url:
149
- return ""
150
- if url.startswith("data:image"):
151
- return "data:image/jpeg;base64,...(generated)"
152
- return url[:400]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/backends.py CHANGED
@@ -2,71 +2,19 @@ from abc import ABC, abstractmethod
2
  from dataclasses import dataclass
3
  import json
4
  import os
5
- from pathlib import Path
6
- import queue
7
- import threading
8
  import time
9
  from typing import Any
10
 
11
  from pydantic import ValidationError
12
 
13
- from puppet_theater.models import Actor, ActorResponse, DirectorDecision, TheaterSession, ToolRequest
14
  from puppet_theater.prompts import ACTOR_LINE_PROMPT
15
- from puppet_theater import zerogpu
16
 
17
 
18
  MAX_ACTOR_LINE_CHARS = 220
19
  DEFAULT_OPENBMB_MODEL_ID = "openbmb/MiniCPM5-1B"
20
- DEFAULT_HF_API_MODEL = "Qwen/Qwen3-4B-Instruct-2507:nscale"
21
- 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
- # 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
37
- HF_API_TOP_P = 0.9
38
- HF_API_TIMEOUT_SECONDS = 30.0
39
- DEFAULT_ACTOR_LORA_BASE_MODEL = "openbmb/MiniCPM5-1B"
40
- DEFAULT_ACTOR_LORA_ADAPTER = "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-LoRA"
41
- DEFAULT_ACTOR_GGUF_REPO_ID = "build-small-hackathon/AI-Puppet-Theater-MiniCPM5-Actor-GGUF"
42
- DEFAULT_ACTOR_GGUF_FILENAME = "minicpm5-actor-q4_k_m.gguf"
43
- ACTOR_LORA_MAX_NEW_TOKENS = 160
44
- ACTOR_LORA_TIMEOUT_SECONDS = 120.0
45
- ACTOR_GGUF_MAX_TOKENS = 160
46
- ACTOR_GGUF_TIMEOUT_SECONDS = 120.0
47
- ACTOR_GGUF_N_GPU_LAYERS = -1
48
- LOCAL_ACTOR_REPETITION_PENALTY = 1.12
49
- LOCAL_ACTOR_TEMPERATURE = 0.35
50
- LOCAL_ACTOR_TOP_P = 0.9
51
- ACTOR_JSON_FIELDS = (
52
- "intent",
53
- "line",
54
- "emotion",
55
- "gesture",
56
- "stage_effect",
57
- "memory_update",
58
- "tool_request",
59
- )
60
- ACTOR_SYSTEM_MESSAGE = (
61
- "You are an Actor agent in AI Puppet Theater. Return only one valid JSON object. "
62
- "No markdown. No commentary. Keep the puppet line short, theatrical, and speakable."
63
- )
64
- ACTOR_RESPONSE_SUFFIX = (
65
- "Return exactly one JSON object with only these top-level keys: "
66
- "intent, line, emotion, gesture, stage_effect, memory_update, tool_request. "
67
- "Do not include speaking_style, show_state, recent_transcript, or any copied input fields. "
68
- "Stop after the JSON object."
69
- )
70
 
71
 
72
  @dataclass(frozen=True)
@@ -86,18 +34,12 @@ class BackendRuntimeStatus:
86
  backend_name: str
87
  model_id: str | None
88
  load_status: str
89
- token_configured: bool | None = None
90
  latest_latency_ms: int | None = None
91
  latest_validation_status: str | None = None
92
  latest_fallback_used: bool | None = None
93
  latest_fallback_reason: str | None = None
94
  max_new_tokens: int | None = None
95
  temperature: float | None = None
96
- zerogpu_enabled: bool = False
97
- spaces_available: bool = False
98
- zerogpu_gpu_active: bool = False
99
- torch_version: str | None = None
100
- cuda_available_in_gpu_fn: bool | None = None
101
 
102
 
103
  class ModelBackend(ABC):
@@ -108,7 +50,7 @@ class ModelBackend(ABC):
108
  def generate_actor_response(
109
  self,
110
  session: TheaterSession,
111
- decision: DirectorDecision,
112
  speaker: Actor,
113
  prop: str | None,
114
  ) -> ActorResponse | dict[str, Any] | str:
@@ -117,7 +59,7 @@ class ModelBackend(ABC):
117
  def repair_actor_response(
118
  self,
119
  session: TheaterSession,
120
- decision: DirectorDecision,
121
  speaker: Actor,
122
  prop: str | None,
123
  invalid_output: ActorResponse | dict[str, Any] | str,
@@ -133,11 +75,11 @@ class DeterministicBackend(ModelBackend):
133
  def generate_actor_response(
134
  self,
135
  session: TheaterSession,
136
- decision: DirectorDecision,
137
  speaker: Actor,
138
  prop: str | None,
139
  ) -> ActorResponse:
140
- return deterministic_actor_response(session, decision, speaker, prop)
141
 
142
 
143
  class OpenBMBTransformersBackend(ModelBackend):
@@ -170,23 +112,23 @@ class OpenBMBTransformersBackend(ModelBackend):
170
  def generate_actor_response(
171
  self,
172
  session: TheaterSession,
173
- decision: DirectorDecision,
174
  speaker: Actor,
175
  prop: str | None,
176
  ) -> str:
177
- prompt = build_actor_line_prompt(session, decision, speaker, prop)
178
  return self._generate_text(prompt)
179
 
180
  def repair_actor_response(
181
  self,
182
  session: TheaterSession,
183
- decision: DirectorDecision,
184
  speaker: Actor,
185
  prop: str | None,
186
  invalid_output: ActorResponse | dict[str, Any] | str,
187
  validation_status: str,
188
  ) -> str | None:
189
- prompt = build_actor_line_prompt(session, decision, speaker, prop)
190
  repair_prompt = (
191
  f"{prompt}\n\nThe previous output failed validation with status {validation_status}.\n"
192
  "Return only valid compact JSON. Do not include markdown, commentary, or extra keys.\n"
@@ -227,28 +169,6 @@ class OpenBMBTransformersBackend(ModelBackend):
227
  self.load_status = "loaded"
228
 
229
  def _generate_text(self, prompt: str) -> str:
230
- if zerogpu.USE_ZEROGPU:
231
- if not zerogpu.ZEROGPU_GPU_ACTIVE:
232
- reason = "USE_ZEROGPU=true but the spaces package is not available"
233
- self.load_status = "zerogpu_unavailable"
234
- self.latest_fallback_reason = reason
235
- zerogpu.record_gpu_failure(reason)
236
- raise RuntimeError(reason)
237
- try:
238
- self.load_status = "zerogpu_ready"
239
- return zerogpu.generate_openbmb_text_on_zerogpu(
240
- self.model_id,
241
- prompt,
242
- self.max_new_tokens,
243
- self.temperature,
244
- )
245
- except Exception as exc:
246
- reason = _summarize_error(exc)
247
- self.load_status = "error"
248
- self.latest_fallback_reason = reason
249
- zerogpu.record_gpu_failure(reason)
250
- raise RuntimeError(f"ZeroGPU local generation failed: {reason}") from exc
251
-
252
  self._load()
253
  messages = [{"role": "user", "content": prompt}]
254
  tokenizer = self._tokenizer
@@ -290,415 +210,24 @@ class OpenBMBTransformersBackend(ModelBackend):
290
  return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
291
 
292
 
293
- class HFAPIBackend(ModelBackend):
294
- name = "hf_api"
295
-
296
- def __init__(
297
- self,
298
- model_id: str | None = None,
299
- max_new_tokens: int = HF_API_ACTOR_MAX_TOKENS,
300
- temperature: float = HF_API_ACTOR_TEMPERATURE,
301
- top_p: float = HF_API_TOP_P,
302
- timeout_seconds: float = HF_API_TIMEOUT_SECONDS,
303
- ) -> None:
304
- self.model_id = model_id or os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID)
305
- self.max_new_tokens = max_new_tokens
306
- self.temperature = temperature
307
- self.top_p = top_p
308
- self.timeout_seconds = timeout_seconds
309
- self.load_status = "remote_ready" if self.token_configured else "missing_token"
310
- self.latest_latency_ms: int | None = None
311
- self.latest_validation_status: str | None = None
312
- self.latest_fallback_used: bool | None = None
313
- self.latest_fallback_reason: str | None = None
314
-
315
- @property
316
- def token_configured(self) -> bool:
317
- return bool(_hf_api_token())
318
-
319
- def configure(
320
- self,
321
- max_new_tokens: int | None = None,
322
- temperature: float | None = None,
323
- top_p: float | None = None,
324
- ) -> None:
325
- if max_new_tokens is not None:
326
- self.max_new_tokens = _clamp_int(max_new_tokens, 16, 240)
327
- if temperature is not None:
328
- self.temperature = _clamp_float(temperature, 0.0, 1.5)
329
- if top_p is not None:
330
- self.top_p = _clamp_float(top_p, 0.0, 1.0)
331
- self.load_status = "remote_ready" if self.token_configured else "missing_token"
332
-
333
- def generate_actor_response(
334
- self,
335
- session: TheaterSession,
336
- decision: DirectorDecision,
337
- speaker: Actor,
338
- prop: str | None,
339
- ) -> str:
340
- prompt = build_actor_line_prompt(session, decision, speaker, prop)
341
- return self._generate_text(
342
- prompt,
343
- max_tokens=HF_API_ACTOR_MAX_TOKENS,
344
- temperature=HF_API_ACTOR_TEMPERATURE,
345
- )
346
-
347
- def repair_actor_response(
348
- self,
349
- session: TheaterSession,
350
- decision: DirectorDecision,
351
- speaker: Actor,
352
- prop: str | None,
353
- invalid_output: ActorResponse | dict[str, Any] | str,
354
- validation_status: str,
355
- ) -> str | None:
356
- repair_prompt = (
357
- f"{build_actor_line_prompt(session, decision, speaker, prop)}\n\n"
358
- f"The previous output failed validation with status {validation_status}.\n"
359
- "Return only valid compact JSON. Do not include markdown, commentary, or extra keys.\n"
360
- f"Previous output: {invalid_output}"
361
- )
362
- return self._generate_text(
363
- repair_prompt,
364
- max_tokens=HF_API_ACTOR_MAX_TOKENS,
365
- temperature=HF_API_ACTOR_TEMPERATURE,
366
- )
367
-
368
- def _generate_text(
369
- self,
370
- prompt: str,
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:
378
- self.load_status = "missing_token"
379
- self.latest_fallback_reason = "HF API token is not configured"
380
- raise RuntimeError("HF API token is not configured; set HF_TOKEN or HUGGINGFACEHUB_API_TOKEN")
381
-
382
- self.load_status = "remote_ready"
383
- try:
384
- from huggingface_hub import InferenceClient
385
- except ImportError as exc:
386
- self.load_status = "error"
387
- self.latest_fallback_reason = "huggingface_hub is not installed"
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
- ],
403
- max_tokens=max_tokens or self.max_new_tokens,
404
- temperature=self.temperature if temperature is None else temperature,
405
- top_p=self.top_p,
406
- )
407
- return _extract_chat_completion_text(output)
408
- except Exception as exc:
409
- self.load_status = "error"
410
- self.latest_fallback_reason = _summarize_error(exc)
411
- raise RuntimeError(f"HF API request failed: {_summarize_error(exc)}") from exc
412
-
413
-
414
- class LocalLoRAActorBackend(ModelBackend):
415
- name = "local_lora"
416
-
417
- def __init__(self) -> None:
418
- self.base_model_id = os.getenv("ACTOR_LORA_BASE_MODEL", DEFAULT_ACTOR_LORA_BASE_MODEL)
419
- self.adapter_id = os.getenv("ACTOR_LORA_ADAPTER", DEFAULT_ACTOR_LORA_ADAPTER)
420
- self.model_id = self.adapter_id
421
- self.max_new_tokens = _env_int("ACTOR_LORA_MAX_NEW_TOKENS", ACTOR_LORA_MAX_NEW_TOKENS, 16, 320)
422
- self.timeout_seconds = _env_float("ACTOR_LORA_TIMEOUT_SECONDS", ACTOR_LORA_TIMEOUT_SECONDS, 1.0, 300.0)
423
- self.device = (os.getenv("ACTOR_LORA_DEVICE") or "auto").strip().lower()
424
- self.load_in_4bit = _env_bool("ACTOR_LORA_LOAD_IN_4BIT", False)
425
- self.repetition_penalty = _env_float("ACTOR_REPETITION_PENALTY", LOCAL_ACTOR_REPETITION_PENALTY, 1.0, 2.0)
426
- self.temperature = _env_float("ACTOR_LOCAL_TEMPERATURE", LOCAL_ACTOR_TEMPERATURE, 0.0, 1.5)
427
- self.top_p = _env_float("ACTOR_LOCAL_TOP_P", LOCAL_ACTOR_TOP_P, 0.05, 1.0)
428
- self.load_status = "unloaded"
429
- self.latest_latency_ms: int | None = None
430
- self.latest_validation_status: str | None = None
431
- self.latest_fallback_used: bool | None = None
432
- self.latest_fallback_reason: str | None = None
433
- self._tokenizer = None
434
- self._model = None
435
- self._torch = None
436
-
437
- def configure(self, max_new_tokens: int | None = None, temperature: float | None = None) -> None:
438
- if max_new_tokens is not None:
439
- self.max_new_tokens = _clamp_int(max_new_tokens, 16, 320)
440
-
441
- def generate_actor_response(
442
- self,
443
- session: TheaterSession,
444
- decision: DirectorDecision,
445
- speaker: Actor,
446
- prop: str | None,
447
- ) -> str:
448
- messages = build_actor_chat_messages(session, decision, speaker, prop)
449
- self._load()
450
- return _run_with_timeout(lambda: self._generate_text(messages), self.timeout_seconds, "Local LoRA generation timed out")
451
-
452
- def repair_actor_response(
453
- self,
454
- session: TheaterSession,
455
- decision: DirectorDecision,
456
- speaker: Actor,
457
- prop: str | None,
458
- invalid_output: ActorResponse | dict[str, Any] | str,
459
- validation_status: str,
460
- ) -> str | None:
461
- messages = build_actor_chat_messages(session, decision, speaker, prop)
462
- messages[-1]["content"] += (
463
- f"\n\nPrevious output failed validation with status {validation_status}.\n"
464
- "Return only one valid compact JSON object with the exact Actor schema.\n"
465
- f"Previous output: {invalid_output}"
466
- )
467
- self._load()
468
- return _run_with_timeout(lambda: self._generate_text(messages), self.timeout_seconds, "Local LoRA repair timed out")
469
-
470
- def _load(self) -> None:
471
- if self._tokenizer is not None and self._model is not None:
472
- self.load_status = "loaded"
473
- return
474
-
475
- self.load_status = "loading"
476
- try:
477
- import torch
478
- from peft import PeftModel
479
- from transformers import AutoModelForCausalLM, AutoTokenizer
480
- except ImportError as exc:
481
- self.load_status = "not_configured"
482
- self.latest_fallback_reason = "Local LoRA dependencies are not installed"
483
- raise RuntimeError("Local LoRA dependencies are not installed; install torch, transformers, and peft") from exc
484
-
485
- try:
486
- self._torch = torch
487
- self._tokenizer = AutoTokenizer.from_pretrained(self.base_model_id, trust_remote_code=True)
488
- model_kwargs: dict[str, object] = {"torch_dtype": "auto"}
489
- if self.load_in_4bit:
490
- model_kwargs["load_in_4bit"] = True
491
- model_kwargs["device_map"] = "auto"
492
- elif self.device == "auto":
493
- model_kwargs["device_map"] = "auto"
494
- base_model = AutoModelForCausalLM.from_pretrained(
495
- self.base_model_id,
496
- trust_remote_code=True,
497
- **model_kwargs,
498
- )
499
- self._model = PeftModel.from_pretrained(base_model, self.adapter_id)
500
- if self.device in {"cpu", "cuda", "mps"} and not self.load_in_4bit:
501
- self._model.to(self.device)
502
- self._model.eval()
503
- except Exception as exc:
504
- self._tokenizer = None
505
- self._model = None
506
- self.load_status = "error"
507
- self.latest_fallback_reason = _summarize_error(exc)
508
- raise
509
-
510
- self.load_status = "loaded"
511
-
512
- def _generate_text(self, messages: list[dict[str, str]]) -> str:
513
- self._load()
514
- tokenizer = self._tokenizer
515
- model = self._model
516
- try:
517
- inputs = tokenizer.apply_chat_template(
518
- messages,
519
- tokenize=True,
520
- add_generation_prompt=True,
521
- enable_thinking=False,
522
- return_dict=True,
523
- return_tensors="pt",
524
- )
525
- except (AttributeError, TypeError):
526
- prompt = format_chatml(messages)
527
- inputs = tokenizer(prompt, return_tensors="pt")
528
-
529
- inputs = inputs.to(model.device)
530
- eos_token_id = tokenizer.eos_token_id
531
- pad_token_id = tokenizer.pad_token_id or eos_token_id
532
- with self._torch.inference_mode():
533
- do_sample = self.temperature > 0
534
- generation_kwargs = {
535
- "max_new_tokens": self.max_new_tokens,
536
- "do_sample": do_sample,
537
- "repetition_penalty": self.repetition_penalty,
538
- "pad_token_id": pad_token_id,
539
- "eos_token_id": eos_token_id,
540
- }
541
- if do_sample:
542
- generation_kwargs["temperature"] = self.temperature
543
- generation_kwargs["top_p"] = self.top_p
544
- outputs = model.generate(
545
- **inputs,
546
- **generation_kwargs,
547
- )
548
- new_tokens = outputs[0][inputs["input_ids"].shape[-1] :]
549
- return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
550
-
551
-
552
- class LocalGGUFActorBackend(ModelBackend):
553
- name = "local_gguf"
554
-
555
- def __init__(self) -> None:
556
- self.model_path = (os.getenv("ACTOR_GGUF_MODEL_PATH") or "").strip()
557
- self.repo_id = (os.getenv("ACTOR_GGUF_REPO_ID") or DEFAULT_ACTOR_GGUF_REPO_ID).strip()
558
- self.filename = (os.getenv("ACTOR_GGUF_FILENAME") or DEFAULT_ACTOR_GGUF_FILENAME).strip()
559
- self.model_id = Path(self.model_path).name if self.model_path else f"{self.repo_id}/{self.filename}"
560
- self.n_ctx = _env_int("ACTOR_GGUF_N_CTX", 4096, 512, 32768)
561
- self.n_gpu_layers = _env_int("ACTOR_GGUF_N_GPU_LAYERS", ACTOR_GGUF_N_GPU_LAYERS, -1, 999)
562
- self.max_new_tokens = _env_int("ACTOR_GGUF_MAX_TOKENS", ACTOR_GGUF_MAX_TOKENS, 16, 320)
563
- self.timeout_seconds = _env_float("ACTOR_GGUF_TIMEOUT_SECONDS", ACTOR_GGUF_TIMEOUT_SECONDS, 1.0, 300.0)
564
- self.repetition_penalty = _env_float("ACTOR_REPETITION_PENALTY", LOCAL_ACTOR_REPETITION_PENALTY, 1.0, 2.0)
565
- self.temperature = _env_float("ACTOR_LOCAL_TEMPERATURE", LOCAL_ACTOR_TEMPERATURE, 0.0, 1.5)
566
- self.top_p = _env_float("ACTOR_LOCAL_TOP_P", LOCAL_ACTOR_TOP_P, 0.05, 1.0)
567
- self.load_status = "unloaded"
568
- self.latest_latency_ms: int | None = None
569
- self.latest_validation_status: str | None = None
570
- self.latest_fallback_used: bool | None = None
571
- self.latest_fallback_reason: str | None = None
572
- self._llama = None
573
-
574
- def configure(self, max_new_tokens: int | None = None, temperature: float | None = None) -> None:
575
- if max_new_tokens is not None:
576
- self.max_new_tokens = _clamp_int(max_new_tokens, 16, 320)
577
-
578
- def generate_actor_response(
579
- self,
580
- session: TheaterSession,
581
- decision: DirectorDecision,
582
- speaker: Actor,
583
- prop: str | None,
584
- ) -> str:
585
- prompt = format_chatml(build_actor_chat_messages(session, decision, speaker, prop))
586
- self._load()
587
- return _run_with_timeout(lambda: self._generate_text(prompt), self.timeout_seconds, "Local GGUF generation timed out")
588
-
589
- def repair_actor_response(
590
- self,
591
- session: TheaterSession,
592
- decision: DirectorDecision,
593
- speaker: Actor,
594
- prop: str | None,
595
- invalid_output: ActorResponse | dict[str, Any] | str,
596
- validation_status: str,
597
- ) -> str | None:
598
- messages = build_actor_chat_messages(session, decision, speaker, prop)
599
- messages[-1]["content"] += (
600
- f"\n\nPrevious output failed validation with status {validation_status}.\n"
601
- "Return only one valid compact JSON object with the exact Actor schema.\n"
602
- f"Previous output: {invalid_output}"
603
- )
604
- prompt = format_chatml(messages)
605
- self._load()
606
- return _run_with_timeout(lambda: self._generate_text(prompt), self.timeout_seconds, "Local GGUF repair timed out")
607
-
608
- def _load(self) -> None:
609
- if self._llama is not None:
610
- self.load_status = "loaded"
611
- return
612
- model_path = self._resolve_model_path()
613
- if not Path(model_path).exists():
614
- self.load_status = "not_configured"
615
- self.latest_fallback_reason = "Local GGUF file was not found"
616
- raise RuntimeError("Local GGUF file was not found")
617
-
618
- self.load_status = "loading"
619
- try:
620
- from llama_cpp import Llama
621
- except ImportError as exc:
622
- self.load_status = "not_configured"
623
- self.latest_fallback_reason = "llama-cpp-python is not installed"
624
- raise RuntimeError("llama-cpp-python is not installed") from exc
625
-
626
- try:
627
- self._llama = Llama(
628
- model_path=model_path,
629
- n_ctx=self.n_ctx,
630
- n_gpu_layers=self.n_gpu_layers,
631
- verbose=False,
632
- )
633
- except Exception as exc:
634
- self._llama = None
635
- self.load_status = "error"
636
- self.latest_fallback_reason = _summarize_error(exc)
637
- raise
638
-
639
- self.load_status = "loaded"
640
-
641
- def _resolve_model_path(self) -> str:
642
- if self.model_path:
643
- return self.model_path
644
- if not self.repo_id or not self.filename:
645
- self.load_status = "not_configured"
646
- self.latest_fallback_reason = "ACTOR_GGUF_REPO_ID or ACTOR_GGUF_FILENAME is not configured"
647
- raise RuntimeError("ACTOR_GGUF_REPO_ID or ACTOR_GGUF_FILENAME is not configured")
648
- try:
649
- from huggingface_hub import hf_hub_download
650
- except ImportError as exc:
651
- self.load_status = "not_configured"
652
- self.latest_fallback_reason = "huggingface_hub is not installed"
653
- raise RuntimeError("huggingface_hub is not installed") from exc
654
- try:
655
- self.load_status = "downloading"
656
- resolved_path = hf_hub_download(repo_id=self.repo_id, filename=self.filename)
657
- except Exception as exc:
658
- self.load_status = "error"
659
- self.latest_fallback_reason = _summarize_error(exc)
660
- raise RuntimeError(f"GGUF download failed: {_summarize_error(exc)}") from exc
661
- self.model_path = resolved_path
662
- self.model_id = f"{self.repo_id}/{self.filename}"
663
- return resolved_path
664
-
665
- def _generate_text(self, prompt: str) -> str:
666
- self._load()
667
- output = self._llama(
668
- prompt,
669
- max_tokens=self.max_new_tokens,
670
- temperature=self.temperature,
671
- top_p=self.top_p,
672
- repeat_penalty=self.repetition_penalty,
673
- stop=["<|im_end|>", "<|im_start|>", "\nUSER:", "\nSYSTEM:", "\nASSISTANT:"],
674
- )
675
- if isinstance(output, dict):
676
- choices = output.get("choices") or []
677
- if choices and isinstance(choices[0], dict):
678
- return str(choices[0].get("text", "")).strip()
679
- return str(output).strip()
680
-
681
-
682
  def deterministic_actor_response(
683
  session: TheaterSession,
684
- decision: DirectorDecision,
685
  speaker: Actor,
686
  prop: str | None,
687
  ) -> ActorResponse:
688
  return ActorResponse(
689
- intent=_intent_for_beat(decision.beat_type, prop),
690
- line=_line_for_beat(session, decision, speaker, prop),
691
- emotion=_emotion_for_beat(decision.beat_type),
692
- gesture=_gesture_for_beat(decision.beat_type),
693
- stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
694
- memory_update=_memory_for_beat(session, decision, speaker, prop),
695
- tool_request=_tool_request_for_beat(decision, prop),
696
  )
697
 
698
 
699
  def generate_actor_response(
700
  session: TheaterSession,
701
- decision: DirectorDecision,
702
  speaker: Actor,
703
  prop: str | None,
704
  backend: ModelBackend | None = None,
@@ -711,12 +240,12 @@ def generate_actor_response(
711
  start_time = time.perf_counter()
712
  raw_output: ActorResponse | dict[str, Any] | str | None = None
713
  try:
714
- raw_output = active_backend.generate_actor_response(session, decision, speaker, prop)
715
  except Exception as exc:
716
  latency_ms = _elapsed_ms(start_time)
717
  return _fallback_generation(
718
  session=session,
719
- decision=decision,
720
  speaker=speaker,
721
  prop=prop,
722
  backend=active_backend,
@@ -727,27 +256,6 @@ def generate_actor_response(
727
 
728
  response, validation_status = parse_actor_output(raw_output)
729
  if response is not None:
730
- if _is_repeated_actor_line(response, session):
731
- validation_status = (
732
- f"{validation_status};repeated_line" if validation_status != "valid" else "repeated_line"
733
- )
734
- else:
735
- generation = BackendGeneration(
736
- response=response,
737
- backend_name=active_backend.name,
738
- model_id=active_backend.model_id,
739
- fallback_used=False,
740
- validation_status=validation_status,
741
- load_status=getattr(active_backend, "load_status", "loaded"),
742
- latency_ms=_elapsed_ms(start_time),
743
- )
744
- _record_generation_status(active_backend, generation)
745
- return generation
746
-
747
- if response is not None and validation_status.endswith("repeated_line"):
748
- raw_output = response.model_dump()
749
-
750
- if response is not None and not validation_status.endswith("repeated_line"):
751
  generation = BackendGeneration(
752
  response=response,
753
  backend_name=active_backend.name,
@@ -763,7 +271,7 @@ def generate_actor_response(
763
  try:
764
  repair_output = active_backend.repair_actor_response(
765
  session=session,
766
- decision=decision,
767
  speaker=speaker,
768
  prop=prop,
769
  invalid_output=raw_output,
@@ -772,7 +280,7 @@ def generate_actor_response(
772
  except Exception as exc:
773
  return _fallback_generation(
774
  session=session,
775
- decision=decision,
776
  speaker=speaker,
777
  prop=prop,
778
  backend=active_backend,
@@ -783,25 +291,22 @@ def generate_actor_response(
783
  if repair_output is not None:
784
  response, repair_status = parse_actor_output(repair_output)
785
  if response is not None:
786
- if _is_repeated_actor_line(response, session):
787
- validation_status = f"{validation_status};repair_{repair_status};repair_repeated_line"
788
- else:
789
- generation = BackendGeneration(
790
- response=response,
791
- backend_name=active_backend.name,
792
- model_id=active_backend.model_id,
793
- fallback_used=False,
794
- validation_status=f"repair_{repair_status}",
795
- load_status=getattr(active_backend, "load_status", "loaded"),
796
- latency_ms=_elapsed_ms(start_time),
797
- )
798
- _record_generation_status(active_backend, generation)
799
- return generation
800
  validation_status = f"{validation_status};repair_{repair_status}"
801
 
802
  return _fallback_generation(
803
  session=session,
804
- decision=decision,
805
  speaker=speaker,
806
  prop=prop,
807
  backend=active_backend,
@@ -816,42 +321,6 @@ def get_backend(
816
  temperature: float | None = None,
817
  ) -> ModelBackend:
818
  normalized_name = (backend_name or "deterministic").strip().lower()
819
- if normalized_name in {"local_lora", "lora", "actor_lora"}:
820
- cache_key = (
821
- "local_lora:"
822
- f"{os.getenv('ACTOR_LORA_BASE_MODEL', DEFAULT_ACTOR_LORA_BASE_MODEL)}:"
823
- f"{os.getenv('ACTOR_LORA_ADAPTER', DEFAULT_ACTOR_LORA_ADAPTER)}"
824
- )
825
- if cache_key not in _BACKEND_CACHE:
826
- _BACKEND_CACHE[cache_key] = LocalLoRAActorBackend()
827
- backend = _BACKEND_CACHE[cache_key]
828
- if isinstance(backend, LocalLoRAActorBackend):
829
- backend.configure(max_new_tokens=max_new_tokens, temperature=temperature)
830
- return backend
831
- if normalized_name in {"local_gguf", "gguf", "actor_gguf"}:
832
- model_path = os.getenv("ACTOR_GGUF_MODEL_PATH", "").strip()
833
- repo_id = os.getenv("ACTOR_GGUF_REPO_ID", DEFAULT_ACTOR_GGUF_REPO_ID).strip()
834
- filename = os.getenv("ACTOR_GGUF_FILENAME", DEFAULT_ACTOR_GGUF_FILENAME).strip()
835
- cache_key = f"local_gguf:{model_path or repo_id}:{filename}"
836
- if cache_key not in _BACKEND_CACHE:
837
- _BACKEND_CACHE[cache_key] = LocalGGUFActorBackend()
838
- backend = _BACKEND_CACHE[cache_key]
839
- if isinstance(backend, LocalGGUFActorBackend):
840
- backend.configure(max_new_tokens=max_new_tokens, temperature=temperature)
841
- return backend
842
- if normalized_name in {"hf_api", "huggingface_api", "hf-inference-api"}:
843
- model_id = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID)
844
- cache_key = f"hf_api:{model_id}"
845
- if cache_key not in _BACKEND_CACHE:
846
- _BACKEND_CACHE[cache_key] = HFAPIBackend(
847
- model_id=model_id,
848
- max_new_tokens=max_new_tokens or HF_API_ACTOR_MAX_TOKENS,
849
- temperature=temperature if temperature is not None else HF_API_ACTOR_TEMPERATURE,
850
- )
851
- backend = _BACKEND_CACHE[cache_key]
852
- if isinstance(backend, HFAPIBackend):
853
- backend.configure(max_new_tokens=max_new_tokens, temperature=temperature)
854
- return backend
855
  if normalized_name == "openbmb":
856
  model_id = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID)
857
  cache_key = f"openbmb:{model_id}"
@@ -878,13 +347,7 @@ def warm_up_openbmb(
878
 
879
  start_time = time.perf_counter()
880
  try:
881
- if zerogpu.USE_ZEROGPU:
882
- backend._generate_text(
883
- "Return only this JSON: "
884
- '{"intent":"Confirm readiness.","line":"Ready.","emotion":"ready","gesture":"wave","stage_effect":"spotlight","memory_update":"Ready for the cue.","tool_request":null}'
885
- )
886
- else:
887
- backend._load()
888
  backend.latest_latency_ms = _elapsed_ms(start_time)
889
  backend.latest_fallback_reason = None
890
  except Exception as exc:
@@ -895,51 +358,6 @@ def warm_up_openbmb(
895
 
896
  def get_backend_status(backend_name: str | None = None) -> BackendRuntimeStatus:
897
  normalized_name = (backend_name or "deterministic").strip().lower()
898
- if normalized_name in {"local_lora", "lora", "actor_lora"}:
899
- base_model_id = os.getenv("ACTOR_LORA_BASE_MODEL", DEFAULT_ACTOR_LORA_BASE_MODEL)
900
- adapter_id = os.getenv("ACTOR_LORA_ADAPTER", DEFAULT_ACTOR_LORA_ADAPTER)
901
- cache_key = f"local_lora:{base_model_id}:{adapter_id}"
902
- backend = _BACKEND_CACHE.get(cache_key)
903
- if backend is None:
904
- return BackendRuntimeStatus(
905
- backend_name="local_lora",
906
- model_id=adapter_id,
907
- load_status="unloaded",
908
- max_new_tokens=_env_int("ACTOR_LORA_MAX_NEW_TOKENS", ACTOR_LORA_MAX_NEW_TOKENS, 16, 320),
909
- temperature=0,
910
- **_zerogpu_status_kwargs(),
911
- )
912
- return _runtime_status_from_backend(backend)
913
- if normalized_name in {"local_gguf", "gguf", "actor_gguf"}:
914
- model_path = (os.getenv("ACTOR_GGUF_MODEL_PATH") or "").strip()
915
- repo_id = os.getenv("ACTOR_GGUF_REPO_ID", DEFAULT_ACTOR_GGUF_REPO_ID).strip()
916
- filename = os.getenv("ACTOR_GGUF_FILENAME", DEFAULT_ACTOR_GGUF_FILENAME).strip()
917
- cache_key = f"local_gguf:{model_path or repo_id}:{filename}"
918
- backend = _BACKEND_CACHE.get(cache_key)
919
- if backend is None:
920
- return BackendRuntimeStatus(
921
- backend_name="local_gguf",
922
- model_id=Path(model_path).name if model_path else f"{repo_id}/{filename}",
923
- load_status="unloaded",
924
- max_new_tokens=_env_int("ACTOR_GGUF_MAX_TOKENS", ACTOR_GGUF_MAX_TOKENS, 16, 320),
925
- temperature=0,
926
- **_zerogpu_status_kwargs(),
927
- )
928
- return _runtime_status_from_backend(backend)
929
- if normalized_name in {"hf_api", "huggingface_api", "hf-inference-api"}:
930
- model_id = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID)
931
- backend = _BACKEND_CACHE.get(f"hf_api:{model_id}")
932
- if backend is None:
933
- return BackendRuntimeStatus(
934
- backend_name="hf_api",
935
- model_id=model_id,
936
- load_status="remote_ready" if _hf_api_token() else "missing_token",
937
- token_configured=bool(_hf_api_token()),
938
- max_new_tokens=HF_API_ACTOR_MAX_TOKENS,
939
- temperature=HF_API_ACTOR_TEMPERATURE,
940
- **_zerogpu_status_kwargs(),
941
- )
942
- return _runtime_status_from_backend(backend)
943
  if normalized_name == "openbmb":
944
  model_id = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID)
945
  backend = _BACKEND_CACHE.get(f"openbmb:{model_id}")
@@ -950,7 +368,6 @@ def get_backend_status(backend_name: str | None = None) -> BackendRuntimeStatus:
950
  load_status="unloaded",
951
  max_new_tokens=OPENBMB_MAX_NEW_TOKENS,
952
  temperature=OPENBMB_TEMPERATURE,
953
- **_zerogpu_status_kwargs(),
954
  )
955
  return _runtime_status_from_backend(backend)
956
  return _runtime_status_from_backend(_BACKEND_CACHE["deterministic"])
@@ -958,124 +375,33 @@ def get_backend_status(backend_name: str | None = None) -> BackendRuntimeStatus:
958
 
959
  def build_actor_line_prompt(
960
  session: TheaterSession,
961
- decision: DirectorDecision,
962
  speaker: Actor,
963
  prop: str | None,
964
  ) -> str:
965
  recent_transcript = "\n".join(
966
  f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
967
  ) or "No lines yet."
968
- recent_memory = "; ".join(speaker.recent_memory[-3:]) or "None"
969
- held_props = ", ".join(speaker.held_props or ([speaker.held_prop] if speaker.held_prop else [])) or "none"
970
  prompt = ACTOR_LINE_PROMPT.format(
971
  show_title=session.show_title,
972
  premise=session.premise,
973
  setting=session.setting,
974
- beat_type=decision.beat_type,
975
  speaker_name=speaker.name,
976
  speaker_goal=speaker.goal,
977
- speaker_mood=speaker.mood,
978
- speaker_current_goal=speaker.current_goal or speaker.goal,
979
- speaker_goal_progress=speaker.goal_progress,
980
- speaker_held_props=held_props,
981
- speaker_tools=", ".join(speaker.tools) or "None",
982
- speaker_secret_status=speaker.secret_status,
983
- speaker_recent_memory=recent_memory,
984
  speaker_style=speaker.speaking_style,
985
- recent_tool_results=_format_recent_tool_results(session),
986
  audience_action=session.latest_audience_action or "None",
987
  latest_prop=prop or session.latest_prop or "None",
988
- director_instruction=decision.instruction,
989
- reveal_secret=decision.reveal_secret,
990
- stage_effect=decision.stage_effect,
991
  )
992
  return (
993
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
994
- "Keep the line under 25 words. Keep intent and memory_update short. Return JSON only."
995
- )
996
-
997
-
998
- def build_actor_chat_messages(
999
- session: TheaterSession,
1000
- decision: DirectorDecision,
1001
- speaker: Actor,
1002
- prop: str | None,
1003
- ) -> list[dict[str, str]]:
1004
- show_state = {
1005
- "show_title": session.show_title,
1006
- "setting": session.setting,
1007
- "beat_index": session.beat_index,
1008
- "min_beats": session.min_beats,
1009
- "target_beats": session.target_beats,
1010
- "max_beats": session.max_beats,
1011
- "story_phase": _story_phase_for_prompt(session),
1012
- "latest_audience_action": session.latest_audience_action,
1013
- "latest_prop": prop or session.latest_prop,
1014
- "stage_lighting": session.stage_lighting,
1015
- "recent_transcript": [
1016
- {"speaker": beat.speaker, "line": beat.line}
1017
- for beat in session.transcript[-4:]
1018
- ],
1019
- "recent_tool_results": [
1020
- {
1021
- "actor_name": result.actor_name,
1022
- "tool_name": result.tool_name,
1023
- "result": result.result,
1024
- }
1025
- for result in session.recent_tool_results[-3:]
1026
- ],
1027
- "finale_requested": session.finale_requested,
1028
- }
1029
- actor_payload = {
1030
- "avatar": speaker.avatar,
1031
- "name": speaker.name,
1032
- "goal": speaker.goal,
1033
- "secret": speaker.secret,
1034
- "speaking_style": speaker.speaking_style,
1035
- "tools": speaker.tools,
1036
- "mood": speaker.mood,
1037
- "current_goal": speaker.current_goal or speaker.goal,
1038
- "goal_progress": speaker.goal_progress,
1039
- "held_props": speaker.held_props or ([speaker.held_prop] if speaker.held_prop else []),
1040
- "secret_status": speaker.secret_status,
1041
- "recent_memory": speaker.recent_memory[-3:],
1042
- }
1043
- instruction_parts = [decision.instruction]
1044
- if decision.beat_type:
1045
- instruction_parts.append(f"Beat type: {decision.beat_type}.")
1046
- if decision.reveal_secret:
1047
- instruction_parts.append("Reveal or hint the actor secret in the line.")
1048
- if prop:
1049
- instruction_parts.append(f"Use the latest prop: {prop}.")
1050
- if session.transcript:
1051
- instruction_parts.append("Do not repeat any exact line from recent_transcript; advance the scene with new wording.")
1052
- instruction_parts.append(f"Suggested stage effect: {decision.stage_effect}.")
1053
- user = "\n".join(
1054
- [
1055
- f"premise: {session.premise}",
1056
- f"show_state JSON: {json.dumps(show_state, sort_keys=True, separators=(',', ':'))}",
1057
- f"actor JSON: {json.dumps(actor_payload, sort_keys=True, separators=(',', ':'))}",
1058
- f"director_instruction: {' '.join(instruction_parts)}",
1059
- "",
1060
- ACTOR_RESPONSE_SUFFIX,
1061
- ]
1062
  )
1063
- return [{"role": "system", "content": ACTOR_SYSTEM_MESSAGE}, {"role": "user", "content": user}]
1064
-
1065
-
1066
- def format_chatml(messages: list[dict[str, str]]) -> str:
1067
- chunks = []
1068
- for message in messages:
1069
- role = message.get("role", "user")
1070
- content = message.get("content", "")
1071
- chunks.append(f"<|im_start|>{role}\n{content}\n<|im_end|>")
1072
- chunks.append("<|im_start|>assistant\n")
1073
- return "\n".join(chunks)
1074
 
1075
 
1076
  def _fallback_generation(
1077
  session: TheaterSession,
1078
- decision: DirectorDecision,
1079
  speaker: Actor,
1080
  prop: str | None,
1081
  backend: ModelBackend,
@@ -1083,9 +409,7 @@ def _fallback_generation(
1083
  latency_ms: int | None,
1084
  error: str | None = None,
1085
  ) -> BackendGeneration:
1086
- fallback_response = deterministic_actor_response(session, decision, speaker, prop)
1087
- if _is_repeated_actor_line(fallback_response, session):
1088
- fallback_response = _vary_repeated_fallback_response(fallback_response, session, decision, speaker)
1089
  generation = BackendGeneration(
1090
  response=fallback_response,
1091
  backend_name=backend.name,
@@ -1100,44 +424,11 @@ def _fallback_generation(
1100
  return generation
1101
 
1102
 
1103
- def _is_repeated_actor_line(response: ActorResponse, session: TheaterSession) -> bool:
1104
- line = " ".join(response.line.lower().strip().split())
1105
- if not line:
1106
- return False
1107
- recent_lines = {
1108
- " ".join(beat.line.lower().strip().split())
1109
- for beat in session.transcript[-4:]
1110
- if beat.line.strip()
1111
- }
1112
- return line in recent_lines
1113
-
1114
-
1115
- def _vary_repeated_fallback_response(
1116
- response: ActorResponse,
1117
- session: TheaterSession,
1118
- decision: DirectorDecision,
1119
- speaker: Actor,
1120
- ) -> ActorResponse:
1121
- premise_word = next(
1122
- (word.strip(".,!?;:()[]{}\"'") for word in session.premise.split() if len(word.strip(".,!?;:()[]{}\"'")) > 4),
1123
- "mystery",
1124
- )
1125
- beat_label = decision.beat_type.replace("_", " ")
1126
- line = f"{speaker.name.split()[0]} turns the {premise_word.lower()} clue into a new {beat_label} wobble."
1127
- return response.model_copy(
1128
- update={
1129
- "line": _cap_words(line, 25),
1130
- "memory_update": f"Varied a repeated {beat_label} beat.",
1131
- }
1132
- )
1133
-
1134
-
1135
  def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tuple[ActorResponse | None, str]:
1136
  parsed = _coerce_actor_output(raw_output)
1137
  if parsed is None:
1138
  return None, "invalid_schema"
1139
 
1140
- parsed, validation_status = _normalize_actor_payload(parsed)
1141
  try:
1142
  response = ActorResponse.model_validate(parsed)
1143
  except ValidationError:
@@ -1146,13 +437,12 @@ def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tupl
1146
  if not response.line.strip():
1147
  return None, "invalid_empty_line"
1148
 
1149
- if len(response.line) > MAX_ACTOR_LINE_CHARS or len(response.line.split()) > 25:
1150
- capped_line = _cap_words(response.line, 25)
1151
  response = response.model_copy(update={"line": capped_line})
1152
- capped_status = "valid_line_capped"
1153
- return response, f"{validation_status};{capped_status}" if validation_status != "valid" else capped_status
1154
 
1155
- return response, validation_status
1156
 
1157
 
1158
  def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
@@ -1164,13 +454,6 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
1164
  text = raw_output.strip()
1165
  if not text:
1166
  return None
1167
- if text.startswith("```"):
1168
- text = text.strip("`")
1169
- if "\n" in text:
1170
- text = text.split("\n", maxsplit=1)[1]
1171
- extracted_json = extract_first_balanced_json(text)
1172
- if extracted_json is not None:
1173
- text = extracted_json
1174
  try:
1175
  decoded = json.loads(text)
1176
  except json.JSONDecodeError:
@@ -1179,147 +462,15 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
1179
  return None
1180
 
1181
 
1182
- def _normalize_actor_payload(parsed: ActorResponse | dict[str, Any]) -> tuple[ActorResponse | dict[str, Any], str]:
1183
- if isinstance(parsed, ActorResponse):
1184
- return parsed, "valid"
1185
-
1186
- normalized = {field: parsed[field] for field in ACTOR_JSON_FIELDS if field in parsed}
1187
- notes: list[str] = []
1188
- if "intent" not in normalized:
1189
- normalized["intent"] = "Respond to the Director's cue."
1190
- notes.append("compat_intent_defaulted")
1191
- if "emotion" not in normalized:
1192
- normalized["emotion"] = "focused"
1193
- notes.append("compat_emotion_defaulted")
1194
- if "gesture" not in normalized:
1195
- normalized["gesture"] = "leans toward the tiny spotlight"
1196
- notes.append("compat_gesture_defaulted")
1197
- if "stage_effect" not in normalized:
1198
- normalized["stage_effect"] = "warm_spotlight"
1199
- notes.append("compat_stage_effect_defaulted")
1200
- if "memory_update" not in normalized:
1201
- normalized["memory_update"] = ""
1202
- notes.append("compat_memory_defaulted")
1203
- if "tool_request" not in normalized:
1204
- normalized["tool_request"] = None
1205
- notes.append("compat_tool_defaulted")
1206
- elif isinstance(normalized["tool_request"], str | list):
1207
- normalized["tool_request"] = None
1208
- notes.append("compat_tool_ignored")
1209
- elif isinstance(normalized["tool_request"], dict):
1210
- tool_request = dict(normalized["tool_request"])
1211
- if "tool_name" not in tool_request and "tool" in tool_request:
1212
- tool_request["tool_name"] = tool_request.pop("tool")
1213
- notes.append("compat_tool_name_mapped")
1214
- if "arguments" not in tool_request and "args" in tool_request:
1215
- tool_request["arguments"] = tool_request.pop("args")
1216
- notes.append("compat_tool_args_mapped")
1217
- if "arguments" not in tool_request:
1218
- tool_request["arguments"] = {}
1219
- notes.append("compat_tool_arguments_defaulted")
1220
- if "reason" not in tool_request:
1221
- tool_request["reason"] = "Requested theatrical help."
1222
- notes.append("compat_tool_reason_defaulted")
1223
- tool_request, tool_status = _sanitize_tool_request(tool_request)
1224
- normalized["tool_request"] = tool_request
1225
- if tool_status != "valid":
1226
- notes.append(tool_status)
1227
-
1228
- if isinstance(normalized.get("line"), str):
1229
- line = " ".join(str(normalized["line"]).strip().split())
1230
- if len(line) > MAX_ACTOR_LINE_CHARS or len(line.split()) > 25:
1231
- normalized["line"] = _cap_words(line, 25)
1232
- notes.append("line_capped")
1233
- if isinstance(normalized.get("intent"), str) and len(str(normalized["intent"])) > 90:
1234
- normalized["intent"] = str(normalized["intent"])[:87].rstrip() + "..."
1235
- notes.append("intent_capped")
1236
- if isinstance(normalized.get("memory_update"), str) and len(str(normalized["memory_update"])) > 140:
1237
- normalized["memory_update"] = str(normalized["memory_update"])[:137].rstrip() + "..."
1238
- notes.append("memory_capped")
1239
-
1240
- return normalized, ";".join(notes) if notes else "valid"
1241
-
1242
-
1243
- def extract_first_balanced_json(text: str) -> str | None:
1244
- start = text.find("{")
1245
- if start == -1:
1246
- return None
1247
-
1248
- depth = 0
1249
- in_string = False
1250
- escape_next = False
1251
- for index, char in enumerate(text[start:], start=start):
1252
- if escape_next:
1253
- escape_next = False
1254
- continue
1255
- if char == "\\" and in_string:
1256
- escape_next = True
1257
- continue
1258
- if char == '"':
1259
- in_string = not in_string
1260
- continue
1261
- if in_string:
1262
- continue
1263
- if char == "{":
1264
- depth += 1
1265
- elif char == "}":
1266
- depth -= 1
1267
- if depth == 0:
1268
- return text[start : index + 1]
1269
- return None
1270
-
1271
-
1272
- def _sanitize_tool_request(raw_request: dict[str, Any]) -> tuple[dict[str, Any] | None, str]:
1273
- try:
1274
- from puppet_theater.tools import validate_tool_request
1275
-
1276
- request, validation_status = validate_tool_request(raw_request)
1277
- except Exception:
1278
- return None, "tool_request_ignored"
1279
- if request is None:
1280
- return None, f"tool_request_{validation_status}"
1281
- return request.model_dump(), "valid"
1282
-
1283
-
1284
- def _cap_words(value: str, max_words: int) -> str:
1285
- words = " ".join(value.strip().split()).split()
1286
- capped = " ".join(words[:max_words]).rstrip(" ,;:")
1287
- if len(capped) > MAX_ACTOR_LINE_CHARS:
1288
- capped = capped[: MAX_ACTOR_LINE_CHARS - 3].rstrip()
1289
- if len(words) > max_words or len(value) > MAX_ACTOR_LINE_CHARS:
1290
- return f"{capped}..."
1291
- return capped
1292
-
1293
-
1294
  def _elapsed_ms(start_time: float) -> int:
1295
  return round((time.perf_counter() - start_time) * 1000)
1296
 
1297
 
1298
- def _run_with_timeout(callback, timeout_seconds: float, timeout_message: str) -> str:
1299
- result_queue: queue.Queue[tuple[str, object]] = queue.Queue(maxsize=1)
1300
-
1301
- def run_callback() -> None:
1302
- try:
1303
- result_queue.put(("result", callback()), block=False)
1304
- except Exception as exc:
1305
- result_queue.put(("error", exc), block=False)
1306
-
1307
- worker = threading.Thread(target=run_callback, daemon=True)
1308
- worker.start()
1309
- try:
1310
- status, payload = result_queue.get(timeout=timeout_seconds)
1311
- except queue.Empty as exc:
1312
- raise RuntimeError(timeout_message) from exc
1313
- if status == "error":
1314
- raise payload
1315
- return str(payload)
1316
-
1317
-
1318
  def _summarize_error(exc: Exception) -> str:
1319
  message = " ".join(str(exc).split())
1320
  if not message:
1321
  message = exc.__class__.__name__
1322
- return _redact_secrets(message)[:180]
1323
 
1324
 
1325
  def _clamp_int(value: int, minimum: int, maximum: int) -> int:
@@ -1330,47 +481,6 @@ def _clamp_float(value: float, minimum: float, maximum: float) -> float:
1330
  return max(minimum, min(maximum, float(value)))
1331
 
1332
 
1333
- def _env_int(name: str, default: int, minimum: int, maximum: int) -> int:
1334
- raw_value = os.getenv(name)
1335
- if raw_value is None:
1336
- return default
1337
- try:
1338
- return _clamp_int(int(raw_value), minimum, maximum)
1339
- except ValueError:
1340
- return default
1341
-
1342
-
1343
- def _env_float(name: str, default: float, minimum: float, maximum: float) -> float:
1344
- raw_value = os.getenv(name)
1345
- if raw_value is None:
1346
- return default
1347
- try:
1348
- return _clamp_float(float(raw_value), minimum, maximum)
1349
- except ValueError:
1350
- return default
1351
-
1352
-
1353
- def _env_bool(name: str, default: bool) -> bool:
1354
- raw_value = os.getenv(name)
1355
- if raw_value is None:
1356
- return default
1357
- return raw_value.strip().lower() in {"1", "true", "yes", "on"}
1358
-
1359
-
1360
- def _story_phase_for_prompt(session: TheaterSession) -> str:
1361
- target_beats = max(1, session.target_beats)
1362
- progress = min(1.0, max(0.0, session.beat_index / target_beats))
1363
- if progress < 0.20:
1364
- return "opening"
1365
- if progress < 0.45:
1366
- return "complication"
1367
- if progress < 0.65:
1368
- return "reveal"
1369
- if progress < 0.85:
1370
- return "chaos"
1371
- return "finale"
1372
-
1373
-
1374
  def _record_generation_status(backend: ModelBackend, generation: BackendGeneration) -> None:
1375
  if hasattr(backend, "latest_latency_ms"):
1376
  backend.latest_latency_ms = generation.latency_ms
@@ -1383,113 +493,40 @@ def _record_generation_status(backend: ModelBackend, generation: BackendGenerati
1383
 
1384
 
1385
  def _runtime_status_from_backend(backend: ModelBackend) -> BackendRuntimeStatus:
1386
- fallback_reason = getattr(backend, "latest_fallback_reason", None)
1387
- if fallback_reason is None:
1388
- fallback_reason = zerogpu.LAST_GPU_FALLBACK_REASON
1389
  return BackendRuntimeStatus(
1390
  backend_name=backend.name,
1391
  model_id=backend.model_id,
1392
  load_status=getattr(backend, "load_status", "loaded"),
1393
- token_configured=getattr(backend, "token_configured", None),
1394
  latest_latency_ms=getattr(backend, "latest_latency_ms", None),
1395
  latest_validation_status=getattr(backend, "latest_validation_status", None),
1396
  latest_fallback_used=getattr(backend, "latest_fallback_used", None),
1397
- latest_fallback_reason=fallback_reason,
1398
  max_new_tokens=getattr(backend, "max_new_tokens", None),
1399
  temperature=getattr(backend, "temperature", None),
1400
- **_zerogpu_status_kwargs(),
1401
  )
1402
 
1403
 
1404
- def _zerogpu_status_kwargs() -> dict[str, object]:
1405
- return {
1406
- "zerogpu_enabled": zerogpu.USE_ZEROGPU,
1407
- "spaces_available": zerogpu.SPACES_AVAILABLE,
1408
- "zerogpu_gpu_active": zerogpu.ZEROGPU_GPU_ACTIVE,
1409
- "torch_version": zerogpu.torch_version(),
1410
- "cuda_available_in_gpu_fn": zerogpu.LAST_GPU_CUDA_AVAILABLE,
1411
- }
1412
-
1413
-
1414
  def _line_for_beat(
1415
  session: TheaterSession,
1416
- decision: DirectorDecision,
1417
  speaker: Actor,
1418
  prop: str | None,
1419
  ) -> str:
1420
  if prop is not None:
1421
- return f"This {prop} is evidence, comfort, and possibly our smallest witness."
1422
- beat_type = decision.beat_type
1423
- if session.latest_tool_result is not None and beat_type not in {"setup", "finale"}:
1424
- clue = _cap_words(session.latest_tool_result.result, 13).rstrip(".")
1425
- return f"The last stage clue says: {clue}, so I am changing tactics."
1426
  if beat_type == "setup":
1427
- premise = _cap_words(session.premise, 12).rstrip(".")
1428
- return f"I see it clearly: {premise}, and somehow I am in charge."
1429
  if beat_type == "denial_or_contradiction":
1430
  return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
1431
  if beat_type == "evidence_or_prop":
1432
  return "I found a prop with fingerprints, glitter, and a very dramatic attitude."
1433
  if beat_type == "secret_reveal":
1434
- if decision.reveal_secret:
1435
- return f"I confess: {speaker.secret}"
1436
- return "I nearly confessed something, but the spotlight blinked and I lost my nerve."
1437
  if beat_type == "chaos_or_intervention":
1438
  return "The audience has interrupted with imaginary confetti, so everyone must panic gracefully."
1439
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
1440
 
1441
 
1442
- def _intent_for_beat(beat_type: str, prop: str | None) -> str:
1443
- if prop is not None:
1444
- return f"Make the {prop} matter."
1445
- return {
1446
- "setup": "Establish the scene.",
1447
- "denial_or_contradiction": "Challenge the premise.",
1448
- "evidence_or_prop": "Turn a clue into momentum.",
1449
- "secret_reveal": "Reveal pressure without derailing.",
1450
- "chaos_or_intervention": "React and escalate briefly.",
1451
- "finale": "Close the show cleanly.",
1452
- }[beat_type]
1453
-
1454
-
1455
- def _memory_for_beat(
1456
- session: TheaterSession,
1457
- decision: DirectorDecision,
1458
- speaker: Actor,
1459
- prop: str | None,
1460
- ) -> str:
1461
- if prop is not None:
1462
- return f"Used {prop} as important evidence."
1463
- if decision.beat_type == "secret_reveal":
1464
- if decision.reveal_secret:
1465
- return "Shared a secret with the audience."
1466
- return "Almost revealed a secret under pressure."
1467
- if decision.beat_type == "finale":
1468
- return "Reached the curtain call."
1469
- if session.latest_audience_action:
1470
- return "Reacted to the audience interruption."
1471
- return f"Advanced the {decision.beat_type.replace('_', ' ')} beat."
1472
-
1473
-
1474
- def _tool_request_for_beat(decision: DirectorDecision, prop: str | None) -> ToolRequest | None:
1475
- if prop is None or not decision.uses_prop:
1476
- return None
1477
- return ToolRequest(
1478
- tool_name="inspect_prop",
1479
- arguments={"prop": prop},
1480
- reason="The prop should reveal a theatrical clue.",
1481
- )
1482
-
1483
-
1484
- def _format_recent_tool_results(session: TheaterSession) -> str:
1485
- if not session.recent_tool_results:
1486
- return "None"
1487
- return "\n".join(
1488
- f"- {result.actor_name} used {result.tool_name}: {result.result}"
1489
- for result in session.recent_tool_results[-3:]
1490
- )
1491
-
1492
-
1493
  def _emotion_for_beat(beat_type: str) -> str:
1494
  return {
1495
  "setup": "curious",
@@ -1526,44 +563,3 @@ def _effect_for_beat(beat_type: str) -> str:
1526
  _BACKEND_CACHE: dict[str, ModelBackend] = {
1527
  "deterministic": DeterministicBackend(),
1528
  }
1529
-
1530
-
1531
- def _hf_api_token() -> str | None:
1532
- token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
1533
- return token.strip() if token and token.strip() else None
1534
-
1535
-
1536
- def _redact_secrets(message: str) -> str:
1537
- redacted = message
1538
- for secret in (os.getenv("HF_TOKEN"), os.getenv("HUGGINGFACEHUB_API_TOKEN")):
1539
- if secret:
1540
- redacted = redacted.replace(secret, "[redacted]")
1541
- return redacted
1542
-
1543
-
1544
- def _extract_chat_completion_text(output: Any) -> str:
1545
- choices = getattr(output, "choices", None)
1546
- if choices:
1547
- message = getattr(choices[0], "message", None)
1548
- content = getattr(message, "content", None)
1549
- if content is not None:
1550
- return str(content).strip()
1551
- if isinstance(output, dict):
1552
- try:
1553
- return str(output["choices"][0]["message"]["content"]).strip()
1554
- except (KeyError, IndexError, TypeError):
1555
- pass
1556
- return str(output).strip()
1557
-
1558
-
1559
- def _extract_text_generation_output(output: Any) -> str:
1560
- if isinstance(output, str):
1561
- return output.strip()
1562
- generated_text = getattr(output, "generated_text", None)
1563
- if generated_text is not None:
1564
- return str(generated_text).strip()
1565
- if isinstance(output, dict):
1566
- text = output.get("generated_text") or output.get("text")
1567
- if text is not None:
1568
- return str(text).strip()
1569
- return str(output).strip()
 
2
  from dataclasses import dataclass
3
  import json
4
  import os
 
 
 
5
  import time
6
  from typing import Any
7
 
8
  from pydantic import ValidationError
9
 
10
+ from puppet_theater.models import Actor, ActorResponse, TheaterSession
11
  from puppet_theater.prompts import ACTOR_LINE_PROMPT
 
12
 
13
 
14
  MAX_ACTOR_LINE_CHARS = 220
15
  DEFAULT_OPENBMB_MODEL_ID = "openbmb/MiniCPM5-1B"
 
 
16
  OPENBMB_MAX_NEW_TOKENS = 80
17
  OPENBMB_TEMPERATURE = 0.8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  @dataclass(frozen=True)
 
34
  backend_name: str
35
  model_id: str | None
36
  load_status: str
 
37
  latest_latency_ms: int | None = None
38
  latest_validation_status: str | None = None
39
  latest_fallback_used: bool | None = None
40
  latest_fallback_reason: str | None = None
41
  max_new_tokens: int | None = None
42
  temperature: float | None = None
 
 
 
 
 
43
 
44
 
45
  class ModelBackend(ABC):
 
50
  def generate_actor_response(
51
  self,
52
  session: TheaterSession,
53
+ beat_type: str,
54
  speaker: Actor,
55
  prop: str | None,
56
  ) -> ActorResponse | dict[str, Any] | str:
 
59
  def repair_actor_response(
60
  self,
61
  session: TheaterSession,
62
+ beat_type: str,
63
  speaker: Actor,
64
  prop: str | None,
65
  invalid_output: ActorResponse | dict[str, Any] | str,
 
75
  def generate_actor_response(
76
  self,
77
  session: TheaterSession,
78
+ beat_type: str,
79
  speaker: Actor,
80
  prop: str | None,
81
  ) -> ActorResponse:
82
+ return deterministic_actor_response(session, beat_type, speaker, prop)
83
 
84
 
85
  class OpenBMBTransformersBackend(ModelBackend):
 
112
  def generate_actor_response(
113
  self,
114
  session: TheaterSession,
115
+ beat_type: str,
116
  speaker: Actor,
117
  prop: str | None,
118
  ) -> str:
119
+ prompt = build_actor_line_prompt(session, beat_type, speaker, prop)
120
  return self._generate_text(prompt)
121
 
122
  def repair_actor_response(
123
  self,
124
  session: TheaterSession,
125
+ beat_type: str,
126
  speaker: Actor,
127
  prop: str | None,
128
  invalid_output: ActorResponse | dict[str, Any] | str,
129
  validation_status: str,
130
  ) -> str | None:
131
+ prompt = build_actor_line_prompt(session, beat_type, speaker, prop)
132
  repair_prompt = (
133
  f"{prompt}\n\nThe previous output failed validation with status {validation_status}.\n"
134
  "Return only valid compact JSON. Do not include markdown, commentary, or extra keys.\n"
 
169
  self.load_status = "loaded"
170
 
171
  def _generate_text(self, prompt: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  self._load()
173
  messages = [{"role": "user", "content": prompt}]
174
  tokenizer = self._tokenizer
 
210
  return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  def deterministic_actor_response(
214
  session: TheaterSession,
215
+ beat_type: str,
216
  speaker: Actor,
217
  prop: str | None,
218
  ) -> ActorResponse:
219
  return ActorResponse(
220
+ line=_line_for_beat(session, beat_type, speaker, prop),
221
+ emotion=_emotion_for_beat(beat_type),
222
+ gesture=_gesture_for_beat(beat_type),
223
+ stage_effect=_effect_for_beat(beat_type),
224
+ tool_request=None,
 
 
225
  )
226
 
227
 
228
  def generate_actor_response(
229
  session: TheaterSession,
230
+ beat_type: str,
231
  speaker: Actor,
232
  prop: str | None,
233
  backend: ModelBackend | None = None,
 
240
  start_time = time.perf_counter()
241
  raw_output: ActorResponse | dict[str, Any] | str | None = None
242
  try:
243
+ raw_output = active_backend.generate_actor_response(session, beat_type, speaker, prop)
244
  except Exception as exc:
245
  latency_ms = _elapsed_ms(start_time)
246
  return _fallback_generation(
247
  session=session,
248
+ beat_type=beat_type,
249
  speaker=speaker,
250
  prop=prop,
251
  backend=active_backend,
 
256
 
257
  response, validation_status = parse_actor_output(raw_output)
258
  if response is not None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  generation = BackendGeneration(
260
  response=response,
261
  backend_name=active_backend.name,
 
271
  try:
272
  repair_output = active_backend.repair_actor_response(
273
  session=session,
274
+ beat_type=beat_type,
275
  speaker=speaker,
276
  prop=prop,
277
  invalid_output=raw_output,
 
280
  except Exception as exc:
281
  return _fallback_generation(
282
  session=session,
283
+ beat_type=beat_type,
284
  speaker=speaker,
285
  prop=prop,
286
  backend=active_backend,
 
291
  if repair_output is not None:
292
  response, repair_status = parse_actor_output(repair_output)
293
  if response is not None:
294
+ generation = BackendGeneration(
295
+ response=response,
296
+ backend_name=active_backend.name,
297
+ model_id=active_backend.model_id,
298
+ fallback_used=False,
299
+ validation_status=f"repair_{repair_status}",
300
+ load_status=getattr(active_backend, "load_status", "loaded"),
301
+ latency_ms=_elapsed_ms(start_time),
302
+ )
303
+ _record_generation_status(active_backend, generation)
304
+ return generation
 
 
 
305
  validation_status = f"{validation_status};repair_{repair_status}"
306
 
307
  return _fallback_generation(
308
  session=session,
309
+ beat_type=beat_type,
310
  speaker=speaker,
311
  prop=prop,
312
  backend=active_backend,
 
321
  temperature: float | None = None,
322
  ) -> ModelBackend:
323
  normalized_name = (backend_name or "deterministic").strip().lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  if normalized_name == "openbmb":
325
  model_id = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID)
326
  cache_key = f"openbmb:{model_id}"
 
347
 
348
  start_time = time.perf_counter()
349
  try:
350
+ backend._load()
 
 
 
 
 
 
351
  backend.latest_latency_ms = _elapsed_ms(start_time)
352
  backend.latest_fallback_reason = None
353
  except Exception as exc:
 
358
 
359
  def get_backend_status(backend_name: str | None = None) -> BackendRuntimeStatus:
360
  normalized_name = (backend_name or "deterministic").strip().lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  if normalized_name == "openbmb":
362
  model_id = os.getenv("OPENBMB_MODEL_ID", DEFAULT_OPENBMB_MODEL_ID)
363
  backend = _BACKEND_CACHE.get(f"openbmb:{model_id}")
 
368
  load_status="unloaded",
369
  max_new_tokens=OPENBMB_MAX_NEW_TOKENS,
370
  temperature=OPENBMB_TEMPERATURE,
 
371
  )
372
  return _runtime_status_from_backend(backend)
373
  return _runtime_status_from_backend(_BACKEND_CACHE["deterministic"])
 
375
 
376
  def build_actor_line_prompt(
377
  session: TheaterSession,
378
+ beat_type: str,
379
  speaker: Actor,
380
  prop: str | None,
381
  ) -> str:
382
  recent_transcript = "\n".join(
383
  f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
384
  ) or "No lines yet."
 
 
385
  prompt = ACTOR_LINE_PROMPT.format(
386
  show_title=session.show_title,
387
  premise=session.premise,
388
  setting=session.setting,
389
+ beat_type=beat_type,
390
  speaker_name=speaker.name,
391
  speaker_goal=speaker.goal,
 
 
 
 
 
 
 
392
  speaker_style=speaker.speaking_style,
 
393
  audience_action=session.latest_audience_action or "None",
394
  latest_prop=prop or session.latest_prop or "None",
 
 
 
395
  )
396
  return (
397
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
398
+ "Keep the line under 220 characters. Return JSON only."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  )
 
 
 
 
 
 
 
 
 
 
 
400
 
401
 
402
  def _fallback_generation(
403
  session: TheaterSession,
404
+ beat_type: str,
405
  speaker: Actor,
406
  prop: str | None,
407
  backend: ModelBackend,
 
409
  latency_ms: int | None,
410
  error: str | None = None,
411
  ) -> BackendGeneration:
412
+ fallback_response = deterministic_actor_response(session, beat_type, speaker, prop)
 
 
413
  generation = BackendGeneration(
414
  response=fallback_response,
415
  backend_name=backend.name,
 
424
  return generation
425
 
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tuple[ActorResponse | None, str]:
428
  parsed = _coerce_actor_output(raw_output)
429
  if parsed is None:
430
  return None, "invalid_schema"
431
 
 
432
  try:
433
  response = ActorResponse.model_validate(parsed)
434
  except ValidationError:
 
437
  if not response.line.strip():
438
  return None, "invalid_empty_line"
439
 
440
+ if len(response.line) > MAX_ACTOR_LINE_CHARS:
441
+ capped_line = response.line[: MAX_ACTOR_LINE_CHARS - 3].rstrip() + "..."
442
  response = response.model_copy(update={"line": capped_line})
443
+ return response, "valid_line_capped"
 
444
 
445
+ return response, "valid"
446
 
447
 
448
  def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
 
454
  text = raw_output.strip()
455
  if not text:
456
  return None
 
 
 
 
 
 
 
457
  try:
458
  decoded = json.loads(text)
459
  except json.JSONDecodeError:
 
462
  return None
463
 
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  def _elapsed_ms(start_time: float) -> int:
466
  return round((time.perf_counter() - start_time) * 1000)
467
 
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  def _summarize_error(exc: Exception) -> str:
470
  message = " ".join(str(exc).split())
471
  if not message:
472
  message = exc.__class__.__name__
473
+ return message[:180]
474
 
475
 
476
  def _clamp_int(value: int, minimum: int, maximum: int) -> int:
 
481
  return max(minimum, min(maximum, float(value)))
482
 
483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  def _record_generation_status(backend: ModelBackend, generation: BackendGeneration) -> None:
485
  if hasattr(backend, "latest_latency_ms"):
486
  backend.latest_latency_ms = generation.latency_ms
 
493
 
494
 
495
  def _runtime_status_from_backend(backend: ModelBackend) -> BackendRuntimeStatus:
 
 
 
496
  return BackendRuntimeStatus(
497
  backend_name=backend.name,
498
  model_id=backend.model_id,
499
  load_status=getattr(backend, "load_status", "loaded"),
 
500
  latest_latency_ms=getattr(backend, "latest_latency_ms", None),
501
  latest_validation_status=getattr(backend, "latest_validation_status", None),
502
  latest_fallback_used=getattr(backend, "latest_fallback_used", None),
503
+ latest_fallback_reason=getattr(backend, "latest_fallback_reason", None),
504
  max_new_tokens=getattr(backend, "max_new_tokens", None),
505
  temperature=getattr(backend, "temperature", None),
 
506
  )
507
 
508
 
 
 
 
 
 
 
 
 
 
 
509
  def _line_for_beat(
510
  session: TheaterSession,
511
+ beat_type: str,
512
  speaker: Actor,
513
  prop: str | None,
514
  ) -> str:
515
  if prop is not None:
516
+ return f"I shall use this {prop} as evidence, a prop, and possibly a tiny emotional support object."
 
 
 
 
517
  if beat_type == "setup":
518
+ return f"I see it clearly: {session.premise}, and somehow I am in charge."
 
519
  if beat_type == "denial_or_contradiction":
520
  return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
521
  if beat_type == "evidence_or_prop":
522
  return "I found a prop with fingerprints, glitter, and a very dramatic attitude."
523
  if beat_type == "secret_reveal":
524
+ return f"I confess: {speaker.secret}"
 
 
525
  if beat_type == "chaos_or_intervention":
526
  return "The audience has interrupted with imaginary confetti, so everyone must panic gracefully."
527
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
528
 
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  def _emotion_for_beat(beat_type: str) -> str:
531
  return {
532
  "setup": "curious",
 
563
  _BACKEND_CACHE: dict[str, ModelBackend] = {
564
  "deterministic": DeterministicBackend(),
565
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/director.py CHANGED
@@ -1,31 +1,7 @@
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
8
- import time
9
 
10
- from pydantic import ValidationError
11
-
12
- from puppet_theater.backends import (
13
- DEFAULT_HF_API_MODEL_ID,
14
- DEFAULT_OPENBMB_MODEL_ID,
15
- HFAPIBackend,
16
- HF_API_DIRECTOR_MAX_TOKENS,
17
- HF_API_DIRECTOR_TEMPERATURE,
18
- OpenBMBTransformersBackend,
19
- generate_actor_response,
20
- get_backend,
21
- )
22
- from puppet_theater.models import Actor, Beat, DirectorDecision, TheaterSession
23
- from puppet_theater.prompts import DIRECTOR_DECISION_PROMPT
24
- from puppet_theater.trace import add_trace_event
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,516 +11,40 @@ BEAT_ARC = [
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"
53
- if progress < 0.45:
54
- return "complication"
55
- if progress < 0.65:
56
- return "reveal"
57
- if progress < 0.85:
58
- return "chaos"
59
- return "finale"
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
67
- or session.beat_index >= session.max_beats - 1
68
- )
69
-
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
77
- validation_status: str
78
- fallback_used: bool
79
- reason_summary: str
80
- latency_ms: int | None = None
81
- error: str | None = 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(
94
- next_speaker=speaker.name,
95
- beat_type="finale",
96
- instruction="Tie the scene together in one crisp curtain-call line.",
97
- stage_effect="curtain_fall",
98
- should_end_scene=True,
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(
106
- next_speaker=speaker.name,
107
- beat_type="evidence_or_prop",
108
- instruction=f"Use the {session.latest_prop} as evidence and make it matter immediately.",
109
- stage_effect="prop_table_glow",
110
- uses_prop=True,
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(
118
- next_speaker=summoned_actor.name,
119
- beat_type="chaos_or_intervention",
120
- instruction="Enter the scene with one specific complication, then hand control back.",
121
- stage_effect="entrance_sparkle",
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,
133
- instruction=self._instruction_for_beat(beat_type, reveal_secret),
134
- stage_effect=self._effect_for_beat(beat_type),
135
- reveal_secret=reveal_secret,
136
- should_end_scene=beat_type == "finale",
137
- reason_summary=f"Following {phase} phase at {progress:.0%} progress with {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:
158
- return actor
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 {
166
- "setup": "Establish the premise and your role in one clear line.",
167
- "denial_or_contradiction": "Contradict the premise with theatrical confidence.",
168
- "evidence_or_prop": "Present a clue or prop as if it changes the whole case.",
169
- "chaos_or_intervention": "React to the audience and escalate the scene briefly.",
170
- "finale": "End the scene with a clean button and a bow.",
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",
178
- "evidence_or_prop": "prop_table_glow",
179
- "secret_reveal": "single_spotlight",
180
- "chaos_or_intervention": "confetti_rustle",
181
- "finale": "curtain_fall",
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":
189
- return "evidence_or_prop" if session.beat_index % 2 == 0 else "denial_or_contradiction"
190
- if phase == "reveal":
191
- return "secret_reveal"
192
- if phase == "chaos":
193
- return "chaos_or_intervention"
194
- return "chaos_or_intervention"
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
-
202
- def decide(self, session: TheaterSession) -> DirectorGeneration:
203
- start_time = time.perf_counter()
204
- backend = get_backend(
205
- "openbmb",
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,
225
- director_mode="openbmb",
226
- model_id=backend.model_id,
227
- validation_status=validation_status,
228
- fallback_used=False,
229
- reason_summary=decision.reason_summary,
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:
243
- return self._fallback(
244
- session,
245
- f"{validation_status};repair_backend_error",
246
- start_time,
247
- _summarize_error(exc),
248
- backend.model_id,
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,
256
- director_mode="openbmb",
257
- model_id=backend.model_id,
258
- validation_status=f"repair_{repair_status}",
259
- fallback_used=False,
260
- reason_summary=decision.reason_summary,
261
- latency_ms=_elapsed_ms(start_time),
262
- )
263
-
264
- return self._fallback(
265
- session,
266
- f"{validation_status};repair_{repair_status}",
267
- start_time,
268
- model_id=backend.model_id,
269
- )
270
-
271
- def _fallback(
272
- self,
273
- session: TheaterSession,
274
- validation_status: str,
275
- start_time: float,
276
- error: str | None = None,
277
- model_id: str | None = None,
278
- ) -> DirectorGeneration:
279
- decision = self.fallback_policy.decide(session)
280
- return DirectorGeneration(
281
- decision=decision,
282
- director_mode="openbmb",
283
- model_id=model_id or DEFAULT_OPENBMB_MODEL_ID,
284
- validation_status=validation_status,
285
- fallback_used=True,
286
- reason_summary=decision.reason_summary,
287
- latency_ms=_elapsed_ms(start_time),
288
- error=error,
289
- )
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
-
297
- def decide(self, session: TheaterSession) -> DirectorGeneration:
298
- start_time = time.perf_counter()
299
- backend = get_backend(
300
- "hf_api",
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,
313
- max_tokens=HF_API_DIRECTOR_MAX_TOKENS,
314
- temperature=HF_API_DIRECTOR_TEMPERATURE,
315
- )
316
- except Exception as exc:
317
- generation = self._fallback(session, "backend_error", start_time, _summarize_error(exc), backend.model_id)
318
- _record_director_backend_status(backend, generation)
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,
326
- director_mode="hf_api",
327
- model_id=backend.model_id,
328
- validation_status=validation_status,
329
- fallback_used=False,
330
- reason_summary=decision.reason_summary,
331
- latency_ms=_elapsed_ms(start_time),
332
- )
333
- _record_director_backend_status(backend, generation)
334
- return generation
335
-
336
- repair_prompt = (
337
- f"{prompt}\n\nPrevious output failed validation with status {validation_status}.\n"
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,
345
- max_tokens=HF_API_DIRECTOR_MAX_TOKENS,
346
- temperature=HF_API_DIRECTOR_TEMPERATURE,
347
- )
348
- except Exception as exc:
349
- generation = self._fallback(
350
- session,
351
- f"{validation_status};repair_backend_error",
352
- start_time,
353
- _summarize_error(exc),
354
- backend.model_id,
355
- )
356
- _record_director_backend_status(backend, generation)
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,
364
- director_mode="hf_api",
365
- model_id=backend.model_id,
366
- validation_status=f"repair_{repair_status}",
367
- fallback_used=False,
368
- reason_summary=decision.reason_summary,
369
- latency_ms=_elapsed_ms(start_time),
370
- )
371
- _record_director_backend_status(backend, generation)
372
- return generation
373
-
374
- generation = self._fallback(
375
- session,
376
- f"{validation_status};repair_{repair_status}",
377
- start_time,
378
- model_id=backend.model_id,
379
- )
380
- _record_director_backend_status(backend, generation)
381
- return generation
382
-
383
- def _fallback(
384
- self,
385
- session: TheaterSession,
386
- validation_status: str,
387
- start_time: float,
388
- error: str | None = None,
389
- model_id: str | None = None,
390
- ) -> DirectorGeneration:
391
- decision = self.fallback_policy.decide(session)
392
- return DirectorGeneration(
393
- decision=decision,
394
- director_mode="hf_api",
395
- model_id=model_id or DEFAULT_HF_API_MODEL_ID,
396
- validation_status=validation_status,
397
- fallback_used=True,
398
- reason_summary=decision.reason_summary,
399
- latency_ms=_elapsed_ms(start_time),
400
- error=error,
401
- )
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,
414
- director_mode="deterministic",
415
- model_id=None,
416
- validation_status="valid",
417
- fallback_used=False,
418
- reason_summary=decision.reason_summary,
419
- latency_ms=0,
420
- )
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
 
428
  if session.beat_index >= session.max_beats:
429
  session.director_log.append("Curtain already fallen; no new beat added.")
430
- add_trace_event(
431
- session,
432
- "beat_skipped",
433
- validation_status="curtain_already_fallen",
434
- fallback_used=False,
435
- )
436
  return session
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
-
450
- session.director_log.append(
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(
473
- session,
474
- "director_decision",
475
- speaker=speaker.name,
476
- beat_type=decision.beat_type,
477
- backend_name=director_generation.director_mode,
478
- model_id=director_generation.model_id,
479
- latency_ms=director_generation.latency_ms,
480
- validation_status=director_generation.validation_status,
481
- fallback_used=director_generation.fallback_used,
482
- fallback_reason=director_generation.error,
483
- reason_summary=director_generation.reason_summary,
484
- story_phase=current_phase,
485
- progress=round(current_progress, 3),
486
- min_beats=session.min_beats,
487
- target_beats=session.target_beats,
488
- max_beats=session.max_beats,
489
- uses_prop=decision.uses_prop,
490
- reveal_secret=decision.reveal_secret,
491
- should_end_scene=decision.should_end_scene,
492
- )
493
- if director_generation.error:
494
- add_trace_event(
495
- session,
496
- "director_error",
497
- backend_name=director_generation.director_mode,
498
- model_id=director_generation.model_id,
499
- validation_status=director_generation.validation_status,
500
- fallback_used=director_generation.fallback_used,
501
- fallback_reason=director_generation.error,
502
- )
503
- if director_generation.fallback_used:
504
- add_trace_event(
505
- session,
506
- "director_fallback",
507
- speaker=speaker.name,
508
- backend_name=director_generation.director_mode,
509
- model_id=director_generation.model_id,
510
- validation_status=director_generation.validation_status,
511
- fallback_used=True,
512
- fallback_reason=director_generation.error or director_generation.validation_status,
513
- story_phase=current_phase,
514
- )
515
-
516
- backend_generation = generate_actor_response(session, decision, speaker, prop)
517
-
518
  session.beat_index += 1
519
 
520
  response = backend_generation.response
521
  beat = Beat(
522
  speaker=speaker.name,
523
- intent=response.intent,
524
  line=response.line,
525
  emotion=response.emotion,
526
  gesture=response.gesture,
527
- stage_effect=response.stage_effect or decision.stage_effect,
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
-
541
  if prop is not None:
542
  session.latest_prop = None
543
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
544
- add_trace_event(session, "prop_used", speaker=speaker.name, prop=prop)
545
-
546
  session.director_log.append(
547
- f"Beat {session.beat_index}/{session.max_beats}: {decision.beat_type} assigned to {speaker.name}."
548
  )
549
  session.director_log.append(
550
  "Backend "
@@ -556,318 +56,30 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
556
  session.director_log.append(f"Model id: {backend_generation.model_id}.")
557
  if backend_generation.error:
558
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
559
-
560
- add_trace_event(
561
- session,
562
- "beat_added",
563
- beat_index=session.beat_index,
564
- beat_type=decision.beat_type,
565
- speaker=speaker.name,
566
- stage_effect=beat.stage_effect,
567
- story_phase=story_phase(session),
568
- progress=round(story_progress(session), 3),
569
- )
570
- add_trace_event(
571
- session,
572
- "actor_response",
573
- beat_index=session.beat_index,
574
- speaker=speaker.name,
575
- backend_name=backend_generation.backend_name,
576
- model_id=backend_generation.model_id,
577
- latency_ms=backend_generation.latency_ms,
578
- validation_status=backend_generation.validation_status,
579
- fallback_used=backend_generation.fallback_used,
580
- fallback_reason=backend_generation.error,
581
- load_status=backend_generation.load_status,
582
- )
583
- add_trace_event(
584
- session,
585
- "actor_intent",
586
- beat_index=session.beat_index,
587
- speaker=speaker.name,
588
- intent=_public_actor_text(response.intent, speaker),
589
- validation_status=backend_generation.validation_status,
590
- fallback_used=backend_generation.fallback_used,
591
- )
592
- if state_update["memory_update"]:
593
- add_trace_event(
594
- session,
595
- "actor_memory_update",
596
- beat_index=session.beat_index,
597
- speaker=speaker.name,
598
- memory_update=state_update["memory_update"],
599
- )
600
- add_trace_event(
601
- session,
602
- "actor_state_update",
603
- beat_index=session.beat_index,
604
- speaker=speaker.name,
605
- mood=speaker.mood,
606
- current_goal=_public_actor_text(speaker.current_goal or "", speaker),
607
- goal_progress=_public_actor_text(speaker.goal_progress, speaker),
608
- held_props=list(speaker.held_props),
609
- secret_status=speaker.secret_status,
610
- memory_count=len(speaker.recent_memory),
611
  )
612
- if backend_generation.error:
613
- add_trace_event(
614
- session,
615
- "backend_error",
616
- beat_index=session.beat_index,
617
- speaker=speaker.name,
618
- backend_name=backend_generation.backend_name,
619
- model_id=backend_generation.model_id,
620
- validation_status=backend_generation.validation_status,
621
- fallback_used=backend_generation.fallback_used,
622
- fallback_reason=backend_generation.error,
623
- )
624
- if backend_generation.fallback_used:
625
- add_trace_event(
626
- session,
627
- "actor_fallback",
628
- beat_index=session.beat_index,
629
- speaker=speaker.name,
630
- backend_name=backend_generation.backend_name,
631
- model_id=backend_generation.model_id,
632
- validation_status=backend_generation.validation_status,
633
- fallback_used=True,
634
- fallback_reason=backend_generation.error or backend_generation.validation_status,
635
- load_status=backend_generation.load_status,
636
- )
637
 
638
- if decision.should_end_scene or decision.beat_type == "finale":
639
  session.beat_index = session.max_beats
640
  session.finale_requested = True
641
  session.director_log.append("Finale reached; curtain falls cleanly.")
642
- add_trace_event(
643
- session,
644
- "scene_completed",
645
- beat_index=session.beat_index,
646
- story_phase="finale",
647
- reason_summary="Finale reached; curtain falls cleanly.",
648
- )
649
 
650
  return session
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
669
- return session.actors[session.beat_index % len(session.actors)]
670
-
671
-
672
- def apply_actor_state_update(
673
- session: TheaterSession,
674
- speaker: Actor,
675
- response: object,
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)
683
-
684
- speaker.mood = emotion
685
- speaker.current_goal = intent
686
- speaker.goal_progress = f"Beat {session.beat_index}: {intent}"
687
-
688
- if prop is not None and prop not in speaker.held_props:
689
- speaker.held_props.append(prop)
690
- if prop is not None:
691
- speaker.held_prop = prop
692
-
693
- if decision.reveal_secret:
694
- speaker.secret_status = "revealed"
695
- elif decision.beat_type == "secret_reveal" and speaker.secret_status == "hidden":
696
- speaker.secret_status = "hinted"
697
- elif decision.beat_type == "finale" and speaker.secret_status in {"hinted", "revealed"}:
698
- speaker.secret_status = "resolved"
699
-
700
- if memory_update:
701
- speaker.recent_memory.append(memory_update)
702
- speaker.recent_memory = speaker.recent_memory[-4:]
703
-
704
- return {"intent": intent, "memory_update": memory_update, "mood": emotion}
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}; "
712
- f"tools={', '.join(actor.tools) or 'none'}; secret={actor.secret}"
713
- for actor in session.actors
714
- )
715
- recent_transcript = "\n".join(
716
- f"{beat.speaker}: {beat.line}" for beat in session.transcript[-4:]
717
- ) or "No lines yet."
718
- recent_tool_results = "\n".join(
719
- f"- {result.actor_name} used {result.tool_name}: {result.result}"
720
- for result in session.recent_tool_results[-3:]
721
- ) or "None"
722
- summoned_actors = ", ".join(actor.name for actor in session.actors[3:]) or "None"
723
- return DIRECTOR_DECISION_PROMPT.format(
724
- show_title=session.show_title,
725
- premise=session.premise,
726
- setting=session.setting,
727
- beat_index=session.beat_index,
728
- min_beats=session.min_beats,
729
- target_beats=session.target_beats,
730
- max_beats=session.max_beats,
731
- current_progress=f"{story_progress(session):.0%}",
732
- story_phase=story_phase(session),
733
- allowed_beat_types=", ".join(BEAT_ARC),
734
- actor_profiles=actor_profiles,
735
- recent_transcript=recent_transcript,
736
- recent_tool_results=recent_tool_results,
737
- audience_action=session.latest_audience_action or "None",
738
- props=", ".join(session.props) or "None",
739
- latest_prop=session.latest_prop or "None",
740
- summoned_actors=summoned_actors,
741
- finale_requested=session.finale_requested,
742
- json_schema=json.dumps(
743
- {
744
- "next_speaker": "one exact actor name from Available actors",
745
- "beat_type": "one allowed beat type",
746
- "instruction": "non-empty director instruction, 240 chars max",
747
- "stage_effect": "non-empty concise stage effect label",
748
- "uses_prop": "boolean",
749
- "reveal_secret": "boolean",
750
- "should_end_scene": "boolean",
751
- "reason_summary": "non-empty validation-safe summary, 240 chars max",
752
- },
753
- indent=2,
754
- ),
755
- )
756
-
757
-
758
- def parse_director_decision(raw_output: object, session: TheaterSession) -> tuple[DirectorDecision | None, str]:
759
- parsed = _coerce_director_output(raw_output)
760
- if parsed is None:
761
- return None, "invalid_schema"
762
- if "speaker_name" in parsed and "next_speaker" not in parsed:
763
- parsed["next_speaker"] = parsed["speaker_name"]
764
- try:
765
- decision = DirectorDecision.model_validate(parsed)
766
- except ValidationError:
767
- return None, "invalid_required_fields"
768
- return validate_director_decision(decision, session)
769
-
770
-
771
- def validate_director_decision(decision: DirectorDecision, session: TheaterSession) -> tuple[DirectorDecision | None, str]:
772
- actor_names = {actor.name for actor in session.actors}
773
- if decision.next_speaker not in actor_names:
774
- return None, "invalid_speaker"
775
- if decision.beat_type not in BEAT_ARC:
776
- return None, "invalid_beat_type"
777
- if len(decision.instruction) > 240 or len(decision.reason_summary) > 240:
778
- return None, "invalid_too_long"
779
-
780
- update: dict[str, object] = {}
781
- if should_force_finale(session):
782
- update["beat_type"] = "finale"
783
- update["should_end_scene"] = True
784
- update["stage_effect"] = "curtain_fall"
785
- else:
786
- if decision.should_end_scene and session.beat_index < session.target_beats - 1:
787
- update["should_end_scene"] = False
788
- if decision.beat_type == "finale" and session.beat_index < session.target_beats - 1:
789
- update["beat_type"] = _safe_non_finale_beat_type(session)
790
- update["should_end_scene"] = False
791
- update["stage_effect"] = DirectorPolicy()._effect_for_beat(str(update["beat_type"]))
792
- if decision.uses_prop and session.latest_prop is None:
793
- update["uses_prop"] = False
794
- if decision.beat_type == "secret_reveal":
795
- update["reveal_secret"] = True
796
-
797
- if update:
798
- decision = decision.model_copy(update=update)
799
- return decision, "valid"
800
-
801
-
802
- def _safe_non_finale_beat_type(session: TheaterSession) -> str:
803
- phase = story_phase(session)
804
- if phase == "opening":
805
- return "setup"
806
- if phase == "complication":
807
- return "evidence_or_prop"
808
- if phase == "reveal":
809
- return "secret_reveal"
810
- return "chaos_or_intervention"
811
-
812
-
813
- def _coerce_director_output(raw_output: object) -> dict[str, object] | None:
814
- if isinstance(raw_output, DirectorDecision):
815
- return raw_output.model_dump()
816
- if isinstance(raw_output, dict):
817
- return raw_output
818
- if isinstance(raw_output, str):
819
- text = raw_output.strip()
820
- if not text:
821
- return None
822
- if text.startswith("```"):
823
- text = text.strip("`")
824
- if "\n" in text:
825
- text = text.split("\n", maxsplit=1)[1]
826
- start = text.find("{")
827
- end = text.rfind("}")
828
- if start != -1 and end != -1 and end > start:
829
- text = text[start : end + 1]
830
- try:
831
- decoded = json.loads(text)
832
- except json.JSONDecodeError:
833
- return None
834
- return decoded if isinstance(decoded, dict) else None
835
- return None
836
-
837
-
838
- def _elapsed_ms(start_time: float) -> int:
839
- return round((time.perf_counter() - start_time) * 1000)
840
-
841
-
842
- def _summarize_error(exc: Exception) -> str:
843
- message = " ".join(str(exc).split()) or exc.__class__.__name__
844
- return message[:180]
845
-
846
-
847
- def _trace_text(value: str) -> str:
848
- return " ".join(value.split()).replace(":", "-")[:140]
849
-
850
-
851
- def _public_actor_text(value: str, actor: Actor) -> str:
852
- text = " ".join(value.split())
853
- if actor.secret and actor.secret_status not in {"revealed", "resolved"}:
854
- text = text.replace(actor.secret, "[redacted secret]")
855
- return text[:140]
856
-
857
-
858
- def _short_public_text(value: str, actor: Actor, max_chars: int) -> str:
859
- text = _public_actor_text(value, actor)
860
- if len(text) <= max_chars:
861
- return text
862
- return text[: max_chars - 3].rstrip() + "..."
863
-
864
-
865
- def _record_director_backend_status(backend: object, generation: DirectorGeneration) -> None:
866
- if hasattr(backend, "latest_latency_ms"):
867
- backend.latest_latency_ms = generation.latency_ms
868
- if hasattr(backend, "latest_validation_status"):
869
- backend.latest_validation_status = generation.validation_status
870
- if hasattr(backend, "latest_fallback_used"):
871
- backend.latest_fallback_used = generation.fallback_used
872
- if hasattr(backend, "latest_fallback_reason"):
873
- backend.latest_fallback_reason = generation.error
 
1
+ from puppet_theater.backends import generate_actor_response
2
+ from puppet_theater.models import Beat, TheaterSession
 
3
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  BEAT_ARC = [
6
  "setup",
7
  "denial_or_contradiction",
 
11
  "finale",
12
  ]
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
 
16
  if session is None:
17
  return None
18
 
19
  if session.beat_index >= session.max_beats:
20
  session.director_log.append("Curtain already fallen; no new beat added.")
21
+ session.trace_events.append("beat_skipped:curtain_already_fallen")
 
 
 
 
 
22
  return session
23
 
24
+ beat_type = "finale" if session.finale_requested else BEAT_ARC[session.beat_index]
25
+ speaker = session.actors[session.beat_index % len(session.actors)]
26
+ prop = session.latest_prop
 
 
 
 
 
 
27
  if prop is not None:
28
  speaker.held_prop = prop
29
+ backend_generation = generate_actor_response(session, beat_type, speaker, prop)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  session.beat_index += 1
31
 
32
  response = backend_generation.response
33
  beat = Beat(
34
  speaker=speaker.name,
 
35
  line=response.line,
36
  emotion=response.emotion,
37
  gesture=response.gesture,
38
+ stage_effect=response.stage_effect,
 
39
  tool_request=response.tool_request,
40
  )
 
41
  session.transcript.append(beat)
 
 
 
 
 
 
 
 
42
  if prop is not None:
43
  session.latest_prop = None
44
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
45
+ session.trace_events.append(f"prop_handled:{prop}:{speaker.name}")
 
46
  session.director_log.append(
47
+ f"Beat {session.beat_index}/{session.max_beats}: {beat_type} assigned to {speaker.name}."
48
  )
49
  session.director_log.append(
50
  "Backend "
 
56
  session.director_log.append(f"Model id: {backend_generation.model_id}.")
57
  if backend_generation.error:
58
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
59
+ session.trace_events.append(f"beat_added:{session.beat_index}:{beat_type}")
60
+ session.trace_events.append(
61
+ "backend_result:"
62
+ f"{backend_generation.backend_name}:"
63
+ f"model={backend_generation.model_id or 'none'}:"
64
+ f"load_status={backend_generation.load_status}:"
65
+ f"fallback={backend_generation.fallback_used}:"
66
+ f"validation={backend_generation.validation_status}:"
67
+ f"latency_ms={backend_generation.latency_ms}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ if beat_type == "finale":
71
  session.beat_index = session.max_beats
72
  session.finale_requested = True
73
  session.director_log.append("Finale reached; curtain falls cleanly.")
74
+ session.trace_events.append("scene_completed")
 
 
 
 
 
 
75
 
76
  return session
77
 
78
 
79
  def run_full_act(session: TheaterSession | None) -> TheaterSession | None:
 
80
  if session is None:
81
  return None
82
 
83
  while session.beat_index < session.max_beats:
84
  run_one_beat(session)
 
85
  return session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/models.py CHANGED
@@ -1,143 +1,16 @@
1
  from dataclasses import dataclass, field
2
- from datetime import datetime, timezone
3
- from uuid import uuid4
4
 
5
- from typing import Any, Literal
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",
33
- "denial_or_contradiction",
34
- "evidence_or_prop",
35
- "secret_reveal",
36
- "chaos_or_intervention",
37
- "finale",
38
- ]
39
-
40
-
41
- SimpleToolValue = str | int | float | bool | None
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
55
-
56
- @field_validator("tool_name", "reason")
57
- @classmethod
58
- def require_text(cls, value: str) -> str:
59
- cleaned = " ".join(value.strip().split())
60
- if not cleaned:
61
- raise ValueError("field must not be empty")
62
- return cleaned
63
-
64
- @field_validator("reason")
65
- @classmethod
66
- def keep_reason_short(cls, value: str) -> str:
67
- if len(value) > 140:
68
- raise ValueError("reason must be 140 characters or fewer")
69
- return value
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
91
  gesture: str
92
  stage_effect: str
93
- memory_update: str = Field(default="", description="Short visible memory note from this beat.")
94
- tool_request: ToolRequest | None = None
95
-
96
- @field_validator("intent", "line", "emotion", "gesture", "stage_effect", "memory_update")
97
- @classmethod
98
- def require_text(cls, value: str, info: ValidationInfo) -> str:
99
- cleaned = " ".join(value.strip().split())
100
- if not cleaned and info.field_name != "memory_update":
101
- raise ValueError("field must not be empty")
102
- return cleaned
103
-
104
- @field_validator("intent")
105
- @classmethod
106
- def keep_intent_short(cls, value: str) -> str:
107
- if len(value) > 90:
108
- raise ValueError("intent must be 90 characters or fewer")
109
- return value
110
-
111
- @field_validator("line")
112
- @classmethod
113
- def keep_line_short(cls, value: str) -> str:
114
- if not value:
115
- raise ValueError("line must not be empty")
116
- if len(value.split()) > 25:
117
- raise ValueError("line must be 25 words or fewer")
118
- if len(value) > 220:
119
- raise ValueError("line must be 220 characters or fewer")
120
- return value
121
-
122
- @field_validator("memory_update")
123
- @classmethod
124
- def keep_memory_short(cls, value: str) -> str:
125
- if len(value) > 140:
126
- raise ValueError("memory_update must be 140 characters or fewer")
127
- return value
128
-
129
-
130
- class DirectorDecision(BaseModel):
131
- next_speaker: str
132
- beat_type: BeatType
133
- instruction: str
134
- stage_effect: str
135
- uses_prop: bool = False
136
- reveal_secret: bool = False
137
- should_end_scene: bool = False
138
- reason_summary: str
139
 
140
- @field_validator("next_speaker", "instruction", "stage_effect", "reason_summary")
141
  @classmethod
142
  def require_text(cls, value: str) -> str:
143
  cleaned = " ".join(value.strip().split())
@@ -145,52 +18,34 @@ class DirectorDecision(BaseModel):
145
  raise ValueError("field must not be empty")
146
  return cleaned
147
 
148
- @field_validator("instruction", "reason_summary")
149
  @classmethod
150
- def keep_brief(cls, value: str) -> str:
151
- if len(value) > 240:
152
- raise ValueError("field must be 240 characters or fewer")
153
- return value
 
154
 
155
 
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
168
- goal_progress: str = "Waiting for the curtain."
169
- held_props: list[str] = field(default_factory=list)
170
- secret_status: Literal["hidden", "hinted", "revealed", "resolved"] = "hidden"
171
- recent_memory: list[str] = field(default_factory=list)
172
 
173
 
174
  @dataclass
175
  class Beat:
176
  speaker: str
177
- intent: str
178
  line: str
179
  emotion: str
180
  gesture: str
181
  stage_effect: str
182
- memory_update: str = ""
183
- tool_request: ToolRequest | None = None
184
-
185
-
186
- @dataclass
187
- class ToolResult:
188
- tool_name: str
189
- result: str
190
- actor_name: str
191
- reason: str
192
- arguments: dict[str, SimpleToolValue] = field(default_factory=dict)
193
- stage_effect: str | None = None
194
 
195
 
196
  @dataclass
@@ -199,29 +54,16 @@ class TheaterSession:
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
207
- min_beats: int = 7
208
- target_beats: int = 10
209
- max_beats: int = 12
210
- show_length_mode: str = "standard"
211
  transcript: list[Beat] = field(default_factory=list)
212
  props: list[str] = field(default_factory=list)
213
  latest_prop: str | None = None
214
  latest_audience_action: str | None = None
215
- latest_tool_result: ToolResult | None = None
216
- recent_tool_results: list[ToolResult] = field(default_factory=list)
217
- stage_lighting: str = "warm_spotlight"
218
  director_log: list[str] = field(default_factory=list)
219
- trace_events: list[dict[str, Any] | str] = field(default_factory=list)
220
  finale_requested: bool = False
221
  backend_name: str = "deterministic"
222
  backend_model_id: str | None = None
223
- backend_max_new_tokens: int = 120
224
- backend_temperature: float = 0.75
225
- director_mode: str = "deterministic"
226
- # One-shot: show opening-curtain animation on the first stage render after create.
227
- play_opening_curtain: bool = False
 
1
  from dataclasses import dataclass, field
 
 
2
 
3
+ from pydantic import BaseModel, Field, field_validator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
 
6
  class ActorResponse(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  line: str = Field(description="Short, stage-ready puppet dialogue.")
8
  emotion: str
9
  gesture: str
10
  stage_effect: str
11
+ tool_request: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ @field_validator("line", "emotion", "gesture", "stage_effect")
14
  @classmethod
15
  def require_text(cls, value: str) -> str:
16
  cleaned = " ".join(value.strip().split())
 
18
  raise ValueError("field must not be empty")
19
  return cleaned
20
 
21
+ @field_validator("tool_request")
22
  @classmethod
23
+ def clean_optional_text(cls, value: str | None) -> str | None:
24
+ if value is None:
25
+ return None
26
+ cleaned = " ".join(value.strip().split())
27
+ return cleaned or None
28
 
29
 
30
  @dataclass
31
  class Actor:
32
  name: str
33
+ avatar: str
34
  goal: str
35
  secret: str
36
  speaking_style: str
37
  tools: list[str] = field(default_factory=list)
 
38
  held_prop: str | None = None
 
 
 
 
 
 
39
 
40
 
41
  @dataclass
42
  class Beat:
43
  speaker: str
 
44
  line: str
45
  emotion: str
46
  gesture: str
47
  stage_effect: str
48
+ tool_request: str | None = None
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  @dataclass
 
54
  premise: str
55
  setting: str
56
  actors: list[Actor]
 
 
 
 
57
  beat_index: int = 0
58
+ max_beats: int = 6
 
 
 
59
  transcript: list[Beat] = field(default_factory=list)
60
  props: list[str] = field(default_factory=list)
61
  latest_prop: str | None = None
62
  latest_audience_action: str | None = None
 
 
 
63
  director_log: list[str] = field(default_factory=list)
64
+ trace_events: list[str] = field(default_factory=list)
65
  finale_requested: bool = False
66
  backend_name: str = "deterministic"
67
  backend_model_id: str | None = None
68
+ backend_max_new_tokens: int = 80
69
+ backend_temperature: float = 0.8
 
 
 
puppet_theater/prompts.py CHANGED
@@ -1,21 +1,11 @@
1
  ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
2
 
3
  Return only JSON with these fields:
4
- - intent: one short visible reason for the actor's next move
5
  - line: a short, stage-ready spoken line
6
  - emotion: one concise emotion label
7
  - gesture: one concise stage gesture label
8
  - stage_effect: one concise stage effect label
9
- - memory_update: one short visible note the actor should remember, or empty string
10
- - tool_request: null or an object with tool_name, arguments, and reason
11
-
12
- Allowed tool names:
13
- - inspect_prop: arguments {{"prop": "short prop name"}}
14
- - consult_stage_oracle: arguments {{"question": "short stage question"}}
15
- - change_lighting: arguments {{"mood": "short lighting mood"}}
16
-
17
- Keep line under 25 words. Return JSON only.
18
- Only request a tool when it will make the next beat more theatrical.
19
 
20
  Show title: {show_title}
21
  Premise: {premise}
@@ -23,19 +13,7 @@ Setting: {setting}
23
  Beat type: {beat_type}
24
  Speaker: {speaker_name}
25
  Speaker goal: {speaker_goal}
26
- Speaker mood: {speaker_mood}
27
- Speaker current goal: {speaker_current_goal}
28
- Speaker goal progress: {speaker_goal_progress}
29
- Speaker held props: {speaker_held_props}
30
- Speaker tools: {speaker_tools}
31
- Speaker secret status: {speaker_secret_status}
32
- Speaker recent memory: {speaker_recent_memory}
33
  Speaker style: {speaker_style}
34
- Recent tool results:
35
- {recent_tool_results}
36
- Director instruction: {director_instruction}
37
- Reveal secret this beat: {reveal_secret}
38
- Suggested stage effect: {stage_effect}
39
  Audience action: {audience_action}
40
  Latest prop: {latest_prop}
41
  """
@@ -43,43 +21,22 @@ Latest prop: {latest_prop}
43
 
44
  DIRECTOR_DECISION_PROMPT = """You are the Director for AI Puppet Theater.
45
 
46
- Choose the next beat for a short puppet scene. Return a proposed decision only.
47
- The app will validate the decision and mutate session state later. Keep the scene tight,
48
- rotate speakers, respect finale requests, and avoid exposing actor secrets unless the
49
- beat calls for it.
50
 
51
  Return only JSON with these fields:
52
- - next_speaker
53
  - beat_type
54
- - instruction
55
- - stage_effect
56
- - uses_prop
57
- - reveal_secret
58
  - reason_summary
59
- - should_end_scene
60
-
61
- Required JSON schema:
62
- {json_schema}
63
 
64
  Show title: {show_title}
65
  Premise: {premise}
66
- Setting: {setting}
67
  Current beat: {beat_index}
68
- Minimum beats before normal ending: {min_beats}
69
- Target beats: {target_beats}
70
  Maximum beats: {max_beats}
71
- Current progress: {current_progress}
72
- Story phase: {story_phase}
73
- Allowed beat types: {allowed_beat_types}
74
- Available actors:
75
- {actor_profiles}
76
  Recent transcript: {recent_transcript}
77
- Recent tool results:
78
- {recent_tool_results}
79
  Audience action: {audience_action}
80
- Props on stage: {props}
81
- Latest prop: {latest_prop}
82
- Summoned actors: {summoned_actors}
83
  Finale requested: {finale_requested}
84
  """
85
 
 
1
  ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
2
 
3
  Return only JSON with these fields:
 
4
  - line: a short, stage-ready spoken line
5
  - emotion: one concise emotion label
6
  - gesture: one concise stage gesture label
7
  - stage_effect: one concise stage effect label
8
+ - tool_request: null or one concise theatrical tool request
 
 
 
 
 
 
 
 
 
9
 
10
  Show title: {show_title}
11
  Premise: {premise}
 
13
  Beat type: {beat_type}
14
  Speaker: {speaker_name}
15
  Speaker goal: {speaker_goal}
 
 
 
 
 
 
 
16
  Speaker style: {speaker_style}
 
 
 
 
 
17
  Audience action: {audience_action}
18
  Latest prop: {latest_prop}
19
  """
 
21
 
22
  DIRECTOR_DECISION_PROMPT = """You are the Director for AI Puppet Theater.
23
 
24
+ Choose the next beat for a short puppet scene. Keep the scene tight, rotate speakers,
25
+ respect finale requests, and avoid exposing actor secrets unless the beat calls for it.
 
 
26
 
27
  Return only JSON with these fields:
 
28
  - beat_type
29
+ - speaker_name
 
 
 
30
  - reason_summary
31
+ - should_end
 
 
 
32
 
33
  Show title: {show_title}
34
  Premise: {premise}
 
35
  Current beat: {beat_index}
 
 
36
  Maximum beats: {max_beats}
37
+ Available actors: {actor_names}
 
 
 
 
38
  Recent transcript: {recent_transcript}
 
 
39
  Audience action: {audience_action}
 
 
 
40
  Finale requested: {finale_requested}
41
  """
42
 
puppet_theater/session.py CHANGED
@@ -1,72 +1,12 @@
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
46
- min_beats, target_beats, max_beats = SHOW_LENGTH_PRESETS[normalized]
47
- return normalized, min_beats, target_beats, max_beats
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,16 +15,6 @@ def _title_from_premise(premise: str) -> str:
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,117 +27,25 @@ def _setting_from_premise(premise: str) -> str:
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="🎬",
207
  goal="Keep the scene moving toward a crisp finale.",
208
  secret="Has already misplaced the final cue card.",
209
  speaking_style="brisk, theatrical, and slightly overconfident",
210
- tools=["change_lighting", "consult_stage_oracle"],
211
  ),
212
  Actor(
213
  name="Mina Moonbutton",
@@ -215,7 +53,7 @@ def _default_cast() -> list[Actor]:
215
  goal="Find the emotional truth hiding inside the premise.",
216
  secret="Believes every prop is personally judging her.",
217
  speaking_style="earnest, poetic, and prone to dramatic pauses",
218
- tools=["consult_stage_oracle", "change_lighting"],
219
  ),
220
  Actor(
221
  name="Bolt McJiggle",
@@ -223,350 +61,41 @@ def _default_cast() -> list[Actor]:
223
  goal="Turn every problem into a practical stage gag.",
224
  secret="Is secretly building a confetti finale backstage.",
225
  speaking_style="punchy, practical, and full of suspicious confidence",
226
- tools=["inspect_prop", "change_lighting"],
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,
506
- max_beats=max_beats,
507
- show_length_mode=active_show_length,
508
  transcript=[],
509
  props=[],
510
  latest_prop=None,
511
  latest_audience_action=None,
512
  director_log=director_log,
513
- trace_events=[],
514
  finale_requested=False,
515
  backend_name=active_backend,
516
  backend_model_id=backend_model_id,
517
  backend_max_new_tokens=backend_max_new_tokens,
518
  backend_temperature=backend_temperature,
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",
548
- backend_name=active_backend,
549
- model_id=backend_model_id,
550
- director_mode=active_director_mode,
551
- show_length=active_show_length,
552
- min_beats=min_beats,
553
- target_beats=target_beats,
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",
567
- director_mode=active_director_mode,
568
- story_phase="opening",
569
- reason_summary=f"{active_show_length.title()} progress-based show plan created.",
570
- )
571
-
572
- return session
 
 
 
 
 
 
 
1
  from puppet_theater.models import Actor, TheaterSession
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
 
4
  def _clean_premise(premise: str) -> str:
 
 
 
 
5
  cleaned = " ".join(premise.strip().split())
6
  return cleaned or "A mysterious puppet show with no premise"
7
 
8
 
9
  def _title_from_premise(premise: str) -> str:
 
 
 
 
 
 
 
 
 
 
10
  words = [word.strip(".,!?;:()[]{}\"'") for word in premise.split()]
11
  keywords = [word.title() for word in words if len(word.strip(".,!?;:()[]{}\"'")) > 3]
12
  if not keywords:
 
15
 
16
 
17
  def _setting_from_premise(premise: str) -> str:
 
 
 
 
 
 
 
 
 
 
18
  lowered = premise.lower()
19
  if "moon" in lowered or "space" in lowered or "star" in lowered:
20
  return "a cardboard moon base with glittery stars and a squeaky hatch"
 
27
  return "a pocket-sized improv stage with painted flats and a wobbly spotlight"
28
 
29
 
30
+ def create_show_from_premise(
31
+ premise: str,
32
+ backend_name: str = "deterministic",
33
+ backend_model_id: str | None = None,
34
+ backend_max_new_tokens: int = 80,
35
+ backend_temperature: float = 0.8,
36
+ ) -> TheaterSession:
37
+ cleaned_premise = _clean_premise(premise)
38
+ show_title = _title_from_premise(cleaned_premise)
39
+ setting = _setting_from_premise(cleaned_premise)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ actors = [
 
 
42
  Actor(
43
  name="Pip the Director",
44
  avatar="🎬",
45
  goal="Keep the scene moving toward a crisp finale.",
46
  secret="Has already misplaced the final cue card.",
47
  speaking_style="brisk, theatrical, and slightly overconfident",
48
+ tools=["spotlight", "cue_cards"],
49
  ),
50
  Actor(
51
  name="Mina Moonbutton",
 
53
  goal="Find the emotional truth hiding inside the premise.",
54
  secret="Believes every prop is personally judging her.",
55
  speaking_style="earnest, poetic, and prone to dramatic pauses",
56
+ tools=["monologue", "soft_lights"],
57
  ),
58
  Actor(
59
  name="Bolt McJiggle",
 
61
  goal="Turn every problem into a practical stage gag.",
62
  secret="Is secretly building a confetti finale backstage.",
63
  speaking_style="punchy, practical, and full of suspicious confidence",
64
+ tools=["props", "sound_effects"],
65
  ),
66
  ]
67
 
68
+ active_backend = backend_name if backend_name in {"deterministic", "openbmb"} else "deterministic"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  model_note = f" ({backend_model_id})" if backend_model_id else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  director_log = [
71
+ "Director created a deterministic six-beat show plan.",
72
  f"Active backend: {active_backend}{model_note}.",
73
+ f"Setting selected: {setting}.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  "Three puppet actors are waiting for the first beat.",
75
  ]
76
+ trace_events = [
77
+ "show_created",
78
+ "actors_created:3",
79
+ "director_plan_created",
80
+ f"backend_active:{active_backend}",
81
+ ]
82
 
83
+ return TheaterSession(
 
84
  show_title=show_title,
85
  premise=cleaned_premise,
86
  setting=setting,
87
  actors=actors,
 
 
88
  beat_index=0,
89
+ max_beats=6,
 
 
 
90
  transcript=[],
91
  props=[],
92
  latest_prop=None,
93
  latest_audience_action=None,
94
  director_log=director_log,
95
+ trace_events=trace_events,
96
  finale_requested=False,
97
  backend_name=active_backend,
98
  backend_model_id=backend_model_id,
99
  backend_max_new_tokens=backend_max_new_tokens,
100
  backend_temperature=backend_temperature,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/show_bible.py DELETED
@@ -1,686 +0,0 @@
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/tools.py DELETED
@@ -1,205 +0,0 @@
1
- from typing import Any
2
-
3
- from pydantic import ValidationError
4
-
5
- from puppet_theater.models import Actor, SimpleToolValue, TheaterSession, ToolRequest, ToolResult
6
- from puppet_theater.trace import add_trace_event
7
-
8
-
9
- ALLOWED_TOOL_NAMES = {"inspect_prop", "consult_stage_oracle", "change_lighting"}
10
- _ALLOWED_ARGUMENTS = {
11
- "inspect_prop": {"prop"},
12
- "consult_stage_oracle": {"question"},
13
- "change_lighting": {"mood"},
14
- }
15
-
16
-
17
- def run_actor_tool_request(
18
- session: TheaterSession,
19
- speaker: Actor,
20
- raw_request: ToolRequest | dict[str, Any] | None,
21
- ) -> ToolResult | None:
22
- if raw_request is None:
23
- return None
24
-
25
- requested_name = _raw_tool_name(raw_request)
26
- add_trace_event(
27
- session,
28
- "tool_requested",
29
- speaker=speaker.name,
30
- tool_name=requested_name or "unknown",
31
- )
32
-
33
- request, validation_status = validate_tool_request(raw_request)
34
- if request is None:
35
- session.director_log.append(f"Tool request from {speaker.name} ignored: {validation_status}.")
36
- add_trace_event(
37
- session,
38
- "tool_ignored",
39
- speaker=speaker.name,
40
- tool_name=requested_name or "unknown",
41
- validation_status=validation_status,
42
- fallback_used=False,
43
- )
44
- return None
45
-
46
- add_trace_event(
47
- session,
48
- "tool_executed",
49
- speaker=speaker.name,
50
- tool_name=request.tool_name,
51
- reason=request.reason,
52
- arguments=request.arguments,
53
- validation_status=validation_status,
54
- fallback_used=False,
55
- )
56
- result = execute_tool(session, speaker, request)
57
- session.latest_tool_result = result
58
- session.recent_tool_results.append(result)
59
- session.recent_tool_results = session.recent_tool_results[-4:]
60
- session.director_log.append(f"Tool {request.tool_name} returned: {result.result}")
61
- add_trace_event(
62
- session,
63
- "tool_result",
64
- speaker=speaker.name,
65
- tool_name=result.tool_name,
66
- result=result.result,
67
- stage_effect=result.stage_effect,
68
- fallback_used=False,
69
- )
70
- return result
71
-
72
-
73
- def validate_tool_request(raw_request: ToolRequest | dict[str, Any]) -> tuple[ToolRequest | None, str]:
74
- try:
75
- request = raw_request if isinstance(raw_request, ToolRequest) else ToolRequest.model_validate(raw_request)
76
- except ValidationError:
77
- return None, "invalid_tool_schema"
78
-
79
- if request.tool_name not in ALLOWED_TOOL_NAMES:
80
- return None, "invalid_tool_name"
81
- if not isinstance(request.arguments, dict):
82
- return None, "invalid_tool_arguments"
83
- allowed_arguments = _ALLOWED_ARGUMENTS[request.tool_name]
84
- unexpected_arguments = set(request.arguments) - allowed_arguments
85
- if unexpected_arguments:
86
- return None, "invalid_tool_arguments"
87
- cleaned_arguments = _clean_arguments(request.arguments)
88
- if cleaned_arguments is None:
89
- return None, "invalid_tool_arguments"
90
- return request.model_copy(update={"arguments": cleaned_arguments}), "valid"
91
-
92
-
93
- def execute_tool(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult:
94
- if request.tool_name == "inspect_prop":
95
- return _inspect_prop(session, speaker, request)
96
- if request.tool_name == "consult_stage_oracle":
97
- return _consult_stage_oracle(speaker, request)
98
- return _change_lighting(session, speaker, request)
99
-
100
-
101
- def _inspect_prop(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult:
102
- prop = _text_arg(request.arguments, "prop") or speaker.held_prop or session.latest_prop or "mystery prop"
103
- clue = _prop_clue(prop)
104
- return ToolResult(
105
- tool_name=request.tool_name,
106
- actor_name=speaker.name,
107
- reason=request.reason,
108
- arguments={"prop": prop},
109
- result=f"The {prop} reveals {clue}.",
110
- stage_effect="prop_table_glow",
111
- )
112
-
113
-
114
- def _consult_stage_oracle(speaker: Actor, request: ToolRequest) -> ToolResult:
115
- question = _text_arg(request.arguments, "question") or "What should the scene notice next?"
116
- clue = _oracle_clue(question)
117
- return ToolResult(
118
- tool_name=request.tool_name,
119
- actor_name=speaker.name,
120
- reason=request.reason,
121
- arguments={"question": question},
122
- result=clue,
123
- stage_effect="oracle_haze",
124
- )
125
-
126
-
127
- def _change_lighting(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult:
128
- mood = _text_arg(request.arguments, "mood") or speaker.mood or "dramatic"
129
- lighting = f"{_safe_label(mood)}_lighting"
130
- session.stage_lighting = lighting
131
- return ToolResult(
132
- tool_name=request.tool_name,
133
- actor_name=speaker.name,
134
- reason=request.reason,
135
- arguments={"mood": mood},
136
- result=f"Lights shift to {mood}, making every pause look intentional.",
137
- stage_effect=lighting,
138
- )
139
-
140
-
141
- def _raw_tool_name(raw_request: ToolRequest | dict[str, Any]) -> str | None:
142
- if isinstance(raw_request, ToolRequest):
143
- return raw_request.tool_name
144
- name = raw_request.get("tool_name") if isinstance(raw_request, dict) else None
145
- return str(name)[:80] if name is not None else None
146
-
147
-
148
- def _text_arg(arguments: dict[str, SimpleToolValue], key: str) -> str | None:
149
- value = arguments.get(key)
150
- if value is None:
151
- return None
152
- return " ".join(str(value).strip().split())[:120] or None
153
-
154
-
155
- def _clean_arguments(arguments: dict[str, Any]) -> dict[str, SimpleToolValue] | None:
156
- if len(arguments) > 4:
157
- return None
158
- cleaned: dict[str, SimpleToolValue] = {}
159
- for raw_key, raw_value in arguments.items():
160
- key = " ".join(str(raw_key).strip().split())
161
- if not key or len(key) > 40:
162
- return None
163
- if isinstance(raw_value, str):
164
- value: SimpleToolValue = " ".join(raw_value.strip().split())
165
- if len(value) > 120:
166
- return None
167
- elif isinstance(raw_value, bool) or raw_value is None:
168
- value = raw_value
169
- elif isinstance(raw_value, int | float):
170
- value = raw_value
171
- else:
172
- return None
173
- cleaned[key] = value
174
- return cleaned
175
-
176
-
177
- def _safe_label(value: str) -> str:
178
- label = "_".join(part for part in value.lower().split() if part.isalnum())
179
- return label[:32] or "dramatic"
180
-
181
-
182
- def _prop_clue(prop: str) -> str:
183
- lowered = prop.lower()
184
- if "duck" in lowered:
185
- return "a squeak that points accusingly stage left"
186
- if "crown" in lowered:
187
- return "glitter arranged like tiny royal footprints"
188
- if "tomato" in lowered:
189
- return "a red smear shaped suspiciously like applause"
190
- if "scroll" in lowered:
191
- return "a footnote written in very nervous ink"
192
- if "egg" in lowered:
193
- return "a crack shaped like a dramatic reveal"
194
- return "a clue too theatrical to be accidental"
195
-
196
-
197
- def _oracle_clue(question: str) -> str:
198
- lowered = question.lower()
199
- if "finale" in lowered or "end" in lowered:
200
- return "The oracle whispers: every loose thread wants a bow."
201
- if "secret" in lowered:
202
- return "The oracle whispers: secrets knock twice before entering."
203
- if "prop" in lowered or "clue" in lowered:
204
- return "The oracle whispers: ask the smallest object why it is glowing."
205
- return "The oracle whispers: follow the spotlight, then distrust it politely."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/trace.py DELETED
@@ -1,486 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import datetime, timezone
4
- import json
5
- import os
6
- import re
7
- import tempfile
8
- from typing import Any
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"
40
- _MAX_TEXT_CHARS = 500
41
- _STANDARD_EVENT_KEYS = (
42
- "event_type",
43
- "timestamp",
44
- "step",
45
- "beat_index",
46
- "story_phase",
47
- "speaker",
48
- "director_decision",
49
- "actor_intent",
50
- "actor_memory_update",
51
- "tool_request",
52
- "tool_result",
53
- "audience_action",
54
- "backend_metadata",
55
- "latency_ms",
56
- "validation_status",
57
- "fallback_used",
58
- "fallback_reason",
59
- "summary",
60
- )
61
- _PRIVATE_PATH_PATTERNS = [
62
- re.compile(r"/Users/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
63
- re.compile(r"/private/(?:tmp|var)/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
64
- re.compile(r"/tmp/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
65
- ]
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),
81
- "timestamp": datetime.now(timezone.utc).isoformat(),
82
- "step": len(session.trace_events) + 1,
83
- }
84
- if session.beat_index is not None:
85
- event["beat_index"] = session.beat_index
86
- for key, value in fields.items():
87
- if value is not None:
88
- event[key] = sanitize_value(value)
89
- session.trace_events.append(event)
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
-
102
- actor_model_id = session.backend_model_id
103
- director_model_id = _model_id_for_mode(session.director_mode)
104
- active_model_id = actor_model_id or director_model_id
105
- return {
106
- "app_name": APP_NAME,
107
- "trace_version": TRACE_VERSION,
108
- "session_id": sanitize_value(session.session_id),
109
- "created_at": sanitize_value(session.created_at),
110
- "premise": sanitize_value(session.premise),
111
- "title": sanitize_value(session.show_title),
112
- "setting": sanitize_value(session.setting),
113
- "show_length_mode": sanitize_value(session.show_length_mode),
114
- "min_beats": session.min_beats,
115
- "target_beats": session.target_beats,
116
- "max_beats": session.max_beats,
117
- "active_backend": sanitize_value(session.backend_name),
118
- "actor_backend": sanitize_value(session.backend_name),
119
- "director_mode": sanitize_value(session.director_mode),
120
- "director_backend": sanitize_value(session.director_mode),
121
- "model_id": sanitize_value(active_model_id),
122
- "actor_model_id": sanitize_value(actor_model_id),
123
- "director_model_id": sanitize_value(director_model_id),
124
- "events": normalize_trace_events(session),
125
- }
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."
138
- return json.dumps(payload, indent=2, sort_keys=False)
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
151
-
152
- safe_session_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(payload["session_id"]))[:48] or "session"
153
- filename = f"ai-puppet-theater-trace-{safe_session_id}.json"
154
- path = os.path.join(tempfile.gettempdir(), filename)
155
- with open(path, "w", encoding="utf-8") as trace_file:
156
- json.dump(payload, trace_file, indent=2, ensure_ascii=False)
157
- trace_file.write("\n")
158
- return path
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):
171
- normalized.append(_normalize_event_dict(raw_event, index))
172
- continue
173
-
174
- normalized.append(
175
- {
176
- "event_type": "legacy_event",
177
- "step": index,
178
- "message": sanitize_value(raw_event),
179
- "summary": sanitize_value(raw_event),
180
- }
181
- )
182
- return normalized
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
-
195
- events = normalize_trace_events(session)
196
- if not events:
197
- return "No trace events yet."
198
-
199
- lines = [
200
- f"{session.show_title} ({session.session_id})",
201
- (
202
- f"{session.show_length_mode} show: {session.min_beats}/"
203
- f"{session.target_beats}/{session.max_beats} beats, "
204
- f"actor backend={session.backend_name}, director={session.director_mode}"
205
- ),
206
- "",
207
- ]
208
- for event in events[-max_events:]:
209
- step = event.get("step", "?")
210
- event_type = event.get("event_type", "event")
211
- beat = event.get("beat_index")
212
- prefix = f"{step}. {event_type}"
213
- if beat is not None:
214
- prefix += f" [beat {beat}]"
215
- lines.append(f"{prefix}: {_event_summary(event)}")
216
- return "\n".join(lines)
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] = {
230
- "event_type": sanitize_value(event_type),
231
- "step": cleaned.pop("step", index),
232
- }
233
- if "timestamp" in cleaned:
234
- normalized["timestamp"] = cleaned.pop("timestamp")
235
-
236
- for key in (
237
- "beat_index",
238
- "story_phase",
239
- "speaker",
240
- "latency_ms",
241
- "validation_status",
242
- "fallback_used",
243
- "fallback_reason",
244
- ):
245
- if key in cleaned:
246
- normalized[key] = cleaned.pop(key)
247
-
248
- if event_type == "director_decision":
249
- normalized["director_decision"] = {
250
- key: cleaned.pop(key)
251
- for key in (
252
- "beat_type",
253
- "reason_summary",
254
- "progress",
255
- "uses_prop",
256
- "reveal_secret",
257
- "should_end_scene",
258
- "min_beats",
259
- "target_beats",
260
- "max_beats",
261
- )
262
- if key in cleaned
263
- }
264
- if event_type == "actor_intent" and "intent" in cleaned:
265
- normalized["actor_intent"] = cleaned.pop("intent")
266
- if event_type == "actor_memory_update" and "memory_update" in cleaned:
267
- normalized["actor_memory_update"] = cleaned.pop("memory_update")
268
- if event_type in {"tool_requested", "tool_executed", "tool_ignored"}:
269
- normalized["tool_request"] = {
270
- key: cleaned.pop(key)
271
- for key in ("tool_name", "reason", "arguments")
272
- if key in cleaned
273
- }
274
- if event_type == "tool_result":
275
- normalized["tool_result"] = {
276
- key: cleaned.pop(key)
277
- for key in ("tool_name", "result", "stage_effect")
278
- if key in cleaned
279
- }
280
- if event_type == "audience_action":
281
- action = cleaned.pop("audience_action", None)
282
- normalized["audience_action"] = {
283
- key: value
284
- for key, value in {
285
- "action": action,
286
- "summary": cleaned.pop("action_summary", None),
287
- "prop": cleaned.pop("prop", None),
288
- "summoned_actor": cleaned.pop("summoned_actor", None),
289
- }.items()
290
- if value is not None
291
- }
292
-
293
- backend_metadata = {
294
- key: cleaned.pop(key)
295
- for key in (
296
- "backend_name",
297
- "model_id",
298
- "load_status",
299
- "director_mode",
300
- "tts_backend",
301
- "voice_mode",
302
- "voice_name",
303
- )
304
- if key in cleaned
305
- }
306
- if backend_metadata:
307
- normalized["backend_metadata"] = backend_metadata
308
-
309
- if "reason_summary" in cleaned:
310
- normalized["summary"] = cleaned.pop("reason_summary")
311
- elif "action_summary" in cleaned:
312
- normalized["summary"] = cleaned.pop("action_summary")
313
- elif "error_summary" in cleaned:
314
- normalized["summary"] = cleaned.pop("error_summary")
315
-
316
- for key, value in cleaned.items():
317
- if key not in normalized:
318
- normalized[key] = value
319
-
320
- if "summary" not in normalized:
321
- normalized["summary"] = _event_summary(normalized)
322
- return {key: normalized[key] for key in _STANDARD_EVENT_KEYS if key in normalized} | {
323
- key: value for key, value in normalized.items() if key not in _STANDARD_EVENT_KEYS
324
- }
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":
347
- decision = event.get("director_decision") if isinstance(event.get("director_decision"), dict) else {}
348
- beat_type = decision.get("beat_type") if isinstance(decision, dict) else None
349
- speaker = event.get("speaker") or "next actor"
350
- reason = decision.get("reason_summary") if isinstance(decision, dict) else None
351
- return f"Director chose {speaker} for {beat_type or 'a beat'}" + (f": {reason}" if reason else ".")
352
- if event_type in {"actor_response", "backend_error", "backend_warmup"}:
353
- status = event.get("validation_status") or "status unknown"
354
- fallback = " with fallback" if event.get("fallback_used") else ""
355
- return f"Backend returned {status}{fallback}."
356
- if event_type in {"actor_fallback", "director_fallback"}:
357
- reason = event.get("fallback_reason") or event.get("validation_status") or "fallback used"
358
- return f"Fallback used: {reason}."
359
- if event_type == "actor_intent":
360
- return str(event.get("actor_intent") or "Actor intent recorded.")
361
- if event_type == "actor_memory_update":
362
- return str(event.get("actor_memory_update") or "Actor memory updated.")
363
- if event_type == "tool_requested":
364
- tool = _tool_label(event.get("tool_request"))
365
- return f"Tool requested: {tool}."
366
- if event_type == "tool_executed":
367
- tool = _tool_label(event.get("tool_request"))
368
- return f"Tool executed: {tool}."
369
- if event_type == "tool_result":
370
- result = event.get("tool_result") if isinstance(event.get("tool_result"), dict) else {}
371
- return str(result.get("result") or f"Tool result from {result.get('tool_name', 'tool')}.")
372
- if event_type == "audience_action":
373
- action = event.get("audience_action") if isinstance(event.get("audience_action"), dict) else {}
374
- return str(action.get("summary") or action.get("action") or "Audience action recorded.")
375
- if event_type in {"tts_requested", "tts_generated", "tts_fallback"}:
376
- metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
377
- return f"TTS event via {metadata.get('tts_backend', 'voice backend')}."
378
- if event_type == "scene_completed":
379
- return str(event.get("summary") or "Scene completed.")
380
- return str(event.get("summary") or event_type.replace("_", " ").title())
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)
395
- for key, item in value.items()
396
- if item is not None and not _is_sensitive_key(str(key))
397
- }
398
- if isinstance(value, list):
399
- return [sanitize_value(item) for item in value]
400
- if isinstance(value, tuple):
401
- return [sanitize_value(item) for item in value]
402
- if isinstance(value, str):
403
- return _sanitize_text(value)
404
- return value
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"
423
- for key, secret in os.environ.items():
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]",
430
- text,
431
- )
432
- for pattern in _PRIVATE_PATH_PATTERNS:
433
- text = pattern.sub("[redacted-path]", text)
434
- return text[:_MAX_TEXT_CHARS].rstrip()
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")
447
- return f"{backend} ({model})" if model else str(backend)
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":
485
- return os.getenv("HF_API_MODEL_ID", "Qwen/Qwen3-4B-Instruct-2507:nscale")
486
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
puppet_theater/zerogpu.py DELETED
@@ -1,112 +0,0 @@
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)
14
- if value is None:
15
- return default
16
- return value.strip().lower() in {"1", "true", "yes", "on"}
17
-
18
-
19
- USE_ZEROGPU = _env_flag("USE_ZEROGPU", default=False)
20
-
21
- try:
22
- import spaces
23
- except ImportError:
24
- spaces = None
25
-
26
- SPACES_AVAILABLE = spaces is not None
27
- ZEROGPU_GPU_ACTIVE = USE_ZEROGPU and SPACES_AVAILABLE
28
- LAST_GPU_CUDA_AVAILABLE: bool | None = None
29
- LAST_GPU_FALLBACK_REASON: str | None = (
30
- "USE_ZEROGPU=true but the spaces package is not installed"
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:
38
- return func
39
- return spaces.GPU(duration=30)(func)
40
-
41
-
42
- def torch_version() -> str:
43
- try:
44
- import torch
45
- except ImportError:
46
- return "not installed"
47
- return str(getattr(torch, "__version__", "unknown"))
48
-
49
-
50
- def record_gpu_failure(reason: str) -> None:
51
- global LAST_GPU_FALLBACK_REASON
52
- LAST_GPU_FALLBACK_REASON = " ".join(reason.split())[:180]
53
-
54
-
55
- @gpu_decorator
56
- def generate_openbmb_text_on_zerogpu(
57
- model_id: str,
58
- prompt: str,
59
- max_new_tokens: int,
60
- temperature: float,
61
- ) -> str:
62
- global LAST_GPU_CUDA_AVAILABLE, LAST_GPU_FALLBACK_REASON
63
-
64
- import torch
65
- from transformers import AutoModelForCausalLM, AutoTokenizer
66
-
67
- LAST_GPU_CUDA_AVAILABLE = bool(torch.cuda.is_available())
68
- LAST_GPU_FALLBACK_REASON = None
69
-
70
- tokenizer = AutoTokenizer.from_pretrained(model_id)
71
- model = AutoModelForCausalLM.from_pretrained(
72
- model_id,
73
- torch_dtype="auto",
74
- device_map="auto",
75
- )
76
- model.eval()
77
-
78
- messages = [{"role": "user", "content": prompt}]
79
- try:
80
- inputs = tokenizer.apply_chat_template(
81
- messages,
82
- tokenize=True,
83
- add_generation_prompt=True,
84
- enable_thinking=False,
85
- return_dict=True,
86
- return_tensors="pt",
87
- )
88
- except TypeError:
89
- inputs = tokenizer.apply_chat_template(
90
- messages,
91
- tokenize=True,
92
- add_generation_prompt=True,
93
- return_dict=True,
94
- return_tensors="pt",
95
- )
96
-
97
- inputs = inputs.to(model.device)
98
- eos_token_id = tokenizer.eos_token_id
99
- pad_token_id = tokenizer.pad_token_id or eos_token_id
100
- do_sample = temperature > 0
101
- generation_kwargs = {
102
- "max_new_tokens": max_new_tokens,
103
- "do_sample": do_sample,
104
- "pad_token_id": pad_token_id,
105
- "eos_token_id": eos_token_id,
106
- }
107
- if do_sample:
108
- generation_kwargs["temperature"] = temperature
109
- with torch.inference_mode():
110
- outputs = model.generate(**inputs, **generation_kwargs)
111
- new_tokens = outputs[0][inputs["input_ids"].shape[-1] :]
112
- return tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -3,18 +3,13 @@ name = "ai-puppet-theater"
3
  version = "0.1.0"
4
  description = "A Hugging Face Gradio Space for AI Puppet Theater."
5
  readme = "README.md"
6
- requires-python = ">=3.10,<3.12"
7
  dependencies = [
8
- "accelerate==1.13.0",
9
- "edge-tts",
10
  "gradio==6.5.1",
11
- "huggingface-hub==1.18.0",
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
  ]
19
 
20
  [dependency-groups]
 
3
  version = "0.1.0"
4
  description = "A Hugging Face Gradio Space for AI Puppet Theater."
5
  readme = "README.md"
6
+ requires-python = "==3.11.*"
7
  dependencies = [
8
+ "accelerate",
 
9
  "gradio==6.5.1",
 
 
10
  "pydantic<=2.12.5",
11
+ "torch",
12
+ "transformers",
 
 
13
  ]
14
 
15
  [dependency-groups]
requirements.txt CHANGED
@@ -1,25 +1,9 @@
1
  # This file was autogenerated by uv via the following command:
2
  # uv pip compile pyproject.toml -o requirements.txt
3
- #
4
- # Manual Space runtime addition:
5
- # Use llama-cpp-python's prebuilt CUDA wheel index so the Local GGUF Actor model
6
- # can use GPU offload on the current GPU Space without forcing a source build
7
- # when a compatible wheel is available. For CPU Spaces, change /cu121 to /cpu
8
- # and set ACTOR_GGUF_N_GPU_LAYERS=0.
9
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121
10
- llama-cpp-python==0.3.28
11
  accelerate==1.13.0
12
- # via
13
- # ai-puppet-theater (pyproject.toml)
14
- # peft
15
  aiofiles==24.1.0
16
  # via gradio
17
- aiohappyeyeballs==2.6.2
18
- # via aiohttp
19
- aiohttp==3.14.1
20
- # via edge-tts
21
- aiosignal==1.4.0
22
- # via aiohttp
23
  annotated-doc==0.0.4
24
  # via
25
  # fastapi
@@ -31,25 +15,17 @@ anyio==4.13.0
31
  # gradio
32
  # httpx
33
  # starlette
34
- attrs==26.1.0
35
- # via aiohttp
36
  brotli==1.2.0
37
  # via gradio
38
  certifi==2026.5.20
39
  # via
40
- # edge-tts
41
  # httpcore
42
  # httpx
43
- # requests
44
- charset-normalizer==3.4.7
45
- # via requests
46
  click==8.4.1
47
  # via
48
  # huggingface-hub
49
  # typer
50
  # uvicorn
51
- edge-tts==7.2.8
52
- # via ai-puppet-theater (pyproject.toml)
53
  fastapi==0.136.3
54
  # via gradio
55
  ffmpy==1.0.0
@@ -58,19 +34,13 @@ filelock==3.29.1
58
  # via
59
  # huggingface-hub
60
  # torch
61
- frozenlist==1.8.0
62
- # via
63
- # aiohttp
64
- # aiosignal
65
  fsspec==2026.4.0
66
  # via
67
  # gradio-client
68
  # huggingface-hub
69
  # torch
70
  gradio==6.5.1
71
- # via
72
- # ai-puppet-theater (pyproject.toml)
73
- # spaces
74
  gradio-client==2.0.3
75
  # via gradio
76
  groovy==0.1.2
@@ -89,22 +59,17 @@ httpx==0.28.1
89
  # gradio-client
90
  # huggingface-hub
91
  # safehttpx
92
- # spaces
93
  huggingface-hub==1.18.0
94
  # via
95
- # ai-puppet-theater (pyproject.toml)
96
  # accelerate
97
  # gradio
98
  # gradio-client
99
- # peft
100
  # tokenizers
101
  # transformers
102
  idna==3.18
103
  # via
104
  # anyio
105
  # httpx
106
- # requests
107
- # yarl
108
  jinja2==3.1.6
109
  # via
110
  # gradio
@@ -119,18 +84,13 @@ mdurl==0.1.2
119
  # via markdown-it-py
120
  mpmath==1.3.0
121
  # via sympy
122
- multidict==6.7.1
123
- # via
124
- # aiohttp
125
- # yarl
126
- networkx==3.4.2
127
  # via torch
128
- numpy==2.2.6
129
  # via
130
  # accelerate
131
  # gradio
132
  # pandas
133
- # peft
134
  # transformers
135
  orjson==3.11.9
136
  # via gradio
@@ -140,29 +100,18 @@ packaging==26.2
140
  # gradio
141
  # gradio-client
142
  # huggingface-hub
143
- # peft
144
- # spaces
145
  # transformers
146
- pandas==2.3.3
147
  # via gradio
148
- peft==0.19.1
149
- # via ai-puppet-theater (pyproject.toml)
150
  pillow==12.2.0
151
  # via gradio
152
- propcache==0.5.2
153
- # via
154
- # aiohttp
155
- # yarl
156
  psutil==7.2.2
157
- # via
158
- # accelerate
159
- # peft
160
  pydantic==2.12.5
161
  # via
162
  # ai-puppet-theater (pyproject.toml)
163
  # fastapi
164
  # gradio
165
- # spaces
166
  pydantic-core==2.41.5
167
  # via pydantic
168
  pydub==0.25.1
@@ -171,25 +120,18 @@ pygments==2.20.0
171
  # via rich
172
  python-dateutil==2.9.0.post0
173
  # via pandas
174
- python-dotenv==1.2.2
175
- # via ai-puppet-theater (pyproject.toml)
176
  python-multipart==0.0.32
177
  # via gradio
178
  pytz==2026.2
179
- # via
180
- # gradio
181
- # pandas
182
  pyyaml==6.0.3
183
  # via
184
  # accelerate
185
  # gradio
186
  # huggingface-hub
187
- # peft
188
  # transformers
189
  regex==2026.5.9
190
  # via transformers
191
- requests==2.34.2
192
- # via spaces
193
  rich==15.0.0
194
  # via typer
195
  safehttpx==0.1.7
@@ -197,42 +139,35 @@ safehttpx==0.1.7
197
  safetensors==0.7.0
198
  # via
199
  # accelerate
200
- # peft
201
  # transformers
202
  semantic-version==2.10.0
203
  # via gradio
 
 
204
  shellingham==1.5.4
205
  # via typer
206
  six==1.17.0
207
  # via python-dateutil
208
- spaces==0.50.4
209
- # via ai-puppet-theater (pyproject.toml)
210
  starlette==0.52.1
211
  # via
212
  # fastapi
213
  # gradio
214
  sympy==1.14.0
215
  # via torch
216
- tabulate==0.10.0
217
- # via edge-tts
218
  tokenizers==0.22.2
219
  # via transformers
220
  tomlkit==0.13.3
221
  # via gradio
222
- torch==2.8.0
223
  # via
224
  # ai-puppet-theater (pyproject.toml)
225
  # accelerate
226
- # peft
227
  tqdm==4.68.1
228
  # via
229
  # huggingface-hub
230
- # peft
231
  # transformers
232
  transformers==5.10.2
233
- # via
234
- # ai-puppet-theater (pyproject.toml)
235
- # peft
236
  typer==0.25.1
237
  # via
238
  # gradio
@@ -240,17 +175,13 @@ typer==0.25.1
240
  # transformers
241
  typing-extensions==4.15.0
242
  # via
243
- # aiohttp
244
- # aiosignal
245
  # anyio
246
- # edge-tts
247
  # fastapi
248
  # gradio
249
  # gradio-client
250
  # huggingface-hub
251
  # pydantic
252
  # pydantic-core
253
- # spaces
254
  # starlette
255
  # torch
256
  # typing-inspection
@@ -258,11 +189,5 @@ typing-inspection==0.4.2
258
  # via
259
  # fastapi
260
  # pydantic
261
- tzdata==2026.2
262
- # via pandas
263
- urllib3==2.7.0
264
- # via requests
265
  uvicorn==0.49.0
266
  # via gradio
267
- yarl==1.24.2
268
- # via aiohttp
 
1
  # This file was autogenerated by uv via the following command:
2
  # uv pip compile pyproject.toml -o requirements.txt
 
 
 
 
 
 
 
 
3
  accelerate==1.13.0
4
+ # via ai-puppet-theater (pyproject.toml)
 
 
5
  aiofiles==24.1.0
6
  # via gradio
 
 
 
 
 
 
7
  annotated-doc==0.0.4
8
  # via
9
  # fastapi
 
15
  # gradio
16
  # httpx
17
  # starlette
 
 
18
  brotli==1.2.0
19
  # via gradio
20
  certifi==2026.5.20
21
  # via
 
22
  # httpcore
23
  # httpx
 
 
 
24
  click==8.4.1
25
  # via
26
  # huggingface-hub
27
  # typer
28
  # uvicorn
 
 
29
  fastapi==0.136.3
30
  # via gradio
31
  ffmpy==1.0.0
 
34
  # via
35
  # huggingface-hub
36
  # torch
 
 
 
 
37
  fsspec==2026.4.0
38
  # via
39
  # gradio-client
40
  # huggingface-hub
41
  # torch
42
  gradio==6.5.1
43
+ # via ai-puppet-theater (pyproject.toml)
 
 
44
  gradio-client==2.0.3
45
  # via gradio
46
  groovy==0.1.2
 
59
  # gradio-client
60
  # huggingface-hub
61
  # safehttpx
 
62
  huggingface-hub==1.18.0
63
  # via
 
64
  # accelerate
65
  # gradio
66
  # gradio-client
 
67
  # tokenizers
68
  # transformers
69
  idna==3.18
70
  # via
71
  # anyio
72
  # httpx
 
 
73
  jinja2==3.1.6
74
  # via
75
  # gradio
 
84
  # via markdown-it-py
85
  mpmath==1.3.0
86
  # via sympy
87
+ networkx==3.6.1
 
 
 
 
88
  # via torch
89
+ numpy==2.4.6
90
  # via
91
  # accelerate
92
  # gradio
93
  # pandas
 
94
  # transformers
95
  orjson==3.11.9
96
  # via gradio
 
100
  # gradio
101
  # gradio-client
102
  # huggingface-hub
 
 
103
  # transformers
104
+ pandas==3.0.3
105
  # via gradio
 
 
106
  pillow==12.2.0
107
  # via gradio
 
 
 
 
108
  psutil==7.2.2
109
+ # via accelerate
 
 
110
  pydantic==2.12.5
111
  # via
112
  # ai-puppet-theater (pyproject.toml)
113
  # fastapi
114
  # gradio
 
115
  pydantic-core==2.41.5
116
  # via pydantic
117
  pydub==0.25.1
 
120
  # via rich
121
  python-dateutil==2.9.0.post0
122
  # via pandas
 
 
123
  python-multipart==0.0.32
124
  # via gradio
125
  pytz==2026.2
126
+ # via gradio
 
 
127
  pyyaml==6.0.3
128
  # via
129
  # accelerate
130
  # gradio
131
  # huggingface-hub
 
132
  # transformers
133
  regex==2026.5.9
134
  # via transformers
 
 
135
  rich==15.0.0
136
  # via typer
137
  safehttpx==0.1.7
 
139
  safetensors==0.7.0
140
  # via
141
  # accelerate
 
142
  # transformers
143
  semantic-version==2.10.0
144
  # via gradio
145
+ setuptools==81.0.0
146
+ # via torch
147
  shellingham==1.5.4
148
  # via typer
149
  six==1.17.0
150
  # via python-dateutil
 
 
151
  starlette==0.52.1
152
  # via
153
  # fastapi
154
  # gradio
155
  sympy==1.14.0
156
  # via torch
 
 
157
  tokenizers==0.22.2
158
  # via transformers
159
  tomlkit==0.13.3
160
  # via gradio
161
+ torch==2.12.0
162
  # via
163
  # ai-puppet-theater (pyproject.toml)
164
  # accelerate
 
165
  tqdm==4.68.1
166
  # via
167
  # huggingface-hub
 
168
  # transformers
169
  transformers==5.10.2
170
+ # via ai-puppet-theater (pyproject.toml)
 
 
171
  typer==0.25.1
172
  # via
173
  # gradio
 
175
  # transformers
176
  typing-extensions==4.15.0
177
  # via
 
 
178
  # anyio
 
179
  # fastapi
180
  # gradio
181
  # gradio-client
182
  # huggingface-hub
183
  # pydantic
184
  # pydantic-core
 
185
  # starlette
186
  # torch
187
  # typing-inspection
 
189
  # via
190
  # fastapi
191
  # pydantic
 
 
 
 
192
  uvicorn==0.49.0
193
  # via gradio
 
 
scripts/test_hf_api.py DELETED
@@ -1,70 +0,0 @@
1
- import json
2
- import os
3
-
4
- from huggingface_hub import InferenceClient
5
-
6
-
7
- DEFAULT_HF_API_MODEL = "Qwen/Qwen3-4B-Instruct-2507:nscale"
8
-
9
-
10
- def get_hf_token() -> str | None:
11
- return os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
12
-
13
-
14
- def extract_json_object(text: str) -> dict | None:
15
- cleaned = text.strip()
16
- if cleaned.startswith("```"):
17
- cleaned = cleaned.strip("`")
18
- if "\n" in cleaned:
19
- cleaned = cleaned.split("\n", maxsplit=1)[1]
20
- start = cleaned.find("{")
21
- end = cleaned.rfind("}")
22
- if start != -1 and end != -1 and end > start:
23
- cleaned = cleaned[start : end + 1]
24
- try:
25
- decoded = json.loads(cleaned)
26
- except json.JSONDecodeError:
27
- return None
28
- return decoded if isinstance(decoded, dict) else None
29
-
30
-
31
- def main() -> None:
32
- token = get_hf_token()
33
- if not token:
34
- raise RuntimeError("HF_TOKEN is not configured")
35
-
36
- model_id = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL)
37
- client = InferenceClient(token=token)
38
- completion = client.chat.completions.create(
39
- model=model_id,
40
- messages=[
41
- {
42
- "role": "system",
43
- "content": "You are a puppet actor in a short absurd theater scene. Return valid JSON only.",
44
- },
45
- {
46
- "role": "user",
47
- "content": (
48
- "Scene: A moon library where the sun is overdue.\n"
49
- "Actor: Mina Moonbutton\n"
50
- "Goal: recover the missing sun\n"
51
- "Style: stern, poetic, tiny\n"
52
- "Recent transcript: none\n"
53
- "Write one short puppet line under 25 words.\n\n"
54
- "Return JSON with keys: line, emotion, gesture, stage_effect, tool_request."
55
- ),
56
- },
57
- ],
58
- max_tokens=120,
59
- temperature=0.75,
60
- top_p=0.9,
61
- )
62
- text = completion.choices[0].message.content or ""
63
- parsed = extract_json_object(text)
64
- print("Raw model output:")
65
- print(text)
66
- print(f"JSON parsing succeeded: {parsed is not None}")
67
-
68
-
69
- if __name__ == "__main__":
70
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/conftest.py DELETED
@@ -1,7 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
-
4
-
5
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
6
- if str(PROJECT_ROOT) not in sys.path:
7
- sys.path.insert(0, str(PROJECT_ROOT))