ShubhamSetia commited on
Commit
da51a9e
·
1 Parent(s): 202450d

feat: add director policy

Browse files
puppet_theater/__init__.py CHANGED
@@ -9,8 +9,8 @@ from puppet_theater.backends import (
9
  parse_actor_output,
10
  warm_up_openbmb,
11
  )
12
- from puppet_theater.director import BEAT_ARC, run_full_act, run_one_beat
13
- from puppet_theater.models import Actor, ActorResponse, Beat, TheaterSession
14
  from puppet_theater.session import create_show_from_premise
15
 
16
  __all__ = [
@@ -20,6 +20,8 @@ __all__ = [
20
  "Beat",
21
  "DEFAULT_OPENBMB_MODEL_ID",
22
  "DeterministicBackend",
 
 
23
  "ModelBackend",
24
  "OpenBMBTransformersBackend",
25
  "TheaterSession",
 
9
  parse_actor_output,
10
  warm_up_openbmb,
11
  )
12
+ from puppet_theater.director import BEAT_ARC, DirectorPolicy, run_full_act, run_one_beat
13
+ from puppet_theater.models import Actor, ActorResponse, Beat, DirectorDecision, TheaterSession
14
  from puppet_theater.session import create_show_from_premise
15
 
16
  __all__ = [
 
20
  "Beat",
21
  "DEFAULT_OPENBMB_MODEL_ID",
22
  "DeterministicBackend",
23
+ "DirectorDecision",
24
+ "DirectorPolicy",
25
  "ModelBackend",
26
  "OpenBMBTransformersBackend",
27
  "TheaterSession",
puppet_theater/backends.py CHANGED
@@ -7,7 +7,7 @@ from typing import Any
7
 
8
  from pydantic import ValidationError
9
 
10
- from puppet_theater.models import Actor, ActorResponse, TheaterSession
11
  from puppet_theater.prompts import ACTOR_LINE_PROMPT
12
 
13
 
@@ -50,7 +50,7 @@ class ModelBackend(ABC):
50
  def generate_actor_response(
51
  self,
52
  session: TheaterSession,
53
- beat_type: str,
54
  speaker: Actor,
55
  prop: str | None,
56
  ) -> ActorResponse | dict[str, Any] | str:
@@ -59,7 +59,7 @@ class ModelBackend(ABC):
59
  def repair_actor_response(
60
  self,
61
  session: TheaterSession,
62
- beat_type: str,
63
  speaker: Actor,
64
  prop: str | None,
65
  invalid_output: ActorResponse | dict[str, Any] | str,
@@ -75,11 +75,11 @@ class DeterministicBackend(ModelBackend):
75
  def generate_actor_response(
76
  self,
77
  session: TheaterSession,
78
- beat_type: str,
79
  speaker: Actor,
80
  prop: str | None,
81
  ) -> ActorResponse:
82
- return deterministic_actor_response(session, beat_type, speaker, prop)
83
 
84
 
85
  class OpenBMBTransformersBackend(ModelBackend):
@@ -112,23 +112,23 @@ class OpenBMBTransformersBackend(ModelBackend):
112
  def generate_actor_response(
113
  self,
114
  session: TheaterSession,
115
- beat_type: str,
116
  speaker: Actor,
117
  prop: str | None,
118
  ) -> str:
119
- prompt = build_actor_line_prompt(session, beat_type, speaker, prop)
120
  return self._generate_text(prompt)
121
 
122
  def repair_actor_response(
123
  self,
124
  session: TheaterSession,
125
- beat_type: str,
126
  speaker: Actor,
127
  prop: str | None,
128
  invalid_output: ActorResponse | dict[str, Any] | str,
129
  validation_status: str,
130
  ) -> str | None:
131
- prompt = build_actor_line_prompt(session, beat_type, speaker, prop)
132
  repair_prompt = (
133
  f"{prompt}\n\nThe previous output failed validation with status {validation_status}.\n"
134
  "Return only valid compact JSON. Do not include markdown, commentary, or extra keys.\n"
@@ -212,22 +212,22 @@ class OpenBMBTransformersBackend(ModelBackend):
212
 
213
  def deterministic_actor_response(
214
  session: TheaterSession,
215
- beat_type: str,
216
  speaker: Actor,
217
  prop: str | None,
218
  ) -> ActorResponse:
219
  return ActorResponse(
220
- line=_line_for_beat(session, beat_type, speaker, prop),
221
- emotion=_emotion_for_beat(beat_type),
222
- gesture=_gesture_for_beat(beat_type),
223
- stage_effect=_effect_for_beat(beat_type),
224
  tool_request=None,
225
  )
226
 
227
 
228
  def generate_actor_response(
229
  session: TheaterSession,
230
- beat_type: str,
231
  speaker: Actor,
232
  prop: str | None,
233
  backend: ModelBackend | None = None,
@@ -240,12 +240,12 @@ def generate_actor_response(
240
  start_time = time.perf_counter()
241
  raw_output: ActorResponse | dict[str, Any] | str | None = None
242
  try:
243
- raw_output = active_backend.generate_actor_response(session, beat_type, speaker, prop)
244
  except Exception as exc:
245
  latency_ms = _elapsed_ms(start_time)
246
  return _fallback_generation(
247
  session=session,
248
- beat_type=beat_type,
249
  speaker=speaker,
250
  prop=prop,
251
  backend=active_backend,
@@ -271,7 +271,7 @@ def generate_actor_response(
271
  try:
272
  repair_output = active_backend.repair_actor_response(
273
  session=session,
274
- beat_type=beat_type,
275
  speaker=speaker,
276
  prop=prop,
277
  invalid_output=raw_output,
@@ -280,7 +280,7 @@ def generate_actor_response(
280
  except Exception as exc:
281
  return _fallback_generation(
282
  session=session,
283
- beat_type=beat_type,
284
  speaker=speaker,
285
  prop=prop,
286
  backend=active_backend,
@@ -306,7 +306,7 @@ def generate_actor_response(
306
 
307
  return _fallback_generation(
308
  session=session,
309
- beat_type=beat_type,
310
  speaker=speaker,
311
  prop=prop,
312
  backend=active_backend,
@@ -375,7 +375,7 @@ def get_backend_status(backend_name: str | None = None) -> BackendRuntimeStatus:
375
 
376
  def build_actor_line_prompt(
377
  session: TheaterSession,
378
- beat_type: str,
379
  speaker: Actor,
380
  prop: str | None,
381
  ) -> str:
@@ -386,12 +386,15 @@ def build_actor_line_prompt(
386
  show_title=session.show_title,
387
  premise=session.premise,
388
  setting=session.setting,
389
- beat_type=beat_type,
390
  speaker_name=speaker.name,
391
  speaker_goal=speaker.goal,
392
  speaker_style=speaker.speaking_style,
393
  audience_action=session.latest_audience_action or "None",
394
  latest_prop=prop or session.latest_prop or "None",
 
 
 
395
  )
396
  return (
397
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
@@ -401,7 +404,7 @@ def build_actor_line_prompt(
401
 
402
  def _fallback_generation(
403
  session: TheaterSession,
404
- beat_type: str,
405
  speaker: Actor,
406
  prop: str | None,
407
  backend: ModelBackend,
@@ -409,7 +412,7 @@ def _fallback_generation(
409
  latency_ms: int | None,
410
  error: str | None = None,
411
  ) -> BackendGeneration:
412
- fallback_response = deterministic_actor_response(session, beat_type, speaker, prop)
413
  generation = BackendGeneration(
414
  response=fallback_response,
415
  backend_name=backend.name,
@@ -508,12 +511,13 @@ def _runtime_status_from_backend(backend: ModelBackend) -> BackendRuntimeStatus:
508
 
509
  def _line_for_beat(
510
  session: TheaterSession,
511
- beat_type: str,
512
  speaker: Actor,
513
  prop: str | None,
514
  ) -> str:
515
  if prop is not None:
516
  return f"I shall use this {prop} as evidence, a prop, and possibly a tiny emotional support object."
 
517
  if beat_type == "setup":
518
  return f"I see it clearly: {session.premise}, and somehow I am in charge."
519
  if beat_type == "denial_or_contradiction":
@@ -521,7 +525,9 @@ def _line_for_beat(
521
  if beat_type == "evidence_or_prop":
522
  return "I found a prop with fingerprints, glitter, and a very dramatic attitude."
523
  if beat_type == "secret_reveal":
524
- return f"I confess: {speaker.secret}"
 
 
525
  if beat_type == "chaos_or_intervention":
526
  return "The audience has interrupted with imaginary confetti, so everyone must panic gracefully."
527
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
 
7
 
8
  from pydantic import ValidationError
9
 
10
+ from puppet_theater.models import Actor, ActorResponse, DirectorDecision, TheaterSession
11
  from puppet_theater.prompts import ACTOR_LINE_PROMPT
12
 
13
 
 
50
  def generate_actor_response(
51
  self,
52
  session: TheaterSession,
53
+ decision: DirectorDecision,
54
  speaker: Actor,
55
  prop: str | None,
56
  ) -> ActorResponse | dict[str, Any] | str:
 
59
  def repair_actor_response(
60
  self,
61
  session: TheaterSession,
62
+ decision: DirectorDecision,
63
  speaker: Actor,
64
  prop: str | None,
65
  invalid_output: ActorResponse | dict[str, Any] | str,
 
75
  def generate_actor_response(
76
  self,
77
  session: TheaterSession,
78
+ decision: DirectorDecision,
79
  speaker: Actor,
80
  prop: str | None,
81
  ) -> ActorResponse:
82
+ return deterministic_actor_response(session, decision, speaker, prop)
83
 
84
 
85
  class OpenBMBTransformersBackend(ModelBackend):
 
112
  def generate_actor_response(
113
  self,
114
  session: TheaterSession,
115
+ decision: DirectorDecision,
116
  speaker: Actor,
117
  prop: str | None,
118
  ) -> str:
119
+ prompt = build_actor_line_prompt(session, decision, speaker, prop)
120
  return self._generate_text(prompt)
121
 
122
  def repair_actor_response(
123
  self,
124
  session: TheaterSession,
125
+ decision: DirectorDecision,
126
  speaker: Actor,
127
  prop: str | None,
128
  invalid_output: ActorResponse | dict[str, Any] | str,
129
  validation_status: str,
130
  ) -> str | None:
131
+ prompt = build_actor_line_prompt(session, decision, speaker, prop)
132
  repair_prompt = (
133
  f"{prompt}\n\nThe previous output failed validation with status {validation_status}.\n"
134
  "Return only valid compact JSON. Do not include markdown, commentary, or extra keys.\n"
 
212
 
213
  def deterministic_actor_response(
214
  session: TheaterSession,
215
+ decision: DirectorDecision,
216
  speaker: Actor,
217
  prop: str | None,
218
  ) -> ActorResponse:
219
  return ActorResponse(
220
+ line=_line_for_beat(session, decision, speaker, prop),
221
+ emotion=_emotion_for_beat(decision.beat_type),
222
+ gesture=_gesture_for_beat(decision.beat_type),
223
+ stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
224
  tool_request=None,
225
  )
226
 
227
 
228
  def generate_actor_response(
229
  session: TheaterSession,
230
+ decision: DirectorDecision,
231
  speaker: Actor,
232
  prop: str | None,
233
  backend: ModelBackend | None = None,
 
240
  start_time = time.perf_counter()
241
  raw_output: ActorResponse | dict[str, Any] | str | None = None
242
  try:
243
+ raw_output = active_backend.generate_actor_response(session, decision, speaker, prop)
244
  except Exception as exc:
245
  latency_ms = _elapsed_ms(start_time)
246
  return _fallback_generation(
247
  session=session,
248
+ decision=decision,
249
  speaker=speaker,
250
  prop=prop,
251
  backend=active_backend,
 
271
  try:
272
  repair_output = active_backend.repair_actor_response(
273
  session=session,
274
+ decision=decision,
275
  speaker=speaker,
276
  prop=prop,
277
  invalid_output=raw_output,
 
280
  except Exception as exc:
281
  return _fallback_generation(
282
  session=session,
283
+ decision=decision,
284
  speaker=speaker,
285
  prop=prop,
286
  backend=active_backend,
 
306
 
307
  return _fallback_generation(
308
  session=session,
309
+ decision=decision,
310
  speaker=speaker,
311
  prop=prop,
312
  backend=active_backend,
 
375
 
376
  def build_actor_line_prompt(
377
  session: TheaterSession,
378
+ decision: DirectorDecision,
379
  speaker: Actor,
380
  prop: str | None,
381
  ) -> str:
 
386
  show_title=session.show_title,
387
  premise=session.premise,
388
  setting=session.setting,
389
+ beat_type=decision.beat_type,
390
  speaker_name=speaker.name,
391
  speaker_goal=speaker.goal,
392
  speaker_style=speaker.speaking_style,
393
  audience_action=session.latest_audience_action or "None",
394
  latest_prop=prop or session.latest_prop or "None",
395
+ director_instruction=decision.instruction,
396
+ reveal_secret=decision.reveal_secret,
397
+ stage_effect=decision.stage_effect,
398
  )
399
  return (
400
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
 
404
 
405
  def _fallback_generation(
406
  session: TheaterSession,
407
+ decision: DirectorDecision,
408
  speaker: Actor,
409
  prop: str | None,
410
  backend: ModelBackend,
 
412
  latency_ms: int | None,
413
  error: str | None = None,
414
  ) -> BackendGeneration:
415
+ fallback_response = deterministic_actor_response(session, decision, speaker, prop)
416
  generation = BackendGeneration(
417
  response=fallback_response,
418
  backend_name=backend.name,
 
511
 
512
  def _line_for_beat(
513
  session: TheaterSession,
514
+ decision: DirectorDecision,
515
  speaker: Actor,
516
  prop: str | None,
517
  ) -> str:
518
  if prop is not None:
519
  return f"I shall use this {prop} as evidence, a prop, and possibly a tiny emotional support object."
520
+ beat_type = decision.beat_type
521
  if beat_type == "setup":
522
  return f"I see it clearly: {session.premise}, and somehow I am in charge."
523
  if beat_type == "denial_or_contradiction":
 
525
  if beat_type == "evidence_or_prop":
526
  return "I found a prop with fingerprints, glitter, and a very dramatic attitude."
527
  if beat_type == "secret_reveal":
528
+ if decision.reveal_secret:
529
+ return f"I confess: {speaker.secret}"
530
+ return "I nearly confessed something, but the spotlight blinked and I lost my nerve."
531
  if beat_type == "chaos_or_intervention":
532
  return "The audience has interrupted with imaginary confetti, so everyone must panic gracefully."
533
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
puppet_theater/director.py CHANGED
@@ -1,5 +1,7 @@
 
 
1
  from puppet_theater.backends import generate_actor_response
2
- from puppet_theater.models import Beat, TheaterSession
3
 
4
 
5
  BEAT_ARC = [
@@ -12,6 +14,92 @@ BEAT_ARC = [
12
  ]
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
16
  if session is None:
17
  return None
@@ -21,12 +109,26 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
21
  session.trace_events.append("beat_skipped:curtain_already_fallen")
22
  return session
23
 
24
- beat_type = "finale" if session.finale_requested else BEAT_ARC[session.beat_index]
25
- speaker = session.actors[session.beat_index % len(session.actors)]
26
- prop = session.latest_prop
27
  if prop is not None:
28
  speaker.held_prop = prop
29
- backend_generation = generate_actor_response(session, beat_type, speaker, prop)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  session.beat_index += 1
31
 
32
  response = backend_generation.response
@@ -35,16 +137,18 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
35
  line=response.line,
36
  emotion=response.emotion,
37
  gesture=response.gesture,
38
- stage_effect=response.stage_effect,
39
  tool_request=response.tool_request,
40
  )
41
  session.transcript.append(beat)
 
42
  if prop is not None:
43
  session.latest_prop = None
44
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
45
- session.trace_events.append(f"prop_handled:{prop}:{speaker.name}")
 
46
  session.director_log.append(
47
- f"Beat {session.beat_index}/{session.max_beats}: {beat_type} assigned to {speaker.name}."
48
  )
49
  session.director_log.append(
50
  "Backend "
@@ -56,7 +160,16 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
56
  session.director_log.append(f"Model id: {backend_generation.model_id}.")
57
  if backend_generation.error:
58
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
59
- session.trace_events.append(f"beat_added:{session.beat_index}:{beat_type}")
 
 
 
 
 
 
 
 
 
60
  session.trace_events.append(
61
  "backend_result:"
62
  f"{backend_generation.backend_name}:"
@@ -67,10 +180,11 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
67
  f"latency_ms={backend_generation.latency_ms}"
68
  )
69
 
70
- if beat_type == "finale":
71
  session.beat_index = session.max_beats
72
  session.finale_requested = True
73
  session.director_log.append("Finale reached; curtain falls cleanly.")
 
74
  session.trace_events.append("scene_completed")
75
 
76
  return session
@@ -83,3 +197,10 @@ def run_full_act(session: TheaterSession | None) -> TheaterSession | None:
83
  while session.beat_index < session.max_beats:
84
  run_one_beat(session)
85
  return session
 
 
 
 
 
 
 
 
1
+ from collections import Counter
2
+
3
  from puppet_theater.backends import generate_actor_response
4
+ from puppet_theater.models import Actor, Beat, DirectorDecision, TheaterSession
5
 
6
 
7
  BEAT_ARC = [
 
14
  ]
15
 
16
 
17
+ class DirectorPolicy:
18
+ def decide(self, session: TheaterSession) -> DirectorDecision:
19
+ if session.finale_requested or session.beat_index >= session.max_beats - 1:
20
+ speaker = self._choose_speaker(session)
21
+ return DirectorDecision(
22
+ next_speaker=speaker.name,
23
+ beat_type="finale",
24
+ instruction="Tie the scene together in one crisp curtain-call line.",
25
+ stage_effect="curtain_fall",
26
+ should_end_scene=True,
27
+ reason_summary="Finale requested or final beat reached; closing the scene cleanly.",
28
+ )
29
+
30
+ if session.latest_prop is not None:
31
+ speaker = self._choose_speaker(session)
32
+ return DirectorDecision(
33
+ next_speaker=speaker.name,
34
+ beat_type="evidence_or_prop",
35
+ instruction=f"Use the {session.latest_prop} as evidence and make it matter immediately.",
36
+ stage_effect="prop_table_glow",
37
+ uses_prop=True,
38
+ reason_summary=f"Latest audience prop {session.latest_prop} should affect the next beat.",
39
+ )
40
+
41
+ summoned_actor = self._unused_summoned_actor(session)
42
+ if summoned_actor is not None:
43
+ return DirectorDecision(
44
+ next_speaker=summoned_actor.name,
45
+ beat_type="chaos_or_intervention",
46
+ instruction="Enter the scene with one specific complication, then hand control back.",
47
+ stage_effect="entrance_sparkle",
48
+ reason_summary=f"Newly summoned actor {summoned_actor.name} has not spoken yet.",
49
+ )
50
+
51
+ beat_type = BEAT_ARC[min(session.beat_index, len(BEAT_ARC) - 1)]
52
+ speaker = self._choose_speaker(session)
53
+ reveal_secret = beat_type == "secret_reveal"
54
+ return DirectorDecision(
55
+ next_speaker=speaker.name,
56
+ beat_type=beat_type,
57
+ instruction=self._instruction_for_beat(beat_type, reveal_secret),
58
+ stage_effect=self._effect_for_beat(beat_type),
59
+ reveal_secret=reveal_secret,
60
+ should_end_scene=beat_type == "finale",
61
+ reason_summary=f"Following the compact six-beat arc with {beat_type}.",
62
+ )
63
+
64
+ def _choose_speaker(self, session: TheaterSession) -> Actor:
65
+ latest_speaker = session.transcript[-1].speaker if session.transcript else None
66
+ counts = Counter(beat.speaker for beat in session.transcript)
67
+ candidates = [actor for actor in session.actors if actor.name != latest_speaker] or session.actors
68
+ target_index = session.beat_index % len(session.actors)
69
+ indexed_actor = session.actors[target_index]
70
+ if indexed_actor in candidates and counts[indexed_actor.name] <= min(counts[actor.name] for actor in candidates):
71
+ return indexed_actor
72
+ return min(candidates, key=lambda actor: (counts[actor.name], session.actors.index(actor)))
73
+
74
+ def _unused_summoned_actor(self, session: TheaterSession) -> Actor | None:
75
+ spoken = {beat.speaker for beat in session.transcript}
76
+ for actor in session.actors[3:]:
77
+ if actor.name not in spoken:
78
+ return actor
79
+ return None
80
+
81
+ def _instruction_for_beat(self, beat_type: str, reveal_secret: bool) -> str:
82
+ if reveal_secret:
83
+ return "Reveal your secret directly, but keep it playful and stage-ready."
84
+ return {
85
+ "setup": "Establish the premise and your role in one clear line.",
86
+ "denial_or_contradiction": "Contradict the premise with theatrical confidence.",
87
+ "evidence_or_prop": "Present a clue or prop as if it changes the whole case.",
88
+ "chaos_or_intervention": "React to the audience and escalate the scene briefly.",
89
+ "finale": "End the scene with a clean button and a bow.",
90
+ }.get(beat_type, "Keep the scene moving with one short line.")
91
+
92
+ def _effect_for_beat(self, beat_type: str) -> str:
93
+ return {
94
+ "setup": "warm_spotlight",
95
+ "denial_or_contradiction": "quick_blackout",
96
+ "evidence_or_prop": "prop_table_glow",
97
+ "secret_reveal": "single_spotlight",
98
+ "chaos_or_intervention": "confetti_rustle",
99
+ "finale": "curtain_fall",
100
+ }[beat_type]
101
+
102
+
103
  def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
104
  if session is None:
105
  return None
 
109
  session.trace_events.append("beat_skipped:curtain_already_fallen")
110
  return session
111
 
112
+ decision = DirectorPolicy().decide(session)
113
+ speaker = _actor_by_name(session, decision.next_speaker)
114
+ prop = session.latest_prop if decision.uses_prop else None
115
  if prop is not None:
116
  speaker.held_prop = prop
117
+
118
+ session.director_log.append(
119
+ "Director decision: "
120
+ f"{decision.beat_type} for {speaker.name}; {decision.reason_summary}"
121
+ )
122
+ session.trace_events.append(
123
+ "director_decision:"
124
+ f"speaker={speaker.name}:"
125
+ f"beat={decision.beat_type}:"
126
+ f"uses_prop={decision.uses_prop}:"
127
+ f"reveal_secret={decision.reveal_secret}:"
128
+ f"should_end={decision.should_end_scene}"
129
+ )
130
+
131
+ backend_generation = generate_actor_response(session, decision, speaker, prop)
132
  session.beat_index += 1
133
 
134
  response = backend_generation.response
 
137
  line=response.line,
138
  emotion=response.emotion,
139
  gesture=response.gesture,
140
+ stage_effect=response.stage_effect or decision.stage_effect,
141
  tool_request=response.tool_request,
142
  )
143
  session.transcript.append(beat)
144
+
145
  if prop is not None:
146
  session.latest_prop = None
147
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
148
+ session.trace_events.append(f"prop_used:{prop}:{speaker.name}")
149
+
150
  session.director_log.append(
151
+ f"Beat {session.beat_index}/{session.max_beats}: {decision.beat_type} assigned to {speaker.name}."
152
  )
153
  session.director_log.append(
154
  "Backend "
 
160
  session.director_log.append(f"Model id: {backend_generation.model_id}.")
161
  if backend_generation.error:
162
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
163
+
164
+ session.trace_events.append(f"beat_added:{session.beat_index}:{decision.beat_type}")
165
+ session.trace_events.append(
166
+ "actor_response:"
167
+ f"speaker={speaker.name}:"
168
+ f"backend={backend_generation.backend_name}:"
169
+ f"validation={backend_generation.validation_status}"
170
+ )
171
+ if backend_generation.fallback_used:
172
+ session.trace_events.append(f"fallback_used:{backend_generation.backend_name}:{backend_generation.validation_status}")
173
  session.trace_events.append(
174
  "backend_result:"
175
  f"{backend_generation.backend_name}:"
 
180
  f"latency_ms={backend_generation.latency_ms}"
181
  )
182
 
183
+ if decision.should_end_scene or decision.beat_type == "finale":
184
  session.beat_index = session.max_beats
185
  session.finale_requested = True
186
  session.director_log.append("Finale reached; curtain falls cleanly.")
187
+ session.trace_events.append("finale_triggered")
188
  session.trace_events.append("scene_completed")
189
 
190
  return session
 
197
  while session.beat_index < session.max_beats:
198
  run_one_beat(session)
199
  return session
200
+
201
+
202
+ def _actor_by_name(session: TheaterSession, actor_name: str) -> Actor:
203
+ for actor in session.actors:
204
+ if actor.name == actor_name:
205
+ return actor
206
+ return session.actors[session.beat_index % len(session.actors)]
puppet_theater/models.py CHANGED
@@ -1,8 +1,20 @@
1
  from dataclasses import dataclass, field
2
 
 
 
3
  from pydantic import BaseModel, Field, field_validator
4
 
5
 
 
 
 
 
 
 
 
 
 
 
6
  class ActorResponse(BaseModel):
7
  line: str = Field(description="Short, stage-ready puppet dialogue.")
8
  emotion: str
@@ -27,6 +39,25 @@ class ActorResponse(BaseModel):
27
  return cleaned or None
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @dataclass
31
  class Actor:
32
  name: str
 
1
  from dataclasses import dataclass, field
2
 
3
+ from typing import Literal
4
+
5
  from pydantic import BaseModel, Field, field_validator
6
 
7
 
8
+ BeatType = Literal[
9
+ "setup",
10
+ "denial_or_contradiction",
11
+ "evidence_or_prop",
12
+ "secret_reveal",
13
+ "chaos_or_intervention",
14
+ "finale",
15
+ ]
16
+
17
+
18
  class ActorResponse(BaseModel):
19
  line: str = Field(description="Short, stage-ready puppet dialogue.")
20
  emotion: str
 
39
  return cleaned or None
40
 
41
 
42
+ class DirectorDecision(BaseModel):
43
+ next_speaker: str
44
+ beat_type: BeatType
45
+ instruction: str
46
+ stage_effect: str
47
+ uses_prop: bool = False
48
+ reveal_secret: bool = False
49
+ should_end_scene: bool = False
50
+ reason_summary: str
51
+
52
+ @field_validator("next_speaker", "instruction", "stage_effect", "reason_summary")
53
+ @classmethod
54
+ def require_text(cls, value: str) -> str:
55
+ cleaned = " ".join(value.strip().split())
56
+ if not cleaned:
57
+ raise ValueError("field must not be empty")
58
+ return cleaned
59
+
60
+
61
  @dataclass
62
  class Actor:
63
  name: str
puppet_theater/prompts.py CHANGED
@@ -14,6 +14,9 @@ Beat type: {beat_type}
14
  Speaker: {speaker_name}
15
  Speaker goal: {speaker_goal}
16
  Speaker style: {speaker_style}
 
 
 
17
  Audience action: {audience_action}
18
  Latest prop: {latest_prop}
19
  """
 
14
  Speaker: {speaker_name}
15
  Speaker goal: {speaker_goal}
16
  Speaker style: {speaker_style}
17
+ Director instruction: {director_instruction}
18
+ Reveal secret this beat: {reveal_secret}
19
+ Suggested stage effect: {stage_effect}
20
  Audience action: {audience_action}
21
  Latest prop: {latest_prop}
22
  """