Commit ·
c3e33f1
1
Parent(s): bf65d34
feat: add dynamic story pacing
Browse files- app.py +40 -2
- puppet_theater/__init__.py +8 -1
- puppet_theater/director.py +82 -7
- puppet_theater/models.py +3 -1
- puppet_theater/prompts.py +4 -0
- puppet_theater/session.py +29 -3
- puppet_theater/trace.py +3 -0
- tests/test_director.py +108 -2
app.py
CHANGED
|
@@ -5,13 +5,18 @@ from time import sleep
|
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
from puppet_theater import (
|
|
|
|
| 8 |
DEFAULT_HF_API_MODEL_ID,
|
| 9 |
DEFAULT_OPENBMB_MODEL_ID,
|
|
|
|
| 10 |
TheaterSession,
|
| 11 |
create_show_from_premise,
|
| 12 |
get_backend_status,
|
| 13 |
request_finale,
|
|
|
|
| 14 |
run_one_beat,
|
|
|
|
|
|
|
| 15 |
summon_actor,
|
| 16 |
throw_prop,
|
| 17 |
warm_up_openbmb,
|
|
@@ -44,6 +49,7 @@ EMPTY_TRACE = "No trace events yet."
|
|
| 44 |
EMPTY_BACKEND = (
|
| 45 |
"Active backend: deterministic\n"
|
| 46 |
"Director mode: deterministic\n"
|
|
|
|
| 47 |
"Available actor backends: deterministic, local OpenBMB, Hugging Face API\n"
|
| 48 |
"Available director modes: deterministic, LLM / HF API\n"
|
| 49 |
"OpenBMB model id: openbmb/MiniCPM5-1B\n"
|
|
@@ -74,6 +80,12 @@ HF_API_MODEL_ID = os.getenv("HF_API_MODEL_ID", DEFAULT_HF_API_MODEL_ID)
|
|
| 74 |
DEFAULT_MAX_NEW_TOKENS = 120
|
| 75 |
DEFAULT_TEMPERATURE = 0.75
|
| 76 |
PLAYBACK_DELAY_SECONDS = 0.75
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
PROP_EMOJI = {
|
| 78 |
"rubber duck": "🐤",
|
| 79 |
"duck": "🐤",
|
|
@@ -1547,6 +1559,9 @@ def render_stage(session: TheaterSession | None) -> str:
|
|
| 1547 |
</div>
|
| 1548 |
"""
|
| 1549 |
|
|
|
|
|
|
|
|
|
|
| 1550 |
curtain_html = ""
|
| 1551 |
if show_opening_curtain:
|
| 1552 |
curtain_html = """
|
|
@@ -1564,7 +1579,8 @@ def render_stage(session: TheaterSession | None) -> str:
|
|
| 1564 |
<div class="stage-marquee">{escape(session.show_title)}</div>
|
| 1565 |
<div class="stage-copy">
|
| 1566 |
<strong>Setting:</strong> {escape(session.setting)}<br />
|
| 1567 |
-
<strong>Premise:</strong> {escape(session.premise)}
|
|
|
|
| 1568 |
</div>
|
| 1569 |
<div class="actor-row">
|
| 1570 |
{''.join(actor_columns)}
|
|
@@ -1573,7 +1589,7 @@ def render_stage(session: TheaterSession | None) -> str:
|
|
| 1573 |
{audience_action}
|
| 1574 |
{prop_pile}
|
| 1575 |
</div>
|
| 1576 |
-
<div class="beat-counter">Beat {
|
| 1577 |
</div>
|
| 1578 |
<div class="stage-floorboards"></div>
|
| 1579 |
</div>
|
|
@@ -1614,6 +1630,10 @@ def normalize_director_mode(director_mode: str | None) -> str:
|
|
| 1614 |
return director_mode if director_mode in DIRECTOR_MODE_VALUES else "deterministic"
|
| 1615 |
|
| 1616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1617 |
def normalize_max_new_tokens(max_new_tokens: int | float | None) -> int:
|
| 1618 |
if max_new_tokens is None:
|
| 1619 |
return DEFAULT_MAX_NEW_TOKENS
|
|
@@ -1660,6 +1680,11 @@ def render_backend_settings(
|
|
| 1660 |
active_backend = session.backend_name if session is not None else selected_backend
|
| 1661 |
active_director_mode = session.director_mode if session is not None else selected_director_mode
|
| 1662 |
active_model_id = session.backend_model_id if session is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1663 |
if active_backend == "openbmb" or active_director_mode == "openbmb":
|
| 1664 |
active_model_id = active_model_id or OPENBMB_MODEL_ID
|
| 1665 |
if active_backend == "hf_api" or active_director_mode == "hf_api":
|
|
@@ -1684,6 +1709,7 @@ def render_backend_settings(
|
|
| 1684 |
return (
|
| 1685 |
f"Active backend: {active_backend}\n"
|
| 1686 |
f"Director mode: {active_director_mode}\n"
|
|
|
|
| 1687 |
"Available actor backends: deterministic, local OpenBMB, Hugging Face API\n"
|
| 1688 |
"Available director modes: deterministic, LLM / HF API\n"
|
| 1689 |
f"Active model id: {active_model_id or 'not selected'}\n"
|
|
@@ -1721,12 +1747,14 @@ def render_outputs(session: TheaterSession | None):
|
|
| 1721 |
def create_show(
|
| 1722 |
premise: str,
|
| 1723 |
session: TheaterSession | None,
|
|
|
|
| 1724 |
backend_name: str,
|
| 1725 |
director_mode: str,
|
| 1726 |
max_new_tokens: int | float,
|
| 1727 |
temperature: int | float,
|
| 1728 |
):
|
| 1729 |
premise = premise.strip()
|
|
|
|
| 1730 |
selected_backend = normalize_backend_name(backend_name)
|
| 1731 |
selected_director_mode = normalize_director_mode(director_mode)
|
| 1732 |
selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens)
|
|
@@ -1761,6 +1789,7 @@ def create_show(
|
|
| 1761 |
backend_max_new_tokens=selected_max_new_tokens,
|
| 1762 |
backend_temperature=selected_temperature,
|
| 1763 |
director_mode=selected_director_mode,
|
|
|
|
| 1764 |
)
|
| 1765 |
return session, *render_outputs(session)
|
| 1766 |
|
|
@@ -1776,6 +1805,7 @@ def reset_show():
|
|
| 1776 |
EMPTY_DIRECTOR_LOG,
|
| 1777 |
EMPTY_TRACE,
|
| 1778 |
None,
|
|
|
|
| 1779 |
"deterministic",
|
| 1780 |
"deterministic",
|
| 1781 |
DEFAULT_MAX_NEW_TOKENS,
|
|
@@ -2014,6 +2044,12 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2014 |
placeholder="A moon detective interrogates a suspicious toaster...",
|
| 2015 |
lines=1,
|
| 2016 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2017 |
with gr.Row(elem_classes=["premise-actions"]):
|
| 2018 |
create_button = gr.Button("Create Show", variant="primary", elem_classes=["primary-action"])
|
| 2019 |
reset_button = gr.Button("Reset", elem_classes=["reset-action"])
|
|
@@ -2135,6 +2171,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2135 |
inputs=[
|
| 2136 |
premise_input,
|
| 2137 |
session_state,
|
|
|
|
| 2138 |
backend_select,
|
| 2139 |
director_mode_select,
|
| 2140 |
max_new_tokens_input,
|
|
@@ -2265,6 +2302,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2265 |
director_output,
|
| 2266 |
trace_output,
|
| 2267 |
trace_download,
|
|
|
|
| 2268 |
backend_select,
|
| 2269 |
director_mode_select,
|
| 2270 |
max_new_tokens_input,
|
|
|
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
from puppet_theater import (
|
| 8 |
+
DEFAULT_SHOW_LENGTH,
|
| 9 |
DEFAULT_HF_API_MODEL_ID,
|
| 10 |
DEFAULT_OPENBMB_MODEL_ID,
|
| 11 |
+
SHOW_LENGTH_PRESETS,
|
| 12 |
TheaterSession,
|
| 13 |
create_show_from_premise,
|
| 14 |
get_backend_status,
|
| 15 |
request_finale,
|
| 16 |
+
resolve_show_length,
|
| 17 |
run_one_beat,
|
| 18 |
+
story_phase,
|
| 19 |
+
story_progress,
|
| 20 |
summon_actor,
|
| 21 |
throw_prop,
|
| 22 |
warm_up_openbmb,
|
|
|
|
| 49 |
EMPTY_BACKEND = (
|
| 50 |
"Active backend: deterministic\n"
|
| 51 |
"Director mode: deterministic\n"
|
| 52 |
+
"Show length: min=7, target=10, max=12\n"
|
| 53 |
"Available actor backends: deterministic, local OpenBMB, Hugging Face API\n"
|
| 54 |
"Available director modes: deterministic, LLM / HF API\n"
|
| 55 |
"OpenBMB model id: openbmb/MiniCPM5-1B\n"
|
|
|
|
| 80 |
DEFAULT_MAX_NEW_TOKENS = 120
|
| 81 |
DEFAULT_TEMPERATURE = 0.75
|
| 82 |
PLAYBACK_DELAY_SECONDS = 0.75
|
| 83 |
+
SHOW_LENGTH_CHOICES = [
|
| 84 |
+
("Short", "short"),
|
| 85 |
+
("Standard", "standard"),
|
| 86 |
+
("Extended", "extended"),
|
| 87 |
+
]
|
| 88 |
+
SHOW_LENGTH_VALUES = set(SHOW_LENGTH_PRESETS)
|
| 89 |
PROP_EMOJI = {
|
| 90 |
"rubber duck": "🐤",
|
| 91 |
"duck": "🐤",
|
|
|
|
| 1559 |
</div>
|
| 1560 |
"""
|
| 1561 |
|
| 1562 |
+
phase = story_phase(session)
|
| 1563 |
+
progress_percent = round(story_progress(session) * 100)
|
| 1564 |
+
performed_beats = len(session.transcript)
|
| 1565 |
curtain_html = ""
|
| 1566 |
if show_opening_curtain:
|
| 1567 |
curtain_html = """
|
|
|
|
| 1579 |
<div class="stage-marquee">{escape(session.show_title)}</div>
|
| 1580 |
<div class="stage-copy">
|
| 1581 |
<strong>Setting:</strong> {escape(session.setting)}<br />
|
| 1582 |
+
<strong>Premise:</strong> {escape(session.premise)}<br />
|
| 1583 |
+
<strong>Phase:</strong> {escape(phase.title())} · <strong>Progress:</strong> {progress_percent}%
|
| 1584 |
</div>
|
| 1585 |
<div class="actor-row">
|
| 1586 |
{''.join(actor_columns)}
|
|
|
|
| 1589 |
{audience_action}
|
| 1590 |
{prop_pile}
|
| 1591 |
</div>
|
| 1592 |
+
<div class="beat-counter">Beat {performed_beats} of {session.max_beats} · target {session.target_beats} · {escape(phase.title())}</div>
|
| 1593 |
</div>
|
| 1594 |
<div class="stage-floorboards"></div>
|
| 1595 |
</div>
|
|
|
|
| 1630 |
return director_mode if director_mode in DIRECTOR_MODE_VALUES else "deterministic"
|
| 1631 |
|
| 1632 |
|
| 1633 |
+
def normalize_show_length(show_length: str | None) -> str:
|
| 1634 |
+
return show_length if show_length in SHOW_LENGTH_VALUES else DEFAULT_SHOW_LENGTH
|
| 1635 |
+
|
| 1636 |
+
|
| 1637 |
def normalize_max_new_tokens(max_new_tokens: int | float | None) -> int:
|
| 1638 |
if max_new_tokens is None:
|
| 1639 |
return DEFAULT_MAX_NEW_TOKENS
|
|
|
|
| 1680 |
active_backend = session.backend_name if session is not None else selected_backend
|
| 1681 |
active_director_mode = session.director_mode if session is not None else selected_director_mode
|
| 1682 |
active_model_id = session.backend_model_id if session is not None else None
|
| 1683 |
+
min_beats, target_beats, max_beats = (
|
| 1684 |
+
(session.min_beats, session.target_beats, session.max_beats)
|
| 1685 |
+
if session is not None
|
| 1686 |
+
else resolve_show_length(DEFAULT_SHOW_LENGTH)[1:]
|
| 1687 |
+
)
|
| 1688 |
if active_backend == "openbmb" or active_director_mode == "openbmb":
|
| 1689 |
active_model_id = active_model_id or OPENBMB_MODEL_ID
|
| 1690 |
if active_backend == "hf_api" or active_director_mode == "hf_api":
|
|
|
|
| 1709 |
return (
|
| 1710 |
f"Active backend: {active_backend}\n"
|
| 1711 |
f"Director mode: {active_director_mode}\n"
|
| 1712 |
+
f"Show length: min={min_beats}, target={target_beats}, max={max_beats}\n"
|
| 1713 |
"Available actor backends: deterministic, local OpenBMB, Hugging Face API\n"
|
| 1714 |
"Available director modes: deterministic, LLM / HF API\n"
|
| 1715 |
f"Active model id: {active_model_id or 'not selected'}\n"
|
|
|
|
| 1747 |
def create_show(
|
| 1748 |
premise: str,
|
| 1749 |
session: TheaterSession | None,
|
| 1750 |
+
show_length: str,
|
| 1751 |
backend_name: str,
|
| 1752 |
director_mode: str,
|
| 1753 |
max_new_tokens: int | float,
|
| 1754 |
temperature: int | float,
|
| 1755 |
):
|
| 1756 |
premise = premise.strip()
|
| 1757 |
+
selected_show_length = normalize_show_length(show_length)
|
| 1758 |
selected_backend = normalize_backend_name(backend_name)
|
| 1759 |
selected_director_mode = normalize_director_mode(director_mode)
|
| 1760 |
selected_max_new_tokens = normalize_max_new_tokens(max_new_tokens)
|
|
|
|
| 1789 |
backend_max_new_tokens=selected_max_new_tokens,
|
| 1790 |
backend_temperature=selected_temperature,
|
| 1791 |
director_mode=selected_director_mode,
|
| 1792 |
+
show_length=selected_show_length,
|
| 1793 |
)
|
| 1794 |
return session, *render_outputs(session)
|
| 1795 |
|
|
|
|
| 1805 |
EMPTY_DIRECTOR_LOG,
|
| 1806 |
EMPTY_TRACE,
|
| 1807 |
None,
|
| 1808 |
+
DEFAULT_SHOW_LENGTH,
|
| 1809 |
"deterministic",
|
| 1810 |
"deterministic",
|
| 1811 |
DEFAULT_MAX_NEW_TOKENS,
|
|
|
|
| 2044 |
placeholder="A moon detective interrogates a suspicious toaster...",
|
| 2045 |
lines=1,
|
| 2046 |
)
|
| 2047 |
+
show_length_select = gr.Dropdown(
|
| 2048 |
+
choices=SHOW_LENGTH_CHOICES,
|
| 2049 |
+
value=DEFAULT_SHOW_LENGTH,
|
| 2050 |
+
label="Show Length",
|
| 2051 |
+
interactive=True,
|
| 2052 |
+
)
|
| 2053 |
with gr.Row(elem_classes=["premise-actions"]):
|
| 2054 |
create_button = gr.Button("Create Show", variant="primary", elem_classes=["primary-action"])
|
| 2055 |
reset_button = gr.Button("Reset", elem_classes=["reset-action"])
|
|
|
|
| 2171 |
inputs=[
|
| 2172 |
premise_input,
|
| 2173 |
session_state,
|
| 2174 |
+
show_length_select,
|
| 2175 |
backend_select,
|
| 2176 |
director_mode_select,
|
| 2177 |
max_new_tokens_input,
|
|
|
|
| 2302 |
director_output,
|
| 2303 |
trace_output,
|
| 2304 |
trace_download,
|
| 2305 |
+
show_length_select,
|
| 2306 |
backend_select,
|
| 2307 |
director_mode_select,
|
| 2308 |
max_new_tokens_input,
|
puppet_theater/__init__.py
CHANGED
|
@@ -21,9 +21,11 @@ from puppet_theater.director import (
|
|
| 21 |
choose_director_decision,
|
| 22 |
run_full_act,
|
| 23 |
run_one_beat,
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
from puppet_theater.models import Actor, ActorResponse, Beat, DirectorDecision, TheaterSession
|
| 26 |
-
from puppet_theater.session import create_show_from_premise
|
| 27 |
|
| 28 |
__all__ = [
|
| 29 |
"Actor",
|
|
@@ -33,6 +35,7 @@ __all__ = [
|
|
| 33 |
"DEFAULT_HF_API_MODEL",
|
| 34 |
"DEFAULT_HF_API_MODEL_ID",
|
| 35 |
"DEFAULT_OPENBMB_MODEL_ID",
|
|
|
|
| 36 |
"DeterministicBackend",
|
| 37 |
"DirectorDecision",
|
| 38 |
"DirectorGeneration",
|
|
@@ -42,6 +45,7 @@ __all__ = [
|
|
| 42 |
"ModelBackend",
|
| 43 |
"OpenBMBDirectorPolicy",
|
| 44 |
"OpenBMBTransformersBackend",
|
|
|
|
| 45 |
"TheaterSession",
|
| 46 |
"create_show_from_premise",
|
| 47 |
"choose_director_decision",
|
|
@@ -49,8 +53,11 @@ __all__ = [
|
|
| 49 |
"get_backend_status",
|
| 50 |
"parse_actor_output",
|
| 51 |
"request_finale",
|
|
|
|
| 52 |
"run_full_act",
|
| 53 |
"run_one_beat",
|
|
|
|
|
|
|
| 54 |
"summon_actor",
|
| 55 |
"throw_prop",
|
| 56 |
"warm_up_openbmb",
|
|
|
|
| 21 |
choose_director_decision,
|
| 22 |
run_full_act,
|
| 23 |
run_one_beat,
|
| 24 |
+
story_phase,
|
| 25 |
+
story_progress,
|
| 26 |
)
|
| 27 |
from puppet_theater.models import Actor, ActorResponse, Beat, DirectorDecision, TheaterSession
|
| 28 |
+
from puppet_theater.session import DEFAULT_SHOW_LENGTH, SHOW_LENGTH_PRESETS, create_show_from_premise, resolve_show_length
|
| 29 |
|
| 30 |
__all__ = [
|
| 31 |
"Actor",
|
|
|
|
| 35 |
"DEFAULT_HF_API_MODEL",
|
| 36 |
"DEFAULT_HF_API_MODEL_ID",
|
| 37 |
"DEFAULT_OPENBMB_MODEL_ID",
|
| 38 |
+
"DEFAULT_SHOW_LENGTH",
|
| 39 |
"DeterministicBackend",
|
| 40 |
"DirectorDecision",
|
| 41 |
"DirectorGeneration",
|
|
|
|
| 45 |
"ModelBackend",
|
| 46 |
"OpenBMBDirectorPolicy",
|
| 47 |
"OpenBMBTransformersBackend",
|
| 48 |
+
"SHOW_LENGTH_PRESETS",
|
| 49 |
"TheaterSession",
|
| 50 |
"create_show_from_premise",
|
| 51 |
"choose_director_decision",
|
|
|
|
| 53 |
"get_backend_status",
|
| 54 |
"parse_actor_output",
|
| 55 |
"request_finale",
|
| 56 |
+
"resolve_show_length",
|
| 57 |
"run_full_act",
|
| 58 |
"run_one_beat",
|
| 59 |
+
"story_phase",
|
| 60 |
+
"story_progress",
|
| 61 |
"summon_actor",
|
| 62 |
"throw_prop",
|
| 63 |
"warm_up_openbmb",
|
puppet_theater/director.py
CHANGED
|
@@ -29,6 +29,34 @@ BEAT_ARC = [
|
|
| 29 |
"finale",
|
| 30 |
]
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@dataclass(frozen=True)
|
| 34 |
class DirectorGeneration:
|
|
@@ -44,7 +72,9 @@ class DirectorGeneration:
|
|
| 44 |
|
| 45 |
class DirectorPolicy:
|
| 46 |
def decide(self, session: TheaterSession) -> DirectorDecision:
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
speaker = self._choose_speaker(session)
|
| 49 |
return DirectorDecision(
|
| 50 |
next_speaker=speaker.name,
|
|
@@ -52,7 +82,7 @@ class DirectorPolicy:
|
|
| 52 |
instruction="Tie the scene together in one crisp curtain-call line.",
|
| 53 |
stage_effect="curtain_fall",
|
| 54 |
should_end_scene=True,
|
| 55 |
-
reason_summary="Finale requested or final beat reached; closing
|
| 56 |
)
|
| 57 |
|
| 58 |
if session.latest_prop is not None:
|
|
@@ -76,7 +106,7 @@ class DirectorPolicy:
|
|
| 76 |
reason_summary=f"Newly summoned actor {summoned_actor.name} has not spoken yet.",
|
| 77 |
)
|
| 78 |
|
| 79 |
-
beat_type =
|
| 80 |
speaker = self._choose_speaker(session)
|
| 81 |
reveal_secret = beat_type == "secret_reveal"
|
| 82 |
return DirectorDecision(
|
|
@@ -86,7 +116,7 @@ class DirectorPolicy:
|
|
| 86 |
stage_effect=self._effect_for_beat(beat_type),
|
| 87 |
reveal_secret=reveal_secret,
|
| 88 |
should_end_scene=beat_type == "finale",
|
| 89 |
-
reason_summary=f"Following
|
| 90 |
)
|
| 91 |
|
| 92 |
def _choose_speaker(self, session: TheaterSession) -> Actor:
|
|
@@ -127,6 +157,17 @@ class DirectorPolicy:
|
|
| 127 |
"finale": "curtain_fall",
|
| 128 |
}[beat_type]
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
class OpenBMBDirectorPolicy:
|
| 132 |
def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
|
|
@@ -353,6 +394,8 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 353 |
)
|
| 354 |
return session
|
| 355 |
|
|
|
|
|
|
|
| 356 |
director_generation = choose_director_decision(session)
|
| 357 |
decision = director_generation.decision
|
| 358 |
speaker = _actor_by_name(session, decision.next_speaker)
|
|
@@ -364,6 +407,10 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 364 |
"Director decision: "
|
| 365 |
f"{decision.beat_type} for {speaker.name}; {decision.reason_summary}"
|
| 366 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
session.director_log.append(
|
| 368 |
"Director mode "
|
| 369 |
f"{director_generation.director_mode} "
|
|
@@ -386,6 +433,11 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 386 |
fallback_used=director_generation.fallback_used,
|
| 387 |
fallback_reason=director_generation.error,
|
| 388 |
reason_summary=director_generation.reason_summary,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
uses_prop=decision.uses_prop,
|
| 390 |
reveal_secret=decision.reveal_secret,
|
| 391 |
should_end_scene=decision.should_end_scene,
|
|
@@ -441,6 +493,8 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 441 |
beat_type=decision.beat_type,
|
| 442 |
speaker=speaker.name,
|
| 443 |
stage_effect=beat.stage_effect,
|
|
|
|
|
|
|
| 444 |
)
|
| 445 |
add_trace_event(
|
| 446 |
session,
|
|
@@ -476,6 +530,7 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 476 |
session,
|
| 477 |
"scene_completed",
|
| 478 |
beat_index=session.beat_index,
|
|
|
|
| 479 |
reason_summary="Finale reached; curtain falls cleanly.",
|
| 480 |
)
|
| 481 |
|
|
@@ -514,7 +569,11 @@ def build_director_prompt(session: TheaterSession) -> str:
|
|
| 514 |
premise=session.premise,
|
| 515 |
setting=session.setting,
|
| 516 |
beat_index=session.beat_index,
|
|
|
|
|
|
|
| 517 |
max_beats=session.max_beats,
|
|
|
|
|
|
|
| 518 |
allowed_beat_types=", ".join(BEAT_ARC),
|
| 519 |
actor_profiles=actor_profiles,
|
| 520 |
recent_transcript=recent_transcript,
|
|
@@ -562,12 +621,17 @@ def validate_director_decision(decision: DirectorDecision, session: TheaterSessi
|
|
| 562 |
return None, "invalid_too_long"
|
| 563 |
|
| 564 |
update: dict[str, object] = {}
|
| 565 |
-
if session
|
| 566 |
update["beat_type"] = "finale"
|
| 567 |
update["should_end_scene"] = True
|
| 568 |
update["stage_effect"] = "curtain_fall"
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
if decision.uses_prop and session.latest_prop is None:
|
| 572 |
update["uses_prop"] = False
|
| 573 |
if decision.beat_type == "secret_reveal":
|
|
@@ -578,6 +642,17 @@ def validate_director_decision(decision: DirectorDecision, session: TheaterSessi
|
|
| 578 |
return decision, "valid"
|
| 579 |
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
def _coerce_director_output(raw_output: object) -> dict[str, object] | None:
|
| 582 |
if isinstance(raw_output, DirectorDecision):
|
| 583 |
return raw_output.model_dump()
|
|
|
|
| 29 |
"finale",
|
| 30 |
]
|
| 31 |
|
| 32 |
+
STORY_PHASES = ["opening", "complication", "reveal", "chaos", "finale"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def story_progress(session: TheaterSession) -> float:
|
| 36 |
+
target_beats = max(1, session.target_beats)
|
| 37 |
+
return min(1.0, max(0.0, session.beat_index / target_beats))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def story_phase(session: TheaterSession) -> str:
|
| 41 |
+
progress = story_progress(session)
|
| 42 |
+
if progress < 0.20:
|
| 43 |
+
return "opening"
|
| 44 |
+
if progress < 0.45:
|
| 45 |
+
return "complication"
|
| 46 |
+
if progress < 0.65:
|
| 47 |
+
return "reveal"
|
| 48 |
+
if progress < 0.85:
|
| 49 |
+
return "chaos"
|
| 50 |
+
return "finale"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def should_force_finale(session: TheaterSession) -> bool:
|
| 54 |
+
return (
|
| 55 |
+
session.finale_requested
|
| 56 |
+
or session.beat_index >= session.target_beats - 1
|
| 57 |
+
or session.beat_index >= session.max_beats - 1
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
|
| 61 |
@dataclass(frozen=True)
|
| 62 |
class DirectorGeneration:
|
|
|
|
| 72 |
|
| 73 |
class DirectorPolicy:
|
| 74 |
def decide(self, session: TheaterSession) -> DirectorDecision:
|
| 75 |
+
phase = story_phase(session)
|
| 76 |
+
progress = story_progress(session)
|
| 77 |
+
if should_force_finale(session):
|
| 78 |
speaker = self._choose_speaker(session)
|
| 79 |
return DirectorDecision(
|
| 80 |
next_speaker=speaker.name,
|
|
|
|
| 82 |
instruction="Tie the scene together in one crisp curtain-call line.",
|
| 83 |
stage_effect="curtain_fall",
|
| 84 |
should_end_scene=True,
|
| 85 |
+
reason_summary="Finale requested, target reached, or final beat reached; closing cleanly.",
|
| 86 |
)
|
| 87 |
|
| 88 |
if session.latest_prop is not None:
|
|
|
|
| 106 |
reason_summary=f"Newly summoned actor {summoned_actor.name} has not spoken yet.",
|
| 107 |
)
|
| 108 |
|
| 109 |
+
beat_type = self._beat_type_for_phase(phase, session)
|
| 110 |
speaker = self._choose_speaker(session)
|
| 111 |
reveal_secret = beat_type == "secret_reveal"
|
| 112 |
return DirectorDecision(
|
|
|
|
| 116 |
stage_effect=self._effect_for_beat(beat_type),
|
| 117 |
reveal_secret=reveal_secret,
|
| 118 |
should_end_scene=beat_type == "finale",
|
| 119 |
+
reason_summary=f"Following {phase} phase at {progress:.0%} progress with {beat_type}.",
|
| 120 |
)
|
| 121 |
|
| 122 |
def _choose_speaker(self, session: TheaterSession) -> Actor:
|
|
|
|
| 157 |
"finale": "curtain_fall",
|
| 158 |
}[beat_type]
|
| 159 |
|
| 160 |
+
def _beat_type_for_phase(self, phase: str, session: TheaterSession) -> str:
|
| 161 |
+
if phase == "opening":
|
| 162 |
+
return "setup" if session.beat_index == 0 else "denial_or_contradiction"
|
| 163 |
+
if phase == "complication":
|
| 164 |
+
return "evidence_or_prop" if session.beat_index % 2 == 0 else "denial_or_contradiction"
|
| 165 |
+
if phase == "reveal":
|
| 166 |
+
return "secret_reveal"
|
| 167 |
+
if phase == "chaos":
|
| 168 |
+
return "chaos_or_intervention"
|
| 169 |
+
return "chaos_or_intervention"
|
| 170 |
+
|
| 171 |
|
| 172 |
class OpenBMBDirectorPolicy:
|
| 173 |
def __init__(self, fallback_policy: DirectorPolicy | None = None) -> None:
|
|
|
|
| 394 |
)
|
| 395 |
return session
|
| 396 |
|
| 397 |
+
current_phase = story_phase(session)
|
| 398 |
+
current_progress = story_progress(session)
|
| 399 |
director_generation = choose_director_decision(session)
|
| 400 |
decision = director_generation.decision
|
| 401 |
speaker = _actor_by_name(session, decision.next_speaker)
|
|
|
|
| 407 |
"Director decision: "
|
| 408 |
f"{decision.beat_type} for {speaker.name}; {decision.reason_summary}"
|
| 409 |
)
|
| 410 |
+
session.director_log.append(
|
| 411 |
+
f"Story phase {current_phase} at {current_progress:.0%} progress "
|
| 412 |
+
f"(min={session.min_beats}, target={session.target_beats}, max={session.max_beats})."
|
| 413 |
+
)
|
| 414 |
session.director_log.append(
|
| 415 |
"Director mode "
|
| 416 |
f"{director_generation.director_mode} "
|
|
|
|
| 433 |
fallback_used=director_generation.fallback_used,
|
| 434 |
fallback_reason=director_generation.error,
|
| 435 |
reason_summary=director_generation.reason_summary,
|
| 436 |
+
story_phase=current_phase,
|
| 437 |
+
progress=round(current_progress, 3),
|
| 438 |
+
min_beats=session.min_beats,
|
| 439 |
+
target_beats=session.target_beats,
|
| 440 |
+
max_beats=session.max_beats,
|
| 441 |
uses_prop=decision.uses_prop,
|
| 442 |
reveal_secret=decision.reveal_secret,
|
| 443 |
should_end_scene=decision.should_end_scene,
|
|
|
|
| 493 |
beat_type=decision.beat_type,
|
| 494 |
speaker=speaker.name,
|
| 495 |
stage_effect=beat.stage_effect,
|
| 496 |
+
story_phase=story_phase(session),
|
| 497 |
+
progress=round(story_progress(session), 3),
|
| 498 |
)
|
| 499 |
add_trace_event(
|
| 500 |
session,
|
|
|
|
| 530 |
session,
|
| 531 |
"scene_completed",
|
| 532 |
beat_index=session.beat_index,
|
| 533 |
+
story_phase="finale",
|
| 534 |
reason_summary="Finale reached; curtain falls cleanly.",
|
| 535 |
)
|
| 536 |
|
|
|
|
| 569 |
premise=session.premise,
|
| 570 |
setting=session.setting,
|
| 571 |
beat_index=session.beat_index,
|
| 572 |
+
min_beats=session.min_beats,
|
| 573 |
+
target_beats=session.target_beats,
|
| 574 |
max_beats=session.max_beats,
|
| 575 |
+
current_progress=f"{story_progress(session):.0%}",
|
| 576 |
+
story_phase=story_phase(session),
|
| 577 |
allowed_beat_types=", ".join(BEAT_ARC),
|
| 578 |
actor_profiles=actor_profiles,
|
| 579 |
recent_transcript=recent_transcript,
|
|
|
|
| 621 |
return None, "invalid_too_long"
|
| 622 |
|
| 623 |
update: dict[str, object] = {}
|
| 624 |
+
if should_force_finale(session):
|
| 625 |
update["beat_type"] = "finale"
|
| 626 |
update["should_end_scene"] = True
|
| 627 |
update["stage_effect"] = "curtain_fall"
|
| 628 |
+
else:
|
| 629 |
+
if decision.should_end_scene and session.beat_index < session.target_beats - 1:
|
| 630 |
+
update["should_end_scene"] = False
|
| 631 |
+
if decision.beat_type == "finale" and session.beat_index < session.target_beats - 1:
|
| 632 |
+
update["beat_type"] = _safe_non_finale_beat_type(session)
|
| 633 |
+
update["should_end_scene"] = False
|
| 634 |
+
update["stage_effect"] = DirectorPolicy()._effect_for_beat(str(update["beat_type"]))
|
| 635 |
if decision.uses_prop and session.latest_prop is None:
|
| 636 |
update["uses_prop"] = False
|
| 637 |
if decision.beat_type == "secret_reveal":
|
|
|
|
| 642 |
return decision, "valid"
|
| 643 |
|
| 644 |
|
| 645 |
+
def _safe_non_finale_beat_type(session: TheaterSession) -> str:
|
| 646 |
+
phase = story_phase(session)
|
| 647 |
+
if phase == "opening":
|
| 648 |
+
return "setup"
|
| 649 |
+
if phase == "complication":
|
| 650 |
+
return "evidence_or_prop"
|
| 651 |
+
if phase == "reveal":
|
| 652 |
+
return "secret_reveal"
|
| 653 |
+
return "chaos_or_intervention"
|
| 654 |
+
|
| 655 |
+
|
| 656 |
def _coerce_director_output(raw_output: object) -> dict[str, object] | None:
|
| 657 |
if isinstance(raw_output, DirectorDecision):
|
| 658 |
return raw_output.model_dump()
|
puppet_theater/models.py
CHANGED
|
@@ -97,7 +97,9 @@ class TheaterSession:
|
|
| 97 |
session_id: str = field(default_factory=lambda: uuid4().hex[:12])
|
| 98 |
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 99 |
beat_index: int = 0
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
transcript: list[Beat] = field(default_factory=list)
|
| 102 |
props: list[str] = field(default_factory=list)
|
| 103 |
latest_prop: str | None = None
|
|
|
|
| 97 |
session_id: str = field(default_factory=lambda: uuid4().hex[:12])
|
| 98 |
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 99 |
beat_index: int = 0
|
| 100 |
+
min_beats: int = 7
|
| 101 |
+
target_beats: int = 10
|
| 102 |
+
max_beats: int = 12
|
| 103 |
transcript: list[Beat] = field(default_factory=list)
|
| 104 |
props: list[str] = field(default_factory=list)
|
| 105 |
latest_prop: str | None = None
|
puppet_theater/prompts.py
CHANGED
|
@@ -46,7 +46,11 @@ Show title: {show_title}
|
|
| 46 |
Premise: {premise}
|
| 47 |
Setting: {setting}
|
| 48 |
Current beat: {beat_index}
|
|
|
|
|
|
|
| 49 |
Maximum beats: {max_beats}
|
|
|
|
|
|
|
| 50 |
Allowed beat types: {allowed_beat_types}
|
| 51 |
Available actors:
|
| 52 |
{actor_profiles}
|
|
|
|
| 46 |
Premise: {premise}
|
| 47 |
Setting: {setting}
|
| 48 |
Current beat: {beat_index}
|
| 49 |
+
Minimum beats before normal ending: {min_beats}
|
| 50 |
+
Target beats: {target_beats}
|
| 51 |
Maximum beats: {max_beats}
|
| 52 |
+
Current progress: {current_progress}
|
| 53 |
+
Story phase: {story_phase}
|
| 54 |
Allowed beat types: {allowed_beat_types}
|
| 55 |
Available actors:
|
| 56 |
{actor_profiles}
|
puppet_theater/session.py
CHANGED
|
@@ -2,6 +2,22 @@ from puppet_theater.models import Actor, TheaterSession
|
|
| 2 |
from puppet_theater.trace import add_trace_event
|
| 3 |
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
def _clean_premise(premise: str) -> str:
|
| 6 |
cleaned = " ".join(premise.strip().split())
|
| 7 |
return cleaned or "A mysterious puppet show with no premise"
|
|
@@ -35,10 +51,12 @@ def create_show_from_premise(
|
|
| 35 |
backend_max_new_tokens: int = 120,
|
| 36 |
backend_temperature: float = 0.75,
|
| 37 |
director_mode: str = "deterministic",
|
|
|
|
| 38 |
) -> TheaterSession:
|
| 39 |
cleaned_premise = _clean_premise(premise)
|
| 40 |
show_title = _title_from_premise(cleaned_premise)
|
| 41 |
setting = _setting_from_premise(cleaned_premise)
|
|
|
|
| 42 |
|
| 43 |
actors = [
|
| 44 |
Actor(
|
|
@@ -72,9 +90,10 @@ def create_show_from_premise(
|
|
| 72 |
active_director_mode = director_mode if director_mode in supported_generation_modes else "deterministic"
|
| 73 |
model_note = f" ({backend_model_id})" if backend_model_id else ""
|
| 74 |
director_log = [
|
| 75 |
-
"Director created a
|
| 76 |
f"Active backend: {active_backend}{model_note}.",
|
| 77 |
f"Director mode: {active_director_mode}.",
|
|
|
|
| 78 |
f"Setting selected: {setting}.",
|
| 79 |
"Three puppet actors are waiting for the first beat.",
|
| 80 |
]
|
|
@@ -84,7 +103,9 @@ def create_show_from_premise(
|
|
| 84 |
setting=setting,
|
| 85 |
actors=actors,
|
| 86 |
beat_index=0,
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
transcript=[],
|
| 89 |
props=[],
|
| 90 |
latest_prop=None,
|
|
@@ -105,6 +126,10 @@ def create_show_from_premise(
|
|
| 105 |
backend_name=active_backend,
|
| 106 |
model_id=backend_model_id,
|
| 107 |
director_mode=active_director_mode,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
actor_count=len(actors),
|
| 109 |
validation_status="valid",
|
| 110 |
fallback_used=False,
|
|
@@ -114,6 +139,7 @@ def create_show_from_premise(
|
|
| 114 |
session,
|
| 115 |
"director_plan_created",
|
| 116 |
director_mode=active_director_mode,
|
| 117 |
-
|
|
|
|
| 118 |
)
|
| 119 |
return session
|
|
|
|
| 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
|
| 17 |
+
min_beats, target_beats, max_beats = SHOW_LENGTH_PRESETS[normalized]
|
| 18 |
+
return normalized, min_beats, target_beats, max_beats
|
| 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"
|
|
|
|
| 51 |
backend_max_new_tokens: int = 120,
|
| 52 |
backend_temperature: float = 0.75,
|
| 53 |
director_mode: str = "deterministic",
|
| 54 |
+
show_length: str = DEFAULT_SHOW_LENGTH,
|
| 55 |
) -> TheaterSession:
|
| 56 |
cleaned_premise = _clean_premise(premise)
|
| 57 |
show_title = _title_from_premise(cleaned_premise)
|
| 58 |
setting = _setting_from_premise(cleaned_premise)
|
| 59 |
+
active_show_length, min_beats, target_beats, max_beats = resolve_show_length(show_length)
|
| 60 |
|
| 61 |
actors = [
|
| 62 |
Actor(
|
|
|
|
| 90 |
active_director_mode = director_mode if director_mode in supported_generation_modes else "deterministic"
|
| 91 |
model_note = f" ({backend_model_id})" if backend_model_id else ""
|
| 92 |
director_log = [
|
| 93 |
+
f"Director created a {active_show_length} show plan.",
|
| 94 |
f"Active backend: {active_backend}{model_note}.",
|
| 95 |
f"Director mode: {active_director_mode}.",
|
| 96 |
+
f"Show length: min {min_beats}, target {target_beats}, max {max_beats} beats.",
|
| 97 |
f"Setting selected: {setting}.",
|
| 98 |
"Three puppet actors are waiting for the first beat.",
|
| 99 |
]
|
|
|
|
| 103 |
setting=setting,
|
| 104 |
actors=actors,
|
| 105 |
beat_index=0,
|
| 106 |
+
min_beats=min_beats,
|
| 107 |
+
target_beats=target_beats,
|
| 108 |
+
max_beats=max_beats,
|
| 109 |
transcript=[],
|
| 110 |
props=[],
|
| 111 |
latest_prop=None,
|
|
|
|
| 126 |
backend_name=active_backend,
|
| 127 |
model_id=backend_model_id,
|
| 128 |
director_mode=active_director_mode,
|
| 129 |
+
show_length=active_show_length,
|
| 130 |
+
min_beats=min_beats,
|
| 131 |
+
target_beats=target_beats,
|
| 132 |
+
max_beats=max_beats,
|
| 133 |
actor_count=len(actors),
|
| 134 |
validation_status="valid",
|
| 135 |
fallback_used=False,
|
|
|
|
| 139 |
session,
|
| 140 |
"director_plan_created",
|
| 141 |
director_mode=active_director_mode,
|
| 142 |
+
story_phase="opening",
|
| 143 |
+
reason_summary=f"{active_show_length.title()} progress-based show plan created.",
|
| 144 |
)
|
| 145 |
return session
|
puppet_theater/trace.py
CHANGED
|
@@ -49,6 +49,9 @@ def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
|
|
| 49 |
"premise": sanitize_value(session.premise),
|
| 50 |
"title": sanitize_value(session.show_title),
|
| 51 |
"setting": sanitize_value(session.setting),
|
|
|
|
|
|
|
|
|
|
| 52 |
"active_backend": sanitize_value(session.backend_name),
|
| 53 |
"actor_backend": sanitize_value(session.backend_name),
|
| 54 |
"director_mode": sanitize_value(session.director_mode),
|
|
|
|
| 49 |
"premise": sanitize_value(session.premise),
|
| 50 |
"title": sanitize_value(session.show_title),
|
| 51 |
"setting": sanitize_value(session.setting),
|
| 52 |
+
"min_beats": session.min_beats,
|
| 53 |
+
"target_beats": session.target_beats,
|
| 54 |
+
"max_beats": session.max_beats,
|
| 55 |
"active_backend": sanitize_value(session.backend_name),
|
| 56 |
"actor_backend": sanitize_value(session.backend_name),
|
| 57 |
"director_mode": sanitize_value(session.director_mode),
|
tests/test_director.py
CHANGED
|
@@ -5,9 +5,18 @@ from puppet_theater.director import (
|
|
| 5 |
build_director_prompt,
|
| 6 |
choose_director_decision,
|
| 7 |
parse_director_decision,
|
|
|
|
|
|
|
| 8 |
)
|
| 9 |
from puppet_theater.models import DirectorDecision
|
| 10 |
-
from puppet_theater.session import create_show_from_premise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def test_director_prompt_includes_audience_context_and_schema() -> None:
|
|
@@ -25,10 +34,30 @@ def test_director_prompt_includes_audience_context_and_schema() -> None:
|
|
| 25 |
assert "Audience summoned Professor Button." in prompt
|
| 26 |
assert "Professor Button" in prompt
|
| 27 |
assert "Has already misplaced the final cue card." in prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
assert '"next_speaker"' in prompt
|
| 29 |
assert "Allowed beat types: " in prompt
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
def test_invalid_llm_speaker_is_rejected() -> None:
|
| 33 |
session = create_show_from_premise("A castle argues with a thunderstorm")
|
| 34 |
raw_decision = {
|
|
@@ -83,8 +112,9 @@ def test_finale_request_forces_bounded_finale_decision() -> None:
|
|
| 83 |
assert decision.stage_effect == "curtain_fall"
|
| 84 |
|
| 85 |
|
| 86 |
-
def
|
| 87 |
session = create_show_from_premise("A teacup runs for mayor")
|
|
|
|
| 88 |
decision = DirectorDecision(
|
| 89 |
next_speaker=session.actors[0].name,
|
| 90 |
beat_type="setup",
|
|
@@ -99,3 +129,79 @@ def test_early_should_end_scene_is_bounded() -> None:
|
|
| 99 |
assert status == "valid"
|
| 100 |
assert validated is not None
|
| 101 |
assert validated.should_end_scene is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
build_director_prompt,
|
| 6 |
choose_director_decision,
|
| 7 |
parse_director_decision,
|
| 8 |
+
run_full_act,
|
| 9 |
+
story_phase,
|
| 10 |
)
|
| 11 |
from puppet_theater.models import DirectorDecision
|
| 12 |
+
from puppet_theater.session import create_show_from_premise, resolve_show_length
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_show_length_presets_are_resolved() -> None:
|
| 16 |
+
assert resolve_show_length("short") == ("short", 5, 7, 8)
|
| 17 |
+
assert resolve_show_length("standard") == ("standard", 7, 10, 12)
|
| 18 |
+
assert resolve_show_length("extended") == ("extended", 10, 14, 16)
|
| 19 |
+
assert resolve_show_length("unknown") == ("standard", 7, 10, 12)
|
| 20 |
|
| 21 |
|
| 22 |
def test_director_prompt_includes_audience_context_and_schema() -> None:
|
|
|
|
| 34 |
assert "Audience summoned Professor Button." in prompt
|
| 35 |
assert "Professor Button" in prompt
|
| 36 |
assert "Has already misplaced the final cue card." in prompt
|
| 37 |
+
assert "Minimum beats before normal ending: 7" in prompt
|
| 38 |
+
assert "Target beats: 10" in prompt
|
| 39 |
+
assert "Maximum beats: 12" in prompt
|
| 40 |
+
assert "Current progress: 0%" in prompt
|
| 41 |
+
assert "Story phase: opening" in prompt
|
| 42 |
assert '"next_speaker"' in prompt
|
| 43 |
assert "Allowed beat types: " in prompt
|
| 44 |
|
| 45 |
|
| 46 |
+
def test_story_phase_uses_target_progress() -> None:
|
| 47 |
+
session = create_show_from_premise("A teacup files a weather report", show_length="standard")
|
| 48 |
+
|
| 49 |
+
session.beat_index = 0
|
| 50 |
+
assert story_phase(session) == "opening"
|
| 51 |
+
session.beat_index = 2
|
| 52 |
+
assert story_phase(session) == "complication"
|
| 53 |
+
session.beat_index = 5
|
| 54 |
+
assert story_phase(session) == "reveal"
|
| 55 |
+
session.beat_index = 7
|
| 56 |
+
assert story_phase(session) == "chaos"
|
| 57 |
+
session.beat_index = 9
|
| 58 |
+
assert story_phase(session) == "finale"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def test_invalid_llm_speaker_is_rejected() -> None:
|
| 62 |
session = create_show_from_premise("A castle argues with a thunderstorm")
|
| 63 |
raw_decision = {
|
|
|
|
| 112 |
assert decision.stage_effect == "curtain_fall"
|
| 113 |
|
| 114 |
|
| 115 |
+
def test_early_should_end_scene_is_bounded_until_target() -> None:
|
| 116 |
session = create_show_from_premise("A teacup runs for mayor")
|
| 117 |
+
session.beat_index = session.min_beats - 1
|
| 118 |
decision = DirectorDecision(
|
| 119 |
next_speaker=session.actors[0].name,
|
| 120 |
beat_type="setup",
|
|
|
|
| 129 |
assert status == "valid"
|
| 130 |
assert validated is not None
|
| 131 |
assert validated.should_end_scene is False
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_early_llm_finale_is_rewritten_before_target() -> None:
|
| 135 |
+
session = create_show_from_premise("A lighthouse hosts a talent show")
|
| 136 |
+
decision = DirectorDecision(
|
| 137 |
+
next_speaker=session.actors[0].name,
|
| 138 |
+
beat_type="finale",
|
| 139 |
+
instruction="End immediately.",
|
| 140 |
+
stage_effect="curtain_fall",
|
| 141 |
+
should_end_scene=True,
|
| 142 |
+
reason_summary="The model tried to close early.",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
validated, status = parse_director_decision(decision, session)
|
| 146 |
+
|
| 147 |
+
assert status == "valid"
|
| 148 |
+
assert validated is not None
|
| 149 |
+
assert validated.beat_type != "finale"
|
| 150 |
+
assert validated.should_end_scene is False
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_full_act_standard_runs_to_target_and_stays_bounded() -> None:
|
| 154 |
+
session = create_show_from_premise("A moon detective interrogates a suspicious toaster")
|
| 155 |
+
|
| 156 |
+
run_full_act(session)
|
| 157 |
+
|
| 158 |
+
assert len(session.transcript) == session.target_beats
|
| 159 |
+
assert len(session.transcript) > 6
|
| 160 |
+
assert session.beat_index == session.max_beats
|
| 161 |
+
assert len(session.transcript) <= session.max_beats
|
| 162 |
+
assert session.transcript[-1].stage_effect == "curtain_fall"
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_length_presets_change_full_act_bounds() -> None:
|
| 166 |
+
short_session = create_show_from_premise("A tiny crown demands applause", show_length="short")
|
| 167 |
+
extended_session = create_show_from_premise("A telescope opens a bakery", show_length="extended")
|
| 168 |
+
|
| 169 |
+
run_full_act(short_session)
|
| 170 |
+
run_full_act(extended_session)
|
| 171 |
+
|
| 172 |
+
assert (short_session.min_beats, short_session.target_beats, short_session.max_beats) == (5, 7, 8)
|
| 173 |
+
assert (extended_session.min_beats, extended_session.target_beats, extended_session.max_beats) == (10, 14, 16)
|
| 174 |
+
assert len(short_session.transcript) == 7
|
| 175 |
+
assert len(extended_session.transcript) == 14
|
| 176 |
+
assert len(extended_session.transcript) <= extended_session.max_beats
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_finale_request_can_end_before_minimum() -> None:
|
| 180 |
+
session = create_show_from_premise("A spoon wants a curtain call", show_length="extended")
|
| 181 |
+
session = request_finale(session)
|
| 182 |
+
|
| 183 |
+
run_full_act(session)
|
| 184 |
+
|
| 185 |
+
assert len(session.transcript) == 1
|
| 186 |
+
assert session.transcript[-1].stage_effect == "curtain_fall"
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_llm_decision_is_forced_to_finale_at_max() -> None:
|
| 190 |
+
session = create_show_from_premise("A clock refuses bedtime", show_length="short")
|
| 191 |
+
session.beat_index = session.max_beats - 1
|
| 192 |
+
decision = DirectorDecision(
|
| 193 |
+
next_speaker=session.actors[0].name,
|
| 194 |
+
beat_type="chaos_or_intervention",
|
| 195 |
+
instruction="Keep escalating.",
|
| 196 |
+
stage_effect="confetti_rustle",
|
| 197 |
+
should_end_scene=False,
|
| 198 |
+
reason_summary="The model tried to continue at the hard bound.",
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
validated, status = parse_director_decision(decision, session)
|
| 202 |
+
|
| 203 |
+
assert status == "valid"
|
| 204 |
+
assert validated is not None
|
| 205 |
+
assert validated.beat_type == "finale"
|
| 206 |
+
assert validated.should_end_scene is True
|
| 207 |
+
assert validated.stage_effect == "curtain_fall"
|