M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.
This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:
visible active transcript position == stored head == authoritative state
Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)
A narrator edit no longer rewrites a row. It returns to the state before the
turn, takes the reader's exact text as the accepted narration, re-derives the
state that text implies, and becomes a new active continuation — while the
original narration keeps its words, its live flag and its whole future as
retained history. At the tip the correction is another take; with story below
it, it forks. No new history machinery: this is the existing fork/take/head
path with the reader's text in place of a generated reply. The §14A refusal
is therefore gone for narrator turns, and remains only for player input.
Pre-M5 positions
Migration 88 backfills the empty narrative document onto every action written
before M5, and a missing snapshot now restores the empty document instead of
leaving the previous position's state standing. Restoring to an old Save
Point no longer leaves a later position's entities and facts on screen.
Narrator context
Replayed history carries prose only; the machine-readable block is no longer
reconstructed into past turns, where it contradicted the authoritative state
in the same prompt. A fact withdrawn by a manual correction is now named as
no longer true, with the reader's reason, rather than silently dropped.
Also
- state_changes joins the action-list bulk read, removing one query per row.
- Extraction takes only the application's own protocol payload: an ordinary
```json or ```python block in a story survives, and a mangled proposal
still does not reach the reader.
Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
@@ -13,7 +13,8 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import (
|
||||
attempts, head, limits, memorybank, models, schemas, tree, worldstate,
|
||||
attempts, head, limits, memorybank, models, narrative, schemas, tree,
|
||||
worldstate,
|
||||
)
|
||||
from ...context import build_context, cursors
|
||||
from ...database import get_db
|
||||
@@ -207,25 +208,46 @@ async def _generate_turn(
|
||||
yield turn_error(detail)
|
||||
return
|
||||
|
||||
# RPG world state (Phase 12): read the AI's state delta out of the reply,
|
||||
# apply it through the engine, and strip the block from the displayed text.
|
||||
# M5: read the typed state proposal out of the reply, validate it, apply
|
||||
# what survives, and strip the block from the displayed text.
|
||||
#
|
||||
# A retry re-runs the same turn, so it is played at that turn's depth. The
|
||||
# cooldown rules run on a position in the story, and a second attempt at turn
|
||||
# 12 is still turn 12. This was `retry_of.index`, which held the same number
|
||||
# until SP4. Depth stays correct once a branch has its own numbering.
|
||||
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
|
||||
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
|
||||
# protocol, not the lifecycle. What changed is that the referee now works on
|
||||
# explicit typed events with absolute values, so an accepted proposal cannot
|
||||
# mean something other than it says.
|
||||
#
|
||||
# A retry re-runs the same turn, so it is played at that turn's depth. This
|
||||
# was `retry_of.index`, which held the same number until SP4. Depth stays
|
||||
# correct once a branch has its own numbering.
|
||||
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
if worldstate.has_schema(stat_schema):
|
||||
text, delta = worldstate.extract_delta(text)
|
||||
if not text.strip():
|
||||
yield turn_error("The AI returned only a state update and no story text.")
|
||||
return
|
||||
new_world_state, ws_report = worldstate.apply_delta(
|
||||
adventure.world_state, stat_schema, delta, ai_depth
|
||||
)
|
||||
adventure.world_state = new_world_state
|
||||
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
|
||||
|
||||
text, parsed, raw_block = narrative.extract.split(text)
|
||||
if not text.strip():
|
||||
yield turn_error("The AI returned only a state update and no story text.")
|
||||
return
|
||||
review = narrative.validate.review(
|
||||
parsed if parsed is not None else {"events": []},
|
||||
narrative.store.current(adventure),
|
||||
narrative.store.canon_of(adventure),
|
||||
)
|
||||
# Held until the action exists, because a proposal record names the node
|
||||
# whose narration produced it and the node has no id yet. Everything lands
|
||||
# in the single commit below (L01).
|
||||
# The coordinate is read off the node after it is placed, not guessed here:
|
||||
# `tree.place_action` assigns the branch, and a retry inherits the branch of
|
||||
# the attempt it replaces.
|
||||
pending_state = {
|
||||
"review": review,
|
||||
"parsed": parsed,
|
||||
"raw_block": raw_block,
|
||||
"unparseable": parsed is None and bool(raw_block),
|
||||
}
|
||||
snapshot["narrative_state"] = {
|
||||
"accepted": review.accepted,
|
||||
"rejected": [r.as_dict() for r in review.rejected],
|
||||
"status": review.status,
|
||||
}
|
||||
|
||||
snapshot["raw_output"] = raw_output
|
||||
# The cost the endpoint reports for the call, including how much of the
|
||||
@@ -243,7 +265,6 @@ async def _generate_turn(
|
||||
context_snapshot=snapshot,
|
||||
world_delta=world_delta_of(snapshot),
|
||||
)
|
||||
attempts.snapshot_outcome(adventure, ai_action)
|
||||
if retry_of is not None:
|
||||
attempts.add_attempt(db, adventure, retry_of, ai_action)
|
||||
db.add(ai_action)
|
||||
@@ -264,6 +285,33 @@ async def _generate_turn(
|
||||
else:
|
||||
tree.place_action(db, adventure, ai_action)
|
||||
db.add(ai_action)
|
||||
db.flush()
|
||||
# The state lands after the node exists and before the one commit, so the
|
||||
# narration, the head, the accepted events, the provenance and the snapshot
|
||||
# are one transaction. L01 forbids any window in which a turn looks accepted
|
||||
# while its state is half-written, and the cheapest guarantee is to have a
|
||||
# single commit rather than two that could get out of step.
|
||||
new_state, _proposal = narrative.store.record(
|
||||
db, adventure,
|
||||
review=pending_state["review"],
|
||||
raw_block=pending_state["raw_block"],
|
||||
parsed=pending_state["parsed"],
|
||||
action=ai_action,
|
||||
branch_id=ai_action.branch_id,
|
||||
depth=ai_action.depth,
|
||||
model_name=settings.model or "",
|
||||
source="accepted_story",
|
||||
)
|
||||
if pending_state["unparseable"]:
|
||||
_proposal.status = "unparseable"
|
||||
before_state = narrative.store.current(adventure)
|
||||
narrative.store.set_current(adventure, new_state)
|
||||
ai_action.state_changes = {
|
||||
"accepted": pending_state["review"].accepted,
|
||||
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
|
||||
"summary": narrative.apply.diff(before_state, new_state),
|
||||
}
|
||||
attempts.snapshot_outcome(adventure, ai_action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(ai_action)
|
||||
|
||||
Reference in New Issue
Block a user