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