Files
interactive-story/backend/app/schemas.py
T
parththakkar106andClaude Opus 5 ffb2fd5b0e Let a story go two ways at once
Attempts pile up at the tip as leaves and cost nothing. The moment the
player takes the story down one the line has already moved past, the two
futures have to coexist — so `tree.fork` gives that attempt a branch of
its own, forked at the depth just before it, and the line it leaves
keeps every turn it has.

One row is inserted and one row is moved. Nothing is copied: everything
before the fork is borrowed through the lineage cached on the branch
row. Measured on a 40-turn story forked twenty times — 21 branches, 140
rows, an 80-action story — the page load costs 31,652 B against the
31,433 B the same story flat costs, and a branch is 103 B of ancestry.

Nothing derived moves either, and that is the part worth keeping: a
memory hangs off the coordinate the parent's attempt still occupies, and
the lineage caps the parent one depth short of it. The fork simply
cannot see it, so it resummarizes that ground from the text it actually
tells, without a line of bookkeeping.

Two things had to change underneath. A new node's depth now comes from
the tip of its branch rather than from the adventure-wide `index`, which
would have left a hole in a fork's path the width of the other branch.
And undo stops at the fork — the turns before it belong to the branch
this one grew out of.

`GET /branches`, `POST /branches/{id}/switch` and
`POST /actions/{id}/fork` are the endpoints SP7's tree view is drawn on.

365 tests green, 18 of them new in test_branch_forking.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
2026-08-18 19:14:07 +05:30

438 lines
13 KiB
Python

from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
from . import images
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
# limits: Postgres enforces column lengths (SQLite never did), so anything
# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are
# generous abuse ceilings a legitimate player won't hit.
NAME_MAX = 200 # titles/names — VARCHAR(200)
TAGS_MAX = 500 # VARCHAR(500)
CARD_TYPE_MAX = 100 # VARCHAR(100)
PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
SCRIPT_MAX = 200_000 # one JS source
ACTION_MAX = 20_000 # one player action
MEMORY_TEXT_MAX = 5_000
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
# a client that downscales less aggressively without letting anyone park a
# multi-megabyte PNG in a row that gets read on every list request.
IMAGE_MAX = 400_000
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)]
Prose = Annotated[str, Field(max_length=PROSE_MAX)]
ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)]
ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ---------- Story cards ----------
class StoryCardBase(BaseModel):
type: CardType = ""
name: Name = ""
keys: Prose = ""
entry: Prose = ""
notes: Prose = ""
class StoryCardCreate(StoryCardBase):
scenario_id: int | None = None
adventure_id: int | None = None
class StoryCardUpdate(BaseModel):
type: CardType | None = None
name: Name | None = None
keys: Prose | None = None
entry: Prose | None = None
notes: Prose | None = None
class StoryCardOut(ORMModel, StoryCardBase):
id: int
scenario_id: int | None
adventure_id: int | None
# ---------- Scenarios ----------
class ScenarioBase(BaseModel):
title: Name = "Untitled Scenario"
description: Prose = ""
prompt: Prose = ""
memory: Prose = ""
authors_note: Prose = ""
ai_instructions: Prose = ""
tags: Tags = ""
# Cover art — an https URL or a base64 data URI. See app/images.py.
image: Image = ""
# Emoji/glyph shown when `image` is empty.
icon: Icon = ""
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
# None means no RPG layer.
stat_schema: dict | None = None
class ScenarioCreate(ScenarioBase):
pass
class ScenarioUpdate(BaseModel):
title: Name | None = None
description: Prose | None = None
prompt: Prose | None = None
memory: Prose | None = None
authors_note: Prose | None = None
ai_instructions: Prose | None = None
tags: Tags | None = None
image: Image | None = None
icon: Icon | None = None
stat_schema: dict | None = None
script_ids: list[int] | None = None
class ScenarioOut(ORMModel, ScenarioBase):
id: int
is_public: bool = False # shared demo content — read-only for everyone
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
scripts: list["ScriptOut"] = []
class ScenarioListItem(ORMModel):
id: int
title: str
description: str
tags: str
is_public: bool = False
updated_at: datetime
# Read off the row so `image_url` can be derived, but excluded from the
# response: a list of base64 data URIs would be megabytes of JSON.
image: str = Field("", exclude=True)
icon: str = ""
@computed_field
@property
def image_url(self) -> str:
return images.public_url(self.id, self.image, self.updated_at)
# ---------- Adventures ----------
class AdventureCreate(BaseModel):
scenario_id: int | None = None
title: Name | None = None
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
placeholders: dict[str, str] = {}
class AdventureUpdate(BaseModel):
title: Name | None = None
memory: Prose | None = None
authors_note: Prose | None = None
ai_instructions: Prose | None = None
story_summary: Prose | None = None
auto_summarize: bool | None = None
memory_bank_enabled: bool | None = None
class AdventureRefresh(BaseModel):
"""Body for "Update from scenario". `placeholders` supplies answers the
adventure has no stored value for (see AdventureCreate.placeholders); they
are merged over the stored ones and saved."""
placeholders: dict[str, str] = {}
class RefreshPlan(BaseModel):
"""What a refresh would change — drives the confirm dialog."""
scenario_id: int
scenario_title: str
has_changes: bool
# field name -> {"old": ..., "new": ...}, only for fields that differ.
fields: dict[str, dict] = {}
# {"added"|"updated"|"removed": [card name, ...]}
cards: dict[str, list[str]] = {}
# {"added"|"removed": [stat path, ...]} — live values are otherwise kept.
world_state: dict[str, list[str]] = {}
# ${Placeholder} names the scenario asks for that the adventure has no
# stored answer to; the client must collect these and send them back.
placeholders_needed: list[str] = []
class ActionOut(ORMModel):
id: int
adventure_id: int
index: int
type: str
text: str
reasoning: str | None = None
# Phase 12: compact RPG state changes for this turn (from the model property).
world_changes: list[dict] = []
# Retry history: how many attempts exist for this turn (0 = never retried)
# and which one is live. The attempts themselves come from
# GET /actions/{id}/variants so this payload stays small.
variant_count: int = 0
variant_index: int = 0
created_at: datetime
class VariantOut(BaseModel):
index: int
text: str
reasoning: str | None = None
created_at: str | None = None
active: bool = False
class VariantSelect(BaseModel):
index: int = Field(ge=0)
class BranchOut(ORMModel):
"""One line through the story tree (Phase 14, SP5).
Enough to draw the tree and nothing more: `fork_depth` is where this line
leaves its parent and `depth` is where it currently ends, so a fork is two
numbers rather than a walk. `own_actions` counts the turns played on this
branch itself — the rest of its story is borrowed from its ancestors, which
is the whole point and also why the number is smaller than the reader
expects.
"""
id: int
parent_branch_id: int | None = None
fork_depth: int | None = None
depth: int
own_actions: int = 0
is_head: bool = False
created_at: datetime
class ActionUpdate(BaseModel):
text: ActionText
class ActionCreate(BaseModel):
type: Literal["do", "say", "story", "continue"]
text: ActionText = ""
class AdventureOut(ORMModel):
id: int
scenario_id: int | None
title: str
memory: str
authors_note: str
ai_instructions: str
story_summary: str
auto_summarize: bool
memory_bank_enabled: bool
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
# The NEWEST window of the story, not all of it — older pages arrive from
# GET /{id}/actions as the reader scrolls up. `action_count` is the whole
# story's length, which is how the client knows there is more above.
actions: list[ActionOut] = []
action_count: int = 0
class ActionPage(BaseModel):
"""A slice of the story, counted back from the newest action."""
actions: list[ActionOut] = []
total: int = 0
# Whether anything older than this slice exists. Computed server-side so
# the client never has to do arithmetic on positions to find the end.
has_more: bool = False
# ---------- Memory bank (Phase 6) ----------
class MemoryOut(ORMModel):
id: int
adventure_id: int
text: str
pinned: bool
forgotten: bool
embedded: bool # model property: embedding vector present
use_count: int
last_used_at: datetime | None
source_start: int | None
source_end: int | None
created_at: datetime
class MemoryCreate(BaseModel):
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)]
class MemoryUpdate(BaseModel):
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)] | None = None
pinned: bool | None = None
forgotten: bool | None = None
class AdventureListItem(ORMModel):
id: int
scenario_id: int | None
scenario_title: str | None = None
title: str
updated_at: datetime
action_count: int = 0
# "Where you left off" — the tail of the most recent narrative beat, so a
# Continue card can show the story instead of just a turn count.
snippet: str = ""
# Cover art inherited from the parent scenario (see app/images.py).
image_url: str = ""
icon: str = ""
# ---------- Scripts ----------
class ScriptBase(BaseModel):
name: Name = "Untitled Script"
description: Prose = ""
library_js: ScriptSource = ""
input_js: ScriptSource = ""
context_js: ScriptSource = ""
output_js: ScriptSource = ""
class ScriptCreate(ScriptBase):
pass
class ScriptUpdate(BaseModel):
name: Name | None = None
description: Prose | None = None
library_js: ScriptSource | None = None
input_js: ScriptSource | None = None
context_js: ScriptSource | None = None
output_js: ScriptSource | None = None
class ScriptOut(ORMModel, ScriptBase):
id: int
created_at: datetime
updated_at: datetime
class ScriptTestRequest(BaseModel):
hook: Literal["input", "context", "output"]
text: Prose = ""
state: dict = {}
class AdventureScriptOut(ORMModel):
id: int
adventure_id: int
position: int
enabled: bool
name: str
description: str
library_js: str
input_js: str
context_js: str
output_js: str
# Set by the router (not stored): True when a syncable library version
# exists whose code differs from this copy; None when nothing to sync.
out_of_date: bool | None = None
class AdventureScriptUpdate(BaseModel):
enabled: bool | None = None
library_js: ScriptSource | None = None
input_js: ScriptSource | None = None
context_js: ScriptSource | None = None
output_js: ScriptSource | None = None
# ---------- Auth (Phase 8) ----------
class AuthCredentials(BaseModel):
email: Annotated[str, Field(max_length=320)] # VARCHAR(320)
# Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU
# an attacker would otherwise get for free.
password: Annotated[str, Field(max_length=128)]
# ---------- Settings ----------
class SettingsOut(ORMModel):
endpoint_url: str
# The key itself is never echoed back (encrypted at rest, write-only).
has_api_key: bool
model: str
api_mode: str
temperature: float
max_output_tokens: int
reasoning_max_tokens: int
context_token_budget: int
narrator_prompt: str
stream: bool
summary_model: str
embedding_model: str
memory_bank_capacity: int
memory_top_k: int
ScenarioOut.model_rebuild()
# ---------- AI Chat (power users) ----------
# A scratchpad for talking to a model directly, with no story framing. Nothing
# is persisted server-side, so these caps are purely per-request abuse limits.
CHAT_MESSAGE_MAX = 100_000 # one message
CHAT_TOTAL_MAX = 400_000 # whole conversation sent up per request
CHAT_MESSAGES_MAX = 200 # turns per request
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: Annotated[str, Field(max_length=CHAT_MESSAGE_MAX)]
class ChatRequest(BaseModel):
messages: Annotated[list[ChatMessage], Field(min_length=1, max_length=CHAT_MESSAGES_MAX)]
# Empty/omitted = fall back to the user's configured model.
model: Name | None = None
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
max_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
class SettingsUpdate(BaseModel):
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500)
# Encryption expands the stored value ~4/3 into the same VARCHAR(500):
# 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64).
api_key: Annotated[str, Field(max_length=256)] | None = None
model: Name | None = None
api_mode: Annotated[str, Field(max_length=20)] | None = None
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
# -1 = explicitly off (sends `reasoning: {effort: none}`); 0 = send nothing.
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
narrator_prompt: Prose | None = None
stream: bool | None = None
summary_model: Name | None = None
embedding_model: Name | None = None
memory_bank_capacity: Annotated[int, Field(ge=1, le=1000)] | None = None
memory_top_k: Annotated[int, Field(ge=1, le=50)] | None = None