ShubhamSetia commited on
Commit
67fbecc
·
1 Parent(s): 017769b

feat: add trace export

Browse files
README.md CHANGED
@@ -33,3 +33,9 @@ The default model string includes the provider suffix used by Hugging Face Infer
33
  Configure the token as a Hugging Face Space secret named `HF_TOKEN`. `HUGGINGFACEHUB_API_TOKEN` is also supported. Do not commit tokens to the repository, README, app code, logs, or trace output.
34
 
35
  Local deterministic mode and app launch work without `HF_TOKEN`.
 
 
 
 
 
 
 
33
  Configure the token as a Hugging Face Space secret named `HF_TOKEN`. `HUGGINGFACEHUB_API_TOKEN` is also supported. Do not commit tokens to the repository, README, app code, logs, or trace output.
34
 
35
  Local deterministic mode and app launch work without `HF_TOKEN`.
36
+
37
+ ## Trace export
38
+
39
+ Use the collapsed **Trace / Debug** panel to inspect a readable JSON preview of the current show trace. After creating a show, the **Download Trace JSON** button saves a file named `ai-puppet-theater-trace-<session_id>.json`.
40
+
41
+ The trace includes session metadata, the premise, title, setting, selected actor and Director backends, model ids when used, Director decisions, actor responses, audience actions, validation status, latency, and fallback metadata. Trace output is sanitized for sharing: it does not include Hugging Face tokens, environment variable values, private local paths, raw tracebacks, actor secrets, or hidden reasoning.
app.py CHANGED
@@ -16,6 +16,7 @@ from puppet_theater import (
16
  throw_prop,
17
  warm_up_openbmb,
18
  )
 
19
 
20
 
21
  EMPTY_STAGE = """
@@ -1598,9 +1599,7 @@ def render_director_log(session: TheaterSession | None) -> str:
1598
 
1599
 
1600
  def render_trace(session: TheaterSession | None) -> str:
1601
- if session is None:
1602
- return EMPTY_TRACE
1603
- return "\n".join(f"- {entry}" for entry in session.trace_events)
1604
 
1605
 
1606
  def normalize_backend_name(backend_name: str | None) -> str:
@@ -1700,6 +1699,7 @@ def render_outputs(session: TheaterSession | None):
1700
  render_transcript(session),
1701
  render_director_log(session),
1702
  render_trace(session),
 
1703
  render_backend_settings(session),
1704
  )
1705
 
@@ -1724,6 +1724,7 @@ def create_show(
1724
  "No premise yet. Add a premise to raise the curtain.",
1725
  EMPTY_DIRECTOR_LOG,
1726
  EMPTY_TRACE,
 
1727
  render_backend_settings(
1728
  None,
1729
  selected_backend,
@@ -1760,6 +1761,7 @@ def reset_show():
1760
  EMPTY_TRANSCRIPT,
1761
  EMPTY_DIRECTOR_LOG,
1762
  EMPTY_TRACE,
 
1763
  "deterministic",
1764
  "deterministic",
1765
  DEFAULT_MAX_NEW_TOKENS,
@@ -1783,6 +1785,7 @@ def advance_one_beat(
1783
  "Create a show before running a beat.",
1784
  EMPTY_DIRECTOR_LOG,
1785
  EMPTY_TRACE,
 
1786
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1787
  )
1788
 
@@ -1806,6 +1809,7 @@ def advance_full_act(
1806
  "Create a show before running the full act.",
1807
  EMPTY_DIRECTOR_LOG,
1808
  EMPTY_TRACE,
 
1809
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1810
  )
1811
  return
@@ -1821,7 +1825,14 @@ def advance_full_act(
1821
  session.director_log.append(
1822
  "OpenBMB is selected, so Run Full Act will use deterministic playback for this run."
1823
  )
1824
- session.trace_events.append("full_act_openbmb_deterministic_playback")
 
 
 
 
 
 
 
1825
 
1826
  if session.beat_index >= session.max_beats:
1827
  if deterministic_full_act:
@@ -1862,6 +1873,7 @@ def throw_audience_prop(
1862
  "Create a show before throwing a prop.",
1863
  EMPTY_DIRECTOR_LOG,
1864
  EMPTY_TRACE,
 
1865
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1866
  )
1867
 
@@ -1885,6 +1897,7 @@ def summon_audience_actor(
1885
  "Create a show before summoning an actor.",
1886
  EMPTY_DIRECTOR_LOG,
1887
  EMPTY_TRACE,
 
1888
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1889
  )
1890
 
@@ -1907,6 +1920,7 @@ def request_audience_finale(
1907
  "Create a show before requesting a finale.",
1908
  EMPTY_DIRECTOR_LOG,
1909
  EMPTY_TRACE,
 
1910
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1911
  )
1912
 
@@ -1931,15 +1945,33 @@ def warm_up_backend(
1931
  session.backend_temperature = selected_temperature
1932
  if status.load_status == "loaded":
1933
  session.director_log.append(f"OpenBMB warm-up loaded {status.model_id}.")
1934
- session.trace_events.append(f"openbmb_warmup_loaded:{status.model_id}:latency_ms={status.latest_latency_ms}")
 
 
 
 
 
 
 
 
1935
  else:
1936
  reason = status.latest_fallback_reason or "unknown error"
1937
  session.director_log.append(f"OpenBMB warm-up failed: {reason}.")
1938
- session.trace_events.append(f"openbmb_warmup_failed:{status.model_id}:{reason}")
 
 
 
 
 
 
 
 
 
1939
  return (
1940
  session,
1941
  render_director_log(session),
1942
  render_trace(session),
 
1943
  render_backend_settings(
1944
  session,
1945
  "openbmb",
@@ -2030,11 +2062,16 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2030
  with gr.Accordion("Trace / Debug", open=False):
2031
  trace_output = gr.Textbox(
2032
  value=EMPTY_TRACE,
2033
- label="Trace Events",
2034
- lines=6,
2035
  interactive=False,
2036
  elem_classes=["no-field-label"],
2037
  )
 
 
 
 
 
2038
  with gr.Accordion("Backend", open=False):
2039
  backend_select = gr.Dropdown(
2040
  choices=BACKEND_CHOICES,
@@ -2089,7 +2126,15 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2089
  max_new_tokens_input,
2090
  temperature_input,
2091
  ],
2092
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2093
  )
2094
  run_one_button.click(
2095
  advance_one_beat,
@@ -2100,7 +2145,15 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2100
  max_new_tokens_input,
2101
  temperature_input,
2102
  ],
2103
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2104
  )
2105
  run_full_button.click(
2106
  advance_full_act,
@@ -2112,7 +2165,15 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2112
  temperature_input,
2113
  deterministic_full_act_input,
2114
  ],
2115
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2116
  )
2117
  throw_prop_button.click(
2118
  throw_audience_prop,
@@ -2124,7 +2185,15 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2124
  max_new_tokens_input,
2125
  temperature_input,
2126
  ],
2127
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2128
  )
2129
  summon_actor_button.click(
2130
  summon_audience_actor,
@@ -2136,7 +2205,15 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2136
  max_new_tokens_input,
2137
  temperature_input,
2138
  ],
2139
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2140
  )
2141
  request_finale_button.click(
2142
  request_audience_finale,
@@ -2147,12 +2224,20 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2147
  max_new_tokens_input,
2148
  temperature_input,
2149
  ],
2150
- outputs=[session_state, stage_output, transcript_output, director_output, trace_output, backend_output],
 
 
 
 
 
 
 
 
2151
  )
2152
  warm_up_button.click(
2153
  warm_up_backend,
2154
  inputs=[session_state, max_new_tokens_input, temperature_input],
2155
- outputs=[session_state, director_output, trace_output, backend_output],
2156
  )
2157
  reset_button.click(
2158
  reset_show,
@@ -2165,6 +2250,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2165
  transcript_output,
2166
  director_output,
2167
  trace_output,
 
2168
  backend_select,
2169
  director_mode_select,
2170
  max_new_tokens_input,
 
16
  throw_prop,
17
  warm_up_openbmb,
18
  )
19
+ from puppet_theater.trace import add_trace_event, render_trace_json, write_trace_json_file
20
 
21
 
22
  EMPTY_STAGE = """
 
1599
 
1600
 
1601
  def render_trace(session: TheaterSession | None) -> str:
1602
+ return render_trace_json(session)
 
 
1603
 
1604
 
1605
  def normalize_backend_name(backend_name: str | None) -> str:
 
1699
  render_transcript(session),
1700
  render_director_log(session),
1701
  render_trace(session),
1702
+ write_trace_json_file(session),
1703
  render_backend_settings(session),
1704
  )
1705
 
 
1724
  "No premise yet. Add a premise to raise the curtain.",
1725
  EMPTY_DIRECTOR_LOG,
1726
  EMPTY_TRACE,
1727
+ None,
1728
  render_backend_settings(
1729
  None,
1730
  selected_backend,
 
1761
  EMPTY_TRANSCRIPT,
1762
  EMPTY_DIRECTOR_LOG,
1763
  EMPTY_TRACE,
1764
+ None,
1765
  "deterministic",
1766
  "deterministic",
1767
  DEFAULT_MAX_NEW_TOKENS,
 
1785
  "Create a show before running a beat.",
1786
  EMPTY_DIRECTOR_LOG,
1787
  EMPTY_TRACE,
1788
+ None,
1789
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1790
  )
1791
 
 
1809
  "Create a show before running the full act.",
1810
  EMPTY_DIRECTOR_LOG,
1811
  EMPTY_TRACE,
1812
+ None,
1813
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1814
  )
1815
  return
 
1825
  session.director_log.append(
1826
  "OpenBMB is selected, so Run Full Act will use deterministic playback for this run."
1827
  )
1828
+ add_trace_event(
1829
+ session,
1830
+ "full_act_deterministic_playback",
1831
+ backend_name=selected_backend,
1832
+ director_mode=selected_director_mode,
1833
+ fallback_used=True,
1834
+ fallback_reason="OpenBMB full-act playback uses deterministic mode to keep the demo responsive.",
1835
+ )
1836
 
1837
  if session.beat_index >= session.max_beats:
1838
  if deterministic_full_act:
 
1873
  "Create a show before throwing a prop.",
1874
  EMPTY_DIRECTOR_LOG,
1875
  EMPTY_TRACE,
1876
+ None,
1877
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1878
  )
1879
 
 
1897
  "Create a show before summoning an actor.",
1898
  EMPTY_DIRECTOR_LOG,
1899
  EMPTY_TRACE,
1900
+ None,
1901
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1902
  )
1903
 
 
1920
  "Create a show before requesting a finale.",
1921
  EMPTY_DIRECTOR_LOG,
1922
  EMPTY_TRACE,
1923
+ None,
1924
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
1925
  )
1926
 
 
1945
  session.backend_temperature = selected_temperature
1946
  if status.load_status == "loaded":
1947
  session.director_log.append(f"OpenBMB warm-up loaded {status.model_id}.")
1948
+ add_trace_event(
1949
+ session,
1950
+ "backend_warmup",
1951
+ backend_name="openbmb",
1952
+ model_id=status.model_id,
1953
+ latency_ms=status.latest_latency_ms,
1954
+ validation_status="loaded",
1955
+ fallback_used=False,
1956
+ )
1957
  else:
1958
  reason = status.latest_fallback_reason or "unknown error"
1959
  session.director_log.append(f"OpenBMB warm-up failed: {reason}.")
1960
+ add_trace_event(
1961
+ session,
1962
+ "backend_warmup",
1963
+ backend_name="openbmb",
1964
+ model_id=status.model_id,
1965
+ latency_ms=status.latest_latency_ms,
1966
+ validation_status=status.load_status,
1967
+ fallback_used=True,
1968
+ fallback_reason=reason,
1969
+ )
1970
  return (
1971
  session,
1972
  render_director_log(session),
1973
  render_trace(session),
1974
+ write_trace_json_file(session),
1975
  render_backend_settings(
1976
  session,
1977
  "openbmb",
 
2062
  with gr.Accordion("Trace / Debug", open=False):
2063
  trace_output = gr.Textbox(
2064
  value=EMPTY_TRACE,
2065
+ label="Trace JSON Preview",
2066
+ lines=10,
2067
  interactive=False,
2068
  elem_classes=["no-field-label"],
2069
  )
2070
+ trace_download = gr.DownloadButton(
2071
+ "Download Trace JSON",
2072
+ value=None,
2073
+ elem_classes=["cue-action"],
2074
+ )
2075
  with gr.Accordion("Backend", open=False):
2076
  backend_select = gr.Dropdown(
2077
  choices=BACKEND_CHOICES,
 
2126
  max_new_tokens_input,
2127
  temperature_input,
2128
  ],
2129
+ outputs=[
2130
+ session_state,
2131
+ stage_output,
2132
+ transcript_output,
2133
+ director_output,
2134
+ trace_output,
2135
+ trace_download,
2136
+ backend_output,
2137
+ ],
2138
  )
2139
  run_one_button.click(
2140
  advance_one_beat,
 
2145
  max_new_tokens_input,
2146
  temperature_input,
2147
  ],
2148
+ outputs=[
2149
+ session_state,
2150
+ stage_output,
2151
+ transcript_output,
2152
+ director_output,
2153
+ trace_output,
2154
+ trace_download,
2155
+ backend_output,
2156
+ ],
2157
  )
2158
  run_full_button.click(
2159
  advance_full_act,
 
2165
  temperature_input,
2166
  deterministic_full_act_input,
2167
  ],
2168
+ outputs=[
2169
+ session_state,
2170
+ stage_output,
2171
+ transcript_output,
2172
+ director_output,
2173
+ trace_output,
2174
+ trace_download,
2175
+ backend_output,
2176
+ ],
2177
  )
2178
  throw_prop_button.click(
2179
  throw_audience_prop,
 
2185
  max_new_tokens_input,
2186
  temperature_input,
2187
  ],
2188
+ outputs=[
2189
+ session_state,
2190
+ stage_output,
2191
+ transcript_output,
2192
+ director_output,
2193
+ trace_output,
2194
+ trace_download,
2195
+ backend_output,
2196
+ ],
2197
  )
2198
  summon_actor_button.click(
2199
  summon_audience_actor,
 
2205
  max_new_tokens_input,
2206
  temperature_input,
2207
  ],
2208
+ outputs=[
2209
+ session_state,
2210
+ stage_output,
2211
+ transcript_output,
2212
+ director_output,
2213
+ trace_output,
2214
+ trace_download,
2215
+ backend_output,
2216
+ ],
2217
  )
2218
  request_finale_button.click(
2219
  request_audience_finale,
 
2224
  max_new_tokens_input,
2225
  temperature_input,
2226
  ],
2227
+ outputs=[
2228
+ session_state,
2229
+ stage_output,
2230
+ transcript_output,
2231
+ director_output,
2232
+ trace_output,
2233
+ trace_download,
2234
+ backend_output,
2235
+ ],
2236
  )
2237
  warm_up_button.click(
2238
  warm_up_backend,
2239
  inputs=[session_state, max_new_tokens_input, temperature_input],
2240
+ outputs=[session_state, director_output, trace_output, trace_download, backend_output],
2241
  )
2242
  reset_button.click(
2243
  reset_show,
 
2250
  transcript_output,
2251
  director_output,
2252
  trace_output,
2253
+ trace_download,
2254
  backend_select,
2255
  director_mode_select,
2256
  max_new_tokens_input,
puppet_theater/actions.py CHANGED
@@ -1,4 +1,5 @@
1
  from puppet_theater.models import Actor, TheaterSession
 
2
 
3
 
4
  MAX_ACTORS = 4
@@ -13,7 +14,13 @@ def throw_prop(session: TheaterSession | None, prop_name: str) -> TheaterSession
13
  session.latest_prop = prop
14
  session.latest_audience_action = f"Audience threw {prop} onto the stage."
15
  session.director_log.append(session.latest_audience_action)
16
- session.trace_events.append(f"prop_thrown:{prop}")
 
 
 
 
 
 
17
  return session
18
 
19
 
@@ -25,7 +32,14 @@ def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSess
25
  if len(session.actors) >= MAX_ACTORS:
26
  session.latest_audience_action = "Audience tried to summon an actor, but the stage is full."
27
  session.director_log.append(session.latest_audience_action)
28
- session.trace_events.append("actor_summon_skipped:stage_full")
 
 
 
 
 
 
 
29
  return session
30
 
31
  actor = Actor(
@@ -39,7 +53,13 @@ def summon_actor(session: TheaterSession | None, actor_name: str) -> TheaterSess
39
  session.actors.append(actor)
40
  session.latest_audience_action = f"Audience summoned {name}."
41
  session.director_log.append(session.latest_audience_action)
42
- session.trace_events.append(f"actor_summoned:{name}")
 
 
 
 
 
 
43
  return session
44
 
45
 
@@ -50,5 +70,10 @@ def request_finale(session: TheaterSession | None) -> TheaterSession | None:
50
  session.finale_requested = True
51
  session.latest_audience_action = "Audience requested the finale."
52
  session.director_log.append(session.latest_audience_action)
53
- session.trace_events.append("finale_requested")
 
 
 
 
 
54
  return session
 
1
  from puppet_theater.models import Actor, TheaterSession
2
+ from puppet_theater.trace import add_trace_event
3
 
4
 
5
  MAX_ACTORS = 4
 
14
  session.latest_prop = prop
15
  session.latest_audience_action = f"Audience threw {prop} onto the stage."
16
  session.director_log.append(session.latest_audience_action)
17
+ add_trace_event(
18
+ session,
19
+ "audience_action",
20
+ audience_action="throw_prop",
21
+ prop=prop,
22
+ action_summary=session.latest_audience_action,
23
+ )
24
  return session
25
 
26
 
 
32
  if len(session.actors) >= MAX_ACTORS:
33
  session.latest_audience_action = "Audience tried to summon an actor, but the stage is full."
34
  session.director_log.append(session.latest_audience_action)
35
+ add_trace_event(
36
+ session,
37
+ "audience_action",
38
+ audience_action="summon_actor",
39
+ validation_status="skipped_stage_full",
40
+ fallback_used=False,
41
+ action_summary=session.latest_audience_action,
42
+ )
43
  return session
44
 
45
  actor = Actor(
 
53
  session.actors.append(actor)
54
  session.latest_audience_action = f"Audience summoned {name}."
55
  session.director_log.append(session.latest_audience_action)
56
+ add_trace_event(
57
+ session,
58
+ "audience_action",
59
+ audience_action="summon_actor",
60
+ summoned_actor=name,
61
+ action_summary=session.latest_audience_action,
62
+ )
63
  return session
64
 
65
 
 
70
  session.finale_requested = True
71
  session.latest_audience_action = "Audience requested the finale."
72
  session.director_log.append(session.latest_audience_action)
73
+ add_trace_event(
74
+ session,
75
+ "audience_action",
76
+ audience_action="request_finale",
77
+ action_summary=session.latest_audience_action,
78
+ )
79
  return session
puppet_theater/director.py CHANGED
@@ -17,6 +17,7 @@ from puppet_theater.backends import (
17
  )
18
  from puppet_theater.models import Actor, Beat, DirectorDecision, TheaterSession
19
  from puppet_theater.prompts import DIRECTOR_DECISION_PROMPT
 
20
 
21
 
22
  BEAT_ARC = [
@@ -344,7 +345,12 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
344
 
345
  if session.beat_index >= session.max_beats:
346
  session.director_log.append("Curtain already fallen; no new beat added.")
347
- session.trace_events.append("beat_skipped:curtain_already_fallen")
 
 
 
 
 
348
  return session
349
 
350
  director_generation = choose_director_decision(session)
@@ -368,29 +374,31 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
368
  session.director_log.append(f"Director model id: {director_generation.model_id}.")
369
  if director_generation.error:
370
  session.director_log.append(f"Director fallback reason: {director_generation.error}.")
371
- session.trace_events.append(
372
- "director_decision:"
373
- f"mode={director_generation.director_mode}:"
374
- f"speaker={speaker.name}:"
375
- f"beat={decision.beat_type}:"
376
- f"uses_prop={decision.uses_prop}:"
377
- f"reveal_secret={decision.reveal_secret}:"
378
- f"should_end={decision.should_end_scene}:"
379
- f"validation={director_generation.validation_status}:"
380
- f"fallback={director_generation.fallback_used}:"
381
- f"model={director_generation.model_id or 'none'}:"
382
- f"latency_ms={director_generation.latency_ms}:"
383
- f"reason={_trace_text(director_generation.reason_summary)}"
 
 
384
  )
385
- if director_generation.fallback_used:
386
- session.trace_events.append(f"director_fallback_used:{director_generation.validation_status}")
387
  if director_generation.error:
388
- session.trace_events.append(
389
- "director_error:"
390
- f"mode={director_generation.director_mode}:"
391
- f"model={director_generation.model_id or 'none'}:"
392
- f"type={director_generation.validation_status}:"
393
- f"message={_trace_text(director_generation.error)}"
 
 
394
  )
395
 
396
  backend_generation = generate_actor_response(session, decision, speaker, prop)
@@ -410,7 +418,7 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
410
  if prop is not None:
411
  session.latest_prop = None
412
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
413
- session.trace_events.append(f"prop_used:{prop}:{speaker.name}")
414
 
415
  session.director_log.append(
416
  f"Beat {session.beat_index}/{session.max_beats}: {decision.beat_type} assigned to {speaker.name}."
@@ -426,39 +434,50 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
426
  if backend_generation.error:
427
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
428
 
429
- session.trace_events.append(f"beat_added:{session.beat_index}:{decision.beat_type}")
430
- session.trace_events.append(
431
- "actor_response:"
432
- f"speaker={speaker.name}:"
433
- f"backend={backend_generation.backend_name}:"
434
- f"validation={backend_generation.validation_status}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  )
436
- if backend_generation.fallback_used:
437
- session.trace_events.append(f"fallback_used:{backend_generation.backend_name}:{backend_generation.validation_status}")
438
  if backend_generation.error:
439
- session.trace_events.append(
440
- "backend_error:"
441
- f"backend={backend_generation.backend_name}:"
442
- f"model={backend_generation.model_id or 'none'}:"
443
- f"type={backend_generation.validation_status}:"
444
- f"message={_trace_text(backend_generation.error)}"
 
 
 
 
445
  )
446
- session.trace_events.append(
447
- "backend_result:"
448
- f"{backend_generation.backend_name}:"
449
- f"model={backend_generation.model_id or 'none'}:"
450
- f"load_status={backend_generation.load_status}:"
451
- f"fallback={backend_generation.fallback_used}:"
452
- f"validation={backend_generation.validation_status}:"
453
- f"latency_ms={backend_generation.latency_ms}"
454
- )
455
 
456
  if decision.should_end_scene or decision.beat_type == "finale":
457
  session.beat_index = session.max_beats
458
  session.finale_requested = True
459
  session.director_log.append("Finale reached; curtain falls cleanly.")
460
- session.trace_events.append("finale_triggered")
461
- session.trace_events.append("scene_completed")
 
 
 
 
462
 
463
  return session
464
 
 
17
  )
18
  from puppet_theater.models import Actor, Beat, DirectorDecision, TheaterSession
19
  from puppet_theater.prompts import DIRECTOR_DECISION_PROMPT
20
+ from puppet_theater.trace import add_trace_event
21
 
22
 
23
  BEAT_ARC = [
 
345
 
346
  if session.beat_index >= session.max_beats:
347
  session.director_log.append("Curtain already fallen; no new beat added.")
348
+ add_trace_event(
349
+ session,
350
+ "beat_skipped",
351
+ validation_status="curtain_already_fallen",
352
+ fallback_used=False,
353
+ )
354
  return session
355
 
356
  director_generation = choose_director_decision(session)
 
374
  session.director_log.append(f"Director model id: {director_generation.model_id}.")
375
  if director_generation.error:
376
  session.director_log.append(f"Director fallback reason: {director_generation.error}.")
377
+ add_trace_event(
378
+ session,
379
+ "director_decision",
380
+ speaker=speaker.name,
381
+ beat_type=decision.beat_type,
382
+ backend_name=director_generation.director_mode,
383
+ model_id=director_generation.model_id,
384
+ latency_ms=director_generation.latency_ms,
385
+ validation_status=director_generation.validation_status,
386
+ fallback_used=director_generation.fallback_used,
387
+ fallback_reason=director_generation.error,
388
+ reason_summary=director_generation.reason_summary,
389
+ uses_prop=decision.uses_prop,
390
+ reveal_secret=decision.reveal_secret,
391
+ should_end_scene=decision.should_end_scene,
392
  )
 
 
393
  if director_generation.error:
394
+ add_trace_event(
395
+ session,
396
+ "director_error",
397
+ backend_name=director_generation.director_mode,
398
+ model_id=director_generation.model_id,
399
+ validation_status=director_generation.validation_status,
400
+ fallback_used=director_generation.fallback_used,
401
+ fallback_reason=director_generation.error,
402
  )
403
 
404
  backend_generation = generate_actor_response(session, decision, speaker, prop)
 
418
  if prop is not None:
419
  session.latest_prop = None
420
  session.director_log.append(f"{speaker.name} picked up {prop} and used it in the scene.")
421
+ add_trace_event(session, "prop_used", speaker=speaker.name, prop=prop)
422
 
423
  session.director_log.append(
424
  f"Beat {session.beat_index}/{session.max_beats}: {decision.beat_type} assigned to {speaker.name}."
 
434
  if backend_generation.error:
435
  session.director_log.append(f"Backend fallback reason: {backend_generation.error}.")
436
 
437
+ add_trace_event(
438
+ session,
439
+ "beat_added",
440
+ beat_index=session.beat_index,
441
+ beat_type=decision.beat_type,
442
+ speaker=speaker.name,
443
+ stage_effect=beat.stage_effect,
444
+ )
445
+ add_trace_event(
446
+ session,
447
+ "actor_response",
448
+ beat_index=session.beat_index,
449
+ speaker=speaker.name,
450
+ backend_name=backend_generation.backend_name,
451
+ model_id=backend_generation.model_id,
452
+ latency_ms=backend_generation.latency_ms,
453
+ validation_status=backend_generation.validation_status,
454
+ fallback_used=backend_generation.fallback_used,
455
+ fallback_reason=backend_generation.error,
456
+ load_status=backend_generation.load_status,
457
  )
 
 
458
  if backend_generation.error:
459
+ add_trace_event(
460
+ session,
461
+ "backend_error",
462
+ beat_index=session.beat_index,
463
+ speaker=speaker.name,
464
+ backend_name=backend_generation.backend_name,
465
+ model_id=backend_generation.model_id,
466
+ validation_status=backend_generation.validation_status,
467
+ fallback_used=backend_generation.fallback_used,
468
+ fallback_reason=backend_generation.error,
469
  )
 
 
 
 
 
 
 
 
 
470
 
471
  if decision.should_end_scene or decision.beat_type == "finale":
472
  session.beat_index = session.max_beats
473
  session.finale_requested = True
474
  session.director_log.append("Finale reached; curtain falls cleanly.")
475
+ add_trace_event(
476
+ session,
477
+ "scene_completed",
478
+ beat_index=session.beat_index,
479
+ reason_summary="Finale reached; curtain falls cleanly.",
480
+ )
481
 
482
  return session
483
 
puppet_theater/models.py CHANGED
@@ -1,6 +1,8 @@
1
  from dataclasses import dataclass, field
 
 
2
 
3
- from typing import Literal
4
 
5
  from pydantic import BaseModel, Field, field_validator
6
 
@@ -90,6 +92,8 @@ class TheaterSession:
90
  premise: str
91
  setting: str
92
  actors: list[Actor]
 
 
93
  beat_index: int = 0
94
  max_beats: int = 6
95
  transcript: list[Beat] = field(default_factory=list)
@@ -97,7 +101,7 @@ class TheaterSession:
97
  latest_prop: str | None = None
98
  latest_audience_action: str | None = None
99
  director_log: list[str] = field(default_factory=list)
100
- trace_events: list[str] = field(default_factory=list)
101
  finale_requested: bool = False
102
  backend_name: str = "deterministic"
103
  backend_model_id: str | None = None
 
1
  from dataclasses import dataclass, field
2
+ from datetime import datetime, timezone
3
+ from uuid import uuid4
4
 
5
+ from typing import Any, Literal
6
 
7
  from pydantic import BaseModel, Field, field_validator
8
 
 
92
  premise: str
93
  setting: str
94
  actors: list[Actor]
95
+ session_id: str = field(default_factory=lambda: uuid4().hex[:12])
96
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
97
  beat_index: int = 0
98
  max_beats: int = 6
99
  transcript: list[Beat] = field(default_factory=list)
 
101
  latest_prop: str | None = None
102
  latest_audience_action: str | None = None
103
  director_log: list[str] = field(default_factory=list)
104
+ trace_events: list[dict[str, Any] | str] = field(default_factory=list)
105
  finale_requested: bool = False
106
  backend_name: str = "deterministic"
107
  backend_model_id: str | None = None
puppet_theater/session.py CHANGED
@@ -1,4 +1,5 @@
1
  from puppet_theater.models import Actor, TheaterSession
 
2
 
3
 
4
  def _clean_premise(premise: str) -> str:
@@ -77,15 +78,7 @@ def create_show_from_premise(
77
  f"Setting selected: {setting}.",
78
  "Three puppet actors are waiting for the first beat.",
79
  ]
80
- trace_events = [
81
- "show_created",
82
- "actors_created:3",
83
- "director_plan_created",
84
- f"backend_active:{active_backend}",
85
- f"director_mode_active:{active_director_mode}",
86
- ]
87
-
88
- return TheaterSession(
89
  show_title=show_title,
90
  premise=cleaned_premise,
91
  setting=setting,
@@ -97,7 +90,7 @@ def create_show_from_premise(
97
  latest_prop=None,
98
  latest_audience_action=None,
99
  director_log=director_log,
100
- trace_events=trace_events,
101
  finale_requested=False,
102
  backend_name=active_backend,
103
  backend_model_id=backend_model_id,
@@ -106,3 +99,21 @@ def create_show_from_premise(
106
  director_mode=active_director_mode,
107
  play_opening_curtain=True,
108
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from puppet_theater.models import Actor, TheaterSession
2
+ from puppet_theater.trace import add_trace_event
3
 
4
 
5
  def _clean_premise(premise: str) -> str:
 
78
  f"Setting selected: {setting}.",
79
  "Three puppet actors are waiting for the first beat.",
80
  ]
81
+ session = TheaterSession(
 
 
 
 
 
 
 
 
82
  show_title=show_title,
83
  premise=cleaned_premise,
84
  setting=setting,
 
90
  latest_prop=None,
91
  latest_audience_action=None,
92
  director_log=director_log,
93
+ trace_events=[],
94
  finale_requested=False,
95
  backend_name=active_backend,
96
  backend_model_id=backend_model_id,
 
99
  director_mode=active_director_mode,
100
  play_opening_curtain=True,
101
  )
102
+ add_trace_event(
103
+ session,
104
+ "show_created",
105
+ backend_name=active_backend,
106
+ model_id=backend_model_id,
107
+ director_mode=active_director_mode,
108
+ actor_count=len(actors),
109
+ validation_status="valid",
110
+ fallback_used=False,
111
+ )
112
+ add_trace_event(session, "actors_created", actor_count=len(actors))
113
+ add_trace_event(
114
+ session,
115
+ "director_plan_created",
116
+ director_mode=active_director_mode,
117
+ reason_summary="Deterministic six-beat show plan created.",
118
+ )
119
+ return session
puppet_theater/trace.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from typing import Any
9
+
10
+ from puppet_theater.models import TheaterSession
11
+
12
+
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\"'<>:]+)*"),
19
+ re.compile(r"/tmp/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
20
+ ]
21
+
22
+
23
+ def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
24
+ event: dict[str, Any] = {
25
+ "type": sanitize_value(event_type),
26
+ "timestamp": datetime.now(timezone.utc).isoformat(),
27
+ "step": len(session.trace_events) + 1,
28
+ }
29
+ if session.beat_index is not None:
30
+ event["beat_index"] = session.beat_index
31
+ for key, value in fields.items():
32
+ if value is not None:
33
+ event[key] = sanitize_value(value)
34
+ session.trace_events.append(event)
35
+
36
+
37
+ def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
38
+ if session is None:
39
+ return None
40
+
41
+ actor_model_id = session.backend_model_id
42
+ director_model_id = _model_id_for_mode(session.director_mode)
43
+ active_model_id = actor_model_id or director_model_id
44
+ return {
45
+ "app_name": APP_NAME,
46
+ "trace_version": TRACE_VERSION,
47
+ "session_id": sanitize_value(session.session_id),
48
+ "created_at": sanitize_value(session.created_at),
49
+ "premise": sanitize_value(session.premise),
50
+ "title": sanitize_value(session.show_title),
51
+ "setting": sanitize_value(session.setting),
52
+ "active_backend": sanitize_value(session.backend_name),
53
+ "actor_backend": sanitize_value(session.backend_name),
54
+ "director_mode": sanitize_value(session.director_mode),
55
+ "director_backend": sanitize_value(session.director_mode),
56
+ "model_id": sanitize_value(active_model_id),
57
+ "actor_model_id": sanitize_value(actor_model_id),
58
+ "director_model_id": sanitize_value(director_model_id),
59
+ "events": normalize_trace_events(session),
60
+ }
61
+
62
+
63
+ def render_trace_json(session: TheaterSession | None) -> str:
64
+ payload = export_trace(session)
65
+ if payload is None:
66
+ return "No trace events yet."
67
+ return json.dumps(payload, indent=2, sort_keys=False)
68
+
69
+
70
+ def write_trace_json_file(session: TheaterSession | None) -> str | None:
71
+ payload = export_trace(session)
72
+ if payload is None:
73
+ return None
74
+
75
+ safe_session_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(payload["session_id"]))[:48] or "session"
76
+ filename = f"ai-puppet-theater-trace-{safe_session_id}.json"
77
+ path = os.path.join(tempfile.gettempdir(), filename)
78
+ with open(path, "w", encoding="utf-8") as trace_file:
79
+ json.dump(payload, trace_file, indent=2, ensure_ascii=False)
80
+ trace_file.write("\n")
81
+ return path
82
+
83
+
84
+ def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
85
+ normalized: list[dict[str, Any]] = []
86
+ for index, raw_event in enumerate(session.trace_events, start=1):
87
+ if isinstance(raw_event, dict):
88
+ event = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
89
+ event.setdefault("type", "event")
90
+ event.setdefault("step", index)
91
+ normalized.append(event)
92
+ continue
93
+
94
+ normalized.append(
95
+ {
96
+ "type": "legacy_event",
97
+ "step": index,
98
+ "message": sanitize_value(raw_event),
99
+ }
100
+ )
101
+ return normalized
102
+
103
+
104
+ def sanitize_value(value: Any) -> Any:
105
+ if isinstance(value, dict):
106
+ return {
107
+ str(key): sanitize_value(item)
108
+ for key, item in value.items()
109
+ if item is not None and not _is_sensitive_key(str(key))
110
+ }
111
+ if isinstance(value, list):
112
+ return [sanitize_value(item) for item in value]
113
+ if isinstance(value, tuple):
114
+ return [sanitize_value(item) for item in value]
115
+ if isinstance(value, str):
116
+ return _sanitize_text(value)
117
+ return value
118
+
119
+
120
+ def _sanitize_text(value: str) -> str:
121
+ text = " ".join(value.split())
122
+ if "Traceback (most recent call last)" in text:
123
+ text = "Traceback redacted"
124
+ for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
125
+ secret = os.getenv(secret_name)
126
+ if secret:
127
+ text = text.replace(secret, "[redacted]")
128
+ text = re.sub(r"\b(HF_TOKEN|HUGGINGFACEHUB_API_TOKEN)\s*=\s*\S+", r"\1=[redacted]", text)
129
+ for pattern in _PRIVATE_PATH_PATTERNS:
130
+ text = pattern.sub("[redacted-path]", text)
131
+ return text[:_MAX_TEXT_CHARS].rstrip()
132
+
133
+
134
+ def _is_sensitive_key(key: str) -> bool:
135
+ lowered = key.lower()
136
+ return "token" in lowered or "secret" in lowered or "password" in lowered
137
+
138
+
139
+ def _model_id_for_mode(mode: str | None) -> str | None:
140
+ if mode == "openbmb":
141
+ return os.getenv("OPENBMB_MODEL_ID", "openbmb/MiniCPM5-1B")
142
+ if mode == "hf_api":
143
+ return os.getenv("HF_API_MODEL_ID", "Qwen/Qwen3-4B-Instruct-2507:nscale")
144
+ return None