Keep every retried attempt instead of deleting it

Retry used to delete the last AI action and generate a replacement, so the
discarded narration was simply gone. The row survives now: each attempt is
appended to actions.variants with variant_index naming the live one, and a
ChatGPT-style pager under the message browses them.

Action.text still mirrors the active variant, so the context builder, memory
bank, summarizer and export needed no changes. A variant carries only what
differs between attempts -- the text, the reasoning, and the state it
produced -- never the assembled prompt, which is identical across attempts of
one turn and is the bulk of context_snapshot.

Only the last message can be switched, restoring the script/world state that
attempt produced; earlier turns were written as a continuation of whatever is
active there, so theirs are read-only previews.

Three things that would otherwise bite:

- generate_turn now wraps _generate_turn and watches for a save sentinel. If
  the generator ends without it (provider error, empty reply, script stop,
  client hangup) it re-applies the previous variant -- otherwise a failed
  retry leaves rolled-back stats under un-rolled-back text.
- Retry reuses the turn's own index rather than next_index, or the clock the
  world-state cooldowns run on advances on a re-run of the same turn.
- Editing a message rewrites the active variant too, or paging away and back
  silently reverts the edit.

Migrations 34/35 verified as an upgrade against a populated database, not
just a fresh schema. test_state_revert's retry test asserted the old delete
behaviour and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-02 20:44:06 +05:30
co-authored by Claude Opus 5
parent be6dbb0f24
commit 906ba423d8
9 changed files with 734 additions and 22 deletions
+239 -16
View File
@@ -319,6 +319,63 @@ def snapshot_world_state(adventure: models.Adventure) -> dict:
return copy.deepcopy(state)
# ---------- Retry history (variants) ----------
#
# Retry used to delete the AI action and generate a replacement. Now the row
# survives and every attempt is appended to `Action.variants`, with
# `variant_index` naming the live one. A variant carries only the parts that
# actually differ between attempts — the narration and the state it produced —
# never the assembled prompt, which is identical across attempts of one turn
# and is by far the biggest thing in `context_snapshot`.
# The per-attempt slices of context_snapshot. Everything else in the snapshot
# (system/story/memories) is shared by every attempt at the same turn.
VARIANT_SNAPSHOT_KEYS = ("world_state", "script", "raw_output")
def variant_of(action: models.Action, adventure: models.Adventure) -> dict:
"""Freeze an action's *current* content as a variant entry.
`adventure` supplies the resulting script/world state, so this must be
called before any rollback — those live values are this attempt's outcome.
"""
snapshot = action.context_snapshot if isinstance(action.context_snapshot, dict) else {}
entry = {
"text": action.text,
"reasoning": action.reasoning,
"script_state": snapshot_state(adventure),
"created_at": action.created_at.isoformat() if action.created_at else None,
}
for key in VARIANT_SNAPSHOT_KEYS:
if key in snapshot:
entry[key] = copy.deepcopy(snapshot[key])
return entry
def apply_variant(action: models.Action, adventure: models.Adventure, index: int) -> None:
"""Make variant `index` the live one: its text onto the action, its
outcome back onto the adventure."""
entry = action.variants[index]
action.text = entry.get("text", "")
action.reasoning = entry.get("reasoning")
snapshot = dict(action.context_snapshot) if isinstance(action.context_snapshot, dict) else {}
for key in VARIANT_SNAPSHOT_KEYS:
if key in entry:
snapshot[key] = copy.deepcopy(entry[key])
else:
snapshot.pop(key, None)
action.context_snapshot = snapshot
action.variant_index = index
if isinstance(entry.get("script_state"), dict):
adventure.script_state = copy.deepcopy(entry["script_state"])
# The world state this attempt left behind lives inside its own snapshot
# slice; absent for adventures with no RPG layer, where there's nothing to
# restore anyway.
world_state = (entry.get("world_state") or {}).get("state")
if isinstance(world_state, dict):
adventure.world_state = copy.deepcopy(world_state)
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
def update_adventure(
adventure_id: int,
@@ -409,9 +466,44 @@ async def generate_turn(
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
"""SSE generator: streams the AI continuation through the context/output
script hooks, then stores the result."""
script hooks, then stores the result.
With `retry_of`, the result is appended as a new variant of that existing
AI action rather than stored as a new one — the discarded attempt stays
readable. The caller must have seeded `retry_of.variants` and rolled the
adventure back first (see `retry_action`); if this generator ends without
saving, that rollback is undone so state can't drift from the text still
on screen."""
saved = False
try:
async for event in _generate_turn(adventure, db, pipeline, user, retry_of):
if event is _SAVED:
saved = True
continue
yield event
finally:
if retry_of is not None and not saved:
# Provider error, empty reply, a script stop, or the client hanging
# up: put the attempt we rolled away from back in charge.
apply_variant(retry_of, adventure, retry_of.variant_index)
db.commit()
# Sentinel yielded by _generate_turn once the action is committed, so the
# wrapper above knows the rollback must stand rather than be reversed.
_SAVED = object()
async def _generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
if cfg.using_demo:
@@ -495,7 +587,9 @@ async def generate_turn(
# RPG world state (Phase 12): pull the AI's state delta out of the reply,
# let the engine referee it, and strip the block from the shown text.
ai_index = next_index(adventure)
# A retry re-runs the *same* turn, so it keeps that turn's index — using
# next_index here would advance the clock the cooldown rules run on.
ai_index = retry_of.index if retry_of is not None else next_index(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
@@ -510,17 +604,34 @@ async def generate_turn(
snapshot["raw_output"] = raw_output
ai_action = models.Action(
adventure_id=adventure.id,
index=ai_index,
type="ai",
text=text,
reasoning="".join(reasoning_chunks).strip() or None,
context_snapshot=snapshot,
state_before=state_before,
world_state_before=world_state_before,
)
db.add(ai_action)
reasoning = "".join(reasoning_chunks).strip() or None
if retry_of is not None:
# Same row, one more attempt. Writing text/reasoning/snapshot through
# the variant list keeps the row and its history in step.
ai_action = retry_of
ai_action.context_snapshot = snapshot
history = list(ai_action.variants or [])
history.append({
"text": text,
"reasoning": reasoning,
"script_state": snapshot_state(adventure),
"created_at": models.utcnow().isoformat(),
**{k: copy.deepcopy(snapshot[k]) for k in VARIANT_SNAPSHOT_KEYS if k in snapshot},
})
ai_action.variants = history
apply_variant(ai_action, adventure, len(history) - 1)
else:
ai_action = models.Action(
adventure_id=adventure.id,
index=ai_index,
type="ai",
text=text,
reasoning=reasoning,
context_snapshot=snapshot,
state_before=state_before,
world_state_before=world_state_before,
)
db.add(ai_action)
adventure.updated_at = models.utcnow()
if cfg.using_demo:
# Successful demo turns count against the daily cap (checked up front
@@ -528,6 +639,7 @@ async def generate_turn(
auth.count_demo_turn(user)
db.commit()
db.refresh(ai_action)
yield _SAVED
yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()})
# Phase 6: fire-and-forget summarization/embedding (opens its own DB
# session). Skipped on the demo key — background AI calls would be
@@ -617,14 +729,26 @@ def retry_action(
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Delete the last AI action and regenerate from the same input."""
"""Regenerate the last AI action, keeping the discarded attempt.
The row survives: its current content is frozen as a variant, the shared
script/world state rolls back to the pre-turn snapshot, and the new attempt
is appended as the next variant. Nothing the AI wrote is ever thrown away.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
limits.rate_limit("turn", request, user)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
last_ai = None
try:
if adventure.actions and adventure.actions[-1].type == "ai":
last_ai = adventure.actions[-1]
# First retry: the row has no history yet, so record what's on
# screen as variant 0 before anything is rolled back — the live
# script/world state is precisely that attempt's outcome.
if not last_ai.variants:
last_ai.variants = [variant_of(last_ai, adventure)]
last_ai.variant_index = 0
# Roll the scoreboard back to before this AI turn's hooks ran, so
# regenerating starts fresh instead of stacking output mutations on
# top of the discarded attempt. NULL for pre-migration actions.
@@ -632,7 +756,6 @@ def retry_action(
adventure.script_state = copy.deepcopy(last_ai.state_before)
if last_ai.world_state_before is not None:
adventure.world_state = copy.deepcopy(last_ai.world_state_before)
db.delete(last_ai)
db.commit()
db.refresh(adventure)
except BaseException:
@@ -641,13 +764,87 @@ def retry_action(
return StreamingResponse(
with_turn_lock(
adventure_id,
generate_turn(adventure, db, ScriptPipeline(adventure, db), user),
generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai
),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@router.get(
"/{adventure_id}/actions/{action_id}/variants",
response_model=list[schemas.VariantOut],
)
def list_variants(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Every attempt made for one AI turn. Fetched on demand — the adventure
payload carries only the counts, so old narration doesn't ride along on
every page load."""
get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
variants = action.variants if isinstance(action.variants, list) else []
return [
schemas.VariantOut(
index=i,
text=entry.get("text", ""),
reasoning=entry.get("reasoning"),
created_at=entry.get("created_at"),
active=(i == action.variant_index),
)
for i, entry in enumerate(variants)
]
@router.post(
"/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut
)
def select_variant(
adventure_id: int,
action_id: int,
payload: schemas.VariantSelect,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Make an earlier attempt the live one again, restoring the script/world
state it produced.
Only the last action can be switched: the turns after an older one were
written as a continuation of the text that's currently active, so swapping
it out from underneath them would leave the story contradicting itself.
Earlier turns' attempts stay readable through `list_variants`.
"""
adventure = get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
variants = action.variants if isinstance(action.variants, list) else []
if not 0 <= payload.index < len(variants):
raise HTTPException(400, "No such attempt for this action")
if not adventure.actions or adventure.actions[-1].id != action.id:
raise HTTPException(
400,
"Only the latest message can be switched — the story has already "
"continued from this one.",
)
acquire_turn_lock(adventure_id)
try:
apply_variant(action, adventure, payload.index)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(action)
return action
finally:
_active_turns.discard(adventure_id)
@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut])
def undo_turn(
adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser
@@ -730,6 +927,15 @@ def export_adventure(
{
"index": a.index, "type": a.type, "text": a.text,
"reasoning": a.reasoning,
# Retry history, narration only — a bundle carries no context
# snapshots, so the per-attempt script/world state it would
# restore isn't there to export either.
"variants": [
{"text": v.get("text", ""), "reasoning": v.get("reasoning"),
"createdAt": v.get("created_at")}
for v in (a.variants or [])
] or None,
"variantIndex": a.variant_index,
"createdAt": a.created_at.isoformat(),
}
for a in adv.actions
@@ -812,12 +1018,22 @@ def import_adventure(
for i, a in enumerate(bundle.get("actions") or []):
if isinstance(a, dict) and str(a.get("text") or ""):
variants = [
{"text": str(v.get("text") or ""), "reasoning": v.get("reasoning"),
"created_at": v.get("createdAt")}
for v in (a.get("variants") or [])
if isinstance(v, dict)
]
db.add(models.Action(
adventure_id=adventure.id,
index=int(a.get("index", i)),
type=str(a.get("type") or "story")[:20], # VARCHAR(20)
text=str(a["text"]),
reasoning=str(a["reasoning"]) if a.get("reasoning") else None,
variants=variants or None,
# Clamped: a bundle could name an index its variant list
# doesn't have, which would make the pager point at nothing.
variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)),
))
db.commit()
@@ -1246,6 +1462,13 @@ def update_action(
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
action.text = payload.text
# Keep the live variant in step, or paging away and back would silently
# revert the edit.
variants = action.variants if isinstance(action.variants, list) else []
if 0 <= action.variant_index < len(variants):
history = copy.deepcopy(variants)
history[action.variant_index]["text"] = payload.text
action.variants = history
db.commit()
return action