Commit ·
aa14d1f
1
Parent(s): 780e79f
feat: add agent trace export
Browse files- README.md +6 -2
- app.py +31 -2
- puppet_theater/director.py +25 -0
- puppet_theater/models.py +1 -0
- puppet_theater/session.py +1 -0
- puppet_theater/trace.py +222 -9
README.md
CHANGED
|
@@ -65,6 +65,10 @@ Local deterministic mode and app launch work without `HF_TOKEN`.
|
|
| 65 |
|
| 66 |
## Trace export
|
| 67 |
|
| 68 |
-
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
## Trace export
|
| 67 |
|
| 68 |
+
Every show keeps a lightweight agent trace in memory for the current Gradio session. The collapsed **Behind the Curtain** panel shows a compact human-readable trace summary with a copy button, plus the Director log. The collapsed **Trace / Debug** panel shows the full JSON preview.
|
| 69 |
|
| 70 |
+
After creating a show, use **Download Trace JSON** to save `ai-puppet-theater-trace-<session_id>.json`. The file contains a stable envelope with `app_name`, `trace_version`, `session_id`, `created_at`, premise, title, setting, `show_length_mode`, min/target/max beats, actor backend, Director mode, model id when used, and normalized events.
|
| 71 |
+
|
| 72 |
+
Trace events use a consistent public shape with `event_type`, `timestamp` or `step`, beat/story phase metadata, speaker, Director decisions, actor intent, actor memory updates, tool requests and results, audience actions, backend/model metadata, latency, validation status, fallback usage, and short fallback reasons where available.
|
| 73 |
+
|
| 74 |
+
Traces are sanitized so they are safe to publish later as a dataset: they do not include Hugging Face tokens, environment variable values, private local paths, raw tracebacks, hidden reasoning, or private secrets. Errors are reduced to short summaries and model failures are recorded as fallback events.
|
app.py
CHANGED
|
@@ -27,7 +27,7 @@ from puppet_theater import (
|
|
| 27 |
throw_prop,
|
| 28 |
warm_up_openbmb,
|
| 29 |
)
|
| 30 |
-
from puppet_theater.trace import add_trace_event, render_trace_json, write_trace_json_file
|
| 31 |
|
| 32 |
|
| 33 |
EMPTY_STAGE = """
|
|
@@ -53,6 +53,7 @@ EMPTY_TRANSCRIPT = '<div class="conversation-empty">No show yet. The transcript
|
|
| 53 |
EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
|
| 54 |
EMPTY_DIRECTOR_LOG = "No director notes yet."
|
| 55 |
EMPTY_TRACE = "No trace events yet."
|
|
|
|
| 56 |
EMPTY_BACKEND = (
|
| 57 |
"Active backend: deterministic\n"
|
| 58 |
"Director mode: deterministic\n"
|
|
@@ -2453,6 +2454,10 @@ def render_trace(session: TheaterSession | None) -> str:
|
|
| 2453 |
return render_trace_json(session)
|
| 2454 |
|
| 2455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2456 |
def normalize_backend_name(backend_name: str | None) -> str:
|
| 2457 |
return backend_name if backend_name in BACKEND_VALUES else "deterministic"
|
| 2458 |
|
|
@@ -2574,6 +2579,7 @@ def render_outputs(session: TheaterSession | None, voice_mode: str | None = DEFA
|
|
| 2574 |
f'<div class="voice-status">{escape(tts_status)}</div>',
|
| 2575 |
render_agent_state(session),
|
| 2576 |
render_director_log(session),
|
|
|
|
| 2577 |
render_trace(session),
|
| 2578 |
write_trace_json_file(session),
|
| 2579 |
render_backend_settings(session),
|
|
@@ -2606,6 +2612,7 @@ def create_show(
|
|
| 2606 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2607 |
EMPTY_AGENT_STATE,
|
| 2608 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2609 |
EMPTY_TRACE,
|
| 2610 |
None,
|
| 2611 |
render_backend_settings(
|
|
@@ -2648,6 +2655,7 @@ def reset_show():
|
|
| 2648 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2649 |
EMPTY_AGENT_STATE,
|
| 2650 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2651 |
EMPTY_TRACE,
|
| 2652 |
None,
|
| 2653 |
DEFAULT_SHOW_LENGTH,
|
|
@@ -2680,6 +2688,7 @@ def advance_one_beat(
|
|
| 2680 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2681 |
EMPTY_AGENT_STATE,
|
| 2682 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2683 |
EMPTY_TRACE,
|
| 2684 |
None,
|
| 2685 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
@@ -2709,6 +2718,7 @@ def advance_full_act(
|
|
| 2709 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2710 |
EMPTY_AGENT_STATE,
|
| 2711 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2712 |
EMPTY_TRACE,
|
| 2713 |
None,
|
| 2714 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
@@ -2778,6 +2788,7 @@ def throw_audience_prop(
|
|
| 2778 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2779 |
EMPTY_AGENT_STATE,
|
| 2780 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2781 |
EMPTY_TRACE,
|
| 2782 |
None,
|
| 2783 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
@@ -2807,6 +2818,7 @@ def summon_audience_actor(
|
|
| 2807 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2808 |
EMPTY_AGENT_STATE,
|
| 2809 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2810 |
EMPTY_TRACE,
|
| 2811 |
None,
|
| 2812 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
@@ -2835,6 +2847,7 @@ def request_audience_finale(
|
|
| 2835 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2836 |
EMPTY_AGENT_STATE,
|
| 2837 |
EMPTY_DIRECTOR_LOG,
|
|
|
|
| 2838 |
EMPTY_TRACE,
|
| 2839 |
None,
|
| 2840 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
@@ -2886,6 +2899,7 @@ def warm_up_backend(
|
|
| 2886 |
return (
|
| 2887 |
session,
|
| 2888 |
render_director_log(session),
|
|
|
|
| 2889 |
render_trace(session),
|
| 2890 |
write_trace_json_file(session),
|
| 2891 |
render_backend_settings(
|
|
@@ -3012,6 +3026,14 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3012 |
elem_classes=["no-field-label"],
|
| 3013 |
)
|
| 3014 |
with gr.Accordion("Behind the Curtain", open=False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3015 |
director_output = gr.Textbox(
|
| 3016 |
value=EMPTY_DIRECTOR_LOG,
|
| 3017 |
label="Director Log",
|
|
@@ -3097,6 +3119,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3097 |
tts_status_output,
|
| 3098 |
agent_state_output,
|
| 3099 |
director_output,
|
|
|
|
| 3100 |
trace_output,
|
| 3101 |
trace_download,
|
| 3102 |
backend_output,
|
|
@@ -3121,6 +3144,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3121 |
tts_status_output,
|
| 3122 |
agent_state_output,
|
| 3123 |
director_output,
|
|
|
|
| 3124 |
trace_output,
|
| 3125 |
trace_download,
|
| 3126 |
backend_output,
|
|
@@ -3146,6 +3170,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3146 |
tts_status_output,
|
| 3147 |
agent_state_output,
|
| 3148 |
director_output,
|
|
|
|
| 3149 |
trace_output,
|
| 3150 |
trace_download,
|
| 3151 |
backend_output,
|
|
@@ -3171,6 +3196,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3171 |
tts_status_output,
|
| 3172 |
agent_state_output,
|
| 3173 |
director_output,
|
|
|
|
| 3174 |
trace_output,
|
| 3175 |
trace_download,
|
| 3176 |
backend_output,
|
|
@@ -3196,6 +3222,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3196 |
tts_status_output,
|
| 3197 |
agent_state_output,
|
| 3198 |
director_output,
|
|
|
|
| 3199 |
trace_output,
|
| 3200 |
trace_download,
|
| 3201 |
backend_output,
|
|
@@ -3220,6 +3247,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3220 |
tts_status_output,
|
| 3221 |
agent_state_output,
|
| 3222 |
director_output,
|
|
|
|
| 3223 |
trace_output,
|
| 3224 |
trace_download,
|
| 3225 |
backend_output,
|
|
@@ -3228,7 +3256,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3228 |
warm_up_button.click(
|
| 3229 |
warm_up_backend,
|
| 3230 |
inputs=[session_state, max_new_tokens_input, temperature_input],
|
| 3231 |
-
outputs=[session_state, director_output, trace_output, trace_download, backend_output],
|
| 3232 |
)
|
| 3233 |
reset_button.click(
|
| 3234 |
reset_show,
|
|
@@ -3244,6 +3272,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
|
|
| 3244 |
tts_status_output,
|
| 3245 |
agent_state_output,
|
| 3246 |
director_output,
|
|
|
|
| 3247 |
trace_output,
|
| 3248 |
trace_download,
|
| 3249 |
show_length_select,
|
|
|
|
| 27 |
throw_prop,
|
| 28 |
warm_up_openbmb,
|
| 29 |
)
|
| 30 |
+
from puppet_theater.trace import add_trace_event, render_trace_json, render_trace_summary, write_trace_json_file
|
| 31 |
|
| 32 |
|
| 33 |
EMPTY_STAGE = """
|
|
|
|
| 53 |
EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
|
| 54 |
EMPTY_DIRECTOR_LOG = "No director notes yet."
|
| 55 |
EMPTY_TRACE = "No trace events yet."
|
| 56 |
+
EMPTY_TRACE_SUMMARY = "No trace events yet."
|
| 57 |
EMPTY_BACKEND = (
|
| 58 |
"Active backend: deterministic\n"
|
| 59 |
"Director mode: deterministic\n"
|
|
|
|
| 2454 |
return render_trace_json(session)
|
| 2455 |
|
| 2456 |
|
| 2457 |
+
def render_trace_summary_text(session: TheaterSession | None) -> str:
|
| 2458 |
+
return render_trace_summary(session)
|
| 2459 |
+
|
| 2460 |
+
|
| 2461 |
def normalize_backend_name(backend_name: str | None) -> str:
|
| 2462 |
return backend_name if backend_name in BACKEND_VALUES else "deterministic"
|
| 2463 |
|
|
|
|
| 2579 |
f'<div class="voice-status">{escape(tts_status)}</div>',
|
| 2580 |
render_agent_state(session),
|
| 2581 |
render_director_log(session),
|
| 2582 |
+
render_trace_summary_text(session),
|
| 2583 |
render_trace(session),
|
| 2584 |
write_trace_json_file(session),
|
| 2585 |
render_backend_settings(session),
|
|
|
|
| 2612 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2613 |
EMPTY_AGENT_STATE,
|
| 2614 |
EMPTY_DIRECTOR_LOG,
|
| 2615 |
+
EMPTY_TRACE_SUMMARY,
|
| 2616 |
EMPTY_TRACE,
|
| 2617 |
None,
|
| 2618 |
render_backend_settings(
|
|
|
|
| 2655 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2656 |
EMPTY_AGENT_STATE,
|
| 2657 |
EMPTY_DIRECTOR_LOG,
|
| 2658 |
+
EMPTY_TRACE_SUMMARY,
|
| 2659 |
EMPTY_TRACE,
|
| 2660 |
None,
|
| 2661 |
DEFAULT_SHOW_LENGTH,
|
|
|
|
| 2688 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2689 |
EMPTY_AGENT_STATE,
|
| 2690 |
EMPTY_DIRECTOR_LOG,
|
| 2691 |
+
EMPTY_TRACE_SUMMARY,
|
| 2692 |
EMPTY_TRACE,
|
| 2693 |
None,
|
| 2694 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
|
|
| 2718 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2719 |
EMPTY_AGENT_STATE,
|
| 2720 |
EMPTY_DIRECTOR_LOG,
|
| 2721 |
+
EMPTY_TRACE_SUMMARY,
|
| 2722 |
EMPTY_TRACE,
|
| 2723 |
None,
|
| 2724 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
|
|
| 2788 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2789 |
EMPTY_AGENT_STATE,
|
| 2790 |
EMPTY_DIRECTOR_LOG,
|
| 2791 |
+
EMPTY_TRACE_SUMMARY,
|
| 2792 |
EMPTY_TRACE,
|
| 2793 |
None,
|
| 2794 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
|
|
| 2818 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2819 |
EMPTY_AGENT_STATE,
|
| 2820 |
EMPTY_DIRECTOR_LOG,
|
| 2821 |
+
EMPTY_TRACE_SUMMARY,
|
| 2822 |
EMPTY_TRACE,
|
| 2823 |
None,
|
| 2824 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
|
|
| 2847 |
f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
|
| 2848 |
EMPTY_AGENT_STATE,
|
| 2849 |
EMPTY_DIRECTOR_LOG,
|
| 2850 |
+
EMPTY_TRACE_SUMMARY,
|
| 2851 |
EMPTY_TRACE,
|
| 2852 |
None,
|
| 2853 |
render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
|
|
|
|
| 2899 |
return (
|
| 2900 |
session,
|
| 2901 |
render_director_log(session),
|
| 2902 |
+
render_trace_summary_text(session),
|
| 2903 |
render_trace(session),
|
| 2904 |
write_trace_json_file(session),
|
| 2905 |
render_backend_settings(
|
|
|
|
| 3026 |
elem_classes=["no-field-label"],
|
| 3027 |
)
|
| 3028 |
with gr.Accordion("Behind the Curtain", open=False):
|
| 3029 |
+
trace_summary_output = gr.Textbox(
|
| 3030 |
+
value=EMPTY_TRACE_SUMMARY,
|
| 3031 |
+
label="Trace Summary",
|
| 3032 |
+
lines=8,
|
| 3033 |
+
interactive=False,
|
| 3034 |
+
buttons=["copy"],
|
| 3035 |
+
elem_classes=["no-field-label"],
|
| 3036 |
+
)
|
| 3037 |
director_output = gr.Textbox(
|
| 3038 |
value=EMPTY_DIRECTOR_LOG,
|
| 3039 |
label="Director Log",
|
|
|
|
| 3119 |
tts_status_output,
|
| 3120 |
agent_state_output,
|
| 3121 |
director_output,
|
| 3122 |
+
trace_summary_output,
|
| 3123 |
trace_output,
|
| 3124 |
trace_download,
|
| 3125 |
backend_output,
|
|
|
|
| 3144 |
tts_status_output,
|
| 3145 |
agent_state_output,
|
| 3146 |
director_output,
|
| 3147 |
+
trace_summary_output,
|
| 3148 |
trace_output,
|
| 3149 |
trace_download,
|
| 3150 |
backend_output,
|
|
|
|
| 3170 |
tts_status_output,
|
| 3171 |
agent_state_output,
|
| 3172 |
director_output,
|
| 3173 |
+
trace_summary_output,
|
| 3174 |
trace_output,
|
| 3175 |
trace_download,
|
| 3176 |
backend_output,
|
|
|
|
| 3196 |
tts_status_output,
|
| 3197 |
agent_state_output,
|
| 3198 |
director_output,
|
| 3199 |
+
trace_summary_output,
|
| 3200 |
trace_output,
|
| 3201 |
trace_download,
|
| 3202 |
backend_output,
|
|
|
|
| 3222 |
tts_status_output,
|
| 3223 |
agent_state_output,
|
| 3224 |
director_output,
|
| 3225 |
+
trace_summary_output,
|
| 3226 |
trace_output,
|
| 3227 |
trace_download,
|
| 3228 |
backend_output,
|
|
|
|
| 3247 |
tts_status_output,
|
| 3248 |
agent_state_output,
|
| 3249 |
director_output,
|
| 3250 |
+
trace_summary_output,
|
| 3251 |
trace_output,
|
| 3252 |
trace_download,
|
| 3253 |
backend_output,
|
|
|
|
| 3256 |
warm_up_button.click(
|
| 3257 |
warm_up_backend,
|
| 3258 |
inputs=[session_state, max_new_tokens_input, temperature_input],
|
| 3259 |
+
outputs=[session_state, director_output, trace_summary_output, trace_output, trace_download, backend_output],
|
| 3260 |
)
|
| 3261 |
reset_button.click(
|
| 3262 |
reset_show,
|
|
|
|
| 3272 |
tts_status_output,
|
| 3273 |
agent_state_output,
|
| 3274 |
director_output,
|
| 3275 |
+
trace_summary_output,
|
| 3276 |
trace_output,
|
| 3277 |
trace_download,
|
| 3278 |
show_length_select,
|
puppet_theater/director.py
CHANGED
|
@@ -453,6 +453,18 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 453 |
fallback_used=director_generation.fallback_used,
|
| 454 |
fallback_reason=director_generation.error,
|
| 455 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
|
| 457 |
backend_generation = generate_actor_response(session, decision, speaker, prop)
|
| 458 |
session.beat_index += 1
|
|
@@ -557,6 +569,19 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
|
|
| 557 |
fallback_used=backend_generation.fallback_used,
|
| 558 |
fallback_reason=backend_generation.error,
|
| 559 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
|
| 561 |
if decision.should_end_scene or decision.beat_type == "finale":
|
| 562 |
session.beat_index = session.max_beats
|
|
|
|
| 453 |
fallback_used=director_generation.fallback_used,
|
| 454 |
fallback_reason=director_generation.error,
|
| 455 |
)
|
| 456 |
+
if director_generation.fallback_used:
|
| 457 |
+
add_trace_event(
|
| 458 |
+
session,
|
| 459 |
+
"director_fallback",
|
| 460 |
+
speaker=speaker.name,
|
| 461 |
+
backend_name=director_generation.director_mode,
|
| 462 |
+
model_id=director_generation.model_id,
|
| 463 |
+
validation_status=director_generation.validation_status,
|
| 464 |
+
fallback_used=True,
|
| 465 |
+
fallback_reason=director_generation.error or director_generation.validation_status,
|
| 466 |
+
story_phase=current_phase,
|
| 467 |
+
)
|
| 468 |
|
| 469 |
backend_generation = generate_actor_response(session, decision, speaker, prop)
|
| 470 |
session.beat_index += 1
|
|
|
|
| 569 |
fallback_used=backend_generation.fallback_used,
|
| 570 |
fallback_reason=backend_generation.error,
|
| 571 |
)
|
| 572 |
+
if backend_generation.fallback_used:
|
| 573 |
+
add_trace_event(
|
| 574 |
+
session,
|
| 575 |
+
"actor_fallback",
|
| 576 |
+
beat_index=session.beat_index,
|
| 577 |
+
speaker=speaker.name,
|
| 578 |
+
backend_name=backend_generation.backend_name,
|
| 579 |
+
model_id=backend_generation.model_id,
|
| 580 |
+
validation_status=backend_generation.validation_status,
|
| 581 |
+
fallback_used=True,
|
| 582 |
+
fallback_reason=backend_generation.error or backend_generation.validation_status,
|
| 583 |
+
load_status=backend_generation.load_status,
|
| 584 |
+
)
|
| 585 |
|
| 586 |
if decision.should_end_scene or decision.beat_type == "finale":
|
| 587 |
session.beat_index = session.max_beats
|
puppet_theater/models.py
CHANGED
|
@@ -161,6 +161,7 @@ class TheaterSession:
|
|
| 161 |
min_beats: int = 7
|
| 162 |
target_beats: int = 10
|
| 163 |
max_beats: int = 12
|
|
|
|
| 164 |
transcript: list[Beat] = field(default_factory=list)
|
| 165 |
props: list[str] = field(default_factory=list)
|
| 166 |
latest_prop: str | None = None
|
|
|
|
| 161 |
min_beats: int = 7
|
| 162 |
target_beats: int = 10
|
| 163 |
max_beats: int = 12
|
| 164 |
+
show_length_mode: str = "standard"
|
| 165 |
transcript: list[Beat] = field(default_factory=list)
|
| 166 |
props: list[str] = field(default_factory=list)
|
| 167 |
latest_prop: str | None = None
|
puppet_theater/session.py
CHANGED
|
@@ -106,6 +106,7 @@ def create_show_from_premise(
|
|
| 106 |
min_beats=min_beats,
|
| 107 |
target_beats=target_beats,
|
| 108 |
max_beats=max_beats,
|
|
|
|
| 109 |
transcript=[],
|
| 110 |
props=[],
|
| 111 |
latest_prop=None,
|
|
|
|
| 106 |
min_beats=min_beats,
|
| 107 |
target_beats=target_beats,
|
| 108 |
max_beats=max_beats,
|
| 109 |
+
show_length_mode=active_show_length,
|
| 110 |
transcript=[],
|
| 111 |
props=[],
|
| 112 |
latest_prop=None,
|
puppet_theater/trace.py
CHANGED
|
@@ -13,6 +13,26 @@ from puppet_theater.models import TheaterSession
|
|
| 13 |
APP_NAME = "AI Puppet Theater"
|
| 14 |
TRACE_VERSION = "1.0"
|
| 15 |
_MAX_TEXT_CHARS = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
_PRIVATE_PATH_PATTERNS = [
|
| 17 |
re.compile(r"/Users/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
|
| 18 |
re.compile(r"/private/(?:tmp|var)/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
|
|
@@ -22,7 +42,7 @@ _PRIVATE_PATH_PATTERNS = [
|
|
| 22 |
|
| 23 |
def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
|
| 24 |
event: dict[str, Any] = {
|
| 25 |
-
"
|
| 26 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 27 |
"step": len(session.trace_events) + 1,
|
| 28 |
}
|
|
@@ -49,6 +69,7 @@ def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
|
|
| 49 |
"premise": sanitize_value(session.premise),
|
| 50 |
"title": sanitize_value(session.show_title),
|
| 51 |
"setting": sanitize_value(session.setting),
|
|
|
|
| 52 |
"min_beats": session.min_beats,
|
| 53 |
"target_beats": session.target_beats,
|
| 54 |
"max_beats": session.max_beats,
|
|
@@ -88,22 +109,193 @@ def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
|
|
| 88 |
normalized: list[dict[str, Any]] = []
|
| 89 |
for index, raw_event in enumerate(session.trace_events, start=1):
|
| 90 |
if isinstance(raw_event, dict):
|
| 91 |
-
|
| 92 |
-
event.setdefault("type", "event")
|
| 93 |
-
event.setdefault("step", index)
|
| 94 |
-
normalized.append(event)
|
| 95 |
continue
|
| 96 |
|
| 97 |
normalized.append(
|
| 98 |
{
|
| 99 |
-
"
|
| 100 |
"step": index,
|
| 101 |
"message": sanitize_value(raw_event),
|
|
|
|
| 102 |
}
|
| 103 |
)
|
| 104 |
return normalized
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
def sanitize_value(value: Any) -> Any:
|
| 108 |
if isinstance(value, dict):
|
| 109 |
return {
|
|
@@ -123,20 +315,41 @@ def sanitize_value(value: Any) -> Any:
|
|
| 123 |
def _sanitize_text(value: str) -> str:
|
| 124 |
text = " ".join(value.split())
|
| 125 |
if "Traceback (most recent call last)" in text:
|
| 126 |
-
text = "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
|
| 128 |
secret = os.getenv(secret_name)
|
| 129 |
if secret:
|
| 130 |
text = text.replace(secret, "[redacted]")
|
| 131 |
-
text = re.sub(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
for pattern in _PRIVATE_PATH_PATTERNS:
|
| 133 |
text = pattern.sub("[redacted-path]", text)
|
| 134 |
return text[:_MAX_TEXT_CHARS].rstrip()
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
def _is_sensitive_key(key: str) -> bool:
|
| 138 |
lowered = key.lower()
|
| 139 |
-
return
|
| 140 |
|
| 141 |
|
| 142 |
def _model_id_for_mode(mode: str | None) -> str | None:
|
|
|
|
| 13 |
APP_NAME = "AI Puppet Theater"
|
| 14 |
TRACE_VERSION = "1.0"
|
| 15 |
_MAX_TEXT_CHARS = 500
|
| 16 |
+
_STANDARD_EVENT_KEYS = (
|
| 17 |
+
"event_type",
|
| 18 |
+
"timestamp",
|
| 19 |
+
"step",
|
| 20 |
+
"beat_index",
|
| 21 |
+
"story_phase",
|
| 22 |
+
"speaker",
|
| 23 |
+
"director_decision",
|
| 24 |
+
"actor_intent",
|
| 25 |
+
"actor_memory_update",
|
| 26 |
+
"tool_request",
|
| 27 |
+
"tool_result",
|
| 28 |
+
"audience_action",
|
| 29 |
+
"backend_metadata",
|
| 30 |
+
"latency_ms",
|
| 31 |
+
"validation_status",
|
| 32 |
+
"fallback_used",
|
| 33 |
+
"fallback_reason",
|
| 34 |
+
"summary",
|
| 35 |
+
)
|
| 36 |
_PRIVATE_PATH_PATTERNS = [
|
| 37 |
re.compile(r"/Users/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
|
| 38 |
re.compile(r"/private/(?:tmp|var)/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
|
|
|
|
| 42 |
|
| 43 |
def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
|
| 44 |
event: dict[str, Any] = {
|
| 45 |
+
"event_type": sanitize_value(event_type),
|
| 46 |
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 47 |
"step": len(session.trace_events) + 1,
|
| 48 |
}
|
|
|
|
| 69 |
"premise": sanitize_value(session.premise),
|
| 70 |
"title": sanitize_value(session.show_title),
|
| 71 |
"setting": sanitize_value(session.setting),
|
| 72 |
+
"show_length_mode": sanitize_value(session.show_length_mode),
|
| 73 |
"min_beats": session.min_beats,
|
| 74 |
"target_beats": session.target_beats,
|
| 75 |
"max_beats": session.max_beats,
|
|
|
|
| 109 |
normalized: list[dict[str, Any]] = []
|
| 110 |
for index, raw_event in enumerate(session.trace_events, start=1):
|
| 111 |
if isinstance(raw_event, dict):
|
| 112 |
+
normalized.append(_normalize_event_dict(raw_event, index))
|
|
|
|
|
|
|
|
|
|
| 113 |
continue
|
| 114 |
|
| 115 |
normalized.append(
|
| 116 |
{
|
| 117 |
+
"event_type": "legacy_event",
|
| 118 |
"step": index,
|
| 119 |
"message": sanitize_value(raw_event),
|
| 120 |
+
"summary": sanitize_value(raw_event),
|
| 121 |
}
|
| 122 |
)
|
| 123 |
return normalized
|
| 124 |
|
| 125 |
|
| 126 |
+
def render_trace_summary(session: TheaterSession | None, max_events: int = 14) -> str:
|
| 127 |
+
if session is None:
|
| 128 |
+
return "No trace events yet."
|
| 129 |
+
|
| 130 |
+
events = normalize_trace_events(session)
|
| 131 |
+
if not events:
|
| 132 |
+
return "No trace events yet."
|
| 133 |
+
|
| 134 |
+
lines = [
|
| 135 |
+
f"{session.show_title} ({session.session_id})",
|
| 136 |
+
(
|
| 137 |
+
f"{session.show_length_mode} show: {session.min_beats}/"
|
| 138 |
+
f"{session.target_beats}/{session.max_beats} beats, "
|
| 139 |
+
f"actor backend={session.backend_name}, director={session.director_mode}"
|
| 140 |
+
),
|
| 141 |
+
"",
|
| 142 |
+
]
|
| 143 |
+
for event in events[-max_events:]:
|
| 144 |
+
step = event.get("step", "?")
|
| 145 |
+
event_type = event.get("event_type", "event")
|
| 146 |
+
beat = event.get("beat_index")
|
| 147 |
+
prefix = f"{step}. {event_type}"
|
| 148 |
+
if beat is not None:
|
| 149 |
+
prefix += f" [beat {beat}]"
|
| 150 |
+
lines.append(f"{prefix}: {_event_summary(event)}")
|
| 151 |
+
return "\n".join(lines)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _normalize_event_dict(raw_event: dict[str, Any], index: int) -> dict[str, Any]:
|
| 155 |
+
cleaned = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
|
| 156 |
+
event_type = str(cleaned.pop("event_type", cleaned.pop("type", "event")) or "event")
|
| 157 |
+
normalized: dict[str, Any] = {
|
| 158 |
+
"event_type": sanitize_value(event_type),
|
| 159 |
+
"step": cleaned.pop("step", index),
|
| 160 |
+
}
|
| 161 |
+
if "timestamp" in cleaned:
|
| 162 |
+
normalized["timestamp"] = cleaned.pop("timestamp")
|
| 163 |
+
|
| 164 |
+
for key in (
|
| 165 |
+
"beat_index",
|
| 166 |
+
"story_phase",
|
| 167 |
+
"speaker",
|
| 168 |
+
"latency_ms",
|
| 169 |
+
"validation_status",
|
| 170 |
+
"fallback_used",
|
| 171 |
+
"fallback_reason",
|
| 172 |
+
):
|
| 173 |
+
if key in cleaned:
|
| 174 |
+
normalized[key] = cleaned.pop(key)
|
| 175 |
+
|
| 176 |
+
if event_type == "director_decision":
|
| 177 |
+
normalized["director_decision"] = {
|
| 178 |
+
key: cleaned.pop(key)
|
| 179 |
+
for key in (
|
| 180 |
+
"beat_type",
|
| 181 |
+
"reason_summary",
|
| 182 |
+
"progress",
|
| 183 |
+
"uses_prop",
|
| 184 |
+
"reveal_secret",
|
| 185 |
+
"should_end_scene",
|
| 186 |
+
"min_beats",
|
| 187 |
+
"target_beats",
|
| 188 |
+
"max_beats",
|
| 189 |
+
)
|
| 190 |
+
if key in cleaned
|
| 191 |
+
}
|
| 192 |
+
if event_type == "actor_intent" and "intent" in cleaned:
|
| 193 |
+
normalized["actor_intent"] = cleaned.pop("intent")
|
| 194 |
+
if event_type == "actor_memory_update" and "memory_update" in cleaned:
|
| 195 |
+
normalized["actor_memory_update"] = cleaned.pop("memory_update")
|
| 196 |
+
if event_type in {"tool_requested", "tool_executed", "tool_ignored"}:
|
| 197 |
+
normalized["tool_request"] = {
|
| 198 |
+
key: cleaned.pop(key)
|
| 199 |
+
for key in ("tool_name", "reason", "arguments")
|
| 200 |
+
if key in cleaned
|
| 201 |
+
}
|
| 202 |
+
if event_type == "tool_result":
|
| 203 |
+
normalized["tool_result"] = {
|
| 204 |
+
key: cleaned.pop(key)
|
| 205 |
+
for key in ("tool_name", "result", "stage_effect")
|
| 206 |
+
if key in cleaned
|
| 207 |
+
}
|
| 208 |
+
if event_type == "audience_action":
|
| 209 |
+
action = cleaned.pop("audience_action", None)
|
| 210 |
+
normalized["audience_action"] = {
|
| 211 |
+
key: value
|
| 212 |
+
for key, value in {
|
| 213 |
+
"action": action,
|
| 214 |
+
"summary": cleaned.pop("action_summary", None),
|
| 215 |
+
"prop": cleaned.pop("prop", None),
|
| 216 |
+
"summoned_actor": cleaned.pop("summoned_actor", None),
|
| 217 |
+
}.items()
|
| 218 |
+
if value is not None
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
backend_metadata = {
|
| 222 |
+
key: cleaned.pop(key)
|
| 223 |
+
for key in (
|
| 224 |
+
"backend_name",
|
| 225 |
+
"model_id",
|
| 226 |
+
"load_status",
|
| 227 |
+
"director_mode",
|
| 228 |
+
"tts_backend",
|
| 229 |
+
"voice_mode",
|
| 230 |
+
"voice_name",
|
| 231 |
+
)
|
| 232 |
+
if key in cleaned
|
| 233 |
+
}
|
| 234 |
+
if backend_metadata:
|
| 235 |
+
normalized["backend_metadata"] = backend_metadata
|
| 236 |
+
|
| 237 |
+
if "reason_summary" in cleaned:
|
| 238 |
+
normalized["summary"] = cleaned.pop("reason_summary")
|
| 239 |
+
elif "action_summary" in cleaned:
|
| 240 |
+
normalized["summary"] = cleaned.pop("action_summary")
|
| 241 |
+
elif "error_summary" in cleaned:
|
| 242 |
+
normalized["summary"] = cleaned.pop("error_summary")
|
| 243 |
+
|
| 244 |
+
for key, value in cleaned.items():
|
| 245 |
+
if key not in normalized:
|
| 246 |
+
normalized[key] = value
|
| 247 |
+
|
| 248 |
+
if "summary" not in normalized:
|
| 249 |
+
normalized["summary"] = _event_summary(normalized)
|
| 250 |
+
return {key: normalized[key] for key in _STANDARD_EVENT_KEYS if key in normalized} | {
|
| 251 |
+
key: value for key, value in normalized.items() if key not in _STANDARD_EVENT_KEYS
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _event_summary(event: dict[str, Any]) -> str:
|
| 256 |
+
event_type = str(event.get("event_type") or "event")
|
| 257 |
+
if event_type == "show_created":
|
| 258 |
+
backend = _backend_label(event)
|
| 259 |
+
return f"Show created with {backend}."
|
| 260 |
+
if event_type == "actors_created":
|
| 261 |
+
return f"{event.get('actor_count', 'Actors')} actors created."
|
| 262 |
+
if event_type == "director_decision":
|
| 263 |
+
decision = event.get("director_decision") if isinstance(event.get("director_decision"), dict) else {}
|
| 264 |
+
beat_type = decision.get("beat_type") if isinstance(decision, dict) else None
|
| 265 |
+
speaker = event.get("speaker") or "next actor"
|
| 266 |
+
reason = decision.get("reason_summary") if isinstance(decision, dict) else None
|
| 267 |
+
return f"Director chose {speaker} for {beat_type or 'a beat'}" + (f": {reason}" if reason else ".")
|
| 268 |
+
if event_type in {"actor_response", "backend_error", "backend_warmup"}:
|
| 269 |
+
status = event.get("validation_status") or "status unknown"
|
| 270 |
+
fallback = " with fallback" if event.get("fallback_used") else ""
|
| 271 |
+
return f"Backend returned {status}{fallback}."
|
| 272 |
+
if event_type in {"actor_fallback", "director_fallback"}:
|
| 273 |
+
reason = event.get("fallback_reason") or event.get("validation_status") or "fallback used"
|
| 274 |
+
return f"Fallback used: {reason}."
|
| 275 |
+
if event_type == "actor_intent":
|
| 276 |
+
return str(event.get("actor_intent") or "Actor intent recorded.")
|
| 277 |
+
if event_type == "actor_memory_update":
|
| 278 |
+
return str(event.get("actor_memory_update") or "Actor memory updated.")
|
| 279 |
+
if event_type == "tool_requested":
|
| 280 |
+
tool = _tool_label(event.get("tool_request"))
|
| 281 |
+
return f"Tool requested: {tool}."
|
| 282 |
+
if event_type == "tool_executed":
|
| 283 |
+
tool = _tool_label(event.get("tool_request"))
|
| 284 |
+
return f"Tool executed: {tool}."
|
| 285 |
+
if event_type == "tool_result":
|
| 286 |
+
result = event.get("tool_result") if isinstance(event.get("tool_result"), dict) else {}
|
| 287 |
+
return str(result.get("result") or f"Tool result from {result.get('tool_name', 'tool')}.")
|
| 288 |
+
if event_type == "audience_action":
|
| 289 |
+
action = event.get("audience_action") if isinstance(event.get("audience_action"), dict) else {}
|
| 290 |
+
return str(action.get("summary") or action.get("action") or "Audience action recorded.")
|
| 291 |
+
if event_type in {"tts_requested", "tts_generated", "tts_fallback"}:
|
| 292 |
+
metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
|
| 293 |
+
return f"TTS event via {metadata.get('tts_backend', 'voice backend')}."
|
| 294 |
+
if event_type == "scene_completed":
|
| 295 |
+
return str(event.get("summary") or "Scene completed.")
|
| 296 |
+
return str(event.get("summary") or event_type.replace("_", " ").title())
|
| 297 |
+
|
| 298 |
+
|
| 299 |
def sanitize_value(value: Any) -> Any:
|
| 300 |
if isinstance(value, dict):
|
| 301 |
return {
|
|
|
|
| 315 |
def _sanitize_text(value: str) -> str:
|
| 316 |
text = " ".join(value.split())
|
| 317 |
if "Traceback (most recent call last)" in text:
|
| 318 |
+
text = "Error details redacted"
|
| 319 |
+
for key, secret in os.environ.items():
|
| 320 |
+
if not _is_sensitive_key(key) or not secret:
|
| 321 |
+
continue
|
| 322 |
+
text = text.replace(secret, "[redacted]")
|
| 323 |
for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
|
| 324 |
secret = os.getenv(secret_name)
|
| 325 |
if secret:
|
| 326 |
text = text.replace(secret, "[redacted]")
|
| 327 |
+
text = re.sub(
|
| 328 |
+
r"\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*\S+",
|
| 329 |
+
r"\1=[redacted]",
|
| 330 |
+
text,
|
| 331 |
+
)
|
| 332 |
for pattern in _PRIVATE_PATH_PATTERNS:
|
| 333 |
text = pattern.sub("[redacted-path]", text)
|
| 334 |
return text[:_MAX_TEXT_CHARS].rstrip()
|
| 335 |
|
| 336 |
|
| 337 |
+
def _backend_label(event: dict[str, Any]) -> str:
|
| 338 |
+
metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
|
| 339 |
+
backend = metadata.get("backend_name") or metadata.get("director_mode") or event.get("backend_name") or "backend"
|
| 340 |
+
model = metadata.get("model_id") or event.get("model_id")
|
| 341 |
+
return f"{backend} ({model})" if model else str(backend)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _tool_label(tool_request: Any) -> str:
|
| 345 |
+
if isinstance(tool_request, dict):
|
| 346 |
+
return str(tool_request.get("tool_name") or "tool")
|
| 347 |
+
return "tool"
|
| 348 |
+
|
| 349 |
+
|
| 350 |
def _is_sensitive_key(key: str) -> bool:
|
| 351 |
lowered = key.lower()
|
| 352 |
+
return any(marker in lowered for marker in ("token", "secret", "password", "credential", "api_key", "access_key"))
|
| 353 |
|
| 354 |
|
| 355 |
def _model_id_for_mode(mode: str | None) -> str | None:
|