Commit ·
da51a9e
1
Parent(s): 202450d
feat: add director policy
Browse files- puppet_theater/__init__.py +4 -2
- puppet_theater/backends.py +32 -26
- puppet_theater/director.py +131 -10
- puppet_theater/models.py +31 -0
- puppet_theater/prompts.py +3 -0
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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 79 |
speaker: Actor,
|
| 80 |
prop: str | None,
|
| 81 |
) -> ActorResponse:
|
| 82 |
-
return deterministic_actor_response(session,
|
| 83 |
|
| 84 |
|
| 85 |
class OpenBMBTransformersBackend(ModelBackend):
|
|
@@ -112,23 +112,23 @@ class OpenBMBTransformersBackend(ModelBackend):
|
|
| 112 |
def generate_actor_response(
|
| 113 |
self,
|
| 114 |
session: TheaterSession,
|
| 115 |
-
|
| 116 |
speaker: Actor,
|
| 117 |
prop: str | None,
|
| 118 |
) -> str:
|
| 119 |
-
prompt = build_actor_line_prompt(session,
|
| 120 |
return self._generate_text(prompt)
|
| 121 |
|
| 122 |
def repair_actor_response(
|
| 123 |
self,
|
| 124 |
session: TheaterSession,
|
| 125 |
-
|
| 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,
|
| 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 |
-
|
| 216 |
speaker: Actor,
|
| 217 |
prop: str | None,
|
| 218 |
) -> ActorResponse:
|
| 219 |
return ActorResponse(
|
| 220 |
-
line=_line_for_beat(session,
|
| 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 |
-
|
| 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,
|
| 244 |
except Exception as exc:
|
| 245 |
latency_ms = _elapsed_ms(start_time)
|
| 246 |
return _fallback_generation(
|
| 247 |
session=session,
|
| 248 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 25 |
-
speaker =
|
| 26 |
-
prop = session.latest_prop
|
| 27 |
if prop is not None:
|
| 28 |
speaker.held_prop = prop
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
"""
|