Commit ·
50cccdf
1
Parent(s): c3e33f1
feat: add agentic actor state
Browse files- app.py +79 -0
- puppet_theater/backends.py +102 -8
- puppet_theater/director.py +80 -0
- puppet_theater/models.py +39 -4
- puppet_theater/prompts.py +10 -0
- tests/test_director.py +36 -0
app.py
CHANGED
|
@@ -44,6 +44,7 @@ EMPTY_STAGE = """
|
|
| 44 |
"""
|
| 45 |
|
| 46 |
EMPTY_TRANSCRIPT = "No show yet. The transcript will appear here."
|
|
|
|
| 47 |
EMPTY_DIRECTOR_LOG = "No director notes yet."
|
| 48 |
EMPTY_TRACE = "No trace events yet."
|
| 49 |
EMPTY_BACKEND = (
|
|
@@ -1018,6 +1019,38 @@ body,
|
|
| 1018 |
border: none !important;
|
| 1019 |
box-shadow: none !important;
|
| 1020 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
.gradio-container .accordion {
|
| 1022 |
background: rgba(13, 6, 14, 0.72) !important;
|
| 1023 |
border: 1px solid rgba(246, 196, 83, 0.22) !important;
|
|
@@ -1612,6 +1645,31 @@ def render_transcript(session: TheaterSession | None) -> str:
|
|
| 1612 |
return "\n".join(transcript_lines)
|
| 1613 |
|
| 1614 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1615 |
def render_director_log(session: TheaterSession | None) -> str:
|
| 1616 |
if session is None:
|
| 1617 |
return EMPTY_DIRECTOR_LOG
|
|
@@ -1737,6 +1795,7 @@ def render_outputs(session: TheaterSession | None):
|
|
| 1737 |
return (
|
| 1738 |
render_stage(session),
|
| 1739 |
render_transcript(session),
|
|
|
|
| 1740 |
render_director_log(session),
|
| 1741 |
render_trace(session),
|
| 1742 |
write_trace_json_file(session),
|
|
@@ -1764,6 +1823,7 @@ def create_show(
|
|
| 1764 |
None,
|
| 1765 |
EMPTY_STAGE,
|
| 1766 |
"No premise yet. Add a premise to raise the curtain.",
|
|
|
|
| 1767 |
EMPTY_DIRECTOR_LOG,
|
| 1768 |
EMPTY_TRACE,
|
| 1769 |
None,
|
|
@@ -1802,6 +1862,7 @@ def reset_show():
|
|
| 1802 |
"",
|
| 1803 |
EMPTY_STAGE,
|
| 1804 |
EMPTY_TRANSCRIPT,
|
|
|
|
| 1805 |
EMPTY_DIRECTOR_LOG,
|
| 1806 |
EMPTY_TRACE,
|
| 1807 |
None,
|
|
@@ -1827,6 +1888,7 @@ def advance_one_beat(
|
|
| 1827 |
None,
|
| 1828 |
EMPTY_STAGE,
|
| 1829 |
"Create a show before running a beat.",
|
|
|
|
| 1830 |
EMPTY_DIRECTOR_LOG,
|
| 1831 |
EMPTY_TRACE,
|
| 1832 |
None,
|
|
@@ -1851,6 +1913,7 @@ def advance_full_act(
|
|
| 1851 |
None,
|
| 1852 |
EMPTY_STAGE,
|
| 1853 |
"Create a show before running the full act.",
|
|
|
|
| 1854 |
EMPTY_DIRECTOR_LOG,
|
| 1855 |
EMPTY_TRACE,
|
| 1856 |
None,
|
|
@@ -1915,6 +1978,7 @@ def throw_audience_prop(
|
|
| 1915 |
None,
|
| 1916 |
EMPTY_STAGE,
|
| 1917 |
"Create a show before throwing a prop.",
|
|
|
|
| 1918 |
EMPTY_DIRECTOR_LOG,
|
| 1919 |
EMPTY_TRACE,
|
| 1920 |
None,
|
|
@@ -1939,6 +2003,7 @@ def summon_audience_actor(
|
|
| 1939 |
None,
|
| 1940 |
EMPTY_STAGE,
|
| 1941 |
"Create a show before summoning an actor.",
|
|
|
|
| 1942 |
EMPTY_DIRECTOR_LOG,
|
| 1943 |
EMPTY_TRACE,
|
| 1944 |
None,
|
|
@@ -1962,6 +2027,7 @@ def request_audience_finale(
|
|
| 1962 |
None,
|
| 1963 |
EMPTY_STAGE,
|
| 1964 |
"Create a show before requesting a finale.",
|
|
|
|
| 1965 |
EMPTY_DIRECTOR_LOG,
|
| 1966 |
EMPTY_TRACE,
|
| 1967 |
None,
|
|
@@ -2101,6 +2167,12 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2101 |
interactive=False,
|
| 2102 |
elem_classes=["transcript-box", "no-field-label"],
|
| 2103 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2104 |
with gr.Accordion("Behind the Curtain", open=False):
|
| 2105 |
director_output = gr.Textbox(
|
| 2106 |
value=EMPTY_DIRECTOR_LOG,
|
|
@@ -2181,6 +2253,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2181 |
session_state,
|
| 2182 |
stage_output,
|
| 2183 |
transcript_output,
|
|
|
|
| 2184 |
director_output,
|
| 2185 |
trace_output,
|
| 2186 |
trace_download,
|
|
@@ -2200,6 +2273,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2200 |
session_state,
|
| 2201 |
stage_output,
|
| 2202 |
transcript_output,
|
|
|
|
| 2203 |
director_output,
|
| 2204 |
trace_output,
|
| 2205 |
trace_download,
|
|
@@ -2220,6 +2294,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2220 |
session_state,
|
| 2221 |
stage_output,
|
| 2222 |
transcript_output,
|
|
|
|
| 2223 |
director_output,
|
| 2224 |
trace_output,
|
| 2225 |
trace_download,
|
|
@@ -2240,6 +2315,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2240 |
session_state,
|
| 2241 |
stage_output,
|
| 2242 |
transcript_output,
|
|
|
|
| 2243 |
director_output,
|
| 2244 |
trace_output,
|
| 2245 |
trace_download,
|
|
@@ -2260,6 +2336,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2260 |
session_state,
|
| 2261 |
stage_output,
|
| 2262 |
transcript_output,
|
|
|
|
| 2263 |
director_output,
|
| 2264 |
trace_output,
|
| 2265 |
trace_download,
|
|
@@ -2279,6 +2356,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2279 |
session_state,
|
| 2280 |
stage_output,
|
| 2281 |
transcript_output,
|
|
|
|
| 2282 |
director_output,
|
| 2283 |
trace_output,
|
| 2284 |
trace_download,
|
|
@@ -2299,6 +2377,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 2299 |
actor_input,
|
| 2300 |
stage_output,
|
| 2301 |
transcript_output,
|
|
|
|
| 2302 |
director_output,
|
| 2303 |
trace_output,
|
| 2304 |
trace_download,
|
|
|
|
| 44 |
"""
|
| 45 |
|
| 46 |
EMPTY_TRANSCRIPT = "No show yet. The transcript will appear here."
|
| 47 |
+
EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
|
| 48 |
EMPTY_DIRECTOR_LOG = "No director notes yet."
|
| 49 |
EMPTY_TRACE = "No trace events yet."
|
| 50 |
EMPTY_BACKEND = (
|
|
|
|
| 1019 |
border: none !important;
|
| 1020 |
box-shadow: none !important;
|
| 1021 |
}
|
| 1022 |
+
.agent-state-grid {
|
| 1023 |
+
display: grid;
|
| 1024 |
+
gap: 0.55rem;
|
| 1025 |
+
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
| 1026 |
+
}
|
| 1027 |
+
.agent-state-card {
|
| 1028 |
+
background: rgba(10, 12, 23, 0.72);
|
| 1029 |
+
border: 1px solid rgba(246, 196, 83, 0.22);
|
| 1030 |
+
border-radius: 8px;
|
| 1031 |
+
box-sizing: border-box;
|
| 1032 |
+
padding: 0.62rem 0.68rem;
|
| 1033 |
+
}
|
| 1034 |
+
.agent-state-name {
|
| 1035 |
+
color: #ffd166;
|
| 1036 |
+
font-weight: 800;
|
| 1037 |
+
line-height: 1.2;
|
| 1038 |
+
margin-bottom: 0.28rem;
|
| 1039 |
+
}
|
| 1040 |
+
.agent-state-line {
|
| 1041 |
+
color: #f8efe4;
|
| 1042 |
+
font-size: 0.78rem;
|
| 1043 |
+
line-height: 1.35;
|
| 1044 |
+
overflow-wrap: anywhere;
|
| 1045 |
+
}
|
| 1046 |
+
.agent-state-line strong {
|
| 1047 |
+
color: #d8c6b6;
|
| 1048 |
+
}
|
| 1049 |
+
.agent-state-empty {
|
| 1050 |
+
color: #cbb7a1;
|
| 1051 |
+
font-size: 0.86rem;
|
| 1052 |
+
padding: 0.35rem 0;
|
| 1053 |
+
}
|
| 1054 |
.gradio-container .accordion {
|
| 1055 |
background: rgba(13, 6, 14, 0.72) !important;
|
| 1056 |
border: 1px solid rgba(246, 196, 83, 0.22) !important;
|
|
|
|
| 1645 |
return "\n".join(transcript_lines)
|
| 1646 |
|
| 1647 |
|
| 1648 |
+
def render_agent_state(session: TheaterSession | None) -> str:
|
| 1649 |
+
if session is None:
|
| 1650 |
+
return EMPTY_AGENT_STATE
|
| 1651 |
+
|
| 1652 |
+
cards: list[str] = []
|
| 1653 |
+
for actor in session.actors:
|
| 1654 |
+
held_props = actor.held_props or ([actor.held_prop] if actor.held_prop else [])
|
| 1655 |
+
held = ", ".join(held_props) if held_props else "none"
|
| 1656 |
+
recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet."
|
| 1657 |
+
current_goal = actor.current_goal or actor.goal
|
| 1658 |
+
cards.append(
|
| 1659 |
+
f"""
|
| 1660 |
+
<div class="agent-state-card">
|
| 1661 |
+
<div class="agent-state-name">{escape(actor.avatar)} {escape(actor.name)}</div>
|
| 1662 |
+
<div class="agent-state-line"><strong>Mood:</strong> {escape(actor.mood)}</div>
|
| 1663 |
+
<div class="agent-state-line"><strong>Goal:</strong> {escape(current_goal)}</div>
|
| 1664 |
+
<div class="agent-state-line"><strong>Props:</strong> {escape(held)}</div>
|
| 1665 |
+
<div class="agent-state-line"><strong>Secret:</strong> {escape(actor.secret_status)}</div>
|
| 1666 |
+
<div class="agent-state-line"><strong>Memory:</strong> {escape(recent_memory)}</div>
|
| 1667 |
+
</div>
|
| 1668 |
+
"""
|
| 1669 |
+
)
|
| 1670 |
+
return f'<div class="agent-state-grid">{"".join(cards)}</div>'
|
| 1671 |
+
|
| 1672 |
+
|
| 1673 |
def render_director_log(session: TheaterSession | None) -> str:
|
| 1674 |
if session is None:
|
| 1675 |
return EMPTY_DIRECTOR_LOG
|
|
|
|
| 1795 |
return (
|
| 1796 |
render_stage(session),
|
| 1797 |
render_transcript(session),
|
| 1798 |
+
render_agent_state(session),
|
| 1799 |
render_director_log(session),
|
| 1800 |
render_trace(session),
|
| 1801 |
write_trace_json_file(session),
|
|
|
|
| 1823 |
None,
|
| 1824 |
EMPTY_STAGE,
|
| 1825 |
"No premise yet. Add a premise to raise the curtain.",
|
| 1826 |
+
EMPTY_AGENT_STATE,
|
| 1827 |
EMPTY_DIRECTOR_LOG,
|
| 1828 |
EMPTY_TRACE,
|
| 1829 |
None,
|
|
|
|
| 1862 |
"",
|
| 1863 |
EMPTY_STAGE,
|
| 1864 |
EMPTY_TRANSCRIPT,
|
| 1865 |
+
EMPTY_AGENT_STATE,
|
| 1866 |
EMPTY_DIRECTOR_LOG,
|
| 1867 |
EMPTY_TRACE,
|
| 1868 |
None,
|
|
|
|
| 1888 |
None,
|
| 1889 |
EMPTY_STAGE,
|
| 1890 |
"Create a show before running a beat.",
|
| 1891 |
+
EMPTY_AGENT_STATE,
|
| 1892 |
EMPTY_DIRECTOR_LOG,
|
| 1893 |
EMPTY_TRACE,
|
| 1894 |
None,
|
|
|
|
| 1913 |
None,
|
| 1914 |
EMPTY_STAGE,
|
| 1915 |
"Create a show before running the full act.",
|
| 1916 |
+
EMPTY_AGENT_STATE,
|
| 1917 |
EMPTY_DIRECTOR_LOG,
|
| 1918 |
EMPTY_TRACE,
|
| 1919 |
None,
|
|
|
|
| 1978 |
None,
|
| 1979 |
EMPTY_STAGE,
|
| 1980 |
"Create a show before throwing a prop.",
|
| 1981 |
+
EMPTY_AGENT_STATE,
|
| 1982 |
EMPTY_DIRECTOR_LOG,
|
| 1983 |
EMPTY_TRACE,
|
| 1984 |
None,
|
|
|
|
| 2003 |
None,
|
| 2004 |
EMPTY_STAGE,
|
| 2005 |
"Create a show before summoning an actor.",
|
| 2006 |
+
EMPTY_AGENT_STATE,
|
| 2007 |
EMPTY_DIRECTOR_LOG,
|
| 2008 |
EMPTY_TRACE,
|
| 2009 |
None,
|
|
|
|
| 2027 |
None,
|
| 2028 |
EMPTY_STAGE,
|
| 2029 |
"Create a show before requesting a finale.",
|
| 2030 |
+
EMPTY_AGENT_STATE,
|
| 2031 |
EMPTY_DIRECTOR_LOG,
|
| 2032 |
EMPTY_TRACE,
|
| 2033 |
None,
|
|
|
|
| 2167 |
interactive=False,
|
| 2168 |
elem_classes=["transcript-box", "no-field-label"],
|
| 2169 |
)
|
| 2170 |
+
with gr.Accordion("Agent State", open=False):
|
| 2171 |
+
agent_state_output = gr.HTML(
|
| 2172 |
+
value=EMPTY_AGENT_STATE,
|
| 2173 |
+
label="Agent State",
|
| 2174 |
+
elem_classes=["no-field-label"],
|
| 2175 |
+
)
|
| 2176 |
with gr.Accordion("Behind the Curtain", open=False):
|
| 2177 |
director_output = gr.Textbox(
|
| 2178 |
value=EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2253 |
session_state,
|
| 2254 |
stage_output,
|
| 2255 |
transcript_output,
|
| 2256 |
+
agent_state_output,
|
| 2257 |
director_output,
|
| 2258 |
trace_output,
|
| 2259 |
trace_download,
|
|
|
|
| 2273 |
session_state,
|
| 2274 |
stage_output,
|
| 2275 |
transcript_output,
|
| 2276 |
+
agent_state_output,
|
| 2277 |
director_output,
|
| 2278 |
trace_output,
|
| 2279 |
trace_download,
|
|
|
|
| 2294 |
session_state,
|
| 2295 |
stage_output,
|
| 2296 |
transcript_output,
|
| 2297 |
+
agent_state_output,
|
| 2298 |
director_output,
|
| 2299 |
trace_output,
|
| 2300 |
trace_download,
|
|
|
|
| 2315 |
session_state,
|
| 2316 |
stage_output,
|
| 2317 |
transcript_output,
|
| 2318 |
+
agent_state_output,
|
| 2319 |
director_output,
|
| 2320 |
trace_output,
|
| 2321 |
trace_download,
|
|
|
|
| 2336 |
session_state,
|
| 2337 |
stage_output,
|
| 2338 |
transcript_output,
|
| 2339 |
+
agent_state_output,
|
| 2340 |
director_output,
|
| 2341 |
trace_output,
|
| 2342 |
trace_download,
|
|
|
|
| 2356 |
session_state,
|
| 2357 |
stage_output,
|
| 2358 |
transcript_output,
|
| 2359 |
+
agent_state_output,
|
| 2360 |
director_output,
|
| 2361 |
trace_output,
|
| 2362 |
trace_download,
|
|
|
|
| 2377 |
actor_input,
|
| 2378 |
stage_output,
|
| 2379 |
transcript_output,
|
| 2380 |
+
agent_state_output,
|
| 2381 |
director_output,
|
| 2382 |
trace_output,
|
| 2383 |
trace_download,
|
puppet_theater/backends.py
CHANGED
|
@@ -372,10 +372,12 @@ def deterministic_actor_response(
|
|
| 372 |
prop: str | None,
|
| 373 |
) -> ActorResponse:
|
| 374 |
return ActorResponse(
|
|
|
|
| 375 |
line=_line_for_beat(session, decision, speaker, prop),
|
| 376 |
emotion=_emotion_for_beat(decision.beat_type),
|
| 377 |
gesture=_gesture_for_beat(decision.beat_type),
|
| 378 |
stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
|
|
|
|
| 379 |
tool_request=None,
|
| 380 |
)
|
| 381 |
|
|
@@ -518,7 +520,7 @@ def warm_up_openbmb(
|
|
| 518 |
if zerogpu.USE_ZEROGPU:
|
| 519 |
backend._generate_text(
|
| 520 |
"Return only this JSON: "
|
| 521 |
-
'{"line":"Ready.","emotion":"ready","gesture":"wave","stage_effect":"spotlight","tool_request":null}'
|
| 522 |
)
|
| 523 |
else:
|
| 524 |
backend._load()
|
|
@@ -571,6 +573,8 @@ def build_actor_line_prompt(
|
|
| 571 |
recent_transcript = "\n".join(
|
| 572 |
f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
|
| 573 |
) or "No lines yet."
|
|
|
|
|
|
|
| 574 |
prompt = ACTOR_LINE_PROMPT.format(
|
| 575 |
show_title=session.show_title,
|
| 576 |
premise=session.premise,
|
|
@@ -578,6 +582,12 @@ def build_actor_line_prompt(
|
|
| 578 |
beat_type=decision.beat_type,
|
| 579 |
speaker_name=speaker.name,
|
| 580 |
speaker_goal=speaker.goal,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
speaker_style=speaker.speaking_style,
|
| 582 |
audience_action=session.latest_audience_action or "None",
|
| 583 |
latest_prop=prop or session.latest_prop or "None",
|
|
@@ -587,7 +597,7 @@ def build_actor_line_prompt(
|
|
| 587 |
)
|
| 588 |
return (
|
| 589 |
f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
|
| 590 |
-
"Keep the line under
|
| 591 |
)
|
| 592 |
|
| 593 |
|
|
@@ -621,6 +631,7 @@ def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tupl
|
|
| 621 |
if parsed is None:
|
| 622 |
return None, "invalid_schema"
|
| 623 |
|
|
|
|
| 624 |
try:
|
| 625 |
response = ActorResponse.model_validate(parsed)
|
| 626 |
except ValidationError:
|
|
@@ -629,12 +640,13 @@ def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tupl
|
|
| 629 |
if not response.line.strip():
|
| 630 |
return None, "invalid_empty_line"
|
| 631 |
|
| 632 |
-
if len(response.line) > MAX_ACTOR_LINE_CHARS:
|
| 633 |
-
capped_line = response.line
|
| 634 |
response = response.model_copy(update={"line": capped_line})
|
| 635 |
-
|
|
|
|
| 636 |
|
| 637 |
-
return response,
|
| 638 |
|
| 639 |
|
| 640 |
def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
|
|
@@ -646,6 +658,14 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
|
|
| 646 |
text = raw_output.strip()
|
| 647 |
if not text:
|
| 648 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
try:
|
| 650 |
decoded = json.loads(text)
|
| 651 |
except json.JSONDecodeError:
|
|
@@ -654,6 +674,47 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
|
|
| 654 |
return None
|
| 655 |
|
| 656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
def _elapsed_ms(start_time: float) -> int:
|
| 658 |
return round((time.perf_counter() - start_time) * 1000)
|
| 659 |
|
|
@@ -720,10 +781,11 @@ def _line_for_beat(
|
|
| 720 |
prop: str | None,
|
| 721 |
) -> str:
|
| 722 |
if prop is not None:
|
| 723 |
-
return f"
|
| 724 |
beat_type = decision.beat_type
|
| 725 |
if beat_type == "setup":
|
| 726 |
-
|
|
|
|
| 727 |
if beat_type == "denial_or_contradiction":
|
| 728 |
return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
|
| 729 |
if beat_type == "evidence_or_prop":
|
|
@@ -737,6 +799,38 @@ def _line_for_beat(
|
|
| 737 |
return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
|
| 738 |
|
| 739 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
def _emotion_for_beat(beat_type: str) -> str:
|
| 741 |
return {
|
| 742 |
"setup": "curious",
|
|
|
|
| 372 |
prop: str | None,
|
| 373 |
) -> ActorResponse:
|
| 374 |
return ActorResponse(
|
| 375 |
+
intent=_intent_for_beat(decision.beat_type, prop),
|
| 376 |
line=_line_for_beat(session, decision, speaker, prop),
|
| 377 |
emotion=_emotion_for_beat(decision.beat_type),
|
| 378 |
gesture=_gesture_for_beat(decision.beat_type),
|
| 379 |
stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
|
| 380 |
+
memory_update=_memory_for_beat(session, decision, speaker, prop),
|
| 381 |
tool_request=None,
|
| 382 |
)
|
| 383 |
|
|
|
|
| 520 |
if zerogpu.USE_ZEROGPU:
|
| 521 |
backend._generate_text(
|
| 522 |
"Return only this JSON: "
|
| 523 |
+
'{"intent":"Confirm readiness.","line":"Ready.","emotion":"ready","gesture":"wave","stage_effect":"spotlight","memory_update":"Ready for the cue.","tool_request":null}'
|
| 524 |
)
|
| 525 |
else:
|
| 526 |
backend._load()
|
|
|
|
| 573 |
recent_transcript = "\n".join(
|
| 574 |
f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
|
| 575 |
) or "No lines yet."
|
| 576 |
+
recent_memory = "; ".join(speaker.recent_memory[-3:]) or "None"
|
| 577 |
+
held_props = ", ".join(speaker.held_props or ([speaker.held_prop] if speaker.held_prop else [])) or "none"
|
| 578 |
prompt = ACTOR_LINE_PROMPT.format(
|
| 579 |
show_title=session.show_title,
|
| 580 |
premise=session.premise,
|
|
|
|
| 582 |
beat_type=decision.beat_type,
|
| 583 |
speaker_name=speaker.name,
|
| 584 |
speaker_goal=speaker.goal,
|
| 585 |
+
speaker_mood=speaker.mood,
|
| 586 |
+
speaker_current_goal=speaker.current_goal or speaker.goal,
|
| 587 |
+
speaker_goal_progress=speaker.goal_progress,
|
| 588 |
+
speaker_held_props=held_props,
|
| 589 |
+
speaker_secret_status=speaker.secret_status,
|
| 590 |
+
speaker_recent_memory=recent_memory,
|
| 591 |
speaker_style=speaker.speaking_style,
|
| 592 |
audience_action=session.latest_audience_action or "None",
|
| 593 |
latest_prop=prop or session.latest_prop or "None",
|
|
|
|
| 597 |
)
|
| 598 |
return (
|
| 599 |
f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
|
| 600 |
+
"Keep the line under 25 words. Keep intent and memory_update short. Return JSON only."
|
| 601 |
)
|
| 602 |
|
| 603 |
|
|
|
|
| 631 |
if parsed is None:
|
| 632 |
return None, "invalid_schema"
|
| 633 |
|
| 634 |
+
parsed, validation_status = _normalize_actor_payload(parsed)
|
| 635 |
try:
|
| 636 |
response = ActorResponse.model_validate(parsed)
|
| 637 |
except ValidationError:
|
|
|
|
| 640 |
if not response.line.strip():
|
| 641 |
return None, "invalid_empty_line"
|
| 642 |
|
| 643 |
+
if len(response.line) > MAX_ACTOR_LINE_CHARS or len(response.line.split()) > 25:
|
| 644 |
+
capped_line = _cap_words(response.line, 25)
|
| 645 |
response = response.model_copy(update={"line": capped_line})
|
| 646 |
+
capped_status = "valid_line_capped"
|
| 647 |
+
return response, f"{validation_status};{capped_status}" if validation_status != "valid" else capped_status
|
| 648 |
|
| 649 |
+
return response, validation_status
|
| 650 |
|
| 651 |
|
| 652 |
def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
|
|
|
|
| 658 |
text = raw_output.strip()
|
| 659 |
if not text:
|
| 660 |
return None
|
| 661 |
+
if text.startswith("```"):
|
| 662 |
+
text = text.strip("`")
|
| 663 |
+
if "\n" in text:
|
| 664 |
+
text = text.split("\n", maxsplit=1)[1]
|
| 665 |
+
start = text.find("{")
|
| 666 |
+
end = text.rfind("}")
|
| 667 |
+
if start != -1 and end != -1 and end > start:
|
| 668 |
+
text = text[start : end + 1]
|
| 669 |
try:
|
| 670 |
decoded = json.loads(text)
|
| 671 |
except json.JSONDecodeError:
|
|
|
|
| 674 |
return None
|
| 675 |
|
| 676 |
|
| 677 |
+
def _normalize_actor_payload(parsed: ActorResponse | dict[str, Any]) -> tuple[ActorResponse | dict[str, Any], str]:
|
| 678 |
+
if isinstance(parsed, ActorResponse):
|
| 679 |
+
return parsed, "valid"
|
| 680 |
+
|
| 681 |
+
normalized = dict(parsed)
|
| 682 |
+
notes: list[str] = []
|
| 683 |
+
if "intent" not in normalized:
|
| 684 |
+
normalized["intent"] = "Respond to the Director's cue."
|
| 685 |
+
notes.append("compat_intent_defaulted")
|
| 686 |
+
if "memory_update" not in normalized:
|
| 687 |
+
normalized["memory_update"] = ""
|
| 688 |
+
notes.append("compat_memory_defaulted")
|
| 689 |
+
if "tool_request" not in normalized:
|
| 690 |
+
normalized["tool_request"] = None
|
| 691 |
+
notes.append("compat_tool_defaulted")
|
| 692 |
+
|
| 693 |
+
if isinstance(normalized.get("line"), str):
|
| 694 |
+
line = " ".join(str(normalized["line"]).strip().split())
|
| 695 |
+
if len(line) > MAX_ACTOR_LINE_CHARS or len(line.split()) > 25:
|
| 696 |
+
normalized["line"] = _cap_words(line, 25)
|
| 697 |
+
notes.append("line_capped")
|
| 698 |
+
if isinstance(normalized.get("intent"), str) and len(str(normalized["intent"])) > 90:
|
| 699 |
+
normalized["intent"] = str(normalized["intent"])[:87].rstrip() + "..."
|
| 700 |
+
notes.append("intent_capped")
|
| 701 |
+
if isinstance(normalized.get("memory_update"), str) and len(str(normalized["memory_update"])) > 140:
|
| 702 |
+
normalized["memory_update"] = str(normalized["memory_update"])[:137].rstrip() + "..."
|
| 703 |
+
notes.append("memory_capped")
|
| 704 |
+
|
| 705 |
+
return normalized, ";".join(notes) if notes else "valid"
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def _cap_words(value: str, max_words: int) -> str:
|
| 709 |
+
words = " ".join(value.strip().split()).split()
|
| 710 |
+
capped = " ".join(words[:max_words]).rstrip(" ,;:")
|
| 711 |
+
if len(capped) > MAX_ACTOR_LINE_CHARS:
|
| 712 |
+
capped = capped[: MAX_ACTOR_LINE_CHARS - 3].rstrip()
|
| 713 |
+
if len(words) > max_words or len(value) > MAX_ACTOR_LINE_CHARS:
|
| 714 |
+
return f"{capped}..."
|
| 715 |
+
return capped
|
| 716 |
+
|
| 717 |
+
|
| 718 |
def _elapsed_ms(start_time: float) -> int:
|
| 719 |
return round((time.perf_counter() - start_time) * 1000)
|
| 720 |
|
|
|
|
| 781 |
prop: str | None,
|
| 782 |
) -> str:
|
| 783 |
if prop is not None:
|
| 784 |
+
return f"This {prop} is evidence, comfort, and possibly our smallest witness."
|
| 785 |
beat_type = decision.beat_type
|
| 786 |
if beat_type == "setup":
|
| 787 |
+
premise = _cap_words(session.premise, 12).rstrip(".")
|
| 788 |
+
return f"I see it clearly: {premise}, and somehow I am in charge."
|
| 789 |
if beat_type == "denial_or_contradiction":
|
| 790 |
return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
|
| 791 |
if beat_type == "evidence_or_prop":
|
|
|
|
| 799 |
return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
|
| 800 |
|
| 801 |
|
| 802 |
+
def _intent_for_beat(beat_type: str, prop: str | None) -> str:
|
| 803 |
+
if prop is not None:
|
| 804 |
+
return f"Make the {prop} matter."
|
| 805 |
+
return {
|
| 806 |
+
"setup": "Establish the scene.",
|
| 807 |
+
"denial_or_contradiction": "Challenge the premise.",
|
| 808 |
+
"evidence_or_prop": "Turn a clue into momentum.",
|
| 809 |
+
"secret_reveal": "Reveal pressure without derailing.",
|
| 810 |
+
"chaos_or_intervention": "React and escalate briefly.",
|
| 811 |
+
"finale": "Close the show cleanly.",
|
| 812 |
+
}[beat_type]
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def _memory_for_beat(
|
| 816 |
+
session: TheaterSession,
|
| 817 |
+
decision: DirectorDecision,
|
| 818 |
+
speaker: Actor,
|
| 819 |
+
prop: str | None,
|
| 820 |
+
) -> str:
|
| 821 |
+
if prop is not None:
|
| 822 |
+
return f"Used {prop} as important evidence."
|
| 823 |
+
if decision.beat_type == "secret_reveal":
|
| 824 |
+
if decision.reveal_secret:
|
| 825 |
+
return "Shared a secret with the audience."
|
| 826 |
+
return "Almost revealed a secret under pressure."
|
| 827 |
+
if decision.beat_type == "finale":
|
| 828 |
+
return "Reached the curtain call."
|
| 829 |
+
if session.latest_audience_action:
|
| 830 |
+
return "Reacted to the audience interruption."
|
| 831 |
+
return f"Advanced the {decision.beat_type.replace('_', ' ')} beat."
|
| 832 |
+
|
| 833 |
+
|
| 834 |
def _emotion_for_beat(beat_type: str) -> str:
|
| 835 |
return {
|
| 836 |
"setup": "curious",
|
puppet_theater/director.py
CHANGED
|
@@ -459,13 +459,16 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 459 |
response = backend_generation.response
|
| 460 |
beat = Beat(
|
| 461 |
speaker=speaker.name,
|
|
|
|
| 462 |
line=response.line,
|
| 463 |
emotion=response.emotion,
|
| 464 |
gesture=response.gesture,
|
| 465 |
stage_effect=response.stage_effect or decision.stage_effect,
|
|
|
|
| 466 |
tool_request=response.tool_request,
|
| 467 |
)
|
| 468 |
session.transcript.append(beat)
|
|
|
|
| 469 |
|
| 470 |
if prop is not None:
|
| 471 |
session.latest_prop = None
|
|
@@ -509,6 +512,35 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 509 |
fallback_reason=backend_generation.error,
|
| 510 |
load_status=backend_generation.load_status,
|
| 511 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
if backend_generation.error:
|
| 513 |
add_trace_event(
|
| 514 |
session,
|
|
@@ -553,6 +585,40 @@ def _actor_by_name(session: TheaterSession, actor_name: str) -> Actor:
|
|
| 553 |
return session.actors[session.beat_index % len(session.actors)]
|
| 554 |
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
def build_director_prompt(session: TheaterSession) -> str:
|
| 557 |
actor_profiles = "\n".join(
|
| 558 |
"- "
|
|
@@ -691,6 +757,20 @@ def _trace_text(value: str) -> str:
|
|
| 691 |
return " ".join(value.split()).replace(":", "-")[:140]
|
| 692 |
|
| 693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
def _record_director_backend_status(backend: object, generation: DirectorGeneration) -> None:
|
| 695 |
if hasattr(backend, "latest_latency_ms"):
|
| 696 |
backend.latest_latency_ms = generation.latency_ms
|
|
|
|
| 459 |
response = backend_generation.response
|
| 460 |
beat = Beat(
|
| 461 |
speaker=speaker.name,
|
| 462 |
+
intent=response.intent,
|
| 463 |
line=response.line,
|
| 464 |
emotion=response.emotion,
|
| 465 |
gesture=response.gesture,
|
| 466 |
stage_effect=response.stage_effect or decision.stage_effect,
|
| 467 |
+
memory_update=response.memory_update,
|
| 468 |
tool_request=response.tool_request,
|
| 469 |
)
|
| 470 |
session.transcript.append(beat)
|
| 471 |
+
state_update = apply_actor_state_update(session, speaker, response, decision, prop)
|
| 472 |
|
| 473 |
if prop is not None:
|
| 474 |
session.latest_prop = None
|
|
|
|
| 512 |
fallback_reason=backend_generation.error,
|
| 513 |
load_status=backend_generation.load_status,
|
| 514 |
)
|
| 515 |
+
add_trace_event(
|
| 516 |
+
session,
|
| 517 |
+
"actor_intent",
|
| 518 |
+
beat_index=session.beat_index,
|
| 519 |
+
speaker=speaker.name,
|
| 520 |
+
intent=_public_actor_text(response.intent, speaker),
|
| 521 |
+
validation_status=backend_generation.validation_status,
|
| 522 |
+
fallback_used=backend_generation.fallback_used,
|
| 523 |
+
)
|
| 524 |
+
if state_update["memory_update"]:
|
| 525 |
+
add_trace_event(
|
| 526 |
+
session,
|
| 527 |
+
"actor_memory_update",
|
| 528 |
+
beat_index=session.beat_index,
|
| 529 |
+
speaker=speaker.name,
|
| 530 |
+
memory_update=state_update["memory_update"],
|
| 531 |
+
)
|
| 532 |
+
add_trace_event(
|
| 533 |
+
session,
|
| 534 |
+
"actor_state_update",
|
| 535 |
+
beat_index=session.beat_index,
|
| 536 |
+
speaker=speaker.name,
|
| 537 |
+
mood=speaker.mood,
|
| 538 |
+
current_goal=_public_actor_text(speaker.current_goal or "", speaker),
|
| 539 |
+
goal_progress=_public_actor_text(speaker.goal_progress, speaker),
|
| 540 |
+
held_props=list(speaker.held_props),
|
| 541 |
+
secret_status=speaker.secret_status,
|
| 542 |
+
memory_count=len(speaker.recent_memory),
|
| 543 |
+
)
|
| 544 |
if backend_generation.error:
|
| 545 |
add_trace_event(
|
| 546 |
session,
|
|
|
|
| 585 |
return session.actors[session.beat_index % len(session.actors)]
|
| 586 |
|
| 587 |
|
| 588 |
+
def apply_actor_state_update(
|
| 589 |
+
session: TheaterSession,
|
| 590 |
+
speaker: Actor,
|
| 591 |
+
response: object,
|
| 592 |
+
decision: DirectorDecision,
|
| 593 |
+
prop: str | None,
|
| 594 |
+
) -> dict[str, str]:
|
| 595 |
+
intent = _short_public_text(getattr(response, "intent", "") or "Respond to the Director's cue.", speaker, 90)
|
| 596 |
+
memory_update = _short_public_text(getattr(response, "memory_update", "") or "", speaker, 140)
|
| 597 |
+
emotion = _short_public_text(getattr(response, "emotion", "") or "focused", speaker, 40)
|
| 598 |
+
|
| 599 |
+
speaker.mood = emotion
|
| 600 |
+
speaker.current_goal = intent
|
| 601 |
+
speaker.goal_progress = f"Beat {session.beat_index}: {intent}"
|
| 602 |
+
|
| 603 |
+
if prop is not None and prop not in speaker.held_props:
|
| 604 |
+
speaker.held_props.append(prop)
|
| 605 |
+
if prop is not None:
|
| 606 |
+
speaker.held_prop = prop
|
| 607 |
+
|
| 608 |
+
if decision.reveal_secret:
|
| 609 |
+
speaker.secret_status = "revealed"
|
| 610 |
+
elif decision.beat_type == "secret_reveal" and speaker.secret_status == "hidden":
|
| 611 |
+
speaker.secret_status = "hinted"
|
| 612 |
+
elif decision.beat_type == "finale" and speaker.secret_status in {"hinted", "revealed"}:
|
| 613 |
+
speaker.secret_status = "resolved"
|
| 614 |
+
|
| 615 |
+
if memory_update:
|
| 616 |
+
speaker.recent_memory.append(memory_update)
|
| 617 |
+
speaker.recent_memory = speaker.recent_memory[-4:]
|
| 618 |
+
|
| 619 |
+
return {"intent": intent, "memory_update": memory_update, "mood": emotion}
|
| 620 |
+
|
| 621 |
+
|
| 622 |
def build_director_prompt(session: TheaterSession) -> str:
|
| 623 |
actor_profiles = "\n".join(
|
| 624 |
"- "
|
|
|
|
| 757 |
return " ".join(value.split()).replace(":", "-")[:140]
|
| 758 |
|
| 759 |
|
| 760 |
+
def _public_actor_text(value: str, actor: Actor) -> str:
|
| 761 |
+
text = " ".join(value.split())
|
| 762 |
+
if actor.secret and actor.secret_status not in {"revealed", "resolved"}:
|
| 763 |
+
text = text.replace(actor.secret, "[redacted secret]")
|
| 764 |
+
return text[:140]
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def _short_public_text(value: str, actor: Actor, max_chars: int) -> str:
|
| 768 |
+
text = _public_actor_text(value, actor)
|
| 769 |
+
if len(text) <= max_chars:
|
| 770 |
+
return text
|
| 771 |
+
return text[: max_chars - 3].rstrip() + "..."
|
| 772 |
+
|
| 773 |
+
|
| 774 |
def _record_director_backend_status(backend: object, generation: DirectorGeneration) -> None:
|
| 775 |
if hasattr(backend, "latest_latency_ms"):
|
| 776 |
backend.latest_latency_ms = generation.latency_ms
|
puppet_theater/models.py
CHANGED
|
@@ -4,7 +4,7 @@ from uuid import uuid4
|
|
| 4 |
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
-
from pydantic import BaseModel, Field, field_validator
|
| 8 |
|
| 9 |
|
| 10 |
BeatType = Literal[
|
|
@@ -18,20 +18,47 @@ BeatType = Literal[
|
|
| 18 |
|
| 19 |
|
| 20 |
class ActorResponse(BaseModel):
|
|
|
|
| 21 |
line: str = Field(description="Short, stage-ready puppet dialogue.")
|
| 22 |
emotion: str
|
| 23 |
gesture: str
|
| 24 |
stage_effect: str
|
|
|
|
| 25 |
tool_request: str | None = None
|
| 26 |
|
| 27 |
-
@field_validator("line", "emotion", "gesture", "stage_effect")
|
| 28 |
@classmethod
|
| 29 |
-
def require_text(cls, value: str) -> str:
|
| 30 |
cleaned = " ".join(value.strip().split())
|
| 31 |
-
if not cleaned:
|
| 32 |
raise ValueError("field must not be empty")
|
| 33 |
return cleaned
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
@field_validator("tool_request")
|
| 36 |
@classmethod
|
| 37 |
def clean_optional_text(cls, value: str | None) -> str | None:
|
|
@@ -76,15 +103,23 @@ class Actor:
|
|
| 76 |
speaking_style: str
|
| 77 |
tools: list[str] = field(default_factory=list)
|
| 78 |
held_prop: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
@dataclass
|
| 82 |
class Beat:
|
| 83 |
speaker: str
|
|
|
|
| 84 |
line: str
|
| 85 |
emotion: str
|
| 86 |
gesture: str
|
| 87 |
stage_effect: str
|
|
|
|
| 88 |
tool_request: str | None = None
|
| 89 |
|
| 90 |
|
|
|
|
| 4 |
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
+
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
| 8 |
|
| 9 |
|
| 10 |
BeatType = Literal[
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class ActorResponse(BaseModel):
|
| 21 |
+
intent: str = Field(default="Keep the scene moving.", description="Short visible actor intention.")
|
| 22 |
line: str = Field(description="Short, stage-ready puppet dialogue.")
|
| 23 |
emotion: str
|
| 24 |
gesture: str
|
| 25 |
stage_effect: str
|
| 26 |
+
memory_update: str = Field(default="", description="Short visible memory note from this beat.")
|
| 27 |
tool_request: str | None = None
|
| 28 |
|
| 29 |
+
@field_validator("intent", "line", "emotion", "gesture", "stage_effect", "memory_update")
|
| 30 |
@classmethod
|
| 31 |
+
def require_text(cls, value: str, info: ValidationInfo) -> str:
|
| 32 |
cleaned = " ".join(value.strip().split())
|
| 33 |
+
if not cleaned and info.field_name != "memory_update":
|
| 34 |
raise ValueError("field must not be empty")
|
| 35 |
return cleaned
|
| 36 |
|
| 37 |
+
@field_validator("intent")
|
| 38 |
+
@classmethod
|
| 39 |
+
def keep_intent_short(cls, value: str) -> str:
|
| 40 |
+
if len(value) > 90:
|
| 41 |
+
raise ValueError("intent must be 90 characters or fewer")
|
| 42 |
+
return value
|
| 43 |
+
|
| 44 |
+
@field_validator("line")
|
| 45 |
+
@classmethod
|
| 46 |
+
def keep_line_short(cls, value: str) -> str:
|
| 47 |
+
if not value:
|
| 48 |
+
raise ValueError("line must not be empty")
|
| 49 |
+
if len(value.split()) > 25:
|
| 50 |
+
raise ValueError("line must be 25 words or fewer")
|
| 51 |
+
if len(value) > 220:
|
| 52 |
+
raise ValueError("line must be 220 characters or fewer")
|
| 53 |
+
return value
|
| 54 |
+
|
| 55 |
+
@field_validator("memory_update")
|
| 56 |
+
@classmethod
|
| 57 |
+
def keep_memory_short(cls, value: str) -> str:
|
| 58 |
+
if len(value) > 140:
|
| 59 |
+
raise ValueError("memory_update must be 140 characters or fewer")
|
| 60 |
+
return value
|
| 61 |
+
|
| 62 |
@field_validator("tool_request")
|
| 63 |
@classmethod
|
| 64 |
def clean_optional_text(cls, value: str | None) -> str | None:
|
|
|
|
| 103 |
speaking_style: str
|
| 104 |
tools: list[str] = field(default_factory=list)
|
| 105 |
held_prop: str | None = None
|
| 106 |
+
mood: str = "ready"
|
| 107 |
+
current_goal: str | None = None
|
| 108 |
+
goal_progress: str = "Waiting for the curtain."
|
| 109 |
+
held_props: list[str] = field(default_factory=list)
|
| 110 |
+
secret_status: Literal["hidden", "hinted", "revealed", "resolved"] = "hidden"
|
| 111 |
+
recent_memory: list[str] = field(default_factory=list)
|
| 112 |
|
| 113 |
|
| 114 |
@dataclass
|
| 115 |
class Beat:
|
| 116 |
speaker: str
|
| 117 |
+
intent: str
|
| 118 |
line: str
|
| 119 |
emotion: str
|
| 120 |
gesture: str
|
| 121 |
stage_effect: str
|
| 122 |
+
memory_update: str = ""
|
| 123 |
tool_request: str | None = None
|
| 124 |
|
| 125 |
|
puppet_theater/prompts.py
CHANGED
|
@@ -1,18 +1,28 @@
|
|
| 1 |
ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
|
| 2 |
|
| 3 |
Return only JSON with these fields:
|
|
|
|
| 4 |
- line: a short, stage-ready spoken line
|
| 5 |
- emotion: one concise emotion label
|
| 6 |
- gesture: one concise stage gesture label
|
| 7 |
- stage_effect: one concise stage effect label
|
|
|
|
| 8 |
- tool_request: null or one concise theatrical tool request
|
| 9 |
|
|
|
|
|
|
|
| 10 |
Show title: {show_title}
|
| 11 |
Premise: {premise}
|
| 12 |
Setting: {setting}
|
| 13 |
Beat type: {beat_type}
|
| 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}
|
|
|
|
| 1 |
ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
|
| 2 |
|
| 3 |
Return only JSON with these fields:
|
| 4 |
+
- intent: one short visible reason for the actor's next move
|
| 5 |
- line: a short, stage-ready spoken line
|
| 6 |
- emotion: one concise emotion label
|
| 7 |
- gesture: one concise stage gesture label
|
| 8 |
- stage_effect: one concise stage effect label
|
| 9 |
+
- memory_update: one short visible note the actor should remember, or empty string
|
| 10 |
- tool_request: null or one concise theatrical tool request
|
| 11 |
|
| 12 |
+
Keep line under 25 words. Return JSON only.
|
| 13 |
+
|
| 14 |
Show title: {show_title}
|
| 15 |
Premise: {premise}
|
| 16 |
Setting: {setting}
|
| 17 |
Beat type: {beat_type}
|
| 18 |
Speaker: {speaker_name}
|
| 19 |
Speaker goal: {speaker_goal}
|
| 20 |
+
Speaker mood: {speaker_mood}
|
| 21 |
+
Speaker current goal: {speaker_current_goal}
|
| 22 |
+
Speaker goal progress: {speaker_goal_progress}
|
| 23 |
+
Speaker held props: {speaker_held_props}
|
| 24 |
+
Speaker secret status: {speaker_secret_status}
|
| 25 |
+
Speaker recent memory: {speaker_recent_memory}
|
| 26 |
Speaker style: {speaker_style}
|
| 27 |
Director instruction: {director_instruction}
|
| 28 |
Reveal secret this beat: {reveal_secret}
|
tests/test_director.py
CHANGED
|
@@ -5,10 +5,12 @@ from puppet_theater.director import (
|
|
| 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 |
|
|
@@ -77,6 +79,40 @@ def test_invalid_llm_speaker_is_rejected() -> None:
|
|
| 77 |
assert status == "invalid_speaker"
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def test_fallback_used_when_hf_api_director_cannot_run(monkeypatch) -> None:
|
| 81 |
session = create_show_from_premise("A kitchen council debates a heroic toaster", director_mode="hf_api")
|
| 82 |
monkeypatch.setattr(director_module, "get_backend", lambda *args, **kwargs: object())
|
|
|
|
| 5 |
build_director_prompt,
|
| 6 |
choose_director_decision,
|
| 7 |
parse_director_decision,
|
| 8 |
+
run_one_beat,
|
| 9 |
run_full_act,
|
| 10 |
story_phase,
|
| 11 |
)
|
| 12 |
from puppet_theater.models import DirectorDecision
|
| 13 |
+
from puppet_theater.backends import parse_actor_output
|
| 14 |
from puppet_theater.session import create_show_from_premise, resolve_show_length
|
| 15 |
|
| 16 |
|
|
|
|
| 79 |
assert status == "invalid_speaker"
|
| 80 |
|
| 81 |
|
| 82 |
+
def test_old_actor_output_defaults_agentic_fields() -> None:
|
| 83 |
+
response, status = parse_actor_output(
|
| 84 |
+
{
|
| 85 |
+
"line": "The moon denies everything with suspicious confidence.",
|
| 86 |
+
"emotion": "suspicious",
|
| 87 |
+
"gesture": "point_accusingly",
|
| 88 |
+
"stage_effect": "spotlight",
|
| 89 |
+
"tool_request": None,
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
assert response is not None
|
| 94 |
+
assert response.intent == "Respond to the Director's cue."
|
| 95 |
+
assert response.memory_update == ""
|
| 96 |
+
assert "compat_intent_defaulted" in status
|
| 97 |
+
assert "compat_memory_defaulted" in status
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_actor_state_updates_after_beat() -> None:
|
| 101 |
+
session = create_show_from_premise("A moon detective interrogates a suspicious toaster")
|
| 102 |
+
actor = session.actors[0]
|
| 103 |
+
|
| 104 |
+
run_one_beat(session)
|
| 105 |
+
|
| 106 |
+
assert session.transcript[-1].intent
|
| 107 |
+
assert actor.mood == session.transcript[-1].emotion
|
| 108 |
+
assert actor.current_goal == session.transcript[-1].intent
|
| 109 |
+
assert actor.recent_memory
|
| 110 |
+
event_types = {event["type"] for event in session.trace_events if isinstance(event, dict)}
|
| 111 |
+
assert "actor_intent" in event_types
|
| 112 |
+
assert "actor_memory_update" in event_types
|
| 113 |
+
assert "actor_state_update" in event_types
|
| 114 |
+
|
| 115 |
+
|
| 116 |
def test_fallback_used_when_hf_api_director_cannot_run(monkeypatch) -> None:
|
| 117 |
session = create_show_from_premise("A kitchen council debates a heroic toaster", director_mode="hf_api")
|
| 118 |
monkeypatch.setattr(director_module, "get_backend", lambda *args, **kwargs: object())
|