ShubhamSetia commited on
Commit
c3e33f1
·
1 Parent(s): bf65d34

feat: add dynamic story pacing

Browse files
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 {session.beat_index} of {session.max_beats}</div>
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
- if session.finale_requested or session.beat_index >= session.max_beats - 1:
 
 
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 the scene cleanly.",
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 = BEAT_ARC[min(session.beat_index, len(BEAT_ARC) - 1)]
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 the compact six-beat arc with {beat_type}.",
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.finale_requested or session.beat_index >= session.max_beats - 1:
566
  update["beat_type"] = "finale"
567
  update["should_end_scene"] = True
568
  update["stage_effect"] = "curtain_fall"
569
- elif decision.should_end_scene and session.beat_index < session.max_beats - 2:
570
- update["should_end_scene"] = False
 
 
 
 
 
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
- max_beats: int = 6
 
 
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 deterministic six-beat show plan.",
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
- max_beats=6,
 
 
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
- reason_summary="Deterministic six-beat show plan created.",
 
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 test_early_should_end_scene_is_bounded() -> None:
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"