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