SP7 replaced the pager with chips, on the grounds that a chip could also offer "take this path" while a pager could only step. Driving it by hand said otherwise, and the reason is worth keeping: the chip meant two different things depending on where the reader was standing -- a real switch at the tip, a preview needing a second button above it further back. Two meanings in one control is what made the tree unusable. So: one control that does one thing. Stepping reads a take and nothing else, and it tells the server nothing, because reading is not a decision. The transcript below a take that is not live simply ends -- such a take is a leaf by construction, since whatever was played after the turn was played after the take that *is* live. The decision is made by writing, and `after_id` carries it. One step does reach the server and is still not a fork: a take with a story of its own lives on its own branch, so going there is a branch switch and only the server can say what is underneath. `branch_id` on the take is what tells the two apart without asking first. And a fork button on every turn but the opening. On the AI's it regenerates; on your own it opens the text so you can say something else. What the story made of the old take is kept, on the line it was written on. `selectVariant` and `forkFromAttempt` leave the client. Both endpoints stay -- tested, and `stand_on` is shared with the write path -- but the pager needs neither. 426 backend tests; lint and build clean. Not yet driven by hand: the frontend still has no test runner, so this needs the `--keep` fixture and eyes, exactly as SP7 did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
491 lines
16 KiB
Python
491 lines
16 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
|
|
# SP9: the pager. How many takes this turn has, and which one is on screen —
|
|
# `2/4`. Keyed on the parent, so it counts the takes of *this* turn and not
|
|
# every node that happens to share a depth, and so it keeps counting them
|
|
# after one has been forked onto a branch of its own.
|
|
#
|
|
# 1/1 for a turn nobody has retaken, which is most of them; the client draws
|
|
# no pager for a count of one. That is a different convention from
|
|
# `variant_count`, which says 0 for the same case — those two are the
|
|
# pre-SP9 pair and SP8 drops them.
|
|
take_count: int = 1
|
|
take_index: int = 0
|
|
# Which line this node is on, so the pager can tell the two kinds of step
|
|
# apart without asking the server first: a take on this branch is a leaf
|
|
# with nothing under it, and showing it is a local matter; a take on another
|
|
# branch has a story of its own, and going there is a branch switch.
|
|
branch_id: int | None = None
|
|
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
|
|
# See ActionOut.branch_id: it decides whether choosing this take is a local
|
|
# step or a branch switch.
|
|
branch_id: int | 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 TakeCreate(BaseModel):
|
|
"""Another take of a turn (SP9).
|
|
|
|
`text` is what the player is saying instead, and is theirs to write only
|
|
when the turn was theirs. An AI turn's other take is generated, so the field
|
|
is ignored there rather than refused — the client asks the same way for both
|
|
and the node type decides what happens.
|
|
"""
|
|
|
|
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
|