`backend/app/routers/adventures.py` held 2353 lines and 35 endpoints. It is now a package of 14 modules, the largest 443 lines. The split moves text rather than rewriting it. An AST comparison against the old file confirms all 86 definitions are identical, and the OpenAPI schema still lists the same 35 operations. Names a test replaces now live in `turns.py` only, and other modules reach them as `turns.<name>`. Rebinding a re-exported alias changes the alias and leaves every caller reading the original, so the package root does not re-export them. A patch aimed at the old target raises `AttributeError` instead of passing while doing nothing. Tests and the fixtures in `backend/tools/` say `adventures.turns.<name>`. The same rule keeps the turn lock working. One module owns `_active_turns`, so one lock guards one set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
444 lines
18 KiB
Python
444 lines
18 KiB
Python
"""Playing a turn: the model call, the SSE stream, and the one-turn-at-a-time lock.
|
|
|
|
Everything a test needs to intercept lives here, and other modules reach it as
|
|
`turns.<name>` rather than importing it by value. That matters twice. The turn
|
|
lock guards one set only while one module owns it. And a test that replaces
|
|
`OpenAICompatibleProvider`, `generate_turn`, or `check_demo_cap` patches this
|
|
module, which every caller reads through.
|
|
"""
|
|
import json
|
|
import threading
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import (
|
|
analytics, attempts, auth, limits, memorybank, models, schemas, tree, worldstate,
|
|
)
|
|
from ...context import build_context, cursors
|
|
from ...database import get_db
|
|
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
|
from ...scripting import ScriptPipeline
|
|
from ..settings import get_settings
|
|
|
|
from .deps import CurrentUser, get_adventure_or_404, router
|
|
from .nodes import _move_to_after, next_depth, next_index
|
|
from .paging import annotate_takes
|
|
|
|
|
|
def world_delta_of(snapshot: dict | None) -> dict | None:
|
|
"""Returns the bulk-read slice of a context snapshot, for `Action.world_delta`.
|
|
|
|
`context_snapshot` is deferred because it holds the whole assembled prompt.
|
|
The parts that every action needs get their own small column instead: the
|
|
world-change chips, the emit block replayed into history, and the refusal
|
|
note fed back to the model. Update this function wherever a snapshot is
|
|
written.
|
|
|
|
Carry all three report lists, not just `applied`. `Action.world_changes`
|
|
marks a chip from `clamped` and builds its refusal chips from `rejected`,
|
|
and `worldstate.refusals` reads both. Storing `applied` alone left every
|
|
consumer unable to tell a refused change from one that worked, which is the
|
|
distinction this column exists to carry. The two extra lists are subsets of
|
|
one turn's block, so they cost a few hundred bytes per action at most.
|
|
"""
|
|
ws = (snapshot or {}).get("world_state")
|
|
if not isinstance(ws, dict):
|
|
return None
|
|
report = ws.get("report") or {}
|
|
return {
|
|
"delta": ws.get("delta") or {},
|
|
"applied": report.get("applied") or [],
|
|
"clamped": report.get("clamped") or [],
|
|
"rejected": report.get("rejected") or [],
|
|
}
|
|
|
|
|
|
# One turn at a time per adventure. The set lives in memory, which is enough for
|
|
# a single-process local app. Sync endpoints run in a threadpool, so the
|
|
# check-and-add needs a lock. The check also has to run during the request
|
|
# rather than when the SSE generator first runs. Otherwise two rapid requests
|
|
# both pass the check and generate concurrently.
|
|
_active_turns: set[int] = set()
|
|
_active_turns_guard = threading.Lock()
|
|
|
|
|
|
def acquire_turn_lock(adventure_id: int):
|
|
"""Claims the adventure's turn slot atomically. `with_turn_lock` releases it."""
|
|
with _active_turns_guard:
|
|
if adventure_id in _active_turns:
|
|
raise HTTPException(409, "A turn is already generating for this adventure.")
|
|
_active_turns.add(adventure_id)
|
|
|
|
|
|
async def with_turn_lock(adventure_id: int, gen):
|
|
"""Wraps an SSE generator so that it releases the `acquire_turn_lock` lock."""
|
|
try:
|
|
async for event in gen:
|
|
yield event
|
|
finally:
|
|
_active_turns.discard(adventure_id)
|
|
|
|
|
|
def format_player_input(action_type: str, text: str) -> str:
|
|
"""Formats player input the way AI Dungeon does."""
|
|
text = text.strip()
|
|
if action_type == "say":
|
|
text = text.strip('"')
|
|
if text and text[-1] not in ".!?…":
|
|
text += "."
|
|
return f'> You say "{text}"'
|
|
if action_type == "do":
|
|
if text.lower().startswith("you "):
|
|
text = text[4:]
|
|
if text and text[-1] not in ".!?…":
|
|
text += "."
|
|
return f"> You {text}"
|
|
return text # The "story" type is appended as raw text.
|
|
|
|
|
|
def sse(obj: dict) -> str:
|
|
return f"data: {json.dumps(obj)}\n\n"
|
|
|
|
|
|
def turn_error(detail: str, **extra) -> str:
|
|
"""Returns an SSE error for a turn that could not be produced, and counts it.
|
|
|
|
A failed turn is still an HTTP 200 response, so the middleware's status-code
|
|
tally cannot see it. This metric exists so that a demo whose model refuses
|
|
every request does not report as healthy.
|
|
"""
|
|
analytics.record(analytics.M_EVENT, analytics.EV_TURN_ERROR)
|
|
return sse({"type": "error", "detail": detail, **extra})
|
|
|
|
|
|
# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering`
|
|
# makes nginx-style reverse proxies, which hosted deploys use, flush each event
|
|
# immediately rather than buffer it.
|
|
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
|
|
|
|
|
def action_json(action: models.Action, db: Session | None = None) -> dict:
|
|
"""Serializes one action for the wire.
|
|
|
|
Passing `db` fills in the pager numbers, and a turn that was just played must
|
|
pass it. The attempt that turn created is often the second one at its
|
|
coordinate, so the message needs a pager that the client cannot infer from a
|
|
count of one. Without `db`, a retry showed no pager until the page reloaded.
|
|
The adventure GET has the same requirement and builds its window a third way.
|
|
"""
|
|
if db is not None:
|
|
annotate_takes(db, action.adventure_id, [action])
|
|
return schemas.ActionOut.model_validate(action).model_dump(mode="json")
|
|
|
|
|
|
async def generate_turn(
|
|
adventure: models.Adventure,
|
|
db: Session,
|
|
pipeline: ScriptPipeline,
|
|
user: models.User,
|
|
retry_of: models.Action | None = None,
|
|
):
|
|
"""Streams the AI continuation as SSE, then stores the result.
|
|
|
|
The continuation passes through the `context` and `output` script hooks.
|
|
|
|
If `retry_of` is set, the result is stored as a sibling of that AI action, at
|
|
the same turn and the same coordinate, and the discarded attempt stays where
|
|
it was written. Before calling, the caller must roll the adventure back to
|
|
the state before that turn. See `retry_action`. If this generator ends
|
|
without saving, the rollback is undone, so the state cannot diverge from the
|
|
text 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:
|
|
# The turn failed with a provider error, an empty reply, a script
|
|
# stop, or a disconnected client. No sibling was written, so the
|
|
# attempt on screen is still the live one. Restore the state it
|
|
# produced.
|
|
attempts.restore_state(adventure, retry_of)
|
|
db.commit()
|
|
|
|
|
|
# `_generate_turn` yields this sentinel once the action is committed. It tells
|
|
# the wrapper above to leave the rollback in place rather than reverse it.
|
|
_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)
|
|
# On a retry, the attempt being replaced is still the live node of its turn,
|
|
# because it stays live until a replacement exists. Filter it out of the
|
|
# context. Otherwise the model reads the attempt it is replacing as
|
|
# established story and writes a sequel to it.
|
|
replacing_id = retry_of.id if retry_of is not None else None
|
|
if cfg.using_demo:
|
|
# The server-funded key makes no embedding or summarization calls, so
|
|
# memory retrieval is skipped. If the bank is on, return a note.
|
|
memories = (
|
|
{"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."}
|
|
if adventure.memory_bank_enabled
|
|
else None
|
|
)
|
|
else:
|
|
memories = await memorybank.retrieve_memories(
|
|
adventure, settings, update_stats=True, exclude_action_id=replacing_id
|
|
)
|
|
system_text, story_text, snapshot = build_context(
|
|
adventure, settings, memories, exclude_action_id=replacing_id
|
|
)
|
|
|
|
# onModelContext: scripts read, and can rewrite, the whole assembled
|
|
# context.
|
|
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
|
|
modified, stop = pipeline.run("context", combined)
|
|
if stop:
|
|
yield sse({"type": "stopped", "script": pipeline.report()})
|
|
return
|
|
context_changed = modified != combined
|
|
parts = (
|
|
PromptParts(system="", story=modified)
|
|
if context_changed
|
|
else PromptParts(system=system_text, story=story_text)
|
|
)
|
|
snapshot["script"] = pipeline.report() | {
|
|
"context_changed": context_changed,
|
|
"context_before": combined if context_changed else None,
|
|
"context_after": modified if context_changed else None,
|
|
}
|
|
|
|
provider = OpenAICompatibleProvider(
|
|
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
|
|
settings.reasoning_max_tokens,
|
|
)
|
|
chunks: list[str] = []
|
|
reasoning_chunks: list[str] = []
|
|
try:
|
|
async for kind, chunk in provider.generate(
|
|
parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens
|
|
):
|
|
if kind == "reasoning":
|
|
reasoning_chunks.append(chunk)
|
|
yield sse({"type": "reasoning", "text": chunk})
|
|
else:
|
|
chunks.append(chunk)
|
|
yield sse({"type": "chunk", "text": chunk})
|
|
except ProviderError as exc:
|
|
yield turn_error(str(exc))
|
|
return
|
|
|
|
text = "".join(chunks).strip()
|
|
# The model's literal reply, kept for the Insights "Raw AI output" view. It
|
|
# still contains the world-state block, which the code below strips.
|
|
raw_output = text
|
|
if not text:
|
|
# The model streamed reasoning but no story text, so it spent its whole
|
|
# budget on reasoning. Report that rather than "empty response".
|
|
if reasoning_chunks:
|
|
detail = (
|
|
"The model used its entire token budget on reasoning and returned no "
|
|
'story text. Raise "Max output tokens" in Settings, set a "Reasoning '
|
|
'max tokens" cap, or switch to a non-reasoning model.'
|
|
)
|
|
else:
|
|
detail = "The AI returned an empty response."
|
|
yield turn_error(detail)
|
|
return
|
|
|
|
# onOutput
|
|
text, _ = pipeline.run("output", text)
|
|
if not text.strip():
|
|
yield turn_error("A script's output modifier returned empty text.")
|
|
return
|
|
snapshot["script"] = snapshot["script"] | pipeline.report()
|
|
|
|
# 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.
|
|
#
|
|
# 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.
|
|
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}
|
|
|
|
snapshot["raw_output"] = raw_output
|
|
# The cost the endpoint reports for the call, including how much of the
|
|
# prompt came from cache rather than being billed in full. This is recorded
|
|
# per attempt, next to the prompt it priced.
|
|
snapshot["usage"] = provider.last_usage
|
|
|
|
reasoning = "".join(reasoning_chunks).strip() or None
|
|
ai_action = models.Action(
|
|
adventure_id=adventure.id,
|
|
# A sibling shares the turn's legacy index for the same reason it
|
|
# shares its depth: it is the same turn. Two rows then hold one index,
|
|
# which is safe, because `max_action_index` takes a maximum rather than
|
|
# a count, and nothing else reads the column.
|
|
index=retry_of.index if retry_of is not None else next_index(adventure),
|
|
depth=ai_depth,
|
|
type="ai",
|
|
text=text,
|
|
reasoning=reasoning,
|
|
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)
|
|
# The text at this coordinate changed, so anything derived from it no
|
|
# longer describes the story. Withdraw the memory attached to the node
|
|
# and return that stretch to both passes. Before SP4 this code was
|
|
# unreachable, because the summarizer held the newest action back until
|
|
# a turn landed on top of it. See `memorybank`.
|
|
memorybank.forget_node(db, adventure, retry_of)
|
|
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
|
|
db.flush()
|
|
attempts.renumber(attempts.group(db, ai_action))
|
|
else:
|
|
tree.place_action(db, adventure, ai_action)
|
|
db.add(ai_action)
|
|
adventure.updated_at = models.utcnow()
|
|
if cfg.using_demo:
|
|
# Successful demo turns count against the daily cap, which the endpoint
|
|
# checks before the turn starts. A failed provider call above returns
|
|
# before this line.
|
|
auth.count_demo_turn(user)
|
|
db.commit()
|
|
# Count the turn here, after every path on which it could still have failed,
|
|
# so the number means "stories advanced" rather than "requests attempted".
|
|
# The demo tally counts those same turns as spend on the server-funded key.
|
|
analytics.record_event(analytics.EV_TURN, user)
|
|
if cfg.using_demo:
|
|
analytics.record(analytics.M_EVENT, analytics.EV_DEMO_TURN)
|
|
db.refresh(ai_action)
|
|
yield _SAVED
|
|
yield sse({"type": "done", "action": action_json(ai_action, db), "script": pipeline.report()})
|
|
# Phase 6: schedule summarization and embedding without waiting for them.
|
|
# The task opens its own database session. It is skipped on the demo key,
|
|
# because background AI calls are unmetered spend on the server-funded
|
|
# key.
|
|
if not cfg.using_demo:
|
|
memorybank.schedule_post_turn(adventure)
|
|
|
|
|
|
def check_demo_cap(db: Session, user: models.User) -> None:
|
|
"""Checks the demo cap before a turn starts.
|
|
|
|
Checking first avoids storing a capped player's input and then leaving it
|
|
without a reply.
|
|
"""
|
|
settings = get_settings(db, user)
|
|
if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0:
|
|
raise HTTPException(429, auth.DEMO_CAP_MESSAGE)
|
|
|
|
|
|
async def run_player_turn(
|
|
adventure: models.Adventure,
|
|
db: Session,
|
|
payload: schemas.ActionCreate,
|
|
user: models.User,
|
|
preformatted: bool = False,
|
|
):
|
|
"""Plays a player's turn: their action, then the reply to it.
|
|
|
|
`preformatted` means the text already carries the `> You ...` conventions and
|
|
is written as-is. That applies when the player retakes a turn they already
|
|
played (SP9). The editor is seeded with the stored text, which is already
|
|
formatted, and a plain edit puts that same text in the box and writes it back
|
|
verbatim. Formatting it a second time produces `> You > You ...`.
|
|
"""
|
|
pipeline = ScriptPipeline(adventure, db)
|
|
|
|
# An empty do, say, or story action behaves as a continue.
|
|
if payload.type != "continue" and payload.text.strip():
|
|
# onInput reads the formatted text, as in AI Dungeon: "> You ...".
|
|
formatted = (
|
|
payload.text.strip() if preformatted
|
|
else format_player_input(payload.type, payload.text)
|
|
)
|
|
modified, stop = pipeline.run("input", formatted)
|
|
if not modified.strip():
|
|
yield turn_error("A script's input modifier returned empty text.",
|
|
script=pipeline.report())
|
|
return
|
|
player_action = models.Action(
|
|
adventure_id=adventure.id,
|
|
index=next_index(adventure),
|
|
depth=next_depth(adventure),
|
|
type=payload.type,
|
|
text=modified,
|
|
)
|
|
# The state after the input hook has run. The node leaves this state
|
|
# behind. The AI turn after it starts here, and a retry of that turn
|
|
# rolls back to here.
|
|
attempts.snapshot_outcome(adventure, player_action)
|
|
tree.place_action(db, adventure, player_action)
|
|
db.add(player_action)
|
|
db.commit()
|
|
db.refresh(player_action)
|
|
# The new action was added through its foreign key, so the loaded
|
|
# `adventure.actions` collection is stale. Without this expire,
|
|
# `build_context` and `next_index` for the AI action do not see the
|
|
# player action that was just saved.
|
|
db.expire(adventure, ["actions"])
|
|
yield sse({"type": "player", "action": action_json(player_action, db)})
|
|
if stop:
|
|
# If onInput returns `{ stop: true }`, skip the AI call.
|
|
yield sse({"type": "stopped", "script": pipeline.report()})
|
|
return
|
|
|
|
async for event in generate_turn(adventure, db, pipeline, user):
|
|
yield event
|
|
|
|
|
|
@router.post("/{adventure_id}/actions")
|
|
def create_action(
|
|
adventure_id: int,
|
|
payload: schemas.ActionCreate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
):
|
|
adventure = get_adventure_or_404(adventure_id, db, user)
|
|
limits.rate_limit("turn", request, user)
|
|
limits.check_row_cap("actions", db, user, adventure=adventure)
|
|
check_demo_cap(db, user)
|
|
acquire_turn_lock(adventure_id)
|
|
try:
|
|
_move_to_after(db, adventure, payload.after_id)
|
|
except BaseException:
|
|
_active_turns.discard(adventure_id)
|
|
raise
|
|
return StreamingResponse(
|
|
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
|
|
media_type="text/event-stream",
|
|
headers=SSE_HEADERS,
|
|
)
|