Files
interactive-story/backend/app/schemas.py
T
parththakkar106andClaude Opus 5 e126bda387 Make the branch happen when you write, not when you look
Stepping between takes used to be two controls and two meanings. At the tip a
chip switched; above the tip it only *previewed*, and taking that line needed a
second button next to it. Which one you got depended on where you were standing,
which is the thing that made the tree unusable when it was driven by hand.

So the server stops caring that anyone is looking. Reading a take the story
moved past changes nothing and creates nothing. `ActionCreate.after_id` names
the node a turn is played after, and naming a take the story left is the first
moment the player has said which line they mean -- so that is where the fork
happens, and only there.

`stand_on` is the old fork endpoint's body, lifted out whole. It already knew
the two cases and got them right: at the tip the takes are leaves nobody built
on, so it is a switch and no branch is made; past the tip the line being left
keeps every turn it has, so the take needs a branch. Both callers now share it,
which is the point -- a fork asked for and a fork arrived at are the same move.

Six tests. Five fail with the grouping reverted to the coordinate, and the one
that does not is deliberate: naming the tip in `after_id` must stay an ordinary
turn that forks nothing, which guards against over-correcting rather than
against the original bug. The nesting test passes both ways too and is kept for
what it says, not for what it catches -- a coordinate separates C1's takes from
C2's by accident, because the fork has already put them on different branches.

415 tests.

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

460 lines
14 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)
BRANCH_NAME_MAX = 80 # what a player called one line of the story — VARCHAR(80)
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):
# Since SP4 every attempt is its own node, so each one has an id — and the
# client needs it: forking is addressed by the attempt being taken, not by
# its ordinal in a group that renumbers whenever one is added.
id: int
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
# NULL for a branch nobody has named. The client draws those from the fork
# depth rather than the server inventing one — see the column comment.
name: str | None = None
created_at: datetime
class BranchRename(BaseModel):
"""A name a player chose, or `null` to go back to being unnamed."""
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
class ActionUpdate(BaseModel):
text: ActionText
class ActionCreate(BaseModel):
type: Literal["do", "say", "story", "continue"]
text: ActionText = ""
# The node this action is played after (SP9). Omitted means "the tip",
# which is every ordinary turn.
#
# Naming a take the story moved past is how a branch gets made: stepping
# between takes costs nothing and creates nothing, and the fork happens on
# the first thing written below one. That is the only moment the player has
# said which line they mean — before it, they were reading.
after_id: int | None = None
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