diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py
index e79816a..12714e4 100644
--- a/backend/app/routers/adventures.py
+++ b/backend/app/routers/adventures.py
@@ -354,6 +354,9 @@ async def generate_turn(
return
text = "".join(chunks).strip()
+ # The model's literal reply, kept for the Insights "Raw AI output" view —
+ # this still contains any world-state block before it gets stripped below.
+ raw_output = text
if not text:
# If the model streamed reasoning but no story text, it spent its whole
# budget thinking — say so instead of a mysterious "empty response".
@@ -390,6 +393,8 @@ async def generate_turn(
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
+ snapshot["raw_output"] = raw_output
+
ai_action = models.Action(
adventure_id=adventure.id,
index=ai_index,
diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py
index 82fb4bb..0903d27 100644
--- a/backend/tests/test_worldstate_integration.py
+++ b/backend/tests/test_worldstate_integration.py
@@ -130,6 +130,14 @@ def test_turn_applies_clamped_delta_and_strips_block(client):
# The state block is not shown to the player.
assert "```state" not in _last_ai_text(client.adv_id)
assert "goblin's blade" in _last_ai_text(client.adv_id)
+ # ...but the raw model reply (with the block) is kept for the Insights view.
+ db = SessionLocal()
+ try:
+ snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
+ finally:
+ db.close()
+ assert "```state" in snap["raw_output"]
+ assert '"player.hp": -80' in snap["raw_output"]
def test_world_state_endpoint(client):
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
index 3dfc424..000f6ff 100644
--- a/frontend/src/pages/Play.jsx
+++ b/frontend/src/pages/Play.jsx
@@ -769,6 +769,14 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
))}
{report.raw_output}
+