Compare commits
179
Commits
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.claude
|
||||
plan
|
||||
*.md
|
||||
LICENSE
|
||||
start.ps1
|
||||
start.sh
|
||||
|
||||
# Secrets & local data — never in the image
|
||||
*.env
|
||||
backend/openrouter_key.env
|
||||
*.db
|
||||
|
||||
# Build/runtime artifacts rebuilt inside the image
|
||||
backend/.venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
@@ -0,0 +1,9 @@
|
||||
# Shell scripts must stay LF or the shebang/exec breaks on Linux/macOS,
|
||||
# including when a Windows checkout is COPY'd into a Docker build.
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Vendored web fonts: binary, and never to be line-ending-normalized.
|
||||
*.woff2 binary
|
||||
*.woff binary
|
||||
@@ -0,0 +1,74 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12" # matches the Dockerfile
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
backend/requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: python -m pytest tests/ -q
|
||||
|
||||
frontend:
|
||||
name: Frontend lint + build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24" # matches the Dockerfile
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
name: Docker image builds
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Secrets
|
||||
*.env
|
||||
backend/openrouter_key.env
|
||||
|
||||
# Python
|
||||
backend/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Misc
|
||||
.claude/
|
||||
|
||||
# Phase 0B candidate clones, spikes and captured runs. Research scratch that
|
||||
# predates the fork: it holds virtualenvs, databases and downloaded models.
|
||||
/phase0b/
|
||||
|
||||
# Phase 8: auto-generated session/encryption secret (lives next to the DB)
|
||||
secret.key
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# Development and local operation
|
||||
|
||||
This is the Adventure Storyteller production fork of AI-DnD. `PROVENANCE.md`
|
||||
records where the code came from; `planning/` holds the product specification
|
||||
and milestone plan.
|
||||
|
||||
Everything here assumes the local-only rule from
|
||||
`planning/DECISIONS/004-local-only-production.md`: after setup, ordinary story
|
||||
play must work with **no Internet access at all**. Setup itself downloads
|
||||
dependencies and models; playing does not.
|
||||
|
||||
## Versions this was built and tested on
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| OS | Linux (Ubuntu 24.04 userland), x86-64, 4 cores, 15 GB RAM, no GPU |
|
||||
| Python | 3.12.3 |
|
||||
| Node | 22.23.1, npm 10.9.8 (the Dockerfile builds the SPA on Node 24) |
|
||||
| Ollama | `ollama/ollama:latest` in Docker |
|
||||
| Models | `qwen2.5:3b-instruct` (narrator), `nomic-embed-text` (memory bank) |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Backend, from the exact tested dependency closure.
|
||||
python3 -m venv backend/.venv
|
||||
backend/.venv/bin/pip install -r backend/requirements.lock
|
||||
|
||||
# Frontend.
|
||||
cd frontend && npm ci && cd ..
|
||||
```
|
||||
|
||||
`backend/requirements.lock` pins every version, transitive ones included.
|
||||
`backend/requirements.txt` states the ranges the code actually needs and stays
|
||||
the file you edit; regenerate the lock after a deliberate upgrade (the header in
|
||||
the lock says how).
|
||||
|
||||
This is the only step that needs the Internet. It downloads Python and npm
|
||||
packages; it does **not** download a tokenizer or a font, because both are
|
||||
vendored in the tree — see "What was made offline-safe" below.
|
||||
|
||||
You also need the models, once:
|
||||
|
||||
```bash
|
||||
ollama pull qwen2.5:3b-instruct
|
||||
ollama pull nomic-embed-text # only if you want the memory bank
|
||||
```
|
||||
|
||||
There is no account to create and nothing to log in to. The application is
|
||||
single-user: whoever can reach it on loopback is its owner.
|
||||
|
||||
## Running
|
||||
|
||||
**Development** — backend on `:8000`, Vite dev server on `:5173`:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Production-shaped** — one server, SPA served by FastAPI:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build && cd ..
|
||||
cd backend && .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000>.
|
||||
|
||||
**Docker**:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### The listener is loopback, and stays loopback
|
||||
|
||||
`start.sh`, `start.ps1` and the production command above all pass
|
||||
`--host 127.0.0.1` explicitly. `docker-compose.yml` publishes
|
||||
`127.0.0.1:8000:8000` — the process inside the container listens on `0.0.0.0`
|
||||
because a published port cannot reach anything else, but the port is only
|
||||
bound on the host's loopback.
|
||||
|
||||
That is a requirement, not a preference. In local mode the storyteller API is
|
||||
single-user and unauthenticated: anything that can reach it can read and
|
||||
rewrite every campaign. Putting Ollama on another machine (below) does **not**
|
||||
change this — it is an outbound connection and needs no inbound exposure.
|
||||
|
||||
If you publish the port to `0.0.0.0` anyway, you have made a deliberate
|
||||
decision that this project's threat model does not cover
|
||||
(`planning/SECURITY-THREAT-MODEL.md`).
|
||||
|
||||
## Pointing the storyteller at Ollama
|
||||
|
||||
The endpoint, model and (unused) API key are **runtime settings stored in the
|
||||
database**, not environment variables. Set them on the app's Settings page, or
|
||||
with one request:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
|
||||
"api_mode":"chat","api_key":"","max_output_tokens":200,
|
||||
"context_token_budget":4096}'
|
||||
```
|
||||
|
||||
`POST /api/settings/test` (the **Test connection** button) returns
|
||||
`{"ok": true, "models": [...]}` and is the fastest way to tell a wrong endpoint
|
||||
from a missing model. When it fails it says which kind of failure it was, and
|
||||
they need different things done about them:
|
||||
|
||||
| `kind` | What it means |
|
||||
| --- | --- |
|
||||
| `rejected` | The endpoint is outside the policy below. Not a network problem. |
|
||||
| `unreachable` | Nothing answered. Ollama is not running there, or the port is wrong. |
|
||||
| `tls` | The certificate did not verify — install the CA (see below). |
|
||||
| `timeout` | It accepted the connection and then said nothing. |
|
||||
| `http` | It answered with an error status; the body is included. |
|
||||
|
||||
A successful test also warns when the endpoint is reachable but has no model by
|
||||
the configured name, which is the commonest way for a correct endpoint to still
|
||||
fail every turn.
|
||||
|
||||
### Which endpoints are allowed
|
||||
|
||||
`backend/app/endpoints.py` decides, and it is deliberately narrow: **loopback,
|
||||
your own LAN, or nothing.** The allowed networks are `127.0.0.0/8`, the three
|
||||
RFC1918 ranges, link-local, IPv6 loopback and unique-local, and `100.64.0.0/10`
|
||||
(carrier-grade NAT, which is what a mesh VPN such as Tailscale hands out).
|
||||
|
||||
Every address the endpoint's hostname resolves to must be in one of them. A
|
||||
public address is refused, a name resolving to both a private and a public
|
||||
address is refused, and known cloud inference hosts are refused by name so the
|
||||
error says why rather than looking like a DNS fault.
|
||||
|
||||
The rule is applied when you save the endpoint *and* again before every
|
||||
outbound request, so a database edited by hand or a hostname that starts
|
||||
resolving somewhere new cannot turn a local install into an exfiltration path.
|
||||
There is no setting to relax it.
|
||||
|
||||
### Same host (the default)
|
||||
|
||||
```text
|
||||
endpoint_url = http://127.0.0.1:11434/v1
|
||||
```
|
||||
|
||||
Nothing else to do. Ollama's own default is to listen on loopback.
|
||||
|
||||
### An Ollama on another machine on your trusted LAN
|
||||
|
||||
Supported and explicitly configured — never guessed, never discovered.
|
||||
|
||||
On the **inference machine**, tell Ollama to accept connections from the LAN,
|
||||
because it binds loopback by default:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||
```
|
||||
|
||||
On the **storyteller machine**, set the endpoint to that host's address:
|
||||
|
||||
```text
|
||||
endpoint_url = http://192.168.1.50:11434/v1
|
||||
```
|
||||
|
||||
Use an IP address or a name your own network resolves. Then:
|
||||
|
||||
- the storyteller UI/API stays on `127.0.0.1` — do not change the listener;
|
||||
- prompts, story text, retrieved memories and embedding inputs all travel to
|
||||
that host, so it has to be one you control, on a network you trust;
|
||||
- the inference machine needs the models installed, not the storyteller;
|
||||
- no Internet is involved in either direction.
|
||||
|
||||
A LAN endpoint is accepted because it is on one of the allowed networks above.
|
||||
Nothing else about it is special.
|
||||
|
||||
#### If that endpoint is HTTPS with your own CA
|
||||
|
||||
Some inference hosts are only reachable over TLS. A StartOS server is one: it
|
||||
serves Ollama over HTTPS with a certificate from its own local CA, and plain
|
||||
HTTP redirects to it.
|
||||
|
||||
Install that CA on the machine running the storyteller, the same way you would
|
||||
for the browser — on Debian and Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo cp your-ca.crt /usr/local/share/ca-certificates/
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
then use the `https://` URL and the hostname the certificate is issued for:
|
||||
|
||||
```text
|
||||
endpoint_url = https://inference.lan:8443/v1
|
||||
```
|
||||
|
||||
The application verifies against the machine's CA store **and** the `certifi`
|
||||
bundle (`backend/app/tlstrust.py`), so a CA you installed at the OS level is
|
||||
honoured, exactly as `curl` and your browser honour it. Public certificates
|
||||
keep working unchanged.
|
||||
|
||||
There is deliberately **no** setting to skip verification. If a connection is
|
||||
refused with `CERTIFICATE_VERIFY_FAILED`, the CA is not installed where the
|
||||
storyteller can see it, or the URL's hostname does not match the certificate —
|
||||
`openssl s_client -connect host:port` will say which. In a container, remember
|
||||
the CA has to be inside the image or bind-mounted; the host's store is not
|
||||
visible from within.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 604 tests
|
||||
cd frontend && npm run lint && npm run build
|
||||
```
|
||||
|
||||
Two files are the M1 regression guards.
|
||||
|
||||
`test_offline_assets.py` fails if the tokenizer starts fetching its table
|
||||
again, if a remote font or stylesheet comes back, or if the CSP names a remote
|
||||
origin. Two of its checks read the built SPA under `frontend/dist/` and skip
|
||||
when it has not been built, so run `npm run build` before treating a green
|
||||
suite as complete evidence.
|
||||
|
||||
`test_tls_trust.py` fails if outbound verification is weakened, if a public CA
|
||||
is lost from the union, or if a new HTTP client is added without the shared
|
||||
verification context.
|
||||
|
||||
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
|
||||
addresses widens, or if either place the rule is applied stops applying it —
|
||||
it resolves hostnames through a stub, so it tests the policy rather than
|
||||
whatever DNS the machine has. `test_local_only_surface.py` fails if a removed
|
||||
subsystem comes back as a route, if an API key becomes settable again, if the
|
||||
model timeout stops being configurable or becomes unbounded, or if a supported
|
||||
start path stops binding loopback.
|
||||
|
||||
## What was made offline-safe, and how to check
|
||||
|
||||
Two runtime downloads were removed in Milestone M1. Both were invisible on a
|
||||
machine that had been online once, which is exactly why they need tests.
|
||||
|
||||
**The tokenizer.** `tiktoken.get_encoding("cl100k_base")` downloads a 1.7 MB
|
||||
BPE table on first use, and the context builder counts tokens on every turn, so
|
||||
the first story turn on an air-gapped install died with a `ConnectionError`.
|
||||
The table is vendored at `backend/app/context/vendor/cl100k_base.tiktoken` and
|
||||
`backend/app/context/encoding.py` builds the encoding from it, verifying its
|
||||
SHA-256 against the digest `tiktoken` itself pins.
|
||||
|
||||
**The fonts.** The SPA linked `fonts.googleapis.com` from `index.html`, so
|
||||
every page load fetched a stylesheet and font files from Google. The three
|
||||
families are self-hosted under `frontend/public/fonts/`, declared in
|
||||
`frontend/src/styles/fonts.css`, and re-vendored by
|
||||
`python3 frontend/tools/vendor_fonts.py`. The CSP in `backend/app/main.py` now
|
||||
names no remote origin at all.
|
||||
|
||||
To convince yourself on a machine that has already been online, run the app
|
||||
with no route out rather than trusting a cold cache:
|
||||
|
||||
```bash
|
||||
docker network create --internal offline
|
||||
docker run -d --name ollama --network offline -v ollama-models:/root/.ollama ollama/ollama
|
||||
docker build -t storyteller .
|
||||
# The app shares Ollama's network namespace, so Ollama is on its loopback and
|
||||
# neither has a route to the Internet.
|
||||
docker run -d --name app --network container:ollama -v story-data:/data \
|
||||
storyteller uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
docker exec app python -c "import socket; socket.create_connection(('1.1.1.1',443),timeout=4)"
|
||||
# -> OSError: Network is unreachable, and story turns still work
|
||||
```
|
||||
|
||||
`planning/reports/M1-BASELINE-REPORT.md` records the run this procedure is
|
||||
taken from, including the packet captures.
|
||||
|
||||
## Things inherited from upstream that M1 deliberately did not touch
|
||||
|
||||
These are M2's scope (`planning/BUILD-MILESTONES.md`), listed here so nobody
|
||||
reports them as new:
|
||||
|
||||
- hosted/multi-user/account/demo-key code, analytics tables, Postgres and
|
||||
Render deployment paths, and the OpenRouter default endpoint constant all
|
||||
still exist in the tree. None of them is reachable from a default local run,
|
||||
and none requires a cloud service.
|
||||
- `docs/*.html` is upstream's GitHub Pages project site and still links Google
|
||||
Fonts. It is not served by the application and is not part of any build.
|
||||
- `.github/workflows/ci.yml` is upstream's GitHub Actions pipeline. This
|
||||
repository lives on a self-hosted Gitea; the workflow is kept for provenance
|
||||
and is not what runs the tests here.
|
||||
- QuickJS campaign scripting is still present and still tested.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Stage 1 — build the React SPA. Node 24: package-lock.json is written by
|
||||
# npm 11, which older bundled npms (node 22's 10.x) refuse as out-of-sync.
|
||||
FROM node:24-alpine AS frontend-build
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 — runtime. Every remaining dependency ships a wheel, so there is no
|
||||
# compile step and no toolchain to keep out of the image. The wheel-building
|
||||
# stage that used to sit here existed for quickjs, which compiled from source
|
||||
# and which M2 removed with campaign scripting.
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
||||
|
||||
# Layout mirrors the repo: main.py finds the SPA at ../../frontend/dist
|
||||
# relative to backend/app/main.py.
|
||||
COPY backend/app /app/backend/app
|
||||
COPY --from=frontend-build /build/dist /app/frontend/dist
|
||||
|
||||
# Database lives on a volume; parent dir is created by the app if missing.
|
||||
ENV AIDND_DB_PATH=/data/data.db
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 8000
|
||||
# Publish this port to loopback only — `-p 127.0.0.1:8000:8000`, which is what
|
||||
# docker-compose.yml does. The listener below is 0.0.0.0 because that is the
|
||||
# only address a published port can reach inside a container; it is not an
|
||||
# invitation to put the storyteller on the LAN, which is single-user and
|
||||
# unauthenticated in local mode.
|
||||
WORKDIR /app/backend
|
||||
# Single worker on purpose: the turn lock and the debug log are in-process
|
||||
# state.
|
||||
#
|
||||
# No --proxy-headers. That existed for a hosted deployment behind a platform
|
||||
# edge, along with the per-IP rate limiting that read X-Forwarded-For. Neither
|
||||
# survives M2, and trusting a forwarded header on a loopback-published port
|
||||
# would be a way to lie to the app rather than a feature.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Parth Thakkar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Provenance
|
||||
|
||||
This repository is the Adventure Storyteller production fork. Its application
|
||||
code comes from **AI-DnD**, and its planning package (`planning/`) is original
|
||||
to this project.
|
||||
|
||||
## Upstream
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | AI-DnD |
|
||||
| Repository | <https://github.com/parththakkar106/AI-DnD> |
|
||||
| Commit | `d72f7c1bda0f34fccd84afb7a25c34eb01c901de` |
|
||||
| Subject | Stop paying twice for a block a retry can still throw away |
|
||||
| Author date | Mon 31 Aug 2026 16:14:24 +0000 |
|
||||
| Position | tip of `upstream/main` on 1 Sep 2026, when the fork was taken |
|
||||
| License | MIT, © 2026 Parth Thakkar |
|
||||
|
||||
The commit is the one pinned by `planning/DECISIONS/009-ai-dnd-production-base.md`
|
||||
after Phase 0B. It was not substituted for a newer upstream commit.
|
||||
|
||||
## How the fork is wired
|
||||
|
||||
Upstream history is *in* this repository rather than copied out of it. The
|
||||
import is a merge of the pinned commit with `--allow-unrelated-histories`, so:
|
||||
|
||||
- `git log d72f7c1bda0f34fccd84afb7a25c34eb01c901de` shows the real upstream
|
||||
history, not a squashed snapshot;
|
||||
- upstream paths are unchanged (`backend/`, `frontend/`, `docs/`, …), so a
|
||||
later upstream commit can still be fetched and cherry-picked against
|
||||
matching files;
|
||||
- the planning package that predates the fork keeps its own history on the
|
||||
other parent of the merge.
|
||||
|
||||
To re-verify from a fresh clone:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/parththakkar106/AI-DnD.git
|
||||
git fetch --no-tags upstream
|
||||
git cat-file -t d72f7c1bda0f34fccd84afb7a25c34eb01c901de # -> commit
|
||||
git merge-base --is-ancestor d72f7c1bda0f34fccd84afb7a25c34eb01c901de HEAD && echo "in this history"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Upstream is MIT. `LICENSE` is upstream's file, unmodified, and the copyright
|
||||
notice stays with it. The MIT terms require that the notice travel with the
|
||||
code and with substantial portions of it; keep `LICENSE` in place in any
|
||||
redistribution of this fork, including a packaged build.
|
||||
|
||||
Work done in this repository after the fork is a derivative of that MIT-licensed
|
||||
code.
|
||||
|
||||
## Vendored third-party assets
|
||||
|
||||
Both were added by Milestone M1 to remove a runtime Internet dependency. Each
|
||||
is redistributable and each has a regeneration path in the tree, so neither is
|
||||
an opaque binary nobody can rebuild.
|
||||
|
||||
### `backend/app/context/vendor/cl100k_base.tiktoken`
|
||||
|
||||
The BPE merge table for OpenAI's `cl100k_base` tokenizer, used for context
|
||||
budgeting only — no model of OpenAI's is ever called.
|
||||
|
||||
- Source: `https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken`
|
||||
- SHA-256: `223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7`,
|
||||
which is the digest `tiktoken` itself pins for that URL, and which
|
||||
`backend/app/context/encoding.py` re-checks every time it builds the encoding.
|
||||
- Published by OpenAI for use with `tiktoken` (MIT).
|
||||
|
||||
### `frontend/public/fonts/*.woff2`
|
||||
|
||||
Cinzel, Crimson Pro and Inter, Latin and Latin Extended subsets, as variable
|
||||
fonts. All three are licensed under the SIL Open Font License 1.1; the license
|
||||
text ships beside them as `OFL-cinzel.txt`, `OFL-crimsonpro.txt` and
|
||||
`OFL-inter.txt`, which is what the OFL requires of a redistribution.
|
||||
|
||||
Regenerate with `python3 frontend/tools/vendor_fonts.py`, which also rewrites
|
||||
`frontend/src/styles/fonts.css`.
|
||||
|
||||
## What this fork changed in Milestone M2
|
||||
|
||||
M2 is subtractive. It reduced the inherited application to the intended
|
||||
single-user, local-first trust boundary. **Nothing was added that upstream did
|
||||
not have, except the endpoint policy and the tests that hold these removals in
|
||||
place.**
|
||||
|
||||
Removed in full: campaign scripting and the QuickJS sandbox; multi-user
|
||||
accounts, guest sessions, login, registration and the shared demo key; the
|
||||
visitor analytics tables, dashboard and beacon; the access log; per-IP and
|
||||
per-user rate limiting and quotas; Render deployment config; Postgres/Neon
|
||||
support; cloud inference providers and the API-key field; session-cookie
|
||||
signing and API-key encryption at rest.
|
||||
|
||||
Added: `backend/app/endpoints.py`, which decides what an inference endpoint may
|
||||
be, and a configurable model timeout.
|
||||
|
||||
Three database tables (`scripts`, `adventure_scripts`, `analytics_daily`,
|
||||
`analytics_visitor_days`, `access_log`) and four columns (`adventures.script_state`,
|
||||
`settings.api_key`, `users.demo_turns_used`, `users.demo_turns_date`) are left
|
||||
in place, unmapped or inert, so that an existing M1 campaign database opens
|
||||
unchanged. They are not product functionality and nothing reads or writes them.
|
||||
|
||||
## What this fork changed in Milestone M1
|
||||
|
||||
Nothing was removed from upstream. The changes are the offline/locality
|
||||
hardening M1 called for; see `planning/reports/M1-BASELINE-REPORT.md` for the
|
||||
evidence.
|
||||
|
||||
- `backend/app/context/encoding.py` (new) and `backend/app/context/builder.py` —
|
||||
build `cl100k_base` from the vendored table instead of downloading it on
|
||||
first use.
|
||||
- `frontend/index.html`, `frontend/src/index.css`,
|
||||
`frontend/src/styles/fonts.css` (new), `frontend/public/fonts/` (new),
|
||||
`frontend/tools/vendor_fonts.py` (new) — self-hosted fonts in place of the
|
||||
Google Fonts link.
|
||||
- `backend/app/main.py` — CSP narrowed to same-origin, with the two Google
|
||||
hosts dropped and `object-src` / `base-uri` / `form-action` added; `woff2`
|
||||
registered so the self-hosted fonts are served with their real media type.
|
||||
- `backend/app/tlstrust.py` (new), `backend/app/providers/openai_compatible.py`,
|
||||
`backend/app/routers/settings.py` — outbound HTTPS verifies against the
|
||||
machine's own CA store as well as certifi's, so a trusted-LAN Ollama with a
|
||||
locally-issued certificate works. Verification is not relaxed.
|
||||
- `start.sh`, `start.ps1`, `docker-compose.yml` — the storyteller listener is
|
||||
explicitly loopback-bound.
|
||||
- `backend/requirements.lock` (new) — the exact tested dependency closure.
|
||||
- `backend/tests/test_offline_assets.py` and `backend/tests/test_tls_trust.py`
|
||||
(new) — regression tests for the above.
|
||||
- `DEVELOPMENT.md` (new) — environment setup and Ollama configuration.
|
||||
@@ -0,0 +1,292 @@
|
||||
# AI D&D
|
||||
|
||||
[](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
An AI Dungeon-style interactive storytelling app that runs entirely on your own machine, with
|
||||
your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
|
||||
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
|
||||
scripting**.
|
||||
|
||||
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
|
||||
> The project page loads instantly and launches the hosted demo in one tap. Play a scenario as
|
||||
> a guest: no sign-up and no API key needed. The demo runs on a free tier that sleeps, so the
|
||||
> first load after it's been idle takes about 30 to 60 seconds to wake up.
|
||||
>
|
||||
> For the internals, read the **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**.
|
||||
> They walk through the context budgeting, the world-state referee, and the memory bank, and
|
||||
> state the reasoning behind each one ([Markdown version](docs/GUIDE.md)).
|
||||
|
||||
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, running on
|
||||
SQLite locally and Postgres in the cloud. It works with **any OpenAI-compatible endpoint**:
|
||||
Ollama and LM Studio locally, or OpenRouter, OpenAI, Groq, or vLLM in the cloud. Endpoint, key,
|
||||
and model are all runtime settings, and OpenRouter's free-tier models make the whole experience
|
||||
cost nothing.
|
||||
|
||||

|
||||
|
||||
*The play screen. The left rail shows live world state. The AI proposes changes each turn, and
|
||||
a Python engine decides what actually sticks. The chip under the narration reports what
|
||||
changed. The `‹ 2/2 ›` under a turn steps between the takes it has. Writing below a take that
|
||||
isn't the live one starts a new branch.*
|
||||
|
||||
## Features
|
||||
|
||||
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
|
||||
💭 panel with its own token budget.
|
||||
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
|
||||
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
|
||||
and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||||
branch. Branches borrow their ancestors' turns instead of copying them, so a fork costs about
|
||||
100 bytes, and a 20-fork story loads within 1% of the same story flat. Switching restores that
|
||||
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
|
||||
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
|
||||
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
|
||||
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
|
||||
monotonic and milestones sticky, then strips the machine-readable block out of the prose
|
||||
(`backend/app/worldstate/engine.py`). Word-labeled bands (`40–60: minor damage`) make the
|
||||
model reliable at it. No dice and no scripting are required.
|
||||
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
|
||||
info) are triggered by keywords in recent story text, then assembled under a token budget
|
||||
(`backend/app/context/builder.py`).
|
||||
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
|
||||
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
|
||||
included.
|
||||
- **JavaScript scripting, AI Dungeon-compatible.** `onInput` / `onModelContext` / `onOutput`
|
||||
modifiers share `state` and a `worldEntries` API, and run in an embedded quickjs sandbox
|
||||
(`backend/app/scripting/`). Real AI Dungeon scripts import and run as is. An in-app CodeMirror
|
||||
editor is included.
|
||||
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
|
||||
memories every few actions, a running story summary, and embedding-based retrieval that
|
||||
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
|
||||
(`backend/app/memorybank.py`).
|
||||
- **Undo and retry that actually roll back state.** Undo and retry roll back the world state
|
||||
and script state to a per-node snapshot, not just the text, and prune the memories that
|
||||
covered the removed turns. Nothing a retry replaces is discarded: the old attempt stays as
|
||||
another take of that turn, one keystroke and one click from becoming a branch of its own.
|
||||
- **Import and export.** AI Dungeon-compatible formats for scripts and scenarios; JSON for
|
||||
everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
|
||||
every branch, every take, and the fork points, since those were chosen rather than computed.
|
||||
Files saved in the old single-line format still import.
|
||||
- **Optional accounts for hosted deployments.** By default the app is single-user with zero
|
||||
auth friction. Set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
|
||||
session cookie), can register (email and password) at any point to keep their data, and each
|
||||
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
|
||||
demo key** with a daily turn cap lets people try it without bringing a key
|
||||
(`backend/app/auth.py`). Each new guest is also given a copy of a short pre-played
|
||||
adventure, so the first screen shows real turns and their world-state changes without
|
||||
spending a demo turn (`backend/app/starter.py`).
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Insights**: the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring**: stats with ranges, per-turn caps, cooldowns, and word-labeled bands; NPCs the AI addresses by id. |
|
||||
|  |  |
|
||||
| **Scripting**: the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home**: continue a story in progress or start from a scenario. |
|
||||
|  |  |
|
||||
| **The tree**: one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches**: every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. |
|
||||
|
||||
## Quick start
|
||||
|
||||
### Docker (any OS)
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open http://localhost:8000. Your data persists in a named volume across restarts.
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
cd backend; python -m venv .venv; .\.venv\Scripts\pip.exe install -r requirements.txt; cd ..
|
||||
cd frontend; npm install; cd ..
|
||||
.\start.ps1
|
||||
```
|
||||
|
||||
Open http://localhost:5173 (dev servers; API docs at http://localhost:8000/docs).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```sh
|
||||
./start.sh # creates the venv and installs dependencies on first run
|
||||
```
|
||||
|
||||
Open http://localhost:5173.
|
||||
|
||||
## Connect a model
|
||||
|
||||
Open **Settings** in the app and point it at any OpenAI-compatible endpoint:
|
||||
|
||||
| Provider | Endpoint URL | Notes |
|
||||
|---|---|---|
|
||||
| Ollama (local) | `http://localhost:11434/v1` | free, private; also serves embedding models for the Memory Bank (e.g. `nomic-embed-text`) |
|
||||
| LM Studio (local) | `http://localhost:1234/v1` | free, private |
|
||||
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
|
||||
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
|
||||
| Claude Code CLI (local) | `http://127.0.0.1:8787/v1` | your Claude subscription instead of an API key; see [Playing against Claude locally](#playing-against-claude-locally) |
|
||||
|
||||
Model name, API key, generation parameters, and (optionally) summary and embedding models for
|
||||
the Memory Bank are all configured there too. No config files and no rebuild are needed.
|
||||
|
||||
### Playing against Claude locally
|
||||
|
||||
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint backed by the
|
||||
`claude` command line tool, so you can play the demos against a real model without an
|
||||
API key. Each request spawns one `claude --print` process, which suits the turn engine:
|
||||
the app assembles the whole prompt every turn and expects a stateless endpoint.
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
.venv/Scripts/python.exe tools/claude_shim.py # listens on 127.0.0.1:8787
|
||||
```
|
||||
|
||||
In Settings, choose the OpenAI-compatible provider, set the base URL to
|
||||
`http://127.0.0.1:8787/v1`, put any non-empty string in the API key field, and pick
|
||||
`sonnet`. The shim ignores the key and authenticates as you, through the CLI. Set the
|
||||
reasoning budget to `0` or `-1`: a positive budget sends a `reasoning.max_tokens` field
|
||||
that Claude 5 models reject.
|
||||
|
||||
Embeddings are not served. Leave the embedding model blank, or point the Memory Bank at
|
||||
a real endpoint.
|
||||
|
||||
Run it against a local backend only. The endpoint has no authentication, and anything
|
||||
reaching it spends your Claude quota. `app/netguard.py` blocks localhost endpoints when
|
||||
`AIDND_MULTI_USER` is set, so a deployed instance cannot be pointed at it.
|
||||
|
||||
## How a turn works
|
||||
|
||||
```
|
||||
player input
|
||||
→ onInput script modifier
|
||||
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
|
||||
+ [plot essentials] + [story summary] + [retrieved memories]
|
||||
+ [triggered story cards] + [history along this branch, token-budgeted]
|
||||
+ [author's note] + [player action]
|
||||
→ onModelContext script modifier
|
||||
→ snapshot context (Insights)
|
||||
→ provider adapter → AI (streamed)
|
||||
→ extract + referee the world-state delta block, strip it from the prose
|
||||
→ onOutput script modifier
|
||||
→ store & render
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, analytics, debug
|
||||
├─ models.py SQLAlchemy: User, Scenario, Adventure, Branch, Action, StoryCard, Script, Settings, Memory
|
||||
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (64 and counting)
|
||||
├─ auth.py guest/registered users, sessions, shared demo key
|
||||
├─ security.py password hashing, cookie signing, API-key encryption
|
||||
├─ tree.py forking, promotion, and where a node is placed
|
||||
├─ attempts.py the takes of one turn, grouped by parent
|
||||
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||
├─ scripting/ quickjs sandbox + AI Dungeon API surface
|
||||
├─ memorybank.py auto-summarization + embedding retrieval
|
||||
├─ analytics.py buffered visit counters + the owner's dashboard query
|
||||
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
|
||||
├─ providers/ OpenAI-compatible adapter, streaming
|
||||
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
|
||||
```
|
||||
|
||||
In production the backend serves the built SPA from one port (see `Dockerfile`). In
|
||||
development, Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
549 backend tests: unit tests plus full HTTP integration through the real quickjs scripting
|
||||
engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend
|
||||
lint/build and a Docker image build.
|
||||
|
||||
```sh
|
||||
cd backend && pip install -r requirements.txt -r requirements-dev.txt
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
## Notes on performance
|
||||
|
||||
Two of the tests exist because of bugs that were measured rather than guessed at. They are the
|
||||
most interesting engineering in the repo.
|
||||
|
||||
- **Database egress, cut about 189x.** Every adventure load pulled `Action.context_snapshot`,
|
||||
the entire assembled prompt at about 74 KB per turn, to read two small fields off it. Moving
|
||||
those fields into their own columns and marking the heavy ones `deferred` took one adventure
|
||||
load from 38.5 MB to 0.20 MB. The backfill runs server-side with dialect-specific SQL, so the
|
||||
old data never crosses the wire. `tests/test_egress.py` hooks into SQLAlchemy's cursor events
|
||||
and fails if a bulk load ever names those columns again.
|
||||
- **Turn cost, made flat.** Assembling a turn walked the whole story, so cost scaled with story
|
||||
length: 839 KB of reads at turn 200. `backend/app/context/history.py` now serves tails and
|
||||
slices from SQL and measures what it fetched. The same turn now costs 129 KB and stops
|
||||
growing at around turn 50.
|
||||
- **Branching that costs nothing to read.** A branch stores no turns. It stores where it left
|
||||
its parent, and borrows everything above that. A 40-turn story forked twenty times loads in
|
||||
31,652 bytes against 31,433 bytes for the same story flat: a 1.007x ratio, or about 103 bytes
|
||||
per branch. Reads stay cheap because the lineage is windowed the same way the history is, so
|
||||
the number of SQL clauses is bounded by the context window rather than by the number of
|
||||
forks.
|
||||
|
||||
## Visit analytics
|
||||
|
||||
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` shows
|
||||
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
|
||||
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
|
||||
`AIDND_ANALYTICS_EMAILS`, and the route returns 404 for everyone else.
|
||||
|
||||
This is built into the app rather than added with a third-party script, for reasons specific
|
||||
to this project: the CSP allows only `script-src 'self'`, ad blockers block the popular
|
||||
trackers, and none of those trackers can see the measurement that matters here, a turn. Counts
|
||||
are aggregated in memory and flushed as UPSERTs, so a visit is a write and never a read, and
|
||||
every dashboard query is a `GROUP BY` that returns tens of rows regardless of traffic volume.
|
||||
That matters: see the egress note above for what reading rows per request costs on this stack.
|
||||
|
||||
## Deploy (Render)
|
||||
|
||||
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that serves
|
||||
the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres. The free
|
||||
Render tier has no persistent disk, so the database lives off-box.
|
||||
|
||||
1. Create a **Neon** project and copy its pooled connection string.
|
||||
2. In Render, choose **New → Blueprint** and point it at this repo. Render reads
|
||||
`render.yaml`.
|
||||
3. Fill in the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
|
||||
string); `AIDND_DEMO_API_KEY` and `AIDND_DEMO_MODELS` to offer a no-signup demo; and
|
||||
`AIDND_ANALYTICS_EMAILS` (your own account's email) to see the Visitors dashboard.
|
||||
`AIDND_SECRET_KEY` is generated automatically and stays stable across deploys.
|
||||
4. Deploy. Pushes to `main` auto-deploy after this. The health check is `/api/health`.
|
||||
|
||||
On the free tier the service sleeps after about 15 minutes idle, and the first request after
|
||||
that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/api/health`,
|
||||
which deliberately doesn't touch the database: waking the database around the clock costs far
|
||||
more than the cold start saves.
|
||||
|
||||
If you put another proxy or CDN in front of Render, set `AIDND_TRUSTED_PROXY_HOPS` to the
|
||||
number of proxies in the chain. It defaults to 1. The rate limiter reads the client IP that
|
||||
many entries from the right of `X-Forwarded-For`, because the trusted edge appends the real
|
||||
one last. Leave it at 1 behind two proxies and the limiter reads an entry the caller
|
||||
supplied, so anyone can rotate the header for a fresh rate-limit bucket per request.
|
||||
|
||||
## Repo notes
|
||||
|
||||
- `plan/` holds the phased implementation plan this project was built from, kept as a build
|
||||
log. All fourteen phases are complete. The later files (11, 12, 14) also serve as design
|
||||
notes for the state-revert, world-state, and story-tree work.
|
||||
[`plan/STATUS.md`](plan/STATUS.md) is the running thread: what shipped, what was measured,
|
||||
and what is owed next.
|
||||
- [`docs/GUIDE.md`](docs/GUIDE.md) holds design notes: how each subsystem works and why it was
|
||||
built that way, with the measurements behind the decisions. It is also rendered as a
|
||||
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
|
||||
- `backend/.env.example` lists the few environment variables the backend reads.
|
||||
- [`docs/self-review.md`](docs/self-review.md) records a full-codebase self-review pass and
|
||||
what came out of it. All correctness findings are resolved.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Environment variables read by the backend.
|
||||
#
|
||||
# NOTE: the app reads real environment variables — it does NOT auto-load this
|
||||
# file. Set them in your shell or in docker-compose.yml. This file is
|
||||
# documentation.
|
||||
#
|
||||
# There are two, and neither is required. Everything about the model — the
|
||||
# endpoint, the model names, the timeout, the context budget — is a runtime
|
||||
# setting stored in the database and edited on the Settings page, because it is
|
||||
# a preference rather than a deployment detail.
|
||||
|
||||
# Absolute path for the SQLite database file. The parent directory is created
|
||||
# if missing. Default when unset: backend/data.db
|
||||
# docker-compose.yml sets this to /data/data.db (a named volume).
|
||||
AIDND_DB_PATH=
|
||||
|
||||
# Comma-separated list of allowed CORS origins. Only needed when the frontend
|
||||
# is served from a different origin than the API; the production build is
|
||||
# served same-origin by FastAPI, so a normal run can leave this unset.
|
||||
# Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server).
|
||||
#
|
||||
# A wildcard is rejected. The storyteller API is unauthenticated by design and
|
||||
# bound to loopback; letting any origin call it would undo that.
|
||||
AIDND_CORS_ORIGINS=
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Phase 14, SP4: the attempts at one turn.
|
||||
|
||||
A retry used to rewrite the AI action in place and append the discarded attempt
|
||||
to a JSON list on the same row. Seven separate bugs came from that arrangement.
|
||||
The row's `text` duplicated one entry of a repeating group, a second column
|
||||
duplicated its length, and every reader that touched the story during a retry
|
||||
had to be told to ignore the row.
|
||||
|
||||
Now an attempt is a node. A retry writes a sibling at the same `(branch_id,
|
||||
depth)` and marks it live. The previous attempt stays as it was written, at the
|
||||
same coordinate, with `live = False`. Nothing is duplicated, so nothing can
|
||||
diverge.
|
||||
|
||||
Two invariants hold the arrangement together, and this module is the only place
|
||||
that maintains either one:
|
||||
|
||||
* Exactly one sibling in a group is live. `lineage.Path.clause` selects on it, so
|
||||
the other attempts are invisible to every read of the story, and none of those
|
||||
reads has to know that attempts exist.
|
||||
* The assembled prompt is stored once per turn, on the live sibling. A
|
||||
`context_snapshot` is about 163 kB of prompt that every attempt at a turn
|
||||
shares, plus a few hundred bytes that differ, listed in `ATTEMPT_KEYS`. Giving
|
||||
each sibling its own copy would make a retry a permanent multiplier on the
|
||||
largest column in the database, which is what the JSON list was invented to
|
||||
avoid. The prompt therefore moves with the live flag, and a superseded sibling
|
||||
keeps only its own slices.
|
||||
|
||||
Ordering inside a group comes from `id`, not from `created_at`. Two attempts made
|
||||
in the same second still have to page in the order they were made, and `id`
|
||||
increases with every insert. SP8 dropped `variant_index`, an explicit ordinal
|
||||
that carried the same order, once a run of the suite confirmed that the two
|
||||
agreed in every group.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import models
|
||||
from .context import lineage
|
||||
|
||||
# The slices of a context snapshot that belong to one attempt rather than to the
|
||||
# turn. They are the world-state delta the attempt proposed and what the engine
|
||||
# did with it, the model's literal reply, and the endpoint's
|
||||
# token accounting. Each attempt is its own API call, and a retry is the call
|
||||
# most likely to read the prompt back out of cache. Everything else in a snapshot
|
||||
# is the prompt, which is assembled once per turn.
|
||||
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
"""Returns every attempt at `action`'s turn, oldest first.
|
||||
|
||||
The query keys on the parent rather than on the coordinate (SP9). The two
|
||||
agree until an attempt is forked onto its own branch. That attempt keeps its
|
||||
parent but leaves the `(branch, depth)` its siblings are still at, so a
|
||||
coordinate would report it as the only attempt at its turn, showing `1/1`
|
||||
where the player should see `1/3`.
|
||||
|
||||
The parent also nests groups correctly without extra work. Attempts under C1
|
||||
and attempts under C2 share a depth, and until one of them forks they share a
|
||||
branch. Only the parent separates them, which is what makes a pager under C2
|
||||
read `2/2` rather than count C1's three as well.
|
||||
|
||||
There are two fallbacks, and both mean the row predates the key being asked
|
||||
about. A node with no branch is a pre-tree row that no path contains, and a
|
||||
node with no parent is a pre-SP9 row the backfill could not place. Under the
|
||||
rule each was written with, both are the only attempt at their turn.
|
||||
"""
|
||||
if action.branch_id is None or action.depth is None:
|
||||
return [action]
|
||||
if action.parent_id is None:
|
||||
# This row is pre-SP9, and the coordinate is the key those rows were
|
||||
# written under. A root node also reaches this branch and is genuinely
|
||||
# alone, because nothing is an attempt at the opening of a story.
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == action.adventure_id,
|
||||
models.Action.branch_id == action.branch_id,
|
||||
models.Action.depth == action.depth,
|
||||
models.Action.parent_id.is_(None),
|
||||
)
|
||||
.order_by(models.Action.id)
|
||||
.all()
|
||||
)
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == action.adventure_id,
|
||||
models.Action.parent_id == action.parent_id,
|
||||
)
|
||||
.order_by(models.Action.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def on_branch(rows: list[models.Action], node: models.Action) -> list[models.Action]:
|
||||
"""Returns the attempts in `rows` that are on `node`'s own branch.
|
||||
|
||||
`group` reports which attempts belong to this turn, and since SP9 that spans
|
||||
branches. An attempt forked onto its own line is still an attempt at the same
|
||||
turn, which is the reason for keying on the parent.
|
||||
|
||||
Deletion is the one caller that must not follow a group across branches. An
|
||||
attempt on another branch is reachable through that branch and belongs to the
|
||||
story someone is telling there. Removing it because a turn was undone here
|
||||
would delete a line nobody asked about. The same parent and the same branch
|
||||
together are the coordinate, which is what every attempt at this turn meant
|
||||
before a fork could move one out of it.
|
||||
"""
|
||||
return [row for row in rows if row.branch_id == node.branch_id]
|
||||
|
||||
|
||||
def live_in(rows: list[models.Action]) -> models.Action | None:
|
||||
for row in rows:
|
||||
if row.live:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def preceding(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> models.Action | None:
|
||||
"""Returns the node the story tells immediately before `node`.
|
||||
|
||||
This reads "before this turn" as a fact about the path rather than as a
|
||||
snapshot taken from inside the turn, which is what makes the after-snapshots
|
||||
sufficient on their own. The query undefers both of them, because the only
|
||||
reason to fetch this row is to restore what it left behind.
|
||||
"""
|
||||
if node.depth is None:
|
||||
return None
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
models.Action.depth < node.depth,
|
||||
)
|
||||
.options(
|
||||
undefer(models.Action.state_after),
|
||||
undefer(models.Action.world_state_after),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Restores the world state that `node` left behind.
|
||||
|
||||
A NULL snapshot means leave the live state as it is, never reset it. Rows
|
||||
written before SP4 that the migration could not derive an outcome for carry
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
"""
|
||||
if node is None:
|
||||
return
|
||||
if isinstance(node.world_state_after, dict):
|
||||
adventure.world_state = copy.deepcopy(node.world_state_after)
|
||||
|
||||
|
||||
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Records on `node` the state of the adventure now that the node has played."""
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
# `state_after` held the scripting engine's shared state, which M2 removed.
|
||||
# The column stays for schema compatibility and is written empty.
|
||||
node.state_after = {}
|
||||
node.world_state_after = copy.deepcopy(world)
|
||||
|
||||
|
||||
def roll_back_before(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> None:
|
||||
"""Rewinds the shared state to what it was before `node` was played."""
|
||||
restore_state(adventure, preceding(db, adventure, node))
|
||||
|
||||
|
||||
def add_attempt(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
previous: models.Action,
|
||||
replacement: models.Action,
|
||||
) -> None:
|
||||
"""Places `replacement` next to `previous` as the newer attempt at that turn.
|
||||
|
||||
The placement is done here rather than through `tree.place_action`, which
|
||||
moves the head. A sibling is not a new turn. It is another attempt at the
|
||||
turn the head is already on.
|
||||
"""
|
||||
replacement.branch_id = previous.branch_id
|
||||
replacement.depth = previous.depth
|
||||
# Copy the parent rather than resolve it from the path. An attempt belongs
|
||||
# to the turn it is an attempt at, and that is what `group` keys on.
|
||||
# Resolving it here would ask which node is live one depth back. That is the
|
||||
# same node right now, and it stops being the same node once the story forks
|
||||
# away from this turn.
|
||||
replacement.parent_id = previous.parent_id
|
||||
replacement.live = True
|
||||
# The replacement takes its place at the end of the group, because `group`
|
||||
# orders by `id` and this row has no id yet. Switching a three-attempt turn
|
||||
# back to attempt 1 and retrying therefore still pages 1, 2, 3, 4, which is
|
||||
# the order the attempts were made in.
|
||||
previous.live = False
|
||||
# The replacement was assembled with a fresh snapshot, so the prompt for
|
||||
# this turn is now the one it carries. The superseded attempt keeps only the
|
||||
# slices that were its own.
|
||||
keep_own_slices(previous)
|
||||
|
||||
|
||||
def make_live(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> list[models.Action]:
|
||||
"""Makes `node` the attempt the story tells, and restores its outcome.
|
||||
|
||||
Returns the group, so that a caller reporting on it does not read it twice.
|
||||
"""
|
||||
rows = group(db, node)
|
||||
previous = live_in(rows)
|
||||
if previous is not None and previous is not node:
|
||||
hand_over_the_prompt(previous, node)
|
||||
for row in rows:
|
||||
row.live = row is node
|
||||
restore_state(adventure, node)
|
||||
return rows
|
||||
|
||||
|
||||
# ------------------------------------------------- the prompt, stored once
|
||||
|
||||
def keep_own_slices(node: models.Action) -> None:
|
||||
"""Reduces `node`'s snapshot to the slices that are only its own."""
|
||||
snapshot = node.context_snapshot
|
||||
if not isinstance(snapshot, dict):
|
||||
return
|
||||
node.context_snapshot = {
|
||||
key: snapshot[key] for key in ATTEMPT_KEYS if key in snapshot
|
||||
} or None
|
||||
|
||||
|
||||
def hand_over_the_prompt(giver: models.Action, taker: models.Action) -> None:
|
||||
"""Moves the turn's assembled prompt from one attempt to another.
|
||||
|
||||
The caller runs this when the live flag moves, so that the row in the story
|
||||
is always the row the Insights viewer can explain. Nothing is copied. The
|
||||
prompt exists once before and once after, on whichever sibling is being read.
|
||||
"""
|
||||
held = giver.context_snapshot if isinstance(giver.context_snapshot, dict) else {}
|
||||
shared = {k: v for k, v in held.items() if k not in ATTEMPT_KEYS}
|
||||
if not shared:
|
||||
return
|
||||
keep_own_slices(giver)
|
||||
own = taker.context_snapshot if isinstance(taker.context_snapshot, dict) else {}
|
||||
taker.context_snapshot = shared | {
|
||||
k: v for k, v in own.items() if k in ATTEMPT_KEYS
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Resolving the one local user. **There is no authentication in this product.**
|
||||
|
||||
The module keeps its name so the dependency every router already depends on
|
||||
keeps working, but nothing here authenticates anybody. The Adventure
|
||||
Storyteller is a single-user application that binds to loopback: whoever can
|
||||
reach the API is the person who started it, and there is nobody else to tell
|
||||
them apart from.
|
||||
|
||||
Upstream had two modes. `AIDND_MULTI_USER` selected a hosted deployment with
|
||||
signed session cookies, guest accounts, registration, login, a shared demo API
|
||||
key with a per-day cap, "power users", and an owner allowlist for the analytics
|
||||
dashboard. M2 removed all of it: this product has no hosted mode to protect, and
|
||||
every one of those surfaces was a way for the application to be reached by
|
||||
someone other than its owner.
|
||||
|
||||
What is left is the local path that upstream already had. Every request
|
||||
resolves to one automatically created user row.
|
||||
|
||||
The `users` table and the `user_id` foreign keys on scenarios, adventures and
|
||||
settings stay. They are an **internal ownership detail**, not an account
|
||||
system: nothing creates a second user, nothing logs in, and no request carries
|
||||
an identity. They remain because rewriting them out would mean a migration
|
||||
across most of the schema to delete a column that costs nothing and keeps every
|
||||
existing M1 database readable.
|
||||
"""
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .database import get_db
|
||||
|
||||
|
||||
def local_user(db: Session) -> models.User:
|
||||
"""Returns the single implicit user, creating it on first use.
|
||||
|
||||
A migration gives this user ownership of data written before per-user rows
|
||||
existed, so an older database resolves to the row that already owns its
|
||||
campaigns rather than to a fresh empty one.
|
||||
"""
|
||||
user = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.email.is_(None), models.User.is_guest.is_(False))
|
||||
.order_by(models.User.id)
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
user = models.User(is_guest=False)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _touch(user: models.User, db: Session) -> None:
|
||||
now = models.utcnow()
|
||||
last = user.last_seen_at
|
||||
if last is not None and last.tzinfo is None:
|
||||
# SQLite returns DateTime columns without a timezone. They were stored
|
||||
# as UTC.
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
if last is None or (now - last).total_seconds() > 3600:
|
||||
user.last_seen_at = now
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, db: Session = Depends(get_db)
|
||||
) -> models.User:
|
||||
"""The dependency every router uses. It always succeeds.
|
||||
|
||||
`request` is unused and kept so the signature stays a FastAPI dependency
|
||||
the routers can depend on unchanged.
|
||||
"""
|
||||
user = local_user(db)
|
||||
_touch(user, db)
|
||||
return user
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Phase 14, SP6: the export bundle, as a tree.
|
||||
|
||||
A bundle is the one place a story leaves the database, and the only part of the
|
||||
tree no migration can reach. A file downloaded today has to still import into a
|
||||
build shipped next year. The format is therefore versioned, both versions are
|
||||
defined here, and nothing else in the app knows either of them.
|
||||
|
||||
Version 1 is a flat list of turns, each with an optional `variants` array, which
|
||||
is the repeating group SP4 unpacked into rows. Nothing writes that shape now.
|
||||
The reader stays, because bundles already on people's disks still use it, and a
|
||||
backup that stops importing is not a backup.
|
||||
|
||||
Version 2 carries the tree. It holds three things version 1 could not, and each
|
||||
one is required:
|
||||
|
||||
* The branches, because a forked adventure is two stories and a flat list holds
|
||||
one. A version 1 export interleaved them by turn number, which read as a
|
||||
garbled story rather than as lost data.
|
||||
* `live`, because a coordinate can hold several attempts at one turn and exactly
|
||||
one of them is the story.
|
||||
* Both after-snapshots, because they are what a branch switch and an undo
|
||||
restore. A bundle carrying the actions but not the outcomes would import a
|
||||
tree nobody could switch inside.
|
||||
|
||||
## The rule about what a bundle carries
|
||||
|
||||
A bundle carries what was chosen, never what is derived. The head branch, the
|
||||
fork points, the live flags, and the anchors are decisions somebody made, so
|
||||
they are in the file. `lineage` and the head depth are computed from those, and
|
||||
the import recomputes them:
|
||||
|
||||
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
|
||||
put a second source of truth for one fact into a file anyone can hand-edit,
|
||||
and the two could then disagree without any read reporting it.
|
||||
* The head depth is the tip of the head branch, which is a fact about the nodes
|
||||
that arrived with it.
|
||||
|
||||
Every hand-editable coordinate is therefore checked before a row is written, in
|
||||
`plan`, rather than repaired afterwards. An import that fails partway leaves an
|
||||
adventure holding half a tree, and a tree missing a branch is a story that stops
|
||||
without reporting anything.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import insert, update
|
||||
from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import attempts, models, schemas
|
||||
from .context import cursors, lineage
|
||||
|
||||
FORMAT = "ai-dnd-adventure-v2"
|
||||
LEGACY_FORMAT = "ai-dnd-adventure-v1"
|
||||
|
||||
# `actions.type` is VARCHAR(20), and a raw-dict import bypasses the schemas.
|
||||
TYPE_MAX = 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- exporting
|
||||
|
||||
def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"""Returns the whole adventure as a version 2 bundle.
|
||||
|
||||
The export is not scoped to a path. A backup holds the entire tree, not the
|
||||
branch its owner is currently reading. Both after-snapshots are undeferred in
|
||||
the one query, because they are per-node columns nothing else reads in bulk
|
||||
and requesting them a row at a time would cost one query per turn.
|
||||
|
||||
The bundle carries no context snapshots. It has never carried the assembled
|
||||
prompts, and it still does not. They run about 163 kB per turn, they explain
|
||||
a generation rather than form part of the story, and the Insights viewer they
|
||||
feed reads the adventure they came from.
|
||||
"""
|
||||
branches = (
|
||||
db.query(models.Branch)
|
||||
.filter(models.Branch.adventure_id == adventure.id)
|
||||
.order_by(models.Branch.id)
|
||||
.all()
|
||||
)
|
||||
# Branch ids are local to the file and are positions in this list, because
|
||||
# the database ids they hold here are already in use on the importing
|
||||
# side.
|
||||
local = {branch.id: i for i, branch in enumerate(branches)}
|
||||
nodes = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adventure.id)
|
||||
.options(
|
||||
undefer(models.Action.state_after),
|
||||
undefer(models.Action.world_state_after),
|
||||
)
|
||||
.order_by(
|
||||
models.Action.branch_id, models.Action.depth, models.Action.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"format": FORMAT,
|
||||
"title": adventure.title,
|
||||
"memory": adventure.memory,
|
||||
"authorsNote": adventure.authors_note,
|
||||
"aiInstructions": adventure.ai_instructions,
|
||||
"storySummary": adventure.story_summary,
|
||||
# Phase 18. A bundle written before personas existed has no key here,
|
||||
# and the import below reads it with `.get`, so it lands with an empty
|
||||
# persona — which is the same as having none. No FORMAT bump needed.
|
||||
"persona": {
|
||||
"name": adventure.persona_name,
|
||||
"pronouns": adventure.persona_pronouns,
|
||||
"desc": adventure.persona_desc,
|
||||
},
|
||||
"worldState": adventure.world_state,
|
||||
"autoSummarize": adventure.auto_summarize,
|
||||
"memoryBankEnabled": adventure.memory_bank_enabled,
|
||||
# Write a root entry even for an adventure whose branch row was never
|
||||
# created. A story with no branch is a pre-tree story, and the tree it
|
||||
# belongs to is the root. `_local` places its nodes there.
|
||||
"branches": [_exported_branch(b, local) for b in branches] or [_ROOT],
|
||||
"headBranch": local.get(adventure.head_branch_id, 0),
|
||||
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
||||
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
|
||||
"memories": [_exported_memory(m, local) for m in adventure.memories],
|
||||
"storyCards": [
|
||||
{"type": c.type, "name": c.name, "keys": c.keys,
|
||||
"entry": c.entry, "notes": c.notes}
|
||||
for c in adventure.story_cards
|
||||
],
|
||||
"actions": [_exported_node(a, local) for a in nodes],
|
||||
}
|
||||
|
||||
|
||||
_ROOT = {"parent": None, "forkDepth": None}
|
||||
|
||||
|
||||
def _local(branch_id: int | None, local: dict[int, int]) -> int:
|
||||
return local.get(branch_id, 0) if branch_id is not None else 0
|
||||
|
||||
|
||||
def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
parent = (
|
||||
local.get(branch.parent_branch_id)
|
||||
if branch.parent_branch_id is not None else None
|
||||
)
|
||||
out = (
|
||||
dict(_ROOT) if parent is None
|
||||
else {"parent": parent, "forkDepth": branch.fork_depth}
|
||||
)
|
||||
# A player chose the name, so it goes into the file. That is the same rule
|
||||
# that puts the fork points in the file and leaves `lineage` out. An unnamed
|
||||
# branch omits the key rather than carry a null, which keeps the file for an
|
||||
# unnamed tree byte-identical to the one SP6 wrote.
|
||||
if branch.name:
|
||||
out["name"] = branch.name
|
||||
return out
|
||||
|
||||
|
||||
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
node = {
|
||||
"branch": _local(action.branch_id, local),
|
||||
"depth": action.depth,
|
||||
"live": bool(action.live),
|
||||
"type": action.type,
|
||||
"text": action.text,
|
||||
"createdAt": action.created_at.isoformat() if action.created_at else None,
|
||||
}
|
||||
if action.reasoning:
|
||||
node["reasoning"] = action.reasoning
|
||||
# `{}` and an absent key mean different things. `{}` means the node left an
|
||||
# empty state behind, and an absent key means the state is unknown and the
|
||||
# live state stays as it is. An empty snapshot is therefore written rather
|
||||
# than omitted. It costs about eighteen bytes per row, and it decides
|
||||
# whether an undo clears a score or leaves it in place.
|
||||
if action.state_after is not None:
|
||||
node["stateAfter"] = action.state_after
|
||||
if action.world_state_after is not None:
|
||||
node["worldStateAfter"] = action.world_state_after
|
||||
if action.world_delta:
|
||||
node["worldDelta"] = action.world_delta
|
||||
return node
|
||||
|
||||
|
||||
def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
|
||||
return {
|
||||
"text": memory.text, "pinned": memory.pinned, "forgotten": memory.forgotten,
|
||||
"sourceStart": memory.source_start, "sourceEnd": memory.source_end,
|
||||
"useCount": memory.use_count,
|
||||
# The node this memory is attached to. A hand-written memory summarizes
|
||||
# no node, so it has a branch and no depth, and it keeps that shape
|
||||
# here.
|
||||
"branch": _local(memory.branch_id, local),
|
||||
"depth": memory.depth,
|
||||
}
|
||||
|
||||
|
||||
def _imported_persona(persona) -> dict:
|
||||
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
|
||||
|
||||
A raw-dict import bypasses the schemas, so the strings are truncated to the
|
||||
widths the columns declare, in the same way the rest of `_import` does. A
|
||||
bundle written before Phase 18 has no block at all, and an empty persona is
|
||||
the same as having none.
|
||||
"""
|
||||
if not isinstance(persona, dict):
|
||||
return {}
|
||||
return {
|
||||
"persona_name": str(persona.get("name") or "")[:schemas.PERSONA_NAME_MAX],
|
||||
"persona_pronouns":
|
||||
str(persona.get("pronouns") or "")[:schemas.PERSONA_PRONOUNS_MAX],
|
||||
"persona_desc": str(persona.get("desc") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _exported_anchor(
|
||||
adventure: models.Adventure, cursor: cursors.Cursor, local: dict[int, int]
|
||||
) -> dict:
|
||||
branch_id, depth = cursor.stored(adventure)
|
||||
return {
|
||||
"branch": local.get(branch_id) if branch_id is not None else None,
|
||||
"depth": depth,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- importing
|
||||
|
||||
def check_format(bundle: dict) -> str:
|
||||
"""Returns the bundle's version, or raises a 400."""
|
||||
fmt = bundle.get("format")
|
||||
if fmt in (FORMAT, LEGACY_FORMAT):
|
||||
return fmt
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Not an adventure export file (expected format {FORMAT} or {LEGACY_FORMAT}).",
|
||||
)
|
||||
|
||||
|
||||
def plan(bundle: dict, version: str) -> dict:
|
||||
"""Returns the bundle's tree, checked and normalized, before a row is written.
|
||||
|
||||
The function has no side effects. It opens no session, needs no adventure,
|
||||
and creates nothing. It catches everything a hand-edited file can get wrong
|
||||
about the shape of a tree, because the alternative is an import that fails
|
||||
partway and leaves an adventure holding a story with a gap in it.
|
||||
|
||||
Both versions produce the same shape, so `write` never learns that there are
|
||||
two formats. A version 1 bundle is a linear story, which is a tree with one
|
||||
branch, and its `variants` array is a sibling group written the old way.
|
||||
"""
|
||||
branches = (
|
||||
_planned_branches(bundle) if version == FORMAT else [dict(_ROOT)]
|
||||
)
|
||||
nodes = (
|
||||
_planned_nodes(bundle, len(branches)) if version == FORMAT
|
||||
else _planned_v1_nodes(bundle)
|
||||
)
|
||||
return {
|
||||
"branches": branches,
|
||||
"nodes": nodes,
|
||||
"memories": _planned_memories(bundle, len(branches)),
|
||||
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
|
||||
# Version 2 records where the derived work reached. Version 1 counted
|
||||
# it, and a count cannot become a node until the nodes exist. See
|
||||
# `settle`.
|
||||
"anchors": _planned_anchors(bundle, len(branches)) if version == FORMAT else None,
|
||||
"positions": None if version == FORMAT else {
|
||||
"memory": _as_int(bundle.get("memoryCursor"), 0),
|
||||
"summary": _as_int(bundle.get("summaryCursor"), 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _planned_branches(bundle: dict) -> list[dict]:
|
||||
raw = bundle.get("branches")
|
||||
entries = [b for b in raw if isinstance(b, dict)] if isinstance(raw, list) else []
|
||||
if not entries:
|
||||
return [dict(_ROOT)]
|
||||
specs: list[dict] = []
|
||||
for i, entry in enumerate(entries):
|
||||
parent = entry.get("parent")
|
||||
name = _planned_branch_name(entry, i)
|
||||
if parent is None:
|
||||
specs.append(dict(_ROOT, **({"name": name} if name else {})))
|
||||
continue
|
||||
# A branch may fork only from a branch listed before it. The export
|
||||
# writes them that way, because branches are numbered in creation order
|
||||
# and a parent always exists first. Requiring it here guarantees the
|
||||
# graph is acyclic for the cost of one comparison. A lineage is computed
|
||||
# by walking to the parent, so a cycle would be an import that never
|
||||
# returns.
|
||||
if not _is_int(parent) or not 0 <= parent < i:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Branch {i} forks from branch {parent!r}, which is not one of "
|
||||
f"the {i} branches listed before it.",
|
||||
)
|
||||
fork_depth = entry.get("forkDepth")
|
||||
if not _is_int(fork_depth):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Branch {i} forks from branch {parent} but does not say at "
|
||||
f"what depth.",
|
||||
)
|
||||
specs.append({
|
||||
"parent": parent, "forkDepth": fork_depth,
|
||||
**({"name": name} if name else {}),
|
||||
})
|
||||
return specs
|
||||
|
||||
|
||||
def _planned_branch_name(entry: dict, i: int) -> str | None:
|
||||
"""Returns the name a branch entry carries, or `None` if nobody named it.
|
||||
|
||||
The check runs before the row is created rather than being left to the
|
||||
column, for the reason the planner exists. A 400 from a function with no side
|
||||
effects is better than a half-written adventure and a database error three
|
||||
branches in.
|
||||
"""
|
||||
raw = entry.get("name")
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raise HTTPException(400, f"Branch {i} has a name that is not text.")
|
||||
name = raw.strip()
|
||||
if len(name) > schemas.BRANCH_NAME_MAX:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Branch {i}'s name is longer than {schemas.BRANCH_NAME_MAX} "
|
||||
f"characters.",
|
||||
)
|
||||
return name or None
|
||||
|
||||
|
||||
def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
|
||||
raw = bundle.get("actions")
|
||||
nodes: list[dict] = []
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(entry, dict) or not str(entry.get("text") or ""):
|
||||
continue
|
||||
branch = entry.get("branch", 0)
|
||||
if not _is_int(branch) or not 0 <= branch < branches:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"An action names branch {branch!r}, but the file lists "
|
||||
f"{branches}.",
|
||||
)
|
||||
depth = entry.get("depth")
|
||||
if not _is_int(depth) or depth < 0:
|
||||
raise HTTPException(
|
||||
400, f"An action on branch {branch} has no depth to sit at."
|
||||
)
|
||||
nodes.append({
|
||||
"branch": branch,
|
||||
"depth": depth,
|
||||
"live": bool(entry.get("live", True)),
|
||||
"type": str(entry.get("type") or "story")[:TYPE_MAX],
|
||||
"text": str(entry.get("text") or ""),
|
||||
"reasoning": _as_text(entry.get("reasoning")),
|
||||
"stateAfter": _as_dict(entry.get("stateAfter")),
|
||||
"worldStateAfter": _as_dict(entry.get("worldStateAfter")),
|
||||
"worldDelta": _as_dict(entry.get("worldDelta")),
|
||||
"createdAt": _as_time(entry.get("createdAt")),
|
||||
})
|
||||
return nodes
|
||||
|
||||
|
||||
def _planned_v1_nodes(bundle: dict) -> list[dict]:
|
||||
"""Returns a version 1 bundle's turns as nodes, one node per attempt.
|
||||
|
||||
The `variants` array is the repeating group SP4 unpacked, so reading one
|
||||
performs the same split that migration 60 does. Every attempt becomes a row
|
||||
at the turn's coordinate, and `variantIndex` selects the live one. The index
|
||||
is clamped, because a hand-edited bundle can name an attempt its own list
|
||||
does not contain, and a turn with no live node is a turn no read can see.
|
||||
|
||||
The depth is the bundle's `index`. A version 1 bundle has one branch, where
|
||||
the two numbers agree.
|
||||
"""
|
||||
raw = bundle.get("actions")
|
||||
nodes: list[dict] = []
|
||||
for i, entry in enumerate(raw if isinstance(raw, list) else []):
|
||||
if not isinstance(entry, dict) or not str(entry.get("text") or ""):
|
||||
continue
|
||||
depth = _as_int(entry.get("index"), i)
|
||||
kind = str(entry.get("type") or "story")[:TYPE_MAX]
|
||||
variants = [v for v in (entry.get("variants") or []) if isinstance(v, dict)]
|
||||
if not variants:
|
||||
variants = [{"text": entry["text"], "reasoning": entry.get("reasoning")}]
|
||||
live = min(max(_as_int(entry.get("variantIndex"), 0), 0), len(variants) - 1)
|
||||
for n, variant in enumerate(variants):
|
||||
text = str(variant.get("text") or "")
|
||||
if not text:
|
||||
continue
|
||||
nodes.append({
|
||||
"branch": 0,
|
||||
"depth": max(depth, 0),
|
||||
"live": n == live,
|
||||
"type": kind,
|
||||
"text": text,
|
||||
"reasoning": _as_text(variant.get("reasoning")),
|
||||
"stateAfter": None,
|
||||
"worldStateAfter": None,
|
||||
"worldDelta": None,
|
||||
"createdAt": _as_time(variant.get("createdAt")),
|
||||
})
|
||||
return nodes
|
||||
|
||||
|
||||
def _planned_memories(bundle: dict, branches: int) -> list[dict]:
|
||||
raw = bundle.get("memories")
|
||||
out: list[dict] = []
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(entry, dict) or not str(entry.get("text") or "").strip():
|
||||
continue
|
||||
out.append({
|
||||
"text": str(entry["text"]),
|
||||
"pinned": bool(entry.get("pinned", False)),
|
||||
"forgotten": bool(entry.get("forgotten", False)),
|
||||
"sourceStart": entry.get("sourceStart"),
|
||||
"sourceEnd": entry.get("sourceEnd"),
|
||||
"useCount": _as_int(entry.get("useCount"), 0),
|
||||
# A value that is out of range, rather than absent, means the file
|
||||
# disagrees with itself. The root is the safe reading, because a
|
||||
# memory on a branch nothing can see never reaches a prompt
|
||||
# again.
|
||||
"branch": _as_index(entry.get("branch"), branches, default=0),
|
||||
"depth": entry.get("depth") if _is_int(entry.get("depth")) else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||
anchors = {}
|
||||
for name in ("memory", "summary"):
|
||||
raw = bundle.get(f"{name}Cursor")
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
branch = raw.get("branch")
|
||||
anchors[name] = (
|
||||
_as_index(branch, branches) if branch is not None else None,
|
||||
_as_int(raw.get("depth"), lineage.NO_DEPTH),
|
||||
)
|
||||
return anchors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Writes a planned tree onto a newly created adventure.
|
||||
|
||||
The order is fixed. Branches come first, because a node needs a branch id.
|
||||
The nodes come next, because the head and the anchors name a node.
|
||||
"""
|
||||
ids = _write_branches(db, adventure, story["branches"])
|
||||
_write_nodes(db, adventure, story["nodes"], ids)
|
||||
_write_memories(db, adventure, story["memories"], ids)
|
||||
_point_the_head(adventure, story, ids)
|
||||
_write_anchors(adventure, story, ids)
|
||||
|
||||
|
||||
def _write_branches(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict]
|
||||
) -> list[int]:
|
||||
"""Writes one row per branch, computing the lineage rather than reading it.
|
||||
|
||||
The rows are inserted through Core and the lineage is written second, for the
|
||||
reason `tree.root_branch` gives: the lineage names the row's own id. The
|
||||
parent's cached ancestry is capped at this fork, which is the arithmetic
|
||||
`tree.fork` performs. A fork made now and a fork made a year ago and then
|
||||
exported have to produce the same rows.
|
||||
"""
|
||||
ids: list[int] = []
|
||||
lineages: list[list[list]] = []
|
||||
for spec in specs:
|
||||
parent = spec.get("parent")
|
||||
fork_depth = spec.get("forkDepth")
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=ids[parent] if parent is not None else None,
|
||||
fork_depth=fork_depth if parent is not None else None,
|
||||
lineage=[],
|
||||
name=spec.get("name"),
|
||||
created_at=models.utcnow(),
|
||||
)
|
||||
).inserted_primary_key[0]
|
||||
entries = [[new_id, None]]
|
||||
if parent is not None:
|
||||
entries += [
|
||||
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
|
||||
for branch_id, cap in lineages[parent]
|
||||
]
|
||||
db.execute(
|
||||
update(models.Branch)
|
||||
.where(models.Branch.id == new_id)
|
||||
.values(lineage=entries)
|
||||
)
|
||||
ids.append(new_id)
|
||||
lineages.append(entries)
|
||||
return ids
|
||||
|
||||
|
||||
def _write_nodes(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
"""Writes the nodes, grouped into the turns they are attempts at.
|
||||
|
||||
One value is decided here rather than read from the file. Exactly one
|
||||
attempt in each group is made live, because a file can name none or several,
|
||||
and a turn with no live node disappears from the story.
|
||||
"""
|
||||
groups: dict[tuple[int, int], list[models.Action]] = {}
|
||||
for spec in specs:
|
||||
key = (spec["branch"], spec["depth"])
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
type=spec["type"],
|
||||
text=spec["text"],
|
||||
reasoning=spec["reasoning"],
|
||||
live=spec["live"],
|
||||
state_after=spec["stateAfter"],
|
||||
world_state_after=spec["worldStateAfter"],
|
||||
world_delta=spec["worldDelta"],
|
||||
)
|
||||
if spec["createdAt"] is not None:
|
||||
action.created_at = spec["createdAt"]
|
||||
db.add(action)
|
||||
groups.setdefault(key, []).append(action)
|
||||
|
||||
for rows in groups.values():
|
||||
live = next((row for row in rows if row.live), rows[0])
|
||||
for row in rows:
|
||||
row.live = row is live
|
||||
|
||||
|
||||
def _write_memories(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
for spec in specs:
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id,
|
||||
text=spec["text"],
|
||||
pinned=spec["pinned"],
|
||||
forgotten=spec["forgotten"],
|
||||
source_start=spec["sourceStart"],
|
||||
source_end=spec["sourceEnd"],
|
||||
use_count=spec["useCount"],
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
)
|
||||
# A version 1 memory has no depth of its own. `source_end` is the index
|
||||
# of the last action it summarizes, which on one branch is that node's
|
||||
# depth.
|
||||
if memory.depth is None and memory.source_end is not None:
|
||||
memory.depth = memory.source_end
|
||||
# A version 1 memory that summarizes nothing, which means one the player
|
||||
# typed, has no depth to derive. Leaving it NULL here would recreate on
|
||||
# import the state migration 62 exists to end. `Path._entry_clause`
|
||||
# compares `depth <= max_depth`, which a NULL fails, so the memory would
|
||||
# disappear from every branch as soon as the imported adventure was
|
||||
# forked. The root is the answer the migration gives, for the same
|
||||
# reason: 0 is at or before every fork point, so the memory is visible
|
||||
# from every path this adventure can grow.
|
||||
if memory.depth is None:
|
||||
memory.depth = lineage.ROOT_DEPTH
|
||||
db.add(memory)
|
||||
|
||||
|
||||
def _point_the_head(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""Sets which branch the story is played on, and how deep it goes.
|
||||
|
||||
The branch comes from the file and the depth does not. The tip of a branch is
|
||||
whatever arrived on it, and a branch with no nodes of its own sits at its
|
||||
fork point, which is the last node its story contains. That node is borrowed
|
||||
but it is still the tip. This is the rule `tree.refresh_head` applies, run
|
||||
here before a flush.
|
||||
"""
|
||||
head = story["head"]
|
||||
adventure.head_branch_id = ids[head]
|
||||
depths = [n["depth"] for n in story["nodes"] if n["branch"] == head]
|
||||
if depths:
|
||||
adventure.head_depth = max(depths)
|
||||
return
|
||||
fork_depth = story["branches"][head].get("forkDepth")
|
||||
adventure.head_depth = fork_depth if fork_depth is not None else lineage.NO_DEPTH
|
||||
|
||||
|
||||
def _write_anchors(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""Records how far the memories and the summary have read. Version 2 only.
|
||||
|
||||
A version 1 bundle stores a count instead, and a count cannot be resolved to
|
||||
a node until the nodes are in the database. `settle` does that part
|
||||
afterwards.
|
||||
"""
|
||||
anchors = story["anchors"]
|
||||
if anchors is None:
|
||||
return
|
||||
for cursor in cursors.ALL:
|
||||
branch, depth = anchors[cursor.name]
|
||||
cursor.anchor(adventure, ids[branch] if branch is not None else None, depth)
|
||||
|
||||
|
||||
def settle(adventure: models.Adventure, story: dict) -> None:
|
||||
"""Resolves a version 1 bundle's counts into anchors, once the nodes exist.
|
||||
|
||||
A version 1 file records how far the memories and the summary have read as
|
||||
a count, so the anchor is found by counting that far along the story. A
|
||||
version 2 file carries the anchor itself, which `_write_anchors` has
|
||||
already stored, so there is nothing left to do.
|
||||
|
||||
The caller runs this after the flush, because counting needs the actions to
|
||||
be queryable.
|
||||
"""
|
||||
positions = story["positions"]
|
||||
if positions is None:
|
||||
return
|
||||
for cursor in cursors.ALL:
|
||||
cursors.anchor_at_position(adventure, cursor, positions[cursor.name])
|
||||
|
||||
|
||||
def materialize(
|
||||
db: Session, payload: dict, story: dict, user_id: int | None
|
||||
) -> models.Adventure:
|
||||
"""Writes a planned bundle into a new adventure owned by `user_id`.
|
||||
|
||||
Call `check_format` and `plan` first, and apply any rate or size limits
|
||||
before calling this. The split exists because those checks belong to the
|
||||
import endpoint alone: the guest starter writes a file the server ships, so
|
||||
it has nothing to rate-limit and no untrusted list to cap.
|
||||
|
||||
The caller commits. Two flushes happen here, because the anchors and the
|
||||
legacy counts describe one boundary in two coordinate systems, and aligning
|
||||
them needs the actions to be queryable.
|
||||
"""
|
||||
# A raw-dict import bypasses the schemas, so truncate strings bound for
|
||||
# VARCHAR columns. Postgres enforces the widths. See `schemas.py`.
|
||||
adventure = models.Adventure(
|
||||
user_id=user_id,
|
||||
title=str(payload.get("title") or "Imported Adventure")[:schemas.NAME_MAX],
|
||||
memory=str(payload.get("memory") or ""),
|
||||
authors_note=str(payload.get("authorsNote") or ""),
|
||||
ai_instructions=str(payload.get("aiInstructions") or ""),
|
||||
story_summary=str(payload.get("storySummary") or ""),
|
||||
world_state=payload.get("worldState") or {},
|
||||
auto_summarize=bool(payload.get("autoSummarize", False)),
|
||||
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
|
||||
**_imported_persona(payload.get("persona")),
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
|
||||
for card in payload.get("storyCards") or []:
|
||||
if isinstance(card, dict):
|
||||
db.add(models.StoryCard(
|
||||
adventure_id=adventure.id,
|
||||
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
|
||||
name=str(card.get("name") or "")[:schemas.NAME_MAX],
|
||||
keys=str(card.get("keys") or ""),
|
||||
entry=str(card.get("entry") or ""),
|
||||
notes=str(card.get("notes") or ""),
|
||||
))
|
||||
|
||||
# A bundle exported before M2 may carry "scripts" and "scriptState".
|
||||
# Campaign scripting is gone, so both are ignored rather than rejected: the
|
||||
# story, its tree, its cards and its memories still import intact, which is
|
||||
# what the bundle is for.
|
||||
|
||||
write(db, adventure, story)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
settle(adventure, story)
|
||||
return adventure
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
# Small coercions. A raw-dict import bypasses the schemas, so every value from a
|
||||
# bundle is whatever the JSON held.
|
||||
|
||||
def _is_int(value) -> bool:
|
||||
"""Returns whether `value` is an integer. Python counts `True` as an `int`,
|
||||
and a coordinate does not."""
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _as_int(value, default: int) -> int:
|
||||
return value if _is_int(value) else default
|
||||
|
||||
|
||||
def _as_index(value, count: int, default: int | None = None) -> int | None:
|
||||
"""Returns a local branch number, or `default` when the file names a branch
|
||||
that is not present.
|
||||
|
||||
An out-of-range value means the file disagrees with itself, and it is not a
|
||||
value any read can be given.
|
||||
"""
|
||||
return value if _is_int(value) and 0 <= value < count else default
|
||||
|
||||
|
||||
def _as_text(value) -> str | None:
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _as_dict(value) -> dict | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _as_time(value) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Storing a JSON column compressed.
|
||||
|
||||
`actions.context_snapshot` holds the entire assembled prompt for a turn. It is
|
||||
89% of the database, which is 150.8 MB of JSON across 944 actions in production
|
||||
and 232 kB per row on the longest adventure, and the free tier this deploys to
|
||||
allows 512 MB. Reads are not the problem. The column is deferred, so a page load
|
||||
never touches it and exactly one endpoint fetches one row of it at a time.
|
||||
Storage is the problem, and storage has a hard limit.
|
||||
|
||||
Postgres already compresses the column. TOAST brings 150.8 MB down to about
|
||||
89 MB, a factor of 1.7. Postgres chooses pglz for decompression speed on data a
|
||||
query might filter on, and no query filters on this column. A prompt is written
|
||||
once and read whole, occasionally, by one screen. zlib at the application layer
|
||||
reaches three to four times on the same text, and the cost is one decompression
|
||||
on a request that already makes an LLM call.
|
||||
|
||||
Doing it as a TypeDecorator rather than a second column keeps every call site
|
||||
writing `action.context_snapshot = {...}` and reading a dict back, and keeps
|
||||
`deferred=True`, `undefer()` and `load_only()` naming the same attribute they
|
||||
named before. The storage format changes; nothing else does.
|
||||
|
||||
Level 6 is zlib's default and the knee of the curve here: 9 spends noticeably
|
||||
more CPU on prompt text for about a percent more space.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zlib
|
||||
|
||||
from sqlalchemy import LargeBinary
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
LEVEL = 6
|
||||
|
||||
|
||||
def pack(value) -> bytes:
|
||||
"""A JSON-able value as compressed UTF-8."""
|
||||
raw = json.dumps(value, separators=(",", ":"), default=str).encode("utf-8")
|
||||
return zlib.compress(raw, LEVEL)
|
||||
|
||||
|
||||
def unpack(blob: bytes) -> object:
|
||||
"""The value `pack` was given."""
|
||||
return json.loads(zlib.decompress(bytes(blob)).decode("utf-8"))
|
||||
|
||||
|
||||
class CompressedJSON(TypeDecorator):
|
||||
"""A JSON column stored as zlib-compressed UTF-8 in a BLOB/BYTEA.
|
||||
|
||||
`cache_ok = True`: the type carries no per-instance configuration, so
|
||||
SQLAlchemy may reuse a compiled statement across instances of it.
|
||||
"""
|
||||
|
||||
impl = LargeBinary
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
return None if value is None else pack(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
# Tolerate a row the backfill has not reached yet, or one written
|
||||
# before the conversion: a snapshot that cannot be read back is worth
|
||||
# less than the screen that shows it, and never worth a 500 on the
|
||||
# turn that happens to load it.
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return unpack(value)
|
||||
except (zlib.error, UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,19 @@
|
||||
from . import history
|
||||
from .builder import (
|
||||
build_context,
|
||||
count_tokens,
|
||||
match_cards,
|
||||
render_persona,
|
||||
truncate_to_last_tokens,
|
||||
)
|
||||
from .history import story_actions
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
"count_tokens",
|
||||
"history",
|
||||
"match_cards",
|
||||
"render_persona",
|
||||
"story_actions",
|
||||
"truncate_to_last_tokens",
|
||||
]
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Context assembly per AI Dungeon's memory system
|
||||
(help.aidungeon.com/faq/the-memory-system):
|
||||
|
||||
[AI Instructions] always included
|
||||
[Player Character] always included when the adventure has a persona
|
||||
[Plot Essentials] always included (classic "Memory")
|
||||
[Story Summary] always included (manual in Phase 3, auto in Phase 6)
|
||||
[Used Memories] top-K memory-bank retrievals (Phase 6, when enabled)
|
||||
[Triggered Story Cards] "World Lore: <entry>", conditional; first dropped when over budget
|
||||
[Story history] newest actions that fit the remaining token budget
|
||||
[Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history
|
||||
[Latest player action] (+ script frontMemory right after it, Phase 4)
|
||||
|
||||
The list above comes from AI Dungeon's design. The order does not. This module
|
||||
emits every fixed section first and every changing section after the history,
|
||||
because prompt caching bills on a shared prefix. A section that changes near the
|
||||
top of the prompt re-prices everything below it. See the comments on the static
|
||||
block and the live sections in `build_context`.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import tiktoken
|
||||
|
||||
from .. import models, worldstate
|
||||
from . import encoding, history
|
||||
|
||||
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
|
||||
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
|
||||
NPC_WINDOW = 6 # actions of story searched for NPC trigger words ("in scene")
|
||||
SEPARATOR = "\n\n"
|
||||
|
||||
# Output-length guidance. The endpoint enforces `max_output_tokens` as a hard
|
||||
# limit, and it truncates the reply mid-sentence when the model reaches it. The
|
||||
# state block is emitted last, so truncation removes it. Asking the model to
|
||||
# finish inside the limit prevents the truncation.
|
||||
LENGTH_HEADROOM = 50 # Tokens reserved from the cap for the state block.
|
||||
# Models cannot count their own tokens, but they do follow a word budget, so the
|
||||
# hint states a number of words. English prose averages 0.75 words per token.
|
||||
WORDS_PER_TOKEN = 0.75
|
||||
# Models regularly exceed a word budget, and the cap it protects is a hard
|
||||
# limit. Aiming 10% below the real ceiling leaves room for that overshoot, so it
|
||||
# does not consume the state block.
|
||||
LENGTH_BUFFER = 0.90
|
||||
MIN_LENGTH_HINT_WORDS = 40 # Below this, the hint adds nothing useful.
|
||||
# A ceiling on its own gives one-sided guidance, and models respond to it
|
||||
# differently. A verbose model treats it as a limit. A terse model has only the
|
||||
# instruction to write as much as the moment needs, and it produces two
|
||||
# paragraphs. Adding a floor turns the guidance into a range, so the same prompt
|
||||
# produces a similar length from either model. The floor is a share of the
|
||||
# ceiling so that it can never approach the ceiling.
|
||||
LENGTH_FLOOR_SHARE = 0.35
|
||||
# Below this word count, a floor means nothing, because a short turn is the
|
||||
# correct turn at a tight cap. The wording used at a tight cap is also the
|
||||
# wording that was measured to preserve the state block, so it is unchanged.
|
||||
MIN_LENGTH_FLOOR_WORDS = 60
|
||||
# The floor prevents a collapse to two paragraphs. It does not ask for an essay.
|
||||
# At a 2400-token cap, the share alone would request a minimum of 555 words. A
|
||||
# reader who wants longer turns can ask for them in the author's note.
|
||||
MAX_LENGTH_FLOOR_WORDS = 300
|
||||
|
||||
|
||||
# Built from the table vendored in `encoding.py`, not fetched: the upstream
|
||||
# `tiktoken.get_encoding("cl100k_base")` downloads it on first use, and this
|
||||
# is called on every turn.
|
||||
def _encoding() -> tiktoken.Encoding:
|
||||
return encoding.get_encoding()
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
return len(_encoding().encode(text))
|
||||
|
||||
|
||||
def truncate_to_last_tokens(text: str, budget: int) -> str:
|
||||
tokens = _encoding().encode(text)
|
||||
if len(tokens) <= budget:
|
||||
return text
|
||||
return _encoding().decode(tokens[-budget:])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
label: str
|
||||
text: str
|
||||
|
||||
@property
|
||||
def tokens(self) -> int:
|
||||
return count_tokens(self.text)
|
||||
|
||||
|
||||
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
"""Ask for a turn that fits inside the output cap, stated as a word budget.
|
||||
|
||||
Returns an empty string when the cap is too small to state usefully. The
|
||||
model can exceed the hint, so the hint earns its tokens only when there is
|
||||
enough room for that overshoot to stay inside the cap.
|
||||
"""
|
||||
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
|
||||
if words < MIN_LENGTH_HINT_WORDS:
|
||||
return ""
|
||||
tail = (
|
||||
" Finish the narration and append the state block well inside the limit."
|
||||
if has_ws
|
||||
else " Bring the turn to a close well inside the limit rather than "
|
||||
"stopping mid-sentence."
|
||||
)
|
||||
# State the number as a ceiling, never as a budget. In measurements, the
|
||||
# wording "keep this turn under about N words" read to the model as a target
|
||||
# to fill. It raised the average from 174 words to 246 across five runs, and
|
||||
# every hinted run was longer than every unhinted run. The hint therefore
|
||||
# pushed turns toward the limit it exists to avoid. Naming the number as a
|
||||
# limit, and adding that a typical turn is much shorter, held the average at
|
||||
# 170 while still preserving the state block at tight caps.
|
||||
floor = min(int(words * LENGTH_FLOOR_SHARE), MAX_LENGTH_FLOOR_WORDS)
|
||||
if floor < MIN_LENGTH_FLOOR_WORDS:
|
||||
return (
|
||||
f"[Hard limit: this turn must not exceed {words} words. Write only as "
|
||||
f"much as the moment needs — a typical turn is much shorter.{tail}]"
|
||||
)
|
||||
# Both numbers are bounds, and the wording is deliberately asymmetric. The
|
||||
# ceiling uses "must not exceed", because the endpoint enforces it. The floor
|
||||
# uses "should not stop short of". Neither reads as a target, which the
|
||||
# measurement above shows is what matters. The clause that asks the model to
|
||||
# prefer the lower end does the job the earlier wording did, which was to
|
||||
# keep a verbose model away from the ceiling. It now has a number beneath it,
|
||||
# so a terse model reading the same clause stops at the floor rather than at
|
||||
# forty words.
|
||||
return (
|
||||
f"[Hard limit: this turn must not exceed {words} words, and it should not "
|
||||
f"stop short of about {floor}. Prefer the lower end of that range unless "
|
||||
f"the scene genuinely needs more.{tail}]"
|
||||
)
|
||||
|
||||
|
||||
def render_persona(adventure: models.Adventure) -> str:
|
||||
"""Returns the Player Character section, or "" when there is no persona.
|
||||
|
||||
The three fields are independent. A name alone is enough, a description
|
||||
alone is enough, and the wording holds together for either. Pronouns are
|
||||
stated because the summarizer in `memorybank` writes about the protagonist
|
||||
in the third person, and a model that has to infer a pronoun from a name
|
||||
will sometimes infer wrongly and then repeat that error in every memory it
|
||||
writes.
|
||||
"""
|
||||
name = adventure.persona_name.strip()
|
||||
pronouns = adventure.persona_pronouns.strip()
|
||||
desc = adventure.persona_desc.strip()
|
||||
if not (name or desc):
|
||||
return ""
|
||||
head = f"You are {name}" if name else ""
|
||||
if head and pronouns:
|
||||
head += f" ({pronouns})"
|
||||
# Joined with a space, not `SEPARATOR`: this is one short paragraph about
|
||||
# one character, and a blank line inside it reads as two unrelated notes.
|
||||
body = " ".join(part for part in (f"{head}." if head else "", desc) if part)
|
||||
return f"Player character:\n{body}"
|
||||
|
||||
|
||||
def _script_memory(adventure: models.Adventure) -> dict:
|
||||
"""Script-provided memory overrides (populated by Phase 4 scripting)."""
|
||||
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
|
||||
memory = state.get("memory")
|
||||
return memory if isinstance(memory, dict) else {}
|
||||
|
||||
|
||||
def _history_text(action: models.Action) -> str:
|
||||
"""Returns an AI turn as the model should see it in replayed history.
|
||||
|
||||
The result is the narration with its state block appended again,
|
||||
reconstructed from the stored delta. The app strips that block before
|
||||
storing and displaying the turn. Without this function, every past AI turn
|
||||
would appear to have emitted no state, and the model would copy that pattern
|
||||
and stop emitting state itself. Player turns and turns with no block pass
|
||||
through unchanged.
|
||||
|
||||
The block replays the changes the engine ACCEPTED, not the ones the model
|
||||
sent. Replaying what was sent showed the model a refused change standing as
|
||||
though it had been applied, while the live values in the same prompt
|
||||
disagreed with it. Nothing marked which of the two was true, so the model
|
||||
read its own refused change as correct and sent it again.
|
||||
|
||||
This function reads `world_delta` rather than `context_snapshot`. It runs
|
||||
for every action in the replayed history, and `context_snapshot` is deferred
|
||||
so that a turn never loads the prompt archive from the database.
|
||||
"""
|
||||
text = action.text
|
||||
wd = action.world_delta if isinstance(action.world_delta, dict) else None
|
||||
if wd:
|
||||
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
|
||||
if block:
|
||||
text = f"{text}\n{block}"
|
||||
return text
|
||||
|
||||
|
||||
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
|
||||
"""Returns the NPCs whose trigger words appear in the recent story.
|
||||
|
||||
These are the NPCs in scene, and the prompt includes stats for them only.
|
||||
The result maps an NPC id to its display name.
|
||||
|
||||
`actions` holds only the most recent actions. See `NPC_WINDOW`.
|
||||
"""
|
||||
recent = SEPARATOR.join(a.text for a in actions).lower()
|
||||
visible: dict[str, str] = {}
|
||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||
if not isinstance(ndef, dict):
|
||||
continue
|
||||
if any(trigger in recent for trigger in worldstate.npc_triggers(ndef, npc_key)):
|
||||
visible[npc_key] = worldstate.npc_name(ndef, npc_key)
|
||||
return visible
|
||||
|
||||
|
||||
def match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
|
||||
"""Returns one record per matched story card, naming the keyword that matched.
|
||||
|
||||
Matching follows AI Dungeon's rules. It ignores case, respects spaces, and
|
||||
matches partial words, so "boat" matches "boats".
|
||||
|
||||
Public since Phase 18b: `memorybank.cast_brief` runs the same rule over the
|
||||
block it is about to summarize, so that the summarizer is told who the
|
||||
characters in that stretch of story are. One rule, one implementation.
|
||||
"""
|
||||
haystack = window_text.lower()
|
||||
matched = []
|
||||
for card in cards:
|
||||
for key in (k.strip().lower() for k in card.keys.split(",")):
|
||||
if key and key in haystack:
|
||||
matched.append(
|
||||
{"id": card.id, "name": card.name, "keyword": key, "entry": card.entry}
|
||||
)
|
||||
break
|
||||
return matched
|
||||
|
||||
|
||||
def build_context(
|
||||
adventure: models.Adventure,
|
||||
settings: models.Settings,
|
||||
memory_bank: dict | None = None,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> tuple[str, str, dict]:
|
||||
"""Returns (system_text, story_text, context_report). `memory_bank` is the
|
||||
result of memorybank.retrieve_memories (None when the bank is off);
|
||||
`exclude_action_id` omits one action from the story (see history.py)."""
|
||||
script_mem = _script_memory(adventure)
|
||||
|
||||
# ----- The static block, which is identical on every turn -----
|
||||
# This ordering exists to reduce cost. Prompt caching matches a prefix. The
|
||||
# endpoint reuses the prompt up to the first byte that differs from the
|
||||
# previous request, and no further. A section that changes near the top
|
||||
# therefore re-prices everything below it, and what sits below it is the
|
||||
# story history, which is most of the prompt. Sections that change from turn
|
||||
# to turn go after the history, among the live sections. Placing them there
|
||||
# also gives them the most recency, which is why `EMIT_REMINDER` goes last.
|
||||
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
|
||||
|
||||
# RPG world state (Phase 12): the instructions for reporting changes. The
|
||||
# live values go into a live section below. The guide derived from the
|
||||
# schema and the emit rule do not change while the scenario is unchanged.
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
has_ws = worldstate.has_schema(stat_schema)
|
||||
persona_name = adventure.persona_name.strip()
|
||||
if has_ws:
|
||||
guide = worldstate.render_reference(stat_schema, persona_name)
|
||||
if guide:
|
||||
system_sections.append(Section("world_state_guide", guide))
|
||||
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
|
||||
|
||||
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
|
||||
system_sections.append(Section("script_context", script_mem["context"].strip()))
|
||||
if adventure.ai_instructions.strip():
|
||||
system_sections.append(Section("ai_instructions", adventure.ai_instructions.strip()))
|
||||
# Phase 18. This sits in the static block because only the user can edit it,
|
||||
# so it never changes mid-story and stays inside the cached prefix. It is
|
||||
# emitted whether or not the adventure has an RPG layer: an adventure with
|
||||
# no stats still has a protagonist, and that is the case the persona was
|
||||
# added for.
|
||||
persona_text = render_persona(adventure)
|
||||
if persona_text:
|
||||
system_sections.append(Section("persona", persona_text))
|
||||
if adventure.memory.strip():
|
||||
system_sections.append(
|
||||
Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}")
|
||||
)
|
||||
|
||||
# ----- Live sections, which hold everything that changes -----
|
||||
# This code builds them here and places them after the history further down.
|
||||
# They are ordered from least to most volatile, so a turn that changes only
|
||||
# the fastest-moving section leaves the others cached. The summary is
|
||||
# rewritten every few turns. Lore changes with the scene. The retrieved
|
||||
# memories change on most turns, and the stat values change on nearly every
|
||||
# turn. `world_lore` is added below, because the history window determines
|
||||
# which cards trigger and that window is not known yet.
|
||||
summary_section = (
|
||||
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
|
||||
if adventure.story_summary.strip()
|
||||
else None
|
||||
)
|
||||
memories_section = None
|
||||
if memory_bank and memory_bank.get("used"):
|
||||
lines_text = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
|
||||
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
|
||||
world_state_section = None
|
||||
refusal_note = ""
|
||||
if has_ws:
|
||||
# One read serves both the in-scene NPCs and the refusal note below.
|
||||
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
|
||||
block = worldstate.render_state_section(
|
||||
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
|
||||
persona_name,
|
||||
)
|
||||
if block:
|
||||
world_state_section = Section("world_state", block)
|
||||
# Corrections for the previous AI turn only. A refusal the model has
|
||||
# already had one chance to fix is stale, and repeating it every turn
|
||||
# would price a correction into the whole rest of the adventure.
|
||||
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
|
||||
if last_ai is not None:
|
||||
refusal_note = worldstate.render_refusals(last_ai.world_delta)
|
||||
|
||||
authors_note_text = adventure.authors_note.strip()
|
||||
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
|
||||
authors_note_text = script_mem["authorsNote"].strip()
|
||||
authors_note = f"[Author's note: {authors_note_text}]" if authors_note_text else ""
|
||||
|
||||
front_memory = ""
|
||||
if isinstance(script_mem.get("frontMemory"), str):
|
||||
front_memory = script_mem["frontMemory"].strip()
|
||||
|
||||
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
|
||||
|
||||
# The live sections sit below the history, but they are still part of the
|
||||
# prompt, so they still count against the budget. `world_lore` is the
|
||||
# exception, because the code below budgets it out of `available`.
|
||||
reserved = (
|
||||
sum(s.tokens for s in system_sections)
|
||||
+ sum(
|
||||
s.tokens
|
||||
for s in (summary_section, memories_section, world_state_section)
|
||||
if s is not None
|
||||
)
|
||||
+ count_tokens(authors_note)
|
||||
+ count_tokens(front_memory)
|
||||
+ count_tokens(length_note)
|
||||
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
|
||||
+ count_tokens(refusal_note)
|
||||
)
|
||||
available = max(256, settings.context_token_budget - reserved)
|
||||
|
||||
# Only the newest actions can reach the prompt, because the code below
|
||||
# either truncates the text to `available` tokens or stops at the budget.
|
||||
# Fetch a window that is provably larger than that and no larger. Otherwise
|
||||
# a long adventure reads its whole history on every turn and uses only the
|
||||
# end of it.
|
||||
actions = history.window_covering(
|
||||
adventure, available, count_tokens, exclude_action_id
|
||||
)
|
||||
|
||||
# ----- Story cards: triggered by recent story text (the window history could fill) -----
|
||||
trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available)
|
||||
triggered = match_cards(adventure.story_cards, trigger_window)
|
||||
|
||||
card_budget = int(available * CARD_BUDGET_SHARE)
|
||||
card_records = []
|
||||
lore_lines: list[str] = []
|
||||
used = 0
|
||||
for match in triggered:
|
||||
line = f"World Lore: {match['entry'].strip()}"
|
||||
tokens = count_tokens(line)
|
||||
included = used + tokens <= card_budget
|
||||
if included:
|
||||
lore_lines.append(line)
|
||||
used += tokens
|
||||
card_records.append(
|
||||
{"id": match["id"], "name": match["name"], "keyword": match["keyword"],
|
||||
"included": included}
|
||||
)
|
||||
lore_section = (
|
||||
Section("world_lore", "\n".join(lore_lines)) if lore_lines else None
|
||||
)
|
||||
|
||||
# ----- Story history: newest first until the remaining budget is spent -----
|
||||
history_budget = available - used
|
||||
included_actions: list[models.Action] = []
|
||||
spent = 0
|
||||
oldest_truncated = False
|
||||
for action in reversed(actions):
|
||||
# Budget against the text as it appears in the prompt, which includes
|
||||
# the state block when this adventure tracks world state.
|
||||
rendered = _history_text(action) if has_ws else action.text
|
||||
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
|
||||
if spent + tokens > history_budget:
|
||||
if not included_actions:
|
||||
# Even the newest action alone is over budget: hard-truncate it.
|
||||
included_actions.append(
|
||||
models.Action(
|
||||
adventure_id=action.adventure_id,
|
||||
type=action.type,
|
||||
text=truncate_to_last_tokens(action.text, history_budget),
|
||||
)
|
||||
)
|
||||
oldest_truncated = True
|
||||
break
|
||||
included_actions.append(action)
|
||||
spent += tokens
|
||||
included_actions.reverse()
|
||||
|
||||
# ----- Assemble the story text, with the author's note near the end -----
|
||||
# Append each AI turn's state block again. The app strips it before storage,
|
||||
# and the recent history has to show the model the pattern to follow.
|
||||
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
|
||||
note_sections: list[Section] = []
|
||||
if authors_note:
|
||||
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
|
||||
before, after = texts[:pos], texts[pos:]
|
||||
if before:
|
||||
note_sections.append(Section("history", SEPARATOR.join(before)))
|
||||
note_sections.append(Section("authors_note", authors_note))
|
||||
note_sections.append(Section("recent_history", SEPARATOR.join(after)))
|
||||
else:
|
||||
note_sections.append(Section("history", SEPARATOR.join(texts)))
|
||||
# The live sections, ordered from least to most volatile. See the comment
|
||||
# where they are built. They go below the history so that the history stays
|
||||
# cached, and above the final sections so that those stay last.
|
||||
for live in (summary_section, lore_section, memories_section, world_state_section):
|
||||
if live is not None:
|
||||
note_sections.append(live)
|
||||
if front_memory:
|
||||
note_sections.append(Section("front_memory", front_memory))
|
||||
# Place the length hint just above the emit reminder, which keeps the last
|
||||
# position. The length budget applies to the narration, and the reminder
|
||||
# applies to the block that follows it, so this is also the order in which
|
||||
# the model acts.
|
||||
note_sections.append(Section("length_hint", length_note))
|
||||
if has_ws:
|
||||
# A correction for the previous turn sits directly above the reminder
|
||||
# to emit a block, which is the instruction it modifies.
|
||||
if refusal_note:
|
||||
note_sections.append(Section("world_state_refusals", refusal_note))
|
||||
# The emit rule sits in the system block, far from where the model
|
||||
# generates text, so repeat it last where it has the most effect.
|
||||
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
|
||||
|
||||
story_sections = [s for s in note_sections if s.text]
|
||||
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
|
||||
story_text = SEPARATOR.join(s.text for s in story_sections)
|
||||
|
||||
all_sections = [s for s in system_sections if s.text] + story_sections
|
||||
report = {
|
||||
"sections": [
|
||||
{"label": s.label, "text": s.text, "tokens": s.tokens} for s in all_sections
|
||||
],
|
||||
"prompt": {"system": system_text, "story": story_text},
|
||||
"tokens": {
|
||||
"total": count_tokens(system_text) + count_tokens(story_text),
|
||||
"budget": settings.context_token_budget,
|
||||
},
|
||||
"cards": card_records,
|
||||
"memories": memory_bank,
|
||||
"history": {
|
||||
"included": len(included_actions),
|
||||
# The count covers the whole story rather than the window fetched
|
||||
# above. Insights reports how many of the total actions it
|
||||
# included, so this number must be the real total.
|
||||
"total": history.count(adventure, exclude_action_id),
|
||||
"oldest_truncated": oldest_truncated,
|
||||
},
|
||||
"settings": {
|
||||
"model": settings.model,
|
||||
"api_mode": settings.api_mode,
|
||||
"temperature": settings.temperature,
|
||||
"max_output_tokens": settings.max_output_tokens,
|
||||
},
|
||||
}
|
||||
return system_text, story_text, report
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Phase 14: tracks how far along a story the derived work has reached.
|
||||
|
||||
Two things are built from the story and stored beside it: the memories and the
|
||||
story summary. Both need to record where they stopped.
|
||||
|
||||
That mark used to be a count, such as "the first 12 story actions are covered".
|
||||
A count is a position in a list, and this list changes. If you delete an action
|
||||
in front of the mark, every later action moves down one slot, so the mark now
|
||||
covers an action it never read. The rules in `memorybank` for sliding cursors,
|
||||
rewinding them, and converting between positions and `Action.index` all existed
|
||||
to correct for that, and each rule was a chance to introduce a silent error.
|
||||
|
||||
A cursor here is an anchor instead. It stores `(branch_id, depth)`, naming the
|
||||
node up to and including which the work is done. Deleting an action does not
|
||||
move it, because a depth is a coordinate along a path rather than a position in
|
||||
a list. The question "what is not covered yet" becomes
|
||||
`history.count_after(anchor)`, which asks about the story rather than about a
|
||||
list index, and it stays correct no matter what is deleted in front of it.
|
||||
|
||||
The branch half of the anchor is what makes it survive forking. A depth alone is
|
||||
ambiguous once two branches both hold a node at depth 41. The anchor names the
|
||||
branch, and `Path.depth_on` reads it back as a depth on whichever story is being
|
||||
played. That read caps the depth at the fork, or reports nothing covered if the
|
||||
anchor sits on a branch this path does not contain. Until forking ships there is
|
||||
one branch, so this always returns the stored depth. That is the point: the
|
||||
coordinate system is correct before anything depends on it.
|
||||
|
||||
`NO_DEPTH`, which is -1, means nothing is covered. A new adventure therefore
|
||||
needs no special case, because every node is deeper than -1.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from . import history, lineage
|
||||
|
||||
NO_DEPTH = lineage.NO_DEPTH
|
||||
|
||||
|
||||
class Cursor:
|
||||
"""One anchor on the adventure row, for either the memory bank or the summary.
|
||||
|
||||
The anchor is a pair of columns rather than a foreign key to the node. The
|
||||
node can be deleted, which is what undo does, and the boundary still means
|
||||
something afterwards. A foreign key would repeatedly fail to resolve.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.branch_field = f"{name}_cursor_branch_id"
|
||||
self.depth_field = f"{name}_cursor_depth"
|
||||
|
||||
# ------------------------------------------------------------- reading
|
||||
|
||||
def stored(self, adventure: models.Adventure) -> tuple[int | None, int]:
|
||||
"""Returns the anchor as written, without resolving it against a path."""
|
||||
depth = getattr(adventure, self.depth_field)
|
||||
return getattr(adventure, self.branch_field), (
|
||||
NO_DEPTH if depth is None else depth
|
||||
)
|
||||
|
||||
def depth(self, db: Session, adventure: models.Adventure) -> int:
|
||||
"""Returns the anchor as a depth on the story being played now."""
|
||||
branch_id, depth = self.stored(adventure)
|
||||
return lineage.path_of(db, adventure).depth_on(branch_id, depth)
|
||||
|
||||
# ------------------------------------------------------------- writing
|
||||
|
||||
def anchor(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Sets the anchor to a coordinate supplied directly.
|
||||
|
||||
This is the plain setter beneath `anchor_at`. Only an import supplies a
|
||||
coordinate with no node to read it from. A v2 bundle carries the anchor
|
||||
itself, as described in `app/bundle.py`, and the node it named lives in
|
||||
a different database.
|
||||
"""
|
||||
setattr(adventure, self.branch_field, branch_id)
|
||||
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
|
||||
|
||||
def anchor_at(self, adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Marks the work done up to and including `node`.
|
||||
|
||||
This uses the node's own branch rather than the adventure's head. A block
|
||||
of six actions can end before the fork that created the current branch,
|
||||
and the coverage belongs where those actions are.
|
||||
"""
|
||||
self.anchor(adventure, node.branch_id, lineage.NO_DEPTH
|
||||
if node.depth is None else node.depth)
|
||||
|
||||
def clear(self, adventure: models.Adventure) -> None:
|
||||
"""Clears the anchor, so that nothing counts as covered.
|
||||
|
||||
Call this when the branch the anchor referred to is deleted. On Postgres
|
||||
a stale branch id would never resolve. On SQLite the next fork can reuse
|
||||
an id that was just freed, and a stale anchor would then resolve onto a
|
||||
branch it never saw and report that stretch of story as summarized.
|
||||
Clearing the anchor costs one re-summarize, which is the safe direction
|
||||
to be wrong in.
|
||||
"""
|
||||
self.anchor(adventure, None, NO_DEPTH)
|
||||
|
||||
def rewind_to(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Moves the anchor back to `depth` if it is past that depth.
|
||||
|
||||
The anchor never moves forward here. Moving backward is the only
|
||||
direction that is safe without knowing what else changed. Covering
|
||||
ground twice costs one summarizer call. Skipping ground removes a
|
||||
stretch of story from the memories permanently.
|
||||
"""
|
||||
_, current = self.stored(adventure)
|
||||
if current <= depth:
|
||||
return
|
||||
setattr(adventure, self.branch_field, branch_id)
|
||||
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
|
||||
|
||||
|
||||
MEMORY = Cursor("memory")
|
||||
SUMMARY = Cursor("summary")
|
||||
ALL = (MEMORY, SUMMARY)
|
||||
|
||||
|
||||
def rewind_all(
|
||||
adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Returns a stretch of story to both the memory pass and the summary pass.
|
||||
|
||||
The two move together because they cover the same actions from different
|
||||
directions. The summary folds in the memories, so withdrawing a memory
|
||||
without rewinding the summary would leave the summary claiming to have read
|
||||
something that no longer exists.
|
||||
"""
|
||||
for cursor in ALL:
|
||||
cursor.rewind_to(adventure, branch_id, depth)
|
||||
|
||||
|
||||
def anchor_at_position(
|
||||
adventure: models.Adventure, cursor: Cursor, position: int
|
||||
) -> None:
|
||||
"""Sets `cursor` from a count of covered story actions.
|
||||
|
||||
A v1 bundle stores its mark as a count, and so does a database written
|
||||
before the anchors existed.
|
||||
|
||||
The action at `position` in depth order is the node that carries the same
|
||||
meaning, and it keeps that meaning after something in front of it is
|
||||
deleted. A position past the end of the story is not an invalid value. An
|
||||
adventure that was fully caught up under the old rule can hold one, and it
|
||||
means the same thing as the tip, so this function anchors at the tip.
|
||||
|
||||
`migrations._backfill_cursor_anchors` implements this rule in SQL for every
|
||||
adventure at once, without loading any of them. The two must agree.
|
||||
"""
|
||||
if position <= 0:
|
||||
return
|
||||
covered = history.slice_(adventure, position - 1, 1) or history.tail(adventure, 1)
|
||||
if covered:
|
||||
cursor.anchor_at(adventure, covered[0])
|
||||
|
||||
|
||||
def position_of(adventure: models.Adventure, depth: int) -> int:
|
||||
"""Returns how many story actions lie at or before `depth`.
|
||||
|
||||
This reads an anchor back as a count. The v1 export bundle stores cursors as
|
||||
counts, and builds that have never used depths read v1 bundles. This
|
||||
function is the only remaining code that speaks that coordinate system. The
|
||||
v2 format introduced in SP6 replaces it.
|
||||
"""
|
||||
return max(history.count(adventure) - history.count_after(adventure, depth), 0)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""`cl100k_base` without a first-use download.
|
||||
|
||||
Upstream called `tiktoken.get_encoding("cl100k_base")`, which fetches the BPE
|
||||
table from `openaipublic.blob.core.windows.net` the first time it is used and
|
||||
caches it under the system temp directory. That download is invisible on a
|
||||
developer machine that has already made it once, and fatal on a machine with
|
||||
outbound Internet blocked: the context builder counts tokens on *every* turn,
|
||||
so the first story turn died with a `ConnectionError` instead of narrating
|
||||
(Phase 0B offline report; acceptance tests A01 and H11).
|
||||
|
||||
The table is vendored beside this module and the `Encoding` is constructed from
|
||||
it directly, so no code path inside the tokenizer can reach the network — not a
|
||||
cache that happens to be warm, and not an environment variable a deployment
|
||||
could forget to set.
|
||||
|
||||
The vendored file's SHA-256 is verified against the digest `tiktoken` itself
|
||||
pins for that URL. A truncated checkout or a substituted table then fails
|
||||
loudly, rather than silently changing every token count the context budget is
|
||||
computed from.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import tiktoken
|
||||
|
||||
ENCODING_NAME = "cl100k_base"
|
||||
|
||||
#: Where the table came from, recorded so the vendored copy can be re-derived.
|
||||
SOURCE_URL = "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken"
|
||||
|
||||
#: The digest `tiktoken_ext.openai_public.cl100k_base()` pins for SOURCE_URL.
|
||||
BPE_SHA256 = "223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7"
|
||||
|
||||
BPE_PATH = Path(__file__).resolve().parent / "vendor" / "cl100k_base.tiktoken"
|
||||
|
||||
# Both copied from `tiktoken_ext.openai_public.cl100k_base()`. They are part of
|
||||
# the encoding's identity: the same merge table with a different pattern is a
|
||||
# different tokenizer, so they are pinned here rather than imported, and the
|
||||
# round-trip test asserts this build agrees with tiktoken's own.
|
||||
_PAT_STR = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s"""
|
||||
_SPECIAL_TOKENS = {
|
||||
"<|endoftext|>": 100257,
|
||||
"<|fim_prefix|>": 100258,
|
||||
"<|fim_middle|>": 100259,
|
||||
"<|fim_suffix|>": 100260,
|
||||
"<|endofprompt|>": 100276,
|
||||
}
|
||||
|
||||
|
||||
def _mergeable_ranks() -> dict[bytes, int]:
|
||||
try:
|
||||
data = BPE_PATH.read_bytes()
|
||||
except OSError as exc: # pragma: no cover - packaging fault, not a run fault
|
||||
raise RuntimeError(
|
||||
f"The vendored {ENCODING_NAME} table is missing at {BPE_PATH}. "
|
||||
f"Re-download it from {SOURCE_URL} (SHA-256 {BPE_SHA256})."
|
||||
) from exc
|
||||
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if digest != BPE_SHA256:
|
||||
raise RuntimeError(
|
||||
f"The vendored {ENCODING_NAME} table at {BPE_PATH} has SHA-256 "
|
||||
f"{digest}, expected {BPE_SHA256}."
|
||||
)
|
||||
|
||||
# Same parse as tiktoken.load.load_tiktoken_bpe, minus its fetch/cache step.
|
||||
ranks: dict[bytes, int] = {}
|
||||
for line in data.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
token, rank = line.split()
|
||||
ranks[base64.b64decode(token)] = int(rank)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Error parsing line {line!r} in {BPE_PATH}") from exc
|
||||
return ranks
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_encoding() -> tiktoken.Encoding:
|
||||
"""The `cl100k_base` encoding, built from the vendored table."""
|
||||
return tiktoken.Encoding(
|
||||
name=ENCODING_NAME,
|
||||
pat_str=_PAT_STR,
|
||||
mergeable_ranks=_mergeable_ranks(),
|
||||
special_tokens=_SPECIAL_TOKENS,
|
||||
)
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Reads part of a story without loading all of it.
|
||||
|
||||
`story_actions()` used to walk `adventure.actions`, which loads every row of the
|
||||
adventure. Every caller then discarded nearly all of those rows. The context
|
||||
builder joins the story and immediately trims it to the token budget. The
|
||||
in-scene NPC check reads the last 6 actions. Memory retrieval reads the last 4.
|
||||
The post-turn cursor clamp needs only a count. A turn on a 200-action adventure
|
||||
read about 840 KB in order to use about 70 KB, and the cost grew with every
|
||||
turn.
|
||||
|
||||
This module serves those shapes from SQL directly, as a tail, a slice, or a
|
||||
count. A read is therefore bounded by the context budget rather than by the
|
||||
length of the story.
|
||||
|
||||
Three rules hold the module together:
|
||||
|
||||
- There is one definition of a story action. That definition decides both what
|
||||
a reader sees and what the summarizer receives, so the SQL and the Python must
|
||||
agree exactly. `_STORY_TEXT` and `is_story_text()` express the same rule
|
||||
twice. Keep them in step.
|
||||
- No caller loads the same rows twice. If `adventure.actions` is already in
|
||||
memory, every helper here slices that collection instead of running a query.
|
||||
The scripting pipeline hands the whole history to user scripts, as AI Dungeon
|
||||
does, so a scripted adventure costs no more than it did before.
|
||||
- Every read applies the branch clause, as of Phase 14. `adventure.actions`
|
||||
holds the actions of every branch rather than the story being played, so the
|
||||
in-memory path filters the collection down to the path before slicing it, in
|
||||
the same way the SQL does. Skipping that filter would build a prompt from two
|
||||
different stories without reporting an error, which is why `lineage.Path`
|
||||
owns both forms of the rule.
|
||||
|
||||
Reads order by `depth` rather than `index`. Since SP4, two different rows can
|
||||
hold the same value for both columns, because the attempts at one turn share
|
||||
them. Only `depth` together with the `live` test in the branch clause
|
||||
identifies the row the story uses.
|
||||
|
||||
SP3 added the reads that count from a node rather than from the start:
|
||||
`count_after`, `after`, and `newest`. The memory bank used to ask for positions
|
||||
12 through 18 of the story, and the answer to that question changes when an
|
||||
action in front of those positions is deleted. It now asks for the six actions
|
||||
after depth 41, which is the question that forking requires in any case.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from .. import models
|
||||
from . import lineage
|
||||
|
||||
# How many of the newest actions to read before checking whether they cover the
|
||||
# token budget. If they do not, the next size comes from the average action
|
||||
# length just measured rather than from doubling the previous size. Doubling
|
||||
# overshoots, which means reading hundreds of actions in order to use sixty.
|
||||
WINDOW_START = 32
|
||||
WINDOW_MARGIN = 0.15 # Aim this far past the budget, so a second round is rare.
|
||||
WINDOW_STEP = 8 # Read at least this many more actions in each round.
|
||||
|
||||
# `depth` is the ordering key, and `id` breaks ties. Without `id`, the database
|
||||
# would choose the order. Two rows can share a depth: a pre-tree row, which has
|
||||
# a NULL depth and is invisible to reads, or a pair of sibling attempts.
|
||||
_OLDEST_FIRST = (models.Action.depth, models.Action.id)
|
||||
_NEWEST_FIRST = (models.Action.depth.desc(), models.Action.id.desc())
|
||||
|
||||
|
||||
def _sql_stripped(column):
|
||||
"""`column` with leading/trailing whitespace removed, portably.
|
||||
|
||||
SQLite and Postgres both accept `trim()` with a single argument, but that
|
||||
form removes spaces only. Python's `str.strip()` also removes newlines and
|
||||
tabs. Without this helper, an action containing only a newline would count
|
||||
as story text in SQL but not in Python. Both dialects spell `replace()` and
|
||||
`trim()` the same way, so this function converts the other whitespace to
|
||||
spaces first. It does not handle form feed or vertical tab, because nothing
|
||||
produces them.
|
||||
"""
|
||||
folded = column
|
||||
for char in ("\n", "\r", "\t"):
|
||||
folded = func.replace(folded, char, " ")
|
||||
return func.trim(folded)
|
||||
|
||||
|
||||
_STORY_TEXT = _sql_stripped(models.Action.text) != ""
|
||||
|
||||
|
||||
def is_story_text(text: str) -> bool:
|
||||
"""Returns whether `text` counts as story text.
|
||||
|
||||
This is the Python form of `_STORY_TEXT`. Keep the two in step.
|
||||
"""
|
||||
return bool(text.strip())
|
||||
|
||||
|
||||
def _loaded_actions(adventure: models.Adventure) -> list[models.Action] | None:
|
||||
"""The adventure's actions if they are already in memory, else None.
|
||||
|
||||
Slicing a collection that is already loaded costs nothing, and running a
|
||||
query beside it would fetch the same rows a second time.
|
||||
"""
|
||||
state = sa_inspect(adventure)
|
||||
if state.detached or "actions" in state.unloaded:
|
||||
return None
|
||||
return list(adventure.actions)
|
||||
|
||||
|
||||
def _from_memory(
|
||||
adventure: models.Adventure, exclude_action_id: int | None
|
||||
) -> list[models.Action] | None:
|
||||
"""The story, from the already-loaded collection, or None to go to SQL.
|
||||
|
||||
The collection holds the adventure's actions, which means the actions of
|
||||
every branch. Filtering it down to the path here applies the same rule that
|
||||
the SQL applies. Without that filter, the context builder would receive a
|
||||
prompt built from siblings of the story being played.
|
||||
|
||||
Resolving the path requires a session to read the branch row from. If there
|
||||
is no session, this function returns None so that the caller falls back to
|
||||
SQL rather than guessing.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is None:
|
||||
return None
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return None
|
||||
path = lineage.path_of(db, adventure)
|
||||
rows = [
|
||||
a for a in loaded
|
||||
if path.contains(a)
|
||||
and is_story_text(a.text)
|
||||
and (exclude_action_id is None or a.id != exclude_action_id)
|
||||
]
|
||||
# `Adventure.actions` is ordered by `index`; a path is ordered by depth.
|
||||
rows.sort(key=path.sort_key)
|
||||
return rows
|
||||
|
||||
|
||||
def _filters(
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
) -> list:
|
||||
# `adventure_id` is redundant beside the branch clause, because branch ids
|
||||
# are unique and a branch already identifies one adventure. The filter
|
||||
# remains because it costs little, it catches a node written onto another
|
||||
# adventure's branch, and it makes the query easier to read.
|
||||
conditions = [
|
||||
models.Action.adventure_id == adventure.id,
|
||||
path.clause(models.Action, count=entries),
|
||||
_STORY_TEXT,
|
||||
]
|
||||
if exclude_action_id is not None:
|
||||
conditions.append(models.Action.id != exclude_action_id)
|
||||
return conditions
|
||||
|
||||
|
||||
def _query(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
):
|
||||
# Reasoning traces are never read from replayed history and can be larger
|
||||
# than the narration itself on a reasoning model.
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(*_filters(adventure, path, exclude_action_id, entries))
|
||||
.options(defer(models.Action.reasoning))
|
||||
)
|
||||
|
||||
|
||||
def _count_query(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
):
|
||||
"""A real `SELECT count(...)`.
|
||||
|
||||
This function deliberately avoids `_query(...).count()`. That form wraps the
|
||||
entity select in a subquery, so the emitted SQL names every column,
|
||||
including the deferred columns that this design keeps off the wire. Neither
|
||||
form returns those bytes to the client, but the database still reads them,
|
||||
and an egress guard that inspects the SQL cannot tell the two forms apart.
|
||||
"""
|
||||
return db.query(func.count(models.Action.id)).filter(
|
||||
*_filters(adventure, path, exclude_action_id, entries)
|
||||
)
|
||||
|
||||
|
||||
def _session(adventure: models.Adventure) -> Session | None:
|
||||
return object_session(adventure)
|
||||
|
||||
|
||||
def _path(db: Session, adventure: models.Adventure) -> lineage.Path:
|
||||
return lineage.path_of(db, adventure)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the API
|
||||
|
||||
def story_actions(
|
||||
adventure: models.Adventure, exclude_action_id: int | None = None
|
||||
) -> list[models.Action]:
|
||||
"""Every story action, oldest first.
|
||||
|
||||
Call this function when you need the whole story. User scripts receive it,
|
||||
which matches AI Dungeon's scripting API. Use `tail`, `slice_`, or `count`
|
||||
when you need only part of the story.
|
||||
|
||||
`exclude_action_id` removes one action from the result. Retry uses it. The
|
||||
attempt being replaced is still the live node of its turn, because it stays
|
||||
live until a replacement exists, but it must not appear in the context that
|
||||
is assembled to replace it.
|
||||
"""
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
return in_memory
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
return (
|
||||
_query(db, adventure, _path(db, adventure), exclude_action_id)
|
||||
.order_by(*_OLDEST_FIRST)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def count(adventure: models.Adventure, exclude_action_id: int | None = None) -> int:
|
||||
"""How many story actions there are, without fetching any of them."""
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
return len(in_memory)
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return 0
|
||||
return (
|
||||
_count_query(db, adventure, _path(db, adventure), exclude_action_id).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def tail_range(
|
||||
adventure: models.Adventure,
|
||||
skip: int,
|
||||
limit: int,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""`limit` story actions ending `skip` actions before the end, oldest first.
|
||||
|
||||
Passing `skip=0` returns the newest slice. Passing `skip=32` and `limit=16`
|
||||
returns the 16 actions immediately older than the newest 32. A growing
|
||||
window therefore fetches only the actions it does not already hold.
|
||||
|
||||
This read is the reason the lineage window exists. The path's ranges do not
|
||||
overlap and they descend, so the newest N nodes come from the newest few
|
||||
lineage entries and the query never has to name the rest of the ancestry. A
|
||||
story that has forked 200 times reads its tail with as few clauses as one
|
||||
that has never forked.
|
||||
|
||||
`prefix_covering` estimates how many entries that takes, using depth
|
||||
arithmetic alone. The estimate falls short only when an action was deleted
|
||||
from the middle of the story. In that case this function widens the read to
|
||||
the whole lineage, at the cost of one more query.
|
||||
"""
|
||||
if limit <= 0 or skip < 0:
|
||||
return []
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
stop = len(in_memory) - skip
|
||||
return in_memory[max(stop - limit, 0):stop] if stop > 0 else []
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
path = _path(db, adventure)
|
||||
entries = path.prefix_covering(skip + limit)
|
||||
while True:
|
||||
rows = (
|
||||
_query(db, adventure, path, exclude_action_id, entries)
|
||||
.order_by(*_NEWEST_FIRST)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
if len(rows) >= limit or entries >= len(path):
|
||||
break
|
||||
entries = len(path) # short: widen once, to everything, and re-ask
|
||||
rows.reverse()
|
||||
return rows
|
||||
|
||||
|
||||
def tail(
|
||||
adventure: models.Adventure, limit: int, exclude_action_id: int | None = None
|
||||
) -> list[models.Action]:
|
||||
"""The newest `limit` story actions, returned oldest first."""
|
||||
return tail_range(adventure, 0, limit, exclude_action_id)
|
||||
|
||||
|
||||
def slice_(
|
||||
adventure: models.Adventure,
|
||||
start: int,
|
||||
length: int,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""Story actions at positions [start, start + length), oldest first.
|
||||
|
||||
Positions index into the same filtered, depth-ordered list that the memory
|
||||
cursors count in, which is why the SQL filter must match the Python filter
|
||||
exactly.
|
||||
|
||||
This function counts from the oldest end, so it names the whole lineage. No
|
||||
prefix of the ancestry contains the first ten actions of the story.
|
||||
"""
|
||||
if length <= 0 or start < 0:
|
||||
return []
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
return in_memory[start:start + length]
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
return (
|
||||
_query(db, adventure, _path(db, adventure), exclude_action_id)
|
||||
.order_by(*_OLDEST_FIRST)
|
||||
.offset(start)
|
||||
.limit(length)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def depth_of(action: models.Action) -> int:
|
||||
"""`action.depth`, with the no-depth case spelled once.
|
||||
|
||||
A row with no depth predates the tree. No path contains such a row, so it
|
||||
appears only in a collection that is already loaded. It sorts before the
|
||||
story rather than after it.
|
||||
"""
|
||||
return action.depth if action.depth is not None else lineage.NO_DEPTH
|
||||
|
||||
|
||||
def count_after(
|
||||
adventure: models.Adventure, depth: int, exclude_action_id: int | None = None
|
||||
) -> int:
|
||||
"""How many story actions lie past `depth` on the path.
|
||||
|
||||
This replaces the older calculation, which compared the length of the story
|
||||
with the position of the cursor. Deleting an action in front of the boundary
|
||||
makes this number smaller, which is correct. It does not move the boundary
|
||||
to a different action, which is the error that positions produced.
|
||||
|
||||
`covering_after` reports which lineage entries can hold a node deeper than
|
||||
the boundary, so a cursor near the tip names one branch however many forks
|
||||
lie below it.
|
||||
"""
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
return sum(1 for a in in_memory if depth_of(a) > depth)
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return 0
|
||||
path = _path(db, adventure)
|
||||
return (
|
||||
_count_query(
|
||||
db, adventure, path, exclude_action_id, path.covering_after(depth)
|
||||
)
|
||||
.filter(models.Action.depth > depth)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def after(
|
||||
adventure: models.Adventure,
|
||||
depth: int,
|
||||
limit: int,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""The oldest `limit` story actions past `depth`, oldest first.
|
||||
|
||||
This returns the next block that the summarizer has not read. It asks a
|
||||
question about the story rather than using an offset into a list whose
|
||||
entries move.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
in_memory = _from_memory(adventure, exclude_action_id)
|
||||
if in_memory is not None:
|
||||
return [a for a in in_memory if depth_of(a) > depth][:limit]
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
path = _path(db, adventure)
|
||||
return (
|
||||
_query(db, adventure, path, exclude_action_id, path.covering_after(depth))
|
||||
.filter(models.Action.depth > depth)
|
||||
.order_by(*_OLDEST_FIRST)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def newest(adventure: models.Adventure) -> models.Action | None:
|
||||
"""The newest story action, or None on an empty story.
|
||||
|
||||
This returns a row rather than a count and an offset, because it names the
|
||||
node an anchor moves to when derived work reaches the end of the story.
|
||||
|
||||
It used to return the second newest action. The memory bank held one action
|
||||
back because retry rewrote a row, so a memory that covered the newest action
|
||||
could describe narration the player had already replaced. Since SP4, a retry
|
||||
writes a sibling row instead, and the derived work at a coordinate is
|
||||
withdrawn when the story at that coordinate changes. There is nothing left
|
||||
to hold back.
|
||||
"""
|
||||
rows = tail(adventure, 1)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def window_covering(
|
||||
adventure: models.Adventure,
|
||||
budget_tokens: int,
|
||||
token_counter,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""Returns the newest story actions whose combined text exceeds `budget_tokens`.
|
||||
|
||||
The result always holds at least as much text as the context builder can
|
||||
include, and never less.
|
||||
|
||||
This function counts tokens rather than estimating a characters-per-token
|
||||
ratio, so the prompt matches what loading the whole story would produce. It
|
||||
budgets against the raw text, which is never longer than the rendered
|
||||
history text, so any error causes it to fetch slightly more than needed.
|
||||
|
||||
Each round fetches only the actions it does not already hold, so no row is
|
||||
read twice however many rounds the loop takes.
|
||||
"""
|
||||
actions: list[models.Action] = []
|
||||
tokens = 0
|
||||
size = WINDOW_START
|
||||
while True:
|
||||
older = tail_range(
|
||||
adventure, len(actions), size - len(actions), exclude_action_id
|
||||
)
|
||||
if not older:
|
||||
return actions # The result already holds the whole story.
|
||||
actions = older + actions
|
||||
tokens += sum(token_counter(a.text) for a in older)
|
||||
if len(actions) < size:
|
||||
return actions # That was the whole story.
|
||||
if tokens > budget_tokens:
|
||||
return actions
|
||||
# The window is still short. Estimate how many actions the budget needs
|
||||
# at the average length just measured, then read that many.
|
||||
average = tokens / len(actions)
|
||||
projected = int(budget_tokens / average * (1 + WINDOW_MARGIN)) + WINDOW_STEP
|
||||
size = max(projected, size + WINDOW_STEP)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Phase 14: decides which nodes make up one story.
|
||||
|
||||
`tree.py` decides where a node is written. This module decides which nodes a
|
||||
read can see, and it is the only place that makes that decision.
|
||||
|
||||
A branch owns the nodes played on it and inherits everything before its fork
|
||||
point from its ancestors. "The story on branch C" is therefore not a value you
|
||||
can filter a column on. It is an OR of ranges::
|
||||
|
||||
(branch_id = C) -- C's own nodes, through to the tip
|
||||
OR (branch_id = B AND depth <= 5)
|
||||
OR (branch_id = A AND depth <= 3)
|
||||
|
||||
The `branches.lineage` column records exactly that list, newest first. The fork
|
||||
computes it once, so no read walks parent pointers to rebuild it.
|
||||
|
||||
The shape of the list gives two properties that the windowed reads depend on:
|
||||
|
||||
- The ranges do not overlap, and they descend. A branch's own nodes always sit
|
||||
deeper than its fork point, and each lineage entry is capped at the fork depth
|
||||
of the branch below it. Ordering the whole clause by `depth` descending
|
||||
therefore reads entry 0's nodes, then entry 1's, then entry 2's. A tail read
|
||||
can use the newest few entries and stop.
|
||||
- The number of clauses depends on the size of the context window, not on how
|
||||
many times the story has forked. A story with 200 forks whose newest branch
|
||||
runs 40 turns reads with a single clause, because the window is covered before
|
||||
the second entry is reached. `prefix_covering` implements this, and it is why
|
||||
`history.window_covering` can keep its current shape.
|
||||
|
||||
Everything in this module reads. Nothing here creates a branch or writes a row.
|
||||
An adventure with no branch has no story, and the write side repairs that. See
|
||||
`tree.place_action` and the flush listener in `models.py`.
|
||||
"""
|
||||
|
||||
from sqlalchemy import and_, false, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
|
||||
# The depth of an adventure that has no actions. This mirrors `tree.NO_DEPTH`.
|
||||
# It is duplicated here so that a read never has to import the write half.
|
||||
NO_DEPTH = -1
|
||||
|
||||
# The opening node of an adventure. Depth 0 exists only on the root branch,
|
||||
# because a fork starts its own nodes after the depth it forked at. This
|
||||
# constant therefore names one node per adventure rather than one per branch.
|
||||
#
|
||||
# Migration 62 also placed every memory written before memories had coordinates
|
||||
# at depth 0. That is why both places that can retire a memory must handle this
|
||||
# depth: `memorybank.forget_node`, and a v1 import that has no depth to read.
|
||||
ROOT_DEPTH = 0
|
||||
|
||||
|
||||
def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
|
||||
"""Returns `branch.lineage` as (branch_id, max_depth) pairs, newest first.
|
||||
|
||||
An empty lineage means this branch alone, through to its tip. That is not an
|
||||
error. It is what a root branch's lineage means, and it is also what a
|
||||
branch row holds between the moment it is inserted and the moment its own id
|
||||
is written into the column. The fallback is therefore correct rather than a
|
||||
guess.
|
||||
"""
|
||||
raw = branch.lineage if isinstance(branch.lineage, list) else []
|
||||
entries: list[tuple[int, int | None]] = []
|
||||
for item in raw:
|
||||
# JSON round-trips lists, but a hand-written row might hold tuples.
|
||||
if not isinstance(item, (list, tuple)) or not item:
|
||||
continue
|
||||
branch_id = item[0]
|
||||
max_depth = item[1] if len(item) > 1 else None
|
||||
if not isinstance(branch_id, int):
|
||||
continue
|
||||
entries.append((branch_id, max_depth if isinstance(max_depth, int) else None))
|
||||
return entries or [(branch.id, None)]
|
||||
|
||||
|
||||
class Path:
|
||||
"""One story, expressed as a SQL clause and as a Python predicate.
|
||||
|
||||
The object holds the lineage entries newest first, plus the depth of the
|
||||
tip. The tip is used only to estimate how much story each entry covers.
|
||||
"""
|
||||
|
||||
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
|
||||
self.entries = entries
|
||||
self.tip = tip
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.entries)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.entries)
|
||||
|
||||
# ---------------------------------------------------------------- SQL
|
||||
|
||||
def clause(
|
||||
self,
|
||||
model=models.Action,
|
||||
count: int | None = None,
|
||||
):
|
||||
"""Returns the branch clause over `model`, which is `Action` or `Memory`.
|
||||
|
||||
`count` limits the clause to the newest `count` lineage entries, which
|
||||
produces a windowed read. Pass `None` for the whole lineage. Any caller
|
||||
that counts from the oldest end, such as a slice or a total, must pass
|
||||
`None`.
|
||||
|
||||
Every row this clause selects has a depth. Memories were once an
|
||||
exception, because a hand-written memory had a branch but no depth and
|
||||
needed an escape clause here to avoid being capped at a fork. SP7
|
||||
anchors those memories at the head instead. See `tree.place_memory`. A
|
||||
memory is now on a path rather than exempt from one, and a row with no
|
||||
depth is a pre-tree leftover that no read should return.
|
||||
|
||||
Actions must also be live, as of SP4. One coordinate can hold several
|
||||
attempts at a turn, and the story uses one of them. The other attempts
|
||||
sit at the same branch and depth, and this clause excludes them once, so
|
||||
that no read of the story has to account for retries. Only
|
||||
`app/attempts.py` looks past this filter.
|
||||
|
||||
An empty path returns `false` rather than no filter at all. An adventure
|
||||
whose nodes carry no branch has no story, and the correct way to show
|
||||
that is an empty page rather than every branch at once.
|
||||
"""
|
||||
entries = self.entries if count is None else self.entries[:count]
|
||||
if not entries:
|
||||
return false()
|
||||
on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries])
|
||||
if model is models.Action:
|
||||
return and_(on_path, models.Action.live.is_(True))
|
||||
return on_path
|
||||
|
||||
@staticmethod
|
||||
def _entry_clause(model, branch_id: int, max_depth: int | None):
|
||||
if max_depth is None:
|
||||
return model.branch_id == branch_id
|
||||
return and_(model.branch_id == branch_id, model.depth <= max_depth)
|
||||
|
||||
# ------------------------------------------------------------- Python
|
||||
|
||||
def contains(self, node) -> bool:
|
||||
"""Returns whether `node` is on this path.
|
||||
|
||||
This is the Python equivalent of `clause()`. Keep the two in step.
|
||||
|
||||
Callers use it where the rows are already in memory. The scripting
|
||||
pipeline hands user scripts the whole history, so a loaded collection
|
||||
can be reduced to the path without a second query.
|
||||
|
||||
The method checks `live` first, and only on rows that define the
|
||||
attribute, because memories have no siblings.
|
||||
"""
|
||||
if getattr(node, "live", True) is False:
|
||||
return False
|
||||
for branch_id, max_depth in self.entries:
|
||||
if node.branch_id != branch_id:
|
||||
continue
|
||||
if max_depth is None:
|
||||
return True
|
||||
if node.depth is not None and node.depth <= max_depth:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sort_key(self, node) -> tuple[int, int]:
|
||||
"""Returns a sort key that orders nodes from oldest to newest.
|
||||
|
||||
`depth` is the ordering key. `id` breaks ties, which a pre-tree row with
|
||||
a NULL depth or a pair of siblings can produce.
|
||||
"""
|
||||
return (node.depth if node.depth is not None else NO_DEPTH, node.id or 0)
|
||||
|
||||
# ------------------------------------------------------------ windowing
|
||||
|
||||
def prefix_covering(self, rows: int) -> int:
|
||||
"""Returns how many lineage entries hold the newest `rows` nodes.
|
||||
|
||||
The result is an estimate from depth arithmetic rather than a query.
|
||||
Entry *i* covers the depths between its own cap and the cap of the entry
|
||||
below it, and a path holds at most one node per depth. The estimate is
|
||||
therefore never too large. It is too small only when the story has gaps,
|
||||
which happens after an action is deleted from the middle. In that case
|
||||
the caller widens the read to the whole lineage, which costs one extra
|
||||
query.
|
||||
"""
|
||||
total = len(self.entries)
|
||||
if rows <= 0 or total == 0:
|
||||
return total
|
||||
covered = 0
|
||||
for i, (_, max_depth) in enumerate(self.entries):
|
||||
top = self.tip if max_depth is None else max_depth
|
||||
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
|
||||
if top is None or below is None:
|
||||
# Either no tip was recorded, or a hand-written row is missing a
|
||||
# cap. There is nothing to estimate from, so return every entry.
|
||||
# Guessing low would hide the older half of the story.
|
||||
return total
|
||||
covered += max(top - below, 0)
|
||||
if covered >= rows:
|
||||
return i + 1
|
||||
return total
|
||||
|
||||
def covering_after(self, depth: int) -> int:
|
||||
"""Returns how many lineage entries can hold a node deeper than `depth`.
|
||||
|
||||
This is the counterpart to `prefix_covering`, and it is exact rather than
|
||||
an estimate. Entry *i* holds nothing deeper than its own cap, and the
|
||||
caps descend, so the first entry capped at or below `depth` ends the
|
||||
search. That entry and every older one fall behind the boundary. As a
|
||||
result, reading the story after the cursor touches one branch on any
|
||||
story whose cursor sits on its newest branch, however many times the
|
||||
story has forked.
|
||||
"""
|
||||
for i, (_, max_depth) in enumerate(self.entries):
|
||||
if max_depth is not None and max_depth <= depth:
|
||||
return i
|
||||
return len(self.entries)
|
||||
|
||||
def depth_on(self, branch_id: int | None, depth: int) -> int:
|
||||
"""Reads a stored `(branch_id, depth)` anchor as a depth on this path.
|
||||
|
||||
An anchor records how far along a story some derived work reached, such
|
||||
as which actions the memories cover or what the summary folded in. The
|
||||
anchor names a node, so moving to a different path needs an explicit
|
||||
answer. There are two cases:
|
||||
|
||||
- The anchor's branch is on this path. The depth stands, capped at the
|
||||
fork where this path leaves that branch, because nothing past the fork
|
||||
belongs to this story.
|
||||
- The anchor's branch is not on this path. The work was done on a branch
|
||||
this story does not contain, so nothing here counts as covered.
|
||||
|
||||
The second case cannot occur while an adventure has one branch, because
|
||||
the anchor is always set from a node on it. It exists because the safe
|
||||
answer to an unknown anchor is to redo the work rather than skip it.
|
||||
"""
|
||||
if depth <= NO_DEPTH:
|
||||
return NO_DEPTH
|
||||
if branch_id is None:
|
||||
# The anchor predates the tree, or someone set it by hand. There is
|
||||
# only one story, so the depth is a position in it.
|
||||
return depth
|
||||
for entry_branch, max_depth in self.entries:
|
||||
if entry_branch == branch_id:
|
||||
return depth if max_depth is None else min(depth, max_depth)
|
||||
return NO_DEPTH
|
||||
|
||||
|
||||
def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
|
||||
"""Returns the branch this adventure is read at, or None if it has none.
|
||||
|
||||
This function is deliberately not `tree.head_branch`, which creates a branch.
|
||||
A GET request must not write. An adventure with no branch row also has no
|
||||
nodes that carry a branch, so both answers agree that there is no story.
|
||||
"""
|
||||
if adventure.head_branch_id is not None:
|
||||
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# The head points at a branch that no longer exists. Fall through to the
|
||||
# root, which is the same recovery that `tree.head_branch` performs on
|
||||
# the write side.
|
||||
return (
|
||||
db.query(models.Branch)
|
||||
.filter(
|
||||
models.Branch.adventure_id == adventure.id,
|
||||
models.Branch.parent_branch_id.is_(None),
|
||||
)
|
||||
.order_by(models.Branch.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def path_of(db: Session, adventure: models.Adventure) -> Path:
|
||||
"""Returns the story that the adventure's head currently sits on."""
|
||||
branch = branch_of(db, adventure)
|
||||
if branch is None:
|
||||
return Path([], adventure.head_depth)
|
||||
return Path(entries_of(branch), adventure.head_depth)
|
||||
+100256
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
# The one database. It is SQLite, on this machine, in a file.
|
||||
#
|
||||
# Upstream could also point at a server database — `AIDND_DATABASE_URL` or the
|
||||
# platform-conventional `DATABASE_URL`, normalised onto psycopg3, with
|
||||
# pre-ping for a serverless Postgres that suspends when idle. That existed for
|
||||
# a hosted deployment. M2 removed it along with the deployment: a local
|
||||
# single-user storyteller has one reader, and a network database would be one
|
||||
# more thing that has to be running, and one more place the story lives.
|
||||
#
|
||||
# `AIDND_DB_PATH` stays. It is how the Docker image puts the database on a
|
||||
# volume, and how a test points at a throwaway file.
|
||||
_env_db_path = os.environ.get("AIDND_DB_PATH")
|
||||
DB_PATH = (
|
||||
Path(_env_db_path).resolve()
|
||||
if _env_db_path
|
||||
else Path(__file__).resolve().parent.parent / "data.db"
|
||||
)
|
||||
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_PATH}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, _record):
|
||||
# SQLite ships with foreign keys OFF per connection; without this every
|
||||
# ondelete=CASCADE/SET NULL in models.py is silently ignored.
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""In-memory ring buffer of recent provider requests/responses for the debug page.
|
||||
|
||||
API keys never enter the log: only the request body (which carries no
|
||||
credentials) and response text are recorded, both truncated.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
MAX_ENTRIES = 30
|
||||
MAX_TEXT = 6000
|
||||
|
||||
_entries: deque[dict] = deque(maxlen=MAX_ENTRIES)
|
||||
_ids = itertools.count(1)
|
||||
|
||||
|
||||
def _clip(text: str) -> str:
|
||||
if len(text) <= MAX_TEXT:
|
||||
return text
|
||||
return text[:MAX_TEXT] + f"\n… [{len(text) - MAX_TEXT} more chars truncated]"
|
||||
|
||||
|
||||
def _clip_obj(obj):
|
||||
if isinstance(obj, str):
|
||||
return _clip(obj)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _clip_obj(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_clip_obj(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def start_entry(url: str, model: str, body: dict) -> dict:
|
||||
entry = {
|
||||
"id": next(_ids),
|
||||
"time": datetime.now(timezone.utc).isoformat(),
|
||||
"url": url,
|
||||
"model": model,
|
||||
"request": _clip_obj(body),
|
||||
"status": "pending",
|
||||
"response": "",
|
||||
"usage": None,
|
||||
"error": None,
|
||||
}
|
||||
_entries.appendleft(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def finish_entry(
|
||||
entry: dict,
|
||||
*,
|
||||
response: str = "",
|
||||
error: str | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> None:
|
||||
"""Finishes a log entry.
|
||||
|
||||
`usage` is the endpoint's own token accounting, when it reported any. On
|
||||
OpenRouter it carries `prompt_tokens_details.cached_tokens`, which is the
|
||||
only direct measure of whether the prompt prefix is being cached, and it is
|
||||
worth seeing next to the request that produced it.
|
||||
"""
|
||||
entry["response"] = _clip(response)
|
||||
entry["usage"] = usage
|
||||
entry["error"] = error
|
||||
entry["status"] = "error" if error else "ok"
|
||||
|
||||
|
||||
def recent() -> list[dict]:
|
||||
return list(_entries)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Which inference endpoints this product is willing to talk to.
|
||||
|
||||
The Adventure Storyteller sends the player's prose, the assembled context, the
|
||||
retrieved memories, and the embedding inputs to whatever address the model
|
||||
endpoint names. That makes the endpoint the single most consequential setting
|
||||
in the application: point it somewhere else and the whole campaign goes there.
|
||||
|
||||
The v1 rule (`planning/DECISIONS/002-ollama-only-v1.md`, ADR 004) is that
|
||||
inference runs on user-controlled local infrastructure. Two deployments are
|
||||
supported and no third is:
|
||||
|
||||
* **same-host** — Ollama on loopback, the default;
|
||||
* **explicitly configured trusted LAN** — Ollama on another machine the user
|
||||
controls, named by them, reached over HTTP or over HTTPS with a certificate
|
||||
their machine trusts.
|
||||
|
||||
Everything on the public Internet is refused. Not discouraged in the UI, not
|
||||
absent from a dropdown — refused, here, on the way out, so that a hand-edited
|
||||
database row or a hostname that starts resolving somewhere new cannot quietly
|
||||
turn a local install into an exfiltration path.
|
||||
|
||||
## How the line is drawn
|
||||
|
||||
By **address**, not by name, and against an explicit allowlist of networks:
|
||||
loopback, the three RFC1918 ranges, link-local, IPv6 unique-local, and
|
||||
carrier-grade NAT — the last of which is what a mesh VPN such as Tailscale
|
||||
hands out and is as user-controlled as a LAN.
|
||||
|
||||
Every address the host resolves to must be in one of them. One address outside
|
||||
is enough to refuse the endpoint, so a name resolving to both a private and a
|
||||
public address does not squeak through.
|
||||
|
||||
The networks are spelled out rather than inferred from `ipaddress`'s own
|
||||
classifications, which do not mean what this rule needs: `is_private` is true
|
||||
of the documentation ranges and of `0.0.0.0/8`, and `is_reserved` is true of
|
||||
IPv6 loopback — so a rule written around it refuses `http://[::1]:11434/v1`,
|
||||
which is an ordinary same-host Ollama. Naming the networks keeps the policy
|
||||
readable and makes anything unnamed refused by default.
|
||||
|
||||
Checking addresses rather than hostnames is what makes the rule hard to talk
|
||||
around. A cloud provider cannot be reached by spelling its name differently,
|
||||
and `localhost.` or a DNS entry pointing at a public host is judged on where it
|
||||
actually goes.
|
||||
|
||||
## What this is not
|
||||
|
||||
It is not a general network-policy framework, and there is nothing to configure.
|
||||
There is one predicate, and it is applied in two places: when the endpoint is
|
||||
saved, so the user gets a clear error immediately, and again before every
|
||||
outbound request, because a name that resolved to `192.168.1.50` this morning
|
||||
can resolve to something else this afternoon.
|
||||
|
||||
TLS is a separate matter and is never traded against this one. See
|
||||
`tlstrust.py`: certificates are verified in full, and no endpoint — however
|
||||
private its address — may skip that.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
#: The networks an inference endpoint may live on. Anything else is refused.
|
||||
ALLOWED_NETWORKS = tuple(
|
||||
ipaddress.ip_network(cidr)
|
||||
for cidr in (
|
||||
"127.0.0.0/8", # this machine
|
||||
"10.0.0.0/8", # RFC1918
|
||||
"172.16.0.0/12", # RFC1918
|
||||
"192.168.0.0/16", # RFC1918
|
||||
"169.254.0.0/16", # link-local
|
||||
"100.64.0.0/10", # carrier-grade NAT, which mesh VPNs use
|
||||
"::1/128", # this machine, v6
|
||||
"fc00::/7", # unique-local, v6
|
||||
"fe80::/10", # link-local, v6
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_local(ip) -> bool:
|
||||
return any(ip in net for net in ALLOWED_NETWORKS)
|
||||
|
||||
#: Hosts that are only ever a cloud inference service. The address rule below
|
||||
#: already refuses every one of them, because they all resolve to public
|
||||
#: addresses; this list exists solely so the error says *why* rather than
|
||||
#: leaving the user to wonder whether their DNS is broken.
|
||||
CLOUD_HOSTS = (
|
||||
"openrouter.ai",
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"api.groq.com",
|
||||
"api.mistral.ai",
|
||||
"api.together.xyz",
|
||||
"api.deepseek.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.cohere.ai",
|
||||
"api.perplexity.ai",
|
||||
)
|
||||
|
||||
_CLOUD_REASON = (
|
||||
"this build talks to Ollama on your own machine or on your own network, "
|
||||
"and has no cloud provider support"
|
||||
)
|
||||
|
||||
|
||||
def _cloud_host(host: str) -> bool:
|
||||
host = host.lower().rstrip(".")
|
||||
return any(host == h or host.endswith("." + h) for h in CLOUD_HOSTS)
|
||||
|
||||
|
||||
def rejection_reason(url: str) -> str | None:
|
||||
"""Why this endpoint may not be used, or None if it may.
|
||||
|
||||
The string is shown to the user, so it says what to do rather than what
|
||||
went wrong internally.
|
||||
"""
|
||||
parsed = urlparse((url or "").strip())
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return "the endpoint URL must start with http:// or https://"
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
return "the endpoint URL has no host"
|
||||
if _cloud_host(host):
|
||||
return f"{host} is a cloud inference service — {_CLOUD_REASON}"
|
||||
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return (
|
||||
f"the host {host!r} could not be resolved — check the address, and "
|
||||
"that the machine running Ollama is reachable from here"
|
||||
)
|
||||
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except ValueError:
|
||||
return "the endpoint host resolved to an address that could not be read"
|
||||
if _is_local(ip):
|
||||
continue
|
||||
if ip.is_global:
|
||||
return (
|
||||
f"{host} resolves to {ip}, which is a public Internet address — "
|
||||
f"{_CLOUD_REASON}. Use Ollama on this machine "
|
||||
"(http://127.0.0.1:11434/v1) or on a machine on your own network"
|
||||
)
|
||||
return (
|
||||
f"{host} resolves to {ip}, which is not on this machine and not on "
|
||||
"your own network. Use http://127.0.0.1:11434/v1, or the address of "
|
||||
"a machine on your network"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check(url: str) -> None:
|
||||
"""Raises `EndpointRejected` if this endpoint is outside the policy."""
|
||||
reason = rejection_reason(url)
|
||||
if reason is not None:
|
||||
raise EndpointRejected(reason)
|
||||
|
||||
|
||||
class EndpointRejected(Exception):
|
||||
"""The configured endpoint is not one this product will send a story to."""
|
||||
|
||||
|
||||
def is_loopback(url: str) -> bool:
|
||||
"""Whether this endpoint is on this machine. Used for reporting, not for
|
||||
gating: a trusted-LAN endpoint is equally allowed."""
|
||||
host = urlparse((url or "").strip()).hostname
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
try:
|
||||
return all(ipaddress.ip_address(i[4][0]).is_loopback for i in infos)
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Scenario cover art: inline data URIs in, cacheable URLs out.
|
||||
|
||||
A scenario's `image` column holds either an `https://` URL or a base64
|
||||
`data:image/…` URI (the editor downscales uploads before storing one). Sending
|
||||
those data URIs inside list responses would balloon them, so lists advertise a
|
||||
`image_url` pointing at `GET /api/scenarios/{id}/image` instead, and the bytes
|
||||
are fetched once and cached by the browser.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
|
||||
# Only raster formats a browser renders in an <img>. SVG is deliberately absent:
|
||||
# it can carry script, and these bytes are served from our own origin.
|
||||
DATA_URI_RE = re.compile(
|
||||
r"^data:(image/(?:png|jpeg|webp|gif|avif));base64,([A-Za-z0-9+/=\s]+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def public_url(scenario_id: int, image: str, version: object) -> str:
|
||||
"""The URL a client should load for this scenario's art ("" if none).
|
||||
|
||||
`version` is any object with a stable repr, and is normally the row's
|
||||
`updated_at`. It becomes a cache-buster, so the image response can be marked
|
||||
immutable and still refresh as soon as the author replaces the picture.
|
||||
"""
|
||||
if not image:
|
||||
return ""
|
||||
if DATA_URI_RE.match(image):
|
||||
stamp = int(version.timestamp()) if hasattr(version, "timestamp") else 0
|
||||
return f"/api/scenarios/{scenario_id}/image?v={stamp}"
|
||||
# Anything else must be an absolute https URL. http:// is rejected rather
|
||||
# than passed through: the deployed app is https, so a browser would block
|
||||
# it as mixed content and the author would just see a broken image.
|
||||
return image if image.startswith("https://") else ""
|
||||
|
||||
|
||||
def sanitize(value: object, max_length: int) -> str:
|
||||
"""Coerce an untrusted `image` value from an import bundle to a safe one.
|
||||
|
||||
A value that is not a supported data URI or an https URL, or that is too
|
||||
large to store, becomes "". A hostile or merely unfamiliar bundle therefore
|
||||
cannot pass in a `javascript:` URI or exceed the column cap.
|
||||
"""
|
||||
if not isinstance(value, str) or not value or len(value) > max_length:
|
||||
return ""
|
||||
if DATA_URI_RE.match(value):
|
||||
return value if decode(value) is not None else ""
|
||||
return value if value.startswith("https://") else ""
|
||||
|
||||
|
||||
def decode(image: str) -> tuple[bytes, str] | None:
|
||||
"""`(bytes, content_type)` for a stored data URI, or None if it isn't one."""
|
||||
match = DATA_URI_RE.match(image or "")
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
# validate=True rejects anything outside the base64 alphabet, so strip
|
||||
# the newlines a hand-pasted or line-wrapped URI may carry first.
|
||||
payload = re.sub(r"\s+", "", match.group(2))
|
||||
return base64.b64decode(payload, validate=True), match.group(1).lower()
|
||||
except (binascii.Error, ValueError):
|
||||
# The base64 is truncated or hand-edited. Treat it as no image rather
|
||||
# than return a 500.
|
||||
return None
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Resource bounds on what a single request or a single story may cost.
|
||||
|
||||
Upstream carried three things here, and only one of them belongs in a local
|
||||
single-user product. Per-IP and per-user **rate limiting**, the login-attempt
|
||||
throttle, and the per-user **quotas** were hosted-service policy: they existed
|
||||
to stop a hostile visitor exhausting a shared demo key or filling a shared
|
||||
database. M2 removed all of it. There are no visitors, and throttling the one
|
||||
person who started the application would be a bug rather than a guard.
|
||||
|
||||
What is left is defensive programming, and it applies whatever the deployment:
|
||||
|
||||
* a ceiling on the **request body**, so a malformed or hostile payload cannot
|
||||
be read into memory before anything looks at it;
|
||||
* ceilings on how large **one adventure** may grow, in actions, memories,
|
||||
story cards and branches. These bound storage and the cost of the queries
|
||||
that walk them. They are per-story, not per-user: nothing here counts how
|
||||
many campaigns a person may have.
|
||||
|
||||
An import is checked against the same per-adventure ceilings that live creation
|
||||
uses, so a bundle cannot carry a story past a limit that play could not reach.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
|
||||
# ---------- Per-story row caps ----------
|
||||
|
||||
MAX_STORY_CARDS_PER_OWNER = 200 # Per scenario or per adventure.
|
||||
MAX_MEMORIES_PER_ADVENTURE = 1000
|
||||
MAX_ACTIONS_PER_ADVENTURE = 5000
|
||||
# Phase 14, SP6. A tree holds one branch per divergence somebody built a story
|
||||
# on, so a tree with more branches than the story has turns came from a file
|
||||
# rather than from play. The cap applies to imports only. Forking is a POST that
|
||||
# adds one row and has no cap of its own, and the cap that matters there is
|
||||
# `MAX_ACTIONS_PER_ADVENTURE` above.
|
||||
MAX_BRANCHES_PER_ADVENTURE = 1000
|
||||
|
||||
|
||||
def check_row_cap(
|
||||
kind: str,
|
||||
db: Session,
|
||||
user: models.User,
|
||||
*,
|
||||
adventure: models.Adventure | None = None,
|
||||
scenario_id: int | None = None,
|
||||
adventure_id: int | None = None,
|
||||
) -> None:
|
||||
"""Raises a 409 when creating one more row of `kind` would exceed its cap.
|
||||
|
||||
Only per-story kinds are capped. `adventures` and `scenarios` were per-user
|
||||
quotas and are no longer checked; the callers still pass them, and they are
|
||||
accepted and ignored so that adding a cap back is a change here rather than
|
||||
at every call site.
|
||||
"""
|
||||
if kind in ("adventures", "scenarios"):
|
||||
return
|
||||
if kind == "story_cards":
|
||||
owner_filter = (
|
||||
models.StoryCard.scenario_id == scenario_id
|
||||
if scenario_id is not None
|
||||
else models.StoryCard.adventure_id == adventure_id
|
||||
)
|
||||
count = _count(db, models.StoryCard, owner_filter)
|
||||
cap, subject, hint = (
|
||||
MAX_STORY_CARDS_PER_OWNER, "story cards here", "delete one to make room"
|
||||
)
|
||||
elif kind == "memories":
|
||||
count = _count(db, models.Memory, models.Memory.adventure_id == adventure.id)
|
||||
cap, subject, hint = (
|
||||
MAX_MEMORIES_PER_ADVENTURE, "memories in this adventure",
|
||||
"delete some to make room",
|
||||
)
|
||||
elif kind == "actions":
|
||||
# Count every action in the adventure, which is the whole tree rather
|
||||
# than the path being played. That number is what costs storage, and
|
||||
# nothing is pruned automatically, so it is the right one to cap. It does
|
||||
# mean a heavily branched adventure reaches the cap while its story is
|
||||
# shorter than the cap, which is why the message counts "actions in this
|
||||
# adventure" rather than turns.
|
||||
count = _count(db, models.Action, models.Action.adventure_id == adventure.id)
|
||||
cap, subject, hint = (
|
||||
MAX_ACTIONS_PER_ADVENTURE, "actions in this adventure",
|
||||
"export it and continue in a new adventure",
|
||||
)
|
||||
else: # pragma: no cover. This is a programming error, not user input.
|
||||
raise ValueError(f"Unknown row cap kind: {kind}")
|
||||
if count >= cap:
|
||||
raise HTTPException(409, f"You've reached the limit of {cap} {subject} — {hint}.")
|
||||
|
||||
|
||||
def _count(db: Session, model, condition) -> int:
|
||||
return db.query(func.count(model.id)).filter(condition).scalar() or 0
|
||||
|
||||
|
||||
_BUNDLE_LIST_CAPS = {
|
||||
"story_cards": MAX_STORY_CARDS_PER_OWNER,
|
||||
"memories": MAX_MEMORIES_PER_ADVENTURE,
|
||||
"actions": MAX_ACTIONS_PER_ADVENTURE,
|
||||
"branches": MAX_BRANCHES_PER_ADVENTURE,
|
||||
}
|
||||
|
||||
|
||||
def check_bundle_lists(**lists) -> None:
|
||||
"""Raises a 409 when an import bundle's lists exceed the caps live creation uses.
|
||||
|
||||
The keyword arguments are `story_cards`, `memories`, `actions`, and
|
||||
`branches`.
|
||||
"""
|
||||
for name, value in lists.items():
|
||||
cap = _BUNDLE_LIST_CAPS[name]
|
||||
if isinstance(value, list) and len(value) > cap:
|
||||
noun = name.replace("_", " ")
|
||||
raise HTTPException(
|
||||
409, f"This file contains {len(value)} {noun} — the limit is {cap}."
|
||||
)
|
||||
|
||||
|
||||
# ---------- Request body size ----------
|
||||
# The limit is generous enough for the largest legitimate payload, which is an
|
||||
# adventure export holding thousands of actions. No honest request approaches
|
||||
# it.
|
||||
|
||||
MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class BodySizeLimitMiddleware:
|
||||
"""Rejects oversized request bodies by their declared `Content-Length`.
|
||||
|
||||
This is pure ASGI rather than `BaseHTTPMiddleware`, so SSE responses stream
|
||||
through unchanged. A chunked upload with no length is refused, because every
|
||||
real client of this API sends `Content-Length`, including browser fetch and
|
||||
curl with a file.
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http" and scope.get("method") in ("POST", "PUT", "PATCH"):
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])}
|
||||
limit = (
|
||||
MAX_IMPORT_BODY_BYTES
|
||||
if scope.get("path", "").endswith("/import")
|
||||
else MAX_BODY_BYTES
|
||||
)
|
||||
length = headers.get("content-length")
|
||||
problem = None
|
||||
if length is None:
|
||||
if "chunked" in headers.get("transfer-encoding", "").lower():
|
||||
problem = (411, "Content-Length is required.")
|
||||
else:
|
||||
try:
|
||||
if int(length) > limit:
|
||||
problem = (
|
||||
413,
|
||||
f"Request too large (limit {limit // (1024 * 1024)} MB).",
|
||||
)
|
||||
except ValueError:
|
||||
problem = (400, "Invalid Content-Length.")
|
||||
if problem:
|
||||
await _send_json_error(send, *problem)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
async def _send_json_error(send, status: int, detail: str) -> None:
|
||||
body = json.dumps({"detail": detail}).encode()
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode())],
|
||||
})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
@@ -0,0 +1,160 @@
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .database import engine
|
||||
from .limits import BodySizeLimitMiddleware
|
||||
from .migrations import bootstrap
|
||||
from .routers import adventures, chat, debug, scenarios, settings, story_cards
|
||||
from .seed import seed_public_scenarios
|
||||
|
||||
bootstrap(engine)
|
||||
seed_public_scenarios(engine)
|
||||
|
||||
# Production serves the SPA same-origin, so CORS only matters for the Vite dev
|
||||
# server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup.
|
||||
#
|
||||
# A wildcard is refused rather than honoured. This API is unauthenticated by
|
||||
# design and bound to loopback, so its only protection from a page the user
|
||||
# happens to have open in another tab is the same-origin policy. `*` would hand
|
||||
# every site on the Internet a write handle on the local campaign database. If the
|
||||
# value is wrong the app refuses to start, because a permissive CORS policy that
|
||||
# nobody notices is worse than one that fails loudly.
|
||||
CORS_ORIGINS = [
|
||||
o.strip()
|
||||
for o in os.environ.get("AIDND_CORS_ORIGINS", "").split(",")
|
||||
if o.strip()
|
||||
] or ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
|
||||
if any(o == "*" or o.strip() == "*" for o in CORS_ORIGINS):
|
||||
raise RuntimeError(
|
||||
"AIDND_CORS_ORIGINS must not contain '*'. The storyteller API is "
|
||||
"unauthenticated and loopback-bound; a wildcard origin would let any "
|
||||
"web page read and rewrite every campaign. List the exact origins "
|
||||
"instead."
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="Adventure Storyteller",
|
||||
docs_url="/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(BodySizeLimitMiddleware)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Standard hardening headers on every response. Pure ASGI (wraps `send`)
|
||||
so SSE streams pass through unbuffered.
|
||||
|
||||
The CSP allows exactly what the SPA uses, and that is now same-origin and
|
||||
nothing else: scripts, styles, fonts, images and XHR/SSE all resolve to the
|
||||
app itself. The fonts used to come from Google, which made an Internet
|
||||
request on every page load; they are self-hosted under /fonts/ instead
|
||||
(frontend/tools/vendor_fonts.py), so `font-src 'self'` covers them and the
|
||||
two remote hosts are gone from the policy.
|
||||
|
||||
`'unsafe-inline'` stays on `style-src` because React writes inline `style`
|
||||
attributes. It is deliberately absent from `script-src`.
|
||||
"""
|
||||
|
||||
_HEADERS = [
|
||||
(b"x-content-type-options", b"nosniff"),
|
||||
(b"referrer-policy", b"same-origin"),
|
||||
(b"x-frame-options", b"DENY"),
|
||||
(
|
||||
b"content-security-policy",
|
||||
b"default-src 'self'; "
|
||||
b"script-src 'self'; "
|
||||
b"style-src 'self' 'unsafe-inline'; "
|
||||
b"font-src 'self'; "
|
||||
b"img-src 'self' data:; "
|
||||
b"connect-src 'self'; "
|
||||
b"object-src 'none'; "
|
||||
b"base-uri 'none'; "
|
||||
b"form-action 'self'; "
|
||||
b"frame-ancestors 'none'",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
async def send_with_headers(message):
|
||||
if message["type"] == "http.response.start":
|
||||
message.setdefault("headers", [])
|
||||
message["headers"] = list(message["headers"]) + self._HEADERS
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_headers)
|
||||
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
app.include_router(scenarios.router)
|
||||
app.include_router(adventures.router)
|
||||
app.include_router(story_cards.router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(debug.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# In production, serve the built frontend (frontend/dist) as static files.
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
"""Serve index.html for unknown paths so client-side routes (/play/3)
|
||||
survive a page reload. API routes are matched before this mount, and an
|
||||
unmatched one 404s rather than falling through to the page."""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
try:
|
||||
response = await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
return await self._fallback(path, scope)
|
||||
if response.status_code == 404:
|
||||
return await self._fallback(path, scope)
|
||||
return response
|
||||
|
||||
async def _fallback(self, path, scope):
|
||||
# The mount is a catch-all, so an /api path no router claims — a typo,
|
||||
# or an endpoint this build removed — used to come back as the SPA's
|
||||
# HTML with status 200, and a client asking for JSON parsed a web page
|
||||
# instead of seeing that the route is not there.
|
||||
if path == "api" or path.startswith("api/"):
|
||||
raise StarletteHTTPException(status_code=404)
|
||||
return await super().get_response("index.html", scope)
|
||||
|
||||
|
||||
# Python's mimetypes table has no entry for woff2 on a slim Debian image, so
|
||||
# StaticFiles served the self-hosted fonts as application/octet-stream. Browsers
|
||||
# take them anyway — a @font-face src carries its own format() hint — but the
|
||||
# honest type costs one line.
|
||||
mimetypes.add_type("font/woff2", ".woff2")
|
||||
mimetypes.add_type("font/woff", ".woff")
|
||||
|
||||
frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||
if frontend_dist.is_dir():
|
||||
app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend")
|
||||
@@ -0,0 +1,824 @@
|
||||
"""Phase 6: automatic summarization and the embedding memory bank.
|
||||
|
||||
This follows AI Dungeon's memory system. See
|
||||
help.aidungeon.com/faq/the-memory-system.
|
||||
|
||||
After each turn, `run_post_turn` runs as a fire-and-forget task with its own
|
||||
database session. It does three things:
|
||||
|
||||
- Every `MEMORY_INTERVAL` actions, starting once the adventure reaches
|
||||
`MEMORY_START` actions, it summarizes each uncovered block of actions into a
|
||||
short memory. A block waits until `SETTLE_SLACK` actions sit past it, so a
|
||||
memory never ends on the action a retry could replace.
|
||||
- Every `SUMMARY_INTERVAL` actions, it rewrites the story summary to include the
|
||||
new memories. The rewrite always starts from the text the user edited and
|
||||
never discards it.
|
||||
- It embeds new memories through an OpenAI-compatible `/v1/embeddings` endpoint,
|
||||
then evicts the bank down to its capacity. Evicted memories are marked as
|
||||
forgotten and kept so that the UI can still show them.
|
||||
|
||||
When the app generates a turn, `retrieve_memories` embeds the recent story text
|
||||
and ranks the bank by cosine similarity. The highest-ranked memories become the
|
||||
Memories section of the context.
|
||||
|
||||
Every AI call in this module is best-effort. A failure is logged to the debug
|
||||
page and retried on a later turn, because the cursors advance only after a call
|
||||
succeeds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from array import array
|
||||
from collections import OrderedDict
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from . import models, tree, vectors
|
||||
from .context import (
|
||||
cursors,
|
||||
history,
|
||||
lineage,
|
||||
match_cards,
|
||||
story_actions,
|
||||
truncate_to_last_tokens,
|
||||
)
|
||||
from .database import SessionLocal
|
||||
from .providers import OpenAICompatibleProvider, ProviderError
|
||||
from .vectors import cosine # re-exported: the ranking lives here, the maths there
|
||||
|
||||
MEMORY_INTERVAL = 6 # actions per memory
|
||||
MEMORY_START = 12 # first memory once the adventure reaches this many actions
|
||||
SUMMARY_INTERVAL = 15 # actions between Story Summary updates
|
||||
MAX_MEMORIES_PER_RUN = 5 # cap catch-up work (e.g. imported adventures) per turn
|
||||
MAX_EMBED_BATCH = 32
|
||||
RETRIEVAL_WINDOW_TOKENS = 600 # recent story text used as the similarity query
|
||||
RETRIEVAL_WINDOW_ACTIONS = 4 # ...taken from this many of the newest actions
|
||||
SUMMARY_MAX_WORDS = 250
|
||||
MEMORY_EXCERPT_TOKENS = 2000 # of the block, when a block is longer than this
|
||||
|
||||
# How much story has to sit past a block before that block is summarized.
|
||||
#
|
||||
# This is not the pre-SP4 holdback returning. That rule made the newest action
|
||||
# invisible to the summarizer everywhere, and it existed because a retry
|
||||
# rewrote an action's text in place, so a memory covering the tip could end up
|
||||
# describing narration the player had already replaced. Sibling attempts and
|
||||
# `forget_node` settled that, and correctness does not rest on the number
|
||||
# below: a delete or an undo anywhere in the story is still repaired by
|
||||
# withdrawing what the coordinate produced.
|
||||
#
|
||||
# This is a cost rule, and it is only about the tip. Retry and take-switching
|
||||
# both refuse anything but the newest action (`takes.retry_action`,
|
||||
# `takes.switch_take`), and both withdraw the derived work at the coordinate
|
||||
# they change. So a memory whose block ends on the tip is the one memory a
|
||||
# player can still throw away, and every retry of that turn pays for it twice:
|
||||
# once to write it, once to write it again. A block closes every
|
||||
# MEMORY_INTERVAL actions and a normal turn writes two of them, so that is one
|
||||
# turn in three.
|
||||
#
|
||||
# One action of slack moves the block end out of reach of both endpoints, and
|
||||
# it buys nothing back in context quality: the block that just closed is still
|
||||
# in the history window in full, so a memory of it tells the model what it can
|
||||
# already read. Memories earn their place once the raw text has scrolled out,
|
||||
# which is never the turn the block closed.
|
||||
SETTLE_SLACK = 1
|
||||
|
||||
# ---- The cast brief (Phase 18b) ----
|
||||
# How many characters the brief names, and how much of each description it
|
||||
# carries. The cast is authored content rather than generated, so it is small in
|
||||
# practice; these are ceilings against a scenario with a very large cast, not a
|
||||
# budget anyone is expected to reach.
|
||||
MAX_CAST_MEMBERS = 8
|
||||
CAST_ENTRY_CHARS = 240
|
||||
SETTING_TOKENS = 300 # of `adventure.memory`, the plot essentials
|
||||
|
||||
# A ceiling on one memory, in words. Measured, not guessed: with only
|
||||
# "1-2 plain sentences" to go on, a real model wrote 34 words for one block and
|
||||
# 105 for the next, and a 105-word memory is a paragraph. Five of those are
|
||||
# injected per turn at the default `memory_top_k`, so the bank's cost is set
|
||||
# here. A stated number also holds the length steady between memories, which is
|
||||
# the same consistency the framing rule buys for the wording.
|
||||
MEMORY_MAX_WORDS = 50
|
||||
|
||||
# The framing rule is the larger half of this prompt, and it is worth the
|
||||
# tokens. Without it the model chooses a person per call, so one bank ends up
|
||||
# holding "You entered the crypt", "The player entered the crypt" and "He
|
||||
# entered the crypt" for the same kind of event.
|
||||
#
|
||||
# The reason is stated, not just the rule. A memory is retrieved in isolation
|
||||
# months of story later, and a model told why bare pronouns fail complies far
|
||||
# more consistently than one handed a bare instruction.
|
||||
#
|
||||
# The protagonist's name lives in the user message, in the Cast, rather than
|
||||
# here. That keeps this prompt constant across every adventure and every call.
|
||||
MEMORY_SYSTEM_PROMPT = (
|
||||
"You compress interactive-fiction story excerpts into memories. Respond with "
|
||||
"1-2 plain sentences in past tense stating the concrete facts and events "
|
||||
f"(names, places, items, promises, injuries), in at most {MEMORY_MAX_WORDS} "
|
||||
"words. Keep the details a later scene could turn on — a name, a promise, an "
|
||||
"injury, where something is — and drop the ones it could not.\n\n"
|
||||
"Write in the third person. The excerpt is written in the second person: "
|
||||
'"you" is the protagonist, who is named in the Cast. Refer to the '
|
||||
'protagonist by that name, never as "you". If the Cast gives no name for '
|
||||
'them, call them "the player". Name the other characters too rather than '
|
||||
'writing "he", "she" or "they" on their own — this memory will be read on '
|
||||
"its own, much later, with nothing around it to say who a pronoun meant.\n\n"
|
||||
"No preamble, no commentary."
|
||||
)
|
||||
SUMMARY_SYSTEM_PROMPT = (
|
||||
"You maintain the running summary of an interactive-fiction story. Respond "
|
||||
"with only the updated summary: a single plain-prose overview of the plot "
|
||||
f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established "
|
||||
"facts; compress older events harder than recent ones.\n\n"
|
||||
"Write in the third person, and name the characters. Refer to the "
|
||||
'protagonist by the name given in the Cast, or as "the player" if the Cast '
|
||||
'gives no name. Never address them as "you".'
|
||||
)
|
||||
|
||||
# Adventures with a post-turn task currently running (single-process app).
|
||||
_running: set[int] = set()
|
||||
# Strong references to tasks that are still running. The event loop holds only
|
||||
# weak references, so without this set a fire-and-forget task can be garbage
|
||||
# collected before it finishes.
|
||||
_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
# Both factories read the endpoint and the model names straight off `Settings`.
|
||||
# They used to also read an API key, which is gone: Ollama does not use one and
|
||||
# M2 removed cloud providers. `summary_model` and `embedding_model` fall back to
|
||||
# the narrator model when the user has not named a separate one.
|
||||
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url,
|
||||
settings.summary_model or settings.model,
|
||||
settings.api_mode,
|
||||
settings.model_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url, settings.embedding_model
|
||||
)
|
||||
|
||||
|
||||
def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
|
||||
"""Store (or clear) a memory's embedding.
|
||||
|
||||
This function updates both columns that describe the vector. The ranking
|
||||
reads `embedding_blob`, and everything else reads the `embedded` flag.
|
||||
Routing every write through one function keeps the two in step, and it makes
|
||||
this the only place a stored vector changes. That is why the cache below can
|
||||
be invalidated here and nowhere else.
|
||||
|
||||
One caller cannot use this function: the bulk clear in
|
||||
`routers/settings.py` that runs when the embedding model changes. It sets
|
||||
the same two columns directly. See the comment there.
|
||||
"""
|
||||
memory.embedding_blob = None if vector is None else vectors.pack(vector)
|
||||
memory.embedded = vector is not None
|
||||
cached = _vector_cache.get(memory.adventure_id)
|
||||
if cached is not None:
|
||||
cached.pop(memory.id, None)
|
||||
|
||||
|
||||
# ---------- The vector cache ----------
|
||||
|
||||
# Maps an adventure id to a dict of memory id to vector, with the
|
||||
# most recently used adventure last.
|
||||
#
|
||||
# Turns for one adventure arrive one after another, and the bank changes little
|
||||
# between them. Reading every vector on each turn fetches the same 600 KB
|
||||
# repeatedly. This cache stores vectors as `array("f")`, which uses 4 bytes per
|
||||
# component and matches the 6 KB the column holds. A list of Python floats would
|
||||
# use eight times as much.
|
||||
#
|
||||
# Two rules keep the cache correct. Any code that changes a vector calls
|
||||
# `set_vector`, which removes that entry. Any code that removes a memory from
|
||||
# play removes it from the catalogue query below, and the next read discards
|
||||
# entries that the catalogue no longer lists. Eviction, deletion, pruning, and
|
||||
# an edit that clears the vector all work this way. No code path has to remember
|
||||
# to invalidate the cache, which is the error this design avoids.
|
||||
#
|
||||
# The cache lives in the process, so it assumes one worker, which is what the
|
||||
# deploy runs. With two workers, each keeps its own copy. Both stay correct
|
||||
# about eviction and deletion, but a vector rewritten by one worker can remain
|
||||
# stale in the other until that memory leaves the catalogue.
|
||||
_vector_cache: OrderedDict[int, dict[int, array]] = OrderedDict()
|
||||
VECTOR_CACHE_ADVENTURES = 8 # ~600 KB each at a 100-memory bank
|
||||
|
||||
|
||||
def forget_cached_vectors(adventure_id: int) -> None:
|
||||
"""Drops an adventure's cached vectors.
|
||||
|
||||
Call this only when the adventure itself is deleted. Every other case
|
||||
corrects itself, as described in the comment above.
|
||||
"""
|
||||
_vector_cache.pop(adventure_id, None)
|
||||
|
||||
|
||||
def _vectors_for(db: Session, adventure_id: int, ids: list[int]) -> dict[int, array]:
|
||||
"""The vectors for `ids`, reading only the ones not already held."""
|
||||
cached = _vector_cache.get(adventure_id)
|
||||
if cached is None:
|
||||
cached = _vector_cache[adventure_id] = {}
|
||||
_vector_cache.move_to_end(adventure_id)
|
||||
while len(_vector_cache) > VECTOR_CACHE_ADVENTURES:
|
||||
_vector_cache.popitem(last=False)
|
||||
|
||||
wanted = set(ids)
|
||||
for gone in set(cached) - wanted:
|
||||
del cached[gone]
|
||||
missing = [memory_id for memory_id in ids if memory_id not in cached]
|
||||
if missing:
|
||||
rows = db.execute(
|
||||
select(models.Memory.id, models.Memory.embedding_blob)
|
||||
.where(models.Memory.id.in_(missing))
|
||||
).all()
|
||||
for memory_id, blob in rows:
|
||||
if blob:
|
||||
cached[memory_id] = vectors.unpack(blob)
|
||||
return cached
|
||||
|
||||
|
||||
def forget_node(db: Session, adventure: models.Adventure, action: models.Action) -> int:
|
||||
"""Withdraw what a node produced, because the node is being removed.
|
||||
|
||||
Call this before deleting `action`, which undo and the delete-action
|
||||
endpoint both do. A memory attaches to the node its block ends on, so
|
||||
finding the memories that describe a node is a lookup on `(branch_id,
|
||||
depth)`. The earlier `prune_dangling_memories` instead scanned for rows
|
||||
whose covered range no longer existed, so it could only detect the problem
|
||||
after it occurred.
|
||||
|
||||
Deleting the memory is half the work. The stretch of story it covered still
|
||||
sits behind the cursors. Without a rewind, those actions count as
|
||||
summarized while nothing describes them, and nothing reports the problem for
|
||||
the rest of the adventure. `source_start` records where that stretch began,
|
||||
so the anchor moves to the node before it. That depth is valid whether or
|
||||
not a node still occupies it.
|
||||
|
||||
The opening node is the one exception, because migration 62 placed the whole
|
||||
pre-coordinate bank on it. See the comment on `lineage.ROOT_DEPTH`.
|
||||
|
||||
Returns the number of memories withdrawn.
|
||||
"""
|
||||
if action.branch_id is None or action.depth is None:
|
||||
return 0 # A pre-tree row. No path contains it, so nothing refers to it.
|
||||
doomed = (
|
||||
db.query(models.Memory)
|
||||
.filter(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
models.Memory.branch_id == action.branch_id,
|
||||
models.Memory.depth == action.depth,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if action.depth == lineage.ROOT_DEPTH:
|
||||
# Special case for the opening node, and only for memories that
|
||||
# describe no stretch of story. Migration 62 placed every memory written
|
||||
# before memories had coordinates at depth 0. That choice preserved
|
||||
# every memory, but it also placed them all on one node, so withdrawing
|
||||
# that node would delete a player's entire bank in one action.
|
||||
#
|
||||
# A memory with no `source_start` was either typed by the player or
|
||||
# migrated. It describes no actions, so no deletion can invalidate it,
|
||||
# and it stays. A memory that genuinely summarizes a block ending here is
|
||||
# still withdrawn, because the text it describes is being deleted.
|
||||
doomed = [m for m in doomed if m.source_start is not None]
|
||||
if not doomed:
|
||||
return 0
|
||||
starts = [m.source_start for m in doomed if m.source_start is not None]
|
||||
for memory in doomed:
|
||||
db.delete(memory)
|
||||
if starts:
|
||||
cursors.rewind_all(adventure, action.branch_id, min(starts) - 1)
|
||||
return len(doomed)
|
||||
|
||||
|
||||
def source_block(db: Session, memory: models.Memory) -> list[models.Action]:
|
||||
"""The actions a memory was written from, oldest first.
|
||||
|
||||
The inverse of what `_create_due_memories` recorded. `source_start` and
|
||||
`source_end` are depths, and `branch_id` says which path they are depths
|
||||
on — that branch's own lineage, not the adventure's current path. A memory
|
||||
written before a fork must still read back from the branch it was written
|
||||
on, whichever branch the adventure has since moved to.
|
||||
|
||||
Returns `[]` for a memory that describes no stretch of story. Those are
|
||||
hand-written, or migrated from before memories had coordinates (see
|
||||
`lineage.ROOT_DEPTH`), and there is no block to read.
|
||||
|
||||
The range may come back shorter than `MEMORY_INTERVAL`. An action inside it
|
||||
can have been deleted since, and a memory whose block is now partial still
|
||||
describes the actions that remain.
|
||||
"""
|
||||
if memory.source_start is None or memory.source_end is None:
|
||||
return []
|
||||
if memory.branch_id is None:
|
||||
return [] # A pre-tree row: no path contains it.
|
||||
branch = db.get(models.Branch, memory.branch_id)
|
||||
if branch is None:
|
||||
return []
|
||||
path = lineage.Path(lineage.entries_of(branch))
|
||||
rows = (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == memory.adventure_id,
|
||||
# This clause excludes the sibling attempts at a retried turn, so
|
||||
# the block holds the one text the story used.
|
||||
path.clause(models.Action),
|
||||
models.Action.depth >= memory.source_start,
|
||||
models.Action.depth <= memory.source_end,
|
||||
)
|
||||
# `id` breaks a tie on `depth`, as everywhere else that orders actions.
|
||||
.order_by(models.Action.depth, models.Action.id)
|
||||
# Reasoning traces are never part of an excerpt and can outweigh the
|
||||
# narration on a reasoning model.
|
||||
.options(defer(models.Action.reasoning))
|
||||
.all()
|
||||
)
|
||||
return [a for a in rows if history.is_story_text(a.text)]
|
||||
|
||||
|
||||
# ---------- The cast brief ----------
|
||||
|
||||
def _cast_line(name: str, entry: str, *, protagonist: bool = False) -> str:
|
||||
"""One roster line: who they are, and nothing about where they stand now."""
|
||||
who = f"{name} — the protagonist" if protagonist else f"{name} —"
|
||||
entry = " ".join(entry.split()) # collapse newlines: this is a one-line roster
|
||||
if len(entry) > CAST_ENTRY_CHARS:
|
||||
entry = entry[:CAST_ENTRY_CHARS].rsplit(" ", 1)[0] + "…"
|
||||
if protagonist:
|
||||
return f"- {who}." if not entry else f"- {who}. {entry}"
|
||||
return f"- {name} — {entry}" if entry else f"- {name}"
|
||||
|
||||
|
||||
def cast_brief(adventure: models.Adventure, text: str) -> str:
|
||||
"""Returns who appears in `text`, and what the story is about.
|
||||
|
||||
This is the context the summarizer never had. It was handed six actions of
|
||||
second-person prose and nothing else, so the only honest memory it could
|
||||
write for `You push the door open. She grabs your arm.` was "You entered a
|
||||
room and she stopped you." — which, retrieved forty turns later, names
|
||||
nobody.
|
||||
|
||||
Three rules hold this together.
|
||||
|
||||
**Fixed descriptions only, never live values.** It is tempting to add
|
||||
`Gwen: trust 40 (wary)`. That would make the same event summarized at two
|
||||
different times come out framed differently, which is the fault this whole
|
||||
change exists to remove.
|
||||
|
||||
**The cast comes from the story cards, not from `stat_schema`.** Every NPC a
|
||||
scenario defines is already turned into a story card at adventure creation
|
||||
(`scenario_text.scenario_card_specs`), deduplicated against the hand-written
|
||||
ones by name. Reading the cards therefore covers the schema NPCs, the
|
||||
author's own cards, and an adventure with no RPG layer at all, through one
|
||||
path instead of three.
|
||||
|
||||
**Keyword matching alone is not enough here, which is why the roster is
|
||||
topped up.** The turn prompt includes a card only when its trigger words
|
||||
appear, and that is right for lore: a card nobody mentioned is not relevant
|
||||
to the next sentence. It is wrong for this brief. The block that most needs
|
||||
a cast is exactly the one written in bare pronouns — "she grabs your arm"
|
||||
matches no keyword, and the summarizer is then left guessing at precisely
|
||||
the moment it was given this brief to stop guessing. So matched cards come
|
||||
first, and any remaining slots are filled with the other **character**
|
||||
cards. Places and items are not topped up: an unmentioned tavern is not
|
||||
who "she" was.
|
||||
|
||||
Walking `adventure.story_cards` is a relationship load, which this module
|
||||
otherwise avoids. It is affordable here for two reasons the memory bank's
|
||||
own reads were not: a card is five short text columns with no vector, and
|
||||
this runs once per `MEMORY_INTERVAL` actions in the background task rather
|
||||
than on every turn. `build_context` already walks the same collection.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
name = adventure.persona_name.strip()
|
||||
pronouns = adventure.persona_pronouns.strip()
|
||||
if name or adventure.persona_desc.strip():
|
||||
who = f"{name} ({pronouns})" if name and pronouns else (name or "The player")
|
||||
lines.append(_cast_line(who, adventure.persona_desc.strip(), protagonist=True))
|
||||
|
||||
seen = {name.lower()} if name else set()
|
||||
|
||||
def add(card_name: str, entry: str) -> bool:
|
||||
"""Adds one roster line. Returns False once the roster is full."""
|
||||
card_name = (card_name or "").strip()
|
||||
if card_name and card_name.lower() not in seen:
|
||||
seen.add(card_name.lower())
|
||||
lines.append(_cast_line(card_name, (entry or "").strip()))
|
||||
return len(lines) < MAX_CAST_MEMBERS
|
||||
|
||||
room = True
|
||||
for card in match_cards(adventure.story_cards, text):
|
||||
room = add(card["name"], card["entry"])
|
||||
if not room:
|
||||
break
|
||||
if room:
|
||||
for card in adventure.story_cards:
|
||||
if (card.type or "").strip().lower() != "character":
|
||||
continue
|
||||
if not add(card.name, card.entry):
|
||||
break
|
||||
|
||||
parts = []
|
||||
if lines:
|
||||
parts.append("Cast:\n" + "\n".join(lines))
|
||||
setting = adventure.memory.strip()
|
||||
if setting:
|
||||
parts.append("Setting:\n" + truncate_to_last_tokens(setting, SETTING_TOKENS))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------- Retrieval (runs inside the turn, before build_context) ----------
|
||||
|
||||
async def retrieve_memories(
|
||||
adventure: models.Adventure,
|
||||
settings: models.Settings,
|
||||
*,
|
||||
update_stats: bool,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> dict | None:
|
||||
"""Returns the memories to inject, or None when the bank is off.
|
||||
|
||||
The result is a dict of the form
|
||||
`{"used": [{id, text, similarity, pinned}], "error": str | None}`. It is
|
||||
None when the memory bank is disabled for this adventure.
|
||||
|
||||
Set `update_stats` to True to increment the use counters. Only real turns
|
||||
should do this, not the dry runs that Insights performs.
|
||||
|
||||
`exclude_action_id` removes the action being retried from the similarity
|
||||
query, so that a discarded attempt cannot influence which memories are
|
||||
returned.
|
||||
"""
|
||||
if not adventure.memory_bank_enabled:
|
||||
return None
|
||||
if not settings.embedding_model.strip():
|
||||
return {"used": [], "error": "No embedding model configured in Settings."}
|
||||
db = object_session(adventure)
|
||||
if db is None:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Select which memories are in play, and nothing else about them. This code
|
||||
# used to walk `adventure.memories`, which loaded every row of the bank,
|
||||
# including its vector. That cost about 31 KB per memory and about 3 MB per
|
||||
# turn, which was 96% of everything a turn read. An id and a flag come to
|
||||
# about eight bytes per row.
|
||||
#
|
||||
# The branch clause uses the whole lineage here rather than the window the
|
||||
# story is read through. Retrieval exists to recall events from far back in
|
||||
# the story, such as what happened forty turns ago. The full lineage stays
|
||||
# affordable because memories are sparse, at roughly one per six actions, so
|
||||
# even a heavily forked story returns only tens of small rows.
|
||||
catalogue = db.execute(
|
||||
select(models.Memory.id, models.Memory.pinned).where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
models.Memory.forgotten.is_(False),
|
||||
models.Memory.embedded.is_(True),
|
||||
)
|
||||
).all()
|
||||
if not catalogue:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
recent = history.tail(adventure, RETRIEVAL_WINDOW_ACTIONS, exclude_action_id)
|
||||
query = truncate_to_last_tokens(
|
||||
"\n\n".join(a.text for a in recent), RETRIEVAL_WINDOW_TOKENS
|
||||
)
|
||||
if not query.strip():
|
||||
return {"used": [], "error": None}
|
||||
|
||||
try:
|
||||
[query_vec] = await embedding_provider(settings).embed([query])
|
||||
except ProviderError as exc:
|
||||
return {"used": [], "error": str(exc)}
|
||||
|
||||
held = _vectors_for(db, adventure.id, [memory_id for memory_id, _ in catalogue])
|
||||
scored = sorted(
|
||||
(
|
||||
(cosine(query_vec, held[memory_id]), memory_id, pinned)
|
||||
for memory_id, pinned in catalogue
|
||||
if memory_id in held
|
||||
),
|
||||
key=lambda row: row[0],
|
||||
reverse=True,
|
||||
)
|
||||
# Pinned memories are always used, and they count toward `top_k`, so the
|
||||
# injected set stays within the budget unless the pinned memories alone
|
||||
# exceed it.
|
||||
top_k = max(1, settings.memory_top_k)
|
||||
used = [row for row in scored if row[2]]
|
||||
remaining = max(0, top_k - len(used))
|
||||
used += [row for row in scored if not row[2]][:remaining]
|
||||
used.sort(key=lambda row: row[0], reverse=True)
|
||||
if not used:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Fetch the text only now, and only for the `top_k` rows that were chosen.
|
||||
used_ids = [memory_id for _, memory_id, _ in used]
|
||||
texts = dict(
|
||||
db.execute(
|
||||
select(models.Memory.id, models.Memory.text)
|
||||
.where(models.Memory.id.in_(used_ids))
|
||||
).all()
|
||||
)
|
||||
|
||||
if update_stats:
|
||||
# Pass `synchronize_session=False` because nothing in this request
|
||||
# reads the counters back. Matching the UPDATE against loaded objects
|
||||
# would require loading those objects, which is the cost this code path
|
||||
# exists to avoid.
|
||||
db.execute(
|
||||
update(models.Memory)
|
||||
.where(models.Memory.id.in_(used_ids))
|
||||
.values(use_count=models.Memory.use_count + 1, last_used_at=models.utcnow())
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"used": [
|
||||
{"id": memory_id, "text": texts.get(memory_id, ""),
|
||||
"similarity": round(score, 4), "pinned": pinned}
|
||||
for score, memory_id, pinned in used
|
||||
],
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------- Post-turn background work ----------
|
||||
|
||||
def schedule_post_turn(adventure: models.Adventure) -> None:
|
||||
"""Fire-and-forget summarization/embedding work after a turn is saved."""
|
||||
if not (adventure.auto_summarize or adventure.memory_bank_enabled):
|
||||
return
|
||||
if adventure.id in _running:
|
||||
return
|
||||
task = asyncio.get_running_loop().create_task(run_post_turn(adventure.id))
|
||||
_tasks.add(task)
|
||||
task.add_done_callback(_tasks.discard)
|
||||
|
||||
|
||||
async def run_post_turn(adventure_id: int) -> None:
|
||||
if adventure_id in _running:
|
||||
return
|
||||
_running.add(adventure_id)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, adventure_id)
|
||||
if adventure is None:
|
||||
return
|
||||
# Settings are per-user (Phase 8): use the adventure owner's row.
|
||||
settings = (
|
||||
db.query(models.Settings)
|
||||
.filter(models.Settings.user_id == adventure.user_id)
|
||||
.first()
|
||||
)
|
||||
if settings is None:
|
||||
return
|
||||
# This code no longer clamps the cursors. Undo can leave the story
|
||||
# shorter than the mark. When the mark was a position, a value past the
|
||||
# end of the list stalled the pass until the story grew back, so every
|
||||
# post-turn run clamped it. That clamp introduced its own error, because
|
||||
# clamping to the settled count rewound a caught-up adventure by one
|
||||
# step and covered an action twice.
|
||||
#
|
||||
# An anchor past the tip is not an invalid value. `settled_after`
|
||||
# reports that there is nothing to do, and once the story grows past the
|
||||
# anchor the pass resumes where it stopped.
|
||||
if adventure.auto_summarize:
|
||||
await _create_due_memories(adventure, settings, db)
|
||||
await _update_story_summary(adventure, settings, db)
|
||||
if adventure.memory_bank_enabled and settings.embedding_model.strip():
|
||||
await _embed_pending(adventure, settings, db)
|
||||
_evict_over_capacity(adventure, settings, db)
|
||||
finally:
|
||||
db.close()
|
||||
_running.discard(adventure_id)
|
||||
|
||||
|
||||
async def summarize_block(
|
||||
adventure: models.Adventure,
|
||||
provider: OpenAICompatibleProvider,
|
||||
block: list[models.Action],
|
||||
) -> str:
|
||||
"""Writes one memory from one block of story.
|
||||
|
||||
Both callers come through here, which is the point of the function. The
|
||||
pass below writes a memory as the story reaches it; `tools/rewrite_memories`
|
||||
rewrites one an older prompt produced. Assembling the prompt in two places
|
||||
would mean a rewritten memory was written by a prompt that never shipped,
|
||||
and nothing would report the difference.
|
||||
|
||||
Raises `ProviderError`, which each caller handles its own way: the pass
|
||||
below leaves the cursor alone and retries next turn, and the tool leaves the
|
||||
old text in place and moves on.
|
||||
"""
|
||||
raw = "\n\n".join(a.text for a in block)
|
||||
excerpt = truncate_to_last_tokens(raw, MEMORY_EXCERPT_TOKENS)
|
||||
# Match the cast against the untruncated block. The excerpt is what the
|
||||
# model reads, but a character named in the part that was trimmed is still
|
||||
# one the memory may have to name.
|
||||
brief = cast_brief(adventure, raw)
|
||||
prompt = f"Story excerpt:\n\n{excerpt}\n\nMemory:"
|
||||
return await provider.complete(
|
||||
MEMORY_SYSTEM_PROMPT, f"{brief}\n\n{prompt}" if brief else prompt
|
||||
)
|
||||
|
||||
|
||||
async def _create_due_memories(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
provider = summary_provider(settings)
|
||||
for _ in range(MAX_MEMORIES_PER_RUN):
|
||||
# Re-read the anchor on every pass. Committing a memory does not change
|
||||
# the story, but this loop is the only code that moves the anchor, so
|
||||
# both numbers must be current.
|
||||
anchor = cursors.MEMORY.depth(db, adventure)
|
||||
if history.count_after(adventure, anchor) < MEMORY_INTERVAL + SETTLE_SLACK:
|
||||
return # No settled block of story sits past the mark. The block
|
||||
# itself is still MEMORY_INTERVAL actions; the slack asks
|
||||
# for story past its end. See `SETTLE_SLACK`.
|
||||
if history.count(adventure) < MEMORY_START:
|
||||
return # The adventure is too short to have started summarizing.
|
||||
# The order of those two checks is deliberate. The usual answer is that
|
||||
# no memory is due, and the first check settles that without measuring
|
||||
# the length of the whole story.
|
||||
block = history.after(adventure, anchor, MEMORY_INTERVAL)
|
||||
if len(block) < MEMORY_INTERVAL:
|
||||
return
|
||||
try:
|
||||
text = await summarize_block(adventure, provider, block)
|
||||
except ProviderError:
|
||||
return # Logged on the debug page. The cursor is unchanged, so the
|
||||
# next turn retries this block.
|
||||
if not text:
|
||||
return
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id,
|
||||
text=text,
|
||||
source_start=block[0].depth,
|
||||
source_end=block[-1].depth,
|
||||
)
|
||||
# Attach the memory to the node it summarizes, so that a fork inherits
|
||||
# the memories of the path it forked from and no others. Then move the
|
||||
# mark to that same node. Both values record how far this pass has
|
||||
# reached, and taking them from one row keeps them in step even when the
|
||||
# depths have gaps.
|
||||
tree.attach_memory(memory, block[-1])
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, block[-1])
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _update_story_summary(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
anchor = cursors.SUMMARY.depth(db, adventure)
|
||||
uncovered = history.count_after(adventure, anchor)
|
||||
if uncovered < SUMMARY_INTERVAL:
|
||||
return
|
||||
# Where the summary stands once this run succeeds. Read this before the AI
|
||||
# call rather than after it. The mark records the end of the story as this
|
||||
# pass saw it, and a turn that arrives during the call must not be counted
|
||||
# as read.
|
||||
caught_up = history.newest(adventure)
|
||||
if caught_up is None:
|
||||
return
|
||||
|
||||
# Include the memories for the stretch that the summary has not read, which
|
||||
# means every memory attached to a node past the anchor. The marks and the
|
||||
# memories are now depths on one path, so no coordinate conversion remains.
|
||||
# If memory creation has fallen behind, for example because the last attempt
|
||||
# failed, this falls back to the raw story text.
|
||||
new_events = db.execute(
|
||||
select(models.Memory.text)
|
||||
.where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
models.Memory.depth > anchor,
|
||||
)
|
||||
.order_by(models.Memory.depth)
|
||||
).scalars().all()
|
||||
if new_events:
|
||||
events_text = "\n".join(f"- {t}" for t in new_events)
|
||||
else:
|
||||
block = history.after(adventure, anchor, uncovered)
|
||||
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
|
||||
|
||||
current = adventure.story_summary.strip()
|
||||
# The summary is built from the memories, so it inherits their framing for
|
||||
# free once they are named and third-person. It still gets the brief of its
|
||||
# own, because the fallback above hands it raw second-person story text
|
||||
# whenever memory creation has fallen behind.
|
||||
brief = cast_brief(adventure, f"{current}\n\n{events_text}")
|
||||
user_prompt = (
|
||||
f"Current story summary:\n{current or '(none yet)'}\n\n"
|
||||
f"New events since the last update:\n{events_text}\n\n"
|
||||
"Updated summary:"
|
||||
)
|
||||
if brief:
|
||||
user_prompt = f"{brief}\n\n{user_prompt}"
|
||||
try:
|
||||
text = await summary_provider(settings).complete(
|
||||
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
|
||||
)
|
||||
except ProviderError:
|
||||
return
|
||||
if not text:
|
||||
return
|
||||
adventure.story_summary = text
|
||||
cursors.SUMMARY.anchor_at(adventure, caught_up)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _embed_pending(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# Use a query rather than walking `adventure.memories`. That walk ran on
|
||||
# every turn and loaded the whole bank's vectors in order to find the few
|
||||
# rows with none.
|
||||
#
|
||||
# Neither this query nor the eviction below applies a branch clause, and
|
||||
# that is deliberate. Whether a row is embedded is a fact about the row, not
|
||||
# about the path being played. Skipping a sibling branch's memories would
|
||||
# only postpone the work until someone switched branches and needed them
|
||||
# ranked. Capacity works the same way. The bank belongs to the adventure,
|
||||
# and the memories of a branch nobody is reading are the right ones to evict
|
||||
# first.
|
||||
pending = (
|
||||
db.query(models.Memory)
|
||||
.filter(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
models.Memory.embedded.is_(False),
|
||||
models.Memory.forgotten.is_(False),
|
||||
)
|
||||
.order_by(models.Memory.id)
|
||||
.limit(MAX_EMBED_BATCH)
|
||||
.all()
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
try:
|
||||
new = await embedding_provider(settings).embed([m.text for m in pending])
|
||||
except ProviderError:
|
||||
return
|
||||
for memory, vector in zip(pending, new):
|
||||
set_vector(memory, vector)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _evict_over_capacity(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# The database performs both the count and the ranking, and returns neither
|
||||
# the rows nor the vectors. Counting by walking `adventure.memories` fetched
|
||||
# every vector in the bank on every turn, whether or not the bank was over
|
||||
# capacity.
|
||||
in_this_bank = (models.Memory.adventure_id == adventure.id,
|
||||
models.Memory.forgotten.is_(False))
|
||||
active = db.execute(
|
||||
select(func.count(models.Memory.id)).where(*in_this_bank)
|
||||
).scalar() or 0
|
||||
overflow = active - max(1, settings.memory_bank_capacity)
|
||||
if overflow <= 0:
|
||||
return
|
||||
# Evict the least recently used memory first, and use the use count only to
|
||||
# break ties.
|
||||
#
|
||||
# Ordering by use count first froze the bank. A memory written on this turn
|
||||
# has never been used, so once every other memory had been retrieved at
|
||||
# least once, the new memory held the lowest count in the bank. The same
|
||||
# post-turn run that wrote it then evicted it, one pass after embedding it.
|
||||
# Use counts only increase, so the bank never recovered. An adventure kept
|
||||
# whatever memories it held when the bank first filled, and every later
|
||||
# memory was summarized, marked as forgotten, and never ranked.
|
||||
#
|
||||
# Ordering by recency avoids that. A new memory carries the newest
|
||||
# timestamp, so it is the last row to be evicted rather than the first, and
|
||||
# it remains until other memories are used. Demoting the use count costs
|
||||
# little, because retrieving a useful memory also makes it recent. The two
|
||||
# orderings differ only for memories that were used once and have not been
|
||||
# retrieved since, which are the rows a full bank should evict.
|
||||
doomed = db.execute(
|
||||
select(models.Memory.id)
|
||||
.where(*in_this_bank, models.Memory.pinned.is_(False))
|
||||
.order_by(
|
||||
func.coalesce(models.Memory.last_used_at, models.Memory.created_at),
|
||||
models.Memory.use_count,
|
||||
)
|
||||
.limit(overflow)
|
||||
).scalars().all()
|
||||
if not doomed:
|
||||
return # Every active memory is pinned, so the pins override capacity.
|
||||
db.execute(
|
||||
update(models.Memory)
|
||||
.where(models.Memory.id.in_(doomed))
|
||||
.values(forgotten=True)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
# The bulk UPDATE bypassed the loaded objects, so code that still holds the
|
||||
# collection would otherwise see the evicted memories as active.
|
||||
db.expire(adventure, ["memories"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,570 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
|
||||
String, Text, event,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
|
||||
|
||||
from .compression import CompressedJSON
|
||||
from .database import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Phase 8: optional accounts.
|
||||
|
||||
Three kinds of row share this table:
|
||||
|
||||
- The local user has a NULL email and `is_guest` set to False. Single-user
|
||||
mode creates this row automatically. It owns everything that a database
|
||||
from before Phase 8 contained.
|
||||
- Guests have a NULL email and `is_guest` set to True. Multi-user mode
|
||||
creates one on a visitor's first visit and identifies it only by the
|
||||
session cookie.
|
||||
- Registered users have an email. Registration upgrades a guest row in
|
||||
place, so the guest's data survives without being reassigned.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
is_guest: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# Was the shared demo key's per-day tally. M2 removed the demo key; these
|
||||
# columns stay so existing databases open unchanged and are never written.
|
||||
demo_turns_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
demo_turns_date: Mapped[str] = mapped_column(String(10), default="")
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
__tablename__ = "scenarios"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# NULL owner + is_public = seeded demo content, readable by everyone.
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
prompt: Mapped[str] = mapped_column(Text, default="")
|
||||
# Plot components (AI Dungeon terminology; `memory` == Plot Essentials)
|
||||
memory: Mapped[str] = mapped_column(Text, default="")
|
||||
authors_note: Mapped[str] = mapped_column(Text, default="")
|
||||
ai_instructions: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[str] = mapped_column(String(500), default="")
|
||||
# Cover art. The value is either an "https://" URL or an inline
|
||||
# "data:image/...;base64,..." URI. The editor downscales uploads before
|
||||
# storing them. An empty value tells the UI to fall back to an emoji sigil
|
||||
# or to generated art. The image is stored in the row rather than on disk,
|
||||
# because Render's free tier provides no persistent volume. Storing it here
|
||||
# also keeps export bundles self-contained.
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
# A single emoji or glyph, used when `image` is empty. This is a separate
|
||||
# column because the value is a character rather than a location, so
|
||||
# nothing needs to fetch or cache it.
|
||||
icon: Mapped[str] = mapped_column(String(16), default="")
|
||||
# Phase 12: the RPG world-state template. It holds stat definitions, which
|
||||
# include bands and rules, and milestones. A NULL or empty value means the
|
||||
# scenario has no RPG layer.
|
||||
stat_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
story_cards: Mapped[list["StoryCard"]] = relationship(
|
||||
back_populates="scenario", cascade="all, delete-orphan"
|
||||
)
|
||||
adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario")
|
||||
|
||||
|
||||
class Adventure(Base):
|
||||
__tablename__ = "adventures"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
scenario_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), default="Untitled Adventure")
|
||||
memory: Mapped[str] = mapped_column(Text, default="")
|
||||
authors_note: Mapped[str] = mapped_column(Text, default="")
|
||||
ai_instructions: Mapped[str] = mapped_column(Text, default="")
|
||||
story_summary: Mapped[str] = mapped_column(Text, default="")
|
||||
# Phase 18: who the player is playing as. The AI never writes these — they
|
||||
# are user-only, which is what lets them sit in the cached system block
|
||||
# rather than below the history with the values that change. An empty
|
||||
# `persona_name` means the adventure has no persona, and every read below
|
||||
# falls back to the wording used before this existed.
|
||||
#
|
||||
# These are adventure columns rather than part of the scenario's
|
||||
# `stat_schema`, for two reasons. An adventure with no RPG layer still has a
|
||||
# protagonist, and `worldstate.schema._initials` treats every dict inside a
|
||||
# stat section as a stat definition, so a persona placed there would be
|
||||
# instantiated, rendered and given an `initial` value as though it were one.
|
||||
persona_name: Mapped[str] = mapped_column(String(80), default="")
|
||||
persona_pronouns: Mapped[str] = mapped_column(String(40), default="")
|
||||
persona_desc: Mapped[str] = mapped_column(Text, default="")
|
||||
# Was the campaign scripting engine's shared `state` object. M2 removed
|
||||
# scripting; the column stays so existing databases open unchanged, and it
|
||||
# is never written with anything but an empty dict.
|
||||
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
# Phase 12: live RPG world state (world/player/npc stats + milestones),
|
||||
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
|
||||
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
# The ${Placeholder} answers collected when this adventure was started, kept
|
||||
# so "Update from scenario" can re-fill freshly copied scenario text with the
|
||||
# same values. NULL for adventures created before this column existed.
|
||||
placeholders: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# Phase 6: opt-in per adventure (extra AI calls)
|
||||
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Phase 14, SP3: how far the memory pass and the summary pass have read,
|
||||
# each as a coordinate. Each pair
|
||||
# holds the branch and depth of the last action that pass covered. A
|
||||
# position moves when an action in front of it is deleted, so the mark
|
||||
# silently starts covering an action it never read. A depth is a coordinate
|
||||
# along a path, so deleting an action does not move it. NO_DEPTH, which is
|
||||
# -1, means that nothing is covered yet, so the first block needs no special
|
||||
# case. These are plain integers rather than foreign keys, for the reason
|
||||
# given on `head_branch_id` below. See `context/cursors.py`.
|
||||
memory_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
memory_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
summary_cursor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
summary_cursor_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
# Phase 14: where the story is being played. `head_branch_id` names the
|
||||
# branch, and `head_depth` gives the depth of its newest node.
|
||||
#
|
||||
# `head_branch_id` is deliberately not a ForeignKey. `branches.adventure_id`
|
||||
# already points from branches to adventures, so a constraint in this
|
||||
# direction would make the two tables a cycle that `create_all` cannot
|
||||
# order. The usual fix is `use_alter`, which needs an ALTER statement that
|
||||
# SQLite does not provide. The column caches a pointer, and
|
||||
# `tree.head_branch` treats a head that names a missing branch as a bug to
|
||||
# recover from rather than a state to preserve.
|
||||
head_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The depth of the tip, so the next node is always head_depth + 1.
|
||||
# NO_DEPTH (-1) for an adventure with no actions yet.
|
||||
head_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
scenario: Mapped[Scenario | None] = relationship(back_populates="adventures")
|
||||
story_cards: Mapped[list["StoryCard"]] = relationship(
|
||||
back_populates="adventure", cascade="all, delete-orphan"
|
||||
)
|
||||
# Every action in the adventure, across all branches. This collection is
|
||||
# the tree, not the story being played. Ordering it by depth does not make
|
||||
# it a story, because a path is a selection out of the tree. Code that shows
|
||||
# a story to a reader goes through `context.history`, which applies the
|
||||
# branch clause. This relationship exists for ownership and for the
|
||||
# delete-orphan cascade.
|
||||
actions: Mapped[list["Action"]] = relationship(
|
||||
back_populates="adventure",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Action.id",
|
||||
)
|
||||
memories: Mapped[list["Memory"]] = relationship(
|
||||
back_populates="adventure",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Memory.id",
|
||||
)
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
"""Phase 14: one path through an adventure's story tree.
|
||||
|
||||
A branch does not own a copy of the story. It holds the nodes played on it,
|
||||
and it inherits everything before its fork point from its ancestors. Reading
|
||||
branch C means reading C's nodes, then B's nodes up to the depth where C
|
||||
forked, then A's nodes up to the depth where B forked. The `lineage` column
|
||||
records that list, so a read becomes one OR clause per entry instead of a
|
||||
walk up parent pointers.
|
||||
|
||||
Until forking ships, each adventure has one root branch and every node
|
||||
belongs to it. This is not a partly migrated state. A linear story is a tree
|
||||
with one branch, which is why writing these columns changes nothing that a
|
||||
reader can observe.
|
||||
|
||||
This class defines no ORM relationships, by design. `actions.branch_id` and
|
||||
`memories.branch_id` both use ON DELETE CASCADE, so the database removes a
|
||||
deleted branch's nodes. A relationship would make SQLAlchemy load those rows
|
||||
first, and loading every action of a branch is what the windowed reads exist
|
||||
to avoid.
|
||||
"""
|
||||
|
||||
__tablename__ = "branches"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
# NULL on a root branch.
|
||||
parent_branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# The depth at which this branch left its parent. The fork records this
|
||||
# value, and no code infers it later. Deriving it from the first depth where
|
||||
# two branches' nodes differ would produce a wrong answer whenever an
|
||||
# attempt repeats its parent's text.
|
||||
fork_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The ancestry, newest first, as [[branch_id, max_depth], ...]. A NULL
|
||||
# `max_depth` means the entry extends to the tip of that branch. Any other
|
||||
# value is the fork depth of the branch below it, inclusive. The fork
|
||||
# computes this list once from the parent's lineage plus one entry, so no
|
||||
# read has to reconstruct it.
|
||||
lineage: Mapped[list] = mapped_column(JSON, default=list)
|
||||
# The name the player gave this line of the story, or NULL if no one named
|
||||
# it. The column stores NULL rather than a generated name such as
|
||||
# "branch 4", because it records what the player chose rather than what the
|
||||
# app derived. A stored default would also become wrong as soon as an
|
||||
# earlier branch is deleted and the ordinals shift. The UI labels an unnamed
|
||||
# branch by its fork depth, which deleting a branch does not change.
|
||||
name: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
||||
|
||||
The vector lives in `embedding_blob` as packed float32 (see vectors.py).
|
||||
NULL until embedded, which also marks it for backfill when an embedding
|
||||
model becomes available.
|
||||
|
||||
Cosine ranking runs in Python, so the vectors travel over the wire. Measure
|
||||
that cost in bytes rather than in rows. A few hundred vectors stored as JSON
|
||||
come to about 10 MB, fetched again on every turn. Size any new column by the
|
||||
bytes it adds, not by the number of rows.
|
||||
"""
|
||||
|
||||
__tablename__ = "memories"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
# The vector, stored as little-endian float32. This column is deferred
|
||||
# because it is wider than the rest of the row combined and only one code
|
||||
# path reads it. Code that loads memories in bulk, such as the Memories
|
||||
# drawer, eviction, and the embed queue, must select the columns it needs
|
||||
# instead of loading whole entities.
|
||||
embedding_blob: Mapped[bytes | None] = mapped_column(
|
||||
LargeBinary, nullable=True, deferred=True
|
||||
)
|
||||
# The stretch of story this memory summarizes, given as depths on
|
||||
# `branch_id`. Both are NULL for a hand-written memory, which summarizes no
|
||||
# actions. `source_end` is the depth of the node the memory
|
||||
# attaches to, and `depth` below mirrors it. `source_start` is where the
|
||||
# stretch begins, which is where the summarizer resumes if the memory is
|
||||
# withdrawn.
|
||||
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Phase 14: the node that produced this memory, meaning the last action the
|
||||
# memory summarizes. Derived data attaches to the node it came from, which
|
||||
# is what makes forking cheap. Memories on a shared ancestor are shared
|
||||
# automatically, and a memory that covers part of branch B is not visible
|
||||
# from any path that does not go through B.
|
||||
#
|
||||
# Every memory has a coordinate, including a hand-written one, which takes
|
||||
# the head as of the moment it was written (SP7). A NULL depth used to mean
|
||||
# that the memory belonged to the adventure rather than to a path. A fork
|
||||
# cannot cap a NULL, so such a memory followed the reader onto branches
|
||||
# whose story it did not describe.
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this column
|
||||
# current. Readers need only the yes-or-no answer, and fetching six
|
||||
# kilobytes of vector to get it is too expensive.
|
||||
embedded: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
adventure: Mapped[Adventure] = relationship(back_populates="memories")
|
||||
|
||||
|
||||
class StoryCard(Base):
|
||||
"""Owned by either a scenario or an adventure (exactly one set)."""
|
||||
|
||||
__tablename__ = "story_cards"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
adventure_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("adventures.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(100), default="")
|
||||
name: Mapped[str] = mapped_column(String(200), default="")
|
||||
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
|
||||
entry: Mapped[str] = mapped_column(Text, default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
# Set on adventure copies only. It records which piece of the scenario the
|
||||
# card came from, as either "card:<scenario_card_id>" or "npc:<npc_key>".
|
||||
# The "Update from scenario" action refreshes or removes exactly these
|
||||
# cards. A NULL value means the player wrote the card, so the update leaves
|
||||
# it alone, or that the copy predates this column, in which case the update
|
||||
# matches it by name and then sets this value.
|
||||
source_ref: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards")
|
||||
adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards")
|
||||
|
||||
|
||||
def _change_label(parts: list[str]) -> str:
|
||||
"""Names a world-state path for the inline turn summary.
|
||||
|
||||
`npc.gwen.trust` becomes "gwen trust". Every other shape uses its last
|
||||
segment, so `player.hp` becomes "hp".
|
||||
"""
|
||||
if len(parts) == 3 and parts[0] == "npc":
|
||||
return f"{parts[1]} {parts[2]}"
|
||||
return parts[-1] if parts else ""
|
||||
|
||||
|
||||
class Action(Base):
|
||||
__tablename__ = "actions"
|
||||
# Phase 14: every story read selects one branch up to one depth, then
|
||||
# another branch up to another depth, and so on. The pair (branch_id, depth)
|
||||
# is the index those clauses need.
|
||||
__table_args__ = (Index("ix_actions_branch_depth", "branch_id", "depth"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
# Phase 14: the node's place in the tree. `depth` is a position along one
|
||||
# path rather than a global turn number. Node A4 and node B4 are
|
||||
# alternatives, not duplicates.
|
||||
#
|
||||
# Both columns are nullable because ALTER TABLE cannot add a NOT NULL column
|
||||
# without a default, and no default makes sense for a branch. The migration
|
||||
# fills these columns for existing rows, and `tree.place_action` fills them
|
||||
# for new rows. From SP2 onward, a NULL `branch_id` marks a row that no read
|
||||
# can see.
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Phase 14, SP9: the node that this node was played after. It names the take
|
||||
# that was live when this row was written, not whatever sits at depth - 1
|
||||
# now.
|
||||
#
|
||||
# This column answers one question: which takes belong to the same turn. A
|
||||
# coordinate cannot answer it. A take that is forked onto its own branch
|
||||
# leaves the coordinate that its siblings still occupy, so the pager would
|
||||
# show it as 1/1 next to their 1/3. Forking a branch does not change a
|
||||
# node's parent.
|
||||
#
|
||||
# Code reads this column only to group takes, using one indexed lookup
|
||||
# rather than a walk. Paths still resolve through `lineage`, which is why
|
||||
# adding this column required no change to any read of the story.
|
||||
#
|
||||
# The value is NULL on a root node, and on pre-SP9 rows that the migration
|
||||
# could not place. For those rows, `attempts.group` falls back to the
|
||||
# coordinate, which is how they were written.
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("actions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
# Phase 14, SP4: whether this node is the one the story uses at its
|
||||
# coordinate. Retry no longer rewrites a row. It writes a sibling at the
|
||||
# same branch and depth, so one coordinate can hold several attempts while
|
||||
# exactly one of them is on the path. `lineage.Path.clause` is the only
|
||||
# place that reads this column, for the same reason it is the only place
|
||||
# that knows about branches. If a discarded attempt reaches a read, the page
|
||||
# renders the same turn twice.
|
||||
#
|
||||
# A node with no siblings is live, so the default is True and every pre-SP4
|
||||
# row is already correct. The migration does not need to visit them.
|
||||
live: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
# Reasoning-model "thinking" that preceded the text (AI actions only).
|
||||
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# The full assembled prompt for this turn, used by the Insights viewer.
|
||||
# This is the largest column in the database. It averages 163 KB per row in
|
||||
# production and 232 KB on the longest adventure, and it accounts for 89% of
|
||||
# everything stored. Only one endpoint reads it, one action at a time.
|
||||
#
|
||||
# The column is expensive in two ways, so it has two protections.
|
||||
# `deferred=True` protects reads, because SQLAlchemy loads the column only
|
||||
# when code touches the attribute. A page load therefore costs nothing. Code
|
||||
# that reads actions in bulk must not touch this attribute, which is why
|
||||
# `world_delta` below exists. `CompressedJSON` protects storage, because
|
||||
# this column determines when the free tier's 512 MB limit is reached. The
|
||||
# attribute behaves like a plain dict in both cases. See compression.py.
|
||||
context_snapshot: Mapped[dict | None] = mapped_column(
|
||||
CompressedJSON, nullable=True, deferred=True
|
||||
)
|
||||
# The small slice of the snapshot that IS needed in bulk: this turn's RPG
|
||||
# state changes, for the inline chips under an AI message (world_changes)
|
||||
# and for re-attaching the emit block when replaying history to the model.
|
||||
# Mirrors the active variant, same as text/reasoning/context_snapshot.
|
||||
world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# Phase 14, SP4: the RPG world state as it stood after this node was
|
||||
# played. These columns record the node's outcome rather than its starting
|
||||
# position. (`state_after` held the scripting engine's state, which M2
|
||||
# removed; it is now always written empty.)
|
||||
#
|
||||
# Two operations need this outcome, and neither can use a snapshot taken
|
||||
# before the turn. Switching between siblings must restore the state that
|
||||
# the chosen attempt produced, and the attempts differ in exactly that.
|
||||
# Rolling back to before a turn means restoring the state that the preceding
|
||||
# node left behind, which is one lookup along the path.
|
||||
#
|
||||
# The value is NULL on pre-SP4 rows for which the migration could not derive
|
||||
# one. Every caller tolerates that. A missing snapshot means that the caller
|
||||
# leaves the live state unchanged. It never means reset the state.
|
||||
#
|
||||
# These columns are deferred, because code reads them only for the single
|
||||
# node being switched to, undone, or retried past.
|
||||
state_after: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
world_state_after: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
adventure: Mapped[Adventure] = relationship(back_populates="actions")
|
||||
|
||||
@property
|
||||
def world_changes(self) -> list[dict]:
|
||||
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
|
||||
under an AI message. Labels are path-based (no schema needed):
|
||||
`npc.gwen.trust` -> "gwen trust".
|
||||
|
||||
The summary reports refused changes as well as accepted ones. A stat the
|
||||
engine clamped carries `clamped`, and a stat it refused outright becomes
|
||||
a `rejected` entry carrying the reason. Reporting only the accepted
|
||||
changes made a clamp indistinguishable from a change that never
|
||||
happened: a value the model pushed past its ceiling came back as a
|
||||
delta of 0 and rendered as an ordinary chip, so a refused update read on
|
||||
screen as an applied one.
|
||||
|
||||
Reads `world_delta`, never `context_snapshot`. This runs for every
|
||||
action in a list response, and touching the deferred snapshot here would
|
||||
drag the entire prompt archive out of the database."""
|
||||
wd = self.world_delta if isinstance(self.world_delta, dict) else None
|
||||
if wd is None:
|
||||
return []
|
||||
clamped_paths = {
|
||||
str(e.get("path", "")) for e in (wd.get("clamped") or []) if isinstance(e, dict)
|
||||
}
|
||||
out: list[dict] = []
|
||||
for entry in wd.get("applied") or []:
|
||||
path = str(entry.get("path", ""))
|
||||
parts = path.split(".")
|
||||
section, name = parts[0], parts[-1]
|
||||
if section == "flags":
|
||||
out.append({"kind": "flag", "label": name, "on": bool(entry.get("new"))})
|
||||
elif section == "milestones":
|
||||
out.append({"kind": "milestone", "label": name})
|
||||
else:
|
||||
old, new = entry.get("old"), entry.get("new")
|
||||
delta = new - old if isinstance(old, (int, float)) and isinstance(new, (int, float)) else None
|
||||
chip = {
|
||||
"kind": "stat",
|
||||
"label": _change_label(parts),
|
||||
"delta": delta,
|
||||
"value": new,
|
||||
"clamped": path in clamped_paths,
|
||||
}
|
||||
# Carried only when the engine wrote one. It is empty for every
|
||||
# accepted change, and a key per chip per action is paid on
|
||||
# every page load.
|
||||
if entry.get("fix"):
|
||||
chip["fix"] = str(entry["fix"])
|
||||
out.append(chip)
|
||||
for entry in wd.get("rejected") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
parts = str(entry.get("path", "")).split(".")
|
||||
chip = {
|
||||
"kind": "rejected",
|
||||
"label": _change_label(parts),
|
||||
"reason": str(entry.get("reason", "")),
|
||||
}
|
||||
if entry.get("fix"):
|
||||
chip["fix"] = str(entry["fix"])
|
||||
out.append(chip)
|
||||
return out
|
||||
|
||||
|
||||
class Settings(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Phase 8: one row per user (pre-Phase-8 DBs had a single id=1 row, which
|
||||
# the migration assigns to the local user).
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
|
||||
)
|
||||
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
|
||||
# Was a cloud provider's API key, encrypted at rest. Ollama does not use
|
||||
# one and M2 removed cloud providers, so nothing reads or writes this now.
|
||||
# The column stays so existing databases open unchanged; an old value is
|
||||
# left where it is rather than migrated or decrypted.
|
||||
api_key: Mapped[str] = mapped_column(String(500), default="")
|
||||
model: Mapped[str] = mapped_column(String(200), default="")
|
||||
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.8)
|
||||
# 800 leaves room for a full scene; 400 tended to truncate mid-paragraph
|
||||
# and left reasoning models with nothing after their thinking.
|
||||
max_output_tokens: Mapped[int] = mapped_column(Integer, default=800)
|
||||
# Was an OpenRouter-style thinking budget. Ollama's OpenAI-compatible
|
||||
# endpoint ignores the field, so M2 stopped sending it and removed it from
|
||||
# the Settings API and UI. The column stays so existing databases open
|
||||
# unchanged and is never read.
|
||||
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
|
||||
# How long to wait for the model, in seconds, before giving up on a turn.
|
||||
# A cold load of a mid-sized model on a CPU-only machine can take minutes,
|
||||
# while the same turn takes seconds once the model is resident. See
|
||||
# `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
|
||||
model_timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
narrator_prompt: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
default=(
|
||||
"You are a masterful storyteller continuing an interactive adventure. "
|
||||
"Continue the story naturally in second person, staying consistent with "
|
||||
"everything established so far. Write vivid prose. Never speak for the "
|
||||
"player or break character. Do not conclude the story; always leave room "
|
||||
"for the player's next action."
|
||||
),
|
||||
)
|
||||
# Phase 6: auto-summarization + memory bank
|
||||
summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model
|
||||
embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled
|
||||
# This was 200. It was lowered mainly to improve retrieval quality. Ranking
|
||||
# 200 memories to choose 5 selects from a large amount of noise, and the
|
||||
# oldest memories describe a part of the story that the player has left
|
||||
# behind. Cheaper reads are a secondary benefit rather than the reason.
|
||||
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
|
||||
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
|
||||
|
||||
|
||||
|
||||
# The hosted visitor dashboard's two counter tables and the access log that
|
||||
# recorded sign-ins, addresses and devices used to be mapped here. M2 removed
|
||||
# the hosted deployment they served.
|
||||
#
|
||||
# The tables are left in the database rather than dropped: they are inert,
|
||||
# nothing reads or writes them, and a destructive migration would risk an
|
||||
# existing campaign database for tidiness alone. They are not product
|
||||
# functionality.
|
||||
|
||||
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _place_new_nodes_on_the_tree(session, flush_context, instances):
|
||||
from . import tree
|
||||
|
||||
tree.place_new_nodes(session)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .base import PromptParts, Provider, ProviderError
|
||||
from .openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
__all__ = ["PromptParts", "Provider", "ProviderError", "OpenAICompatibleProvider"]
|
||||
@@ -0,0 +1,34 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptParts:
|
||||
"""Assembled context, provider-agnostic. Providers map this to their wire format."""
|
||||
|
||||
system: str # narrator prompt + AI instructions + memory
|
||||
story: str # the story text so far (already token-budgeted)
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""User-presentable provider failure (connection refused, bad key, model not found…)."""
|
||||
|
||||
|
||||
class Provider(ABC):
|
||||
# The endpoint's own token accounting for the most recent call, when it
|
||||
# reported any. It notably carries `prompt_tokens_details.cached_tokens`,
|
||||
# which is the only direct measure of whether the prompt prefix is being
|
||||
# cached. A caller reads it after the call it made, and one provider is built
|
||||
# per request, so nothing races.
|
||||
last_usage: dict | None = None
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
parts: PromptParts,
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Yield ("text" | "reasoning", chunk) pairs. Raises ProviderError on failure."""
|
||||
@@ -0,0 +1,401 @@
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import debuglog, endpoints, tlstrust
|
||||
from .base import PromptParts, Provider, ProviderError
|
||||
|
||||
# Appended after the story text in chat mode, so a chat-tuned model continues
|
||||
# the prose rather than replying conversationally.
|
||||
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
|
||||
|
||||
# A machine that is not listening refuses in milliseconds, so a slow connect
|
||||
# means the wrong address rather than a busy model.
|
||||
CONNECT_TIMEOUT = 10.0
|
||||
|
||||
# How long to wait for generation when Settings names no value. Upstream
|
||||
# hardcoded 120s, and M1 measured a *cold* load of a 3B model on a GPU-less
|
||||
# four-core host exceeding it three times while the same turn took 6-9 seconds
|
||||
# once the model was resident. 300s covers a cold start on modest hardware and
|
||||
# is still a number: a wedged endpoint fails rather than hanging forever.
|
||||
DEFAULT_READ_TIMEOUT = 300.0
|
||||
|
||||
# Embeddings are short and never cold-load a large model.
|
||||
EMBED_READ_TIMEOUT = 60.0
|
||||
|
||||
|
||||
|
||||
# Completion endpoints have no roles, so a chat has to be flattened into one
|
||||
# labeled transcript that ends on "Assistant:" for the model to continue.
|
||||
_ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant"}
|
||||
|
||||
|
||||
def flatten_messages(messages: list[dict]) -> str:
|
||||
turns = "\n\n".join(
|
||||
f"{_ROLE_LABELS.get(m['role'], m['role'])}: {m['content']}" for m in messages
|
||||
)
|
||||
return f"{turns}\n\nAssistant:"
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(Provider):
|
||||
"""Adapter for Ollama's OpenAI-compatible `/v1` API.
|
||||
|
||||
The protocol is OpenAI's, which is what the module is named for; the
|
||||
product speaks it to Ollama and to nothing else. `endpoints.py` decides
|
||||
which addresses may be reached, and every request re-checks — the shape of
|
||||
the wire format is not the same thing as permission to use it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
api_mode: str = "chat",
|
||||
read_timeout: float | None = None,
|
||||
):
|
||||
self.base_url = endpoint_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_mode = api_mode # Either "chat" or "completion".
|
||||
# How long to wait for the model, in seconds. Cold-loading a model on a
|
||||
# CPU-only machine can take minutes, and a fixed short timeout reports
|
||||
# that as a failure. See `DEFAULT_READ_TIMEOUT`.
|
||||
self.read_timeout = read_timeout or DEFAULT_READ_TIMEOUT
|
||||
# The token accounting from the last call, when the endpoint reported
|
||||
# any. Every request method writes it, so a caller reads it after the
|
||||
# call it made. One provider is built per request.
|
||||
self.last_usage: dict | None = None
|
||||
|
||||
def _headers(self) -> dict:
|
||||
# No Authorization header: Ollama does not use one, and this build has
|
||||
# no cloud provider to carry a key for.
|
||||
return {"Content-Type": "application/json"}
|
||||
|
||||
def _timeout(self, seconds: float | None = None) -> httpx.Timeout:
|
||||
"""Short to connect, patient to read.
|
||||
|
||||
A machine that is not listening says so in milliseconds, so a slow
|
||||
connect is a wrong address rather than a busy model and should fail
|
||||
fast. Generation is the opposite: the first token can be minutes away
|
||||
while a model loads.
|
||||
"""
|
||||
return httpx.Timeout(seconds or self.read_timeout, connect=CONNECT_TIMEOUT)
|
||||
|
||||
def _record_usage(self, payload: dict) -> None:
|
||||
"""Records the endpoint's own token accounting, if it reported any.
|
||||
|
||||
OpenRouter now always reports usage, and `usage: {include: true}` and
|
||||
`stream_options` are deprecated and do nothing. In a stream the usage
|
||||
arrives on a final chunk that carries no choices, which is why this is
|
||||
read separately from the text extraction.
|
||||
"""
|
||||
usage = payload.get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
self.last_usage = usage
|
||||
|
||||
def _request(self, parts: PromptParts, temperature: float, max_tokens: int) -> tuple[str, dict]:
|
||||
if self.api_mode == "completion":
|
||||
url = f"{self.base_url}/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"prompt": f"{parts.system}\n\n{parts.story}",
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
else:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": parts.system},
|
||||
{"role": "user", "content": parts.story + CHAT_CONTINUE_HINT},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
return url, body
|
||||
|
||||
@staticmethod
|
||||
def _extract_chunk(payload: dict) -> str:
|
||||
choices = payload.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
choice = choices[0]
|
||||
# A chat stream uses `delta.content`, and a completion stream uses
|
||||
# `text`. The non-stream fallbacks are `message.content` and `text`.
|
||||
delta = choice.get("delta") or {}
|
||||
return (
|
||||
delta.get("content")
|
||||
or choice.get("text")
|
||||
or (choice.get("message") or {}).get("content")
|
||||
or ""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_reasoning(payload: dict) -> str:
|
||||
"""Returns a reasoning model's thinking text.
|
||||
|
||||
OpenRouter normalizes it to `reasoning`, and DeepSeek-style servers use
|
||||
`reasoning_content`.
|
||||
"""
|
||||
choices = payload.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
choice = choices[0]
|
||||
delta = choice.get("delta") or {}
|
||||
message = choice.get("message") or {}
|
||||
return (
|
||||
delta.get("reasoning")
|
||||
or delta.get("reasoning_content")
|
||||
or message.get("reasoning")
|
||||
or message.get("reasoning_content")
|
||||
or ""
|
||||
)
|
||||
|
||||
async def generate(
|
||||
self, parts: PromptParts, *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Yields `("text", chunk)` and `("reasoning", chunk)` pairs."""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
url, body = self._request(parts, temperature, max_tokens)
|
||||
async for event in self._stream(url, body):
|
||||
yield event
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict], *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Runs a plain multi-turn chat, with no story framing and no context
|
||||
assembly.
|
||||
|
||||
The method sends `[{"role", "content"}, ...]` straight to the endpoint.
|
||||
The AI Chat scratchpad uses it, and the turn engine uses `generate()`.
|
||||
"""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
if self.api_mode == "completion":
|
||||
url = f"{self.base_url}/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"prompt": flatten_messages(messages),
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
else:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
async for event in self._stream(url, body):
|
||||
yield event
|
||||
|
||||
async def _stream(self, url: str, body: dict) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Runs the shared SSE request for `generate()` and `chat()`.
|
||||
|
||||
The method POSTs a streaming request, yields `("text", chunk)` and
|
||||
`("reasoning", chunk)` pairs, and logs the exchange.
|
||||
"""
|
||||
# Re-checked on every request, not only when the endpoint was saved: a
|
||||
# hostname that resolved to a LAN address yesterday can resolve
|
||||
# somewhere else today, and a database row can be edited by hand.
|
||||
reason = endpoints.rejection_reason(url)
|
||||
if reason:
|
||||
raise ProviderError(f"This endpoint can't be used — {reason}.")
|
||||
log = debuglog.start_entry(url, self.model, body)
|
||||
received: list[str] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self._timeout(), verify=tlstrust.ssl_context()
|
||||
) as client:
|
||||
async with client.stream("POST", url, json=body, headers=self._headers()) as resp:
|
||||
if resp.status_code != 200:
|
||||
detail = (await resp.aread()).decode(errors="replace")[:500]
|
||||
raise ProviderError(self._friendly_http_error(resp.status_code, detail))
|
||||
# Some servers ignore `stream=true` and return one plain
|
||||
# JSON body, so buffer the non-SSE lines to fall back to.
|
||||
saw_sse = False
|
||||
raw_lines: list[str] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
if not saw_sse:
|
||||
raw_lines.append(line)
|
||||
continue
|
||||
saw_sse = True
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
debuglog.finish_entry(
|
||||
log, response="".join(received), usage=self.last_usage
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
self._record_usage(payload)
|
||||
reasoning = self._extract_reasoning(payload)
|
||||
if reasoning:
|
||||
yield "reasoning", reasoning
|
||||
chunk = self._extract_chunk(payload)
|
||||
if chunk:
|
||||
received.append(chunk)
|
||||
yield "text", chunk
|
||||
if not saw_sse:
|
||||
body_text = "\n".join(raw_lines).strip()
|
||||
try:
|
||||
payload = json.loads(body_text)
|
||||
except ValueError:
|
||||
raise ProviderError(
|
||||
"AI endpoint returned neither an SSE stream nor JSON: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
self._record_usage(payload)
|
||||
reasoning = self._extract_reasoning(payload)
|
||||
if reasoning:
|
||||
yield "reasoning", reasoning
|
||||
chunk = self._extract_chunk(payload)
|
||||
if chunk:
|
||||
received.append(chunk)
|
||||
yield "text", chunk
|
||||
if not received:
|
||||
raise ProviderError(
|
||||
"AI endpoint returned a response with no text: "
|
||||
+ body_text[:200]
|
||||
)
|
||||
debuglog.finish_entry(log, response="".join(received), usage=self.last_usage)
|
||||
except httpx.ConnectError as exc:
|
||||
error = f"Could not connect to {self.base_url} — is the AI server running?"
|
||||
debuglog.finish_entry(log, response="".join(received), error=error)
|
||||
raise ProviderError(error) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
debuglog.finish_entry(log, response="".join(received), error="Timed out")
|
||||
raise ProviderError("The AI endpoint timed out.") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
debuglog.finish_entry(log, response="".join(received), error=str(exc))
|
||||
raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc
|
||||
except (ProviderError, GeneratorExit, BaseException) as exc:
|
||||
status = "cancelled" if isinstance(exc, GeneratorExit) else str(exc)
|
||||
debuglog.finish_entry(log, response="".join(received), error=status)
|
||||
raise
|
||||
|
||||
async def complete(
|
||||
self, system: str, user: str, *, temperature: float = 0.3, max_tokens: int = 400
|
||||
) -> str:
|
||||
"""Runs a single non-streaming completion, for background calls such as
|
||||
summarization.
|
||||
|
||||
Unlike `generate()`, this adds no story-continuation framing.
|
||||
"""
|
||||
if not self.model:
|
||||
raise ProviderError("No model configured — set one in Settings.")
|
||||
if self.api_mode == "completion":
|
||||
url = f"{self.base_url}/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"prompt": f"{system}\n\n{user}",
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
else:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
# Same check as `_stream`: every outbound request re-tests the
|
||||
# endpoint, so no path reaches an address the policy refuses.
|
||||
reason = endpoints.rejection_reason(url)
|
||||
if reason:
|
||||
raise ProviderError(f"This endpoint can't be used — {reason}.")
|
||||
log = debuglog.start_entry(url, self.model, body)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self._timeout(), verify=tlstrust.ssl_context()
|
||||
) as client:
|
||||
resp = await client.post(url, json=body, headers=self._headers())
|
||||
except httpx.HTTPError as exc:
|
||||
debuglog.finish_entry(log, error=str(exc))
|
||||
raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc
|
||||
if resp.status_code != 200:
|
||||
error = self._friendly_http_error(resp.status_code, resp.text[:500])
|
||||
debuglog.finish_entry(log, error=error)
|
||||
raise ProviderError(error)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
debuglog.finish_entry(log, error="Invalid JSON response")
|
||||
raise ProviderError("AI endpoint returned invalid JSON.") from exc
|
||||
self._record_usage(payload)
|
||||
text = self._extract_chunk(payload)
|
||||
debuglog.finish_entry(log, response=text, usage=self.last_usage)
|
||||
return text.strip()
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""POSTs to /v1/embeddings. Here `self.model` is the embedding model."""
|
||||
if not self.model:
|
||||
raise ProviderError("No embedding model configured — set one in Settings.")
|
||||
url = f"{self.base_url}/embeddings"
|
||||
body = {"model": self.model, "input": texts}
|
||||
# Same check as `_stream`: every outbound request re-tests the
|
||||
# endpoint, so no path reaches an address the policy refuses.
|
||||
reason = endpoints.rejection_reason(url)
|
||||
if reason:
|
||||
raise ProviderError(f"This endpoint can't be used — {reason}.")
|
||||
log = debuglog.start_entry(url, self.model, body)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self._timeout(EMBED_READ_TIMEOUT), verify=tlstrust.ssl_context()
|
||||
) as client:
|
||||
resp = await client.post(url, json=body, headers=self._headers())
|
||||
except httpx.HTTPError as exc:
|
||||
debuglog.finish_entry(log, error=str(exc))
|
||||
raise ProviderError(f"Embedding request failed: {exc}") from exc
|
||||
if resp.status_code != 200:
|
||||
error = self._friendly_http_error(resp.status_code, resp.text[:500])
|
||||
debuglog.finish_entry(log, error=error)
|
||||
raise ProviderError(error)
|
||||
try:
|
||||
data = resp.json().get("data", [])
|
||||
vectors = [item["embedding"] for item in sorted(data, key=lambda d: d.get("index", 0))]
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
debuglog.finish_entry(log, error="Malformed embeddings response")
|
||||
raise ProviderError("AI endpoint returned malformed embeddings.") from exc
|
||||
if len(vectors) != len(texts):
|
||||
debuglog.finish_entry(log, error="Embedding count mismatch")
|
||||
raise ProviderError("AI endpoint returned the wrong number of embeddings.")
|
||||
debuglog.finish_entry(log, response=f"{len(vectors)} vectors × {len(vectors[0]) if vectors else 0} dims")
|
||||
return vectors
|
||||
|
||||
def _friendly_http_error(self, status: int, detail: str) -> str:
|
||||
if status == 401:
|
||||
return "Authentication failed — check your API key in Settings."
|
||||
if status == 404:
|
||||
return (
|
||||
f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that "
|
||||
f"model '{self.model}' exists. {detail}"
|
||||
)
|
||||
if status == 429:
|
||||
# OpenRouter's shared free tier has a per-day cap. Distinguish it
|
||||
# from a short-term burst limit, so the message tells the reader what
|
||||
# to do.
|
||||
if "free-models-per-day" in detail:
|
||||
return (
|
||||
"The free demo has hit its daily request limit (resets at "
|
||||
"00:00 UTC). Please try again later."
|
||||
)
|
||||
return "The AI is getting too many requests right now — wait a moment and try again."
|
||||
return f"AI endpoint returned HTTP {status}: {detail}"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The adventure endpoints, split across one module per group of routes.
|
||||
|
||||
Importing this package registers every route, because each endpoint module
|
||||
decorates its handlers with the shared `router` from `deps`. The import order
|
||||
below is the order FastAPI matches paths in. No two routes here shadow each
|
||||
other, so the order is for reading rather than for correctness.
|
||||
|
||||
Read the modules in this order to follow a turn from end to end:
|
||||
|
||||
deps the router and the ownership check every endpoint runs
|
||||
paging reading a window of actions and numbering its attempts
|
||||
nodes moving around the story tree
|
||||
turns playing a turn, and the lock that allows only one at a time
|
||||
takes retries and the attempts that collect at one coordinate
|
||||
branches where a story splits
|
||||
|
||||
What this package re-exports, and what it deliberately does not:
|
||||
|
||||
Pure helpers and handlers are re-exported below, so `adventures.ACTION_PAGE` and
|
||||
`adventures.undo_turn` keep working. The names a test replaces are not, and you
|
||||
must reach those as `adventures.turns.<name>`. Rebinding a re-exported alias
|
||||
changes only the alias, so patching `adventures.generate_turn` would leave every
|
||||
caller reading the original. Leaving those names off raises `AttributeError`
|
||||
instead, which is the failure you want.
|
||||
"""
|
||||
from .deps import router
|
||||
|
||||
# Imported for the side effect of registering routes. The names are unused here.
|
||||
from . import ( # noqa: F401
|
||||
crud,
|
||||
turns,
|
||||
takes,
|
||||
branches,
|
||||
bundle_io,
|
||||
refresh,
|
||||
insights,
|
||||
memories,
|
||||
actions,
|
||||
)
|
||||
from ... import limits # noqa: F401 `adventures.limits` is patched by tests.
|
||||
from .crud import SNIPPET_MAX, _snippet
|
||||
from .paging import ACTION_PAGE
|
||||
from .takes import retry_action, undo_turn
|
||||
from .turns import world_delta_of
|
||||
|
||||
__all__ = [
|
||||
"ACTION_PAGE",
|
||||
"SNIPPET_MAX",
|
||||
"_snippet",
|
||||
"limits",
|
||||
"retry_action",
|
||||
"router",
|
||||
"undo_turn",
|
||||
"world_delta_of",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Reading, editing, and deleting individual actions.
|
||||
|
||||
`list_actions` pages through the current branch. The edit and delete endpoints
|
||||
change one node, and deleting one removes the whole attempt group at its
|
||||
coordinate through `nodes.delete_turn`.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import attempts, models, schemas, tree
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .nodes import db_tip, delete_turn
|
||||
from .paging import ACTION_PAGE, action_window, annotate_takes
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage)
|
||||
def list_actions(
|
||||
before_id: int | None = None,
|
||||
limit: int = ACTION_PAGE,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns a page of the story, working backwards from the newest action.
|
||||
|
||||
`before_id` is the oldest action the caller already holds, so scrolling up
|
||||
asks for what comes before it. Omit `before_id` for the newest window. See
|
||||
`action_window` for why this anchors on a row rather than an offset.
|
||||
"""
|
||||
limit = max(1, min(limit, ACTION_PAGE * 4))
|
||||
actions, total, has_more = action_window(
|
||||
db, adventure, before_id=before_id, limit=limit
|
||||
)
|
||||
return schemas.ActionPage(
|
||||
actions=[
|
||||
schemas.ActionOut.model_validate(a)
|
||||
for a in annotate_takes(db, adventure.id, actions)
|
||||
],
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut)
|
||||
def update_action(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
payload: schemas.ActionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
# One row holds one text. Nothing mirrors it now, so nothing else has to be
|
||||
# updated. The edit used to have to be written into the live variant entry
|
||||
# as well, or paging away and back reverted it.
|
||||
action.text = payload.text
|
||||
db.commit()
|
||||
return action
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
|
||||
def delete_action(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
# The lock is held for the same reason undo holds it: this endpoint puts
|
||||
# the shared state back, and a turn that is still generating is about to
|
||||
# write it.
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
# This works like undo. The turn is deleted with all of its attempts,
|
||||
# and whatever it produced is withdrawn. The marks are depths, and a
|
||||
# depth does not move when an action before it is deleted.
|
||||
delete_turn(db, adventure, action)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
# Deleting the newest action moves the tip. Deleting an action in the
|
||||
# middle leaves a gap in the depths, which is intended. See
|
||||
# `_backfill_tree`.
|
||||
tree.refresh_head(db, adventure)
|
||||
# The script state and the world state belong to the adventure, not to
|
||||
# the node, so deleting the node does not take back what it did to
|
||||
# them. Put them back to what the story now ends with, which is the
|
||||
# same restore a branch switch does.
|
||||
#
|
||||
# The world state carries the cooldown clock in `_meta.last_changed`,
|
||||
# and that clock is a depth. Leaving it set marked the deleted turn's
|
||||
# changes as having happened at the depth the next turn is played at,
|
||||
# so the referee refused them as changed too recently — on a turn the
|
||||
# story no longer contains. Deleting a middle action restores the tip's
|
||||
# own outcome, which is the state the adventure is already in.
|
||||
attempts.restore_state(adventure, db_tip(db, adventure))
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""The branch endpoints: list, rename, delete, and switch.
|
||||
|
||||
Attempts accumulate at the tip as siblings, which costs nothing. An attempt
|
||||
becomes a branch only when the player continues the story from it and leaves the
|
||||
line that moved past it. That is the same event as playing a turn past the
|
||||
attempt. Creating the branch then rather than on the next turn means a branch
|
||||
exists only for a divergence someone built on, and the line being left is not
|
||||
modified.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import attempts, models, schemas, tree
|
||||
from ...context import cursors
|
||||
from ...context import lineage
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .nodes import db_tip
|
||||
from .paging import current_window
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/branches", response_model=list[schemas.BranchOut])
|
||||
def list_branches(
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns every branch of the adventure and where each one leaves its parent.
|
||||
|
||||
A tree view is drawn from this shape. `fork_depth` gives the depth where the
|
||||
line splits off, and `depth` gives the depth where it currently ends. The
|
||||
whole picture costs one query over `branches` plus one grouped query over
|
||||
`actions`, never one query per branch, so a view of a hundred forks does not
|
||||
cost a hundred round trips.
|
||||
"""
|
||||
branches = (
|
||||
db.query(models.Branch)
|
||||
.filter(models.Branch.adventure_id == adventure.id)
|
||||
.order_by(models.Branch.id)
|
||||
.all()
|
||||
)
|
||||
owned = {
|
||||
branch_id: (count, tip)
|
||||
for branch_id, count, tip in db.query(
|
||||
models.Action.branch_id,
|
||||
func.count(models.Action.id),
|
||||
func.max(models.Action.depth),
|
||||
)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.live.is_(True),
|
||||
)
|
||||
.group_by(models.Action.branch_id)
|
||||
.all()
|
||||
}
|
||||
out = []
|
||||
for branch in branches:
|
||||
count, tip = owned.get(branch.id, (0, None))
|
||||
out.append(schemas.BranchOut(
|
||||
id=branch.id,
|
||||
parent_branch_id=branch.parent_branch_id,
|
||||
fork_depth=branch.fork_depth,
|
||||
# A branch with no nodes of its own sits at its fork point. That
|
||||
# node is the last one its story contains. The node is borrowed, but
|
||||
# it is still the tip. This matches `tree.refresh_head`.
|
||||
depth=tip if tip is not None else (
|
||||
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
||||
),
|
||||
own_actions=count,
|
||||
is_head=(branch.id == adventure.head_branch_id),
|
||||
name=branch.name,
|
||||
created_at=branch.created_at,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def get_branch_or_404(
|
||||
adventure: models.Adventure, branch_id: int, db: Session
|
||||
) -> models.Branch:
|
||||
"""Returns one branch of this adventure.
|
||||
|
||||
If the branch belongs to another adventure, the 404 does not confirm that the
|
||||
branch exists.
|
||||
"""
|
||||
branch = db.get(models.Branch, branch_id)
|
||||
if branch is None or branch.adventure_id != adventure.id:
|
||||
raise HTTPException(404, "Branch not found")
|
||||
return branch
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut
|
||||
)
|
||||
def rename_branch(
|
||||
branch_id: int,
|
||||
payload: schemas.BranchRename,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Names a branch, or clears the name to leave it unnamed.
|
||||
|
||||
A blank string means the same thing as `null`. A name of only spaces is not a
|
||||
name anyone chose, and storing one gives the client an empty label to draw
|
||||
instead of the fork depth.
|
||||
"""
|
||||
branch = get_branch_or_404(adventure, branch_id, db)
|
||||
name = (payload.name or "").strip()
|
||||
branch.name = name or None
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(branch)
|
||||
# Read both numbers in one pass, and count them the way `list_branches`
|
||||
# counts them, as live rows on this branch. A renamed branch is the same
|
||||
# branch, so this response has to match the row the panel would fetch.
|
||||
tip, own = (
|
||||
db.query(func.max(models.Action.depth), func.count(models.Action.id))
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.branch_id == branch.id,
|
||||
models.Action.live.is_(True),
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return schemas.BranchOut(
|
||||
id=branch.id,
|
||||
parent_branch_id=branch.parent_branch_id,
|
||||
fork_depth=branch.fork_depth,
|
||||
depth=tip if tip is not None else (
|
||||
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
||||
),
|
||||
own_actions=own,
|
||||
is_head=(branch.id == adventure.head_branch_id),
|
||||
name=branch.name,
|
||||
created_at=branch.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204)
|
||||
def delete_branch(
|
||||
adventure_id: int,
|
||||
branch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Deletes a branch and everything forked from it.
|
||||
|
||||
Nothing prunes the tree automatically, so this endpoint is what keeps a
|
||||
heavily retried adventure from growing without bound. That is why it ships
|
||||
with the view that first lets anyone create a fork rather than after it.
|
||||
|
||||
Two kinds of branch cannot be deleted. The root cannot, because it holds the
|
||||
turns every other branch borrows, so deleting it deletes the whole story. The
|
||||
branch currently being read cannot, and neither can any branch it was forked
|
||||
from, because the cascade would remove the head under the player and leave
|
||||
`head_branch_id` dangling. Switch branches first.
|
||||
|
||||
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
|
||||
cascade on `branches.parent_branch_id`, so the delete is a single statement
|
||||
however deep the subtree is.
|
||||
"""
|
||||
branch = get_branch_or_404(adventure, branch_id, db)
|
||||
if branch.parent_branch_id is None:
|
||||
raise HTTPException(
|
||||
400, "This is the story's first branch — deleting it would delete "
|
||||
"the adventure. Delete the adventure itself instead.",
|
||||
)
|
||||
head = db.get(models.Branch, adventure.head_branch_id)
|
||||
# The head's lineage lists itself and every branch it borrows from, so one
|
||||
# membership test covers both the branch being read and any branch forked
|
||||
# from it.
|
||||
if head is not None and branch.id in {
|
||||
entry_id for entry_id, _ in lineage.entries_of(head)
|
||||
}:
|
||||
raise HTTPException(
|
||||
400, "You are reading this branch, or one forked from it. Switch to "
|
||||
"another branch first.",
|
||||
)
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
# Collect the subtree before the delete, because afterwards there is no
|
||||
# way to ask which branches were removed. A cursor left pointing at a
|
||||
# deleted branch is harmless on Postgres, which never reuses ids, but it
|
||||
# is a bug on SQLite, where the next fork can receive the id that was
|
||||
# just freed. A stale anchor then resolves onto a branch it never saw.
|
||||
doomed = _branch_subtree(db, adventure, branch)
|
||||
for cursor in cursors.ALL:
|
||||
stored_branch, _ = cursor.stored(adventure)
|
||||
if stored_branch in doomed:
|
||||
cursor.clear(adventure)
|
||||
# The deleted branch's memories are deleted with it, and their cached
|
||||
# vectors drop out of the catalogue on the next read, so no
|
||||
# invalidation call is needed. See the note on the `memorybank` cache.
|
||||
db.delete(branch)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
def _branch_subtree(
|
||||
db: Session, adventure: models.Adventure, root: models.Branch
|
||||
) -> set[int]:
|
||||
"""Returns `root` and every branch descended from it, following parent pointers.
|
||||
|
||||
The walk runs over the adventure's own branch rows rather than one query per
|
||||
level. An adventure has few branches, so the walk costs one round trip, and a
|
||||
recursive CTE would have to be written twice for the two dialects this
|
||||
codebase supports.
|
||||
"""
|
||||
children: dict[int | None, list[int]] = {}
|
||||
for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter(
|
||||
models.Branch.adventure_id == adventure.id
|
||||
):
|
||||
children.setdefault(parent, []).append(bid)
|
||||
found: set[int] = set()
|
||||
stack = [root.id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if current in found:
|
||||
continue
|
||||
found.add(current)
|
||||
stack.extend(children.get(current, ()))
|
||||
return found
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage
|
||||
)
|
||||
def switch_branch(
|
||||
adventure_id: int,
|
||||
branch_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Reads and plays a different branch of the story.
|
||||
|
||||
No row is copied and no row is rewritten. The head pointer moves, and the
|
||||
shared script state and world state are restored to what that branch's tip
|
||||
left behind. The restore is what makes a switch safe. Both states are stored
|
||||
per adventure, so a branch that did not restore them would be played with
|
||||
another branch's numbers, including the world-state cooldown clock inside the
|
||||
snapshot.
|
||||
"""
|
||||
branch = db.get(models.Branch, branch_id)
|
||||
if branch is None or branch.adventure_id != adventure.id:
|
||||
raise HTTPException(404, "Branch not found")
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
adventure.head_branch_id = branch.id
|
||||
tree.refresh_head(db, adventure)
|
||||
attempts.restore_state(adventure, db_tip(db, adventure))
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return current_window(db, adventure)
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Exporting an adventure to a bundle, and importing one back.
|
||||
|
||||
`app/bundle.py` owns the format and the version handling. These two endpoints
|
||||
only check ownership and hand the work over.
|
||||
"""
|
||||
|
||||
from fastapi import Body, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import bundle, limits, models, schemas
|
||||
from ...database import get_db
|
||||
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/export")
|
||||
def export_adventure(
|
||||
db: Session = Depends(get_db),
|
||||
adv: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns a full backup: plot components, story cards, scripts, state, and tree.
|
||||
|
||||
`app/bundle.py` owns the format, in both of its versions. A backup outlives
|
||||
the schema, so no call site decides anything about its shape.
|
||||
"""
|
||||
return bundle.export(db, adv)
|
||||
|
||||
|
||||
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
|
||||
def import_adventure(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
):
|
||||
version = bundle.check_format(payload)
|
||||
limits.check_row_cap("adventures", db, user)
|
||||
limits.check_bundle_lists(
|
||||
story_cards=payload.get("storyCards"),
|
||||
memories=payload.get("memories"),
|
||||
actions=payload.get("actions"),
|
||||
branches=payload.get("branches"),
|
||||
)
|
||||
# Check the tree before the adventure row exists, so that an inconsistent
|
||||
# file returns a 400 rather than leaving a half-imported adventure with a
|
||||
# gap in its story.
|
||||
story = bundle.plan(payload, version)
|
||||
# Count again, this time over what is written. The check above reads the
|
||||
# file's own lists, and in a v1 file one turn is one entry that carries its
|
||||
# retries in a `variants` array. `plan()` expands that into one row per
|
||||
# attempt, because SP4 made every attempt a node. A file of 5,000 turns with
|
||||
# ten attempts each therefore passes a 5,000-action cap and writes 50,000
|
||||
# rows, well inside the 20 MB body limit. `plan()` has no side effects and
|
||||
# the adventure does not exist yet, so this check costs only the planning.
|
||||
limits.check_bundle_lists(
|
||||
actions=story["nodes"],
|
||||
memories=story["memories"],
|
||||
branches=story["branches"],
|
||||
)
|
||||
|
||||
adventure = bundle.materialize(db, payload, story, user.id)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
# This is not a funnel step. A returning player imports a bundle, so it
|
||||
# says nothing about how far a first-time visitor got. It is counted anyway,
|
||||
# because it is the clearest evidence that anyone uses the export format.
|
||||
return adventure
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Listing, creating, reading, renaming, and deleting adventures.
|
||||
|
||||
The world-state and script-state readers are here too, because they report an
|
||||
adventure's stored state rather than play a turn.
|
||||
"""
|
||||
|
||||
from fastapi import Body, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
from ... import attempts, images, limits, memorybank, models, schemas, tree, worldstate
|
||||
from ...database import get_db
|
||||
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .paging import action_window, annotate_takes
|
||||
from .scenario_text import fill_placeholders, scenario_card_specs
|
||||
|
||||
|
||||
# How many characters of the last narration a Continue card shows. The limit is
|
||||
# long enough to re-establish the scene and short enough to keep the card
|
||||
# small.
|
||||
SNIPPET_MAX = 220
|
||||
|
||||
|
||||
def _snippet(text: str) -> str:
|
||||
"""Condenses stored action text into a single line for a card."""
|
||||
# `turns._generate_turn` strips the world-state block before storing AI text,
|
||||
# so this function only has to normalize whitespace.
|
||||
collapsed = " ".join((text or "").split())
|
||||
if len(collapsed) <= SNIPPET_MAX:
|
||||
return collapsed
|
||||
# Cut at a word boundary rather than mid-word. CSS adds the ellipsis.
|
||||
cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0]
|
||||
return f"{cut}…"
|
||||
|
||||
|
||||
# Action types that read as narration. `start` is the scenario's opening prompt,
|
||||
# which is the only text a newly created adventure has. Without `start`, a new
|
||||
# story's card would show no text at all. `do` and `say` are excluded because
|
||||
# the card quotes the story's voice rather than the player's.
|
||||
NARRATION_TYPES = ("ai", "story", "start")
|
||||
|
||||
|
||||
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
|
||||
"""Maps each adventure id to the text of its most recent narrated action.
|
||||
|
||||
This runs one window-function query rather than one lookup per adventure, so
|
||||
the list endpoint makes a fixed number of round trips.
|
||||
|
||||
The query is scoped by head branch rather than by the full lineage, and this
|
||||
is the only place in the codebase that does so. A lineage clause per
|
||||
adventure would add a hundred OR terms to the index screen's query to select
|
||||
one row each. The two scopes differ only for a branch with no nodes of its
|
||||
own, and playing a turn onto a branch is what creates it, so that state does
|
||||
not occur. An adventure with no branch has no story to quote.
|
||||
"""
|
||||
branch_ids = [b for b in head_branches.values() if b is not None]
|
||||
if not branch_ids:
|
||||
return {}
|
||||
ranked = (
|
||||
db.query(
|
||||
models.Action.adventure_id.label("adventure_id"),
|
||||
models.Action.text.label("text"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=models.Action.adventure_id,
|
||||
order_by=(models.Action.depth.desc(), models.Action.id.desc()),
|
||||
)
|
||||
.label("rank"),
|
||||
)
|
||||
.filter(
|
||||
models.Action.adventure_id.in_(list(head_branches)),
|
||||
models.Action.branch_id.in_(branch_ids),
|
||||
models.Action.type.in_(NARRATION_TYPES),
|
||||
# Sibling attempts share a depth, and the newest has the highest
|
||||
# id. Without this filter the snippet quotes the attempt written
|
||||
# last rather than the one the story tells. After you switch back to
|
||||
# an earlier attempt, the index screen would quote the discarded one
|
||||
# and disagree with the story on screen.
|
||||
models.Action.live.is_(True),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all()
|
||||
return {adventure_id: text for adventure_id, text in rows}
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.AdventureListItem])
|
||||
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
|
||||
# Select named columns rather than the whole Adventure entity. The entity is
|
||||
# sixteen columns wide and includes `script_state`, `world_state`,
|
||||
# `placeholders`, `story_summary`, `memory`, `authors_note`, and
|
||||
# `ai_instructions`. That is about 15 kB per row in production, fetched once
|
||||
# per adventure on every index load, and this screen uses none of it. Naming
|
||||
# the columns also means a wide column added to Adventure later has to opt
|
||||
# in to being listed here.
|
||||
rows = (
|
||||
db.query(
|
||||
models.Adventure.id,
|
||||
models.Adventure.scenario_id,
|
||||
models.Adventure.title,
|
||||
models.Adventure.updated_at,
|
||||
models.Adventure.head_branch_id,
|
||||
func.count(models.Action.id),
|
||||
models.Scenario.title,
|
||||
models.Scenario.image,
|
||||
models.Scenario.icon,
|
||||
models.Scenario.updated_at,
|
||||
)
|
||||
.outerjoin(models.Action)
|
||||
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
|
||||
.filter(models.Adventure.user_id == user.id)
|
||||
# Group by both primary keys. Postgres requires every selected column
|
||||
# to be grouped or aggregated. The Adventure columns are covered by its
|
||||
# own grouped primary key, but the Scenario columns come from a joined
|
||||
# table and have to be listed as well. SQLite accepts the shorter form,
|
||||
# and Postgres rejects it.
|
||||
.group_by(
|
||||
models.Adventure.id,
|
||||
models.Scenario.id,
|
||||
models.Scenario.title,
|
||||
models.Scenario.image,
|
||||
models.Scenario.icon,
|
||||
models.Scenario.updated_at,
|
||||
)
|
||||
.order_by(models.Adventure.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
narration = _latest_narration(db, {row[0]: row[4] for row in rows})
|
||||
return [
|
||||
schemas.AdventureListItem(
|
||||
id=adv_id,
|
||||
scenario_id=scenario_id,
|
||||
scenario_title=scenario_title,
|
||||
title=title,
|
||||
updated_at=updated_at,
|
||||
action_count=count,
|
||||
snippet=_snippet(narration.get(adv_id, "")),
|
||||
# The art belongs to the scenario, so the cache-busting stamp uses
|
||||
# the scenario's `updated_at`, not the adventure's.
|
||||
image_url=images.public_url(scenario_id, image or "", scenario_updated),
|
||||
icon=icon or "",
|
||||
)
|
||||
# `count` counts every action in the adventure, not only the ones on
|
||||
# the path. With one branch the two numbers are equal. After forking
|
||||
# ships, the index screen overstates a story that has sibling branches.
|
||||
# The fix belongs to SP5, which is where a fork can first exist.
|
||||
for (adv_id, scenario_id, title, updated_at, _head_branch_id, count,
|
||||
scenario_title, image, icon, scenario_updated) in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.AdventureOut, status_code=201)
|
||||
def create_adventure(
|
||||
payload: schemas.AdventureCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
):
|
||||
limits.check_row_cap("adventures", db, user)
|
||||
scenario = None
|
||||
if payload.scenario_id is not None:
|
||||
scenario = db.get(models.Scenario, payload.scenario_id)
|
||||
# A scenario is playable if the user owns it or if it is public.
|
||||
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
||||
raise HTTPException(404, "Scenario not found")
|
||||
|
||||
values = payload.placeholders
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id,
|
||||
scenario_id=scenario.id if scenario else None,
|
||||
title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
|
||||
memory=fill_placeholders(scenario.memory, values) if scenario else "",
|
||||
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
|
||||
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
|
||||
# Phase 12: seed the live RPG state from the scenario's template.
|
||||
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
|
||||
# Stored so that a later "Update from scenario" fills the copied text
|
||||
# with the same answers instead of inserting literal `${...}` tokens.
|
||||
placeholders=dict(values),
|
||||
# Phase 18: the persona the player named before starting. It belongs to
|
||||
# the adventure rather than the scenario, so nothing is copied here and
|
||||
# "Update from scenario" never touches it.
|
||||
persona_name=payload.persona_name.strip(),
|
||||
persona_pronouns=payload.persona_pronouns.strip(),
|
||||
persona_desc=payload.persona_desc.strip(),
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
# Give every adventure a story tree as soon as it exists, before anything
|
||||
# is played onto it. Otherwise the tree code has to tolerate a NULL head
|
||||
# everywhere, which buys nothing.
|
||||
tree.head_branch(db, adventure)
|
||||
|
||||
if scenario:
|
||||
for ref, spec in scenario_card_specs(scenario, values).items():
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
||||
if scenario.prompt.strip():
|
||||
opening = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="start",
|
||||
text=fill_placeholders(scenario.prompt, values),
|
||||
)
|
||||
# Record the starting state on the opening node, so undoing or
|
||||
# retrying the first turn has a state to roll back to.
|
||||
attempts.snapshot_outcome(adventure, opening)
|
||||
tree.place_action(db, adventure, opening)
|
||||
db.add(opening)
|
||||
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
|
||||
|
||||
@router.get("/{adventure_id}", response_model=schemas.AdventureOut)
|
||||
def get_adventure(
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns the adventure and the newest window of its story.
|
||||
|
||||
`actions` holds the last `ACTION_PAGE` actions, not all of them.
|
||||
`action_count` reports the real total, so the reader knows that more actions
|
||||
exist above. `GET /{id}/actions` serves the older pages as the reader
|
||||
scrolls up.
|
||||
"""
|
||||
actions, total, _ = action_window(db, adventure)
|
||||
# Annotate before handing over the window. This path serializes through the
|
||||
# relationship rather than building `ActionOut` itself, so the pager numbers
|
||||
# have to be on the rows before Pydantic reads them.
|
||||
annotate_takes(db, adventure.id, actions)
|
||||
# Attach the window as if the relationship had loaded it.
|
||||
# `set_committed_value` is the only safe way to do this. Assigning
|
||||
# `adventure.actions = [...]` marks the collection dirty, and the
|
||||
# relationship cascades delete-orphan, so the next flush deletes every
|
||||
# action outside the window. `set_committed_value` records the rows as the
|
||||
# already-loaded, unmodified value, so serialization triggers no lazy load
|
||||
# and leaves nothing pending.
|
||||
set_committed_value(adventure, "actions", actions)
|
||||
out = schemas.AdventureOut.model_validate(adventure)
|
||||
out.action_count = total
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/world-state")
|
||||
def get_world_state(
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns the live RPG world state and the scenario's `stat_schema`.
|
||||
|
||||
The play view uses both to render the character sheet and the milestones.
|
||||
`schema` is null when the adventure has no RPG layer.
|
||||
"""
|
||||
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
return {
|
||||
"state": state,
|
||||
"schema": schema if worldstate.has_schema(schema) else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{adventure_id}/world-state")
|
||||
def override_world_state(
|
||||
overrides: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Edits the live RPG values directly, as a manual correction rather than a turn.
|
||||
|
||||
`overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and
|
||||
`milestones.y` to their new absolute values. The endpoint rejects unknown
|
||||
paths and wrong types one at a time, and applies the rest.
|
||||
"""
|
||||
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
if not worldstate.has_schema(schema):
|
||||
raise HTTPException(400, "This adventure has no RPG world-state layer")
|
||||
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
new_state, report = worldstate.apply_override(state, schema, overrides)
|
||||
adventure.world_state = new_state
|
||||
db.commit()
|
||||
return {"state": new_state, "report": report}
|
||||
|
||||
|
||||
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
|
||||
def update_adventure(
|
||||
payload: schemas.AdventureUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(adventure, field, value)
|
||||
db.commit()
|
||||
return adventure
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}", status_code=204)
|
||||
def delete_adventure(
|
||||
adventure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
db.delete(adventure)
|
||||
db.commit()
|
||||
# No later request reads this adventure's vectors, so drop them now. The
|
||||
# cache would otherwise hold them until the process restarted.
|
||||
memorybank.forget_cached_vectors(adventure_id)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""The router every endpoint module registers on, and the dependencies they share.
|
||||
|
||||
This module imports nothing else in the package. Keeping it at the bottom of the
|
||||
import graph is what lets each endpoint module import the router without
|
||||
importing its siblings.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import auth, models
|
||||
from ...database import get_db
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/adventures", tags=["adventures"])
|
||||
|
||||
CurrentUser = Depends(auth.get_current_user)
|
||||
|
||||
|
||||
def get_adventure_or_404(
|
||||
adventure_id: int, db: Session, user: models.User
|
||||
) -> models.Adventure:
|
||||
adventure = db.get(models.Adventure, adventure_id)
|
||||
if adventure is None or adventure.user_id != user.id:
|
||||
raise HTTPException(404, "Adventure not found")
|
||||
return adventure
|
||||
|
||||
|
||||
def current_adventure(
|
||||
adventure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
) -> models.Adventure:
|
||||
"""Resolves the `{adventure_id}` in the path to the caller's own adventure.
|
||||
|
||||
Declare this in a handler's signature. The ownership check then reads in the
|
||||
signature, where you look for it, and it runs before the handler body.
|
||||
|
||||
FastAPI caches a dependency for the length of one request, so the handler's
|
||||
own `db` is the same session this one loaded the adventure from.
|
||||
"""
|
||||
return get_adventure_or_404(adventure_id, db, user)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Read-only views of the context an adventure would send, or did send.
|
||||
|
||||
The dry run assembles a prompt without calling the model. The per-action endpoint
|
||||
returns the prompt a turn was actually generated from. Neither writes anything.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import memorybank, models
|
||||
from ...context import build_context
|
||||
from ...database import get_db
|
||||
from ..settings import get_settings
|
||||
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/context")
|
||||
async def dry_run_context(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns what the app would send to the AI if the player continued now."""
|
||||
settings = get_settings(db, user)
|
||||
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
|
||||
_, _, report = build_context(adventure, settings, memories)
|
||||
return report
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/actions/{action_id}/context")
|
||||
def action_context(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
if action.context_snapshot is None:
|
||||
raise HTTPException(404, "No context snapshot for this action")
|
||||
return action.context_snapshot
|
||||
@@ -0,0 +1,116 @@
|
||||
"""CRUD for the memory bank entries attached to one adventure.
|
||||
|
||||
`app/memorybank.py` owns embedding and retrieval. These endpoints only edit the
|
||||
rows.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from ... import limits, memorybank, models, schemas, tree
|
||||
from ...context import lineage
|
||||
from ...database import get_db
|
||||
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
|
||||
|
||||
# The columns `schemas.MemoryOut` renders. `embedded` is a real column and
|
||||
# belongs here. The vector it describes does not.
|
||||
MEMORY_LIST_COLUMNS = (
|
||||
models.Memory.adventure_id,
|
||||
models.Memory.text,
|
||||
models.Memory.pinned,
|
||||
models.Memory.forgotten,
|
||||
models.Memory.embedded,
|
||||
models.Memory.use_count,
|
||||
models.Memory.last_used_at,
|
||||
models.Memory.source_start,
|
||||
models.Memory.source_end,
|
||||
models.Memory.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut])
|
||||
def list_memories(
|
||||
adventure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
# Name the columns in a query rather than walk `adventure.memories`.
|
||||
# Retrieval used to walk the relationship, which is why a turn cost
|
||||
# megabytes: a relationship load returns whole entities, so it reads
|
||||
# whatever the model carries. `embedding_blob` is deferred and would stay
|
||||
# out today, so this rule is about the next wide column rather than that
|
||||
# one.
|
||||
#
|
||||
# The drawer shows the same bank the model reads. The filter uses the same
|
||||
# clause retrieval uses, so the drawer answers one question rather than two.
|
||||
# An adventure-wide list would show memories from branches this story never
|
||||
# went down, which are never retrieved, and a reader cannot tell those apart
|
||||
# from the ones in play. No memory becomes unreachable, because a memory
|
||||
# belongs to a branch: switching to that branch shows it, and deleting the
|
||||
# branch deletes its memories.
|
||||
return (
|
||||
db.query(models.Memory)
|
||||
.options(load_only(*MEMORY_LIST_COLUMNS))
|
||||
.filter(
|
||||
models.Memory.adventure_id == adventure_id,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
)
|
||||
.order_by(models.Memory.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
|
||||
def create_memory(
|
||||
payload: schemas.MemoryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Adds a memory manually. The next post-turn pass embeds it."""
|
||||
limits.check_row_cap("memories", db, user, adventure=adventure)
|
||||
if not payload.text.strip():
|
||||
raise HTTPException(400, "Memory text cannot be empty")
|
||||
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
|
||||
# No node produced this memory, so it gets a branch but no depth.
|
||||
tree.place_memory(db, adventure, memory)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
return memory
|
||||
|
||||
|
||||
@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut)
|
||||
def update_memory(
|
||||
adventure_id: int,
|
||||
memory_id: int,
|
||||
payload: schemas.MemoryUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
memory = db.get(models.Memory, memory_id)
|
||||
if memory is None or memory.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Memory not found")
|
||||
fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
|
||||
if "text" in fields and fields["text"].strip() != memory.text:
|
||||
memorybank.set_vector(memory, None) # Re-embed on the next post-turn pass.
|
||||
for field, value in fields.items():
|
||||
setattr(memory, field, value)
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204)
|
||||
def delete_memory(
|
||||
adventure_id: int,
|
||||
memory_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
memory = db.get(models.Memory, memory_id)
|
||||
if memory is None or memory.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Memory not found")
|
||||
db.delete(memory)
|
||||
db.commit()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Moving around the story tree: what is newest, what comes next, what to remove.
|
||||
|
||||
These functions answer questions about action nodes without knowing which
|
||||
endpoint asked. They do not touch the turn lock and they do not stream, so any
|
||||
module in the package can import them.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from ... import attempts, memorybank, models, tree
|
||||
from ...context import cursors
|
||||
from ...context import lineage
|
||||
|
||||
|
||||
def next_depth(adventure: models.Adventure) -> int:
|
||||
"""Returns the depth for the next node played onto this story, one past the tip.
|
||||
|
||||
A depth is a position along one story, and the branch is what makes it
|
||||
unambiguous. Two branches each hold a node at depth 4, and they are
|
||||
alternatives rather than duplicates.
|
||||
"""
|
||||
return adventure.head_depth + 1
|
||||
|
||||
|
||||
def last_action(adventure: models.Adventure, db: Session) -> models.Action | None:
|
||||
"""Returns the newest action of any kind on the story being played, or `None`.
|
||||
|
||||
This runs a query rather than reading `adventure.actions[-1]`, which loads
|
||||
the entire story to read one row. That collection also holds every branch's
|
||||
actions, so it sometimes returns a row from the wrong branch.
|
||||
"""
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _move_to_after(
|
||||
db: Session, adventure: models.Adventure, after_id: int | None
|
||||
) -> None:
|
||||
"""Moves the story to `after_id` before the turn is played.
|
||||
|
||||
This is where a branch is created (SP9). Reading an attempt that the story
|
||||
moved past changes nothing on the server. Writing below one is the first time
|
||||
the player states which line they mean, and that is when the fork happens.
|
||||
|
||||
An attempt already on the path needs no move, because the story is already
|
||||
there.
|
||||
"""
|
||||
if after_id is None:
|
||||
return
|
||||
node = db.get(models.Action, after_id)
|
||||
if node is None or node.adventure_id != adventure.id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
if node.live and lineage.path_of(db, adventure).contains(node):
|
||||
return
|
||||
if not node.live and len(attempts.group(db, node)) < 2:
|
||||
# The pager cannot reach this node, so no legitimate action put the
|
||||
# player here.
|
||||
raise HTTPException(400, "That take is not one of this turn's.")
|
||||
stand_on(db, adventure, node)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
|
||||
|
||||
def delete_turn(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> None:
|
||||
"""Removes a turn, including every attempt at it and not only the one on screen.
|
||||
|
||||
A discarded attempt is a leaf at the same coordinate, and the only way to
|
||||
reach it is through that coordinate. Leaving it behind when the turn is
|
||||
deleted orphans a row that no read can reach. Whatever the turn produced is
|
||||
withdrawn once, because a memory is attached to the coordinate rather than to
|
||||
one attempt.
|
||||
"""
|
||||
memorybank.forget_node(db, adventure, node)
|
||||
# Scoped to this node's branch (SP9). Groups span branches now, and an
|
||||
# attempt forked onto its own line belongs to another branch's story. See
|
||||
# `attempts.on_branch`.
|
||||
for attempt in attempts.on_branch(attempts.group(db, node), node):
|
||||
db.delete(attempt)
|
||||
|
||||
|
||||
def db_tip(db: Session, adventure: models.Adventure) -> models.Action | None:
|
||||
"""Returns the newest node of the story as it stands, with its outcome loaded."""
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
)
|
||||
.options(
|
||||
undefer(models.Action.state_after),
|
||||
undefer(models.Action.world_state_after),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def stand_on(
|
||||
db: Session, adventure: models.Adventure, action: models.Action
|
||||
) -> None:
|
||||
"""Makes `action` the attempt the story tells, forking only if that is needed.
|
||||
|
||||
There are two cases, and the caller does not have to know which one applies.
|
||||
While the turn is still the tip, its attempts are leaves that nothing was
|
||||
built on, so this is a switch and no branch is created. Once the story has
|
||||
moved past the turn, the line being left keeps every turn it has, so the
|
||||
attempt needs a branch of its own.
|
||||
|
||||
The fork endpoint calls this function, and so does a turn played below an
|
||||
attempt the story moved past. Both are the same operation, once as a request
|
||||
and once as a step on the way to writing (SP9).
|
||||
"""
|
||||
newest = last_action(adventure, db)
|
||||
at_the_tip = (
|
||||
newest is not None
|
||||
and newest.branch_id == action.branch_id
|
||||
and newest.depth == action.depth
|
||||
)
|
||||
if at_the_tip:
|
||||
# The story at this coordinate is about to change, so withdraw whatever
|
||||
# was derived from it. A retry does the same thing. A fork needs none of
|
||||
# this, because it leaves the coordinate and its memory where they are.
|
||||
# See `tree.fork`.
|
||||
memorybank.forget_node(db, adventure, action)
|
||||
cursors.rewind_all(adventure, action.branch_id, (action.depth or 0) - 1)
|
||||
attempts.make_live(db, adventure, action)
|
||||
else:
|
||||
tree.fork(db, adventure, action)
|
||||
attempts.restore_state(adventure, action)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Reading a window of actions, and numbering the attempts inside it.
|
||||
|
||||
Three callers build an action window: the adventure GET, the action list, and
|
||||
every endpoint that returns a page after changing the story. They read the same
|
||||
columns and apply the same numbering, so both live here.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from ... import models, schemas
|
||||
from ...context import lineage
|
||||
|
||||
|
||||
# The columns `schemas.ActionOut` renders, listed explicitly.
|
||||
#
|
||||
# `deferred=True` in `models.py` keeps the four heavy columns out of bulk reads,
|
||||
# but each new column then has to opt in to staying narrow. Both egress
|
||||
# regressions this project has had came from a column that did not opt in. This
|
||||
# tuple inverts the default: a new column costs nothing until you add it here.
|
||||
#
|
||||
# `world_delta` is listed because `ActionOut.world_changes` is computed from it.
|
||||
# Omitting it saves no bytes. It converts one bulk read into one lazy load per
|
||||
# row.
|
||||
ACTION_LIST_COLUMNS = (
|
||||
models.Action.adventure_id,
|
||||
models.Action.type,
|
||||
models.Action.text,
|
||||
models.Action.reasoning,
|
||||
models.Action.world_delta,
|
||||
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
|
||||
# would cost a lazy load, which is the cost `load_only` is here to prevent.
|
||||
# `branch_id` is listed for the same reason. The pager reads it to tell a
|
||||
# local step from a branch switch.
|
||||
models.Action.parent_id,
|
||||
models.Action.branch_id,
|
||||
models.Action.created_at,
|
||||
)
|
||||
|
||||
|
||||
# How many actions an adventure opens with, and how many arrive per scroll.
|
||||
#
|
||||
# Opening a finished adventure once fetched the whole story in one response.
|
||||
# That reached 589.5 kB for the longest story in production, and it grew as
|
||||
# stories grew. 60 actions is a few screens of reading. The common case of
|
||||
# opening a story, reading the end, and taking a turn never pages, and the worst
|
||||
# case is bounded by the window size rather than by the length of the story.
|
||||
ACTION_PAGE = 60
|
||||
|
||||
|
||||
def action_window(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
before_id: int | None = None,
|
||||
limit: int = ACTION_PAGE,
|
||||
) -> tuple[list[models.Action], int, bool]:
|
||||
"""Returns the `limit` actions immediately older than `before_id`, oldest first.
|
||||
|
||||
The return value is `(actions, total, has_more)`. If `before_id` is `None`,
|
||||
the newest window is returned.
|
||||
|
||||
The query is scoped to the head branch's lineage, which is the story being
|
||||
played, rather than to the adventure. A sibling branch's turns therefore
|
||||
never appear in the transcript. `total` counts the same path, because it is
|
||||
what tells the reader that more actions exist above.
|
||||
|
||||
The window is anchored on an action, never on a count or on arithmetic over
|
||||
depth, for two reasons:
|
||||
|
||||
* Appends. Counting back from the newest action shifts every older position
|
||||
when a turn lands. A reader who scrolls up while a turn is generating gets
|
||||
a window that is one row off, which re-sends one action and skips another.
|
||||
An anchor is stable, because "older than this action" means the same thing
|
||||
before and after the story grows.
|
||||
* The story tree. Depth is dense today, and branching ends that. Comparing
|
||||
depths to order a path still works, but treating them as positions does
|
||||
not.
|
||||
|
||||
`has_more` comes from requesting one row past the window rather than from a
|
||||
second count, so it costs one row instead of a scan.
|
||||
"""
|
||||
path = lineage.path_of(db, adventure)
|
||||
on_path = (
|
||||
models.Action.adventure_id == adventure.id,
|
||||
path.clause(models.Action),
|
||||
)
|
||||
total = db.query(func.count(models.Action.id)).filter(*on_path).scalar()
|
||||
if limit <= 0:
|
||||
return [], total, total > 0
|
||||
|
||||
query = db.query(models.Action).options(load_only(*ACTION_LIST_COLUMNS)).filter(*on_path)
|
||||
if before_id is not None:
|
||||
anchor = (
|
||||
db.query(models.Action.depth)
|
||||
.filter(models.Action.id == before_id, *on_path)
|
||||
.scalar()
|
||||
)
|
||||
if anchor is None:
|
||||
# The anchor was deleted while the reader scrolled, by an undo or
|
||||
# by an edited turn, or it belongs to a branch this story is not on.
|
||||
# No row can be older than a row that is not present, so report the
|
||||
# end of the story rather than guess and return a duplicate page.
|
||||
return [], total, False
|
||||
query = query.filter(models.Action.depth < anchor)
|
||||
|
||||
rows = (
|
||||
query.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
rows.reverse()
|
||||
return rows, total, has_more
|
||||
|
||||
|
||||
def annotate_takes(
|
||||
db: Session, adventure_id: int, actions: list[models.Action]
|
||||
) -> list[models.Action]:
|
||||
"""Sets the `2/4` pager numbers on every action on a page (SP9).
|
||||
|
||||
This runs one query for the whole page rather than one per row. The pager
|
||||
needs the shape of each turn's attempt group, and calling `attempts.group`
|
||||
per action costs one query per message on screen.
|
||||
|
||||
This function reads the siblings rather than counting them. A group holds
|
||||
only a few attempts, the page is bounded, and a count still needs a second
|
||||
query for the ordinal. It fetches only the id and the parent, so it stays
|
||||
cheap even when the text is large.
|
||||
"""
|
||||
parents = {a.parent_id for a in actions if a.parent_id is not None}
|
||||
if parents:
|
||||
rows = (
|
||||
db.query(models.Action.id, models.Action.parent_id)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure_id,
|
||||
models.Action.parent_id.in_(parents),
|
||||
)
|
||||
.order_by(models.Action.id)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
rows = []
|
||||
siblings: dict[int, list[int]] = {}
|
||||
for row_id, parent_id in rows:
|
||||
siblings.setdefault(parent_id, []).append(row_id)
|
||||
for action in actions:
|
||||
ids = siblings.get(action.parent_id) if action.parent_id else None
|
||||
if not ids:
|
||||
# A root node, or a pre-SP9 row that the backfill could not place.
|
||||
# It has one attempt, which is how it was written.
|
||||
action.take_count, action.take_index = 1, 0
|
||||
continue
|
||||
action.take_count = len(ids)
|
||||
action.take_index = ids.index(action.id) if action.id in ids else 0
|
||||
return actions
|
||||
|
||||
|
||||
def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPage:
|
||||
actions, total, has_more = action_window(db, adventure)
|
||||
return schemas.ActionPage(
|
||||
actions=[
|
||||
schemas.ActionOut.model_validate(a)
|
||||
for a in annotate_takes(db, adventure.id, actions)
|
||||
],
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Copying a scenario's current content over an adventure's copy.
|
||||
|
||||
An adventure copies the scenario's plot text and story cards when it is created,
|
||||
so that later authoring does not change a story in progress. The per-script "Sync
|
||||
from library" in `scripts` works the same way. This module is the explicit
|
||||
opt-out. The preview endpoint reports what would change, and the write endpoint
|
||||
applies it.
|
||||
"""
|
||||
|
||||
from fastapi import Body, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import models, schemas, worldstate
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .scenario_text import (
|
||||
CARD_FIELDS, SCENARIO_TEXT_FIELDS, fill_placeholders, scenario_card_specs,
|
||||
scenario_placeholder_names,
|
||||
)
|
||||
|
||||
|
||||
def resolve_source_scenario(
|
||||
adventure: models.Adventure, db: Session, user: models.User
|
||||
) -> models.Scenario | None:
|
||||
"""Returns the scenario an adventure can refresh from.
|
||||
|
||||
The result is the scenario the adventure was started from, if that scenario
|
||||
still exists and the user can still read it, which means the user owns it or
|
||||
it is public. The result is `None` after the scenario is deleted, which sets
|
||||
`scenario_id` to NULL, or after it stops being shared.
|
||||
"""
|
||||
if adventure.scenario_id is None:
|
||||
return None
|
||||
scenario = db.get(models.Scenario, adventure.scenario_id)
|
||||
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
||||
return None
|
||||
return scenario
|
||||
|
||||
|
||||
def _scenario_cards(adventure: models.Adventure) -> dict[str, models.StoryCard]:
|
||||
"""Returns the adventure's scenario-derived cards, keyed by `source_ref`.
|
||||
|
||||
An adventure created before `source_ref` existed has none, so it falls back
|
||||
to matching the scenario's cards by name. The fallback runs only when the
|
||||
adventure has no tagged cards at all. Otherwise a player-authored card that
|
||||
shares a scenario card's name would be adopted and overwritten.
|
||||
"""
|
||||
return {c.source_ref: c for c in adventure.story_cards if c.source_ref}
|
||||
|
||||
|
||||
def _match_legacy(
|
||||
adventure: models.Adventure, specs: dict[str, dict]
|
||||
) -> dict[str, models.StoryCard]:
|
||||
by_name: dict[str, models.StoryCard] = {}
|
||||
for card in adventure.story_cards:
|
||||
by_name.setdefault((card.name or "").strip().lower(), card)
|
||||
matched: dict[str, models.StoryCard] = {}
|
||||
for ref, spec in specs.items():
|
||||
card = by_name.get((spec["name"] or "").strip().lower())
|
||||
if card is not None:
|
||||
matched[ref] = card
|
||||
return matched
|
||||
|
||||
|
||||
def plan_refresh(
|
||||
adventure: models.Adventure, scenario: models.Scenario, values: dict[str, str]
|
||||
) -> tuple[dict, dict, dict]:
|
||||
"""Computes what a refresh would change, without modifying anything.
|
||||
|
||||
The return value is `(plan, specs, matched)`. `plan` is the summary the UI
|
||||
shows, `specs` holds the scenario's card specs by ref, and `matched` holds
|
||||
the existing adventure card for each ref that already has one.
|
||||
"""
|
||||
fields = {
|
||||
field: {"old": getattr(adventure, field), "new": fill_placeholders(
|
||||
getattr(scenario, field), values)}
|
||||
for field in SCENARIO_TEXT_FIELDS
|
||||
}
|
||||
changed_fields = {f: v for f, v in fields.items() if v["old"] != v["new"]}
|
||||
|
||||
specs = scenario_card_specs(scenario, values)
|
||||
tagged = _scenario_cards(adventure)
|
||||
matched = tagged or _match_legacy(adventure, specs)
|
||||
|
||||
added, updated = [], []
|
||||
for ref, spec in specs.items():
|
||||
card = matched.get(ref)
|
||||
if card is None:
|
||||
added.append(spec["name"])
|
||||
elif any(getattr(card, f) != spec[f] for f in CARD_FIELDS):
|
||||
updated.append(card.name or spec["name"])
|
||||
# Only a card the scenario produced is removable. A player-authored card
|
||||
# has no `source_ref` and is never modified.
|
||||
removed = [c.name for ref, c in tagged.items() if ref not in specs]
|
||||
|
||||
_, world = worldstate.reconcile(adventure.world_state, scenario.stat_schema)
|
||||
|
||||
plan = {
|
||||
"scenario_id": scenario.id,
|
||||
"scenario_title": scenario.title,
|
||||
"fields": changed_fields,
|
||||
"cards": {"added": added, "updated": updated, "removed": removed},
|
||||
"world_state": world,
|
||||
}
|
||||
plan["has_changes"] = bool(
|
||||
changed_fields or added or updated or removed
|
||||
or world["added"] or world["removed"]
|
||||
)
|
||||
return plan, specs, matched
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/refresh", response_model=schemas.RefreshPlan)
|
||||
def preview_refresh(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns what "Update from scenario" would change, for the confirm dialog."""
|
||||
scenario = resolve_source_scenario(adventure, db, user)
|
||||
if scenario is None:
|
||||
raise HTTPException(404, "No scenario to update from")
|
||||
stored = adventure.placeholders if isinstance(adventure.placeholders, dict) else {}
|
||||
plan, _, _ = plan_refresh(adventure, scenario, stored)
|
||||
# An adventure started before placeholder answers were stored has none, and
|
||||
# an author can add a new `${...}` later. In both cases the player is asked
|
||||
# for the missing names, and the answers are saved for next time.
|
||||
plan["placeholders_needed"] = [
|
||||
n for n in scenario_placeholder_names(scenario) if n not in stored
|
||||
]
|
||||
return plan
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/refresh", response_model=schemas.AdventureOut)
|
||||
def refresh_from_scenario(
|
||||
adventure_id: int,
|
||||
payload: schemas.AdventureRefresh = Body(default=schemas.AdventureRefresh()),
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Copies the scenario's current plot text, story cards, and stat schema over
|
||||
this adventure's copy.
|
||||
|
||||
The refresh overwrites the plot fields and every scenario-derived card, adds
|
||||
what the scenario gained, and removes what it dropped.
|
||||
|
||||
The refresh leaves these unchanged: the opening `start` action, because the
|
||||
story is built on it and it is already part of the memories and the summary;
|
||||
the adventure's own title; its story summary; its player-authored story
|
||||
cards; and, through `worldstate.reconcile`, the live value of every stat the
|
||||
schema still defines.
|
||||
"""
|
||||
scenario = resolve_source_scenario(adventure, db, user)
|
||||
if scenario is None:
|
||||
raise HTTPException(404, "No scenario to update from")
|
||||
|
||||
values = {**(adventure.placeholders if isinstance(adventure.placeholders, dict) else {}),
|
||||
**payload.placeholders}
|
||||
|
||||
# A refresh rewrites the same state a turn is part-way through changing, so
|
||||
# it takes the turn slot rather than run at the same time as the
|
||||
# generator.
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
_, specs, matched = plan_refresh(adventure, scenario, values)
|
||||
|
||||
for field in SCENARIO_TEXT_FIELDS:
|
||||
setattr(adventure, field, fill_placeholders(getattr(scenario, field), values))
|
||||
|
||||
for ref, spec in specs.items():
|
||||
card = matched.get(ref)
|
||||
if card is None:
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
||||
continue
|
||||
for field in CARD_FIELDS:
|
||||
setattr(card, field, spec[field])
|
||||
# Store the ref, so that a name-matched legacy card syncs by id
|
||||
# next time.
|
||||
card.source_ref = ref
|
||||
for ref, card in _scenario_cards(adventure).items():
|
||||
if ref not in specs:
|
||||
db.delete(card)
|
||||
|
||||
adventure.world_state, _ = worldstate.reconcile(
|
||||
adventure.world_state, scenario.stat_schema
|
||||
)
|
||||
adventure.placeholders = values
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Copying a scenario's text and story cards onto an adventure.
|
||||
|
||||
An adventure holds its own copy of the scenario's plot text and cards, so that
|
||||
later authoring does not change a story in progress. Two callers make that copy:
|
||||
`crud.create_adventure` on the way in, and `refresh` when the player asks for the
|
||||
scenario's current content.
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
from ... import models, worldstate
|
||||
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
|
||||
def fill_placeholders(text: str, values: dict[str, str]) -> str:
|
||||
"""Replaces `${Name}` with the player-provided value.
|
||||
|
||||
Unknown names are left unchanged.
|
||||
"""
|
||||
if not text or not values:
|
||||
return text
|
||||
return PLACEHOLDER_RE.sub(
|
||||
lambda m: values.get(m.group(1).strip(), m.group(0)), text
|
||||
)
|
||||
|
||||
|
||||
# Adventure fields that start as a copy of the scenario's text, so "Update from
|
||||
# scenario" can copy them again. `title` is excluded because it is the
|
||||
# adventure's own name, which players rename. `story_summary` is excluded
|
||||
# because it is play output rather than scenario content.
|
||||
SCENARIO_TEXT_FIELDS = ("memory", "authors_note", "ai_instructions")
|
||||
|
||||
|
||||
# Story-card fields that are copied from the scenario and compared to detect
|
||||
# drift.
|
||||
CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
|
||||
|
||||
|
||||
def scenario_card_specs(scenario: models.Scenario, values: dict[str, str]) -> dict[str, dict]:
|
||||
"""Returns every story card a scenario implies, keyed by a stable `source_ref`.
|
||||
|
||||
The result holds the scenario's own cards, keyed `card:<id>`, plus one card
|
||||
per NPC defined in its `stat_schema`, keyed `npc:<key>`. Placeholders are
|
||||
already filled in.
|
||||
|
||||
Adventure creation and refresh both call this function, so the two cannot
|
||||
diverge.
|
||||
"""
|
||||
specs: dict[str, dict] = {}
|
||||
existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards}
|
||||
for card in scenario.story_cards:
|
||||
specs[f"card:{card.id}"] = {
|
||||
"type": card.type,
|
||||
"name": card.name,
|
||||
"keys": fill_placeholders(card.keys, values),
|
||||
"entry": fill_placeholders(card.entry, values),
|
||||
"notes": card.notes,
|
||||
}
|
||||
# Phase 12: each defined NPC gets a story card, so its description works as
|
||||
# lore and can trigger in a scene. If a card with that name already exists,
|
||||
# skip the NPC.
|
||||
for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items():
|
||||
if not isinstance(ndef, dict):
|
||||
continue
|
||||
name = worldstate.npc_name(ndef, npc_key)
|
||||
if name.strip().lower() in existing_names:
|
||||
continue
|
||||
specs[f"npc:{npc_key}"] = {
|
||||
"type": "character",
|
||||
"name": name,
|
||||
"keys": fill_placeholders(str(ndef.get("keys") or name), values),
|
||||
"entry": fill_placeholders(str(ndef.get("desc") or ""), values),
|
||||
"notes": "",
|
||||
}
|
||||
return specs
|
||||
|
||||
|
||||
def _placeholder_names(*texts: str) -> list[str]:
|
||||
"""Returns the unique `${Placeholder}` names across the given texts.
|
||||
|
||||
The order is first appearance first. This matches the frontend's
|
||||
`extractPlaceholders`.
|
||||
"""
|
||||
names: list[str] = []
|
||||
for text in texts:
|
||||
for match in PLACEHOLDER_RE.finditer(text or ""):
|
||||
name = match.group(1).strip()
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def scenario_placeholder_names(scenario: models.Scenario) -> list[str]:
|
||||
"""Returns every placeholder the scenario's refreshable content asks for.
|
||||
|
||||
The opening prompt is excluded, because a refresh never rewrites it.
|
||||
"""
|
||||
texts = [scenario.memory, scenario.authors_note, scenario.ai_instructions]
|
||||
for card in scenario.story_cards:
|
||||
texts += [card.keys, card.entry]
|
||||
for ndef in (scenario.stat_schema or {}).get("npcs", {}).values():
|
||||
if isinstance(ndef, dict):
|
||||
texts += [str(ndef.get("keys") or ""), str(ndef.get("desc") or "")]
|
||||
return _placeholder_names(*texts)
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Retries, takes, and the attempts that pile up at one coordinate.
|
||||
|
||||
Retry first deleted the AI action and generated a replacement. A later version
|
||||
kept the row and appended each attempt to a JSON list on it. Now every attempt is
|
||||
its own node on the same branch at the same depth, and exactly one of them is
|
||||
`live`. `app/attempts.py` owns the group and its invariants. The endpoints here
|
||||
only query it.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import attempts, limits, memorybank, models, schemas, tree
|
||||
from ...context import cursors
|
||||
from ...context import lineage
|
||||
from ...database import get_db
|
||||
from ...sse import SSE_HEADERS
|
||||
|
||||
from . import turns
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .nodes import delete_turn, last_action, stand_on
|
||||
from .paging import action_window, annotate_takes, current_window
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/retry")
|
||||
def retry_action(
|
||||
adventure_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Regenerates the last AI action and keeps the discarded attempt.
|
||||
|
||||
The attempt on screen stays as it was written. The shared script state and
|
||||
world state roll back to what the node before it left behind, and the new
|
||||
attempt is stored as a sibling at the same coordinate. No text the AI wrote
|
||||
is rewritten or deleted.
|
||||
"""
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
last_ai = None
|
||||
try:
|
||||
newest = last_action(adventure, db)
|
||||
if newest is not None and newest.type == "ai":
|
||||
last_ai = newest
|
||||
# Roll the state back to before this AI turn's hooks ran, so that
|
||||
# regenerating starts from a clean state rather than applying output
|
||||
# mutations on top of the attempt being replaced. If the preceding
|
||||
# node has no snapshot, which happens for a pre-SP4 row that the
|
||||
# migration could not derive one for, this call does nothing and
|
||||
# leaves the state as it is.
|
||||
attempts.roll_back_before(db, adventure, last_ai)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
except BaseException:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
raise
|
||||
return StreamingResponse(
|
||||
turns.with_turn_lock(
|
||||
adventure_id,
|
||||
turns.generate_turn(adventure, db, user, retry_of=last_ai),
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers=SSE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{adventure_id}/actions/{action_id}/variants",
|
||||
response_model=list[schemas.VariantOut],
|
||||
)
|
||||
def list_variants(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Returns every attempt made for one AI turn.
|
||||
|
||||
The client fetches these on demand, because the adventure payload carries
|
||||
only the counts. That keeps old narration out of every page load.
|
||||
|
||||
You can address the turn by any of its attempts, not only the live one.
|
||||
Switching changes which row the story tells, and a client that holds an id it
|
||||
received a moment ago still has to be able to ask about the same turn.
|
||||
"""
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
rows = attempts.group(db, action)
|
||||
if len(rows) < 2:
|
||||
return [] # Never retried, so the turn has one attempt.
|
||||
return [
|
||||
schemas.VariantOut(
|
||||
id=row.id,
|
||||
index=i,
|
||||
text=row.text,
|
||||
reasoning=row.reasoning,
|
||||
branch_id=row.branch_id,
|
||||
created_at=row.created_at.isoformat() if row.created_at else None,
|
||||
active=row.live,
|
||||
)
|
||||
for i, row in enumerate(rows)
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut
|
||||
)
|
||||
def select_variant(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
payload: schemas.VariantSelect,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Makes an earlier attempt live again and restores the state it produced.
|
||||
|
||||
The restored state covers both the script state and the world state.
|
||||
|
||||
Only the last action can be switched. The turns after an older action were
|
||||
written to continue the text that is currently active, so replacing that text
|
||||
would leave the story contradicting itself. The attempts of earlier turns
|
||||
stay readable through `list_variants`.
|
||||
"""
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
rows = attempts.group(db, action)
|
||||
if not 0 <= payload.index < len(rows) or len(rows) < 2:
|
||||
raise HTTPException(400, "No such attempt for this action")
|
||||
newest = last_action(adventure, db)
|
||||
if newest is None or newest.depth != action.depth or newest.branch_id != action.branch_id:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Only the latest message can be switched — the story has already "
|
||||
"continued from this one.",
|
||||
)
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
chosen = rows[payload.index]
|
||||
if not chosen.live:
|
||||
# The story at this coordinate is about to change, so withdraw
|
||||
# anything derived from the previous text. A retry does the same
|
||||
# thing for the same reason.
|
||||
memorybank.forget_node(db, adventure, chosen)
|
||||
cursors.rewind_all(adventure, chosen.branch_id, (chosen.depth or 0) - 1)
|
||||
attempts.make_live(db, adventure, chosen)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(chosen)
|
||||
# Return the row that is now in the story, which is a different row
|
||||
# from the one the request addressed. An attempt is a node, so choosing
|
||||
# one moves the story onto it rather than rewriting a row.
|
||||
return chosen
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{adventure_id}/actions/{action_id}/fork", response_model=schemas.ActionPage
|
||||
)
|
||||
def fork_from_attempt(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Continues the story from this attempt, forking a branch if one is needed.
|
||||
|
||||
There are three cases, and the first two do not fork:
|
||||
|
||||
* The attempt is already the one the story tells, so there is nothing to do.
|
||||
* Its turn is the tip, so the attempts are still leaves that nothing was
|
||||
built on. The endpoint switches, as `/variant` does, and creates no branch.
|
||||
* The story has moved past its turn, so the endpoint forks. The attempt gets
|
||||
a branch of its own, and the line it leaves keeps every turn it has.
|
||||
"""
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
# Check this before checking the shape of the turn, because a fork leaves
|
||||
# the promoted attempt alone on its branch. A client that repeats the call,
|
||||
# after a double click or a retried request, has to get the same answer
|
||||
# rather than an error saying the turn it just forked has nothing to fork
|
||||
# to.
|
||||
if action.live:
|
||||
# A live node already holds what its coordinate says, so there is no
|
||||
# attempt here to promote. On the path being read this call does
|
||||
# nothing, and it has to stay that way, so that a repeated call after a
|
||||
# double click or a retried request gets the same answer. Off the path
|
||||
# the node belongs to another line's story, and moving there is a branch
|
||||
# switch.
|
||||
#
|
||||
# The membership test covers the whole lineage, not `head_branch_id`. A
|
||||
# head borrows its ancestors' turns, so a live node on an ancestor is
|
||||
# already being read. Forking it would move the live row off the parent
|
||||
# and promote a sibling in its place, which rewrites the story on a
|
||||
# branch nobody asked about and on this one, which borrows that depth.
|
||||
if lineage.path_of(db, adventure).contains(action):
|
||||
return current_window(db, adventure)
|
||||
raise HTTPException(
|
||||
400,
|
||||
"That take is already the story on another branch. Switch to that "
|
||||
"branch to read it.",
|
||||
)
|
||||
if len(attempts.group(db, action)) < 2:
|
||||
raise HTTPException(
|
||||
400, "This turn has only one take, so there is nothing to fork to."
|
||||
)
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
stand_on(db, adventure, action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return current_window(db, adventure)
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/actions/{action_id}/takes")
|
||||
def add_take(
|
||||
adventure_id: int,
|
||||
action_id: int,
|
||||
payload: schemas.TakeCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = CurrentUser,
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Plays a turn again, whoever wrote it.
|
||||
|
||||
This endpoint replaces two earlier operations. `retry` gave an AI turn
|
||||
another attempt, but only for the newest turn, and a player's own message had
|
||||
no attempts at all, so changing text you had typed meant overwriting it and
|
||||
losing the story it led to. Here an AI turn regenerates, a player turn takes
|
||||
the text you supply, and neither depends on where in the story it sits.
|
||||
|
||||
The tip is the only case that needs no branch, and only for an AI turn,
|
||||
because nothing was played after it and its attempts are still leaves. A
|
||||
player turn is never at the tip, since the reply to it is, so a player turn
|
||||
that has been answered always takes a branch.
|
||||
|
||||
A branch is needed here for the same reason `fork` needs one. The turn being
|
||||
replayed already has a story after it, and that story was written as a
|
||||
continuation of the old text. `branch_at` leaves the path just before this
|
||||
turn, so the new attempt is written at the same depth under the same parent,
|
||||
and the line it leaves is unchanged. No node below is copied.
|
||||
"""
|
||||
limits.check_row_cap("actions", db, user, adventure=adventure)
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
if action.type not in ("do", "say", "story", "continue", "ai"):
|
||||
# The opening is not a turn anyone played, so it has no second attempt.
|
||||
# Editing the scenario is what changes it.
|
||||
raise HTTPException(400, "The opening of a story has no other take.")
|
||||
if action.depth is None or not lineage.path_of(db, adventure).contains(action):
|
||||
raise HTTPException(400, "That turn is not on the story you are reading.")
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
retry_of = None
|
||||
try:
|
||||
newest = last_action(adventure, db)
|
||||
at_the_tip = newest is not None and newest.id == action.id
|
||||
if at_the_tip and action.type == "ai":
|
||||
# Nothing was played after it, so its attempts are still leaves and
|
||||
# a branch would serve no purpose. This is the `retry` path.
|
||||
retry_of = action
|
||||
attempts.roll_back_before(db, adventure, action)
|
||||
else:
|
||||
# The turn has a story after it, written as a continuation of the
|
||||
# text that is there now. The new attempt leaves the path just
|
||||
# before the turn, so that story keeps the attempt it was written
|
||||
# for.
|
||||
tree.branch_at(db, adventure, action.depth - 1)
|
||||
attempts.roll_back_before(db, adventure, action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
except BaseException:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
raise
|
||||
if action.type == "ai":
|
||||
# There is no player action to write. The action this turn answers is
|
||||
# already on the path, borrowed from the line being left.
|
||||
stream = turns.generate_turn(adventure, db, user, retry_of=retry_of)
|
||||
else:
|
||||
stream = turns.run_player_turn(
|
||||
adventure,
|
||||
db,
|
||||
schemas.ActionCreate(type=action.type, text=payload.text),
|
||||
user,
|
||||
# The client seeded its editor from the stored text, which already
|
||||
# carries the "> You ..." conventions.
|
||||
preformatted=True,
|
||||
)
|
||||
return StreamingResponse(
|
||||
turns.with_turn_lock(adventure_id, stream),
|
||||
media_type="text/event-stream",
|
||||
headers=SSE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage)
|
||||
def undo_turn(
|
||||
adventure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Deletes the last turn: the trailing AI action and its player action, if any.
|
||||
|
||||
The endpoint also rolls the shared `script_state` back to before that turn
|
||||
ran, and it prunes any memory that summarized the removed actions. The turn
|
||||
lock prevents an undo while a turn is still generating.
|
||||
"""
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
# Only the last turn is removed, so fetch the two actions it can
|
||||
# consist of rather than the whole story.
|
||||
newest = (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
if not newest or newest[0].type == "start":
|
||||
raise HTTPException(400, "Nothing to undo")
|
||||
last = newest[0]
|
||||
before_that = newest[1] if len(newest) > 1 else None
|
||||
# Undo only what this branch owns. Everything before the fork is
|
||||
# borrowed from an ancestor and is part of that ancestor's story too, so
|
||||
# an undo here must never delete a turn out of another branch. The test
|
||||
# reads the row's own branch rather than the fork depth, because the
|
||||
# branch is what decides the case.
|
||||
if last.branch_id != adventure.head_branch_id:
|
||||
raise HTTPException(
|
||||
400, "Nothing to undo on this branch — the turns before it "
|
||||
"belong to the branch it was forked from.",
|
||||
)
|
||||
first_removed = last
|
||||
if (last.type == "ai" and before_that is not None
|
||||
and before_that.type in ("do", "say", "story")
|
||||
and before_that.branch_id == adventure.head_branch_id):
|
||||
first_removed = before_that
|
||||
# The state the story returns to once the turn is gone, which is what
|
||||
# the node before the earliest removed one left behind. Read it before
|
||||
# the deletes, while those rows are still in the story.
|
||||
restore_to = attempts.preceding(db, adventure, first_removed)
|
||||
delete_turn(db, adventure, last)
|
||||
if first_removed is not last:
|
||||
delete_turn(db, adventure, first_removed)
|
||||
attempts.restore_state(adventure, restore_to)
|
||||
db.flush() # Apply the deletes before anything reads the story back.
|
||||
db.expire(adventure, ["actions"])
|
||||
# The tip moves back with the deleted rows.
|
||||
tree.refresh_head(db, adventure)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
# Return the newest window rather than the whole story. The client
|
||||
# replaces its transcript with this response, and the transcript is a
|
||||
# window. Returning everything would defeat the paging on the action a
|
||||
# player is most likely to repeat several times in a row.
|
||||
actions, total, has_more = action_window(db, adventure)
|
||||
return schemas.ActionPage(
|
||||
actions=[
|
||||
schemas.ActionOut.model_validate(a)
|
||||
for a in annotate_takes(db, adventure.id, actions)
|
||||
],
|
||||
total=total,
|
||||
has_more=has_more,
|
||||
)
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""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` or `generate_turn` patches this module, which every
|
||||
caller reads through.
|
||||
"""
|
||||
import threading
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import (
|
||||
attempts, limits, memorybank, models, schemas, tree, worldstate,
|
||||
)
|
||||
from ...context import build_context, cursors
|
||||
from ...database import get_db
|
||||
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
||||
from ...sse import SSE_HEADERS, sse, turn_error
|
||||
from ..settings import get_settings
|
||||
|
||||
from .deps import CurrentUser, current_adventure, router
|
||||
from .nodes import _move_to_after, next_depth
|
||||
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 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,
|
||||
user: models.User,
|
||||
retry_of: models.Action | None = None,
|
||||
):
|
||||
"""Streams the AI continuation as SSE, then stores the result.
|
||||
|
||||
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, 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, 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,
|
||||
user: models.User,
|
||||
retry_of: models.Action | None = None,
|
||||
):
|
||||
settings = get_settings(db, user)
|
||||
# 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
|
||||
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
|
||||
)
|
||||
|
||||
parts = PromptParts(system=system_text, story=story_text)
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
settings.endpoint_url, settings.model, settings.api_mode,
|
||||
settings.model_timeout_seconds,
|
||||
)
|
||||
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
|
||||
|
||||
# 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,
|
||||
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. `memorybank.SETTLE_SLACK` keeps a memory
|
||||
# off the tip again, for cost rather than for correctness, so this is
|
||||
# now the rare case: undo or delete can carry a summarized node back to
|
||||
# the tip, and then a retry of it lands here. See `memorybank`.
|
||||
memorybank.forget_node(db, adventure, retry_of)
|
||||
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
|
||||
# Flush so the new attempt has an id. The session does not autoflush,
|
||||
# and attempts page in id order, so a read taken before this point puts
|
||||
# the newest attempt nowhere.
|
||||
db.flush()
|
||||
else:
|
||||
tree.place_action(db, adventure, ai_action)
|
||||
db.add(ai_action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(ai_action)
|
||||
yield _SAVED
|
||||
yield sse({"type": "done", "action": action_json(ai_action, db)})
|
||||
# Phase 6: schedule summarization and embedding without waiting for them.
|
||||
# The task opens its own database session.
|
||||
memorybank.schedule_post_turn(adventure)
|
||||
|
||||
|
||||
|
||||
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 ...`.
|
||||
"""
|
||||
# An empty do, say, or story action behaves as a continue.
|
||||
if payload.type != "continue" and payload.text.strip():
|
||||
formatted = (
|
||||
payload.text.strip() if preformatted
|
||||
else format_player_input(payload.type, payload.text)
|
||||
)
|
||||
player_action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
depth=next_depth(adventure),
|
||||
type=payload.type,
|
||||
text=formatted,
|
||||
)
|
||||
# The state this node leaves 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` for the AI action does not see the player action
|
||||
# that was just saved.
|
||||
db.expire(adventure, ["actions"])
|
||||
yield sse({"type": "player", "action": action_json(player_action, db)})
|
||||
|
||||
async for event in generate_turn(adventure, db, 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: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
limits.check_row_cap("actions", db, user, adventure=adventure)
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""AI Chat: a plain scratchpad for talking to the configured model directly.
|
||||
|
||||
Deliberately thin. It adds no story context and no world state, and it persists
|
||||
nothing. The conversation lives in the browser and is posted in full on each
|
||||
turn. It exists for checking a model, a prompt, or an endpoint without starting
|
||||
an adventure — which is exactly the kind of thing a local single-user install
|
||||
wants a page for.
|
||||
|
||||
Upstream gated this behind a "power user" email allowlist and pinned the model
|
||||
when a shared demo key was in play. M2 removed both: there is one local user,
|
||||
who owns the endpoint, and there is no server-funded key to protect. The model
|
||||
this page talks to is the one in Settings, or one the user names per request —
|
||||
either way it is their own Ollama.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import auth, models, schemas
|
||||
from ..database import get_db
|
||||
from ..providers import OpenAICompatibleProvider, ProviderError
|
||||
from ..sse import SSE_HEADERS, sse
|
||||
from .settings import get_settings, list_endpoint_models
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def chat_config(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Returns what this page can talk to.
|
||||
|
||||
The model listing is best effort: an unreachable endpoint returns an empty
|
||||
list and the reason, rather than failing the page.
|
||||
"""
|
||||
settings = get_settings(db, user)
|
||||
listing = await list_endpoint_models(settings.endpoint_url)
|
||||
return {
|
||||
"endpoint_url": settings.endpoint_url,
|
||||
"model": settings.model,
|
||||
"api_mode": settings.api_mode,
|
||||
"temperature": settings.temperature,
|
||||
"max_tokens": settings.max_output_tokens,
|
||||
# Suggestions from the endpoint, not a restriction.
|
||||
"models": listing.get("models", []),
|
||||
"models_error": None if listing.get("ok") else listing.get("detail"),
|
||||
}
|
||||
|
||||
|
||||
async def run_chat(
|
||||
settings: models.Settings, model: str, payload: schemas.ChatRequest
|
||||
):
|
||||
"""Streams the reply as SSE, using the turn stream's event shape.
|
||||
|
||||
The generator emits `reasoning` and `chunk` events while generating and then
|
||||
a `done` event, so the frontend reuses the same code.
|
||||
"""
|
||||
provider = OpenAICompatibleProvider(
|
||||
settings.endpoint_url, model, settings.api_mode,
|
||||
settings.model_timeout_seconds,
|
||||
)
|
||||
messages = [m.model_dump() for m in payload.messages]
|
||||
chunks: list[str] = []
|
||||
reasoning_chunks: list[str] = []
|
||||
try:
|
||||
async for kind, chunk in provider.chat(
|
||||
messages,
|
||||
temperature=(
|
||||
payload.temperature
|
||||
if payload.temperature is not None
|
||||
else settings.temperature
|
||||
),
|
||||
max_tokens=payload.max_tokens or 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 sse({"type": "error", "detail": str(exc)})
|
||||
return
|
||||
|
||||
text = "".join(chunks).strip()
|
||||
if not text:
|
||||
detail = (
|
||||
"The model used its entire token budget on reasoning and returned no "
|
||||
"reply — raise max tokens or use a non-reasoning model."
|
||||
if reasoning_chunks
|
||||
else "The AI returned an empty response."
|
||||
)
|
||||
yield sse({"type": "error", "detail": detail})
|
||||
return
|
||||
|
||||
yield sse({
|
||||
"type": "done",
|
||||
"text": text,
|
||||
"reasoning": "".join(reasoning_chunks).strip() or None,
|
||||
"model": model,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
def chat_stream(
|
||||
payload: schemas.ChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
total = sum(len(m.content) for m in payload.messages)
|
||||
if total > schemas.CHAT_TOTAL_MAX:
|
||||
raise HTTPException(
|
||||
413, f"This conversation is too long to send ({total:,} characters) — "
|
||||
"clear it or start a new one."
|
||||
)
|
||||
settings = get_settings(db, user)
|
||||
model = (payload.model or "").strip() or settings.model
|
||||
if not model:
|
||||
raise HTTPException(400, "No model configured — set one in Settings or pick one here.")
|
||||
return StreamingResponse(
|
||||
run_chat(settings, model, payload),
|
||||
media_type="text/event-stream",
|
||||
headers=SSE_HEADERS,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .. import debuglog
|
||||
|
||||
router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.get("/requests")
|
||||
def recent_requests():
|
||||
"""Most-recent-first log of provider requests and responses.
|
||||
|
||||
A single process-wide ring buffer. It holds the prompts this install sent
|
||||
to its own Ollama, which is exactly what the person running it needs to
|
||||
diagnose a turn, and there is nobody else it could expose them to.
|
||||
"""
|
||||
return debuglog.recent()
|
||||
@@ -0,0 +1,232 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import auth, images, limits, models, schemas
|
||||
from ..database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
|
||||
def get_scenario_or_404(
|
||||
scenario_id: int, db: Session, user: models.User, *, edit: bool = False
|
||||
) -> models.Scenario:
|
||||
"""Visible = owned or public; editable = owned only."""
|
||||
scenario = db.get(models.Scenario, scenario_id)
|
||||
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
||||
raise HTTPException(404, "Scenario not found")
|
||||
if edit and scenario.user_id != user.id:
|
||||
raise HTTPException(403, "This is a shared demo scenario — it can't be edited. Start an adventure from it, or duplicate it.")
|
||||
return scenario
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.ScenarioListItem])
|
||||
def list_scenarios(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
return (
|
||||
db.query(models.Scenario)
|
||||
.filter(or_(models.Scenario.user_id == user.id, models.Scenario.is_public))
|
||||
.order_by(models.Scenario.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.ScenarioOut, status_code=201)
|
||||
def create_scenario(
|
||||
payload: schemas.ScenarioCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
limits.check_row_cap("scenarios", db, user)
|
||||
scenario = models.Scenario(**payload.model_dump(), user_id=user.id)
|
||||
db.add(scenario)
|
||||
db.commit()
|
||||
return scenario
|
||||
|
||||
|
||||
@router.get("/{scenario_id}", response_model=schemas.ScenarioOut)
|
||||
def get_scenario(
|
||||
scenario_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
scenario = get_scenario_or_404(scenario_id, db, user)
|
||||
return scenario
|
||||
|
||||
|
||||
@router.get("/{scenario_id}/image")
|
||||
def get_scenario_image(
|
||||
scenario_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Serve an uploaded cover image as real bytes.
|
||||
|
||||
Lists point here instead of inlining the data URI. The response is marked
|
||||
immutable and the URL carries a `?v=<updated_at>` stamp, so browsers cache
|
||||
it indefinitely but pick up a new picture the moment the author saves one.
|
||||
"""
|
||||
scenario = get_scenario_or_404(scenario_id, db, user)
|
||||
decoded = images.decode(scenario.image)
|
||||
if decoded is None:
|
||||
raise HTTPException(404, "This scenario has no uploaded image")
|
||||
data, content_type = decoded
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=content_type,
|
||||
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}", response_model=schemas.ScenarioOut)
|
||||
def update_scenario(
|
||||
scenario_id: int,
|
||||
payload: schemas.ScenarioUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in data.items():
|
||||
setattr(scenario, field, value)
|
||||
db.commit()
|
||||
return scenario
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}", status_code=204)
|
||||
def delete_scenario(
|
||||
scenario_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
|
||||
db.delete(scenario)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- Import / Export ----------
|
||||
|
||||
@router.get("/{scenario_id}/export")
|
||||
def export_scenario(
|
||||
scenario_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
s = get_scenario_or_404(scenario_id, db, user)
|
||||
return {
|
||||
"format": "ai-dnd-scenario-v1",
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"prompt": s.prompt,
|
||||
"memory": s.memory,
|
||||
"authorsNote": s.authors_note,
|
||||
"aiInstructions": s.ai_instructions,
|
||||
"tags": s.tags,
|
||||
"image": s.image,
|
||||
"icon": s.icon,
|
||||
"statSchema": s.stat_schema,
|
||||
"storyCards": [
|
||||
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
|
||||
for c in s.story_cards
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# Key aliases seen in AI Dungeon scenario exports, mapped best-effort.
|
||||
_SCENARIO_KEYS = {
|
||||
"title": "title",
|
||||
"description": "description",
|
||||
"prompt": "prompt",
|
||||
"memory": "memory",
|
||||
"authorsNote": "authors_note",
|
||||
"authors_note": "authors_note",
|
||||
"authorsNoteText": "authors_note",
|
||||
"aiInstructions": "ai_instructions",
|
||||
"ai_instructions": "ai_instructions",
|
||||
"instructions": "ai_instructions",
|
||||
}
|
||||
_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags",
|
||||
"statSchema", "stat_schema", "image", "icon",
|
||||
"createdAt", "updatedAt", "id", "publicId", "nsfw", "type", "options"}
|
||||
|
||||
|
||||
@router.post("/import", status_code=201)
|
||||
def import_scenario(
|
||||
request: Request,
|
||||
bundle: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Accepts our export format and AI Dungeon scenario exports best-effort;
|
||||
reports any keys it didn't understand."""
|
||||
limits.check_row_cap("scenarios", db, user)
|
||||
fields: dict = {}
|
||||
unmapped: list[str] = []
|
||||
for key, value in bundle.items():
|
||||
if key in _SCENARIO_KEYS and isinstance(value, str):
|
||||
fields[_SCENARIO_KEYS[key]] = value
|
||||
elif key not in _IGNORED_KEYS:
|
||||
unmapped.append(key)
|
||||
|
||||
tags = bundle.get("tags")
|
||||
if isinstance(tags, list):
|
||||
fields["tags"] = ", ".join(str(t) for t in tags)
|
||||
elif isinstance(tags, str):
|
||||
fields["tags"] = tags
|
||||
|
||||
schema = bundle.get("statSchema") or bundle.get("stat_schema")
|
||||
if isinstance(schema, dict):
|
||||
fields["stat_schema"] = schema
|
||||
|
||||
# An AI Dungeon bundle also carries an `image`, so this reads it. The value
|
||||
# is untrusted input, so it goes through `sanitize()` rather than a direct
|
||||
# assignment.
|
||||
image = images.sanitize(bundle.get("image"), schemas.IMAGE_MAX)
|
||||
if image:
|
||||
fields["image"] = image
|
||||
icon = bundle.get("icon")
|
||||
if isinstance(icon, str) and icon:
|
||||
fields["icon"] = icon[:schemas.ICON_MAX]
|
||||
|
||||
scenario = models.Scenario(**fields, user_id=user.id)
|
||||
if not scenario.title:
|
||||
scenario.title = "Imported Scenario"
|
||||
# A raw-dict import bypasses the schemas, so truncate to the VARCHAR widths.
|
||||
# Postgres enforces them. See `schemas.py`. Column defaults have not been
|
||||
# applied yet, because that happens at flush, so a bundle with no `tags` key
|
||||
# leaves the attribute None, which is why the code says `or ""`.
|
||||
scenario.title = scenario.title[:schemas.NAME_MAX]
|
||||
scenario.tags = (scenario.tags or "")[:schemas.TAGS_MAX]
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
|
||||
# AI Dungeon exports have used all three names for the same list.
|
||||
cards = (
|
||||
bundle.get("storyCards")
|
||||
or bundle.get("worldInfo")
|
||||
or bundle.get("worldInformation")
|
||||
or []
|
||||
)
|
||||
limits.check_bundle_lists(story_cards=cards)
|
||||
for card in cards:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
db.add(
|
||||
models.StoryCard(
|
||||
scenario_id=scenario.id,
|
||||
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
|
||||
name=str(card.get("name") or card.get("title") or "")[:schemas.NAME_MAX],
|
||||
keys=str(card.get("keys") or ""),
|
||||
# AI Dungeon world info uses "value"; story cards use "entry".
|
||||
entry=str(card.get("entry") or card.get("value") or ""),
|
||||
notes=str(card.get("notes") or card.get("description") or ""),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
db.commit()
|
||||
out = schemas.ScenarioOut.model_validate(scenario).model_dump(mode="json")
|
||||
return {"scenario": out, "unmapped_keys": unmapped}
|
||||
@@ -0,0 +1,185 @@
|
||||
"""The model settings, and the connection test that tells you why they don't work.
|
||||
|
||||
There is one settings row, belonging to the one local user. It describes an
|
||||
Ollama: where it is, which model to narrate with, which to embed with, and how
|
||||
long to wait for it.
|
||||
|
||||
Upstream let this row name any OpenAI-compatible endpoint and carry an
|
||||
encrypted API key for it. M2 narrowed both: `endpoints.py` decides which
|
||||
addresses may be named, and there is no key field, because Ollama does not use
|
||||
one and this build has no cloud provider to carry a key for.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from .. import auth, endpoints, models, schemas, tlstrust
|
||||
from ..database import get_db
|
||||
from ..providers.openai_compatible import CONNECT_TIMEOUT
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
#: The connection test is a listing, not a generation, so it never waits on a
|
||||
#: model load and does not need the turn engine's patience.
|
||||
TEST_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def get_settings(db: Session, user: models.User) -> models.Settings:
|
||||
"""Returns the settings row, creating it on first access."""
|
||||
settings = (
|
||||
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
|
||||
)
|
||||
if settings is None:
|
||||
settings = models.Settings(user_id=user.id)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
return settings
|
||||
|
||||
|
||||
@router.get("", response_model=schemas.SettingsOut)
|
||||
def read_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
return get_settings(db, user)
|
||||
|
||||
|
||||
@router.put("", response_model=schemas.SettingsOut)
|
||||
async def update_settings(
|
||||
payload: schemas.SettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
settings = get_settings(db, user)
|
||||
fields = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "endpoint_url" in fields:
|
||||
# Refused here so the user finds out while they are looking at the
|
||||
# field, rather than on their next turn. The provider re-checks before
|
||||
# every request regardless; this is the friendly half of the same rule.
|
||||
reason = await run_in_threadpool(
|
||||
endpoints.rejection_reason, fields["endpoint_url"]
|
||||
)
|
||||
if reason is not None:
|
||||
raise HTTPException(400, f"That endpoint can't be used — {reason}.")
|
||||
|
||||
embedding_model_changed = (
|
||||
"embedding_model" in fields
|
||||
and fields["embedding_model"] != settings.embedding_model
|
||||
)
|
||||
for field, value in fields.items():
|
||||
setattr(settings, field, value)
|
||||
if embedding_model_changed:
|
||||
# Vectors from the old model have a different dimensionality/space;
|
||||
# clear them so the post-turn task re-embeds with the new model.
|
||||
#
|
||||
# Both columns, and the flag. This is the one place that clears vectors
|
||||
# in bulk rather than through memorybank.set_vector, and when the
|
||||
# vectors moved to embedding_blob it kept nulling the old JSON column
|
||||
# alone. The blob survived, `embedded` stayed true, and
|
||||
# `_embed_pending`, which selects rows where `embedded IS FALSE`, never
|
||||
# found the rows. The bank kept ranking against the previous model's
|
||||
# vectors.
|
||||
owned = (
|
||||
db.query(models.Adventure.id)
|
||||
.filter(models.Adventure.user_id == user.id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
db.query(models.Memory).filter(models.Memory.adventure_id.in_(owned)).update(
|
||||
{"embedding_blob": None, "embedded": False}, synchronize_session=False
|
||||
)
|
||||
# No cache invalidation needed, and deliberately none added: clearing
|
||||
# `embedded` drops these rows out of the catalogue query, so retrieval
|
||||
# stops asking for them, and by the time _embed_pending puts one back
|
||||
# it has gone through set_vector, which evicts that entry. The rule
|
||||
# holds: anything that removes a memory from play corrects itself.
|
||||
db.commit()
|
||||
return settings
|
||||
|
||||
|
||||
async def list_endpoint_models(endpoint_url: str) -> dict:
|
||||
"""Fetches the endpoint's `/models` listing, and doubles as the connection test.
|
||||
|
||||
Returns `{"ok": False, "detail": ...}` rather than raising, because every
|
||||
caller wants to show the reason rather than fail the page.
|
||||
|
||||
The failure cases are told apart on purpose. "Ollama isn't running", "that
|
||||
address isn't allowed", "the certificate doesn't verify" and "it answered,
|
||||
but with an error" need four different things done about them, and a single
|
||||
"connection failed" leaves the user guessing which they have.
|
||||
"""
|
||||
reason = await run_in_threadpool(endpoints.rejection_reason, endpoint_url)
|
||||
if reason is not None:
|
||||
return {
|
||||
"ok": False, "kind": "rejected",
|
||||
"detail": f"That endpoint can't be used — {reason}.",
|
||||
}
|
||||
|
||||
url = endpoint_url.rstrip("/") + "/models"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(TEST_TIMEOUT, connect=CONNECT_TIMEOUT),
|
||||
verify=tlstrust.ssl_context(),
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
except httpx.ConnectError as exc:
|
||||
# A TLS failure arrives as a ConnectError too, and it needs a different
|
||||
# answer from "nothing is listening": install the CA, don't start Ollama.
|
||||
if "CERTIFICATE_VERIFY" in str(exc).upper() or "SSL" in str(exc).upper():
|
||||
return {
|
||||
"ok": False, "kind": "tls",
|
||||
"detail": (
|
||||
"The endpoint's TLS certificate could not be verified. If it "
|
||||
"uses a private or self-signed CA, install that CA on this "
|
||||
"machine so the system trusts it. Certificate checking is "
|
||||
"not optional."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"ok": False, "kind": "unreachable",
|
||||
"detail": f"Could not connect to {endpoint_url} — is Ollama running there?",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"ok": False, "kind": "timeout",
|
||||
"detail": f"{endpoint_url} did not answer within {TEST_TIMEOUT:.0f}s.",
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"ok": False, "kind": "error", "detail": f"Connection failed: {exc}"}
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {
|
||||
"ok": False, "kind": "http",
|
||||
"detail": f"HTTP {resp.status_code}: {resp.text[:300]}",
|
||||
}
|
||||
|
||||
models_available: list[str] = []
|
||||
try:
|
||||
data = resp.json()
|
||||
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # The body is not JSON or has an unexpected shape. The endpoint
|
||||
# is still reachable.
|
||||
return {"ok": True, "models": models_available}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_connection(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Checks the endpoint the turn engine would use, and lists its models."""
|
||||
settings = get_settings(db, user)
|
||||
result = await list_endpoint_models(settings.endpoint_url)
|
||||
if result.get("ok") and settings.model and settings.model not in result["models"]:
|
||||
# Reachable, but pointed at a model that is not installed there — the
|
||||
# commonest way for a correct endpoint to still fail every turn.
|
||||
return result | {
|
||||
"warning": (
|
||||
f"{settings.endpoint_url} is reachable, but has no model named "
|
||||
f"'{settings.model}'. Pull it there, or pick one from the list."
|
||||
)
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,186 @@
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import auth, limits, models, schemas
|
||||
from ..database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
|
||||
|
||||
# The AI Dungeon world-info and story-card array format. Its field names differ
|
||||
# from this app's columns: `value` maps to `entry`, `title` maps to `name`, and
|
||||
# `description` maps to `notes`. The extra `useForCharacterCreation` flag has no
|
||||
# equivalent here. It is ignored on import and written as false on export, so a
|
||||
# round trip through AI Dungeon stays valid.
|
||||
|
||||
|
||||
def _visible_owner(scenario_id, adventure_id, db, user):
|
||||
"""Resolves the scenario or adventure a caller may read cards from.
|
||||
|
||||
Public demo scenarios are included. The function raises a 404 or a 422 when
|
||||
it cannot resolve one.
|
||||
"""
|
||||
if (scenario_id is None) == (adventure_id is None):
|
||||
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
|
||||
if scenario_id is not None:
|
||||
scenario = db.get(models.Scenario, scenario_id)
|
||||
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
||||
raise HTTPException(404, "Owner not found")
|
||||
return scenario
|
||||
adventure = db.get(models.Adventure, adventure_id)
|
||||
if adventure is None or adventure.user_id != user.id:
|
||||
raise HTTPException(404, "Owner not found")
|
||||
return adventure
|
||||
|
||||
|
||||
def _card_editable_or_404(card: models.StoryCard | None, user: models.User) -> models.StoryCard:
|
||||
"""Cards inherit their scope from the owning scenario/adventure. Public
|
||||
(demo) scenarios are visible to everyone but editable by no one."""
|
||||
if card is not None:
|
||||
owner = card.scenario if card.scenario_id is not None else card.adventure
|
||||
if owner is not None and owner.user_id == user.id:
|
||||
return card
|
||||
raise HTTPException(404, "Story card not found")
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.StoryCardOut])
|
||||
def list_story_cards(
|
||||
scenario_id: int | None = None,
|
||||
adventure_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
if (scenario_id is None) == (adventure_id is None):
|
||||
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
|
||||
if scenario_id is not None:
|
||||
scenario = db.get(models.Scenario, scenario_id)
|
||||
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
||||
raise HTTPException(404, "Owner not found")
|
||||
return sorted(scenario.story_cards, key=lambda c: c.id)
|
||||
adventure = db.get(models.Adventure, adventure_id)
|
||||
if adventure is None or adventure.user_id != user.id:
|
||||
raise HTTPException(404, "Owner not found")
|
||||
return sorted(adventure.story_cards, key=lambda c: c.id)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.StoryCardOut, status_code=201)
|
||||
def create_story_card(
|
||||
payload: schemas.StoryCardCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
if (payload.scenario_id is None) == (payload.adventure_id is None):
|
||||
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
|
||||
owner_model = models.Scenario if payload.scenario_id else models.Adventure
|
||||
owner_id = payload.scenario_id or payload.adventure_id
|
||||
owner = db.get(owner_model, owner_id)
|
||||
if owner is None or owner.user_id != user.id:
|
||||
raise HTTPException(404, "Owner not found")
|
||||
limits.check_row_cap(
|
||||
"story_cards", db, user,
|
||||
scenario_id=payload.scenario_id, adventure_id=payload.adventure_id,
|
||||
)
|
||||
card = models.StoryCard(**payload.model_dump())
|
||||
db.add(card)
|
||||
db.commit()
|
||||
return card
|
||||
|
||||
|
||||
@router.patch("/{card_id}", response_model=schemas.StoryCardOut)
|
||||
def update_story_card(
|
||||
card_id: int,
|
||||
payload: schemas.StoryCardUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
card = _card_editable_or_404(db.get(models.StoryCard, card_id), user)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(card, field, value)
|
||||
db.commit()
|
||||
return card
|
||||
|
||||
|
||||
@router.delete("/{card_id}", status_code=204)
|
||||
def delete_story_card(
|
||||
card_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
card = _card_editable_or_404(db.get(models.StoryCard, card_id), user)
|
||||
db.delete(card)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- Bulk import / export (AI Dungeon world-info format) ----------
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_story_cards(
|
||||
scenario_id: int | None = None,
|
||||
adventure_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
owner = _visible_owner(scenario_id, adventure_id, db, user)
|
||||
return [
|
||||
{
|
||||
"keys": c.keys,
|
||||
"value": c.entry,
|
||||
"type": c.type,
|
||||
"title": c.name,
|
||||
"description": c.notes,
|
||||
"useForCharacterCreation": False,
|
||||
}
|
||||
for c in sorted(owner.story_cards, key=lambda c: c.id)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/import", response_model=list[schemas.StoryCardOut], status_code=201)
|
||||
def import_story_cards(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Append a list of story cards (AI Dungeon world-info format, or our own
|
||||
export) to a scenario/adventure the caller owns."""
|
||||
scenario_id = payload.get("scenario_id")
|
||||
adventure_id = payload.get("adventure_id")
|
||||
if (scenario_id is None) == (adventure_id is None):
|
||||
raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id")
|
||||
owner_model = models.Scenario if scenario_id is not None else models.Adventure
|
||||
owner = db.get(owner_model, scenario_id if scenario_id is not None else adventure_id)
|
||||
if owner is None or owner.user_id != user.id:
|
||||
raise HTTPException(404, "Owner not found")
|
||||
|
||||
# Accept a bare array or {"cards": [...]} / {"storyCards": [...]}, so an
|
||||
# AI Dungeon world-info file dropped in as-is still works.
|
||||
cards_in = payload.get("cards") or payload.get("storyCards") or payload.get("worldInfo")
|
||||
if not isinstance(cards_in, list):
|
||||
raise HTTPException(422, 'Expected a "cards" array of story cards.')
|
||||
cards_in = [c for c in cards_in if isinstance(c, dict)]
|
||||
|
||||
limits.check_bundle_lists(story_cards=cards_in)
|
||||
existing = len(owner.story_cards)
|
||||
if auth.MULTI_USER and existing + len(cards_in) > limits.MAX_STORY_CARDS_PER_OWNER:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Importing {len(cards_in)} would exceed the limit of "
|
||||
f"{limits.MAX_STORY_CARDS_PER_OWNER} story cards here "
|
||||
f"({existing} already present) — remove some first.",
|
||||
)
|
||||
|
||||
created = []
|
||||
for card in cards_in:
|
||||
obj = models.StoryCard(
|
||||
scenario_id=scenario_id,
|
||||
adventure_id=adventure_id,
|
||||
type=str(card.get("type") or "")[:schemas.CARD_TYPE_MAX],
|
||||
name=str(card.get("title") or card.get("name") or "")[:schemas.NAME_MAX],
|
||||
keys=str(card.get("keys") or "")[:schemas.PROSE_MAX],
|
||||
entry=str(card.get("value") or card.get("entry") or "")[:schemas.PROSE_MAX],
|
||||
notes=str(card.get("description") or card.get("notes") or "")[:schemas.PROSE_MAX],
|
||||
)
|
||||
db.add(obj)
|
||||
created.append(obj)
|
||||
db.commit()
|
||||
return created
|
||||
@@ -0,0 +1,439 @@
|
||||
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 caps are a correctness requirement rather
|
||||
# than only an abuse limit. Postgres enforces column lengths and SQLite never
|
||||
# did, so a longer value has to be a 422 here rather than a 500 at INSERT. The
|
||||
# text-column caps are generous abuse ceilings that a legitimate player does not
|
||||
# reach.
|
||||
NAME_MAX = 200 # Titles and names. VARCHAR(200).
|
||||
TAGS_MAX = 500 # VARCHAR(500).
|
||||
CARD_TYPE_MAX = 100 # VARCHAR(100).
|
||||
PROSE_MAX = 50_000 # Memory, author's note, prompts, entries, and notes.
|
||||
ACTION_MAX = 20_000 # One player action.
|
||||
MEMORY_TEXT_MAX = 5_000
|
||||
# A scenario cover image, stored inline as a base64 data URI. A 400x300 WebP at
|
||||
# the quality the editor encodes runs about 20 to 40 kB. A cap of 400 kB leaves
|
||||
# room for a client that downscales less aggressively, and it stops anyone from
|
||||
# storing a multi-megabyte PNG in a row that every list request reads.
|
||||
IMAGE_MAX = 400_000
|
||||
ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
|
||||
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
|
||||
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
|
||||
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
|
||||
|
||||
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)]
|
||||
ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
|
||||
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
|
||||
Icon = Annotated[str, Field(max_length=ICON_MAX)]
|
||||
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
|
||||
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_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, either an https URL or a base64 data URI. See `app/images.py`.
|
||||
image: Image = ""
|
||||
# The emoji or glyph shown when `image` is empty.
|
||||
icon: Icon = ""
|
||||
# Phase 12: the RPG world-state template, holding stat definitions, bands,
|
||||
# rules, and milestones. `None` means the scenario has 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
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
|
||||
class ScenarioListItem(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
tags: str
|
||||
is_public: bool = False
|
||||
updated_at: datetime
|
||||
# Read from the row so that `image_url` can be derived, and excluded from
|
||||
# the response, because 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
|
||||
# The `${Placeholder}` values collected from the player at the start, which
|
||||
# is the AI Dungeon behavior.
|
||||
placeholders: dict[str, str] = {}
|
||||
# Phase 18: who the player is playing as, collected by the same modal. These
|
||||
# are independent of `placeholders`: a scenario that asks for `${Name}` is
|
||||
# asking its own question, and nothing here fills it in.
|
||||
persona_name: PersonaName = ""
|
||||
persona_pronouns: PersonaPronouns = ""
|
||||
persona_desc: Prose = ""
|
||||
|
||||
|
||||
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
|
||||
persona_name: PersonaName | None = None
|
||||
persona_pronouns: PersonaPronouns | None = None
|
||||
persona_desc: Prose | None = None
|
||||
|
||||
|
||||
class AdventureRefresh(BaseModel):
|
||||
"""The body for "Update from scenario".
|
||||
|
||||
`placeholders` supplies answers the adventure has no stored value for. See
|
||||
`AdventureCreate.placeholders`. The answers are merged over the stored ones
|
||||
and saved.
|
||||
"""
|
||||
|
||||
placeholders: dict[str, str] = {}
|
||||
|
||||
|
||||
class RefreshPlan(BaseModel):
|
||||
"""What a refresh would change. The confirm dialog is built from this."""
|
||||
|
||||
scenario_id: int
|
||||
scenario_title: str
|
||||
has_changes: bool
|
||||
# Maps a field name to `{"old": ..., "new": ...}`, for differing fields
|
||||
# only.
|
||||
fields: dict[str, dict] = {}
|
||||
# Maps "added", "updated", or "removed" to a list of card names.
|
||||
cards: dict[str, list[str]] = {}
|
||||
# Maps "added" or "removed" to a list of stat paths. Live values are
|
||||
# otherwise kept.
|
||||
world_state: dict[str, list[str]] = {}
|
||||
# The `${Placeholder}` names the scenario asks for that the adventure has
|
||||
# no stored answer to. The client collects these and sends them back.
|
||||
placeholders_needed: list[str] = []
|
||||
|
||||
|
||||
class ActionOut(ORMModel):
|
||||
id: int
|
||||
adventure_id: int
|
||||
type: str
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# Phase 12: the compact RPG state changes for this turn, read from the
|
||||
# model property.
|
||||
world_changes: list[dict] = []
|
||||
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
|
||||
# and which one is on screen. It is keyed on the parent, so it counts the
|
||||
# attempts of this turn rather than every node that shares a depth, and it
|
||||
# keeps counting them after one has been forked onto its own branch.
|
||||
#
|
||||
# A turn nobody has retaken reads 1/1, which is most turns, and the client
|
||||
# draws no pager for a count of one. The attempts themselves come from
|
||||
# `GET /actions/{id}/variants`, so this payload stays small.
|
||||
take_count: int = 1
|
||||
take_index: int = 0
|
||||
# Which line this node is on, so the pager can distinguish the two kinds of
|
||||
# step without asking the server. An attempt on this branch is a leaf with
|
||||
# nothing below it, so showing it is a local change. An attempt on another
|
||||
# branch has a story of its own, so moving to it 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 that id. A fork is addressed by the attempt being promoted,
|
||||
# not by its position in a group that renumbers whenever an attempt is
|
||||
# added.
|
||||
id: int
|
||||
index: int
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# See `ActionOut.branch_id`. It decides whether choosing this attempt 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).
|
||||
|
||||
This carries 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 why the number is smaller than a 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 labels those from the fork
|
||||
# depth rather than the server inventing a name. See the column comment.
|
||||
name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BranchRename(BaseModel):
|
||||
"""A name a player chose, or `null` to make the branch unnamed again."""
|
||||
|
||||
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). Omitting it means the tip,
|
||||
# which is what every ordinary turn uses.
|
||||
#
|
||||
# Naming an attempt the story moved past is what creates a branch. Stepping
|
||||
# between attempts costs nothing and creates nothing, and the fork happens
|
||||
# on the first text written below one. That is the first moment the player
|
||||
# states which line they mean. Before it, they were reading.
|
||||
after_id: int | None = None
|
||||
|
||||
|
||||
class TakeCreate(BaseModel):
|
||||
"""Another attempt at a turn (SP9).
|
||||
|
||||
`text` is what the player says instead, and it applies only when the turn was
|
||||
the player's. An AI turn's other attempt is generated, so the field is
|
||||
ignored there rather than rejected. The client makes the same request 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
|
||||
persona_name: str
|
||||
persona_pronouns: str
|
||||
persona_desc: str
|
||||
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 more actions exist 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. The server computes it, 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
|
||||
# The end of the most recent narration, so a Continue card can show the
|
||||
# story rather than only a turn count.
|
||||
snippet: str = ""
|
||||
# Cover art inherited from the parent scenario. See `app/images.py`.
|
||||
image_url: str = ""
|
||||
icon: str = ""
|
||||
|
||||
|
||||
# ---------- Auth (Phase 8) ----------
|
||||
|
||||
class AuthCredentials(BaseModel):
|
||||
email: Annotated[str, Field(max_length=320)] # VARCHAR(320).
|
||||
# The upper bound keeps the scrypt cost constant. Without it, hashing a
|
||||
# megabyte password would give an attacker free CPU time.
|
||||
password: Annotated[str, Field(max_length=128)]
|
||||
|
||||
|
||||
# ---------- Settings ----------
|
||||
|
||||
class SettingsOut(ORMModel):
|
||||
endpoint_url: str
|
||||
model: str
|
||||
api_mode: str
|
||||
temperature: float
|
||||
max_output_tokens: int
|
||||
context_token_budget: int
|
||||
model_timeout_seconds: int
|
||||
narrator_prompt: str
|
||||
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. The
|
||||
# server persists nothing, so these caps are per-request abuse limits only.
|
||||
|
||||
CHAT_MESSAGE_MAX = 100_000 # One message.
|
||||
CHAT_TOTAL_MAX = 400_000 # The whole conversation sent 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)]
|
||||
# If this field is empty or omitted, the user's configured model is used.
|
||||
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).
|
||||
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
|
||||
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
|
||||
# Seconds to wait for the model. The floor is high enough that a normal
|
||||
# turn cannot trip it; the ceiling exists so that "wait longer" stays a
|
||||
# number rather than becoming "wait forever".
|
||||
model_timeout_seconds: Annotated[int, Field(ge=30, le=3600)] | None = None
|
||||
narrator_prompt: Prose | 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
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Seed public demo scenarios on startup.
|
||||
|
||||
Every JSON file in ``seed_data/`` describes one demo scenario in the same
|
||||
model-native shape the export endpoint produces. Seeded scenarios have a NULL
|
||||
owner and ``is_public=True``, so every visitor (including guests) sees them and
|
||||
can start an adventure from them, while nobody can edit them. Starting an
|
||||
adventure copies the scenario's story cards into the adventure.
|
||||
|
||||
Seed files are the source of truth for demo content: a scenario is inserted if
|
||||
missing, reconciled in place when a seed file's content changes, and deleted
|
||||
when no file claims its title any more, so an edit ships on the next deploy.
|
||||
Rename a seed by changing its `title` and listing the old one under
|
||||
`previous_titles`, which moves the rename onto the existing row. When a seed already matches, nothing is written, so
|
||||
this stays cheap to run on every boot. An adventure already started from a demo
|
||||
keeps its own copied cards and is unchanged. Only a new adventure
|
||||
picks up the updated content.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from . import models
|
||||
from .database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parent / "seed_data"
|
||||
|
||||
# `title` is in this list so that a rename found through `previous_titles` is
|
||||
# both detected by `_matches` and written by `_apply_scalars`.
|
||||
_SCALARS = ("title", "description", "prompt", "memory", "authors_note", "ai_instructions",
|
||||
"tags", "image", "icon")
|
||||
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
|
||||
|
||||
|
||||
def seed_public_scenarios(engine: Engine) -> None:
|
||||
if not SEED_DIR.is_dir():
|
||||
return
|
||||
files = sorted(SEED_DIR.glob("*.json"))
|
||||
if not files:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
changed = 0
|
||||
# Every title the files claim, including the ones they used to use. The
|
||||
# sweep below deletes the seeded rows this set does not name.
|
||||
claimed: set[str] = set()
|
||||
complete = True
|
||||
for path in files:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Skipping seed file %s: %s", path.name, exc)
|
||||
complete = False
|
||||
continue
|
||||
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
|
||||
claimed.add(title)
|
||||
claimed.update(str(old) for old in data.get("previous_titles") or [])
|
||||
|
||||
existing = find_seeded(db, title) or _find_renamed(db, data)
|
||||
if existing is None:
|
||||
_insert_scenario(db, data)
|
||||
changed += 1
|
||||
elif not _matches(existing, data):
|
||||
_update_scenario(db, existing, data)
|
||||
changed += 1
|
||||
|
||||
changed += _sweep_unclaimed(db, claimed) if complete else 0
|
||||
if changed:
|
||||
db.commit()
|
||||
logger.info("Seeded/updated %d public demo scenario(s).", changed)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
# A seed failure must never take the app down; log and carry on.
|
||||
logger.exception("Seeding public scenarios failed; continuing without them.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _sweep_unclaimed(db, claimed: set[str]) -> int:
|
||||
"""Deletes seeded scenarios no seed file claims any more, and returns how
|
||||
many went.
|
||||
|
||||
A rename used to strand the row it left behind. `previous_titles` stops new
|
||||
ones appearing, and this removes the ones already out there, which was
|
||||
otherwise hand-work on every deployment. Only rows with a NULL owner and
|
||||
`is_public` are considered, and a player's own scenario is neither, so
|
||||
nothing anybody created can be reached from here.
|
||||
|
||||
An adventure started from a deleted demo survives. `adventures.scenario_id`
|
||||
is `ON DELETE SET NULL`, so the story and its cards are its
|
||||
own copies and stay; the adventure loses the cover art it inherited.
|
||||
|
||||
The caller skips this when a seed file failed to parse. A file that cannot
|
||||
be read claims no title, and deleting on that basis would treat a syntax
|
||||
error as an instruction to remove live content. An empty seed directory
|
||||
never reaches here at all, for the same reason.
|
||||
"""
|
||||
stale = (
|
||||
db.query(models.Scenario)
|
||||
.filter(
|
||||
models.Scenario.user_id.is_(None),
|
||||
models.Scenario.is_public.is_(True),
|
||||
models.Scenario.title.notin_(claimed) if claimed else True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for scenario in stale:
|
||||
logger.info("Removing seeded scenario %r; no seed file claims it.", scenario.title)
|
||||
db.delete(scenario)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _card_tuple(source, get) -> tuple:
|
||||
return tuple(get(source, f) for f in _CARD_FIELDS)
|
||||
|
||||
|
||||
def find_seeded(db, title: str) -> models.Scenario | None:
|
||||
"""Returns the seeded scenario with this exact title, if there is one."""
|
||||
return (
|
||||
db.query(models.Scenario)
|
||||
.filter(
|
||||
models.Scenario.title == title,
|
||||
models.Scenario.user_id.is_(None),
|
||||
models.Scenario.is_public.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _find_renamed(db, data: dict) -> models.Scenario | None:
|
||||
"""Returns the row a renamed seed file used to own, so the rename lands on it.
|
||||
|
||||
A seed is matched by title, so renaming one inserts a second scenario and
|
||||
strands the first. The stranded row stays public forever and has to be
|
||||
deleted by hand on every deployment. List the old title under
|
||||
`previous_titles` in the seed file and the rename updates the existing row
|
||||
instead, which also keeps the adventures already started from it pointing at
|
||||
a scenario that still exists.
|
||||
|
||||
Drop a `previous_titles` entry once every deployment has booted past it.
|
||||
"""
|
||||
for old in data.get("previous_titles") or []:
|
||||
found = find_seeded(db, str(old))
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _matches(scenario: models.Scenario, data: dict) -> bool:
|
||||
"""True when the DB scenario already equals the seed file, so we can skip
|
||||
the write and avoid churning rows on every boot."""
|
||||
if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS):
|
||||
return False
|
||||
if (scenario.stat_schema or None) != (data.get("stat_schema") or None):
|
||||
return False
|
||||
have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards)
|
||||
want_cards = sorted(
|
||||
_card_tuple(c, lambda o, f: o.get(f, ""))
|
||||
for c in (data.get("story_cards") or []) if isinstance(c, dict)
|
||||
)
|
||||
return have_cards == want_cards
|
||||
|
||||
|
||||
def _insert_scenario(db, data: dict) -> None:
|
||||
scenario = models.Scenario(user_id=None, is_public=True, title=data.get("title", ""))
|
||||
_apply_scalars(scenario, data)
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
_populate_children(db, scenario, data)
|
||||
|
||||
|
||||
def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
|
||||
_apply_scalars(scenario, data)
|
||||
# Replace the child content in full. Demo content is owned by the server and
|
||||
# cheap to rebuild, and replacing it this way keeps the scenario row, and the
|
||||
# adventure foreign keys that point at it, intact.
|
||||
for card in list(scenario.story_cards):
|
||||
db.delete(card)
|
||||
db.flush()
|
||||
_populate_children(db, scenario, data)
|
||||
|
||||
|
||||
def _apply_scalars(scenario: models.Scenario, data: dict) -> None:
|
||||
for field in _SCALARS:
|
||||
setattr(scenario, field, data.get(field, ""))
|
||||
# Phase 12: RPG world-state template (a JSON dict, not a scalar string).
|
||||
scenario.stat_schema = data.get("stat_schema") or None
|
||||
|
||||
|
||||
def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
|
||||
for card in data.get("story_cards") or []:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
db.add(
|
||||
models.StoryCard(
|
||||
scenario_id=scenario.id,
|
||||
type=card.get("type", ""),
|
||||
name=card.get("name", ""),
|
||||
keys=card.get("keys", ""),
|
||||
entry=card.get("entry", ""),
|
||||
notes=card.get("notes", ""),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"title": "[Demo] The Sunken Crypt of Vharos",
|
||||
"description": "A short dungeon-crawl demo scenario with story cards and one of each script hook, for testing the app end to end.",
|
||||
"prompt": "Rain hammers the moors as you descend the moss-slick steps beneath the ruined chapel. Your torch gutters in the stale air. Below, the Sunken Crypt of Vharos waits — its iron door ajar, as if someone (or something) expected you. Mira's warning rings in your ears: bring back the Ember Amulet before nightfall, or the village of Hollowmere burns.\n\nYou stand before the iron door, water pooling around your boots.",
|
||||
"memory": "The player is an adventurer hired by Mira, blacksmith of Hollowmere, to retrieve the Ember Amulet from the Sunken Crypt of Vharos before nightfall. The crypt is flooded, dark, and haunted. Tone: classic D&D dungeon crawl, dangerous but fair.",
|
||||
"authors_note": "Keep scenes tense and grounded; offer clear choices; consequences matter.",
|
||||
"ai_instructions": "Write in second person, present tense. End each response at a moment where the player can act.",
|
||||
"tags": "demo, dungeon, fantasy, short",
|
||||
"story_cards": [
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Mira the Blacksmith",
|
||||
"keys": "Mira, blacksmith",
|
||||
"entry": "Mira is Hollowmere's blacksmith: broad-shouldered, gray-braided, practical. She hired the player and paid half up front. She knows more about the crypt than she has admitted.",
|
||||
"notes": "Secretly a descendant of Vharos."
|
||||
},
|
||||
{
|
||||
"type": "location",
|
||||
"name": "The Sunken Crypt",
|
||||
"keys": "crypt, tomb, Vharos",
|
||||
"entry": "A flooded burial complex beneath a ruined chapel. Knee-deep black water, collapsed pillars, and phosphorescent moss. Three chambers: the Drowned Hall, the Ossuary, and the sealed Reliquary where the Ember Amulet rests.",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "The Ember Amulet",
|
||||
"keys": "amulet, ember",
|
||||
"entry": "A fist-sized garnet on a bronze chain that glows like a coal. It keeps Hollowmere's protective hearth-ward burning. Touching it bare-handed brands the flesh but does no lasting harm.",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"scripts": [
|
||||
{
|
||||
"name": "[Demo] Dice Roller",
|
||||
"description": "Input hook + shared library. Type '!roll 2d6' or '!roll d20' in a Do/Say/Story action and the command is replaced with the rolled result.",
|
||||
"library_js": "// Shared library: available to every hook of this script.\nfunction rollDice(count, sides) {\n var total = 0, rolls = [];\n for (var i = 0; i < count; i++) {\n var r = Math.floor(Math.random() * sides) + 1;\n rolls.push(r);\n total += r;\n }\n return { total: total, rolls: rolls };\n}\n",
|
||||
"input_js": "const modifier = (text) => {\n // Replace every \"!roll NdS\" (N optional) with the roll result.\n var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) {\n var count = parseInt(n || \"1\", 10);\n var sides = parseInt(s, 10);\n var res = rollDice(count, sides);\n log(\"Rolled \" + count + \"d\" + sides + \": [\" + res.rolls.join(\", \") + \"] = \" + res.total);\n return \"(rolled \" + count + \"d\" + sides + \": \" + res.total + \")\";\n });\n return { text: out };\n};\nmodifier(text);\n",
|
||||
"context_js": "",
|
||||
"output_js": ""
|
||||
},
|
||||
{
|
||||
"name": "[Demo] Turn & HP Tracker",
|
||||
"description": "Demonstrates persistent state. Counts turns; '!hp -3' or '!hp +5' in input adjusts HP (starts at 20). Current stats appear in state.message.",
|
||||
"library_js": "",
|
||||
"input_js": "const modifier = (text) => {\n if (state.hp === undefined) state.hp = 20;\n state.turns = (state.turns || 0) + 1;\n\n var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) {\n state.hp += parseInt(delta, 10);\n return \"\";\n });\n\n state.message = \"Turn \" + state.turns + \" | HP: \" + state.hp + \"/20\";\n log(state.message);\n\n if (state.hp <= 0) {\n // stop:true ends the turn before the AI is called.\n return { text: out + \"\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)\", stop: true };\n }\n return { text: out };\n};\nmodifier(text);\n",
|
||||
"context_js": "",
|
||||
"output_js": ""
|
||||
},
|
||||
{
|
||||
"name": "[Demo] Context Inspector",
|
||||
"description": "Context hook: logs the size of the assembled context each turn and appends a style directive. Check the logs/context in the action's context snapshot.",
|
||||
"library_js": "",
|
||||
"input_js": "",
|
||||
"context_js": "const modifier = (text) => {\n log(\"Context size: \" + text.length + \" chars, actions so far: \" + info.actionCount\n + \", story cards: \" + storyCards.length);\n // Anything returned here replaces what is sent to the model.\n return { text: text + \"\\n[Style: keep the response under three paragraphs.]\" };\n};\nmodifier(text);\n",
|
||||
"output_js": ""
|
||||
},
|
||||
{
|
||||
"name": "[Demo] Output Polish + Card Discovery",
|
||||
"description": "Output hook: trims a trailing incomplete sentence from the AI reply, and auto-creates a story card the first time the ghost Vharos is mentioned.",
|
||||
"library_js": "",
|
||||
"input_js": "",
|
||||
"context_js": "",
|
||||
"output_js": "const modifier = (text) => {\n var out = text;\n\n // Drop a trailing sentence fragment (no ending punctuation).\n var m = out.match(/^([\\s\\S]*[.!?\"'\\u2026])[^.!?\"'\\u2026]*$/);\n if (m && m[1].length > 40) {\n if (m[1].length < out.length) log(\"Trimmed incomplete final sentence.\");\n out = m[1];\n }\n\n // Demonstrate script-created story cards.\n if (/vharos/i.test(out)) {\n var added = addStoryCard(\n \"Vharos, ghost, spirit\",\n \"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.\",\n \"character\"\n );\n if (added !== false) log(\"Vharos mentioned — story card created.\");\n }\n\n return { text: out };\n};\nmodifier(text);\n"
|
||||
}
|
||||
],
|
||||
"icon": "🗝"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "[Demo] Signal from the Derelict",
|
||||
"description": "A tense sci-fi mystery aboard a drifting research station. Showcases the memory and story-card context engine with no scripts required.",
|
||||
"prompt": "The airlock cycles with a hiss, and the cold of the Meridian station folds around your suit. Six days ago it stopped answering hails; three hours ago your ship's antenna caught a single repeating pulse from inside — a heartbeat rhythm, too regular to be an accident. Emergency lighting stutters red down the corridor ahead. Your suit reads breathable air, faint traces of ozone, and no life signs.\n\nYou float at the threshold, the pulse ticking softly in your headset.",
|
||||
"memory": "The player is a salvage contractor who boarded the derelict research station Meridian after it went silent six days ago. A repeating signal is coming from somewhere inside. The crew of nine is missing. Tone: grounded, tense sci-fi mystery — quiet dread over action.",
|
||||
"authors_note": "Keep the atmosphere claustrophobic and quiet; reward careful investigation; make the station feel physical and specific.",
|
||||
"ai_instructions": "Write in second person, present tense. Favor sensory detail and restraint. End each response where the player can act.",
|
||||
"tags": "demo, sci-fi, mystery, short",
|
||||
"story_cards": [
|
||||
{
|
||||
"type": "location",
|
||||
"name": "Meridian Station",
|
||||
"keys": "Meridian, station, ship",
|
||||
"entry": "A mid-sized orbital research station in a decaying orbit around a gas giant. Ring layout: docking spur, crew habitat, the labs, and the sealed reactor core. Power is failing; artificial gravity is intermittent. It smells faintly of ozone and antiseptic.",
|
||||
"notes": "Orbit decays fully in roughly 40 hours — a soft clock."
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Dr. Reyes",
|
||||
"keys": "Reyes, doctor, chief scientist",
|
||||
"entry": "Dr. Ana Reyes was Meridian's chief scientist: meticulous, secretive, protective of her work. Her personal logs are locked. The last public entry, six days ago, reads only: 'It answered back.'",
|
||||
"notes": "Whereabouts unknown. Her lab is sealed from the inside."
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "The Signal",
|
||||
"keys": "signal, pulse, heartbeat",
|
||||
"entry": "A repeating low-frequency pulse broadcasting from deep in the station, roughly one beat per second. It is not a standard distress code. The closer you get to the labs, the stronger it becomes.",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"scripts": [],
|
||||
"icon": "🛰"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "[Demo] The Goblin Ambush (HP script)",
|
||||
"description": "A tiny combat scene that shows script-driven state: the AI narrates and decides damage, a JavaScript script tracks your HP. Open the State panel on the left to watch it change.",
|
||||
"prompt": "The forest path narrows between two moss-choked boulders. You are halfway through when the bushes erupt — three goblins, blades drawn, teeth bared. The lead one lunges at you with a rusty scimitar.\n\nYou have no time to think.",
|
||||
"memory": "The player is a lone traveler ambushed by three goblins on a forest path. This is a dangerous but fair combat encounter. The player starts at full health.",
|
||||
"authors_note": "Keep the combat visceral and consequential. Let the player's choices matter — a reckless move should hurt, a clever one should pay off.",
|
||||
"ai_instructions": "Write in second person, present tense. End each response where the player can act.",
|
||||
"tags": "demo, combat, scripting, hp, short",
|
||||
"story_cards": [],
|
||||
"scripts": [
|
||||
{
|
||||
"name": "[Demo] HP System",
|
||||
"description": "AI-driven health. The script tells the AI the current HP and to tag damage/healing as [HP-N]/[HP+N]; the AI decides the amounts, the script applies them to state.hp and hides the tags. Watch state.hp in the left State panel.",
|
||||
"library_js": "// Shared HP helpers — available to every hook of this script.\nvar HP_MAX = 100;\nfunction getHp() {\n if (typeof state.hp !== \"number\") state.hp = HP_MAX;\n return state.hp;\n}\nfunction setHp(value) {\n state.hp = Math.max(0, Math.min(HP_MAX, Math.round(value)));\n return state.hp;\n}\n",
|
||||
"input_js": "",
|
||||
"context_js": "// Tell the AI the current HP and how to report changes it decides on.\n// The tag goes on the FIRST line so it survives even if the reply is cut off.\nvar modifier = function (text) {\n var hp = getHp();\n var note =\n \"\\n\\n[HP SYSTEM] The player currently has \" + hp + \" of \" + HP_MAX + \" health. \" +\n \"Based on the story, you decide whether the player loses health (an attack, fall, or trap) \" +\n \"or gains it (a potion, rest, or spell) this turn. \" +\n \"If their health changes, make the FIRST line of your reply the change by itself in square brackets: \" +\n \"write [HP-14] to show losing 14, or [HP+25] to show gaining 25 (choose the number that fits the moment). \" +\n \"Then write the story on the following lines. If health does not change, do not write any bracket tag. \" +\n \"Never mention this tag or the health system in the story prose itself.\";\n return { text: text + note };\n};\nmodifier(text);\n",
|
||||
"output_js": "// Read the AI's [HP+N]/[HP-N] tags, apply them to state, hide them from the player.\nvar modifier = function (text) {\n var re = /\\[HP([+-])(\\d{1,3})\\]/g;\n var hp = getHp();\n var changed = false;\n var m;\n while ((m = re.exec(text)) !== null) {\n var amount = parseInt(m[2], 10);\n hp += (m[1] === \"-\" ? -amount : amount);\n changed = true;\n }\n if (changed) setHp(hp);\n var clean = text\n .replace(/\\[HP[+-]\\d{1,3}\\]/g, \"\")\n .replace(/[ \\t]+\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n if (changed && getHp() <= 0 && !state.dead) {\n state.dead = true;\n clean += \"\\n\\nYour strength gives out and the world goes dark. (0 HP)\";\n }\n return { text: clean };\n};\nmodifier(text);\n"
|
||||
}
|
||||
],
|
||||
"icon": "🗡"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"title": "[Demo] The Bandit Camp (RPG world state)",
|
||||
"description": "A short RPG scene showing the built-in world-state system: the AI tracks your HP and mana, an in-game day counter, an NPC ally's health and trust, and story milestones. No scripting — the engine keeps the numbers honest. Open the World State panel on the left to watch it change.",
|
||||
"prompt": "Dawn breaks grey over the treeline as you and Gwen crouch at the edge of the bandit camp. Smoke curls from a dying fire; three bedrolls lie empty. Somewhere ahead, the stolen caravan strongbox waits.\n\nGwen checks her bowstring and looks to you. \"Quiet, or loud?\"",
|
||||
"memory": "The player and Gwen, a loyal ranger ally, are raiding a bandit camp to recover a stolen strongbox. The player is a capable adventurer. This is a dangerous but winnable encounter.",
|
||||
"authors_note": "Keep it tense and consequential. Reckless moves should cost health; clever ones should pay off. Gwen reacts to how the player treats her.",
|
||||
"ai_instructions": "Write in second person, present tense. End each reply where the player can act. Let the world state guide the fiction — if the player is badly hurt, show it. Reflect what happens in the numbers each turn: change health when someone is hurt or healed, shift Gwen's trust based on how the player treats her, move the leader's aggression as the situation escalates or calms, and mark milestones as they are reached.",
|
||||
"tags": "demo, rpg, world-state, combat, short",
|
||||
"icon": "🏹",
|
||||
"stat_schema": {
|
||||
"world": {
|
||||
"day": { "type": "counter", "min": 1, "initial": 1, "desc": "Which in-game day it is; only ever counts up." }
|
||||
},
|
||||
"player": {
|
||||
"hp": {
|
||||
"desc": "The player's physical health. At 0 they fall.",
|
||||
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
|
||||
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
|
||||
[50, 80, "scratched"], [80, 100, "healthy"], [100, 101, "full health"]]
|
||||
},
|
||||
"mana": {
|
||||
"desc": "Magical energy for spells; spent casting, restored by resting.",
|
||||
"min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25,
|
||||
"bands": [[0, 10, "drained"], [10, 30, "steady"], [30, 51, "brimming"]]
|
||||
},
|
||||
"outfit": {
|
||||
"type": "text", "initial": "worn leather armor, travel-stained",
|
||||
"desc": "What the player is currently wearing/carrying visibly; update it when gear is gained, lost, or changed."
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"alarm_raised": { "desc": "True once the bandits know they're under attack; stealth is blown.", "initial": false },
|
||||
"player_hidden": { "desc": "True while the player is out of sight in cover.", "initial": true }
|
||||
},
|
||||
"npcs": {
|
||||
"gwen": {
|
||||
"name": "Gwen",
|
||||
"keys": "Gwen, ranger, her",
|
||||
"desc": "A loyal ranger and the player's ally. Quick with a bow, dry-humoured, fiercely protective. Her trust rises when the player fights smart and watches her back, and falls when they are reckless with her life.",
|
||||
"stats": {
|
||||
"health": {
|
||||
"desc": "Gwen's physical health.",
|
||||
"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 35,
|
||||
"bands": [[0, 1, "dead"], [1, 25, "gravely wounded"], [25, 50, "hurt"],
|
||||
[50, 80, "scratched"], [80, 101, "healthy"]]
|
||||
},
|
||||
"trust": {
|
||||
"desc": "How much Gwen trusts the player; rises with smart, loyal play and falls with recklessness.",
|
||||
"min": -100, "max": 100, "initial": 20, "max_delta_per_turn": 20,
|
||||
"bands": [[-100, -30, "hostile"], [-30, 30, "wary"], [30, 70, "friendly"],
|
||||
[70, 101, "devoted"]]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bandit_leader": {
|
||||
"name": "Bandit Leader",
|
||||
"keys": "leader, chief, boss, scarred",
|
||||
"desc": "The scarred leader of the bandit camp, guarding the strongbox. Fights harder the more cornered he becomes.",
|
||||
"stats": {
|
||||
"health": {
|
||||
"desc": "The leader's physical health.",
|
||||
"min": 0, "max": 120, "initial": 120, "max_delta_per_turn": 40,
|
||||
"bands": [[0, 1, "dead"], [1, 30, "near death"], [30, 70, "bloodied"],
|
||||
[70, 121, "unhurt"]]
|
||||
},
|
||||
"aggression": {
|
||||
"desc": "How aggressively the leader fights; climbs as the fight turns against him.",
|
||||
"min": 0, "max": 100, "initial": 40, "max_delta_per_turn": 25,
|
||||
"bands": [[0, 30, "cautious"], [30, 70, "fierce"], [70, 101, "berserk"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"milestones": {
|
||||
"camp_cleared": { "desc": "Clear the bandit camp of enemies" },
|
||||
"strongbox_found": { "desc": "Recover the stolen strongbox" },
|
||||
"gwen_survives": { "desc": "Escape with Gwen still alive" }
|
||||
}
|
||||
},
|
||||
"story_cards": [
|
||||
{
|
||||
"type": "location",
|
||||
"name": "Bandit Camp",
|
||||
"keys": "camp, bandits, strongbox",
|
||||
"entry": "A rough camp of bandits in a forest clearing, holding a stolen caravan strongbox.",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"title": "[Demo] Pokemon League Championship: Round One",
|
||||
"previous_titles": ["[Demo] League Championship: Round One"],
|
||||
"description": "A full Pokémon battle run on the built-in world-state system. Every member of your team is tracked as its own entity with HP and a status condition, a text field names who is currently out, and your potion supply runs down as you use it. The opponent's active Pokémon is tracked the same way. No scripting — the engine keeps the numbers honest. Open the World State panel on the left to watch the battle play out.",
|
||||
"prompt": "The floodlights come up over the championship field and the referee raises both flags. Round One of eight. Beat the trainer in front of you or go home.\n\nAcross the packed rock terrain, Trainer Milo tosses his first Poké Ball. Graveler lands hard enough to crack the stone under it, four arms flexing, and settles into a low stance.\n\n\"Rock-types all the way down,\" Milo calls over the noise. \"Hope you brought something that hits harder than it looks.\"\n\nYour Pidgeotto is already in the air, wings locked into a slow circle above the field. It is fast, but Rock-type moves will tear it out of the sky, and Graveler is built to take a hit. Three Hyper Potions sit in your belt pouch.\n\nGraveler grinds one fist into its palm. The referee drops the flag.\n\nWhat's your move?",
|
||||
"memory": "The player is a Trainer in Round One of an eight-round League Championship. The Round One opponent is Milo, a Rock-type specialist with three Pokémon: Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). The player's team is Pidgeotto (Normal/Flying), Ivysaur (Grass/Poison), Wartortle (Water), Machoke (Fighting), and Pikachu (Electric). Pidgeotto is out first. The player carries Hyper Potions, which restore HP and can be swapped for a status heal when needed. A battle is lost when every Pokémon on one side has fainted.",
|
||||
"authors_note": "Play it like a real battle. Type matchups decide damage, status conditions stick around and cost the afflicted Pokémon turns, and switching costs the player a turn while the opponent attacks freely. Milo is competent: he switches to punish bad matchups and targets whatever is weakest.",
|
||||
"ai_instructions": "Write in second person, present tense. Narrate the battle move by move, naming the active Pokémon on each side and the move each one uses. End every reply at a decision point where the player can choose a move, switch Pokémon, or use an item.\n\nReflect type advantage and disadvantage in the damage you narrate and in the HP numbers: a super-effective hit should take roughly double a neutral hit, a resisted hit roughly half. Ground and Rock moves hit Pikachu and Pidgeotto hard. Water, Grass, and Fighting moves are strong against Milo's Rock-types.\n\nEvery turn, update the state. Every number you send is a change to add to the current value, never the value you want it to end up at. The world state lines show you totals; the block you write carries the difference. A Pokémon at 96 HP that takes 30 damage is `-30`, not `66`.\n- Add 1 to `world.turn` in every reply, including this one. It counts the turns this battle has lasted, so it moves whether or not anything else does.\n- Send a negative number for HP lost and a positive number for HP restored on both sides (`npc.<name>.hp` for the player's own Pokémon, e.g. npc.pidgeotto.hp; `npc.milo.active_hp` for Milo's). Every Pokémon starts at its full HP, so a positive number on an undamaged one changes nothing.\n- Set a Pokémon's `status` stat (`npc.<name>.status`, or `npc.milo.active_status`) to the condition it just picked up: poisoned, burned, paralyzed, frozen, asleep, or none when it is cured or wakes up. Status matters. Poison and burn shave HP at the end of each turn, paralysis and freeze cost turns, sleep stops a Pokémon acting until it wakes.\n- When the player switches, set `player.active_pokemon` to the incoming Pokémon's name in full (not a delta).\n- When the player uses a potion, send `-1` for `player.potions` and a positive number for the healed Pokémon's HP. The count falls when a potion is spent. It never rises. Potions cannot be used once the count reaches 0.\n- When a Pokémon's HP hits 0 it faints, its status becomes `fainted`, and its trainer must send out a replacement (the player picks the next `player.active_pokemon`). A fainted Pokémon cannot be healed or switched back in.\n- Whenever Milo sends out a new Pokémon, set `npc.milo.active_pokemon` and `npc.milo.active_status` to their new values in full, and send `npc.milo.active_hp` as the positive change that carries it from where it stands to the newcomer's full HP. From a fainted Pokémon at 0 that is the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.\n\nWhen one of Milo's Pokémon faints, do both of these in the same reply: send `1` for `npc.milo.pokemon_fainted`, which adds one to the count rather than setting it, and set `npc.milo.active_pokemon` with a fresh `npc.milo.active_hp` and `npc.milo.active_status` for the one he sends out next. `npc.milo.pokemon_fainted` counts up from 0 and never goes down.\n\nMark each milestone as it happens, using the id shown beside the goal in the world state. Do not decide the whole battle in one reply; give the player a turn between every exchange.",
|
||||
"tags": "demo, pokemon, battle, tournament, world-state, combat",
|
||||
"icon": "◓",
|
||||
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAMc0lEQVR42u2du44byRWG+w0m712bNtbrsQ0DTKyYMPQAhHIDjA0IYOZACRMHyggnCreTyTYYKNyoM2cCA4cK6FSJGSqkeWa7Z6nRcKZO3bou3wF+QIA4zWb3f6rOvZoGQRAEQRAEQcJL27azExYPMOPJIDmTej4QeTOgH7A74WiJ3dl1xuvKd8x54shURL86I/qtI8FdsRvuYVSMK94QEsJcWZ3QnbCfkOym2A/3usKsQmxJvzxhmwnhTRRCfsuSN4s8R3pZOQ8FkP4SDsNvRBmQO9IvKiD9c8qwgAn1ObHrQswbn2bSGie6/DBlB9mfRUeYtTwzp4fYavSYRxDfCX9pvz0u21/f4e/tb47/bL8zgnx2/Du5BoqAJEv86/abO6IKcX9ovz/+1F4f/9P+8fi/b/7sFXJNubZ8h3yXfKd8N4qAjMSfxSC+EO8f7W+PP7a/D0J0G8WQe5F7knuLpAgk2BKL6mxDmjEj4acmuylGhQhsPm2JGk1P/lWIGP5f218d/9X+LokV3scOIb9FflOgXMIKJhZg7owrfQmkf0oZAu0MmEURyb/2uer/rZ1lZd74NJPkt3veDdYwNINVX6Inpa/22l3BY0SJ3SAA+Zc+Vn15yRJC/G/7p+qJ/xDyTOTZeFKEAwV3/si/hfjZKsIWBruFN3cQP3tF2BEu1ZN/7mrySDkBNr4fH0GepQeTiAI7RWzfKYYvJQOQ1y/kmXrIJZAzCGXvj+YOZA0LD2YRfsEF8ncu9TmYO3HNIse6ow7Gf+ns3to+TFb9aXcDByW4rd45don0SDr/3+0fIOLEkHfgUFpRb4TIhfySwie0mVbI1KGsoj4lcCG/VDZCujQh7wYlCER+iTzUWLSWY5GdZZSoDiWwcXjlgWLv5+UXWCrBLaHORxJb2Pt5+gWWibOuVPJvIT9KUGWyzKa8AfJXrwSrUsg/tylkgzxlwbKgbp47+a+0VZ2yWkCYMmGxExyyjgxpw52YPZhDj4VHq3B6IT9KUIxTPPTwquL8kL8uJbDIEyxzIf9MY/eT5CJZpvAHZjkogGp0CeUNdZdNaEeupE7+NYVtIHAB3boI00fKZyEAEChLqdM0hTSmjzRQ4PSCc6dY2VTTp0Z+VakDTi94zCnOslRCm+2lhxd46jFOI0usSXjJJAFeNHgKymkT2xQcX+N4P6NLgMnIFWV+YJaF44vpAwKZQv1U5F9Q4QkSqRxdJL36M6sT2MwiTXYX0Kz+NLeASE00i+RWfxxfENEh7pNb/XF8QWSHeJHU6k+5A4jcO9CHJv+c1R8kvgvMQypAx+oPEt8FulDkv2L1B5nsAlchFGDN6g8y2QXWIRRgb/Llcuo4LwyEgHDLUAH2k4U+ifuDkHmBSUKips4vbY4gofbJzqcCHJjwADKbJHGIOuRKejp5QSAGFP3Dy2jmD87v0xEMqW6UUJ48J+l6egzyf/IZ+SyRNC/OcBfN/MH5/brJW16U5Wz8+z4KuQZDBKyd4UMU84eGl1/wQ/u9E+mfesZybZ6xumFm6aIAWya8mU84szwYTn1gIM9bNVFuGzz5VbP5I/a6w0np1pDvrLnTTmEG7W3JPyP687Rjq3DGgkHuoVaHWbHwzGwUYEX057KDG8LOd/EPanSUFQvQKlj4s7bkl8Mh0MF9g9qUQJEU64LZ/5AfJZgSQfwA09r/mkYd+ib/y5cvj69evbqD/BslCD5K8cp79Wct9r8r+a+vr4+vX78+vn///vjx48fj58+fH4X8n3xGPit/gxJ49QMWGgXYYP//Eu2xdXhldb+5ublI+OcgfyvXsHWMa4gOKfyAjUYBbon/q1eYe7x48eJuJbcl/kPIteSaNiFS8gH3uPV6wLVss4zn+xpivnz69Mkb+UfINeXa2vupIVlmaJ7uNAqAA6xLtNzBxdzRmEXajDGO8M/wOvun9MkP2tMLY5DfVglKrx1STIyYe4sAlV6ZqIn6xCS/jRKUbq4KF71FgkwjQCXblooHemeXxyb/CI1PUPKCpfDVNt4UoOQIkGnYUyIzIRxejWNsGh0quWdDEQkyUoC+5hIIzXGdPkOdLiFSjqc1LonovShAyZEF07i/JKimJv8I02RZyXkBw4hd7y0HcKmxO3eYOr9TOL6uDjHvzSAXkGqlY1JVl9fXyZB/hEvtUE1AATxgysiPj4gQCoACOCEF59fFGUYBUAAnPFXSPBXknng3jgpg2ghfO1Ij/wjejWODvGYMeq2Q7q1UFcBnZ1nBWKAADkgp/m+bD0ABUAAUAAVAATCBUACcYJxgnGDCoIRBCYOiACTCUAAUgFIIFIAHRTFc1QqwMyHAONavNJgSKctyaN7bzktDjLThpRoJccWbN2+KbYiR31bqezNsDe29tUSW+iA/fPhQbEuk/LZS35vPlshNrqHA2AmlnJriU07gRQwB+5sK0fd9sQ9UM3Mnl7EoKfksviFc9KkAi9ofqDaqkvxgrASjVhM9i4W30Yhv374t+qG+e/eumNGI8ltKflfCRW+jEU1zASlXRUaOLCQ9HLfkiJ02CuZ3PHrh26rStkx2PHrJvprSXN15PyCj5EiQNi+Q4gEZJcf9LSJAqgMyNrkWhYVYeW3r7Kc8IknuecoQbYJ5ENURSQtWmC+TY06H1sU+JO/0tyUnvSx3aNUheVc4wn6VINoxqRWRX9kGan5Mquag7FoedAglCFGlWhP5FSUQuoOyBwXo8APyUYIaya+w/zsbBVjhB1xWgpQa0OVeaiO/0v5f2SjAjETL09EhmxCpb8g91BDtcUxUzhobMfUDasgHPJUss4nRu0K+s4Ykl4f4/76xldMfb6k1Ma8diuEbyHfwvFW1WlsXBVjWXmtuk7wK4R/INUuvwA00BGzZuMjpAgfMIDtHWexzF2WQv5Vr1OjgejJ/Do2rmIZDa4wGaRxmsdelbFee06XGbvk/+Yx8tlbH1nP0p/OhAEuiQSDT6M+y8SGmZlBtSTGQdPLr0PgSUzNotVrxkkBQCMeimT82Y9NxhkEiA4AXjU8xTYrhDIMEnN9941tOF12bJmqIYIAQkTRFonEdQgGuTLef0idGgKQnP+hr/307w+wCYMLVv2tCienMIHYBMOHqP29CiunwXHYBMMHq3zehRRMSZRcAkVf/RRNDNLsAeQHgEvdPavW32QVSPE8L5AHlJLxFE1NMd4FaxvOBScdS9k1s0ewCNMyAgA0v8Vd/m10AhxgEcnz7ZioxnRyBQwwCOb72Ex88KsFWMyyWlww8jTp0a3j3XCN0wBQCkU2fQ7Can1BT5Go4rhPYDxFQDg1YNSmJxiGe+nhRkF65g3K4WN+kJoNDbGwK0T4JLNocR9Nn1qQopk0zTJQDFhPewjW7TGUKMUmCCQ9K8vdN6qI1hWqcaw+szllI1/SxHaZF7wA1/gosm5xEkyCr6VRDyG916ua2yVFMDtxGCSC/twOuE1QAVZaYylEqPJPN9sZopKeJhuaWqA3uqZZKYA5Vb/akV+oQ2ylGCaom/7YpUUwHa6EEVZO/a0qW0w+85RBoklwXcNuULkNkaGejBJRN5FHeYEn+XfYRn9BKQAFdcYVt9ZHfhxJI+Sx+QVr2vrKkuW7y+1ACaaDAL0jD3lc2s0D+R5Tg1vYsXXqMs+nh/crhrZ78riHS82kTjFyJO7pEOb2hrlBnzGTZeZSI3SDOqm8Z5Sk7yTVl2cTDxBmzSMPM6rRMbJVb3hC4gO7g8rCl+AqzyI+5Y1nI9rCqcw6zI0WIHppFhEztQpsezB0iPVP6BSjCpMTH3veoBEtXkwhFiEr8Q3Y9vBkowUw7cuUpRZBTx/ERfrbx5Vl4Iv5xeEczGBtOEdY+doPzsooai+zkNzuUL1xa9dcwNLPd4Ly0ovRdYVztHUoXWPUTzBkcPL/Mu3i3VDaWoAzyG+S3eIjhX1r1ie0nEC7dBni5X+wMOZlJcq+BVvovIjyENws3iy7VHY0KkcIOIfcwEt6xPgdzpxBFWMRQhPOIkhBPQog3Nzd3JQMhFEOuKdeW75Dvku/0GLkxJf4ChqEIKvNJiCqQcgIhrgnks+PfBTZjID6KACB+HYowd+k5qAgdhWvlR40kmbaH7PfYD8+EqE6F5lEXIpeQAQ7Db8fMQe4L7kpXhpH0FKohzyrDthAzaT/8FkiPWCnDbCi56DJRiP1wrysSVkgoJ1p8h80w2mM3Idl3wz1shnvCiUUmDbOOirEZ4ui9o4Lszq6zOSM6YUoke7Nq8QCYKwiCIAiCIEgM+T+tNIWP5O3yJwAAAABJRU5ErkJggg==",
|
||||
"stat_schema": {
|
||||
"world": {
|
||||
"round": { "type": "counter", "min": 1, "max": 8, "initial": 1, "desc": "Which round of the eight-round championship this is; only ever counts up." },
|
||||
"turn": { "type": "counter", "min": 0, "initial": 0, "max_delta_per_turn": 1, "desc": "How many turns the current battle has lasted; only ever counts up." }
|
||||
},
|
||||
"player": {
|
||||
"potions": {
|
||||
"desc": "Hyper Potions left in the player's bag. Each one restores HP or cures a status condition, and is used up when it is used. Send -1 when the player uses one. Never send the number remaining.",
|
||||
"min": 0, "max": 5, "initial": 3, "max_delta_per_turn": 1,
|
||||
"bands": [[0, 1, "out of potions"], [1, 2, "one left"], [2, 4, "a few left"], [4, 6, "well stocked"]]
|
||||
},
|
||||
"active_pokemon": {
|
||||
"type": "text", "initial": "Pidgeotto",
|
||||
"desc": "Which of the player's Pokémon is currently on the field: Pidgeotto, Ivysaur, Wartortle, Machoke, or Pikachu. Replace this the moment the player switches."
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"sandstorm_active": { "desc": "True while a sandstorm is whipping across the field, chipping HP off any Pokémon that is not Rock, Ground, or Steel each turn.", "initial": false }
|
||||
},
|
||||
"npcs": {
|
||||
"pidgeotto": {
|
||||
"name": "Pidgeotto",
|
||||
"keys": "Pidgeotto",
|
||||
"desc": "The player's own Pokémon. Normal/Flying. Fast, but Rock-type moves hit it for four times damage.",
|
||||
"stats": {
|
||||
"hp": {
|
||||
"desc": "Pidgeotto's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.",
|
||||
"min": 0, "max": 84, "initial": 84, "max_delta_per_turn": 60,
|
||||
"bands": [[0, 1, "fainted"], [1, 20, "critical"], [20, 45, "hurt"], [45, 70, "scuffed"], [70, 85, "healthy"]]
|
||||
},
|
||||
"status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "Status condition: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ivysaur": {
|
||||
"name": "Ivysaur",
|
||||
"keys": "Ivysaur",
|
||||
"desc": "The player's own Pokémon. Grass/Poison. Super effective on Milo's Rock/Ground types; can inflict poison or sleep.",
|
||||
"stats": {
|
||||
"hp": {
|
||||
"desc": "Ivysaur's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.",
|
||||
"min": 0, "max": 96, "initial": 96, "max_delta_per_turn": 60,
|
||||
"bands": [[0, 1, "fainted"], [1, 24, "critical"], [24, 50, "hurt"], [50, 78, "scuffed"], [78, 97, "healthy"]]
|
||||
},
|
||||
"status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "Status condition: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"wartortle": {
|
||||
"name": "Wartortle",
|
||||
"keys": "Wartortle",
|
||||
"desc": "The player's own Pokémon. Water. Bulky, and super effective on the whole of Milo's team.",
|
||||
"stats": {
|
||||
"hp": {
|
||||
"desc": "Wartortle's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.",
|
||||
"min": 0, "max": 92, "initial": 92, "max_delta_per_turn": 60,
|
||||
"bands": [[0, 1, "fainted"], [1, 23, "critical"], [23, 48, "hurt"], [48, 75, "scuffed"], [75, 93, "healthy"]]
|
||||
},
|
||||
"status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "Status condition: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"machoke": {
|
||||
"name": "Machoke",
|
||||
"keys": "Machoke",
|
||||
"desc": "The player's own Pokémon. Fighting. Hits Rock-types hardest of the team but is slow and takes Ground moves badly.",
|
||||
"stats": {
|
||||
"hp": {
|
||||
"desc": "Machoke's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.",
|
||||
"min": 0, "max": 104, "initial": 104, "max_delta_per_turn": 65,
|
||||
"bands": [[0, 1, "fainted"], [1, 26, "critical"], [26, 54, "hurt"], [54, 84, "scuffed"], [84, 105, "healthy"]]
|
||||
},
|
||||
"status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "Status condition: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pikachu": {
|
||||
"name": "Pikachu",
|
||||
"keys": "Pikachu",
|
||||
"desc": "The player's own Pokémon. Electric. Frail, and its Electric moves do nothing at all to Ground-types.",
|
||||
"stats": {
|
||||
"hp": {
|
||||
"desc": "Pikachu's HP. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored.",
|
||||
"min": 0, "max": 76, "initial": 76, "max_delta_per_turn": 60,
|
||||
"bands": [[0, 1, "fainted"], [1, 19, "critical"], [19, 40, "hurt"], [40, 62, "scuffed"], [62, 77, "healthy"]]
|
||||
},
|
||||
"status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "Status condition: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"milo": {
|
||||
"name": "Trainer Milo",
|
||||
"keys": "Milo, opponent, Graveler, Onix, Kabutops, rock",
|
||||
"desc": "The Round One opponent, a Rock-type specialist with three Pokémon: Graveler, Onix, and Kabutops. He leads with Graveler, switches to punish a bad matchup, and aims for whichever of the player's Pokémon is closest to fainting.",
|
||||
"stats": {
|
||||
"active_pokemon": {
|
||||
"type": "text", "initial": "Graveler",
|
||||
"desc": "Which Pokémon Milo currently has on the field: Graveler, Onix, or Kabutops. Replace this the moment he sends out a different one."
|
||||
},
|
||||
"active_hp": {
|
||||
"desc": "The HP of Milo's active Pokémon. Send the change, not the new total: a negative number for damage taken, a positive number for HP restored. When Milo sends out a different Pokémon, send the positive change that carries this from where it stands to the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.",
|
||||
"min": 0, "max": 98, "initial": 98, "max_delta_per_turn": 98,
|
||||
"bands": [[0, 1, "fainted"], [1, 25, "critical"], [25, 50, "hurt"], [50, 78, "scuffed"], [78, 99, "healthy"]]
|
||||
},
|
||||
"active_status": {
|
||||
"type": "text", "initial": "none",
|
||||
"desc": "The status condition on Milo's active Pokémon: none, poisoned, burned, paralyzed, frozen, asleep, or fainted."
|
||||
},
|
||||
"pokemon_fainted": {
|
||||
"desc": "How many of Milo's three Pokémon have fainted. Add 1 each time one goes down. Milo loses the round when this reaches 3.",
|
||||
"type": "counter",
|
||||
"min": 0, "max": 3, "initial": 0, "max_delta_per_turn": 1,
|
||||
"bands": [[0, 1, "full team"], [1, 2, "two left"], [2, 3, "last Pokémon"], [3, 4, "defeated"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"milestones": {
|
||||
"graveler_defeated": { "desc": "Knock out Milo's lead Graveler" },
|
||||
"status_landed": { "desc": "Inflict a status condition on one of Milo's Pokémon" },
|
||||
"type_advantage_used": { "desc": "Land a super-effective hit by switching in the right Pokémon" },
|
||||
"round_one_won": { "desc": "Knock out all three of Milo's Pokémon and advance to Round Two" }
|
||||
}
|
||||
},
|
||||
"story_cards": [
|
||||
{
|
||||
"type": "location",
|
||||
"name": "Championship Stadium",
|
||||
"keys": "stadium, arena, battlefield, field, terrain",
|
||||
"entry": "The League Championship's main battlefield. The floor is broken rock terrain, which gives Rock- and Ground-type moves solid footing and gives grounded Pokémon cover to duck behind. Eight rounds stand between any Trainer and the Champion's title, and this is Round One.",
|
||||
"notes": "Rocky terrain. Rock and Ground moves get a slight edge. Boulders can be used as cover or thrown."
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Trainer Milo",
|
||||
"keys": "Milo, opponent, rock trainer",
|
||||
"entry": "Milo is the Round One opponent, a Rock-type specialist who won his qualifiers on raw defense. His team is Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). He leads with Graveler, likes Sandstorm and Rock Slide, and switches out the moment a matchup turns against him.",
|
||||
"notes": "Graveler and Onix are weak to Water, Grass, Fighting, Ground, Ice, and Steel, and are immune to Electric. Kabutops is weak to Grass, Fighting, Ground, and Electric. All three resist Normal and Flying."
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Hyper Potion",
|
||||
"keys": "potion, hyper potion, heal, item, bag",
|
||||
"entry": "A pressurized spray that closes wounds fast. One dose restores a large chunk of a Pokémon's HP, or clears a single status condition instead. Using one costs your turn, and the opponent attacks freely while you do it. You start the round with three and cannot restock mid-battle.",
|
||||
"notes": "Decrement player.potions by 1 per use. A potion cannot revive a fainted Pokémon."
|
||||
}
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Server-sent events: the wire format, the headers, and the error frame.
|
||||
|
||||
Two routers stream: the turn engine in `routers/adventures/turns.py` and the
|
||||
chat scratchpad in `routers/chat.py`. Both send JSON objects as SSE `data:`
|
||||
frames, so the format lives here rather than in either one.
|
||||
"""
|
||||
import json
|
||||
|
||||
# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering`
|
||||
# makes an nginx-style reverse proxy flush each event immediately rather than
|
||||
# buffer it, which matters if anyone puts one in front of the app.
|
||||
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
def sse(obj: dict) -> str:
|
||||
"""Returns one SSE frame carrying `obj` as JSON."""
|
||||
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.
|
||||
|
||||
A failed turn is still an HTTP 200 response, because the error is reported
|
||||
inside the stream the client is already reading.
|
||||
"""
|
||||
return sse({"type": "error", "detail": detail, **extra})
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Gives every new guest a short adventure that is already played.
|
||||
|
||||
A guest arriving on an empty account has nothing to look at, and the demo turns
|
||||
are limited, so learning what the app does used to cost one of them. This copies
|
||||
a small pre-played adventure into the new account instead. It opens on a story
|
||||
with real turns in it, and the turn summaries show what the world-state engine
|
||||
records: the changes it applied, and the changes it refused.
|
||||
|
||||
The file in `starter_data/` is an ordinary export bundle, produced by
|
||||
`GET /api/adventures/{id}/export` and trimmed to its first two exchanges. To
|
||||
replace it, play a new adventure, export it, and overwrite the file. Nothing
|
||||
else here knows what the story contains.
|
||||
|
||||
The copy is the guest's own from the first moment: they can edit it, branch it,
|
||||
delete it, or export it, and nothing links it back to the file. A returning
|
||||
guest is not given a second one, because this runs only where a guest row is
|
||||
created.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import bundle, models, seed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STARTER_FILE = Path(__file__).resolve().parent / "starter_data" / "pokemon-league.json"
|
||||
|
||||
|
||||
def _load() -> dict | None:
|
||||
"""Returns the starter bundle, or None when the file is missing or invalid.
|
||||
|
||||
The file ships with the server, so a failure here is a packaging mistake
|
||||
rather than bad input. It is still tolerated: a guest with no starter
|
||||
adventure can play, and a guest who cannot be created cannot.
|
||||
"""
|
||||
try:
|
||||
return json.loads(STARTER_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.exception("Starter adventure is missing or unreadable; skipping it.")
|
||||
return None
|
||||
|
||||
|
||||
def give(db: Session, user: models.User) -> models.Adventure | None:
|
||||
"""Copies the starter adventure into `user`, and returns it.
|
||||
|
||||
The caller commits. This writes rows and does not commit them, so a guest
|
||||
and their first adventure land in one transaction: an account is never left
|
||||
half-populated.
|
||||
|
||||
Failures are logged and swallowed. The starter is a convenience, and losing
|
||||
it must not cost the visitor their session.
|
||||
"""
|
||||
payload = _load()
|
||||
if payload is None:
|
||||
return None
|
||||
try:
|
||||
# A savepoint, so a failure halfway through writing the tree discards
|
||||
# only the starter's rows. Without it the caller's next commit would
|
||||
# flush whatever part of the adventure the session still held.
|
||||
with db.begin_nested():
|
||||
story = bundle.plan(payload, bundle.check_format(payload))
|
||||
adventure = bundle.materialize(db, payload, story, user.id)
|
||||
_link_scenario(db, adventure, payload)
|
||||
return adventure
|
||||
except Exception:
|
||||
logger.exception("Could not give user %s the starter adventure.", user.id)
|
||||
return None
|
||||
|
||||
|
||||
def _link_scenario(db: Session, adventure: models.Adventure, payload: dict) -> None:
|
||||
"""Points the copy at the demo scenario it came from, when that seed exists.
|
||||
|
||||
A bundle does not carry a scenario id, because the id is local to one
|
||||
database and an export outlives it. The link matters anyway: an adventure
|
||||
has no cover art of its own, and inherits the scenario's. Without it the
|
||||
starter shows a monogram tile while the demo it came from shows its own
|
||||
artwork.
|
||||
|
||||
The name is read from `scenarioTitle` in the file. If no seeded scenario
|
||||
answers to it, the adventure keeps a NULL `scenario_id`, which is the same
|
||||
state an imported bundle is in and costs only the artwork.
|
||||
"""
|
||||
title = str(payload.get("scenarioTitle") or "").strip()
|
||||
if not title:
|
||||
return
|
||||
scenario = seed.find_seeded(db, title)
|
||||
if scenario is not None:
|
||||
adventure.scenario_id = scenario.id
|
||||
@@ -0,0 +1,516 @@
|
||||
{
|
||||
"title": "Pokemon League Championship: Round One",
|
||||
"scenarioTitle": "[Demo] Pokemon League Championship: Round One",
|
||||
"format": "ai-dnd-adventure-v2",
|
||||
"memory": "The player is a Trainer in Round One of an eight-round League Championship. The Round One opponent is Milo, a Rock-type specialist with three Pokémon: Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). The player's team is Pidgeotto (Normal/Flying), Ivysaur (Grass/Poison), Wartortle (Water), Machoke (Fighting), and Pikachu (Electric). Pidgeotto is out first. The player carries Hyper Potions, which restore HP and can be swapped for a status heal when needed. A battle is lost when every Pokémon on one side has fainted.",
|
||||
"authorsNote": "Play it like a real battle. Type matchups decide damage, status conditions stick around and cost the afflicted Pokémon turns, and switching costs the player a turn while the opponent attacks freely. Milo is competent: he switches to punish bad matchups and targets whatever is weakest.",
|
||||
"aiInstructions": "Write in second person, present tense. Narrate the battle move by move, naming the active Pokémon on each side and the move each one uses. End every reply at a decision point where the player can choose a move, switch Pokémon, or use an item.\n\nReflect type advantage and disadvantage in the damage you narrate and in the HP numbers: a super-effective hit should take roughly double a neutral hit, a resisted hit roughly half. Ground and Rock moves hit Pikachu and Pidgeotto hard. Water, Grass, and Fighting moves are strong against Milo's Rock-types.\n\nEvery turn, update the state. Every number you send is a change to add to the current value, never the value you want it to end up at. The world state lines show you totals; the block you write carries the difference. A Pokémon at 96 HP that takes 30 damage is `-30`, not `66`.\n- Add 1 to `world.turn` in every reply, including this one. It counts the turns this battle has lasted, so it moves whether or not anything else does.\n- Send a negative number for HP lost and a positive number for HP restored on both sides (`npc.<name>.hp` for the player's own Pokémon, e.g. npc.pidgeotto.hp; `npc.milo.active_hp` for Milo's). Every Pokémon starts at its full HP, so a positive number on an undamaged one changes nothing.\n- Set a Pokémon's `status` stat (`npc.<name>.status`, or `npc.milo.active_status`) to the condition it just picked up: poisoned, burned, paralyzed, frozen, asleep, or none when it is cured or wakes up. Status matters. Poison and burn shave HP at the end of each turn, paralysis and freeze cost turns, sleep stops a Pokémon acting until it wakes.\n- When the player switches, set `player.active_pokemon` to the incoming Pokémon's name in full (not a delta).\n- When the player uses a potion, send `-1` for `player.potions` and a positive number for the healed Pokémon's HP. The count falls when a potion is spent. It never rises. Potions cannot be used once the count reaches 0.\n- When a Pokémon's HP hits 0 it faints, its status becomes `fainted`, and its trainer must send out a replacement (the player picks the next `player.active_pokemon`). A fainted Pokémon cannot be healed or switched back in.\n- Whenever Milo sends out a new Pokémon, set `npc.milo.active_pokemon` and `npc.milo.active_status` to their new values in full, and send `npc.milo.active_hp` as the positive change that carries it from where it stands to the newcomer's full HP. From a fainted Pokémon at 0 that is the newcomer's full HP: Graveler 98, Onix 90, Kabutops 88.\n\nWhen one of Milo's Pokémon faints, do both of these in the same reply: send `1` for `npc.milo.pokemon_fainted`, which adds one to the count rather than setting it, and set `npc.milo.active_pokemon` with a fresh `npc.milo.active_hp` and `npc.milo.active_status` for the one he sends out next. `npc.milo.pokemon_fainted` counts up from 0 and never goes down.\n\nMark each milestone as it happens, using the id shown beside the goal in the world state. Do not decide the whole battle in one reply; give the player a turn between every exchange.",
|
||||
"storySummary": "",
|
||||
"scriptState": {},
|
||||
"worldState": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 2
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Wartortle"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Onix",
|
||||
"active_hp": 90,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 1
|
||||
}
|
||||
},
|
||||
"milestones": {
|
||||
"graveler_defeated": {
|
||||
"reached": true,
|
||||
"at": 4
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {
|
||||
"world.turn": 4,
|
||||
"player.active_pokemon": 2,
|
||||
"npc.wartortle.hp": 2,
|
||||
"npc.milo.active_hp": 4,
|
||||
"npc.milo.pokemon_fainted": 4,
|
||||
"npc.milo.active_pokemon": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoSummarize": false,
|
||||
"memoryBankEnabled": false,
|
||||
"branches": [
|
||||
{
|
||||
"parent": null,
|
||||
"forkDepth": null
|
||||
}
|
||||
],
|
||||
"headBranch": 0,
|
||||
"memoryCursor": {
|
||||
"branch": null,
|
||||
"depth": -1
|
||||
},
|
||||
"summaryCursor": {
|
||||
"branch": null,
|
||||
"depth": -1
|
||||
},
|
||||
"memories": [],
|
||||
"storyCards": [
|
||||
{
|
||||
"type": "location",
|
||||
"name": "Championship Stadium",
|
||||
"keys": "stadium, arena, battlefield, field, terrain",
|
||||
"entry": "The League Championship's main battlefield. The floor is broken rock terrain, which gives Rock- and Ground-type moves solid footing and gives grounded Pokémon cover to duck behind. Eight rounds stand between any Trainer and the Champion's title, and this is Round One.",
|
||||
"notes": "Rocky terrain. Rock and Ground moves get a slight edge. Boulders can be used as cover or thrown."
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Trainer Milo",
|
||||
"keys": "Milo, opponent, rock trainer",
|
||||
"entry": "Milo is the Round One opponent, a Rock-type specialist who won his qualifiers on raw defense. His team is Graveler (Rock/Ground), Onix (Rock/Ground), and Kabutops (Rock/Water). He leads with Graveler, likes Sandstorm and Rock Slide, and switches out the moment a matchup turns against him.",
|
||||
"notes": "Graveler and Onix are weak to Water, Grass, Fighting, Ground, Ice, and Steel, and are immune to Electric. Kabutops is weak to Grass, Fighting, Ground, and Electric. All three resist Normal and Flying."
|
||||
},
|
||||
{
|
||||
"type": "item",
|
||||
"name": "Hyper Potion",
|
||||
"keys": "potion, hyper potion, heal, item, bag",
|
||||
"entry": "A pressurized spray that closes wounds fast. One dose restores a large chunk of a Pokémon's HP, or clears a single status condition instead. Using one costs your turn, and the opponent attacks freely while you do it. You start the round with three and cannot restock mid-battle.",
|
||||
"notes": "Decrement player.potions by 1 per use. A potion cannot revive a fainted Pokémon."
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Pidgeotto",
|
||||
"keys": "Pidgeotto",
|
||||
"entry": "The player's own Pokémon. Normal/Flying. Fast, but Rock-type moves hit it for four times damage.",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Ivysaur",
|
||||
"keys": "Ivysaur",
|
||||
"entry": "The player's own Pokémon. Grass/Poison. Super effective on Milo's Rock/Ground types; can inflict poison or sleep.",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Wartortle",
|
||||
"keys": "Wartortle",
|
||||
"entry": "The player's own Pokémon. Water. Bulky, and super effective on the whole of Milo's team.",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Machoke",
|
||||
"keys": "Machoke",
|
||||
"entry": "The player's own Pokémon. Fighting. Hits Rock-types hardest of the team but is slow and takes Ground moves badly.",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"type": "character",
|
||||
"name": "Pikachu",
|
||||
"keys": "Pikachu",
|
||||
"entry": "The player's own Pokémon. Electric. Frail, and its Electric moves do nothing at all to Ground-types.",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"scripts": [],
|
||||
"actions": [
|
||||
{
|
||||
"branch": 0,
|
||||
"depth": 0,
|
||||
"live": true,
|
||||
"type": "start",
|
||||
"text": "The floodlights come up over the championship field and the referee raises both flags. Round One of eight. Beat the trainer in front of you or go home.\n\nAcross the packed rock terrain, Trainer Milo tosses his first Poké Ball. Graveler lands hard enough to crack the stone under it, four arms flexing, and settles into a low stance.\n\n\"Rock-types all the way down,\" Milo calls over the noise. \"Hope you brought something that hits harder than it looks.\"\n\nYour Pidgeotto is already in the air, wings locked into a slow circle above the field. It is fast, but Rock-type moves will tear it out of the sky, and Graveler is built to take a hit. Three Hyper Potions sit in your belt pouch.\n\nGraveler grinds one fist into its palm. The referee drops the flag.\n\nWhat's your move?",
|
||||
"createdAt": "2026-08-28T12:24:29.635984",
|
||||
"stateAfter": {},
|
||||
"worldStateAfter": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 0
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Pidgeotto"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 92,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Graveler",
|
||||
"active_hp": 98,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 0
|
||||
}
|
||||
},
|
||||
"milestones": {},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"branch": 0,
|
||||
"depth": 1,
|
||||
"live": true,
|
||||
"type": "do",
|
||||
"text": "> You Recall Pidgeotto and send out Wartortle, then hit Graveler with a full-power Water Gun.",
|
||||
"createdAt": "2026-08-28T12:24:48.236580",
|
||||
"stateAfter": {},
|
||||
"worldStateAfter": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 0
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Pidgeotto"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 92,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Graveler",
|
||||
"active_hp": 98,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 0
|
||||
}
|
||||
},
|
||||
"milestones": {},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"branch": 0,
|
||||
"depth": 2,
|
||||
"live": true,
|
||||
"type": "ai",
|
||||
"text": "You call Pidgeotto back with a flash of red light, and it banks gratefully out of Graveler's range before the crack of a Rock Throw can find it. Wartortle drops onto the broken stone in its place, planting its feet, shell catching the floodlights.\n\n\"Smart,\" Milo grunts, \"but Graveler doesn't fold that easy.\" He points. \"Rock Throw!\"\n\nGraveler wrenches a chunk of the battlefield loose and hurls it in a flat, brutal arc. But Wartortle's already moving, ducking low behind a natural outcrop for a half-second of cover before the shard clips its shoulder — a glancing hit, more noise than damage.\n\n\"Wartortle, Water Gun — full power!\"\n\nWartortle plants both feet and unleashes a roaring jet of water straight into Graveler's chest. It's not a graceful hit, it's a drowning one — the stream slams Graveler backward, hissing against hot rock, and the four-armed boulder staggers, one knee grinding into the dirt before it forces itself back upright, dripping and furious.\n\n\"That's the matchup I didn't want to see,\" Milo mutters, though he's grinning like he half-expected it.\n\nGraveler shakes itself off, glaring, arms flexing back into a battle-ready stance. It's hurt — badly — but still standing, and still dangerous at close range.\n\nThe referee's flag stays down. It's still your move.",
|
||||
"createdAt": "2026-08-28T12:25:00.242852",
|
||||
"stateAfter": {},
|
||||
"worldStateAfter": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 1
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Wartortle"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Graveler",
|
||||
"active_hp": 52,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 0
|
||||
}
|
||||
},
|
||||
"milestones": {},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {
|
||||
"world.turn": 2,
|
||||
"player.active_pokemon": 2,
|
||||
"npc.wartortle.hp": 2,
|
||||
"npc.milo.active_hp": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"worldDelta": {
|
||||
"delta": {
|
||||
"world.turn": 1,
|
||||
"player.active_pokemon": "Wartortle",
|
||||
"npc.wartortle.hp": -8,
|
||||
"npc.milo.active_hp": -46,
|
||||
"flags.type_advantage_used": true
|
||||
},
|
||||
"applied": [
|
||||
{
|
||||
"path": "world.turn",
|
||||
"old": 0,
|
||||
"new": 1
|
||||
},
|
||||
{
|
||||
"path": "player.active_pokemon",
|
||||
"old": "Pidgeotto",
|
||||
"new": "Wartortle"
|
||||
},
|
||||
{
|
||||
"path": "npc.wartortle.hp",
|
||||
"old": 92,
|
||||
"new": 84
|
||||
},
|
||||
{
|
||||
"path": "npc.milo.active_hp",
|
||||
"old": 98,
|
||||
"new": 52
|
||||
}
|
||||
],
|
||||
"clamped": [],
|
||||
"rejected": [
|
||||
{
|
||||
"path": "flags.type_advantage_used",
|
||||
"reason": "unknown flag",
|
||||
"fix": "There is no flag `type_advantage_used`. The flags are: `sandstorm_active`."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"branch": 0,
|
||||
"depth": 3,
|
||||
"live": true,
|
||||
"type": "do",
|
||||
"text": "> You Wartortle hits Graveler with everything it has left, a point-blank Hydro Pump to finish it.",
|
||||
"createdAt": "2026-08-28T12:25:21.664571",
|
||||
"stateAfter": {},
|
||||
"worldStateAfter": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 1
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Wartortle"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Graveler",
|
||||
"active_hp": 52,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 0
|
||||
}
|
||||
},
|
||||
"milestones": {},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {
|
||||
"world.turn": 2,
|
||||
"player.active_pokemon": 2,
|
||||
"npc.wartortle.hp": 2,
|
||||
"npc.milo.active_hp": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"branch": 0,
|
||||
"depth": 4,
|
||||
"live": true,
|
||||
"type": "ai",
|
||||
"text": "Wartortle plants its stubby legs against the cracked stone, shell rattling as it draws breath for the biggest attack it has. \"Finish it — Hydro Pump!\" you shout.\n\nThe blast that follows isn't a stream, it's a cannon — a roaring column of pressurized water that closes the gap before Graveler can even brace. It catches the rock-type square in the chest and drives it backward across the broken terrain, gouging a furrow through the stone floor. Graveler's arms windmill once, twice, searching for purchase that isn't there, and then it simply goes down, crashing onto its back in a spreading puddle, swirl-eyed and still.\n\nMilo's jaw tightens, but he's already reaching for his second ball. \"Didn't want to see that either. Fine — you earned that one.\" He hurls it out. \"Onix, let's go!\"\n\nThe ground itself seems to shudder as Onix erupts from the earth in a towering column of jointed rock, easily three stories high, boulder head swinging around to fix its small eyes on Wartortle. Dust and loose gravel rain down from the impact of its arrival, pattering off your shoulders even from across the field.\n\n\"Rock/Ground,\" you murmur to yourself, but Wartortle doesn't flinch — Water still chews through that typing just as easily, and the referee's flag is already dropping to signal the next exchange.\n\nWartortle shakes water from its shell, tired from the Hydro Pump but still standing tall, eyeing the massive serpent of stone now looming over it.",
|
||||
"createdAt": "2026-08-28T12:25:34.346927",
|
||||
"stateAfter": {},
|
||||
"worldStateAfter": {
|
||||
"world": {
|
||||
"round": 1,
|
||||
"turn": 2
|
||||
},
|
||||
"player": {
|
||||
"potions": 3,
|
||||
"active_pokemon": "Wartortle"
|
||||
},
|
||||
"npc": {
|
||||
"pidgeotto": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"ivysaur": {
|
||||
"hp": 96,
|
||||
"status": "none"
|
||||
},
|
||||
"wartortle": {
|
||||
"hp": 84,
|
||||
"status": "none"
|
||||
},
|
||||
"machoke": {
|
||||
"hp": 104,
|
||||
"status": "none"
|
||||
},
|
||||
"pikachu": {
|
||||
"hp": 76,
|
||||
"status": "none"
|
||||
},
|
||||
"milo": {
|
||||
"active_pokemon": "Onix",
|
||||
"active_hp": 90,
|
||||
"active_status": "none",
|
||||
"pokemon_fainted": 1
|
||||
}
|
||||
},
|
||||
"milestones": {
|
||||
"graveler_defeated": {
|
||||
"reached": true,
|
||||
"at": 4
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"sandstorm_active": false
|
||||
},
|
||||
"_meta": {
|
||||
"last_changed": {
|
||||
"world.turn": 4,
|
||||
"player.active_pokemon": 2,
|
||||
"npc.wartortle.hp": 2,
|
||||
"npc.milo.active_hp": 4,
|
||||
"npc.milo.pokemon_fainted": 4,
|
||||
"npc.milo.active_pokemon": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"worldDelta": {
|
||||
"delta": {
|
||||
"world.turn": 1,
|
||||
"npc.milo.active_hp": -52,
|
||||
"npc.milo.pokemon_fainted": 1,
|
||||
"npc.milo.active_pokemon": "Onix",
|
||||
"npc.milo.active_status": "none",
|
||||
"milestones.graveler_defeated": true
|
||||
},
|
||||
"applied": [
|
||||
{
|
||||
"path": "world.turn",
|
||||
"old": 1,
|
||||
"new": 2
|
||||
},
|
||||
{
|
||||
"path": "npc.milo.active_hp",
|
||||
"old": 52,
|
||||
"new": 0
|
||||
},
|
||||
{
|
||||
"path": "npc.milo.pokemon_fainted",
|
||||
"old": 0,
|
||||
"new": 1
|
||||
},
|
||||
{
|
||||
"path": "npc.milo.active_pokemon",
|
||||
"old": "Graveler",
|
||||
"new": "Onix"
|
||||
},
|
||||
{
|
||||
"path": "milestones.graveler_defeated",
|
||||
"old": false,
|
||||
"new": true
|
||||
}
|
||||
],
|
||||
"clamped": [],
|
||||
"rejected": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Verify TLS against this machine's own trust store as well as certifi's.
|
||||
|
||||
`httpx` verifies against the `certifi` bundle, which carries the public web's
|
||||
certificate authorities and nothing else. A trusted-LAN inference host often
|
||||
has no public certificate: on a StartOS server, Ollama is served over HTTPS
|
||||
with a certificate from a local CA that the user installs on the machines they
|
||||
use it from. `curl` and the browser accepted such an endpoint; this
|
||||
application refused it:
|
||||
|
||||
Connection failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify
|
||||
failed: self-signed certificate in certificate chain
|
||||
|
||||
That is a wrong answer for the deployment this project targets
|
||||
(`planning/DECISIONS/002-ollama-only-v1.md`), because the user had already
|
||||
made the decision to trust that CA, at the level where such decisions belong.
|
||||
|
||||
So the rule is the one a user already expects from everything else on their
|
||||
machine: **a CA installed on this host is trusted by this application.** This
|
||||
is not a relaxation of verification. Certificates are still verified, hostnames
|
||||
are still checked, and a certificate signed by nobody the machine trusts is
|
||||
still refused — `AIDND_ENDPOINT_INSECURE` and its like deliberately do not
|
||||
exist.
|
||||
|
||||
The two stores are unioned rather than swapped. `ssl.create_default_context()`
|
||||
alone would be a behaviour *change* — it loads only the platform's default CA
|
||||
locations (`/etc/ssl/certs` on Debian and Ubuntu), and a stripped-down image
|
||||
whose system store is empty or stale would start failing on endpoints that
|
||||
used to work. Adding certifi on top makes this a strict superset of the old
|
||||
behaviour, so nothing that verified before can stop verifying now.
|
||||
|
||||
Building a context parses every certificate in both stores, so it is done once
|
||||
and cached. The result is read-only afterwards and is shared safely across
|
||||
concurrent requests.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import ssl
|
||||
|
||||
import certifi
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def ssl_context() -> ssl.SSLContext:
|
||||
"""The verification context every outbound HTTPS client should use."""
|
||||
context = ssl.create_default_context() # the platform's CA store
|
||||
context.load_verify_locations(cafile=certifi.where()) # plus the public web's
|
||||
return context
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Phase 14: writes nodes onto the story tree.
|
||||
|
||||
This module is the write half of the tree. It decides which branch a new node
|
||||
goes on, what depth the node gets, and where the adventure's head points. All
|
||||
three decisions live here because a mistake in any of them is silent. A node
|
||||
written without a branch is invisible to every read, and nothing raises an
|
||||
error.
|
||||
|
||||
The read half is `context/lineage.py`. It turns a branch into the set of nodes
|
||||
that make up one story.
|
||||
|
||||
Until forking ships, each adventure has one branch and `depth` mirrors `index`,
|
||||
so nothing here changes observable behavior yet. That is intentional. By the
|
||||
time reads depend on these columns, every row already has them, including the
|
||||
rows written between the two deploys that no migration visits.
|
||||
|
||||
SP2 added `place_new_nodes`, which runs on every flush. Wiring up individual
|
||||
call sites worked while nothing read the columns. Now that reads filter on them,
|
||||
relying on each writer to remember would also mean relying on every fixture,
|
||||
script, and test. A missed call produces a story with missing turns, so the
|
||||
flush enforces the rule instead.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from sqlalchemy import func, insert, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .context import lineage
|
||||
|
||||
# Head depth of an adventure that has no actions. Using -1 keeps the rule "the
|
||||
# next node goes at head_depth + 1" true without a special case. This matches
|
||||
# `migrations.NO_DEPTH`.
|
||||
NO_DEPTH = -1
|
||||
|
||||
|
||||
def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""Returns the adventure's root branch, creating it on first use.
|
||||
|
||||
This function gets or creates the branch instead of creating it alongside
|
||||
the adventure. The adventures that need a root branch are usually ones that
|
||||
already exist: an imported bundle, a fixture built through the ORM, or a
|
||||
database migrated before this code shipped.
|
||||
"""
|
||||
branch = (
|
||||
db.query(models.Branch)
|
||||
.filter(
|
||||
models.Branch.adventure_id == adventure.id,
|
||||
models.Branch.parent_branch_id.is_(None),
|
||||
)
|
||||
.order_by(models.Branch.id)
|
||||
.first()
|
||||
)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# Use a Core insert instead of the ORM. `place_new_nodes` can call this
|
||||
# during a flush, and a nested ORM flush raises an error. Both paths share
|
||||
# one transaction. The lineage refers to the branch's own id, so it needs a
|
||||
# second statement, which runs once per adventure.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=None,
|
||||
fork_depth=None,
|
||||
lineage=[],
|
||||
created_at=models.utcnow(),
|
||||
)
|
||||
).inserted_primary_key[0]
|
||||
db.execute(
|
||||
update(models.Branch)
|
||||
.where(models.Branch.id == new_id)
|
||||
.values(lineage=[[new_id, None]])
|
||||
)
|
||||
return db.get(models.Branch, new_id)
|
||||
|
||||
|
||||
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""Returns the branch that new nodes are played onto."""
|
||||
if adventure.head_branch_id is not None:
|
||||
branch = db.get(models.Branch, adventure.head_branch_id)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# The head points at a branch that no longer exists, which means a bug
|
||||
# elsewhere. Fall back to the root instead of refusing to play,
|
||||
# otherwise the adventure becomes unusable.
|
||||
branch = root_branch(db, adventure)
|
||||
adventure.head_branch_id = branch.id
|
||||
return branch
|
||||
|
||||
|
||||
def fork(db: Session, adventure: models.Adventure, node: models.Action) -> models.Branch:
|
||||
"""Moves `node` onto a new branch so the story can continue from it.
|
||||
|
||||
`node` is a discarded attempt at a turn that the story has already moved
|
||||
past. Making it live where it stands would orphan every turn played after
|
||||
it, because those turns continue the attempt that won. Instead, `node` moves
|
||||
to a new branch that forks from the depth just before it. The parent branch
|
||||
keeps its story unchanged. The new branch inherits everything up to the fork
|
||||
and owns this one node.
|
||||
|
||||
This function inserts one row and moves one row. It copies nothing, so a
|
||||
fork costs one `branches` row plus the ancestry cached on it, regardless of
|
||||
how long the story is.
|
||||
|
||||
Derived data stays on the parent, by design. A memory attaches to the node
|
||||
its block ends on, and that node does not move. From the new branch, the
|
||||
lineage caps the parent at `fork_depth`, so the memory sits one depth past
|
||||
the border. Neither retrieval nor the cursors can see it, and the block is
|
||||
summarized again from the text this branch contains.
|
||||
|
||||
The other attempts at this turn also stay on the parent, because they are
|
||||
still takes on the parent's turn. If none of them is live, the oldest one
|
||||
becomes live, so the parent keeps the line it was written on.
|
||||
"""
|
||||
parent = db.get(models.Branch, node.branch_id)
|
||||
if parent is None or node.depth is None:
|
||||
raise ValueError("cannot fork from a node that is not on a branch")
|
||||
fork_depth = node.depth - 1
|
||||
# Read the sibling attempts before moving the node. The session does not
|
||||
# autoflush, so a later read still finds the node here and hands the live
|
||||
# flag back to it.
|
||||
remaining = [
|
||||
row for row in db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.branch_id == parent.id,
|
||||
models.Action.depth == node.depth,
|
||||
)
|
||||
.order_by(models.Action.id)
|
||||
.all()
|
||||
if row is not node
|
||||
]
|
||||
# The parent's ancestry, with every entry capped at the fork depth. Only the
|
||||
# first entry can change in practice, because older entries are already
|
||||
# capped at a shallower depth. Capping all of them states the invariant
|
||||
# directly.
|
||||
inherited = [
|
||||
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
|
||||
for branch_id, cap in lineage.entries_of(parent)
|
||||
]
|
||||
# Core insert with the lineage written second, for the reason given in
|
||||
# `root_branch`. This code can run inside a flush, and the lineage refers to
|
||||
# the new row's own id.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=parent.id,
|
||||
fork_depth=fork_depth,
|
||||
lineage=[],
|
||||
created_at=models.utcnow(),
|
||||
)
|
||||
).inserted_primary_key[0]
|
||||
db.execute(
|
||||
update(models.Branch)
|
||||
.where(models.Branch.id == new_id)
|
||||
.values(lineage=[[new_id, None]] + inherited)
|
||||
)
|
||||
|
||||
depth = node.depth
|
||||
node.branch_id = new_id
|
||||
node.live = True
|
||||
|
||||
# The group the node left needs a live attempt again. Taking the oldest is
|
||||
# arbitrary, and it has to be somebody: a coordinate with no live attempt
|
||||
# disappears from the story on the branch it was left on.
|
||||
if remaining and not any(row.live for row in remaining):
|
||||
remaining[0].live = True
|
||||
|
||||
adventure.head_branch_id = new_id
|
||||
adventure.head_depth = depth
|
||||
return db.get(models.Branch, new_id)
|
||||
|
||||
|
||||
def branch_at(
|
||||
db: Session, adventure: models.Adventure, fork_depth: int
|
||||
) -> models.Branch:
|
||||
"""Creates an empty branch that leaves the current path at `fork_depth`.
|
||||
|
||||
`fork` moves an existing node onto its own branch. This function creates the
|
||||
same kind of branch with no nodes on it yet, for the case where the take
|
||||
that will live there does not exist. A player asking for another take of a
|
||||
turn the story has moved past reaches this path (SP9).
|
||||
|
||||
The head lands at `fork_depth`, so the next node written becomes the new
|
||||
take. That node gets the same depth as the original and the same parent,
|
||||
which `place_action` derives from the path.
|
||||
|
||||
This function does not modify the branch being left. That branch keeps its
|
||||
node at that depth, the node stays live, and every turn played after it
|
||||
stays in place.
|
||||
"""
|
||||
parent = head_branch(db, adventure)
|
||||
inherited = [
|
||||
[branch_id, fork_depth if cap is None else min(cap, fork_depth)]
|
||||
for branch_id, cap in lineage.entries_of(parent)
|
||||
]
|
||||
# Core insert with the lineage written second, for the reason given in
|
||||
# `root_branch`. This code can run inside a flush, and the lineage refers to
|
||||
# the new row's own id.
|
||||
new_id = db.execute(
|
||||
insert(models.Branch).values(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=parent.id,
|
||||
fork_depth=fork_depth,
|
||||
lineage=[],
|
||||
created_at=models.utcnow(),
|
||||
)
|
||||
).inserted_primary_key[0]
|
||||
db.execute(
|
||||
update(models.Branch)
|
||||
.where(models.Branch.id == new_id)
|
||||
.values(lineage=[[new_id, None]] + inherited)
|
||||
)
|
||||
adventure.head_branch_id = new_id
|
||||
adventure.head_depth = fork_depth
|
||||
return db.get(models.Branch, new_id)
|
||||
|
||||
|
||||
def place_action(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
action: models.Action,
|
||||
branch: models.Branch | None = None,
|
||||
parent: models.Action | None = None,
|
||||
) -> models.Branch:
|
||||
"""Puts `action` on the head branch and moves the head to it.
|
||||
|
||||
An action with no `depth` of its own goes one step past the tip, which is
|
||||
where the next turn belongs. The opening of a new adventure lands at 0 that
|
||||
way, because an adventure with nothing played has a head depth of
|
||||
`NO_DEPTH`.
|
||||
|
||||
Pass `branch` when you have already resolved the head and are placing
|
||||
several nodes at once. See `place_new_nodes` for why that is worth doing.
|
||||
|
||||
`parent` is the take that this node was played after (SP9), and it is what
|
||||
groups the takes of one turn. If you omit it, this function derives it from
|
||||
the path, which is the correct default: a node written now follows the story
|
||||
the player is reading now. If you place several nodes in one flush, chain
|
||||
`parent` explicitly, because the database has not seen any of them yet.
|
||||
"""
|
||||
branch = branch or head_branch(db, adventure)
|
||||
action.branch_id = branch.id
|
||||
if action.depth is None:
|
||||
action.depth = adventure.head_depth + 1
|
||||
if parent is not None:
|
||||
action.parent_id = parent.id
|
||||
elif action.parent_id is None and action.depth:
|
||||
action.parent_id = _preceding_id(db, adventure, branch, action.depth)
|
||||
adventure.head_branch_id = branch.id
|
||||
if action.depth is not None and action.depth > adventure.head_depth:
|
||||
adventure.head_depth = action.depth
|
||||
return branch
|
||||
|
||||
|
||||
def _preceding_id(
|
||||
db: Session, adventure: models.Adventure, branch: models.Branch, depth: int
|
||||
) -> int | None:
|
||||
"""Returns the id of the live node one step back along `branch`'s path.
|
||||
|
||||
The query searches the whole lineage instead of `branch` alone, because a
|
||||
branch inherits the story before its fork point. The node in front of a
|
||||
forked branch's first turn lives on an ancestor, and that node is the parent
|
||||
a pager needs in order to find siblings.
|
||||
"""
|
||||
path = lineage.Path(lineage.entries_of(branch))
|
||||
return (
|
||||
db.query(models.Action.id)
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
models.Action.depth == depth - 1,
|
||||
models.Action.live.is_(True),
|
||||
path.clause(),
|
||||
)
|
||||
.order_by(models.Action.id)
|
||||
.limit(1)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
|
||||
def place_memory(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
memory: models.Memory,
|
||||
branch: models.Branch | None = None,
|
||||
) -> models.Branch:
|
||||
"""Attaches a memory to the node it belongs to.
|
||||
|
||||
`source_end` is the index of the last action the memory summarizes, which is
|
||||
that node's depth. A hand-written memory summarizes no actions, so it uses
|
||||
the head instead. That records the story the author was reading at the time.
|
||||
|
||||
Giving every memory a coordinate is what makes its scope unambiguous (SP7).
|
||||
Before SP7, hand-written memories had a NULL depth and belonged to the
|
||||
adventure rather than to a path. A fork cannot cap a NULL, so those memories
|
||||
followed the reader onto branches whose story they did not describe. Now the
|
||||
question "is this memory part of the story I am reading?" has one answer for
|
||||
every row in the bank, and it is the same answer the lineage gives for
|
||||
nodes.
|
||||
"""
|
||||
branch = branch or head_branch(db, adventure)
|
||||
memory.branch_id = branch.id
|
||||
if memory.depth is None:
|
||||
memory.depth = (
|
||||
memory.source_end if memory.source_end is not None
|
||||
else adventure.head_depth
|
||||
)
|
||||
return branch
|
||||
|
||||
|
||||
def attach_memory(memory: models.Memory, node: models.Action) -> None:
|
||||
"""Attaches a memory to the node it was derived from.
|
||||
|
||||
This follows the general rule for derived data, and the memory bank is the
|
||||
first case of it. Anything derived from the story attaches to the node that
|
||||
produced it, and is then visible from exactly the paths that contain that
|
||||
node. A fork inherits its ancestors' memories because it inherits their
|
||||
nodes, so nothing is copied. A memory made on a sibling branch is not
|
||||
visible, because that node is not on this path.
|
||||
|
||||
This function differs from `place_memory` because it takes the branch from
|
||||
`node`, which is not always the head. A block of story can end before the
|
||||
fork that created the current branch, and the memory belongs where that
|
||||
block is.
|
||||
"""
|
||||
memory.branch_id = node.branch_id
|
||||
memory.depth = node.depth
|
||||
|
||||
|
||||
def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
|
||||
"""Records the state a node left behind, if the writer did not record it.
|
||||
|
||||
This is the fallback under `attempts.snapshot_outcome`, and it exists for
|
||||
the same reason `place_action` has one. Since SP4, undo and retry cannot
|
||||
roll back past a node that has no outcome, and the failure is silent: the
|
||||
script state and world state stay where they were. The turn engine records
|
||||
the outcome itself, so this function skips those rows. It catches fixtures,
|
||||
scripts, and imports that write a story directly through the ORM.
|
||||
|
||||
The values written are the state as of the flush. That is correct, because a
|
||||
writer that changes no state between two nodes leaves the same state behind
|
||||
both of them.
|
||||
"""
|
||||
if action.state_after is None:
|
||||
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
|
||||
action.state_after = copy.deepcopy(state)
|
||||
if action.world_state_after is None:
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
action.world_state_after = copy.deepcopy(world)
|
||||
|
||||
|
||||
def place_new_nodes(session: Session) -> None:
|
||||
"""Places every unplaced node that is about to be inserted.
|
||||
|
||||
This runs on every flush. Call sites still call `place_action` and
|
||||
`place_memory` directly, and they should. Placing a node at the call site
|
||||
happens before the surrounding code reads the row back, and the explicit
|
||||
call makes that ordering visible. This function is the fallback under those
|
||||
calls, so a fixture, script, test, or a call site added later still gets a
|
||||
branch without knowing the tree exists.
|
||||
|
||||
Nodes whose adventure has not been inserted yet are skipped, because there
|
||||
is no id to attach a branch to. This case does not arise in practice, since
|
||||
an Action needs `adventure_id` before it can be written at all.
|
||||
|
||||
The head is resolved once per adventure per flush and cached in `heads`.
|
||||
This saves more than a dictionary lookup. The identity map holds weak
|
||||
references, so a branch row that nothing else refers to is collected between
|
||||
two nodes and read from the database again for the next one. Resolving the
|
||||
head per node turned a fixture that wrote 200 actions in one flush into 200
|
||||
separate queries.
|
||||
"""
|
||||
heads: dict[int, models.Branch] = {}
|
||||
for obj in list(session.new):
|
||||
if isinstance(obj, models.Action):
|
||||
place = place_action
|
||||
elif isinstance(obj, models.Memory):
|
||||
place = place_memory
|
||||
else:
|
||||
continue
|
||||
if obj.adventure_id is None:
|
||||
continue
|
||||
adventure = session.get(models.Adventure, obj.adventure_id)
|
||||
if adventure is None:
|
||||
continue
|
||||
if isinstance(obj, models.Action):
|
||||
stamp_outcome(adventure, obj)
|
||||
if obj.branch_id is not None:
|
||||
continue # already placed at its call site
|
||||
head = heads.get(adventure.id)
|
||||
if head is None:
|
||||
head = heads[adventure.id] = head_branch(session, adventure)
|
||||
place(session, adventure, obj, head)
|
||||
|
||||
|
||||
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
||||
"""Recomputes the head depth after nodes are removed by undo or delete.
|
||||
|
||||
A branch with no nodes of its own sits at its fork point, because that is
|
||||
the last node its story contains. The node is inherited from the parent, but
|
||||
it is still the tip. A root branch with no nodes has no story at all.
|
||||
"""
|
||||
branch = head_branch(db, adventure)
|
||||
tip = (
|
||||
db.query(func.max(models.Action.depth))
|
||||
.filter(models.Action.branch_id == branch.id)
|
||||
.scalar()
|
||||
)
|
||||
if tip is None:
|
||||
tip = branch.fork_depth if branch.fork_depth is not None else NO_DEPTH
|
||||
adventure.head_depth = tip
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Storing and comparing embedding vectors.
|
||||
|
||||
A 1536-dimension vector written as a JSON list is about 31 kB, because every
|
||||
component is written as a decimal string of about seventeen digits. The same
|
||||
vector as packed float32 is 6,144 bytes, which is five times smaller, and the
|
||||
memory bank is read in full on every turn, so those bytes are paid repeatedly.
|
||||
|
||||
Float32 is not an approximation here. The embedding endpoints return vectors
|
||||
computed in float32 and render them into JSON as the shortest decimal string
|
||||
that round-trips through a double. Converting that back to float32 recovers the
|
||||
original bits exactly. Nothing that was present is lost, which is why the
|
||||
conversion needs no re-embedding and carries no risk to retrieval quality.
|
||||
|
||||
Dimensions are deliberately unchanged. Dropping to 512 or 768 would have saved
|
||||
another 3x and cost an API call per stored memory to re-embed, against a bank
|
||||
that the packing and the in-process cache together already make cheap.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
from array import array
|
||||
|
||||
|
||||
def pack(vector) -> bytes:
|
||||
"""A vector as little-endian float32."""
|
||||
return struct.pack(f"<{len(vector)}f", *vector)
|
||||
|
||||
|
||||
def unpack(blob: bytes) -> array:
|
||||
"""The inverse of `pack`. Length is implied: four bytes per component.
|
||||
|
||||
Returns an `array("f")` rather than a list, because these are held in
|
||||
memory between turns: the array is the same 4 bytes a component the column
|
||||
is, where a list of Python floats is eight times that. It indexes, zips and
|
||||
lens like a list, which is all the ranking needs.
|
||||
"""
|
||||
vector = array("f")
|
||||
vector.frombytes(blob)
|
||||
if sys.byteorder != "little":
|
||||
vector.byteswap()
|
||||
return vector
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
# Different lengths means the embedding model changed since this vector was
|
||||
# stored; zip() would silently score garbage.
|
||||
if len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
||||
return dot / norm if norm else 0.0
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Phase 12: RPG world state.
|
||||
|
||||
The scenario carries a `stat_schema`, which is the template: which stats exist,
|
||||
their bands and rules, and the milestones. An adventure carries a live
|
||||
`world_state` instantiated from it. Each turn the AI proposes a delta holding
|
||||
only what changed. `apply_delta` decides what the delta is allowed to do. It
|
||||
clamps values to min and max, caps the change per turn, enforces cooldowns, and
|
||||
makes milestones sticky.
|
||||
|
||||
Nothing here raises on bad AI output. A malformed delta returns `{}` and the turn
|
||||
continues, the same way a broken script never breaks a turn.
|
||||
|
||||
The work is split four ways, and each module reads on its own:
|
||||
|
||||
schema what a scenario's `stat_schema` allows, and building state from it
|
||||
parse the block the model writes, and reading it back
|
||||
apply every write to world state, and the limits it is held to
|
||||
render turning state and schema into prompt text
|
||||
|
||||
Import the names from this package rather than from those modules. The split is
|
||||
an implementation detail, and the names below are the interface.
|
||||
"""
|
||||
|
||||
from .apply import apply_delta, apply_override
|
||||
from .parse import (
|
||||
EMIT_REMINDER,
|
||||
EMIT_RULE,
|
||||
applied_delta,
|
||||
extract_delta,
|
||||
refusals,
|
||||
render_delta_block,
|
||||
render_refusals,
|
||||
)
|
||||
from .render import render_reference, render_state_section
|
||||
from .schema import band_label, has_schema, instantiate, npc_name, npc_triggers, reconcile
|
||||
|
||||
__all__ = [
|
||||
"EMIT_REMINDER",
|
||||
"EMIT_RULE",
|
||||
"applied_delta",
|
||||
"apply_delta",
|
||||
"apply_override",
|
||||
"band_label",
|
||||
"extract_delta",
|
||||
"has_schema",
|
||||
"instantiate",
|
||||
"npc_name",
|
||||
"npc_triggers",
|
||||
"reconcile",
|
||||
"refusals",
|
||||
"render_delta_block",
|
||||
"render_reference",
|
||||
"render_refusals",
|
||||
"render_state_section",
|
||||
]
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Applying a delta or an override to world state, within the schema's limits.
|
||||
|
||||
Every write to world state goes through here, so the clamping rules live in one
|
||||
place. A proposed change that breaks a limit is recorded as refused rather than
|
||||
dropped, because the player and the model both need to see that it did not land.
|
||||
"""
|
||||
import copy
|
||||
from typing import NamedTuple
|
||||
|
||||
from .schema import STAT_SECTIONS, _initials, instantiate, npc_name
|
||||
|
||||
|
||||
class _Target(NamedTuple):
|
||||
"""Where one path writes, and what the schema says about the value there."""
|
||||
|
||||
kind: str # "flag", "milestone", or "stat"
|
||||
section: str # "flags", "milestones", "player", "world", or "npc"
|
||||
key: str # the key inside the container
|
||||
stat_def: dict | None # the stat's definition, or None for a flag or milestone
|
||||
npc_id: str | None # the character, for `npc.<id>.<stat>`, else None
|
||||
npc_stats: dict | None # that character's stat defs, used to build a new block
|
||||
|
||||
def container(self, ws: dict) -> dict:
|
||||
"""Returns the dict this path writes into, and creates it if it is missing.
|
||||
|
||||
Call this only when you are about to write. Creating the container is a
|
||||
side effect, and a rejected path must not leave an empty section behind.
|
||||
"""
|
||||
if self.npc_id is None:
|
||||
return ws.setdefault(self.section, {})
|
||||
return ws.setdefault("npc", {}).setdefault(self.npc_id, _initials(self.npc_stats))
|
||||
|
||||
|
||||
def _resolve(path: str, stat_schema: dict) -> tuple[_Target | None, dict | None]:
|
||||
"""Routes one path to the value it names, without deciding what happens to it.
|
||||
|
||||
Returns `(target, None)` when the schema defines the path, and
|
||||
`(None, rejection)` when it does not. The rejection is the entry that goes
|
||||
into a report's `rejected` list, with its reason and its fix already worded.
|
||||
|
||||
`apply_delta` and `apply_override` route paths identically and differ only in
|
||||
what they write, so the routing lives here and each function keeps its own
|
||||
write rule.
|
||||
"""
|
||||
parts = path.split(".")
|
||||
|
||||
# `flags.<name>` is a boolean the scenario declares.
|
||||
if parts[0] == "flags" and len(parts) == 2:
|
||||
flag_defs = stat_schema.get("flags") or {}
|
||||
if parts[1] not in flag_defs:
|
||||
return None, {
|
||||
"path": path, "reason": "unknown flag",
|
||||
"fix": f"There is no flag `{parts[1]}`. {_names_phrase('flag', flag_defs)}",
|
||||
}
|
||||
return _Target("flag", "flags", parts[1], None, None, None), None
|
||||
|
||||
# `milestones.<id>` records that a milestone is reached.
|
||||
if parts[0] == "milestones" and len(parts) == 2:
|
||||
milestones = stat_schema.get("milestones") or {}
|
||||
if parts[1] not in milestones:
|
||||
return None, {
|
||||
"path": path, "reason": "unknown milestone",
|
||||
"fix": f"There is no milestone `{parts[1]}`. "
|
||||
f"{_names_phrase('milestone', milestones)}",
|
||||
}
|
||||
return _Target("milestone", "milestones", parts[1], None, None, None), None
|
||||
|
||||
# `world.<stat>` and `player.<stat>`.
|
||||
if parts[0] in STAT_SECTIONS and len(parts) == 2:
|
||||
stat_defs = stat_schema.get(parts[0]) or {}
|
||||
stat_def = stat_defs.get(parts[1])
|
||||
if not isinstance(stat_def, dict):
|
||||
return None, {
|
||||
"path": path, "reason": "unknown stat",
|
||||
"fix": f"`{parts[0]}` has no stat `{parts[1]}`. "
|
||||
f"{_names_phrase('stat', stat_defs)}",
|
||||
}
|
||||
return _Target("stat", parts[0], parts[1], stat_def, None, None), None
|
||||
|
||||
# `npc.<id>.<stat>`. Each character carries its own stat definitions.
|
||||
if parts[0] == "npc" and len(parts) == 3:
|
||||
npcs = stat_schema.get("npcs") or {}
|
||||
ndef = npcs.get(parts[1])
|
||||
if not isinstance(ndef, dict):
|
||||
return None, {
|
||||
"path": path, "reason": "unknown npc",
|
||||
"fix": f"There is no character `{parts[1]}`. "
|
||||
f"{_names_phrase('character', npcs)}",
|
||||
}
|
||||
stat_defs = ndef.get("stats") or {}
|
||||
stat_def = stat_defs.get(parts[2])
|
||||
if not isinstance(stat_def, dict):
|
||||
return None, {
|
||||
"path": path, "reason": "unknown npc stat",
|
||||
"fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. "
|
||||
f"{_names_phrase('stat', stat_defs)}",
|
||||
}
|
||||
return _Target("stat", "npc", parts[2], stat_def, parts[1], stat_defs), None
|
||||
|
||||
return None, {
|
||||
"path": path, "reason": "unknown path",
|
||||
"fix": f"`{path}` is not a tracked value. Use player.<stat>, "
|
||||
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_number(value):
|
||||
if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here.
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _names_phrase(kind: str, defs: dict, limit: int = 12) -> str:
|
||||
"""Lists what the model could have written instead, for a wrong name.
|
||||
|
||||
A rejection that only says a name is unknown leaves the model guessing
|
||||
again. Naming the alternatives turns it into a correction it can act on.
|
||||
"""
|
||||
names = [k for k in (defs or {}) if isinstance(k, str)]
|
||||
if not names:
|
||||
return f"This scenario tracks no {kind}."
|
||||
shown = ", ".join(f"`{n}`" for n in names[:limit])
|
||||
more = f", and {len(names) - limit} more" if len(names) > limit else ""
|
||||
plural = f"{kind}s" if not kind.endswith("s") else kind
|
||||
return f"The {plural} are: {shown}{more}."
|
||||
|
||||
|
||||
def _limits_phrase(stat_def: dict) -> str:
|
||||
"""Names a stat's numeric limits, for a correction sent back to the model."""
|
||||
lo, hi = stat_def.get("min"), stat_def.get("max")
|
||||
cap = stat_def.get("max_delta_per_turn")
|
||||
bits = []
|
||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||
bits.append(f"it runs from {lo} to {hi}")
|
||||
elif isinstance(lo, (int, float)):
|
||||
bits.append(f"it never goes below {lo}")
|
||||
elif isinstance(hi, (int, float)):
|
||||
bits.append(f"it never goes above {hi}")
|
||||
if isinstance(cap, (int, float)):
|
||||
bits.append(f"it moves at most {cap} per turn")
|
||||
return "; ".join(bits)
|
||||
|
||||
|
||||
def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
||||
path: str, action_index: int, meta: dict, report: dict) -> None:
|
||||
delta = _coerce_number(change)
|
||||
if delta is None:
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a number",
|
||||
"fix": f"`{path}` takes a number, written as a change such as -5 or 8.",
|
||||
})
|
||||
return
|
||||
|
||||
cooldown = stat_def.get("cooldown") or 0
|
||||
last = meta["last_changed"].get(path)
|
||||
if cooldown and last is not None and action_index - last < cooldown:
|
||||
waited = action_index - last
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "cooldown",
|
||||
"fix": f"`{path}` changed {waited} turn(s) ago and cannot change again "
|
||||
f"until {cooldown} turns have passed.",
|
||||
})
|
||||
return
|
||||
|
||||
if stat_def.get("type") == "counter" and delta < 0:
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "counter can't decrease",
|
||||
"fix": f"`{path}` only counts up. Send a positive change such as 1, "
|
||||
f"never a negative and never the running total.",
|
||||
})
|
||||
return
|
||||
|
||||
clamped = False
|
||||
cap = stat_def.get("max_delta_per_turn")
|
||||
if cap is not None and abs(delta) > cap:
|
||||
delta = cap if delta > 0 else -cap
|
||||
clamped = True
|
||||
|
||||
old = container.get(key, stat_def.get("initial", 0))
|
||||
new = old + delta
|
||||
lo, hi = stat_def.get("min"), stat_def.get("max")
|
||||
if lo is not None and new < lo:
|
||||
new, clamped = lo, True
|
||||
if hi is not None and new > hi:
|
||||
new, clamped = hi, True
|
||||
# Keep ints integral for display.
|
||||
if isinstance(old, int) and float(new).is_integer():
|
||||
new = int(new)
|
||||
|
||||
container[key] = new
|
||||
meta["last_changed"][path] = action_index
|
||||
entry = {"path": path, "old": old, "new": new}
|
||||
if clamped and new == old:
|
||||
# The clamp cancelled the change. Nothing moved, so the model needs the
|
||||
# same correction a rejection gets: without it the only evidence is a
|
||||
# value that stayed put, which reads as the change never being asked for.
|
||||
edge = "maximum" if hi is not None and new == hi else "minimum"
|
||||
entry["fix"] = (
|
||||
f"`{path}` did not move. It is already at its {edge} of {new}"
|
||||
+ (f" ({_limits_phrase(stat_def)})." if _limits_phrase(stat_def) else ".")
|
||||
)
|
||||
report["applied"].append(entry)
|
||||
if clamped:
|
||||
report["clamped"].append(entry)
|
||||
|
||||
|
||||
def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
path: str, action_index: int, meta: dict, report: dict) -> None:
|
||||
"""Applies a free-text stat, which replaces rather than adds.
|
||||
|
||||
The AI sends the new value in full rather than a delta. No clamping and no
|
||||
bands apply. Only an optional cooldown and an optional `max_length`
|
||||
truncation apply.
|
||||
"""
|
||||
if not isinstance(change, str):
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a string",
|
||||
"fix": f"`{path}` holds text. Send its new value in full, not a number "
|
||||
f"and not a change.",
|
||||
})
|
||||
return
|
||||
|
||||
cooldown = stat_def.get("cooldown") or 0
|
||||
last = meta["last_changed"].get(path)
|
||||
if cooldown and last is not None and action_index - last < cooldown:
|
||||
waited = action_index - last
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "cooldown",
|
||||
"fix": f"`{path}` changed {waited} turn(s) ago and cannot change again "
|
||||
f"until {cooldown} turns have passed.",
|
||||
})
|
||||
return
|
||||
|
||||
new = change.strip()
|
||||
max_len = stat_def.get("max_length")
|
||||
if isinstance(max_len, int) and max_len > 0 and len(new) > max_len:
|
||||
new = new[:max_len]
|
||||
|
||||
old = container.get(key, stat_def.get("initial", ""))
|
||||
if new == old:
|
||||
return # Nothing changed, so do nothing.
|
||||
|
||||
container[key] = new
|
||||
meta["last_changed"][path] = action_index
|
||||
report["applied"].append({"path": path, "old": old, "new": new})
|
||||
|
||||
|
||||
def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]:
|
||||
"""Sets live values directly, as a manual author edit rather than an AI turn.
|
||||
|
||||
This differs from `apply_delta` in three ways. Numeric stats are set rather
|
||||
than added to. `cooldown`, `max_delta_per_turn`, and the rule that a counter
|
||||
cannot decrease are all ignored, because this is a deliberate correction
|
||||
rather than an AI move to check. Milestones can be toggled in both
|
||||
directions rather than only marked reached.
|
||||
|
||||
Values are still validated against the schema, so an unknown path or a wrong
|
||||
type is rejected, and numeric values still clamp to min and max.
|
||||
"""
|
||||
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
|
||||
if not ws:
|
||||
ws = instantiate(stat_schema)
|
||||
report: dict = {"applied": [], "rejected": []}
|
||||
|
||||
if not isinstance(overrides, dict):
|
||||
return ws, report
|
||||
|
||||
def set_stat(container: dict, key: str, stat_def: dict, value, path: str) -> None:
|
||||
if stat_def.get("type") == "text":
|
||||
if not isinstance(value, str):
|
||||
report["rejected"].append({"path": path, "reason": "not a string"})
|
||||
return
|
||||
new = value.strip()
|
||||
max_len = stat_def.get("max_length")
|
||||
if isinstance(max_len, int) and max_len > 0 and len(new) > max_len:
|
||||
new = new[:max_len]
|
||||
old = container.get(key, stat_def.get("initial", ""))
|
||||
container[key] = new
|
||||
report["applied"].append({"path": path, "old": old, "new": new})
|
||||
return
|
||||
|
||||
num = _coerce_number(value)
|
||||
if num is None:
|
||||
report["rejected"].append({"path": path, "reason": "not a number"})
|
||||
return
|
||||
old = container.get(key, stat_def.get("initial", 0))
|
||||
lo, hi = stat_def.get("min"), stat_def.get("max")
|
||||
if lo is not None and num < lo:
|
||||
num = lo
|
||||
if hi is not None and num > hi:
|
||||
num = hi
|
||||
if isinstance(old, int) and float(num).is_integer():
|
||||
num = int(num)
|
||||
container[key] = num
|
||||
report["applied"].append({"path": path, "old": old, "new": num})
|
||||
|
||||
for raw_path, value in overrides.items():
|
||||
path = str(raw_path)
|
||||
target, rejection = _resolve(path, stat_schema)
|
||||
if rejection is not None:
|
||||
report["rejected"].append(rejection)
|
||||
continue
|
||||
|
||||
if target.kind == "flag":
|
||||
if not isinstance(value, bool):
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a boolean",
|
||||
"fix": f"`{path}` takes true or false.",
|
||||
})
|
||||
continue
|
||||
flags = target.container(ws)
|
||||
old = bool(flags.get(target.key, False))
|
||||
flags[target.key] = value
|
||||
report["applied"].append({"path": path, "old": old, "new": value})
|
||||
|
||||
elif target.kind == "milestone":
|
||||
# An override toggles a milestone in both directions, so unlike a
|
||||
# delta it takes false as well as true.
|
||||
if not isinstance(value, bool):
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a boolean",
|
||||
"fix": f"`{path}` takes true or false.",
|
||||
})
|
||||
continue
|
||||
reached = target.container(ws)
|
||||
old = bool(reached.get(target.key, {}).get("reached"))
|
||||
if value:
|
||||
reached[target.key] = {"reached": True}
|
||||
else:
|
||||
reached.pop(target.key, None)
|
||||
report["applied"].append({"path": path, "old": old, "new": value})
|
||||
|
||||
else:
|
||||
set_stat(target.container(ws), target.key, target.stat_def, value, path)
|
||||
|
||||
return ws, report
|
||||
|
||||
|
||||
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index: int) -> tuple[dict, dict]:
|
||||
"""Validates and clamps `delta` against `stat_schema`, then applies it.
|
||||
|
||||
The delta is applied to a copy of `world_state`. The return value is
|
||||
`(new_world_state, report)`.
|
||||
"""
|
||||
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
|
||||
if not ws:
|
||||
ws = instantiate(stat_schema)
|
||||
ws.setdefault("_meta", {}).setdefault("last_changed", {})
|
||||
meta = ws["_meta"]
|
||||
report: dict = {"applied": [], "clamped": [], "rejected": []}
|
||||
|
||||
if not isinstance(delta, dict):
|
||||
return ws, report
|
||||
|
||||
for raw_path, change in delta.items():
|
||||
path = str(raw_path)
|
||||
target, rejection = _resolve(path, stat_schema)
|
||||
if rejection is not None:
|
||||
report["rejected"].append(rejection)
|
||||
continue
|
||||
|
||||
if target.kind == "flag":
|
||||
# A flag goes both ways, so either value is accepted.
|
||||
if not isinstance(change, bool):
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not a boolean",
|
||||
"fix": f"`{path}` takes true or false.",
|
||||
})
|
||||
continue
|
||||
flags = target.container(ws)
|
||||
old = bool(flags.get(target.key, False))
|
||||
if change != old:
|
||||
flags[target.key] = change
|
||||
report["applied"].append({"path": path, "old": old, "new": change})
|
||||
|
||||
elif target.kind == "milestone":
|
||||
# A milestone is sticky, so only true is accepted.
|
||||
if change is not True:
|
||||
report["rejected"].append({
|
||||
"path": path, "reason": "not true",
|
||||
"fix": f"`{path}` can only be set to true. A milestone is "
|
||||
f"reached once and never taken back.",
|
||||
})
|
||||
continue
|
||||
reached = target.container(ws)
|
||||
if reached.get(target.key, {}).get("reached"):
|
||||
continue # Already reached, so do nothing.
|
||||
reached[target.key] = {"reached": True, "at": action_index}
|
||||
report["applied"].append({"path": path, "old": False, "new": True})
|
||||
|
||||
elif target.stat_def.get("type") == "text":
|
||||
_apply_text_stat(target.container(ws), target.key, target.stat_def, change,
|
||||
path, action_index, meta, report)
|
||||
|
||||
else:
|
||||
_apply_stat(target.container(ws), target.key, target.stat_def, change,
|
||||
path, action_index, meta, report)
|
||||
|
||||
return ws, report
|
||||
@@ -0,0 +1,165 @@
|
||||
"""The delta block: the rule the model is given, and reading back what it sent.
|
||||
|
||||
The model writes one fenced block per turn. This module holds the instruction
|
||||
text, the parser that survives the ways a model gets the format wrong, and the
|
||||
report rendering that tells a player which changes were refused.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
# Appended once to the system prompt, so the model knows how to report
|
||||
# changes.
|
||||
EMIT_RULE = (
|
||||
"You maintain a numeric world state. Treat your own narration as authoritative: "
|
||||
"whenever what you write implies a change to any tracked value — health or resources "
|
||||
"going up or down, time passing, a relationship or mood shifting, a status turning on "
|
||||
"or off, progress toward a goal, an item or piece of information gained or lost — you "
|
||||
"MUST record it, including the numbers, not just on/off flags. After your narration, "
|
||||
"append a fenced code block labelled `state` with a JSON object of the CHANGES ONLY, "
|
||||
"as deltas (not new totals). Update every value the scene affected this turn, not only "
|
||||
"the obvious ones. Read the range and band labels shown for each stat and keep every "
|
||||
"change proportionate to the moment: an ordinary or minor event nudges a value slightly, "
|
||||
"while a large change — or reaching a stat's minimum or maximum — is reserved for a "
|
||||
"genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a "
|
||||
"lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of "
|
||||
'its range in a single ordinary turn. Every stat is listed by its exact path in the '
|
||||
'stat guide and again beside its live value — copy a path from there rather than '
|
||||
'building one out of a name. The shapes are "player.<stat>", '
|
||||
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
||||
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
||||
'"milestones.<id>": true when an objective is completed. Some stats marked (free text) in '
|
||||
"the stat guide hold a short string instead of a number — for those, send the new value "
|
||||
"in full (not a delta), e.g. what the player is now wearing or holding; only send it when "
|
||||
"it actually changed. Send only things that actually "
|
||||
"changed and never restate unchanged values; if truly nothing changed, omit the block. "
|
||||
"Example:\n"
|
||||
'```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true, '
|
||||
'"player.outfit": "torn traveling cloak"}\n```'
|
||||
)
|
||||
|
||||
|
||||
# A short reminder placed at the end of the prompt, which is the strongest
|
||||
# recency position, so the emit rule is close to where the model generates.
|
||||
EMIT_REMINDER = (
|
||||
"[Reminder: end your reply with a ```state block of the changes this turn "
|
||||
"(deltas only), or omit it if truly nothing changed.]"
|
||||
)
|
||||
|
||||
|
||||
def render_delta_block(delta: dict) -> str:
|
||||
"""Renders a stored delta back into the fenced `state` block the AI emitted.
|
||||
|
||||
The caller replays past turns into the context with this, so the model copies
|
||||
the format. An empty delta returns an empty string, which means the turn
|
||||
changed nothing.
|
||||
"""
|
||||
if not isinstance(delta, dict) or not delta:
|
||||
return ""
|
||||
return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```"
|
||||
|
||||
|
||||
def applied_delta(world_delta: dict | None) -> dict:
|
||||
"""Returns the changes the engine accepted, shaped as the AI sends them.
|
||||
|
||||
Replaying the delta the AI sent would show it a refused change standing as
|
||||
though it had been applied, contradicted by the live values in the same
|
||||
prompt. The model has no way to read that as a correction, so it repeats
|
||||
the change. Replaying what was accepted removes the contradiction.
|
||||
|
||||
A numeric change that ended where it started is omitted, because it moved
|
||||
nothing and a zero in the replayed block reads as a value worth sending.
|
||||
"""
|
||||
if not isinstance(world_delta, dict):
|
||||
return {}
|
||||
out: dict = {}
|
||||
for entry in world_delta.get("applied") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = str(entry.get("path", ""))
|
||||
old, new = entry.get("old"), entry.get("new")
|
||||
if path.startswith("milestones."):
|
||||
out[path] = True
|
||||
elif path.startswith("flags."):
|
||||
out[path] = bool(new)
|
||||
elif isinstance(old, (int, float)) and isinstance(new, (int, float)):
|
||||
if new != old:
|
||||
out[path] = new - old
|
||||
else:
|
||||
out[path] = new
|
||||
return out
|
||||
|
||||
|
||||
def refusals(world_delta: dict | None) -> list[str]:
|
||||
"""Returns a correction line for each change the engine did not carry out.
|
||||
|
||||
Covers the changes that were lost: a rejection, and a clamp that left the
|
||||
value where it started. A clamp that reduced a change but still moved the
|
||||
value is deliberately absent. The model's intent landed in that case, and
|
||||
reporting the shortfall invites it to send the remainder on the next turn,
|
||||
which is the swing `max_delta_per_turn` exists to prevent.
|
||||
"""
|
||||
if not isinstance(world_delta, dict):
|
||||
return []
|
||||
lines = []
|
||||
for entry in world_delta.get("rejected") or []:
|
||||
if isinstance(entry, dict) and entry.get("fix"):
|
||||
lines.append(str(entry["fix"]))
|
||||
for entry in world_delta.get("clamped") or []:
|
||||
if isinstance(entry, dict) and entry.get("fix"):
|
||||
lines.append(str(entry["fix"]))
|
||||
return lines
|
||||
|
||||
|
||||
def render_refusals(world_delta: dict | None) -> str:
|
||||
"""Renders `refusals` as the note appended after the most recent AI turn."""
|
||||
lines = refusals(world_delta)
|
||||
if not lines:
|
||||
return ""
|
||||
body = "\n".join(f"- {ln}" for ln in lines)
|
||||
return ("[Part of your last state block was not applied. Correct it in this "
|
||||
f"turn's block:\n{body}]")
|
||||
|
||||
|
||||
# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL.
|
||||
_FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
# Fallback: a bare JSON object hugging the end of the text.
|
||||
_TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def _tolerant_load(blob: str) -> dict:
|
||||
# Strip trailing commas and leading + on numbers, both of which weaker
|
||||
# free models emit and strict JSON rejects.
|
||||
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
|
||||
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def extract_delta(text: str) -> tuple[str, dict]:
|
||||
"""Removes the trailing state block from an AI response.
|
||||
|
||||
The return value is `(clean_text, delta)`. `delta` is `{}` when there is no
|
||||
block or the block cannot be parsed, and `clean_text` has the block removed.
|
||||
A bare trailing object is stripped only when it parses to a delta, so
|
||||
ordinary prose that ends in `}` is left alone.
|
||||
"""
|
||||
matches = list(_FENCE_RE.finditer(text))
|
||||
if matches:
|
||||
m = matches[-1]
|
||||
delta = _tolerant_load(m.group(1))
|
||||
clean = (text[: m.start()] + text[m.end():]).strip()
|
||||
return clean, delta
|
||||
|
||||
m = _TRAILING_RE.search(text)
|
||||
if m:
|
||||
delta = _tolerant_load(m.group(1))
|
||||
if delta and all("." in str(k) for k in delta):
|
||||
clean = text[: m.start()].strip()
|
||||
return clean, delta
|
||||
return text.strip(), {}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Turning world state into the text the model and the player read.
|
||||
|
||||
`render_state_section` writes the current state into the prompt.
|
||||
`render_reference` writes the schema itself, so the model knows which stats
|
||||
exist and what they are called.
|
||||
"""
|
||||
from .schema import STAT_SECTIONS, _initials, band_label, npc_name
|
||||
|
||||
|
||||
def _stat_line(defs: dict, values: dict) -> str:
|
||||
parts = []
|
||||
for name, d in defs.items():
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
if d.get("type") == "text":
|
||||
val = values.get(name, d.get("initial", ""))
|
||||
parts.append(f'{name} "{val}"' if val else f"{name} (unset)")
|
||||
continue
|
||||
val = values.get(name, d.get("initial", 0))
|
||||
hi = d.get("max")
|
||||
shown = f"{val}/{hi}" if hi is not None else f"{val}"
|
||||
label = band_label(d, val)
|
||||
parts.append(f"{name} {shown}" + (f" ({label})" if label else ""))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def render_state_section(world_state: dict, stat_schema: dict,
|
||||
visible_npcs: dict[str, str],
|
||||
player_name: str = "") -> str:
|
||||
"""Returns the compact context block that every turn includes.
|
||||
|
||||
`visible_npcs` maps a card id to a display name, for the NPCs currently in
|
||||
the scene.
|
||||
|
||||
`player_name` is the persona's name (Phase 18), used to label the player's
|
||||
stat block. It is empty for an adventure with no persona, and the block then
|
||||
reads `You:` as it did before personas existed.
|
||||
"""
|
||||
ws = world_state if isinstance(world_state, dict) else {}
|
||||
lines: list[str] = []
|
||||
|
||||
world_defs = stat_schema.get("world") or {}
|
||||
world_line = _stat_line(world_defs, ws.get("world") or {})
|
||||
header = "World state" + (f" — {world_line}." if world_line else ".")
|
||||
lines.append(header)
|
||||
|
||||
player_defs = stat_schema.get("player") or {}
|
||||
player_line = _stat_line(player_defs, ws.get("player") or {})
|
||||
if player_line:
|
||||
# Name the block after the persona, and show the path beside it exactly
|
||||
# as the NPC lines below do. The model reads the name in the narration,
|
||||
# so without the path in view it writes `kaelen.hp` and the delta is
|
||||
# refused as an unknown path.
|
||||
label = f"{player_name} (player)" if player_name else "You"
|
||||
lines.append(f"{label}: {player_line}.")
|
||||
|
||||
npcs = stat_schema.get("npcs") or {}
|
||||
npc_state = ws.get("npc") or {}
|
||||
for npc_key, name in visible_npcs.items():
|
||||
ndef = npcs.get(npc_key) or {}
|
||||
stat_defs = ndef.get("stats") or {}
|
||||
values = npc_state.get(npc_key) or _initials(stat_defs)
|
||||
npc_line = _stat_line(stat_defs, values)
|
||||
if npc_line:
|
||||
# Show the id so the AI can address it as npc.<id>.<stat>.
|
||||
lines.append(f"{name} (npc.{npc_key}): {npc_line}.")
|
||||
|
||||
flag_defs = stat_schema.get("flags") or {}
|
||||
flag_state = ws.get("flags") or {}
|
||||
flag_parts = [
|
||||
f"{name} {'yes' if flag_state.get(name, bool(d.get('initial', False))) else 'no'}"
|
||||
for name, d in flag_defs.items() if isinstance(d, dict)
|
||||
]
|
||||
if flag_parts:
|
||||
lines.append("Flags: " + ", ".join(flag_parts) + ".")
|
||||
|
||||
# Show the id beside each goal, the same as NPCs and flags. The AI marks a
|
||||
# milestone as `milestones.<id>`, and `apply_delta` rejects an id the schema
|
||||
# does not define, so a goal listed by description alone gives the model no
|
||||
# way to name it and it can only guess.
|
||||
milestones = stat_schema.get("milestones") or {}
|
||||
reached = ws.get("milestones") or {}
|
||||
goals = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if not reached.get(mid, {}).get("reached")]
|
||||
done = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items()
|
||||
if reached.get(mid, {}).get("reached")]
|
||||
if goals:
|
||||
lines.append("Goals (mark with milestones.<id>): " + "; ".join(goals) + ".")
|
||||
if done:
|
||||
lines.append("Achieved: " + "; ".join(done) + ".")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _describe_stat(name: str, d: dict) -> str | None:
|
||||
"""Returns one reference line for a stat, named by the path the AI writes.
|
||||
|
||||
`name` is that path. The guide is the model's only complete list of what
|
||||
exists, so a stat named any other way leaves it to work the path out from
|
||||
the live values, and those cover only what is on screen this turn.
|
||||
|
||||
The description and the band ladder are independent, and each is included
|
||||
only when present, so a stat may have either, both, or neither.
|
||||
"""
|
||||
is_text = d.get("type") == "text"
|
||||
# `EMIT_RULE` sends the model here to find out which stats take a whole
|
||||
# value instead of a change, and it names this marker, so the two have to
|
||||
# be written the same way.
|
||||
label = f"{name} (free text)" if is_text else name
|
||||
bits: list[str] = []
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
# Fragments are joined with "; " and end with a single ".", so remove
|
||||
# any trailing period the author put on the description.
|
||||
bits.append(desc.strip().rstrip("."))
|
||||
if is_text:
|
||||
# Free text has no range and no bands, so the marker is the whole
|
||||
# entry. It still earns a line without a description, because the
|
||||
# marker is what stops the model sending a delta.
|
||||
return f"{label} — {'; '.join(bits)}." if bits else f"{label}."
|
||||
lo, hi = d.get("min"), d.get("max")
|
||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||
bits.append(f"range {lo}–{hi}")
|
||||
bands = d.get("bands")
|
||||
if isinstance(bands, list) and bands:
|
||||
ladder = ", ".join(
|
||||
f"{b[0]}–{b[1]} {b[2]}"
|
||||
for b in bands if isinstance(b, list) and len(b) == 3
|
||||
)
|
||||
if ladder:
|
||||
bits.append(f"bands: {ladder}")
|
||||
return f"{label} — {'; '.join(bits)}." if bits else None
|
||||
|
||||
|
||||
def render_reference(stat_schema: dict, player_name: str = "") -> str:
|
||||
"""Returns a fixed, per-scenario legend for the stats.
|
||||
|
||||
Each line gives what a stat means, from its `desc`, and its band ladder. The
|
||||
legend does not change from turn to turn, and it is separate from the live
|
||||
values.
|
||||
|
||||
`player_name` names the persona, so that the guide ties the name the model
|
||||
reads in the story to the `player.` paths it has to write. The persona's
|
||||
description is deliberately not repeated here: it has its own section, and
|
||||
this guide is about paths.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if player_name:
|
||||
lines.append(
|
||||
f"Protagonist {player_name} — the player character; their stats are "
|
||||
f"addressed as player.<stat>."
|
||||
)
|
||||
for section in STAT_SECTIONS:
|
||||
for name, d in (stat_schema.get(section) or {}).items():
|
||||
if isinstance(d, dict):
|
||||
row = _describe_stat(f"{section}.{name}", d)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
|
||||
if not isinstance(ndef, dict):
|
||||
continue
|
||||
name = npc_name(ndef, npc_key)
|
||||
desc = ndef.get("desc")
|
||||
# The header is written even for an NPC with no description, because it
|
||||
# is the only line that ties a display name to the id the AI has to
|
||||
# address. The live values state it too, but only for the NPCs a scene
|
||||
# has mentioned, so without this an NPC off screen can only be guessed
|
||||
# at — and a guess is refused as a character or a stat that does not
|
||||
# exist.
|
||||
head = f"NPC {name} (npc.{npc_key})"
|
||||
lines.append(
|
||||
f"{head} — {desc.strip().rstrip('.')}."
|
||||
if isinstance(desc, str) and desc.strip() else f"{head}."
|
||||
)
|
||||
for sname, sdef in (ndef.get("stats") or {}).items():
|
||||
if isinstance(sdef, dict):
|
||||
row = _describe_stat(f"npc.{npc_key}.{sname}", sdef)
|
||||
if row:
|
||||
lines.append(row)
|
||||
for name, d in (stat_schema.get("flags") or {}).items():
|
||||
if isinstance(d, dict):
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
lines.append(f"flags.{name} — {desc.strip().rstrip('.')}.")
|
||||
if not lines:
|
||||
return ""
|
||||
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Reading a scenario's `stat_schema` and building state that matches it.
|
||||
|
||||
Nothing here decides what a turn changes. These functions answer what the schema
|
||||
allows, what a fresh state looks like, and how an edited schema maps onto state
|
||||
that already exists.
|
||||
"""
|
||||
import copy
|
||||
|
||||
|
||||
# The `stat_schema` top-level sections that hold stat definitions.
|
||||
STAT_SECTIONS = ("world", "player")
|
||||
|
||||
|
||||
def has_schema(stat_schema: dict | None) -> bool:
|
||||
"""Returns `True` when a scenario defines an RPG layer."""
|
||||
if not isinstance(stat_schema, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(stat_schema.get(k), dict) and stat_schema[k]
|
||||
for k in (*STAT_SECTIONS, "npcs", "milestones", "flags")
|
||||
)
|
||||
|
||||
|
||||
def npc_name(ndef: dict, key: str) -> str:
|
||||
name = ndef.get("name")
|
||||
return name.strip() if isinstance(name, str) and name.strip() else key
|
||||
|
||||
|
||||
def npc_triggers(ndef: dict, key: str) -> list[str]:
|
||||
"""Returns the lowercased trigger words that detect an NPC in a scene.
|
||||
|
||||
The words come from the NPC's `keys` field, or from its display name when
|
||||
`keys` is empty.
|
||||
"""
|
||||
raw = ndef.get("keys") or npc_name(ndef, key)
|
||||
return [k.strip().lower() for k in str(raw).split(",") if k.strip()]
|
||||
|
||||
|
||||
def _initials(defs: dict) -> dict:
|
||||
return {
|
||||
name: d.get("initial", "" if d.get("type") == "text" else 0)
|
||||
for name, d in defs.items()
|
||||
if isinstance(d, dict)
|
||||
}
|
||||
|
||||
|
||||
def instantiate(stat_schema: dict | None) -> dict:
|
||||
"""Builds a fresh live `world_state` from a schema, using initial values only."""
|
||||
if not has_schema(stat_schema):
|
||||
return {}
|
||||
ws: dict = {}
|
||||
for section in STAT_SECTIONS:
|
||||
ws[section] = _initials(stat_schema.get(section) or {})
|
||||
# Each defined NPC gets its own stat block from its own `stats` defs.
|
||||
ws["npc"] = {
|
||||
key: _initials(ndef.get("stats") or {})
|
||||
for key, ndef in (stat_schema.get("npcs") or {}).items()
|
||||
if isinstance(ndef, dict)
|
||||
}
|
||||
ws["milestones"] = {} # only reached ones are stored
|
||||
ws["flags"] = {
|
||||
name: bool(d.get("initial", False))
|
||||
for name, d in (stat_schema.get("flags") or {}).items()
|
||||
if isinstance(d, dict)
|
||||
}
|
||||
ws["_meta"] = {"last_changed": {}}
|
||||
return ws
|
||||
|
||||
|
||||
def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]:
|
||||
"""Brings a live `world_state` back in line with an edited schema.
|
||||
|
||||
This is not `instantiate`. A value the schema still defines keeps whatever it
|
||||
reached in play, because re-instantiating would restore the player to full
|
||||
health and clear their milestones. Only the difference is applied. Stats,
|
||||
NPCs, flags, and milestones the schema gained appear at their initial value,
|
||||
and ones it no longer defines are removed along with their `last_changed`
|
||||
bookkeeping. The return value is `(new_state, report)`, where the report
|
||||
lists paths under `added` and `removed`, so the UI can show what a refresh
|
||||
would do.
|
||||
|
||||
The additions are mostly cosmetic. Rendering and delta application both fall
|
||||
back to a stat definition's `initial` when the live state has no value for
|
||||
it, so a newly added stat already behaves correctly. This function stores the
|
||||
value, and unlike those read-through paths it also removes what the schema
|
||||
dropped.
|
||||
"""
|
||||
report: dict = {"added": [], "removed": []}
|
||||
if not has_schema(stat_schema):
|
||||
# The scenario dropped its RPG layer, so the adventure drops it too.
|
||||
stale = bool(world_state)
|
||||
if stale:
|
||||
report["removed"].append("(all world state)")
|
||||
return {}, report
|
||||
|
||||
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
|
||||
if not ws:
|
||||
return instantiate(stat_schema), report
|
||||
|
||||
def sync_section(container: dict, defs: dict, prefix: str) -> dict:
|
||||
out = {}
|
||||
for name, d in defs.items():
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
if name in container:
|
||||
out[name] = container[name]
|
||||
else:
|
||||
out[name] = d.get("initial", "" if d.get("type") == "text" else 0)
|
||||
report["added"].append(f"{prefix}.{name}")
|
||||
for name in container:
|
||||
if name not in out:
|
||||
report["removed"].append(f"{prefix}.{name}")
|
||||
return out
|
||||
|
||||
for section in STAT_SECTIONS:
|
||||
ws[section] = sync_section(
|
||||
ws.get(section) if isinstance(ws.get(section), dict) else {},
|
||||
stat_schema.get(section) or {},
|
||||
section,
|
||||
)
|
||||
|
||||
npc_defs = stat_schema.get("npcs") or {}
|
||||
old_npcs = ws.get("npc") if isinstance(ws.get("npc"), dict) else {}
|
||||
new_npcs = {}
|
||||
for key, ndef in npc_defs.items():
|
||||
if not isinstance(ndef, dict):
|
||||
continue
|
||||
old = old_npcs.get(key) if isinstance(old_npcs.get(key), dict) else {}
|
||||
new_npcs[key] = sync_section(old, ndef.get("stats") or {}, f"npc.{key}")
|
||||
for key in old_npcs:
|
||||
if key not in new_npcs:
|
||||
report["removed"].append(f"npc.{key}")
|
||||
ws["npc"] = new_npcs
|
||||
|
||||
flag_defs = stat_schema.get("flags") or {}
|
||||
old_flags = ws.get("flags") if isinstance(ws.get("flags"), dict) else {}
|
||||
new_flags = {}
|
||||
for name, d in flag_defs.items():
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
if name in old_flags:
|
||||
new_flags[name] = bool(old_flags[name])
|
||||
else:
|
||||
new_flags[name] = bool(d.get("initial", False))
|
||||
report["added"].append(f"flags.{name}")
|
||||
for name in old_flags:
|
||||
if name not in new_flags:
|
||||
report["removed"].append(f"flags.{name}")
|
||||
ws["flags"] = new_flags
|
||||
|
||||
# Milestones store only the ones reached, so there is nothing to add here.
|
||||
# An unreached milestone is absent. Drop reached milestones the scenario no
|
||||
# longer defines, because they would otherwise stay in "Achieved" with no
|
||||
# label.
|
||||
milestone_defs = stat_schema.get("milestones") or {}
|
||||
reached = ws.get("milestones") if isinstance(ws.get("milestones"), dict) else {}
|
||||
ws["milestones"] = {k: v for k, v in reached.items() if k in milestone_defs}
|
||||
for k in reached:
|
||||
if k not in milestone_defs:
|
||||
report["removed"].append(f"milestones.{k}")
|
||||
|
||||
# Cooldown bookkeeping for paths that no longer exist is never read, but it
|
||||
# accumulates in every stored snapshot, so remove it with the rest.
|
||||
meta = ws.setdefault("_meta", {})
|
||||
last_changed = meta.get("last_changed")
|
||||
if isinstance(last_changed, dict):
|
||||
removed = set(report["removed"])
|
||||
meta["last_changed"] = {
|
||||
path: at for path, at in last_changed.items()
|
||||
if not any(path == r or path.startswith(f"{r}.") for r in removed)
|
||||
}
|
||||
else:
|
||||
meta["last_changed"] = {}
|
||||
|
||||
return ws, report
|
||||
|
||||
|
||||
def band_label(stat_def: dict, value) -> str | None:
|
||||
"""Returns the word label for `value` from a stat definition's bands, if any.
|
||||
|
||||
A band is `[lo, hi, label]` and matches when `lo <= value < hi`. The top band
|
||||
includes its upper bound, so a stat at its maximum still gets a label.
|
||||
"""
|
||||
bands = stat_def.get("bands")
|
||||
if not isinstance(bands, list) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
last_hi = None
|
||||
for band in bands:
|
||||
if not (isinstance(band, list) and len(band) == 3):
|
||||
continue
|
||||
lo, hi, label = band
|
||||
last_hi = hi
|
||||
if lo <= value < hi:
|
||||
return str(label)
|
||||
# Inclusive top edge.
|
||||
if bands and value == last_hi:
|
||||
return str(bands[-1][2])
|
||||
return None
|
||||
@@ -0,0 +1,4 @@
|
||||
# Dev/test-only dependencies (not installed on the Render deploy).
|
||||
# pip install -r requirements.txt -r requirements-dev.txt
|
||||
# python -m pytest tests/
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,51 @@
|
||||
# Exact versions of the whole dependency closure, including transitive ones.
|
||||
#
|
||||
# requirements.txt states the ranges the code needs; this file states what was
|
||||
# actually installed and tested, so a fresh checkout reproduces a known-good
|
||||
# environment instead of resolving whatever is newest that day. It covers the
|
||||
# dev/test dependencies too, because the regression report is only meaningful
|
||||
# against a pinned suite.
|
||||
#
|
||||
# python3 -m venv backend/.venv
|
||||
# backend/.venv/bin/pip install -r backend/requirements.lock
|
||||
#
|
||||
# Regenerate after a deliberate upgrade, by installing from requirements.txt
|
||||
# and requirements-dev.txt and re-running the suite:
|
||||
#
|
||||
# backend/.venv/bin/pip freeze > backend/requirements.lock # then restore this header
|
||||
#
|
||||
# `pip` itself is deliberately left out: it is the tool, not a dependency.
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.5.1
|
||||
click==8.5.0
|
||||
fastapi==0.141.1
|
||||
greenlet==3.5.5
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
idna==3.19
|
||||
iniconfig==2.3.0
|
||||
packaging==26.3
|
||||
pluggy==1.6.0
|
||||
pydantic==2.13.5
|
||||
pydantic_core==2.46.5
|
||||
Pygments==2.21.0
|
||||
pytest==9.1.1
|
||||
python-dotenv==1.2.3
|
||||
PyYAML==6.0.3
|
||||
regex==2026.9.3
|
||||
requests==2.34.2
|
||||
SQLAlchemy==2.0.52
|
||||
starlette==1.6.0
|
||||
tiktoken==0.14.0
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.52.4
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
websockets==17.1
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
sqlalchemy>=2.0
|
||||
pydantic>=2.7
|
||||
httpx>=0.27
|
||||
# Imported directly by app/tlstrust.py, which unions this bundle with the
|
||||
# machine's own CA store. It arrives with httpx anyway; declared because the
|
||||
# code imports it by name.
|
||||
certifi
|
||||
tiktoken>=0.7
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Seed a demo scenario + adventure with sample scripts for testing.
|
||||
|
||||
Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py
|
||||
Safe to rerun: it deletes any previous rows titled "[Demo] ..." first.
|
||||
|
||||
Phase 8: the scenario is seeded as PUBLIC (user_id NULL + is_public), so in
|
||||
multi-user mode every guest sees it as read-only starter content. The sample
|
||||
adventure and script-library copies belong to the local user (only relevant
|
||||
on single-user installs).
|
||||
"""
|
||||
|
||||
from app import auth, models, migrations, tree
|
||||
from app.database import SessionLocal, engine
|
||||
|
||||
# create_all + user_version stamp; plain create_all would leave a fresh DB at
|
||||
# version 0 and the server would replay every ALTER TABLE migration on start.
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
DEMO_PREFIX = "[Demo]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample scripts. The AI Dungeon contract is to define `modifier(text)` and call
|
||||
# it last.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DICE_ROLLER = dict(
|
||||
name=f"{DEMO_PREFIX} Dice Roller",
|
||||
description=(
|
||||
"Input hook + shared library. Type '!roll 2d6' or '!roll d20' in a Do/Say/"
|
||||
"Story action and the command is replaced with the rolled result."
|
||||
),
|
||||
library_js="""\
|
||||
// Shared library: available to every hook of this script.
|
||||
function rollDice(count, sides) {
|
||||
var total = 0, rolls = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
var r = Math.floor(Math.random() * sides) + 1;
|
||||
rolls.push(r);
|
||||
total += r;
|
||||
}
|
||||
return { total: total, rolls: rolls };
|
||||
}
|
||||
""",
|
||||
input_js="""\
|
||||
const modifier = (text) => {
|
||||
// Replace every "!roll NdS" (N optional) with the roll result.
|
||||
var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) {
|
||||
var count = parseInt(n || "1", 10);
|
||||
var sides = parseInt(s, 10);
|
||||
var res = rollDice(count, sides);
|
||||
log("Rolled " + count + "d" + sides + ": [" + res.rolls.join(", ") + "] = " + res.total);
|
||||
return "(rolled " + count + "d" + sides + ": " + res.total + ")";
|
||||
});
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
TURN_TRACKER = dict(
|
||||
name=f"{DEMO_PREFIX} Turn & HP Tracker",
|
||||
description=(
|
||||
"Demonstrates persistent state. Counts turns; '!hp -3' or '!hp +5' in input "
|
||||
"adjusts HP (starts at 20). Current stats appear in state.message."
|
||||
),
|
||||
input_js="""\
|
||||
const modifier = (text) => {
|
||||
if (state.hp === undefined) state.hp = 20;
|
||||
state.turns = (state.turns || 0) + 1;
|
||||
|
||||
var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) {
|
||||
state.hp += parseInt(delta, 10);
|
||||
return "";
|
||||
});
|
||||
|
||||
state.message = "Turn " + state.turns + " | HP: " + state.hp + "/20";
|
||||
log(state.message);
|
||||
|
||||
if (state.hp <= 0) {
|
||||
// stop:true ends the turn before the AI is called.
|
||||
return { text: out + "\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)", stop: true };
|
||||
}
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
CONTEXT_INSPECTOR = dict(
|
||||
name=f"{DEMO_PREFIX} Context Inspector",
|
||||
description=(
|
||||
"Context hook: logs the size of the assembled context each turn and appends "
|
||||
"a style directive. Check the logs/context in the action's context snapshot."
|
||||
),
|
||||
context_js="""\
|
||||
const modifier = (text) => {
|
||||
log("Context size: " + text.length + " chars, actions so far: " + info.actionCount
|
||||
+ ", story cards: " + storyCards.length);
|
||||
// Anything returned here replaces what is sent to the model.
|
||||
return { text: text + "\\n[Style: keep the response under three paragraphs.]" };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
OUTPUT_POLISH = dict(
|
||||
name=f"{DEMO_PREFIX} Output Polish + Card Discovery",
|
||||
description=(
|
||||
"Output hook: trims a trailing incomplete sentence from the AI reply, and "
|
||||
"auto-creates a story card the first time the ghost Vharos is mentioned."
|
||||
),
|
||||
output_js="""\
|
||||
const modifier = (text) => {
|
||||
var out = text;
|
||||
|
||||
// Drop a trailing sentence fragment (no ending punctuation).
|
||||
var m = out.match(/^([\\s\\S]*[.!?"'\\u2026])[^.!?"'\\u2026]*$/);
|
||||
if (m && m[1].length > 40) {
|
||||
if (m[1].length < out.length) log("Trimmed incomplete final sentence.");
|
||||
out = m[1];
|
||||
}
|
||||
|
||||
// Demonstrate script-created story cards.
|
||||
if (/vharos/i.test(out)) {
|
||||
var added = addStoryCard(
|
||||
"Vharos, ghost, spirit",
|
||||
"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.",
|
||||
"character"
|
||||
);
|
||||
if (added !== false) log("Vharos mentioned — story card created.");
|
||||
}
|
||||
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
SCRIPTS = [DICE_ROLLER, TURN_TRACKER, CONTEXT_INSPECTOR, OUTPUT_POLISH]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCENARIO = dict(
|
||||
title=f"{DEMO_PREFIX} The Sunken Crypt of Vharos",
|
||||
description=(
|
||||
"A short dungeon-crawl demo scenario with story cards and one of each "
|
||||
"script hook, for testing the app end to end."
|
||||
),
|
||||
prompt=(
|
||||
"Rain hammers the moors as you descend the moss-slick steps beneath the "
|
||||
"ruined chapel. Your torch gutters in the stale air. Below, the Sunken "
|
||||
"Crypt of Vharos waits — its iron door ajar, as if someone (or something) "
|
||||
"expected you. Mira's warning rings in your ears: bring back the Ember "
|
||||
"Amulet before nightfall, or the village of Hollowmere burns.\n\n"
|
||||
"You stand before the iron door, water pooling around your boots."
|
||||
),
|
||||
memory=(
|
||||
"The player is an adventurer hired by Mira, blacksmith of Hollowmere, to "
|
||||
"retrieve the Ember Amulet from the Sunken Crypt of Vharos before "
|
||||
"nightfall. The crypt is flooded, dark, and haunted. Tone: classic D&D "
|
||||
"dungeon crawl, dangerous but fair."
|
||||
),
|
||||
authors_note="Keep scenes tense and grounded; offer clear choices; consequences matter.",
|
||||
ai_instructions=(
|
||||
"Write in second person, present tense. End each response at a moment "
|
||||
"where the player can act."
|
||||
),
|
||||
tags="demo, dungeon, fantasy, short",
|
||||
)
|
||||
|
||||
STORY_CARDS = [
|
||||
dict(
|
||||
type="character",
|
||||
name="Mira the Blacksmith",
|
||||
keys="Mira, blacksmith",
|
||||
entry=(
|
||||
"Mira is Hollowmere's blacksmith: broad-shouldered, gray-braided, "
|
||||
"practical. She hired the player and paid half up front. She knows "
|
||||
"more about the crypt than she has admitted."
|
||||
),
|
||||
notes="Secretly a descendant of Vharos.",
|
||||
),
|
||||
dict(
|
||||
type="location",
|
||||
name="The Sunken Crypt",
|
||||
keys="crypt, tomb, Vharos",
|
||||
entry=(
|
||||
"A flooded burial complex beneath a ruined chapel. Knee-deep black "
|
||||
"water, collapsed pillars, and phosphorescent moss. Three chambers: "
|
||||
"the Drowned Hall, the Ossuary, and the sealed Reliquary where the "
|
||||
"Ember Amulet rests."
|
||||
),
|
||||
notes="",
|
||||
),
|
||||
dict(
|
||||
type="item",
|
||||
name="The Ember Amulet",
|
||||
keys="amulet, ember",
|
||||
entry=(
|
||||
"A fist-sized garnet on a bronze chain that glows like a coal. It "
|
||||
"keeps Hollowmere's protective hearth-ward burning. Touching it bare-"
|
||||
"handed brands the flesh but does no lasting harm."
|
||||
),
|
||||
notes="",
|
||||
),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
owner = auth.local_user(db)
|
||||
|
||||
# Remove earlier demo rows so reruns stay clean.
|
||||
for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(adv)
|
||||
for sc in db.query(models.Scenario).filter(models.Scenario.title.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(sc)
|
||||
for s in db.query(models.Script).filter(models.Script.name.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
|
||||
# Scripts attached to the public scenario are unowned (user_id NULL) so
|
||||
# they ship with it everywhere; they're copied into each adventure at
|
||||
# creation, so they never need to appear in anyone's script library.
|
||||
scripts = [models.Script(**s) for s in SCRIPTS]
|
||||
db.add_all(scripts)
|
||||
|
||||
# A scenario with cards and scripts attached, as public starter content.
|
||||
scenario = models.Scenario(**SCENARIO, is_public=True)
|
||||
scenario.scripts = scripts
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
for card in STORY_CARDS:
|
||||
db.add(models.StoryCard(scenario_id=scenario.id, **card))
|
||||
|
||||
# Adventure created from the scenario, mirroring POST /api/adventures
|
||||
adventure = models.Adventure(
|
||||
user_id=owner.id,
|
||||
scenario_id=scenario.id,
|
||||
title=scenario.title,
|
||||
memory=scenario.memory,
|
||||
authors_note=scenario.authors_note,
|
||||
ai_instructions=scenario.ai_instructions,
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
for card in STORY_CARDS:
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, **card))
|
||||
for position, s in enumerate(SCRIPTS):
|
||||
db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s))
|
||||
opening = models.Action(
|
||||
adventure_id=adventure.id, type="start", text=scenario.prompt
|
||||
)
|
||||
# Through the same door create_adventure uses, so the seeded adventure has a
|
||||
# story tree like every other one.
|
||||
tree.place_action(db, adventure, opening)
|
||||
db.add(opening)
|
||||
db.commit()
|
||||
|
||||
print(f"Scenario id={scenario.id}: {scenario.title}")
|
||||
print(f"Adventure id={adventure.id}: {adventure.title}")
|
||||
print(f"Scripts: {', '.join(s.name for s in scripts)}")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared setup for the test suite.
|
||||
|
||||
pytest imports this file before it imports any test module, which is the only
|
||||
reason the database redirection below works. `app.database` reads
|
||||
`AIDND_DB_PATH` at import and builds `engine` from it once, so the variable has
|
||||
to be set before the first `from app...` line anywhere in the suite.
|
||||
|
||||
Every test module used to carry its own copy of that redirection. Only the first
|
||||
one to be imported ever took effect, because the engine already existed by the
|
||||
time the second one ran. The other copies created a temp file that nothing
|
||||
opened and nothing deleted. One copy here does the job, and it cleans up after
|
||||
itself.
|
||||
|
||||
The tests share one database. That is not new: they already did. Each `client`
|
||||
fixture calls `Base.metadata.create_all` on setup and `drop_all` on teardown, so
|
||||
no test sees another test's rows.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp.close()
|
||||
os.environ["AIDND_DB_PATH"] = _tmp.name
|
||||
# A real `AIDND_DATABASE_URL` or `DATABASE_URL` in the developer's shell points
|
||||
# at Postgres, and `app.database` prefers either over the SQLite path above.
|
||||
# Clear both, so running the suite never touches a server database.
|
||||
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
|
||||
import pytest # noqa: E402 Import order is load-bearing; see above.
|
||||
|
||||
from fakes import ScriptedProvider # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_scripted_provider():
|
||||
"""Clears the fake provider's state between tests.
|
||||
|
||||
`ScriptedProvider` keeps its replies and its call count on the class, because
|
||||
the code under test constructs the provider itself and a test cannot reach
|
||||
the instance. Class state outlives a test, so reset it here rather than
|
||||
trusting every fixture to remember.
|
||||
"""
|
||||
ScriptedProvider.replies = []
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
yield
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Deletes the temporary database once the run ends."""
|
||||
try:
|
||||
os.unlink(_tmp.name)
|
||||
except OSError:
|
||||
# The file is already gone, or Windows still holds a handle on it. It is
|
||||
# in the temp directory either way, so leaving it costs nothing.
|
||||
pass
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Stand-ins for the parts of the app a test must not really call.
|
||||
|
||||
Import these rather than writing another copy. Nine test modules each carried
|
||||
their own `ScriptedProvider`, and the copies had drifted into four different
|
||||
feature sets, so a test that needed to raise a provider error had to be written
|
||||
in one of the files whose copy supported it.
|
||||
"""
|
||||
|
||||
|
||||
class ScriptedProvider:
|
||||
"""Streams canned replies in place of `OpenAICompatibleProvider`.
|
||||
|
||||
Set `replies` to the texts the model returns, one per call. The last entry
|
||||
repeats once the list runs out, so a test that plays more turns than it
|
||||
scripted still gets text. To drive the provider-error path, put an
|
||||
`Exception` in the list. It is raised rather than streamed.
|
||||
|
||||
State lives on the class, not on the instance, because the turn engine
|
||||
constructs the provider itself and a test never sees the object. The autouse
|
||||
`reset_scripted_provider` fixture in `conftest.py` clears it between tests.
|
||||
|
||||
`prompts` records every assembled `(system, story)` pair, which is what a
|
||||
test asserts on to check what the model was shown.
|
||||
"""
|
||||
|
||||
last_usage = None
|
||||
replies: list = []
|
||||
calls = 0
|
||||
prompts: list = []
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def generate(self, parts, *, temperature, max_tokens):
|
||||
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
|
||||
ScriptedProvider.calls += 1
|
||||
ScriptedProvider.prompts.append((parts.system, parts.story))
|
||||
reply = ScriptedProvider.replies[index]
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
yield ("text", reply)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deterministic per-turn state instrumentation
|
||||
# ---------------------------------------------------------------------------
|
||||
# Several tests need a value that changes by a fixed amount on every turn, so
|
||||
# that a rollback failure is arithmetic rather than a judgement call: if a take
|
||||
# stacks instead of replacing, the total is off by exactly one turn's worth.
|
||||
#
|
||||
# That instrumentation used to be a JavaScript `output` hook doing
|
||||
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
|
||||
# the tests below are not about scripting — they are about the state snapshot,
|
||||
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
|
||||
# which is unchanged.
|
||||
#
|
||||
# The counter therefore moved to the world-state engine, which is a real
|
||||
# remaining product path: the model emits a ```state delta block, the referee
|
||||
# applies it, and the result lands in `adventure.world_state`. The fake
|
||||
# provider decides what the model "emits", so it is exactly as deterministic as
|
||||
# the script was, and it exercises production code rather than a test hook.
|
||||
|
||||
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
|
||||
#: `cooldown`, so every +10 is applied in full, every turn.
|
||||
GOLD_SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
GOLD_PER_TURN = 10
|
||||
|
||||
|
||||
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
|
||||
"""A model reply that narrates `text` and banks `amount` gold."""
|
||||
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
|
||||
|
||||
|
||||
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
|
||||
"""`count` numbered replies, each banking one turn's worth of gold."""
|
||||
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Make a database look like an older schema version, so a migration can run.
|
||||
|
||||
`create_all` always builds the current schema. A test that wants to watch a
|
||||
migration run must remove the newer columns first and then stamp an older
|
||||
version. Otherwise the migration finds a column that already exists and
|
||||
fails on a duplicate.
|
||||
|
||||
Rewinding the stamp alone worked for a while, so two test files did exactly
|
||||
that. It stopped working when another `ADD COLUMN` migration landed. Without
|
||||
this rewind, the replay runs migrations the tests never intended to
|
||||
exercise, against columns `create_all` already added. This module rewinds
|
||||
properly in one place. Adding a migration now means adding its inverse here,
|
||||
instead of tracking down failures in three unrelated test files.
|
||||
|
||||
This module supports SQLite only. Every test that replays migrations runs on
|
||||
a temp file, and SQLite stores the stamp in `PRAGMA user_version`.
|
||||
Migrations that change a column's type (43-45, JSON to compressed bytes)
|
||||
have no clean inverse, so this list omits them. Those migrations replay
|
||||
as-is, which is what the tests using them already expect.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
# Each entry is (version that added the column, statements that remove it).
|
||||
# The list is ordered newest first.
|
||||
#
|
||||
# Phase 14's `branch_id` columns are missing on purpose. SQLite refuses to
|
||||
# drop a column that a foreign key references, so a current-schema database
|
||||
# cannot be rewound past them. `migrations._column_already_there` handles
|
||||
# this case. It skips DDL that already ran, so the tree migrations run their
|
||||
# backfill against a schema that already has the columns.
|
||||
_UNDO: list[tuple[int, tuple[str, ...]]] = [
|
||||
# Packed float32 vectors and the flag beside them.
|
||||
(39, ("ALTER TABLE memories DROP COLUMN embedded",)),
|
||||
(38, ("ALTER TABLE memories DROP COLUMN embedding_blob",)),
|
||||
]
|
||||
|
||||
|
||||
def rewind_to(engine: Engine, version: int) -> None:
|
||||
"""Drop everything added after `version`, then stamp the database at it."""
|
||||
with engine.begin() as conn:
|
||||
for added_at, statements in _UNDO:
|
||||
if added_at > version:
|
||||
for sql in statements:
|
||||
conn.execute(text(sql))
|
||||
conn.execute(text(f"PRAGMA user_version = {version}"))
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Opening an adventure fetches a window, not the whole story.
|
||||
|
||||
A story only ever gets longer. Production's longest is 607 actions and
|
||||
589.5 kB in one response, and that number never decreases on its own. The
|
||||
page load returns the newest `ACTION_PAGE` window, and the reader pages
|
||||
upward from there.
|
||||
|
||||
The paging anchors on an action id rather than an offset, and these tests
|
||||
cover why. An offset counted back from the newest shifts every older
|
||||
position the moment a turn lands, which is exactly when a reader is likely
|
||||
to be scrolling. An anchor means the same thing before and after.
|
||||
|
||||
python -m pytest tests/test_action_paging.py -v
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers.adventures import ACTION_PAGE
|
||||
from tools import dbmeter
|
||||
|
||||
TOTAL = ACTION_PAGE * 3 + 7 # deliberately not a whole number of pages
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="paging@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="m"))
|
||||
adventure = models.Adventure(user_id=user.id, title="Cave", script_state={})
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
for i in range(TOTAL):
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="start" if i == 0 else ("ai" if i % 2 else "do"),
|
||||
text=f"Action {i}." + "word " * 200,
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def ordinal(action) -> int:
|
||||
"""Returns which turn this is, read out of the fixture's own text.
|
||||
|
||||
The payload used to carry `index`, a story-wide turn number that SP8
|
||||
dropped. Nothing replaced it: a depth is a position along one branch, and
|
||||
the pager keys on ids. The fixture numbers its own actions, so these tests
|
||||
read the number back rather than reintroduce one.
|
||||
"""
|
||||
return int(re.match(r"Action (\d+)\.", action["text"]).group(1))
|
||||
|
||||
|
||||
def page(client, before_id=None, limit=None):
|
||||
params = {}
|
||||
if before_id is not None:
|
||||
params["before_id"] = before_id
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/actions", params=params)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def add_action(client, text="A new turn.") -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
action = models.Action(
|
||||
adventure_id=client.adv_id, type="ai", text=text
|
||||
)
|
||||
db.add(action)
|
||||
db.commit()
|
||||
return action.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the page load
|
||||
|
||||
def test_the_page_load_returns_only_the_newest_window(client):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["actions"]) == ACTION_PAGE
|
||||
assert body["action_count"] == TOTAL
|
||||
# It is the newest window, ending on the last action.
|
||||
assert ordinal(body["actions"][-1]) == TOTAL - 1
|
||||
assert ordinal(body["actions"][0]) == TOTAL - ACTION_PAGE
|
||||
|
||||
|
||||
def test_a_short_story_is_returned_whole(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Action).filter(models.Action.depth >= 5).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
body = client.get(f"/api/adventures/{client.adv_id}").json()
|
||||
assert len(body["actions"]) == 5
|
||||
assert body["action_count"] == 5
|
||||
|
||||
|
||||
def test_the_page_load_does_not_grow_with_the_story(client):
|
||||
"""Confirm that opening a story costs a window, regardless of the
|
||||
story's length."""
|
||||
meter = dbmeter.Meter()
|
||||
meter.attach(engine)
|
||||
try:
|
||||
with meter.scope("page load"):
|
||||
client.get(f"/api/adventures/{client.adv_id}")
|
||||
windowed = meter.scopes[-1].total.fetched
|
||||
finally:
|
||||
meter.detach()
|
||||
|
||||
# Each action carries about 1 KB of text, and there are 187 of them. A
|
||||
# window holds 60 actions. This ceiling is generous but still far below
|
||||
# the size of the whole story.
|
||||
assert windowed < ACTION_PAGE * 2_000, f"{windowed:,} B for one window"
|
||||
assert windowed < TOTAL * 500, (
|
||||
f"{windowed:,} B — that is the whole story, not a window"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ paging up
|
||||
|
||||
def test_the_first_page_is_the_newest(client):
|
||||
body = page(client)
|
||||
assert len(body["actions"]) == ACTION_PAGE
|
||||
assert body["total"] == TOTAL
|
||||
assert body["has_more"] is True
|
||||
assert ordinal(body["actions"][-1]) == TOTAL - 1
|
||||
|
||||
|
||||
def test_paging_up_covers_the_whole_story_exactly_once(client):
|
||||
seen = []
|
||||
body = page(client)
|
||||
seen = [ordinal(a) for a in body["actions"]]
|
||||
guard = 0
|
||||
while body["has_more"]:
|
||||
guard += 1
|
||||
assert guard < 20, "paging did not terminate"
|
||||
body = page(client, before_id=body["actions"][0]["id"])
|
||||
seen = [ordinal(a) for a in body["actions"]] + seen
|
||||
|
||||
assert seen == list(range(TOTAL)), "gap, duplicate or reordering while paging"
|
||||
|
||||
|
||||
def test_has_more_is_false_at_the_beginning_of_the_story(client):
|
||||
body = page(client)
|
||||
while body["has_more"]:
|
||||
body = page(client, before_id=body["actions"][0]["id"])
|
||||
assert ordinal(body["actions"][0]) == 0
|
||||
|
||||
|
||||
def test_each_page_is_ordered_oldest_first(client):
|
||||
body = page(client)
|
||||
indices = [ordinal(a) for a in body["actions"]]
|
||||
assert indices == sorted(indices)
|
||||
|
||||
|
||||
# ------------------------------------------------- the reason for the anchor
|
||||
|
||||
def test_a_turn_arriving_mid_scroll_does_not_shift_the_next_page(client):
|
||||
"""Reproduce the failure an offset-based scheme would have. Read the
|
||||
newest page, let a turn land, then page up. The reader must get exactly
|
||||
what precedes the actions they already hold, with no duplicate and no
|
||||
skipped action."""
|
||||
first = page(client)
|
||||
oldest_held = first["actions"][0]
|
||||
|
||||
add_action(client)
|
||||
|
||||
older = page(client, before_id=oldest_held["id"])
|
||||
assert ordinal(older["actions"][-1]) == ordinal(oldest_held) - 1, (
|
||||
"the page shifted when a turn landed"
|
||||
)
|
||||
assert all(ordinal(a) < ordinal(oldest_held) for a in older["actions"])
|
||||
# The new turn changes the total, which is expected. It must not move the window.
|
||||
assert older["total"] == TOTAL + 1
|
||||
|
||||
|
||||
def test_a_deleted_anchor_reports_the_end_rather_than_a_duplicate_page(client):
|
||||
"""Undo can remove the action a slow scroll was anchored to. The endpoint
|
||||
must stop instead of returning a page the reader already has."""
|
||||
body = page(client)
|
||||
anchor = body["actions"][0]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Action).filter(models.Action.id == anchor["id"]).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
after = page(client, before_id=anchor["id"])
|
||||
assert after["actions"] == []
|
||||
assert after["has_more"] is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- limits
|
||||
|
||||
def test_limit_is_honoured_and_capped(client):
|
||||
assert len(page(client, limit=5)["actions"]) == 5
|
||||
# A client that asks for the whole story cannot bypass the paging cap.
|
||||
assert len(page(client, limit=100_000)["actions"]) <= ACTION_PAGE * 4
|
||||
|
||||
|
||||
def test_a_nonsense_limit_still_returns_something(client):
|
||||
assert len(page(client, limit=0)["actions"]) >= 1
|
||||
assert len(page(client, limit=-5)["actions"]) >= 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_returns_a_window_not_the_story(client):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["actions"]) == ACTION_PAGE
|
||||
assert body["total"] == TOTAL - 1
|
||||
assert body["has_more"] is True
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Phase 14 SP4: a retry writes a sibling node instead of rewriting a row.
|
||||
|
||||
`test_retry_variants.py` is the behavioral contract from before the tree
|
||||
existed, and it still passes unchanged: the same URLs, the same payload
|
||||
shape, the same outcomes. This file asserts the things that are true only of
|
||||
the new storage. A turn can be several rows, exactly one of them is the
|
||||
story, and the arrangement costs neither an extra prompt nor an extra turn.
|
||||
|
||||
python -m pytest tests/test_attempt_siblings.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import undefer
|
||||
|
||||
from app import attempts, auth, limits, models, tree
|
||||
from app.context import cursors, history
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="siblings@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = ["Attempt one."]
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _play(client, text="look around", type="do"):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": type, "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _retry(client):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _page(client) -> dict:
|
||||
return client.get(f"/api/adventures/{client.adv_id}").json()
|
||||
|
||||
|
||||
def _rows(adv_id) -> list[models.Action]:
|
||||
"""Every action row of the adventure, story or not, live or not.
|
||||
|
||||
The query undefers these columns because the session closes before the
|
||||
caller reads the result. This file specifically tests the columns that a
|
||||
page load never loads.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv_id)
|
||||
.options(
|
||||
undefer(models.Action.state_after),
|
||||
undefer(models.Action.world_state_after),
|
||||
undefer(models.Action.context_snapshot),
|
||||
)
|
||||
.order_by(models.Action.depth, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the sibling
|
||||
|
||||
def test_a_retry_writes_a_second_row_at_the_same_coordinate(client):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
|
||||
rows = _rows(client.adv_id)
|
||||
ai = [a for a in rows if a.type == "ai"]
|
||||
assert len(ai) == 2, "a retry is a node, not a rewrite"
|
||||
assert {(a.branch_id, a.depth) for a in ai} == {(ai[0].branch_id, ai[0].depth)}
|
||||
assert [a.text for a in ai] == ["Attempt one.", "Attempt two."]
|
||||
# Exactly one of them is the story, and it is the newer take.
|
||||
assert [a.live for a in ai] == [False, True]
|
||||
# The discarded attempt is untouched, not a copy of anything.
|
||||
assert ai[0].state_after is not None
|
||||
|
||||
|
||||
def test_the_story_shows_and_counts_the_turn_once(client):
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
before = _page(client)["action_count"]
|
||||
_retry(client)
|
||||
after = _page(client)
|
||||
|
||||
assert after["action_count"] == before, "a discarded attempt is not a turn"
|
||||
assert [a["type"] for a in after["actions"]] == ["start", "do", "ai"]
|
||||
assert after["actions"][-1]["text"] == "Attempt two."
|
||||
|
||||
|
||||
def test_a_discarded_attempt_never_reaches_the_prompt(client):
|
||||
"""This is the failure case the branch clause exists to prevent, at
|
||||
sibling scale. The losing attempt sits at the same branch and depth as
|
||||
the live one, so anything reading the story by coordinate alone would
|
||||
replay both."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
|
||||
story = ScriptedProvider.prompts[-1][1]
|
||||
assert "Attempt two." in story
|
||||
assert "Attempt one." not in story
|
||||
|
||||
|
||||
def test_switching_moves_the_story_onto_the_other_row(client):
|
||||
ScriptedProvider.replies = [
|
||||
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||
"A beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
newest_id = _page(client)["actions"][-1]["id"]
|
||||
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/actions/{newest_id}/variant", json={"index": 0})
|
||||
assert r.status_code == 200, r.text
|
||||
# A different row answers the request. That is the only change.
|
||||
assert r.json()["id"] != newest_id
|
||||
assert r.json()["text"].startswith("A scratch")
|
||||
|
||||
rows = _rows(client.adv_id)
|
||||
ai = [a for a in rows if a.type == "ai"]
|
||||
assert [a.live for a in ai] == [True, False]
|
||||
# Both takes remain unchanged in the database.
|
||||
assert [a.text.split(".")[0] for a in ai] == ["A scratch", "A beating"]
|
||||
|
||||
|
||||
def test_the_assembled_prompt_is_stored_once_per_turn(client):
|
||||
"""A snapshot holds about 160 kB of prompt that every attempt at a turn
|
||||
shares. Giving each sibling its own copy would make retry multiply the
|
||||
size of the largest column in the database. Instead, the prompt moves
|
||||
with the live flag."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
|
||||
def holders():
|
||||
return [
|
||||
a.id for a in _rows(client.adv_id)
|
||||
if a.type == "ai" and "sections" in (a.context_snapshot or {})
|
||||
]
|
||||
|
||||
live_holder = holders()
|
||||
assert len(live_holder) == 1
|
||||
newest = _page(client)["actions"][-1]
|
||||
assert live_holder == [newest["id"]]
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{newest['id']}/variant",
|
||||
json={"index": 0})
|
||||
moved = holders()
|
||||
assert len(moved) == 1 and moved != live_holder, "the prompt follows the story"
|
||||
|
||||
|
||||
# ------------------------------------------------------- removing the turn
|
||||
|
||||
def test_undo_takes_every_attempt_with_it(client):
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_retry(client)
|
||||
assert len([a for a in _rows(client.adv_id) if a.type == "ai"]) == 3
|
||||
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
assert [a.type for a in _rows(client.adv_id)] == ["start"]
|
||||
|
||||
|
||||
def test_deleting_a_retried_turn_deletes_its_attempts(client):
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
newest = _page(client)["actions"][-1]
|
||||
|
||||
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{newest['id']}")
|
||||
assert r.status_code == 204, r.text
|
||||
assert [a.type for a in _rows(client.adv_id)] == ["start", "do"]
|
||||
|
||||
|
||||
def test_deleting_a_turn_through_a_discarded_attempt_still_takes_the_turn(client):
|
||||
"""The pager hands out whichever id it last saw, and a switch changes which
|
||||
row that is. Deleting through the losing sibling must not leave the story
|
||||
holding a turn with no attempts."""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
|
||||
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{discarded.id}")
|
||||
assert r.status_code == 204, r.text
|
||||
assert [a.type for a in _rows(client.adv_id)] == ["start", "do"]
|
||||
|
||||
|
||||
# ------------------------------------------- what the holdback used to cover
|
||||
|
||||
def test_retrying_withdraws_the_memory_the_turn_produced(client):
|
||||
"""Why summarization no longer holds the newest action back.
|
||||
|
||||
A memory covering the newest turn used to be unreachable by
|
||||
construction. The summarizer stopped one action short, because a retry
|
||||
rewrote the row under a mark that had already moved past it. Now the
|
||||
mark and the memory both name the node, so replacing what a node says
|
||||
withdraws them. Undo and delete already had this repair; the holdback
|
||||
was the only gap a retry still needed to close.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
newest = history.newest(adventure)
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text="You looked around.",
|
||||
source_start=1, source_end=newest.depth,
|
||||
)
|
||||
tree.attach_memory(memory, newest)
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, newest)
|
||||
cursors.SUMMARY.anchor_at(adventure, newest)
|
||||
db.commit()
|
||||
covered_depth = newest.depth
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_retry(client)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert db.query(models.Memory).count() == 0, "the withdrawn memory is gone"
|
||||
# The depth range it covered is released, so the block is
|
||||
# summarized again from where it began instead of being skipped.
|
||||
assert cursors.MEMORY.depth(db, adventure) == 0
|
||||
assert cursors.SUMMARY.depth(db, adventure) == 0
|
||||
assert covered_depth > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_a_memory_on_an_earlier_turn_survives_a_retry(client):
|
||||
"""Only the coordinate whose text changed is withdrawn."""
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||
_play(client)
|
||||
_play(client, "go deeper")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
earlier = history.tail(adventure, 3)[0]
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text="An earlier block.",
|
||||
source_start=0, source_end=earlier.depth,
|
||||
)
|
||||
tree.attach_memory(memory, earlier)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_retry(client)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert [m.text for m in db.query(models.Memory).all()] == ["An earlier block."]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the group
|
||||
|
||||
def test_the_group_grows_in_the_order_the_attempts_arrive(client):
|
||||
"""The attempts page in the order they were made, and the pager counts them.
|
||||
|
||||
Ordering used to come from `variant_index`, an explicit ordinal that SP8
|
||||
dropped. `id` carries the same order, because a row is inserted when its
|
||||
attempt is made.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three."]
|
||||
_play(client)
|
||||
assert _page(client)["actions"][-1]["take_count"] == 1 # never retried
|
||||
_retry(client)
|
||||
_retry(client)
|
||||
|
||||
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
|
||||
assert [a.text for a in ai] == ["One.", "Two.", "Three."]
|
||||
assert _page(client)["actions"][-1]["take_count"] == 3
|
||||
|
||||
|
||||
def test_attempts_module_agrees_with_the_endpoint(client):
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
newest = _page(client)["actions"][-1]
|
||||
|
||||
listed = client.get(
|
||||
f"/api/adventures/{client.adv_id}/actions/{newest['id']}/variants").json()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
node = db.get(models.Action, newest["id"])
|
||||
group = attempts.group(db, node)
|
||||
assert [a.text for a in group] == [v["text"] for v in listed]
|
||||
assert attempts.live_in(group).id == newest["id"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_export_carries_every_attempt_as_its_own_node(client):
|
||||
"""SP6 changed the answer here, for the same reasons as the rest of that
|
||||
subphase.
|
||||
|
||||
A v1 bundle had one entry per turn and folded the group back into a
|
||||
`variants` array, because the format had nowhere else to put a second
|
||||
take. A v2 bundle has coordinates, so an attempt is a node in the file
|
||||
exactly as it is a node in the database, and `live` says which one is
|
||||
the story.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
|
||||
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
ai = [a for a in bundle["actions"] if a["type"] == "ai"]
|
||||
assert [(a["text"], a["live"]) for a in ai] == [("One.", False), ("Two.", True)]
|
||||
assert len({(a["branch"], a["depth"]) for a in ai}) == 1, "one turn, two takes"
|
||||
assert "variants" not in ai[0], "nothing writes the repeating group any more"
|
||||
|
||||
# Importing the bundle puts the group back exactly as it stood.
|
||||
imported = client.post("/api/adventures/import", json=bundle).json()["id"]
|
||||
rows = _rows(imported)
|
||||
ai_rows = [a for a in rows if a.type == "ai"]
|
||||
assert [(a.text, a.live) for a in ai_rows] == [("One.", False), ("Two.", True)]
|
||||
assert len({(a.branch_id, a.depth) for a in ai_rows}) == 1
|
||||
|
||||
|
||||
def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
|
||||
"""The group stays in the order the attempts were made.
|
||||
|
||||
`add_attempt` used to number a new take one past the take it replaced.
|
||||
That numbering is correct only when the story is standing on the newest
|
||||
take. Switch a three-take turn back to the first and retry, and the new
|
||||
attempt collided with take 2, which put it between takes 2 and 3. The
|
||||
group orders by `id` now, so a new attempt is always last.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two.", "Three.", "Four."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_retry(client)
|
||||
assert [a.text for a in _rows(client.adv_id) if a.type == "ai"] == [
|
||||
"One.", "Two.", "Three."]
|
||||
|
||||
live = _page(client)["actions"][-1]
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
|
||||
json={"index": 0})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
_retry(client)
|
||||
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
|
||||
assert [a.text for a in ai] == ["One.", "Two.", "Three.", "Four."]
|
||||
assert attempts.live_in(ai).text == "Four."
|
||||
|
||||
|
||||
def test_the_adventure_list_quotes_the_take_the_story_tells(client):
|
||||
"""The index screen and the story have to agree.
|
||||
|
||||
Siblings share a depth, and the newest of them has the highest id. A
|
||||
snippet ordered by `(depth, id)` alone quotes whichever attempt was
|
||||
written last. After switching back, that attempt is the one the player
|
||||
discarded.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
live = _page(client)["actions"][-1]
|
||||
assert live["text"] == "Two."
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
|
||||
json={"index": 0})
|
||||
|
||||
listed = client.get("/api/adventures").json()
|
||||
row = [a for a in listed if a["id"] == client.adv_id][0]
|
||||
assert "One." in row["snippet"]
|
||||
assert "Two." not in row["snippet"]
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Phase 14 SP2: a read sees one story, and knows which one.
|
||||
|
||||
These tests build the fork by hand. Three branch rows and their nodes are
|
||||
written straight to the database, arranged as the design doc's own worked
|
||||
example. That was the only way to build one when this file was written,
|
||||
because nothing forked until SP5. It stays that way now that `tree.fork`
|
||||
exists, because a fixture built with the same code under test could not
|
||||
catch that code being wrong. `test_branch_forking.py` checks the two
|
||||
against each other.
|
||||
|
||||
branch C, tip at depth 7, lineage [(C, 7), (B, 5), (A, 3)]
|
||||
-> A0 A1 A2 A3 B4 B5 C6 C7
|
||||
|
||||
The point of building the fixture by hand is that every read in the app
|
||||
must go through one module. A forgotten clause does not raise an error. It
|
||||
silently shows a story assembled out of two different branches. The
|
||||
fixture deliberately leaves nodes where a forgotten clause would pick them
|
||||
up: A kept playing past the fork (A4, A5), B kept playing past its own
|
||||
(B6), and a second adventure holds a whole story of its own. None of these
|
||||
nodes may appear on C.
|
||||
|
||||
python -m pytest tests/test_branch_clause.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import event
|
||||
|
||||
from app import auth, limits, models, tree
|
||||
from app.context import history, lineage
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from tools import dbmeter
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the fixture
|
||||
|
||||
def make_branch(db, adventure, parent=None, fork_depth=None):
|
||||
"""A branch row whose lineage is its parent's, capped, plus itself.
|
||||
|
||||
This function performs the same computation SP5 does at fork time. It
|
||||
is written out here so the fixture cannot pass by repeating a bug in
|
||||
the code under test.
|
||||
"""
|
||||
branch = models.Branch(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=parent.id if parent else None,
|
||||
fork_depth=fork_depth,
|
||||
lineage=[],
|
||||
)
|
||||
db.add(branch)
|
||||
db.flush()
|
||||
inherited = []
|
||||
if parent is not None:
|
||||
for ancestor_id, cap in lineage.entries_of(parent):
|
||||
capped = fork_depth if cap is None else min(cap, fork_depth)
|
||||
inherited.append([ancestor_id, capped])
|
||||
branch.lineage = [[branch.id, None]] + inherited
|
||||
db.flush()
|
||||
return branch
|
||||
|
||||
|
||||
def add_node(db, adventure, branch, depth, label, index=None):
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
branch_id=branch.id,
|
||||
depth=depth,
|
||||
type="start" if depth == 0 else ("ai" if depth % 2 else "do"),
|
||||
text=f"{label}{depth}",
|
||||
)
|
||||
db.add(action)
|
||||
return action
|
||||
|
||||
|
||||
def make_adventure(db, user, title):
|
||||
adventure = models.Adventure(user_id=user.id, title=title, script_state={})
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
return adventure
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def forked():
|
||||
"""The worked example, plus everything a forgotten clause would expose."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="branch@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="m"))
|
||||
|
||||
adventure = make_adventure(db, user, "Forked")
|
||||
a = make_branch(db, adventure)
|
||||
b = make_branch(db, adventure, parent=a, fork_depth=3)
|
||||
c = make_branch(db, adventure, parent=b, fork_depth=5)
|
||||
|
||||
for depth in range(4):
|
||||
add_node(db, adventure, a, depth, "A")
|
||||
# A did not stop when B forked off it: these two are siblings of B4/B5.
|
||||
for depth in (4, 5):
|
||||
add_node(db, adventure, a, depth, "A", index=100 + depth)
|
||||
for depth in (4, 5):
|
||||
add_node(db, adventure, b, depth, "B")
|
||||
add_node(db, adventure, b, 6, "B", index=200 + 6) # B's own continuation
|
||||
for depth in (6, 7):
|
||||
add_node(db, adventure, c, depth, "C")
|
||||
|
||||
# A second adventure, so "does the clause remember its adventure?" has an
|
||||
# answer. A database holding one adventure cannot tell you.
|
||||
other = make_adventure(db, user, "Elsewhere")
|
||||
other_branch = make_branch(db, other)
|
||||
for depth in range(4):
|
||||
add_node(db, other, other_branch, depth, "X")
|
||||
|
||||
adventure.head_branch_id = c.id
|
||||
adventure.head_depth = 7
|
||||
other.head_branch_id = other_branch.id
|
||||
other.head_depth = 3
|
||||
db.commit()
|
||||
|
||||
ids = {"a": a.id, "b": b.id, "c": c.id, "adventure": adventure.id,
|
||||
"other": other.id, "user": user.id}
|
||||
try:
|
||||
yield db, adventure, ids
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def switch_to(db, adventure, branch_id, tip):
|
||||
"""Move the head, the way SP7's branch picker will."""
|
||||
adventure.head_branch_id = branch_id
|
||||
adventure.head_depth = tip
|
||||
db.commit()
|
||||
|
||||
|
||||
def labels(actions):
|
||||
return [a.text for a in actions]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the story read
|
||||
|
||||
def test_the_worked_example_reads_back_as_the_design_doc_says(forked):
|
||||
db, adventure, _ = forked
|
||||
assert labels(history.story_actions(adventure)) == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
|
||||
|
||||
def test_a_siblings_nodes_are_invisible(forked):
|
||||
db, adventure, _ = forked
|
||||
seen = labels(history.story_actions(adventure))
|
||||
# A4 and A5 are A's own continuation past B's fork. B6 is B's own
|
||||
# continuation past C's fork.
|
||||
assert "A4" not in seen and "A5" not in seen and "B6" not in seen
|
||||
# Nothing from the other adventure appears either.
|
||||
assert not [text for text in seen if text.startswith("X")]
|
||||
|
||||
|
||||
def test_each_branch_reads_its_own_story(forked):
|
||||
db, adventure, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
assert labels(history.story_actions(adventure)) == [
|
||||
"A0", "A1", "A2", "A3", "A4", "A5"
|
||||
]
|
||||
switch_to(db, adventure, ids["b"], 6)
|
||||
assert labels(history.story_actions(adventure)) == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "B6"
|
||||
]
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
assert labels(history.story_actions(adventure)) == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
|
||||
|
||||
def test_the_ancestors_shared_nodes_are_shared_not_copied(forked):
|
||||
db, adventure, ids = forked
|
||||
# A0..A3 appear on all three stories and exist exactly once in the table.
|
||||
rows = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adventure.id,
|
||||
models.Action.text == "A2")
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].branch_id == ids["a"]
|
||||
|
||||
|
||||
def test_count_and_tail_and_slice_agree_with_the_path(forked):
|
||||
db, adventure, _ = forked
|
||||
assert history.count(adventure) == 8
|
||||
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
|
||||
assert labels(history.tail_range(adventure, 2, 2)) == ["B4", "B5"]
|
||||
assert labels(history.slice_(adventure, 3, 3)) == ["A3", "B4", "B5"]
|
||||
|
||||
|
||||
def test_a_window_that_reaches_past_the_fork_still_reads_in_order(forked):
|
||||
db, adventure, _ = forked
|
||||
# 32 is history.WINDOW_START: more than the whole story, so the read has to
|
||||
# widen through all three lineage entries and still come back in order.
|
||||
assert labels(history.window_covering(adventure, 10 ** 6, len)) == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------- the loaded-collection short cut
|
||||
|
||||
def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
|
||||
"""Tests `history._from_memory`'s shortcut, the highest-risk line here.
|
||||
|
||||
`adventure.actions` returns every branch's actions. Slicing it without
|
||||
the path would assemble a prompt out of two different stories, and
|
||||
nothing would raise an error. This test loads the collection
|
||||
deliberately and checks that the answer is still the path.
|
||||
"""
|
||||
db, adventure, _ = forked
|
||||
loaded = list(adventure.actions) # every branch, ordered by index
|
||||
assert len(loaded) == 11 # the path's 8, plus A4, A5 and B6
|
||||
assert labels(history.story_actions(adventure)) == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
assert history.count(adventure) == 8
|
||||
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ over the wire
|
||||
|
||||
@pytest.fixture()
|
||||
def client(forked, monkeypatch):
|
||||
db, adventure, ids = forked
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(session=Depends(get_db)):
|
||||
return session.get(models.User, ids["user"])
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c, db, adventure, ids
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_the_page_the_reader_opens_is_the_path(client):
|
||||
c, db, adventure, ids = client
|
||||
r = c.get(f"/api/adventures/{adventure.id}")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert [a["text"] for a in body["actions"]] == [
|
||||
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
|
||||
]
|
||||
# `action_count` tells the reader whether more actions exist above. It
|
||||
# counts the path too: 8, not the 13 rows the adventure holds.
|
||||
assert body["action_count"] == 8
|
||||
|
||||
|
||||
def test_paging_up_walks_the_path_across_the_forks(client):
|
||||
c, db, adventure, ids = client
|
||||
first = c.get(f"/api/adventures/{adventure.id}/actions", params={"limit": 3}).json()
|
||||
assert [a["text"] for a in first["actions"]] == ["B5", "C6", "C7"]
|
||||
assert first["has_more"] is True
|
||||
older = c.get(
|
||||
f"/api/adventures/{adventure.id}/actions",
|
||||
params={"limit": 3, "before_id": first["actions"][0]["id"]},
|
||||
).json()
|
||||
assert [a["text"] for a in older["actions"]] == ["A2", "A3", "B4"]
|
||||
oldest = c.get(
|
||||
f"/api/adventures/{adventure.id}/actions",
|
||||
params={"limit": 3, "before_id": older["actions"][0]["id"]},
|
||||
).json()
|
||||
assert [a["text"] for a in oldest["actions"]] == ["A0", "A1"]
|
||||
assert oldest["has_more"] is False
|
||||
|
||||
|
||||
def test_paging_from_an_anchor_off_the_path_reports_the_end(client):
|
||||
"""A stale client holding an id from another branch gets an empty page,
|
||||
not that branch's story."""
|
||||
c, db, adventure, ids = client
|
||||
off_path = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adventure.id,
|
||||
models.Action.text == "A5")
|
||||
.one()
|
||||
)
|
||||
page = c.get(
|
||||
f"/api/adventures/{adventure.id}/actions",
|
||||
params={"before_id": off_path.id},
|
||||
).json()
|
||||
assert page["actions"] == []
|
||||
assert page["has_more"] is False
|
||||
|
||||
|
||||
def test_the_index_screen_quotes_the_branch_being_played(client):
|
||||
c, db, adventure, ids = client
|
||||
listed = {row["id"]: row for row in c.get("/api/adventures").json()}
|
||||
# The newest *narrated* node on C: do/say are the player's voice, and the
|
||||
# fixture alternates types, so C7 is the one that reads as story.
|
||||
assert listed[adventure.id]["snippet"] == "C7"
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
listed = {row["id"]: row for row in c.get("/api/adventures").json()}
|
||||
assert listed[adventure.id]["snippet"] == "A5"
|
||||
assert listed[ids["other"]]["snippet"] == "X3"
|
||||
|
||||
|
||||
# ------------------------------------------------------- the flush guard
|
||||
|
||||
def test_a_node_written_without_a_branch_is_placed_anyway(forked):
|
||||
"""SP1 wired the writers. Since SP2, an unplaced node is an invisible one.
|
||||
|
||||
This behavior lets a fixture, a script, or a test built straight through
|
||||
the ORM keep working. It is also why the baseline contract still passes
|
||||
with its actions written directly to the database.
|
||||
"""
|
||||
db, adventure, ids = forked
|
||||
written = models.Action(
|
||||
adventure_id=adventure.id, type="do", text="C8"
|
||||
)
|
||||
db.add(written)
|
||||
db.commit()
|
||||
assert written.branch_id == ids["c"]
|
||||
# The node lands one step past the tip of C. SP8 dropped `index`, which is
|
||||
# where a caller used to name its own depth, so an unplaced node now always
|
||||
# follows the head.
|
||||
assert written.depth == adventure.head_depth
|
||||
assert adventure.head_depth == 8
|
||||
assert labels(history.tail(adventure, 2)) == ["C7", "C8"]
|
||||
|
||||
|
||||
def test_a_memory_written_without_a_branch_is_placed_anyway(forked):
|
||||
db, adventure, ids = forked
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text="The cave was cold.", source_start=0, source_end=3
|
||||
)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
assert memory.branch_id == ids["c"]
|
||||
assert memory.depth == 3
|
||||
|
||||
|
||||
def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql):
|
||||
"""The guard resolves the head once per flush, not once per node.
|
||||
|
||||
The identity map holds weak references. A branch row with no strong
|
||||
reference gets collected between two nodes and read back again for the
|
||||
next one. Writing two hundred actions in one flush ran two hundred
|
||||
SELECTs on `branches` before the head lookup moved outside the loop.
|
||||
The test result alone would not have shown this.
|
||||
"""
|
||||
db, adventure, _ = forked
|
||||
emitted_sql.clear()
|
||||
for i in range(50):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id, type="do", text=f"bulk {i}"
|
||||
))
|
||||
db.commit()
|
||||
branch_reads = [s for s in emitted_sql if s.startswith("SELECT") and "FROM branches" in s]
|
||||
assert len(branch_reads) <= 2, (
|
||||
f"{len(branch_reads)} reads of `branches` to place 50 nodes"
|
||||
)
|
||||
|
||||
|
||||
def test_an_adventure_with_no_branch_at_all_reads_as_empty(forked):
|
||||
"""A missing branch must fail loudly: nothing, rather than everything.
|
||||
|
||||
A row with no branch cannot be shown without guessing which story it
|
||||
belongs to, and a wrong guess here puts a sibling's turns into a
|
||||
prompt.
|
||||
"""
|
||||
db, adventure, ids = forked
|
||||
stray = make_adventure(db, db.get(models.User, ids["user"]), "Stray")
|
||||
db.query(models.Action).filter(models.Action.text == "A0").update(
|
||||
{"adventure_id": stray.id}, synchronize_session=False
|
||||
)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
stray = db.get(models.Adventure, stray.id)
|
||||
assert lineage.path_of(db, stray).entries == []
|
||||
assert history.story_actions(stray) == []
|
||||
assert history.count(stray) == 0
|
||||
|
||||
|
||||
# --------------------------------------------------- the cost of forking
|
||||
|
||||
@pytest.fixture()
|
||||
def deeply_forked():
|
||||
"""A story forked twenty times, then played forty turns past the last one.
|
||||
|
||||
This shape tests the design's core assumption: reading the tail of this
|
||||
story must cost the same as reading the tail of an unforked story,
|
||||
because the window is covered long before the ancestry runs out.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="deep@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
adventure = make_adventure(db, user, "Deep")
|
||||
branch = make_branch(db, adventure)
|
||||
depth = 0
|
||||
for _ in range(4):
|
||||
add_node(db, adventure, branch, depth, "n")
|
||||
depth += 1
|
||||
for fork in range(20):
|
||||
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
|
||||
for _ in range(2):
|
||||
add_node(db, adventure, branch, depth, "n")
|
||||
depth += 1
|
||||
tip_branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
|
||||
for _ in range(40):
|
||||
add_node(db, adventure, tip_branch, depth, "n")
|
||||
depth += 1
|
||||
adventure.head_branch_id = tip_branch.id
|
||||
adventure.head_depth = depth - 1
|
||||
db.commit()
|
||||
try:
|
||||
yield db, adventure
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def emitted_sql():
|
||||
"""Every statement the connection runs, so a clause can be counted."""
|
||||
seen = []
|
||||
|
||||
def on_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
seen.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", on_execute)
|
||||
try:
|
||||
yield seen
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", on_execute)
|
||||
|
||||
|
||||
def branch_terms(statement: str) -> int:
|
||||
return statement.count("actions.branch_id =")
|
||||
|
||||
|
||||
def test_a_tail_read_names_one_branch_however_many_forks_there_were(
|
||||
deeply_forked, emitted_sql
|
||||
):
|
||||
db, adventure = deeply_forked
|
||||
assert len(lineage.path_of(db, adventure)) == 22 # the whole ancestry
|
||||
emitted_sql.clear()
|
||||
rows = history.tail(adventure, 32)
|
||||
assert len(rows) == 32
|
||||
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
|
||||
assert selects, "no action read was emitted"
|
||||
# Clause count is bounded by the context window, not by fork count: the
|
||||
# newest branch alone holds forty turns, so one entry covers a window of
|
||||
# thirty-two and the other twenty-one are never named.
|
||||
assert max(branch_terms(s) for s in selects) == 1
|
||||
|
||||
|
||||
def test_a_window_reaching_past_the_forks_names_only_what_it_needs(
|
||||
deeply_forked, emitted_sql
|
||||
):
|
||||
db, adventure = deeply_forked
|
||||
emitted_sql.clear()
|
||||
rows = history.tail(adventure, 41) # 40 on the tip branch, one older
|
||||
assert len(rows) == 41
|
||||
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
|
||||
# Two lineage entries reach 41 deep (40 + 2). The other twenty stay
|
||||
# unnamed. Every fork past the window costs the query nothing.
|
||||
assert max(branch_terms(s) for s in selects) == 2
|
||||
|
||||
|
||||
def test_the_estimate_is_arithmetic_not_a_query(deeply_forked):
|
||||
db, adventure = deeply_forked
|
||||
path = lineage.path_of(db, adventure)
|
||||
assert path.prefix_covering(1) == 1
|
||||
assert path.prefix_covering(40) == 1
|
||||
assert path.prefix_covering(41) == 2
|
||||
assert path.prefix_covering(43) == 3
|
||||
assert path.prefix_covering(10 ** 6) == len(path)
|
||||
|
||||
|
||||
def test_forking_twenty_times_costs_the_same_bytes_as_never_forking(
|
||||
deeply_forked,
|
||||
):
|
||||
"""The design's cost assumption, measured in bytes.
|
||||
|
||||
Two stories of the same length, one played straight through and one
|
||||
forked twenty times, cost the same to read their newest window. The
|
||||
window is covered by the newest lineage entry either way, and the
|
||||
ancestry is never named. The forked read pays for one extra row: the
|
||||
branch it reads the lineage from.
|
||||
"""
|
||||
db, forked_adventure = deeply_forked
|
||||
flat = make_adventure(db, db.get(models.User, forked_adventure.user_id), "Flat")
|
||||
branch = make_branch(db, flat)
|
||||
for depth in range(84): # the same 84 nodes the forked story is long
|
||||
add_node(db, flat, branch, depth, "n")
|
||||
flat.head_branch_id = branch.id
|
||||
flat.head_depth = 83
|
||||
flat_id, forked_id = flat.id, forked_adventure.id
|
||||
# Commit and release the connection. The meter wraps the connection
|
||||
# pool's factory, so a connection checked out before the meter attaches
|
||||
# is never visible to it. Building the fixture is a write path the test
|
||||
# does not measure, and it is not charged to either scope.
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
meter = dbmeter.Meter()
|
||||
meter.attach(engine)
|
||||
try:
|
||||
with meter.scope("flat"):
|
||||
assert len(history.tail(db.get(models.Adventure, flat_id), 32)) == 32
|
||||
flat_bytes = meter.scopes[-1].total.fetched
|
||||
with meter.scope("forked"):
|
||||
assert len(history.tail(db.get(models.Adventure, forked_id), 32)) == 32
|
||||
forked_bytes = meter.scopes[-1].total.fetched
|
||||
finally:
|
||||
meter.detach()
|
||||
|
||||
assert flat_bytes > 0, "the meter saw nothing; it is measuring the wrong connection"
|
||||
|
||||
assert forked_bytes < flat_bytes * 1.25, (
|
||||
f"reading a 20-fork story cost {forked_bytes:,} B against the "
|
||||
f"{flat_bytes:,} B an unforked one of the same length cost"
|
||||
)
|
||||
|
||||
|
||||
def test_a_gap_in_the_story_widens_the_read_rather_than_shortening_it(
|
||||
deeply_forked, emitted_sql
|
||||
):
|
||||
"""The estimate counts depths, and a deleted action leaves a depth with
|
||||
no row behind it. The read must notice it came up short and widen."""
|
||||
db, adventure = deeply_forked
|
||||
victim = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.branch_id == adventure.head_branch_id)
|
||||
.order_by(models.Action.depth)
|
||||
.first()
|
||||
)
|
||||
db.delete(victim)
|
||||
db.commit()
|
||||
emitted_sql.clear()
|
||||
rows = history.tail(adventure, 40)
|
||||
assert len(rows) == 40 # 39 on the tip branch, one borrowed from above
|
||||
selects = [s for s in emitted_sql if "FROM actions" in s and branch_terms(s)]
|
||||
assert max(branch_terms(s) for s in selects) == len(lineage.path_of(db, adventure))
|
||||
@@ -0,0 +1,555 @@
|
||||
"""Phase 14 SP5: continuing from a discarded attempt forks a branch.
|
||||
|
||||
SP4 made every attempt at a turn a node. While the attempts sit at the
|
||||
tip, they are leaves and cost nothing: switching between them just moves
|
||||
the `live` flag. The moment the player continues from an attempt the line
|
||||
has already moved past, the two futures must coexist. That is a branch.
|
||||
|
||||
This file tests the claim the whole design rests on: a fork inserts one
|
||||
row and moves one row, no matter how large the story behind it is.
|
||||
Everything before the fork is borrowed, not copied, and the arithmetic
|
||||
that makes borrowing possible lives in `lineage`.
|
||||
|
||||
python -m pytest tests/test_branch_forking.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app.context import cursors, lineage
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
|
||||
# advance shows up as a change the referee should have rejected.
|
||||
SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
||||
# The per-turn counter these tests measure rollbacks with. Unbounded and
|
||||
# uncapped on purpose, so every turn's +10 lands in full. See
|
||||
# `fakes.gold_reply`.
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="fork@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = ["A reply."]
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _play(client, text="look around", type="do"):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": type, "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _retry(client):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _texts(client) -> list[str]:
|
||||
return [a["text"] for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]]
|
||||
|
||||
|
||||
def _branches(client) -> list[dict]:
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/branches")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _fork(client, action_id):
|
||||
return client.post(f"/api/adventures/{client.adv_id}/actions/{action_id}/fork")
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _rows(adv_id) -> list[models.Action]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv_id)
|
||||
.order_by(models.Action.depth, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _divergent_story(client):
|
||||
"""A story that retried turn 2, continued from the newer take, and left
|
||||
the older one behind as a leaf.
|
||||
|
||||
start > do > [attempt one | ATTEMPT TWO] > do > next turn
|
||||
|
||||
Returns the id of the discarded attempt.
|
||||
"""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
return [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the fork
|
||||
|
||||
def test_a_fork_inserts_one_branch_row_and_copies_no_actions(client):
|
||||
discarded = _divergent_story(client)
|
||||
rows_before = [a.id for a in _rows(client.adv_id)]
|
||||
assert len(_branches(client)) == 1
|
||||
|
||||
r = _fork(client, discarded)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
branches = _branches(client)
|
||||
assert len(branches) == 2, "exactly one branch row per divergence built on"
|
||||
assert [a.id for a in _rows(client.adv_id)] == rows_before, "a fork copies nothing"
|
||||
forked = [b for b in branches if b["parent_branch_id"] is not None][0]
|
||||
assert forked["is_head"] is True
|
||||
assert forked["own_actions"] == 1, "the promoted attempt, and nothing else"
|
||||
# The fork point is the depth just before the attempt. The code stores
|
||||
# this value instead of inferring it from where two branches first
|
||||
# differ, because that inference would guess wrong as soon as an
|
||||
# attempt repeats its parent's text.
|
||||
assert forked["fork_depth"] == forked["depth"] - 1
|
||||
|
||||
|
||||
def test_the_lineage_is_capped_at_the_fork_depth(client):
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
branch = lineage.branch_of(db, adventure)
|
||||
entries = lineage.entries_of(branch)
|
||||
assert entries[0] == (branch.id, None), "its own nodes, to the tip"
|
||||
assert entries[1] == (branch.parent_branch_id, branch.fork_depth)
|
||||
assert len(entries) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_both_branches_read_independently(client):
|
||||
discarded = _divergent_story(client)
|
||||
parent = _branches(client)[0]["id"]
|
||||
_fork(client, discarded)
|
||||
|
||||
# The fork's story: everything up to the divergence, then the other take.
|
||||
assert _texts(client) == [
|
||||
"You enter a cave.", "> You look around.", "Attempt one.",
|
||||
]
|
||||
# The branch it left behind is unchanged, including turns after the fork.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _texts(client) == [
|
||||
"You enter a cave.", "> You look around.", "Attempt two.",
|
||||
"> You go deeper.", "Next turn.",
|
||||
]
|
||||
|
||||
|
||||
def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client):
|
||||
"""Promoting the other attempt must not leave the parent with a gap in
|
||||
its story. A coordinate with no live node is a turn that disappears
|
||||
from the read."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
parent_id = db.query(models.Branch).filter_by(
|
||||
adventure_id=adventure.id, parent_branch_id=None).one().id
|
||||
per_coordinate = {}
|
||||
for row in _rows(client.adv_id):
|
||||
per_coordinate.setdefault((row.branch_id, row.depth), []).append(row)
|
||||
for (branch_id, depth), group in per_coordinate.items():
|
||||
live = [a for a in group if a.live]
|
||||
assert len(live) == 1, f"branch {branch_id} depth {depth}"
|
||||
# The parent's turn 2 is now a single take, so the pager no longer
|
||||
# offers a page through attempts that diverged onto another branch.
|
||||
parent_turn = per_coordinate[(parent_id, 2)]
|
||||
assert len(parent_turn) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_playing_on_a_fork_continues_that_branchs_depths(client):
|
||||
"""A depth is a position along this story. Numbering the next node from
|
||||
the adventure-wide index would leave a gap where the other branch's
|
||||
turns are, and every windowing estimate would then have to work around
|
||||
that gap."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
ScriptedProvider.replies = ["Onward."]
|
||||
_play(client, "turn back")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
path = lineage.path_of(db, adventure)
|
||||
rows = [a for a in _rows(client.adv_id) if path.contains(a)]
|
||||
assert [a.depth for a in sorted(rows, key=lambda a: a.depth)] == [0, 1, 2, 3, 4]
|
||||
finally:
|
||||
db.close()
|
||||
assert _texts(client)[-1] == "Onward."
|
||||
|
||||
|
||||
# ------------------------------------------------------------- not a fork
|
||||
|
||||
def test_forking_at_the_tip_switches_without_making_a_branch(client):
|
||||
"""Attempts nobody has built on stay leaves. This is what keeps the
|
||||
lineage a list of divergences instead of a list of every retry."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
|
||||
r = _fork(client, discarded)
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_branches(client)) == 1, "no branch for an attempt at the tip"
|
||||
assert _texts(client)[-1] == "Attempt one."
|
||||
|
||||
|
||||
def test_forking_the_attempt_already_in_the_story_does_nothing(client):
|
||||
"""This call is idempotent. A client that has lost track of which take
|
||||
is live must not create a new branch on every click."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
promoted = [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live
|
||||
and a.branch_id != _branches(client)[0]["id"]][0]
|
||||
before = _texts(client)
|
||||
|
||||
for _ in range(3):
|
||||
r = _fork(client, promoted)
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_branches(client)) == 2
|
||||
assert _texts(client) == before
|
||||
|
||||
|
||||
def test_forking_a_turn_that_is_already_the_story_is_a_no_op(client):
|
||||
ScriptedProvider.replies = ["Only take."]
|
||||
_play(client)
|
||||
only = [a.id for a in _rows(client.adv_id) if a.type == "ai"][0]
|
||||
|
||||
r = _fork(client, only)
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_branches(client)) == 1
|
||||
|
||||
|
||||
def test_forking_a_live_node_on_another_branch_is_refused(client):
|
||||
"""A live node off the path belongs to another branch's story, not to a
|
||||
spare attempt on this one. The refusal names the tool that actually
|
||||
switches branches. It used to answer "only one take", which was true
|
||||
of the attempt group but useless here: the caller does not want
|
||||
another take, it wants the branch this node is on."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
parent_id = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
||||
stranded = [a.id for a in _rows(client.adv_id)
|
||||
if a.branch_id == parent_id and a.depth == 2][0]
|
||||
|
||||
r = _fork(client, stranded)
|
||||
assert r.status_code == 400
|
||||
assert "another branch" in r.json()["detail"]
|
||||
# Refusing must leave the tree alone. The bug this guards against is a
|
||||
# fork that promotes a sibling on the branch it was called against.
|
||||
assert len(_branches(client)) == 2
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the state
|
||||
|
||||
def test_switching_restores_the_state_a_branch_left_behind(client):
|
||||
# Two stats move: hp differs per attempt, and gold counts turns. Between
|
||||
# them, a switch that restored the wrong snapshot is visible either way.
|
||||
ScriptedProvider.replies = [
|
||||
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
|
||||
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
|
||||
gold_reply("Onward."),
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
parent = _branches(client)[0]["id"]
|
||||
on_parent = _state(client.adv_id)
|
||||
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
_fork(client, discarded)
|
||||
player, world_state = _state(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
|
||||
assert player["gold"] == 10, "one turn of gold, not three"
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert _state(client.adv_id) == on_parent
|
||||
|
||||
|
||||
def test_the_cooldown_clock_travels_with_the_branch(client):
|
||||
"""The world-state clock is a depth, and depths repeat across branches,
|
||||
so it can only be correct if each branch carries its own. It does,
|
||||
without extra work: the clock lives inside `_meta.last_changed`, which
|
||||
is part of the world state a switch restores."""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Untouched.",
|
||||
"Onward.",
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
on_parent = _state(client.adv_id)[1]
|
||||
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
|
||||
|
||||
_fork(client, discarded)
|
||||
forked = _state(client.adv_id)[1]
|
||||
assert forked["player"]["mana"] == 40
|
||||
assert forked["_meta"]["last_changed"]["player.mana"] == 2
|
||||
|
||||
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
||||
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert _state(client.adv_id)[1] == on_parent
|
||||
|
||||
|
||||
def test_a_retry_does_not_advance_the_cooldown_clock(client):
|
||||
"""SP5's one carried-over open item. A retry re-runs the same turn, so
|
||||
the clock the cooldown rules read must not move. The reused `index`
|
||||
used to guarantee this; the reused depth guarantees it now."""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
|
||||
]
|
||||
_play(client)
|
||||
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
|
||||
_retry(client)
|
||||
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
|
||||
# The second attempt's drain must land, instead of being rejected for a
|
||||
# cooldown it was never actually subject to.
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
||||
|
||||
|
||||
# --------------------------------------------------------- derived work
|
||||
|
||||
def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
"""Nothing is moved or removed when a branch forks. The memory attaches
|
||||
to the coordinate the parent's attempt still occupies, and the lineage
|
||||
caps the parent one depth short of it. The fork therefore cannot see
|
||||
this memory, and resummarizes that span from the text it actually
|
||||
contains."""
|
||||
from app import tree
|
||||
|
||||
discarded = _divergent_story(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
winner = db.query(models.Action).filter_by(
|
||||
adventure_id=adventure.id, type="ai", live=True).order_by(
|
||||
models.Action.depth).first()
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text="Attempt two happened.",
|
||||
source_start=0, source_end=winner.depth,
|
||||
)
|
||||
tree.attach_memory(memory, winner)
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, winner)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_fork(client, discarded)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
# The memory is still there, untouched, because it describes the
|
||||
# parent's story, which is unchanged.
|
||||
assert [m.text for m in db.query(models.Memory).all()] == ["Attempt two happened."]
|
||||
path = lineage.path_of(db, adventure)
|
||||
visible = db.query(models.Memory).filter(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
path.clause(models.Memory),
|
||||
).all()
|
||||
assert visible == [], "a sibling's memory reached this branch"
|
||||
# The cursor reads one depth short of the memory, so this branch
|
||||
# treats the block as due again instead of silently marking it read.
|
||||
assert cursors.MEMORY.depth(db, adventure) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- undo
|
||||
|
||||
def test_undo_stops_at_the_fork(client):
|
||||
"""Undoing a turn on a fork must never reach into the branch it forked
|
||||
from. Those turns belong to that branch's story too."""
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
rows_before = len(_rows(client.adv_id))
|
||||
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
# The promoted attempt is removed, and the player action before it
|
||||
# stays, because that action belongs to the parent and the parent
|
||||
# still has it.
|
||||
assert len(_rows(client.adv_id)) == rows_before - 1
|
||||
assert _texts(client) == ["You enter a cave.", "> You look around."]
|
||||
|
||||
# Nothing is left of this branch's own turns, so undo must refuse
|
||||
# instead of removing the parent's turns.
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 400
|
||||
assert "forked from" in r.json()["detail"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------- the tree view
|
||||
|
||||
def test_the_branch_list_is_the_tree(client):
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
ScriptedProvider.replies = ["Onward."]
|
||||
_play(client, "turn back")
|
||||
|
||||
branches = _branches(client)
|
||||
root = [b for b in branches if b["parent_branch_id"] is None][0]
|
||||
forked = [b for b in branches if b["parent_branch_id"] == root["id"]][0]
|
||||
assert root["fork_depth"] is None and root["depth"] == 4
|
||||
assert forked["fork_depth"] == 1 and forked["depth"] == 4
|
||||
assert root["own_actions"] == 5 and forked["own_actions"] == 3
|
||||
assert forked["is_head"] is True and root["is_head"] is False
|
||||
|
||||
|
||||
def test_fork_agrees_with_the_lineage_computed_by_hand(client):
|
||||
"""`test_branch_clause.make_branch` has computed a fork's lineage by
|
||||
hand since SP2, precisely so the fixture could not pass by repeating a
|
||||
bug in the code under test. SP5 introduces that code, so this test
|
||||
checks the two against each other instead of letting them drift
|
||||
apart."""
|
||||
from tests.test_branch_clause import make_branch
|
||||
|
||||
discarded = _divergent_story(client)
|
||||
_fork(client, discarded)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
real = lineage.branch_of(db, adventure)
|
||||
parent = db.get(models.Branch, real.parent_branch_id)
|
||||
by_hand = make_branch(db, adventure, parent=parent, fork_depth=real.fork_depth)
|
||||
# The two branches share a shape but not an id, so compare only the
|
||||
# ancestry, which is the arithmetic part rather than the allocated id.
|
||||
assert lineage.entries_of(real)[1:] == lineage.entries_of(by_hand)[1:]
|
||||
assert lineage.entries_of(real)[0] == (real.id, None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_a_deep_fork_chain_reads_for_what_one_branch_costs(client):
|
||||
"""Clause count is bounded by the window, not by fork count. This is
|
||||
the property the whole lineage cache exists for, now measured through
|
||||
real forks instead of hand-built rows."""
|
||||
from tools import dbmeter
|
||||
|
||||
ScriptedProvider.replies = ["First take.", "Second take.", "Onward."]
|
||||
_play(client)
|
||||
for _ in range(8):
|
||||
_retry(client)
|
||||
_play(client, "onward")
|
||||
discarded = [
|
||||
a.id for a in _rows(client.adv_id)
|
||||
if a.type == "ai" and not a.live
|
||||
]
|
||||
if discarded:
|
||||
assert _fork(client, discarded[-1]).status_code == 200
|
||||
_play(client, "onward")
|
||||
branches = _branches(client)
|
||||
assert len(branches) > 4, "the fixture did not actually fork"
|
||||
|
||||
meter = dbmeter.Meter()
|
||||
meter.attach(engine)
|
||||
try:
|
||||
with meter.scope("page"):
|
||||
client.get(f"/api/adventures/{client.adv_id}")
|
||||
page_bytes = meter.scopes[-1].total.fetched
|
||||
finally:
|
||||
meter.detach()
|
||||
assert page_bytes > 0, "the meter saw nothing"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
entries = lineage.entries_of(lineage.branch_of(db, adventure))
|
||||
# The whole ancestry is available to be named.
|
||||
assert len(entries) == len(branches)
|
||||
# The windowed read names as few of them as the window needs.
|
||||
path = lineage.path_of(db, adventure)
|
||||
assert path.prefix_covering(60) <= len(entries)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_switching_to_a_branch_of_another_adventure_is_a_404(client):
|
||||
"""A branch id names one adventure, so the two ids in the URL must
|
||||
agree. Otherwise a guessed number reads somebody else's story."""
|
||||
other = client.post("/api/adventures", json={"title": "Elsewhere"}).json()["id"]
|
||||
ScriptedProvider.replies = ["Elsewhere."]
|
||||
r = client.post(f"/api/adventures/{other}/actions", json={"type": "do", "text": "wait"})
|
||||
assert r.status_code == 200, r.text
|
||||
stranger = client.get(f"/api/adventures/{other}/branches").json()
|
||||
assert len(stranger) == 1
|
||||
|
||||
assert client.post(
|
||||
f"/api/adventures/{client.adv_id}/branches/{stranger[0]['id']}/switch"
|
||||
).status_code == 404
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Phase 14 SP7: renaming a branch and deleting one.
|
||||
|
||||
SP5 gave the tree a fork and a switch. Neither operation removes anything,
|
||||
and nothing in the design prunes a tree automatically, so an adventure that
|
||||
is retried and forked enough grows without a limit. Delete is what limits
|
||||
that growth. It ships with the same view that first makes a fork reachable,
|
||||
rather than in a later subphase.
|
||||
|
||||
Two rules carry most of this file:
|
||||
|
||||
* A name is chosen, so the database stores it. A label is derived, so the
|
||||
database does not. An unnamed branch keeps NULL, and the client draws the
|
||||
label from its fork depth. A generated label such as "branch 4" stored in
|
||||
the column would become wrong the moment branch 3 is deleted.
|
||||
* Delete must never remove a branch the reader depends on. Refusing to
|
||||
delete the head is the obvious case. Refusing to delete an ancestor of the
|
||||
head is the same rule applied one level up: deleting it would leave
|
||||
`head_branch_id` pointing at a row the cascade removed.
|
||||
|
||||
python -m pytest tests/test_branch_management.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import auth, limits, models, schemas
|
||||
from app.context import cursors, lineage
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="branches@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", script_state={}, world_state={},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adv.id, type="start", text="You enter a cave."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _play(client, text="look around"):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": "do", "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _retry(client):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _branches(client) -> list[dict]:
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/branches")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _rename(client, branch_id, name):
|
||||
return client.patch(f"/api/adventures/{client.adv_id}/branches/{branch_id}",
|
||||
json={"name": name})
|
||||
|
||||
|
||||
def _delete(client, branch_id):
|
||||
return client.delete(f"/api/adventures/{client.adv_id}/branches/{branch_id}")
|
||||
|
||||
|
||||
def _switch(client, branch_id):
|
||||
return client.post(f"/api/adventures/{client.adv_id}/branches/{branch_id}/switch")
|
||||
|
||||
|
||||
def _texts(client) -> list[str]:
|
||||
return [
|
||||
a["text"]
|
||||
for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
|
||||
]
|
||||
|
||||
|
||||
def _discarded_on(adv_id, branch_id=None) -> int:
|
||||
"""An AI attempt with no turn built on it, optionally restricted to one branch."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(models.Action).filter(
|
||||
models.Action.adventure_id == adv_id,
|
||||
models.Action.type == "ai",
|
||||
models.Action.live.is_(False),
|
||||
)
|
||||
if branch_id is not None:
|
||||
q = q.filter(models.Action.branch_id == branch_id)
|
||||
return q.order_by(models.Action.id).first().id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _forked(client):
|
||||
"""A story with one fork. Returns (root id, forked id). The fork is head.
|
||||
|
||||
start · do · [attempt one | ATTEMPT TWO] · do · next turn
|
||||
└── forked here
|
||||
"""
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
root = _branches(client)[0]["id"]
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/actions/{_discarded_on(client.adv_id)}/fork")
|
||||
assert r.status_code == 200, r.text
|
||||
forked = [b for b in _branches(client) if b["id"] != root][0]["id"]
|
||||
return root, forked
|
||||
|
||||
|
||||
def _counts(adv_id, branch_ids):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.branch_id.in_(branch_ids)).count(),
|
||||
db.query(models.Memory)
|
||||
.filter(models.Memory.branch_id.in_(branch_ids)).count(),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ naming
|
||||
|
||||
def test_a_branch_starts_unnamed(client):
|
||||
"""NULL, not a generated label. The client draws a label from the fork depth.
|
||||
|
||||
A name stored here would go stale the moment a branch before it is
|
||||
deleted and the ordinals shift.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
assert [b["name"] for b in _branches(client)] == [None, None]
|
||||
|
||||
|
||||
def test_a_name_is_stored_and_read_back(client):
|
||||
root, forked = _forked(client)
|
||||
r = _rename(client, forked, "the cellar")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["name"] == "the cellar"
|
||||
assert {b["id"]: b["name"] for b in _branches(client)} == {
|
||||
root: None, forked: "the cellar",
|
||||
}
|
||||
|
||||
|
||||
def test_a_blank_name_goes_back_to_unnamed(client):
|
||||
"""A name of spaces is not a name anyone chose.
|
||||
|
||||
Storing it would give the client an empty label instead of falling back
|
||||
to the fork depth. The branch would look nameless and appear broken.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
_rename(client, forked, "briefly named")
|
||||
assert _rename(client, forked, " ").json()["name"] is None
|
||||
assert _rename(client, forked, None).json()["name"] is None
|
||||
|
||||
|
||||
def test_a_name_longer_than_the_column_is_refused(client):
|
||||
"""422 here rather than a 500 at INSERT: Postgres enforces VARCHAR(80)."""
|
||||
root, forked = _forked(client)
|
||||
assert _rename(client, forked, "x" * (schemas.BRANCH_NAME_MAX + 1)).status_code == 422
|
||||
assert _rename(client, forked, "x" * schemas.BRANCH_NAME_MAX).status_code == 200
|
||||
|
||||
|
||||
def test_a_rename_hands_back_the_row_the_listing_would_give(client):
|
||||
"""Renaming a branch does not change how many turns are on it.
|
||||
|
||||
`own_actions` was hard-coded to 0 in this response. That bug stayed
|
||||
invisible because the panel discards the response body and refetches.
|
||||
Anything that trusted the reply would show a branch that had just lost
|
||||
its turns.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
listed = {b["id"]: b for b in _branches(client)}
|
||||
|
||||
renamed = _rename(client, forked, "the cellar").json()
|
||||
|
||||
assert renamed["own_actions"] == listed[forked]["own_actions"]
|
||||
assert renamed["own_actions"] > 0, "the fixture put turns on this branch"
|
||||
assert renamed["depth"] == listed[forked]["depth"]
|
||||
assert renamed["is_head"] == listed[forked]["is_head"]
|
||||
|
||||
|
||||
def test_naming_a_branch_of_another_adventure_is_a_404(client):
|
||||
root, forked = _forked(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
other = models.Adventure(
|
||||
user_id=db.get(models.Adventure, client.adv_id).user_id,
|
||||
title="Elsewhere", script_state={}, world_state={},
|
||||
)
|
||||
db.add(other)
|
||||
db.commit()
|
||||
other_id = other.id
|
||||
finally:
|
||||
db.close()
|
||||
r = client.patch(f"/api/adventures/{other_id}/branches/{forked}", json={"name": "x"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- deleting
|
||||
|
||||
def test_the_root_branch_cannot_be_deleted(client):
|
||||
"""It holds the turns every other branch borrows."""
|
||||
root, forked = _forked(client)
|
||||
r = _delete(client, root)
|
||||
assert r.status_code == 400
|
||||
assert "adventure" in r.json()["detail"].lower()
|
||||
assert len(_branches(client)) == 2
|
||||
|
||||
|
||||
def test_the_branch_being_read_cannot_be_deleted(client):
|
||||
root, forked = _forked(client)
|
||||
assert [b["is_head"] for b in _branches(client) if b["id"] == forked] == [True]
|
||||
r = _delete(client, forked)
|
||||
assert r.status_code == 400
|
||||
assert "switch" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_an_ancestor_of_the_branch_being_read_cannot_be_deleted(client):
|
||||
"""Deleting an ancestor of the head is the same mistake as deleting the head.
|
||||
|
||||
`parent_branch_id` cascades, so deleting a branch the head was forked
|
||||
from would delete the head too, and leave `head_branch_id` pointing at
|
||||
nothing.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
# A fork of the fork, so `forked` is an ancestor of the head rather than
|
||||
# the head itself.
|
||||
_retry(client)
|
||||
_play(client, "press on")
|
||||
nested = _discarded_on(client.adv_id, branch_id=forked)
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions/{nested}/fork")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(_branches(client)) == 3
|
||||
|
||||
r = _delete(client, forked)
|
||||
assert r.status_code == 400
|
||||
assert "forked from it" in r.json()["detail"]
|
||||
assert len(_branches(client)) == 3
|
||||
|
||||
|
||||
def test_deleting_a_branch_leaves_the_line_it_forked_from_untouched(client):
|
||||
root, forked = _forked(client)
|
||||
_switch(client, root)
|
||||
kept = _texts(client)
|
||||
|
||||
assert _delete(client, forked).status_code == 204
|
||||
assert [b["id"] for b in _branches(client)] == [root]
|
||||
assert _texts(client) == kept, "the parent keeps every turn it had"
|
||||
|
||||
|
||||
def test_deleting_a_branch_takes_its_nodes_and_its_descendants(client):
|
||||
"""One statement deletes the whole subtree, regardless of depth. The
|
||||
cascade performs the traversal."""
|
||||
root, forked = _forked(client)
|
||||
_retry(client)
|
||||
_play(client, "press on")
|
||||
nested_attempt = _discarded_on(client.adv_id, branch_id=forked)
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{nested_attempt}/fork")
|
||||
nested = [b["id"] for b in _branches(client) if b["id"] not in (root, forked)][0]
|
||||
|
||||
doomed_actions, _ = _counts(client.adv_id, [forked, nested])
|
||||
assert doomed_actions > 0
|
||||
root_actions_before, _ = _counts(client.adv_id, [root])
|
||||
|
||||
_switch(client, root)
|
||||
assert _delete(client, forked).status_code == 204
|
||||
|
||||
assert [b["id"] for b in _branches(client)] == [root]
|
||||
assert _counts(client.adv_id, [forked, nested]) == (0, 0)
|
||||
assert _counts(client.adv_id, [root])[0] == root_actions_before
|
||||
|
||||
|
||||
def test_deleting_a_branch_clears_a_cursor_that_stood_on_it(client):
|
||||
"""Harmless on Postgres, a real bug on SQLite.
|
||||
|
||||
Postgres never reuses a branch id, so a stale anchor simply never
|
||||
resolves. SQLite assigns the freed id to the next fork. The anchor then
|
||||
resolves onto a branch it never saw, and reports a stretch of story as
|
||||
already summarized. That stretch is then permanently excluded from the
|
||||
memories.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
cursors.MEMORY.anchor(adventure, forked, 3)
|
||||
cursors.SUMMARY.anchor(adventure, root, 1)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_switch(client, root)
|
||||
assert _delete(client, forked).status_code == 204
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert cursors.MEMORY.stored(adventure) == (None, cursors.NO_DEPTH)
|
||||
# The cursor on a branch that still exists is left exactly where it was.
|
||||
assert cursors.SUMMARY.stored(adventure) == (root, 1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_deleting_an_unknown_branch_is_a_404(client):
|
||||
root, forked = _forked(client)
|
||||
assert _delete(client, forked + 9999).status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------- the bank vs a path
|
||||
|
||||
def _memories(client) -> list[dict]:
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/memories")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _add_memory(client, text):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/memories", json={"text": text})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_a_hand_written_memory_is_anchored_where_it_was_written(client):
|
||||
"""It takes the head, so it is a memory *of a story* rather than of an
|
||||
adventure. A NULL depth is a coordinate no fork can cap."""
|
||||
root, forked = _forked(client)
|
||||
memory_id = _add_memory(client, "Took the other door.")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memory = db.get(models.Memory, memory_id)
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert memory.branch_id == forked, "the branch being read"
|
||||
assert memory.depth is not None, "never NULL again"
|
||||
assert memory.depth == adventure.head_depth
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
|
||||
"""The memories a reader can see match the memories the model can retrieve.
|
||||
|
||||
An adventure-wide list would include memories from branches this story
|
||||
never took. Those memories are never retrieved, and a reader could not
|
||||
distinguish them from the ones actually in play.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
on_the_fork = _add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
on_the_root = _add_memory(client, "Went the long way instead.")
|
||||
|
||||
assert {m["id"] for m in _memories(client)} == {on_the_root}, \
|
||||
"the fork's memory is not on this story"
|
||||
|
||||
# Switching to the fork shows its own memory and the root's. A fork
|
||||
# borrows its ancestors up to the point where it diverged from them.
|
||||
# The relationship is asymmetric on purpose: the parent never took the
|
||||
# fork's path.
|
||||
_switch(client, forked)
|
||||
listed = {m["id"] for m in _memories(client)}
|
||||
assert on_the_fork in listed
|
||||
assert on_the_root not in listed, "written after the fork left this branch"
|
||||
|
||||
|
||||
def test_the_drawer_and_retrieval_agree_on_what_is_visible(client):
|
||||
"""One predicate decides visibility, so a memory can never be listed but
|
||||
unretrievable, or the reverse. Two separate definitions of "on this
|
||||
path" would eventually diverge."""
|
||||
root, forked = _forked(client)
|
||||
_add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
_add_memory(client, "Went the long way instead.")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
retrievable = {
|
||||
row[0] for row in db.execute(
|
||||
select(models.Memory.id).where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
)
|
||||
)
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert {m["id"] for m in _memories(client)} == retrievable
|
||||
|
||||
|
||||
def test_deleting_a_branch_deletes_the_memories_written_on_it(client):
|
||||
"""Deleting a branch removes its memories from the database, not just
|
||||
from view: the cascade deletes the row along with the branch. This
|
||||
keeps a hidden memory from becoming unreachable in a different way: a
|
||||
memory you cannot currently see is on a branch you can still switch to,
|
||||
and deleting that branch deletes the memory permanently."""
|
||||
root, forked = _forked(client)
|
||||
doomed = _add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(models.Memory, doomed) is not None, "still on its own branch"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert _delete(client, forked).status_code == 204
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(models.Memory, doomed) is None, "gone with the branch"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- backup
|
||||
|
||||
def test_a_bundle_carries_the_name_a_player_chose(client):
|
||||
"""A name is a decision, so the export includes it. This is the rule the
|
||||
v2 format is built on.
|
||||
|
||||
`lineage` and the head depth stay out of the export because the
|
||||
importer can compute them from what the file already carries. A name
|
||||
is computed from nothing, so the export must carry it.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
_rename(client, root, "the long way")
|
||||
_rename(client, forked, "the cellar")
|
||||
|
||||
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
assert [b.get("name") for b in exported["branches"]] == ["the long way", "the cellar"]
|
||||
|
||||
r = client.post("/api/adventures/import", json=exported)
|
||||
assert r.status_code == 201, r.text
|
||||
restored = client.get(f"/api/adventures/{r.json()['id']}/branches").json()
|
||||
assert [b["name"] for b in restored] == ["the long way", "the cellar"]
|
||||
|
||||
|
||||
def test_an_unnamed_tree_exports_no_name_key(client):
|
||||
"""Unchanged from the file SP6 wrote, for a tree nobody has named."""
|
||||
_forked(client)
|
||||
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
assert all("name" not in b for b in exported["branches"])
|
||||
|
||||
|
||||
def test_a_bundle_naming_a_branch_with_a_number_is_refused(client):
|
||||
"""400 from the planner, not a database error three branches in."""
|
||||
_forked(client)
|
||||
exported = client.get(f"/api/adventures/{client.adv_id}/export").json()
|
||||
exported["branches"][1]["name"] = 7
|
||||
r = client.post("/api/adventures/import", json=exported)
|
||||
assert r.status_code == 400
|
||||
assert "not text" in r.json()["detail"]
|
||||
@@ -0,0 +1,620 @@
|
||||
"""Phase 14 SP6: the export bundle carries the tree.
|
||||
|
||||
A bundle is the only part of this phase a migration can never reach: the
|
||||
file already exists on somebody's disk. So there are two formats, and the
|
||||
two halves of this file check different properties.
|
||||
|
||||
v2 must be lossless for a story that forked. v1 could not be, because it
|
||||
stored one list for two stories, interleaved by `index`, which read back
|
||||
as a mangled story. Losslessness here means the tree: every branch, the
|
||||
fork point it left on its parent, which attempt at each turn is the
|
||||
story, and what each node left behind. That last item is what a branch
|
||||
switch restores, and a tree nobody can switch inside is not the tree
|
||||
that was exported.
|
||||
|
||||
v1 must still import, because a backup that stops importing is not a
|
||||
backup.
|
||||
|
||||
Both formats follow one rule: a bundle carries what was chosen and never
|
||||
what is derived. The lineage, the head depth, the legacy `index`, and the
|
||||
variant ordinals are all rebuilt on the way in, so a hand-edited file
|
||||
cannot disagree with itself. The tests that matter most here hand the
|
||||
importer a file that does disagree with itself.
|
||||
|
||||
python -m pytest tests/test_bundle_v2.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, bundle, limits, models
|
||||
from app.context import lineage
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Ten gold a turn, so the stored gold total tells how many turns the
|
||||
# story behind it played. This makes an after-snapshot visible from outside.
|
||||
|
||||
OPENING = "You enter a cave."
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="bundle@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Cave", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = ["A reply."]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _play(client, adv_id, text="look around", type="do"):
|
||||
r = client.post(f"/api/adventures/{adv_id}/actions", json={"type": type, "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _retry(client, adv_id):
|
||||
assert client.post(f"/api/adventures/{adv_id}/retry").status_code == 200
|
||||
|
||||
|
||||
def _export(client, adv_id) -> dict:
|
||||
r = client.get(f"/api/adventures/{adv_id}/export")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _import(client, payload):
|
||||
return client.post("/api/adventures/import", json=payload)
|
||||
|
||||
|
||||
def _imported(client, payload) -> int:
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _branches(client, adv_id) -> list[dict]:
|
||||
r = client.get(f"/api/adventures/{adv_id}/branches")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _switch(client, adv_id, branch_id):
|
||||
r = client.post(f"/api/adventures/{adv_id}/branches/{branch_id}/switch")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _texts(client, adv_id) -> list[str]:
|
||||
return [a["text"] for a in client.get(f"/api/adventures/{adv_id}").json()["actions"]]
|
||||
|
||||
|
||||
def _every_branch_story(client, adv_id) -> list[list[str]]:
|
||||
"""What each branch tells, in branch order. This is the whole tree as text."""
|
||||
stories = []
|
||||
for branch in _branches(client, adv_id):
|
||||
_switch(client, adv_id, branch["id"])
|
||||
stories.append(_texts(client, adv_id))
|
||||
return stories
|
||||
|
||||
|
||||
def _adventure_count() -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.query(models.Adventure).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _rows(adv_id) -> list[models.Action]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv_id)
|
||||
.order_by(models.Action.branch_id, models.Action.depth,
|
||||
models.Action.id)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _branch_rows(adv_id) -> list[models.Branch]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Branch)
|
||||
.filter(models.Branch.adventure_id == adv_id)
|
||||
.order_by(models.Branch.id)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _script_state(adv_id) -> dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _forked_story(client) -> int:
|
||||
"""A story that went two ways and stayed both.
|
||||
|
||||
root: start > do > [attempt two] > do > next turn
|
||||
fork: [ATTEMPT ONE] > do > elsewhere
|
||||
|
||||
The discarded attempt is the one this function promotes, because a
|
||||
fork moves the attempt being left for and leaves the line it came
|
||||
from untouched.
|
||||
|
||||
Returns the adventure id, with the head on the fork.
|
||||
"""
|
||||
adv_id = client.adv_id
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn.", "Elsewhere."]
|
||||
_play(client, adv_id)
|
||||
_retry(client, adv_id)
|
||||
_play(client, adv_id, "go deeper")
|
||||
discarded = [a.id for a in _rows(adv_id) if a.type == "ai" and not a.live][0]
|
||||
assert client.post(f"/api/adventures/{adv_id}/actions/{discarded}/fork").status_code == 200
|
||||
_play(client, adv_id, "go sideways")
|
||||
return adv_id
|
||||
|
||||
|
||||
# ------------------------------------------------------- the round trip (v2)
|
||||
|
||||
def test_a_forked_story_survives_the_round_trip(client):
|
||||
"""The main claim: both futures come back, and both are still readable.
|
||||
|
||||
This is the thing v1 could not do. The check is not "the same rows",
|
||||
since the ids are new, but "the same stories", read the way a player
|
||||
reads them: by switching to a branch and looking at what it says.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
before = _every_branch_story(client, original)
|
||||
assert len(before) == 2, "the fixture forked"
|
||||
assert before[0] != before[1], "and the two branches tell different stories"
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
assert copy != original
|
||||
assert _every_branch_story(client, copy) == before
|
||||
|
||||
|
||||
def test_the_fork_point_comes_back_where_it_was_put(client):
|
||||
"""`fork_depth` is stored, never inferred, including through a file.
|
||||
|
||||
Inferring it from where two branches' nodes first differ would guess
|
||||
at how the story was played, and that guess fails as soon as an
|
||||
attempt happens to repeat its parent's text.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
before = [(b["parent_branch_id"] is None, b["fork_depth"]) for b in _branches(client, original)]
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
assert [(b["parent_branch_id"] is None, b["fork_depth"])
|
||||
for b in _branches(client, copy)] == before
|
||||
|
||||
|
||||
def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
|
||||
original = _forked_story(client)
|
||||
head_before = [b["is_head"] for b in _branches(client, original)]
|
||||
assert head_before == [False, True], "the fixture left the head on the fork"
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
assert [b["is_head"] for b in _branches(client, copy)] == head_before
|
||||
# The tip it sits at is derived from the nodes that arrived, not
|
||||
# read from the file. The bundle never states how deep a branch goes.
|
||||
assert _texts(client, copy) == _texts(client, original)
|
||||
|
||||
|
||||
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
|
||||
"""This test justifies why the bundle carries after-snapshots.
|
||||
|
||||
The gold script adds ten a turn, so the stored gold total counts the
|
||||
turns behind it. A bundle that carried the actions but not the
|
||||
outcomes would import a tree that reads correctly but switches to the
|
||||
wrong state.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
# Play one more turn on the fork, so the two tips end up at
|
||||
# genuinely different totals. Turn for turn, both branches earn the
|
||||
# same gold, so a switch that restored nothing would still look right.
|
||||
ScriptedProvider.replies = [gold_reply("Further still.")]
|
||||
_play(client, original, "press on")
|
||||
|
||||
per_branch = []
|
||||
for branch in _branches(client, original):
|
||||
_switch(client, original, branch["id"])
|
||||
per_branch.append(_script_state(original).get("gold"))
|
||||
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
restored = []
|
||||
for branch in _branches(client, copy):
|
||||
_switch(client, copy, branch["id"])
|
||||
restored.append(_script_state(copy).get("gold"))
|
||||
assert restored == per_branch
|
||||
|
||||
|
||||
def test_a_memory_comes_back_on_the_node_it_hangs_off(client):
|
||||
"""Derived work is addressed by coordinate, so the coordinate is carried."""
|
||||
original = _forked_story(client)
|
||||
tip = [a for a in _rows(original) if a.live][-1]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(models.Memory(
|
||||
adventure_id=original, text="They met a goblin.",
|
||||
source_start=0, source_end=tip.depth,
|
||||
branch_id=tip.branch_id, depth=tip.depth,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
exported = _export(client, original)
|
||||
assert [(m["branch"], m["depth"]) for m in exported["memories"]] == [(1, tip.depth)]
|
||||
|
||||
copy = _imported(client, exported)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memories = db.query(models.Memory).filter(
|
||||
models.Memory.adventure_id == copy).all()
|
||||
branches = [b.id for b in _branch_rows(copy)]
|
||||
assert [(branches.index(m.branch_id), m.depth) for m in memories] == [(1, tip.depth)]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# --------------------------------------------------- what is not in the file
|
||||
|
||||
def test_the_lineage_is_rebuilt_rather_than_carried(client):
|
||||
"""A cache of `parent` plus `fork_depth` is not a second thing to ship.
|
||||
|
||||
The file states where each branch forked. The ancestry that makes the
|
||||
fork readable is computed from that value on the way in, capped at
|
||||
the fork exactly as `tree.fork` caps it. Shipping the cache too would
|
||||
put two sources of truth for one fact in a file anybody can hand-edit.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
exported = _export(client, original)
|
||||
assert all("lineage" not in b for b in exported["branches"])
|
||||
|
||||
root, forked = _branch_rows(_imported(client, exported))
|
||||
assert root.lineage == [[root.id, None]]
|
||||
assert forked.lineage == [[forked.id, None], [root.id, forked.fork_depth]]
|
||||
# This is the arithmetic the reader depends on. The parent is capped
|
||||
# one depth short of the attempt that was promoted, so the fork
|
||||
# cannot see it.
|
||||
assert lineage.entries_of(forked) == [(forked.id, None), (root.id, forked.fork_depth)]
|
||||
|
||||
|
||||
def test_two_branches_each_keep_their_own_node_at_one_depth(client):
|
||||
"""A depth describes a path, not the adventure.
|
||||
|
||||
The fork and the branch it left both hold a turn at depth 2, and they are
|
||||
not the same turn. The import used to issue a global `index` per turn as
|
||||
well, so that every coordinate had a number nothing else held. SP8 dropped
|
||||
that column, so the coordinate is all there is, and it has to survive the
|
||||
round trip on its own.
|
||||
"""
|
||||
copy = _imported(client, _export(client, _forked_story(client)))
|
||||
rows = _rows(copy)
|
||||
|
||||
at_depth_2 = [r for r in rows if r.depth == 2]
|
||||
assert len({r.branch_id for r in at_depth_2}) == 2
|
||||
|
||||
|
||||
# ------------------------------------------------------- a file that is wrong
|
||||
|
||||
def test_a_node_naming_a_branch_the_file_does_not_list_is_refused(client):
|
||||
"""The import must refuse this file rather than half-apply it. A tree
|
||||
missing a branch is a story that silently stops, which is the failure
|
||||
this whole phase exists to prevent."""
|
||||
payload = _export(client, _forked_story(client))
|
||||
payload["branches"] = payload["branches"][:1]
|
||||
before = _adventure_count()
|
||||
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "branch" in r.json()["detail"].lower()
|
||||
assert _adventure_count() == before, "nothing was created"
|
||||
|
||||
|
||||
def test_a_branch_forking_from_one_listed_after_it_is_refused(client):
|
||||
"""The ordering rule guarantees no cycles at the cost of one
|
||||
comparison. Without it, a cycle would produce an import that never
|
||||
returns instead of one that fails cleanly."""
|
||||
payload = _export(client, _forked_story(client))
|
||||
payload["branches"] = [{"parent": 1, "forkDepth": 0}, {"parent": None, "forkDepth": None}]
|
||||
before = _adventure_count()
|
||||
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 400, r.text
|
||||
assert _adventure_count() == before
|
||||
|
||||
|
||||
def test_a_fork_with_no_depth_is_refused(client):
|
||||
payload = _export(client, _forked_story(client))
|
||||
payload["branches"][1].pop("forkDepth")
|
||||
before = _adventure_count()
|
||||
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "depth" in r.json()["detail"].lower()
|
||||
assert _adventure_count() == before
|
||||
|
||||
|
||||
def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
|
||||
payload = {
|
||||
"format": bundle.FORMAT, "title": "Too many",
|
||||
"branches": [{"parent": None, "forkDepth": None}]
|
||||
* (limits.MAX_BRANCHES_PER_ADVENTURE + 1),
|
||||
"actions": [],
|
||||
}
|
||||
before = _adventure_count()
|
||||
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 409, r.text
|
||||
assert _adventure_count() == before
|
||||
|
||||
|
||||
def test_a_turn_the_file_gives_no_live_attempt_still_tells_one(client):
|
||||
"""A coordinate with nothing live is a turn no read can see.
|
||||
|
||||
The file is allowed to be wrong about this, since it is a text file
|
||||
someone can edit. The import picks the first attempt instead of
|
||||
importing a story with a gap.
|
||||
"""
|
||||
payload = _export(client, _forked_story(client))
|
||||
for node in payload["actions"]:
|
||||
node["live"] = False
|
||||
copy = _imported(client, payload)
|
||||
|
||||
assert _texts(client, copy), "the story is readable"
|
||||
live = [(r.branch_id, r.depth) for r in _rows(copy) if r.live]
|
||||
assert len(live) == len(set(live)), "exactly one live attempt per coordinate"
|
||||
assert len(live) == len({(r.branch_id, r.depth) for r in _rows(copy)})
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the v1 reader
|
||||
|
||||
def test_a_v1_bundle_still_imports(client):
|
||||
"""The v1 reader must remain even after the v1 writer is gone, because
|
||||
those files already exist and are saved."""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT,
|
||||
"title": "Old backup",
|
||||
"memoryCursor": 0, "summaryCursor": 0,
|
||||
"actions": [
|
||||
{"index": 0, "type": "start", "text": OPENING},
|
||||
{"index": 1, "type": "do", "text": "> You go north."},
|
||||
{
|
||||
"index": 2, "type": "ai", "text": "Two.",
|
||||
"variants": [{"text": "One."}, {"text": "Two."}],
|
||||
"variantIndex": 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
copy = _imported(client, payload)
|
||||
|
||||
assert _texts(client, copy) == [OPENING, "> You go north.", "Two."]
|
||||
# The import produces one branch, and the `variants` array splits
|
||||
# back into the sibling group it always described.
|
||||
assert len(_branches(client, copy)) == 1
|
||||
ai = [r for r in _rows(copy) if r.type == "ai"]
|
||||
assert [(r.text, r.live) for r in ai] == [("One.", False), ("Two.", True)]
|
||||
assert len({(r.branch_id, r.depth) for r in ai}) == 1
|
||||
|
||||
|
||||
def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client):
|
||||
"""v1 counts covered actions. The tree anchors them to a node instead.
|
||||
The translation needs the nodes to exist first, so it runs after they
|
||||
are written."""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
|
||||
"memoryCursor": 2, "summaryCursor": 2,
|
||||
"actions": [
|
||||
{"index": 0, "type": "start", "text": OPENING},
|
||||
{"index": 1, "type": "story", "text": "A corridor."},
|
||||
{"index": 2, "type": "story", "text": "A door."},
|
||||
],
|
||||
}
|
||||
copy = _imported(client, payload)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, copy)
|
||||
assert adventure.memory_cursor_branch_id is not None
|
||||
assert adventure.memory_cursor_depth == 1, "the second story action"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_a_v2_bundle_brings_its_anchors_back(client):
|
||||
"""The other direction: v2 carries the anchor directly."""
|
||||
original = _forked_story(client)
|
||||
tip = [a for a in _rows(original) if a.live][-1]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, original)
|
||||
adventure.memory_cursor_branch_id = tip.branch_id
|
||||
adventure.memory_cursor_depth = tip.depth
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
exported = _export(client, original)
|
||||
assert exported["memoryCursor"] == {"branch": 1, "depth": tip.depth}
|
||||
|
||||
copy = _imported(client, exported)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, copy)
|
||||
branches = [b.id for b in _branch_rows(copy)]
|
||||
assert branches.index(adventure.memory_cursor_branch_id) == 1
|
||||
assert adventure.memory_cursor_depth == tip.depth
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_a_v1_memory_that_summarises_nothing_lands_on_the_root(client):
|
||||
"""The import must answer the question migration 62 already answered.
|
||||
|
||||
A v1 file has no depths, and a memory the player typed has no
|
||||
`sourceEnd` to derive one from. It used to come back with a NULL
|
||||
depth, the exact state SP7 removed from the schema.
|
||||
`Path._entry_clause` compares `depth <= max_depth`, and a NULL fails
|
||||
that comparison. The memory would read fine until the imported
|
||||
adventure forked, then vanish from the new branch.
|
||||
"""
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
|
||||
"memoryCursor": 0, "summaryCursor": 0,
|
||||
"actions": [
|
||||
{"index": 0, "type": "start", "text": OPENING},
|
||||
{"index": 1, "type": "story", "text": "A corridor."},
|
||||
],
|
||||
"memories": [
|
||||
{"text": "Kira is the innkeeper's daughter"}, # typed
|
||||
{"text": "The corridor, summarised", "sourceStart": 1, "sourceEnd": 1},
|
||||
],
|
||||
}
|
||||
copy = _imported(client, payload)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = {m.text: m for m in db.query(models.Memory)
|
||||
.filter(models.Memory.adventure_id == copy).all()}
|
||||
assert rows["Kira is the innkeeper's daughter"].depth == 0, (
|
||||
"a typed memory anchors at the root, which every branch can see"
|
||||
)
|
||||
assert rows["The corridor, summarised"].depth == 1, "derived from its range"
|
||||
assert all(m.depth is not None for m in rows.values())
|
||||
assert all(m.branch_id is not None for m in rows.values())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypatch):
|
||||
"""The cap must count what gets written, not what the file lists.
|
||||
|
||||
A v1 turn carries its retries in a `variants` array, and SP4 made
|
||||
every attempt a row, so one entry can expand into ten. Counting
|
||||
entries instead of rows would let a file inside the cap write a
|
||||
multiple of it. The body-size limit does not help here: the text is
|
||||
tiny, and the row count is the actual cost.
|
||||
"""
|
||||
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
|
||||
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
|
||||
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
|
||||
payload = {
|
||||
"format": bundle.LEGACY_FORMAT, "title": "Small file, many rows",
|
||||
"memoryCursor": 0, "summaryCursor": 0,
|
||||
"actions": [{"index": 0, "type": "start", "text": OPENING}] + [
|
||||
{
|
||||
"index": i, "type": "ai", "text": "Take four.",
|
||||
"variants": [{"text": f"Take {n}."} for n in range(4)],
|
||||
"variantIndex": 3,
|
||||
}
|
||||
for i in range(1, 4)
|
||||
],
|
||||
}
|
||||
assert len(payload["actions"]) <= 6, "the file itself is inside the cap"
|
||||
before = _adventure_count()
|
||||
|
||||
r = _import(client, payload)
|
||||
assert r.status_code == 409, r.text
|
||||
assert _adventure_count() == before, "and nothing was written"
|
||||
|
||||
|
||||
def test_an_unknown_format_is_refused(client):
|
||||
r = _import(client, {"format": "ai-dnd-adventure-v3", "title": "From the future"})
|
||||
assert r.status_code == 400, r.text
|
||||
assert bundle.FORMAT in r.json()["detail"]
|
||||
|
||||
|
||||
# ------------------------------------------------------- the persona (Phase 18)
|
||||
|
||||
def test_the_persona_survives_the_round_trip(client):
|
||||
adv_id = client.adv_id
|
||||
assert client.patch(f"/api/adventures/{adv_id}", json={
|
||||
"persona_name": "Kaelen",
|
||||
"persona_pronouns": "he/him",
|
||||
"persona_desc": "A half-elf ranger, exiled from the northern holds.",
|
||||
}).status_code == 200
|
||||
|
||||
payload = _export(client, adv_id)
|
||||
assert payload["persona"] == {
|
||||
"name": "Kaelen",
|
||||
"pronouns": "he/him",
|
||||
"desc": "A half-elf ranger, exiled from the northern holds.",
|
||||
}
|
||||
|
||||
copy_id = _imported(client, payload)
|
||||
r = client.get(f"/api/adventures/{copy_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
copy = r.json()
|
||||
assert copy["persona_name"] == "Kaelen"
|
||||
assert copy["persona_pronouns"] == "he/him"
|
||||
assert copy["persona_desc"].startswith("A half-elf ranger")
|
||||
|
||||
|
||||
def test_a_bundle_written_before_personas_still_imports(client):
|
||||
"""The key is read with `.get`, so a v2 file from before Phase 18 lands
|
||||
with an empty persona rather than failing. No FORMAT bump was needed."""
|
||||
payload = _export(client, client.adv_id)
|
||||
del payload["persona"]
|
||||
copy = client.get(f"/api/adventures/{_imported(client, payload)}").json()
|
||||
assert copy["persona_name"] == ""
|
||||
assert copy["persona_pronouns"] == ""
|
||||
assert copy["persona_desc"] == ""
|
||||
|
||||
|
||||
def test_an_over_long_persona_in_a_raw_bundle_is_truncated(client):
|
||||
"""A raw-dict import bypasses the schemas, so the widths the columns
|
||||
declare are enforced here instead. Postgres would reject the INSERT."""
|
||||
payload = _export(client, client.adv_id)
|
||||
payload["persona"] = {"name": "K" * 500, "pronouns": "p" * 500, "desc": "d"}
|
||||
copy = client.get(f"/api/adventures/{_imported(client, payload)}").json()
|
||||
assert len(copy["persona_name"]) == 80
|
||||
assert len(copy["persona_pronouns"]) == 40
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Phase 18b: what the summarizer is told about who the characters are.
|
||||
|
||||
Before this, `_create_due_memories` sent six actions of second-person prose and
|
||||
nothing else. The model had no way to know who "you" was or who "she" was, so
|
||||
neither did any memory it wrote, and the summary built from those memories
|
||||
inherited the problem.
|
||||
|
||||
The rules this file holds in place:
|
||||
|
||||
* The brief carries fixed descriptions only. A live stat in it would make the
|
||||
same event, summarized twice, come out framed differently — the exact fault
|
||||
the change exists to remove.
|
||||
* Places and items are not topped up into the roster. Only characters are.
|
||||
* An adventure with no persona and no cards still summarizes, with the prompt
|
||||
it had before.
|
||||
|
||||
python -m pytest tests/test_cast_brief.py -v
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app import memorybank, models
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
GWEN = "A loyal ranger and the player's ally. Quick with a bow, fiercely protective."
|
||||
LEADER = "Scarred, patient, and the one holding the strongbox key."
|
||||
TAVERN = "A tavern three days south of the camp."
|
||||
|
||||
|
||||
class StubSummarizer:
|
||||
"""Records every (system, user) pair handed to the summarizer."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
async def complete(self, system, user, **kwargs):
|
||||
self.calls.append((system, user))
|
||||
return f"Memory {len(self.calls)}."
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def make_adventure(db, *, actions=0, persona=True, cards=True, memory=True):
|
||||
user = models.User(is_guest=False, email="cast@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Camp", script_state={}, auto_summarize=True,
|
||||
memory=("The player and Gwen are raiding a bandit camp to recover a "
|
||||
"stolen strongbox.") if memory else "",
|
||||
persona_name="Kaelen" if persona else "",
|
||||
persona_pronouns="he/him" if persona else "",
|
||||
persona_desc="A half-elf ranger, exiled from the northern holds." if persona else "",
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
if cards:
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="Gwen",
|
||||
keys="Gwen, ranger, her", type="character", entry=GWEN))
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="Bandit Leader",
|
||||
keys="Bandit Leader, leader", type="character", entry=LEADER))
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="The Rusted Tankard",
|
||||
keys="tankard, tavern", type="location", entry=TAVERN))
|
||||
for i in range(actions):
|
||||
db.add(models.Action(adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do",
|
||||
text=f"You walk on. Action {i}."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the brief itself
|
||||
|
||||
def test_the_protagonist_is_named_and_marked_as_such(db):
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "You walk into the camp.")
|
||||
assert "- Kaelen (he/him) — the protagonist." in brief
|
||||
assert "A half-elf ranger" in brief
|
||||
|
||||
|
||||
def test_a_named_character_in_the_text_is_in_the_roster(db):
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "Gwen checks her bowstring.")
|
||||
assert "- Gwen — " in brief and "Quick with a bow" in brief
|
||||
|
||||
|
||||
def test_a_character_referred_to_only_by_pronoun_is_still_in_the_roster(db):
|
||||
"""The block that most needs a cast is the one written in bare pronouns.
|
||||
Keyword matching alone finds nothing here, so the roster is topped up."""
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "She grabs your arm before you step through.")
|
||||
assert "- Gwen — " in brief
|
||||
assert "- Bandit Leader — " in brief
|
||||
|
||||
|
||||
def test_places_are_not_topped_up(db):
|
||||
"""An unmentioned tavern is not who "she" was."""
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "She grabs your arm.")
|
||||
assert "Rusted Tankard" not in brief
|
||||
|
||||
|
||||
def test_a_place_that_is_mentioned_does_appear(db):
|
||||
"""Topping up is limited to characters. Matching is not."""
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "You push into the tavern, breathless.")
|
||||
assert "The Rusted Tankard" in brief
|
||||
|
||||
|
||||
def test_the_setting_is_the_plot_essentials(db):
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "You walk on.")
|
||||
assert "Setting:\nThe player and Gwen are raiding a bandit camp" in brief
|
||||
|
||||
|
||||
def test_no_persona_and_no_cards_gives_no_brief(db):
|
||||
adventure = make_adventure(db, persona=False, cards=False, memory=False)
|
||||
assert memorybank.cast_brief(adventure, "You walk on.") == ""
|
||||
|
||||
|
||||
def test_a_persona_alone_is_enough_for_a_brief(db):
|
||||
adventure = make_adventure(db, cards=False, memory=False)
|
||||
brief = memorybank.cast_brief(adventure, "You walk on.")
|
||||
assert brief.startswith("Cast:\n- Kaelen (he/him) — the protagonist.")
|
||||
|
||||
|
||||
def test_an_unnamed_protagonist_is_called_the_player(db):
|
||||
adventure = make_adventure(db, cards=False, memory=False)
|
||||
adventure.persona_name = ""
|
||||
adventure.persona_pronouns = ""
|
||||
db.commit()
|
||||
assert "- The player — the protagonist." in memorybank.cast_brief(adventure, "x")
|
||||
|
||||
|
||||
def test_the_roster_is_capped(db, monkeypatch):
|
||||
monkeypatch.setattr(memorybank, "MAX_CAST_MEMBERS", 2)
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "She grabs your arm.")
|
||||
assert len([ln for ln in brief.splitlines() if ln.startswith("- ")]) == 2
|
||||
|
||||
|
||||
def test_a_long_description_is_trimmed(db):
|
||||
adventure = make_adventure(db, cards=False, memory=False)
|
||||
adventure.persona_desc = "word " * 400
|
||||
db.commit()
|
||||
brief = memorybank.cast_brief(adventure, "x")
|
||||
assert "…" in brief
|
||||
assert len(brief) < 600
|
||||
|
||||
|
||||
def test_a_character_is_never_listed_twice(db):
|
||||
"""The persona and a card can carry the same name."""
|
||||
adventure = make_adventure(db, cards=False, memory=False)
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="Kaelen",
|
||||
keys="Kaelen", type="character", entry="Also Kaelen."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
brief = memorybank.cast_brief(adventure, "Kaelen walks on.")
|
||||
assert brief.lower().count("kaelen") == 1
|
||||
|
||||
|
||||
def test_the_brief_holds_no_live_values(db):
|
||||
"""Fixed descriptions only. A stat here would frame the same event two ways
|
||||
depending on when it happened to be summarized."""
|
||||
adventure = make_adventure(db)
|
||||
brief = memorybank.cast_brief(adventure, "Gwen checks her bowstring.")
|
||||
for live in ("hp", "trust", "/100", "wary", "healthy"):
|
||||
assert live not in brief.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------- what actually gets sent
|
||||
|
||||
def _run_memory_pass(db, adventure, monkeypatch, stub):
|
||||
monkeypatch.setattr(memorybank, "MEMORY_START", 0)
|
||||
monkeypatch.setattr(memorybank, "MEMORY_INTERVAL", 4)
|
||||
monkeypatch.setattr(memorybank, "MAX_MEMORIES_PER_RUN", 1)
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
settings = db.query(models.Settings).first()
|
||||
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
|
||||
assert stub.calls, "the summarizer was never called"
|
||||
return stub.calls[0]
|
||||
|
||||
|
||||
def test_the_memory_prompt_carries_the_brief_above_the_excerpt(db, monkeypatch):
|
||||
adventure = make_adventure(db, actions=6)
|
||||
system, user = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
|
||||
assert user.index("Cast:") < user.index("Story excerpt:")
|
||||
assert "Kaelen (he/him) — the protagonist" in user
|
||||
assert "Setting:" in user
|
||||
|
||||
|
||||
def test_the_memory_prompt_demands_third_person_and_names(db, monkeypatch):
|
||||
adventure = make_adventure(db, actions=6)
|
||||
system, _ = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
|
||||
assert "third person" in system
|
||||
assert 'never as "you"' in system
|
||||
|
||||
|
||||
def test_an_adventure_with_nothing_to_say_sends_the_old_prompt(db, monkeypatch):
|
||||
"""No persona, no cards, no plot essentials: the user message is exactly
|
||||
what it was before this change, with no stray blank lines."""
|
||||
adventure = make_adventure(db, actions=6, persona=False, cards=False, memory=False)
|
||||
_, user = _run_memory_pass(db, adventure, monkeypatch, StubSummarizer())
|
||||
assert user.startswith("Story excerpt:")
|
||||
assert "Cast:" not in user
|
||||
|
||||
|
||||
def test_the_summary_prompt_carries_the_brief_too(db, monkeypatch):
|
||||
"""It builds from the memories, so it inherits their framing — but the
|
||||
fallback hands it raw second-person story text, which needs the brief."""
|
||||
adventure = make_adventure(db, actions=20)
|
||||
stub = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "SUMMARY_INTERVAL", 1)
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
settings = db.query(models.Settings).first()
|
||||
asyncio.run(memorybank._update_story_summary(adventure, settings, db))
|
||||
assert stub.calls, "the summarizer was never called"
|
||||
system, user = stub.calls[0]
|
||||
assert user.index("Cast:") < user.index("Current story summary:")
|
||||
assert "Kaelen (he/him) — the protagonist" in user
|
||||
assert "third person" in system
|
||||
assert adventure.story_summary == "Memory 1."
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Tests for the changes the engine refuses, and for naming a milestone.
|
||||
|
||||
A refused change used to leave no trace a player could see. The turn summary
|
||||
read `world_delta["applied"]` alone, so a value the model pushed past its
|
||||
ceiling came back with a delta of 0 and rendered as an ordinary chip. The
|
||||
engine had recorded the refusal and nothing showed it.
|
||||
|
||||
The milestone half is the same shape. `apply_delta` matches a milestone by its
|
||||
schema id, and the context named goals by description only, so the model had no
|
||||
way to learn the id it was being asked to send.
|
||||
|
||||
python -m pytest tests/test_change_visibility.py -v
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
from app import models
|
||||
from app import worldstate as w
|
||||
from app.routers.adventures import world_delta_of
|
||||
|
||||
SEED = pathlib.Path(__file__).resolve().parents[1] / "app" / "seed_data"
|
||||
|
||||
SCHEMA = {
|
||||
"world": {"day": {"type": "counter", "min": 1, "initial": 1}},
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30},
|
||||
# Starts at its own ceiling and only ever falls, which is the shape that
|
||||
# turns a wrong-signed delta into a change of nothing.
|
||||
"arrows": {"min": 0, "max": 6, "initial": 6, "max_delta_per_turn": 1},
|
||||
},
|
||||
"npcs": {"gwen": {"name": "Gwen", "stats": {"trust": {"min": -100, "max": 100, "initial": 0}}}},
|
||||
"flags": {"has_key": {"desc": "Holds the key", "initial": False}},
|
||||
"milestones": {
|
||||
"rescue_gwen": {"desc": "Rescue Gwen"},
|
||||
"escape_keep": {"desc": "Escape the keep"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def action(delta, index=1):
|
||||
"""Returns an unsaved Action carrying the report `apply_delta` produced.
|
||||
|
||||
The column is filled through `world_delta_of`, the same function the turn
|
||||
endpoint uses, rather than by assembling the dict here. An earlier version
|
||||
of this helper built the shape by hand with every report list present. That
|
||||
hid a live bug: `world_delta_of` stored `applied` alone, so no refusal ever
|
||||
reached a chip or the next prompt while all of these tests passed.
|
||||
"""
|
||||
ws, report = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, delta, index)
|
||||
snapshot = {"world_state": {"delta": delta, "report": report, "state": ws}}
|
||||
return models.Action(world_delta=world_delta_of(snapshot))
|
||||
|
||||
|
||||
def test_world_delta_of_keeps_every_report_list():
|
||||
"""The stored column must carry the refusals, not just the successes.
|
||||
|
||||
`Action.world_changes` marks a clamped chip from `clamped` and builds its
|
||||
refusal chips from `rejected`, and `worldstate.refusals` reads both. Dropping
|
||||
either list leaves every consumer unable to tell a refused change from one
|
||||
that worked.
|
||||
"""
|
||||
_, report = w.apply_delta(
|
||||
w.instantiate(SCHEMA), SCHEMA,
|
||||
{"player.arrows": 3, "player.nonesuch": 1, "player.hp": -5}, 1,
|
||||
)
|
||||
stored = world_delta_of({"world_state": {"delta": {}, "report": report}})
|
||||
assert [e["path"] for e in stored["applied"]] == ["player.arrows", "player.hp"]
|
||||
assert [e["path"] for e in stored["clamped"]] == ["player.arrows"]
|
||||
assert [e["path"] for e in stored["rejected"]] == ["player.nonesuch"]
|
||||
|
||||
|
||||
def test_world_delta_of_survives_a_snapshot_with_no_world_state():
|
||||
assert world_delta_of({"story": "s"}) is None
|
||||
assert world_delta_of(None) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The goals line names the id the AI has to send
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_goals_line_names_the_milestone_id():
|
||||
line = w.render_state_section(w.instantiate(SCHEMA), SCHEMA, {})
|
||||
assert "rescue_gwen — Rescue Gwen" in line
|
||||
assert "escape_keep — Escape the keep" in line
|
||||
# The path is spelled out, because the id alone does not say how to send it.
|
||||
assert "milestones.<id>" in line
|
||||
|
||||
|
||||
def test_achieved_line_names_the_id_too():
|
||||
ws, _ = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, {"milestones.rescue_gwen": True}, 1)
|
||||
line = w.render_state_section(ws, SCHEMA, {})
|
||||
assert "Achieved: rescue_gwen — Rescue Gwen." in line
|
||||
# A reached milestone leaves the goals list.
|
||||
assert "rescue_gwen — Rescue Gwen;" not in line
|
||||
|
||||
|
||||
def test_every_milestone_in_the_demo_is_named_to_the_model():
|
||||
"""The demo that exposed this must not lose the ids again."""
|
||||
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
|
||||
line = w.render_state_section(w.instantiate(schema), schema, {})
|
||||
for mid in schema["milestones"]:
|
||||
assert mid in line
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A refused change reaches the turn summary
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_a_clamp_that_changes_nothing_is_still_reported():
|
||||
"""The Pokémon bug: a positive delta on a stat already at its ceiling.
|
||||
|
||||
`arrows` sits at 6 of a maximum 6, so `+2` caps to `+1`, reaches 7, and
|
||||
clamps back to 6. The value never moves, and the summary has to say so.
|
||||
"""
|
||||
chips = action({"player.arrows": 2}).world_changes
|
||||
arrows = [c for c in chips if c["label"] == "arrows"]
|
||||
assert len(arrows) == 1
|
||||
assert arrows[0]["delta"] == 0
|
||||
assert arrows[0]["clamped"] is True
|
||||
|
||||
|
||||
def test_a_clamp_that_still_moves_the_value_is_marked():
|
||||
chips = action({"player.hp": -80}).world_changes
|
||||
hp = [c for c in chips if c["label"] == "hp"][0]
|
||||
assert hp["delta"] == -30 # max_delta_per_turn
|
||||
assert hp["clamped"] is True
|
||||
|
||||
|
||||
def test_an_accepted_change_is_not_marked():
|
||||
chips = action({"player.hp": -10}).world_changes
|
||||
hp = [c for c in chips if c["label"] == "hp"][0]
|
||||
assert hp["delta"] == -10
|
||||
assert hp["clamped"] is False
|
||||
|
||||
|
||||
def test_a_refusal_becomes_its_own_entry_carrying_the_reason():
|
||||
chips = action({"world.day": -1, "player.stamina": 5}).world_changes
|
||||
refused = {c["label"]: c["reason"] for c in chips if c["kind"] == "rejected"}
|
||||
assert refused == {"day": "counter can't decrease", "stamina": "unknown stat"}
|
||||
|
||||
|
||||
def test_a_refused_npc_stat_keeps_the_npc_in_its_label():
|
||||
chips = action({"npc.gwen.bogus": 5}).world_changes
|
||||
refused = [c for c in chips if c["kind"] == "rejected"][0]
|
||||
assert refused["label"] == "gwen bogus"
|
||||
|
||||
|
||||
def test_accepted_and_refused_changes_appear_together():
|
||||
chips = action({"player.hp": -10, "world.day": -1}).world_changes
|
||||
kinds = [c["kind"] for c in chips]
|
||||
assert "stat" in kinds and "rejected" in kinds
|
||||
|
||||
|
||||
def test_a_turn_that_changed_nothing_still_has_no_chips():
|
||||
assert models.Action(world_delta=None).world_changes == []
|
||||
|
||||
|
||||
def test_flags_and_milestones_are_unchanged_by_the_new_fields():
|
||||
chips = action({"flags.has_key": True, "milestones.rescue_gwen": True}).world_changes
|
||||
assert {"kind": "flag", "label": "has_key", "on": True} in chips
|
||||
assert {"kind": "milestone", "label": "rescue_gwen"} in chips
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The demo stat counts up, so a wrong sign is refused rather than absorbed
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_milos_faint_counter_counts_up_from_zero():
|
||||
"""A stat that starts at its ceiling cannot report a wrong sign.
|
||||
|
||||
`pokemon_left` began at 3 of a maximum 3, so the model sending the count it
|
||||
had left rather than a delta clamped to no change at all. Counting the
|
||||
faints up from 0 puts the wrong direction on the counter rule, which
|
||||
refuses it out loud.
|
||||
"""
|
||||
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
|
||||
stat = schema["npcs"]["milo"]["stats"]["pokemon_fainted"]
|
||||
assert stat["initial"] == 0 and stat["type"] == "counter"
|
||||
|
||||
ws = w.instantiate(schema)
|
||||
after, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": 1}, 1)
|
||||
assert after["npc"]["milo"]["pokemon_fainted"] == 1
|
||||
|
||||
_, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": -1}, 1)
|
||||
assert len(report["rejected"]) == 1
|
||||
refused = report["rejected"][0]
|
||||
assert refused["path"] == "npc.milo.pokemon_fainted"
|
||||
assert refused["reason"] == "counter can't decrease"
|
||||
|
||||
|
||||
def test_the_demo_no_longer_mentions_the_old_stat():
|
||||
"""The instructions name the stat, so a rename has to reach them too."""
|
||||
raw = (SEED / "05-league-championship.json").read_text(encoding="utf-8")
|
||||
assert "pokemon_left" not in raw
|
||||
assert "pokemon_fainted" in json.loads(raw)["ai_instructions"]
|
||||
|
||||
|
||||
def test_the_demo_asks_for_the_turn_counter():
|
||||
"""`world.turn` sat at 0 for a whole playtest: nothing told the model to
|
||||
move it. The schema defining a stat is not an instruction to update it."""
|
||||
d = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))
|
||||
assert "world.turn" in d["stat_schema"]["world"] or "turn" in d["stat_schema"]["world"]
|
||||
assert "world.turn" in d["ai_instructions"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# What the model is told about its own refused changes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_history_replays_what_was_accepted_not_what_was_sent():
|
||||
"""The contradiction that taught the model to repeat itself.
|
||||
|
||||
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
|
||||
delta showed the model a change the live values disagreed with.
|
||||
"""
|
||||
from app.context.builder import _history_text
|
||||
|
||||
a = action({"player.arrows": 2, "player.hp": -10})
|
||||
a.text = "The arrow flies."
|
||||
replayed = _history_text(a)
|
||||
assert '"player.hp": -10' in replayed
|
||||
assert "arrows" not in replayed
|
||||
|
||||
|
||||
def test_history_replay_keeps_flags_and_milestones_and_text():
|
||||
from app.context.builder import _history_text
|
||||
|
||||
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
|
||||
a.text = "The lock gives."
|
||||
replayed = _history_text(a)
|
||||
assert '"flags.has_key": true' in replayed
|
||||
assert '"milestones.rescue_gwen": true' in replayed
|
||||
|
||||
|
||||
def test_a_refusal_reaches_the_model_with_the_valid_names():
|
||||
note = w.render_refusals(action({"milestones.bogus": True}).world_delta)
|
||||
assert "There is no milestone `bogus`" in note
|
||||
# The correction has to name what it could have sent instead.
|
||||
assert "`rescue_gwen`" in note and "`escape_keep`" in note
|
||||
|
||||
|
||||
def test_a_wrong_sign_on_a_counter_is_explained():
|
||||
note = w.render_refusals(action({"world.day": -1}).world_delta)
|
||||
assert "only counts up" in note
|
||||
|
||||
|
||||
def test_a_clamp_that_moved_nothing_quotes_the_limit():
|
||||
note = w.render_refusals(action({"player.arrows": 2}).world_delta)
|
||||
assert "did not move" in note
|
||||
assert "maximum of 6" in note and "it runs from 0 to 6" in note
|
||||
|
||||
|
||||
def test_a_clamp_that_still_moved_the_value_says_nothing():
|
||||
"""Reporting a trimmed change invites the model to send the remainder next
|
||||
turn, which is the swing `max_delta_per_turn` exists to prevent."""
|
||||
assert w.render_refusals(action({"player.hp": -80}).world_delta) == ""
|
||||
|
||||
|
||||
def test_a_clean_turn_adds_no_note():
|
||||
assert w.render_refusals(action({"player.hp": -10}).world_delta) == ""
|
||||
assert w.render_refusals(None) == ""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""HTTP tests for the AI Chat scratchpad.
|
||||
|
||||
Most of this file used to be about the shared demo key: an access gate on a
|
||||
"power user" email allowlist, and a pinning rule that stopped a public visitor
|
||||
reaching paid models on a server-funded key. M2 removed the hosted deployment
|
||||
those defended, so the rules they tested no longer exist to be tested. See
|
||||
`planning/reports/M2-*` for the accounting.
|
||||
|
||||
What remains is what the page still does: stream a reply from the configured
|
||||
model, honour a system prompt and a per-request model override, and refuse a
|
||||
conversation too large to send.
|
||||
|
||||
python -m pytest tests/test_chat.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import chat
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
"""Records what it was constructed with, then streams a fixed reply.
|
||||
|
||||
It stands in for the real egress point, so asserting on `last_endpoint` and
|
||||
`last_model` is asserting on exactly what would have gone over the wire.
|
||||
There is no `last_key` any more: the provider takes no API key, because
|
||||
Ollama does not use one.
|
||||
"""
|
||||
last_usage = None
|
||||
last_model = None
|
||||
last_endpoint = None
|
||||
last_messages = None
|
||||
|
||||
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
|
||||
FakeProvider.last_model = model
|
||||
FakeProvider.last_endpoint = endpoint_url
|
||||
|
||||
async def chat(self, messages, *, temperature, max_tokens):
|
||||
FakeProvider.last_messages = messages
|
||||
yield ("reasoning", "hmm")
|
||||
yield ("text", "Hello back.")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _send(client, **body):
|
||||
payload = {"messages": [{"role": "user", "content": "hi"}]}
|
||||
payload.update(body)
|
||||
return client.post("/api/chat/stream", json=payload)
|
||||
|
||||
|
||||
def test_the_page_streams_a_reply(client):
|
||||
resp = _send(client)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert '"type": "reasoning"' in resp.text
|
||||
assert "Hello back." in resp.text
|
||||
assert '"type": "done"' in resp.text
|
||||
assert FakeProvider.last_messages == [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
def test_it_uses_the_configured_endpoint_and_model(client):
|
||||
_send(client)
|
||||
assert FakeProvider.last_model == "test-model"
|
||||
# The default from `models.Settings`, and the only kind of address the
|
||||
# endpoint policy allows without configuration.
|
||||
assert FakeProvider.last_endpoint == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_system_prompt_and_model_override_are_honoured(client):
|
||||
resp = client.post("/api/chat/stream", json={
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be terse."},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
"model": "some-other-model",
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert FakeProvider.last_model == "some-other-model"
|
||||
assert FakeProvider.last_messages[0] == {"role": "system", "content": "Be terse."}
|
||||
|
||||
|
||||
def test_a_request_with_no_model_anywhere_is_refused(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(models.Settings).first()
|
||||
settings.model = ""
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
assert _send(client).status_code == 400
|
||||
|
||||
|
||||
def test_oversized_conversation_is_refused(client):
|
||||
huge = "x" * 90_000
|
||||
resp = client.post("/api/chat/stream", json={
|
||||
"messages": [{"role": "user", "content": huge} for _ in range(5)],
|
||||
})
|
||||
assert resp.status_code == 413, resp.text
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Deleting a turn puts the shared state back.
|
||||
|
||||
`world_state` belongs to the adventure, not to the node
|
||||
that changed them. Undo, retry, a take and a branch switch all restore them;
|
||||
the delete endpoint did not. Deleting an AI turn removed the text and left
|
||||
everything the turn did to the numbers standing.
|
||||
|
||||
The visible symptom was the cooldown clock. It lives in
|
||||
`world_state._meta.last_changed` and it holds a depth. A deleted turn left
|
||||
its depth there, and the turn played in its place is played at that same
|
||||
depth, so the referee refused the change as one that had happened this very
|
||||
turn — on a turn the story no longer contains. Delete the AI reply because
|
||||
you did not like the stat change it proposed, press Continue, and the same
|
||||
change comes back refused as "changed too recently".
|
||||
|
||||
python -m pytest tests/test_delete_state.py -v
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
|
||||
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
|
||||
# a refusal rather than as a number that is merely off.
|
||||
SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
|
||||
# The per-turn counter these tests measure rollbacks with. Unbounded and
|
||||
# uncapped on purpose, so every turn's +10 lands in full. See
|
||||
# `fakes.gold_reply`.
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
|
||||
# Ten gold a turn. A total that only ever climbs makes a missing rollback
|
||||
# obvious: it is off by exactly one turn's worth.
|
||||
|
||||
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
|
||||
# The same turn, also banking the per-turn counter the rollback tests measure.
|
||||
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="delstate@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Tower", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adv.id, user.id
|
||||
setup.close()
|
||||
|
||||
ScriptedProvider.replies = [DRAIN_AND_GOLD]
|
||||
ScriptedProvider.calls = 0
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _play(client, text="look around"):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": "do", "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _continue(client):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": "continue", "text": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _delete(client, action_id):
|
||||
return client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}")
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ai_rows(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adv_id, models.Action.type == "ai")
|
||||
.order_by(models.Action.depth, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _last_changes(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.actions[-1].world_changes
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------- the reported bug
|
||||
|
||||
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
|
||||
_play(client)
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
||||
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
|
||||
_, world = _state(client.adv_id)
|
||||
assert world["player"]["mana"] == 50, "the drain went with the turn"
|
||||
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
|
||||
|
||||
|
||||
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
|
||||
"""The bug as a player meets it: delete the reply, press Continue, and
|
||||
the change it proposes is refused as one that already happened."""
|
||||
_play(client)
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
|
||||
_continue(client)
|
||||
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
|
||||
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
|
||||
|
||||
|
||||
def test_deleting_the_ai_turn_rewinds_the_counter(client):
|
||||
"""The same restore, seen through a value that only ever climbs. Without it
|
||||
a replayed turn stacks its effects on top of the deleted one's."""
|
||||
_play(client)
|
||||
assert _state(client.adv_id)[0]["gold"] == 10
|
||||
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
assert _state(client.adv_id)[0]["gold"] == 0
|
||||
|
||||
_continue(client)
|
||||
assert _state(client.adv_id)[0]["gold"] == 10, "one turn of gold, not two"
|
||||
|
||||
|
||||
# ------------------------------------------------- deleting further back
|
||||
|
||||
def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
|
||||
"""A restore reads the tip's own outcome, not the deleted node's
|
||||
neighbour, so removing a turn from the middle of the story does not roll
|
||||
the numbers back to that point. The text goes; the state stays."""
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
before = _state(client.adv_id)
|
||||
assert before[0]["gold"] == 20
|
||||
|
||||
first_ai = _ai_rows(client.adv_id)[0]
|
||||
assert _delete(client, first_ai.id).status_code == 204
|
||||
|
||||
assert _state(client.adv_id) == before
|
||||
|
||||
|
||||
def test_delete_is_blocked_while_a_turn_is_generating(client):
|
||||
"""The endpoint writes the shared state now, so it takes the same lock
|
||||
undo takes rather than racing the turn that is about to write it."""
|
||||
_play(client)
|
||||
action_id = _ai_rows(client.adv_id)[-1].id
|
||||
|
||||
adventures.turns.acquire_turn_lock(client.adv_id) # a turn is "generating"
|
||||
try:
|
||||
assert _delete(client, action_id).status_code == 409
|
||||
# The refused delete must not have released someone else's lock.
|
||||
assert client.adv_id in adventures.turns._active_turns
|
||||
finally:
|
||||
adventures.turns._active_turns.discard(client.adv_id)
|
||||
assert len(_ai_rows(client.adv_id)) == 1, "and the turn is still there"
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Guards on how much the database is asked for.
|
||||
|
||||
`context_snapshot` holds the entire assembled prompt for a turn. That is 163 kB
|
||||
per row averaged over production, 232 kB on the longest adventure, and 89% of the
|
||||
database. It used to be fetched for every action on every adventure load and
|
||||
every turn, to read two small values out of it. These tests fail if that
|
||||
returns.
|
||||
|
||||
Two kinds of guard live here, and both are needed:
|
||||
|
||||
* Column guards assert which columns a statement names. That is the shape both
|
||||
of this project's egress regressions took: one query carrying a column nobody
|
||||
read.
|
||||
* Byte ceilings assert what a request costs. Every column guard would still pass
|
||||
if a response grew tenfold within the columns it is allowed to read, which is
|
||||
what a story that keeps getting longer does.
|
||||
|
||||
python -m pytest tests/test_egress.py -v
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy.orm import undefer
|
||||
|
||||
from app import auth, limits, migrations, models
|
||||
from app.context import history
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from tools import dbmeter
|
||||
from tools.fakeprose import prose
|
||||
|
||||
# A stand-in for the real thing: the assembled prompt, which is what makes the
|
||||
# column enormous, plus the small world_state slice the UI actually needs.
|
||||
#
|
||||
# Varied text, not `"x" * 20_000`. The column is stored compressed now
|
||||
# by migration 43, and a repeated character compresses about a thousandfold.
|
||||
# That would make the byte ceilings below pass against a fixture that costs
|
||||
# nothing, which tests nothing. Prose-shaped filler compresses like the prompts
|
||||
# it stands in for.
|
||||
_SNAPSHOT_RNG = random.Random(20_260_817)
|
||||
BIG_SNAPSHOT = {
|
||||
"system": prose(_SNAPSHOT_RNG, 20_000),
|
||||
"story": prose(_SNAPSHOT_RNG, 40_000),
|
||||
"world_state": {
|
||||
"delta": {"player.hp": -15},
|
||||
"report": {"applied": [{"path": "player.hp", "old": 100, "new": 85}]},
|
||||
"state": {"player": {"hp": 85}},
|
||||
},
|
||||
}
|
||||
|
||||
# Retry history: each discarded attempt keeps its full narration, so an action
|
||||
# retried a few times carries several KB that a list response only ever counts.
|
||||
BIG_VARIANTS = [
|
||||
{"text": "z" * 4_000, "reasoning": None, "script_state": {},
|
||||
"created_at": "2026-01-01T00:00:00"}
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sql_log():
|
||||
"""Every statement the ORM sends, for asserting on what was selected."""
|
||||
statements: list[str] = []
|
||||
|
||||
def record(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", record)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", record)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="egress@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
|
||||
adventure = models.Adventure(user_id=user.id, title="Cave", script_state={})
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
for i in range(12):
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do", text=f"Action {i}.",
|
||||
context_snapshot=BIG_SNAPSHOT,
|
||||
world_delta={"delta": {"player.hp": -15},
|
||||
"applied": [{"path": "player.hp", "old": 100, "new": 85}]},
|
||||
))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def action_selects(statements: list[str]) -> list[str]:
|
||||
return [s for s in statements if "FROM actions" in s and s.lstrip().upper().startswith("SELECT")]
|
||||
|
||||
|
||||
# ------------------------------------------------------- the deferred columns
|
||||
|
||||
def test_loading_an_adventure_does_not_fetch_context_snapshot(client, sql_log):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(r.json()["actions"]) == 12
|
||||
|
||||
selects = action_selects(sql_log)
|
||||
assert selects, "expected at least one SELECT against actions"
|
||||
offenders = [s for s in selects if "context_snapshot" in s]
|
||||
assert offenders == [], f"context_snapshot was fetched in bulk:\n{offenders[0][:400]}"
|
||||
|
||||
|
||||
def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
|
||||
"""Both are rollback snapshots, only ever needed for the single node being
|
||||
undone, retried past or switched to. A page load must pay for neither.
|
||||
"""
|
||||
client.get(f"/api/adventures/{client.adv_id}")
|
||||
selects = action_selects(sql_log)
|
||||
for column in ("state_after", "world_state_after"):
|
||||
offenders = [s for s in selects if column in s]
|
||||
assert offenders == [], f"{column} was fetched in bulk"
|
||||
|
||||
|
||||
def test_world_changes_still_works_without_the_snapshot(client):
|
||||
"""The chips under an AI message must survive the snapshot being deferred."""
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
ai = [a for a in r.json()["actions"] if a["type"] == "ai"]
|
||||
assert ai, "fixture should have AI actions"
|
||||
assert ai[0]["world_changes"] == [
|
||||
{"kind": "stat", "label": "hp", "delta": -15, "value": 85, "clamped": False}
|
||||
]
|
||||
|
||||
|
||||
def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log):
|
||||
"""A count that wraps the entity select in a subquery names every column in
|
||||
the emitted SQL.
|
||||
|
||||
No bytes come back, but the database still reads them, and the guard above
|
||||
cannot distinguish that from a real bulk fetch.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
sql_log.clear()
|
||||
assert history.count(adventure) == 12
|
||||
counts = [s for s in sql_log if "count" in s.lower()]
|
||||
assert counts, "expected a COUNT to be emitted"
|
||||
for column in ("context_snapshot", "state_after", "world_state_after"):
|
||||
assert not any(column in s for s in counts), (
|
||||
f"{column} is named by the count query:\n{counts[0][:400]}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_snapshot_is_still_reachable_on_demand(client):
|
||||
"""Deferred means lazy rather than absent. Insights still gets the whole
|
||||
snapshot."""
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
action_id = r.json()["actions"][0]["id"]
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/actions/{action_id}/context")
|
||||
assert r.status_code == 200, r.text
|
||||
# Round-tripped through zlib and back to a dict, byte for byte.
|
||||
assert r.json()["system"] == BIG_SNAPSHOT["system"]
|
||||
assert r.json()["story"] == BIG_SNAPSHOT["story"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ backfill
|
||||
|
||||
def as_json_snapshot_column(db) -> None:
|
||||
"""Put actions.context_snapshot back as JSON, the way it was before 43.
|
||||
|
||||
Migration 36 lifts world_delta out of the snapshot with SQL JSON
|
||||
functions, so it can only run while the column still *is* JSON. In a real
|
||||
upgrade it always is, because 36 runs seven migrations before 43 compresses
|
||||
the column into a BLOB. `create_all` builds today's schema, so a test that
|
||||
calls that backfill has to rebuild the schema it was written against.
|
||||
"""
|
||||
db.execute(text("ALTER TABLE actions DROP COLUMN context_snapshot"))
|
||||
db.execute(text("ALTER TABLE actions ADD COLUMN context_snapshot JSON"))
|
||||
db.execute(
|
||||
text("UPDATE actions SET context_snapshot = :snapshot"),
|
||||
{"snapshot": json.dumps(BIG_SNAPSHOT)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_backfill_populates_world_delta_from_existing_snapshots(client):
|
||||
"""Migration 36 lifts the slice out server-side, without reading the
|
||||
snapshots into Python."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
as_json_snapshot_column(db)
|
||||
db.execute(text("UPDATE actions SET world_delta = NULL"))
|
||||
db.commit()
|
||||
assert db.query(models.Action).filter(models.Action.world_delta.isnot(None)).count() == 0
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_world_delta(conn)
|
||||
|
||||
db.expire_all()
|
||||
actions = db.query(models.Action).all()
|
||||
assert all(a.world_delta is not None for a in actions)
|
||||
assert actions[0].world_delta["delta"] == {"player.hp": -15}
|
||||
assert actions[0].world_delta["applied"] == [
|
||||
{"path": "player.hp", "old": 100, "new": 85}
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_backfill_populates_variant_count_from_existing_variants(client):
|
||||
"""Migration 37 counts the lists on the server.
|
||||
|
||||
Reading them into Python to count them would fetch the column over the wire
|
||||
once in order to stop fetching it on every request.
|
||||
|
||||
Migration 68 drops `variant_count` and 66 drops `variants`, so this test
|
||||
puts both columns back before it calls the pass, the same way
|
||||
`as_json_snapshot_column` above rebuilds the column migration 36 needs. The
|
||||
assertions read raw SQL, because the model no longer has either attribute.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(text("ALTER TABLE actions ADD COLUMN variants JSON"))
|
||||
db.execute(text(
|
||||
"ALTER TABLE actions ADD COLUMN variant_count INTEGER NOT NULL DEFAULT 0"
|
||||
))
|
||||
db.execute(
|
||||
text("UPDATE actions SET variants = :v WHERE type = 'ai'"),
|
||||
{"v": json.dumps(BIG_VARIANTS)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_variant_count(conn)
|
||||
|
||||
rows = db.execute(text(
|
||||
"SELECT type, variant_count FROM actions ORDER BY id"
|
||||
)).all()
|
||||
assert rows, "fixture should have actions"
|
||||
for kind, count in rows:
|
||||
assert count == (len(BIG_VARIANTS) if kind == "ai" else 0)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_backfill_leaves_actions_without_world_state_alone(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(text("UPDATE actions SET world_delta = NULL, context_snapshot = '{\"story\": \"s\"}'"))
|
||||
db.commit()
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_world_delta(conn)
|
||||
db.expire_all()
|
||||
assert all(a.world_delta is None for a in db.query(models.Action).all())
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- byte ceilings
|
||||
#
|
||||
# The tests above assert which columns a statement names, which is the shape both
|
||||
# of this project's egress regressions took. They would all still pass if a
|
||||
# response grew tenfold within the columns it is allowed to read, and a story
|
||||
# that keeps getting longer does that. These tests put a number on it.
|
||||
#
|
||||
# The ceilings are per action rather than absolute, so they mean the same thing
|
||||
# whatever size the fixture is, and they are generous. They exist to catch a
|
||||
# tenfold regression rather than to freeze today's byte count.
|
||||
|
||||
ACTIONS_IN_FIXTURE = 12
|
||||
|
||||
# 3 kB an action against a real 994 B, measured on production 2026-08-17.
|
||||
# Anything that pulls a deferred column blows past this by two orders of
|
||||
# magnitude. See `test_the_ceiling_discriminates` below.
|
||||
PAGE_LOAD_BYTES_PER_ACTION = 3_000
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meter():
|
||||
"""A byte meter on the shared engine, removed again afterwards.
|
||||
|
||||
A test requests this after `client` in its arguments, so that building the
|
||||
fixture, which is a write path no player takes, is not charged to any
|
||||
scope.
|
||||
"""
|
||||
m = dbmeter.Meter()
|
||||
m.attach(engine)
|
||||
try:
|
||||
yield m
|
||||
finally:
|
||||
m.detach()
|
||||
|
||||
|
||||
def fetched(meter) -> int:
|
||||
return meter.scopes[-1].total.fetched
|
||||
|
||||
|
||||
def test_page_load_stays_under_its_byte_ceiling(client, meter):
|
||||
with meter.scope("page load"):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
assert r.status_code == 200
|
||||
|
||||
budget = ACTIONS_IN_FIXTURE * PAGE_LOAD_BYTES_PER_ACTION
|
||||
assert fetched(meter) < budget, (
|
||||
f"page load fetched {fetched(meter):,} B for {ACTIONS_IN_FIXTURE} "
|
||||
f"actions, over the {budget:,} B budget"
|
||||
)
|
||||
|
||||
|
||||
def test_the_action_list_stays_under_its_byte_ceiling(client, meter):
|
||||
with meter.scope("action list"):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/actions")
|
||||
assert r.status_code == 200
|
||||
|
||||
budget = ACTIONS_IN_FIXTURE * PAGE_LOAD_BYTES_PER_ACTION
|
||||
assert fetched(meter) < budget, (
|
||||
f"the action list fetched {fetched(meter):,} B, over {budget:,} B"
|
||||
)
|
||||
|
||||
|
||||
def test_reading_one_action_does_not_cost_the_whole_story(client, meter):
|
||||
"""The snapshot is reachable on demand, and that request pays for one row
|
||||
rather than for the whole adventure."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
action_id = db.query(models.Action.id).order_by(models.Action.id).first()[0]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with meter.scope("one snapshot"):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/actions/{action_id}/context")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
one_snapshot = len(json.dumps(BIG_SNAPSHOT))
|
||||
assert fetched(meter) < one_snapshot * 2, (
|
||||
f"fetching one action's snapshot cost {fetched(meter):,} B; one "
|
||||
f"snapshot is {one_snapshot:,} B"
|
||||
)
|
||||
|
||||
|
||||
def _fat_adventures(user_id: int, count: int = 5, body: int = 20_000) -> None:
|
||||
"""Adventures whose bodies are heavy and whose index cards are not.
|
||||
|
||||
script_state, world_state and story_summary belong to the play screen. The
|
||||
index shows a title, a stamp and a snippet, and used to load all of it.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for i in range(count):
|
||||
db.add(models.Adventure(
|
||||
user_id=user_id,
|
||||
title=f"Adventure {i}",
|
||||
script_state={"log": "s" * body},
|
||||
world_state={"player": {"notes": "w" * body}},
|
||||
story_summary="y" * body,
|
||||
memory="m" * body,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_index_does_not_read_the_adventure_body(client, sql_log):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_id = db.query(models.User.id).first()[0]
|
||||
finally:
|
||||
db.close()
|
||||
_fat_adventures(user_id)
|
||||
|
||||
r = client.get("/api/adventures")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 6 # the fixture's one, plus five
|
||||
|
||||
listing = [
|
||||
s for s in sql_log
|
||||
if "FROM adventures" in s and s.lstrip().upper().startswith("SELECT")
|
||||
]
|
||||
assert listing, "expected a listing query"
|
||||
for column in ("script_state", "world_state", "story_summary", "memory",
|
||||
"authors_note", "ai_instructions", "placeholders"):
|
||||
assert not any(column in s for s in listing), (
|
||||
f"the index read adventures.{column}, which nothing on that "
|
||||
f"screen displays"
|
||||
)
|
||||
|
||||
|
||||
def test_the_index_stays_under_its_byte_ceiling(client, meter):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_id = db.query(models.User.id).first()[0]
|
||||
finally:
|
||||
db.close()
|
||||
_fat_adventures(user_id)
|
||||
|
||||
with meter.scope("index"):
|
||||
r = client.get("/api/adventures")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Six adventures carrying 80 kB of body each. A card is a title, a stamp
|
||||
# and a 220-character snippet; 4 kB apiece is already generous.
|
||||
budget = 6 * 4_000
|
||||
assert fetched(meter) < budget, (
|
||||
f"the index fetched {fetched(meter):,} B for six adventures, over "
|
||||
f"{budget:,} B — it is reading the bodies again"
|
||||
)
|
||||
|
||||
|
||||
def test_the_ceiling_discriminates(client, meter):
|
||||
"""A ceiling is only worth having if the thing it excludes would breach it.
|
||||
|
||||
This is the regression the byte tests exist to catch, performed on purpose:
|
||||
undefer the snapshot and the same twelve rows cost several times the whole
|
||||
budget. If this ever stops exceeding it, the fixture has gone too small for
|
||||
the tests above to mean anything.
|
||||
|
||||
The margin used to be a hundredfold and is now about six. The guard has not
|
||||
weakened. Migration 43 compresses the column, and the fixture text is
|
||||
prose-shaped, so it compresses like a real prompt rather than like a repeated
|
||||
character.
|
||||
"""
|
||||
budget = ACTIONS_IN_FIXTURE * PAGE_LOAD_BYTES_PER_ACTION
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with meter.scope("undeferred"):
|
||||
rows = (
|
||||
db.query(models.Action)
|
||||
.options(undefer(models.Action.context_snapshot))
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == ACTIONS_IN_FIXTURE
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert fetched(meter) > budget * 3, (
|
||||
"undeferring the snapshot cost only "
|
||||
f"{fetched(meter):,} B against a {budget:,} B budget — the fixture is "
|
||||
"too small for the byte ceilings above to catch anything"
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Migration 38: embeddings move from a JSON list to packed float32.
|
||||
|
||||
The conversion has to be exact, because nothing re-embeds. A memory whose
|
||||
vector shifts is silently ranked wrong forever, with no error anywhere to
|
||||
report it. These tests check that the numbers survive the round trip bit
|
||||
for bit, and that the migration reaches every row, however many there are.
|
||||
|
||||
python -m pytest tests/test_embedding_blob.py -v
|
||||
"""
|
||||
import struct
|
||||
|
||||
|
||||
import json
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import memorybank, migrations, models, vectors
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from tests import schema_rewind
|
||||
|
||||
|
||||
def float32(value: float) -> float:
|
||||
"""`value` as the double nearest to its float32 truncation. This is
|
||||
what an embedding endpoint's JSON actually holds."""
|
||||
return struct.unpack("<f", struct.pack("<f", value))[0]
|
||||
|
||||
|
||||
def sample_vector(rng: random.Random, dims: int = 1536) -> list[float]:
|
||||
return [float32(rng.uniform(-1.0, 1.0)) for _ in range(dims)]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adventure(db):
|
||||
user = models.User(is_guest=False, email="vectors@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
adv = models.Adventure(user_id=user.id, title="Cave", script_state={})
|
||||
db.add(adv)
|
||||
db.commit()
|
||||
return adv
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- packing
|
||||
|
||||
def test_pack_round_trips_exactly():
|
||||
"""Not "close enough": embedding endpoints compute in float32 and render
|
||||
that into JSON, so packing back to float32 must be lossless."""
|
||||
rng = random.Random(1)
|
||||
vector = sample_vector(rng)
|
||||
assert list(vectors.unpack(vectors.pack(vector))) == vector
|
||||
|
||||
|
||||
def test_packed_vector_is_four_bytes_per_dimension():
|
||||
"""1536 dims takes 6 KB packed, against about 31 KB as JSON."""
|
||||
vector = sample_vector(random.Random(2))
|
||||
blob = vectors.pack(vector)
|
||||
assert len(blob) == 1536 * 4
|
||||
assert len(blob) < len(json.dumps(vector).encode()) / 4
|
||||
|
||||
|
||||
def test_pack_handles_the_extremes():
|
||||
vector = [float32(v) for v in (0.0, -0.0, 1.0, -1.0, 3.4028234663852886e38, 1e-38)]
|
||||
assert list(vectors.unpack(vectors.pack(vector))) == vector
|
||||
|
||||
|
||||
def test_unpack_returns_a_compact_array():
|
||||
"""These vectors stay in memory between turns, so the container type
|
||||
matters. An array("f") stores each component in 4 bytes, the same width
|
||||
as the column. A list of Python floats uses eight times that."""
|
||||
vector = sample_vector(random.Random(9))
|
||||
unpacked = vectors.unpack(vectors.pack(vector))
|
||||
assert unpacked.typecode == "f"
|
||||
assert unpacked.itemsize == 4
|
||||
assert len(unpacked) == len(vector)
|
||||
|
||||
|
||||
def test_pack_rounds_a_value_float32_cannot_hold():
|
||||
"""The exactness guarantee applies only to vectors that came from an
|
||||
embedding model, which computes in float32, not to arbitrary doubles.
|
||||
This test pins down that boundary, because it is where the round-trip
|
||||
claim holds."""
|
||||
assert vectors.unpack(vectors.pack([1e-38]))[0] != 1e-38
|
||||
assert vectors.unpack(vectors.pack([1e-38]))[0] == pytest.approx(1e-38)
|
||||
|
||||
|
||||
def test_cosine_moved_but_still_reachable_from_memorybank():
|
||||
"""Callers import it from memorybank. The math lives in vectors."""
|
||||
assert memorybank.cosine is vectors.cosine
|
||||
assert vectors.cosine([1.0, 0.0], [1.0, 0.0]) == pytest.approx(1.0)
|
||||
assert vectors.cosine([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0)
|
||||
assert vectors.cosine([1.0, 0.0], [1.0, 0.0, 0.0]) == 0.0 # length mismatch
|
||||
|
||||
|
||||
# -------------------------------------------------------------- set_vector
|
||||
|
||||
def test_set_vector_writes_the_blob_and_the_flag(db, adventure):
|
||||
"""The two columns that describe a vector move together, or a reader that
|
||||
trusts `embedded` gets a NULL blob."""
|
||||
memory = models.Memory(adventure_id=adventure.id, text="a fact")
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
|
||||
vector = sample_vector(random.Random(3), dims=8)
|
||||
memorybank.set_vector(memory, vector)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
assert list(vectors.unpack(memory.embedding_blob)) == vector
|
||||
assert memory.embedded is True
|
||||
|
||||
|
||||
def test_set_vector_none_clears_both(db, adventure):
|
||||
"""Editing a memory's text drops its vector so the next pass re-embeds. A
|
||||
blob left behind would keep ranking the old text."""
|
||||
memory = models.Memory(adventure_id=adventure.id, text="a fact")
|
||||
db.add(memory)
|
||||
memorybank.set_vector(memory, sample_vector(random.Random(4), dims=8))
|
||||
db.commit()
|
||||
|
||||
memorybank.set_vector(memory, None)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
assert memory.embedding_blob is None
|
||||
assert memory.embedded is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the backfill
|
||||
|
||||
def add_legacy_json_column(db) -> None:
|
||||
"""Put `memories.embedding` back for the length of a test.
|
||||
|
||||
Migration 42 dropped it, and the model no longer declares it, so
|
||||
`create_all` does not produce it. Everything below tests the upgrade
|
||||
from a database that still has the column, which is the only state
|
||||
where the backfill has any work to do. Re-adding it by hand keeps these
|
||||
tests honest about the schema they claim to start from.
|
||||
"""
|
||||
db.execute(text("ALTER TABLE memories ADD COLUMN embedding JSON"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_json_only(db, adventure, count: int, dims: int = 64) -> dict[int, list[float]]:
|
||||
"""Memories as they exist before the migration: JSON vector, no blob."""
|
||||
add_legacy_json_column(db)
|
||||
rng = random.Random(count)
|
||||
expected = {}
|
||||
for i in range(count):
|
||||
vector = sample_vector(rng, dims)
|
||||
memory = models.Memory(adventure_id=adventure.id, text=f"fact {i}")
|
||||
db.add(memory)
|
||||
db.flush()
|
||||
# Raw, because the ORM no longer knows this column exists.
|
||||
db.execute(
|
||||
text("UPDATE memories SET embedding = :v WHERE id = :id"),
|
||||
{"v": json.dumps(vector), "id": memory.id},
|
||||
)
|
||||
expected[memory.id] = vector
|
||||
db.commit()
|
||||
db.execute(text("UPDATE memories SET embedding_blob = NULL, embedded = false"))
|
||||
db.commit()
|
||||
return expected
|
||||
|
||||
|
||||
def test_backfill_converts_every_existing_vector(db, adventure):
|
||||
expected = seed_json_only(db, adventure, count=5)
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
|
||||
db.expire_all()
|
||||
for memory in db.query(models.Memory).all():
|
||||
assert list(vectors.unpack(memory.embedding_blob)) == expected[memory.id]
|
||||
|
||||
|
||||
def test_backfill_reaches_past_one_batch(db, adventure):
|
||||
"""It loops on id, and an off-by-one there would silently leave the
|
||||
tail of a big bank unconverted. That failure reads as "not embedded
|
||||
yet"."""
|
||||
count = migrations.BACKFILL_BATCH * 2 + 3
|
||||
expected = seed_json_only(db, adventure, count=count, dims=4)
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
|
||||
db.expire_all()
|
||||
memories = db.query(models.Memory).all()
|
||||
assert len(memories) == count
|
||||
assert all(m.embedding_blob is not None for m in memories)
|
||||
assert all(list(vectors.unpack(m.embedding_blob)) == expected[m.id] for m in memories)
|
||||
|
||||
|
||||
def test_backfill_leaves_unembedded_memories_alone(db, adventure):
|
||||
add_legacy_json_column(db)
|
||||
db.add(models.Memory(adventure_id=adventure.id, text="not embedded yet"))
|
||||
db.commit()
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
|
||||
db.expire_all()
|
||||
assert db.query(models.Memory).one().embedding_blob is None
|
||||
|
||||
|
||||
def test_backfill_is_idempotent(db, adventure):
|
||||
"""It runs once from bootstrap, but a half-finished run must be safe to
|
||||
repeat, and rows already converted must not be rewritten."""
|
||||
seed_json_only(db, adventure, count=3)
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
db.expire_all()
|
||||
first = {m.id: m.embedding_blob for m in db.query(models.Memory).all()}
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
db.expire_all()
|
||||
assert {m.id: m.embedding_blob for m in db.query(models.Memory).all()} == first
|
||||
|
||||
|
||||
def test_backfill_skips_a_malformed_row_without_stopping(db, adventure):
|
||||
"""One bad row must not strand every row after it. The loop orders by
|
||||
id, so an exception here would leave the rest of the bank unconverted."""
|
||||
expected = seed_json_only(db, adventure, count=2)
|
||||
broken = models.Memory(adventure_id=adventure.id, text="broken")
|
||||
db.add(broken)
|
||||
db.commit()
|
||||
db.execute(
|
||||
text("UPDATE memories SET embedding = :bad WHERE id = :id"),
|
||||
{"bad": '"not a list"', "id": broken.id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with engine.begin() as conn:
|
||||
migrations._backfill_embedding_blob(conn)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(models.Memory, broken.id).embedding_blob is None
|
||||
for memory_id, vector in expected.items():
|
||||
blob = db.get(models.Memory, memory_id).embedding_blob
|
||||
assert list(vectors.unpack(blob)) == vector
|
||||
|
||||
|
||||
# ------------------------------------------------------- the upgrade in full
|
||||
|
||||
def test_bootstrap_adds_the_columns_and_backfills_them(db, adventure):
|
||||
"""The path a deployed database actually takes: sitting at 37 with neither
|
||||
new column, then started on this build."""
|
||||
expected = seed_json_only(db, adventure, count=4)
|
||||
unembedded = models.Memory(adventure_id=adventure.id, text="no vector yet")
|
||||
db.add(unembedded)
|
||||
db.commit()
|
||||
unembedded_id = unembedded.id
|
||||
db.close()
|
||||
|
||||
schema_rewind.rewind_to(engine, migrations.EMBEDDING_BLOB_VERSION - 1)
|
||||
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
with engine.begin() as conn:
|
||||
assert conn.execute(text("PRAGMA user_version")).scalar() == migrations.LATEST_VERSION
|
||||
rows = conn.execute(text("SELECT id, embedding_blob, embedded FROM memories")).all()
|
||||
by_id = {row[0]: (row[1], row[2]) for row in rows}
|
||||
assert len(by_id) == len(expected) + 1
|
||||
for memory_id, vector in expected.items():
|
||||
blob, embedded = by_id[memory_id]
|
||||
assert list(vectors.unpack(blob)) == vector
|
||||
assert embedded
|
||||
# The flag has to follow the vector, not the row: a memory that was never
|
||||
# embedded must still read as not embedded afterwards.
|
||||
assert by_id[unembedded_id] == (None, False)
|
||||
|
||||
# Migration 42, at the end of the same run, removes the JSON column.
|
||||
# Ordering matters: 38 reads it, 42 drops it, and an upgrade that ran
|
||||
# them in the other order would arrive with an empty bank.
|
||||
with engine.begin() as conn:
|
||||
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(memories)"))}
|
||||
assert "embedding" not in columns
|
||||
assert {"embedding_blob", "embedded"} <= columns
|
||||
|
||||
|
||||
def test_migration_38_is_spelled_for_both_dialects():
|
||||
"""Every Postgres deploy replays migrations from 24 on, so a SQLite-only
|
||||
ALTER here would break the live database and nothing else would notice."""
|
||||
sql = dict(migrations.MIGRATIONS)[migrations.EMBEDDING_BLOB_VERSION]
|
||||
assert "BLOB" in migrations._for_dialect(sql, "sqlite")
|
||||
assert "BYTEA" in migrations._for_dialect(sql, "postgresql")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Switching embedding models must re-embed the bank.
|
||||
|
||||
Vectors from two different models are not comparable. They live in
|
||||
different spaces and often have different widths. Changing the model must
|
||||
discard the stored vectors and let the post-turn pass rebuild them.
|
||||
|
||||
This worked while the vectors lived in `memories.embedding`. The settings
|
||||
route nulled that column, and the embed queue picked up the rows. Migration
|
||||
38 moved the vectors to `embedding_blob` and added an `embedded` flag beside
|
||||
them, but the bulk clear kept nulling only the old column. The blob
|
||||
survived, the flag stayed true, and `_embed_pending` (which filters on
|
||||
`embedded IS FALSE`) never saw the rows. The bank kept ranking against the
|
||||
previous model's vectors.
|
||||
|
||||
Nothing reports this failure. `cosine` returns 0.0 on a width mismatch, so a
|
||||
different-width model scores every memory zero, and retrieval silently
|
||||
returns whichever rows sort first. A same-width model scores plausible
|
||||
garbage instead.
|
||||
|
||||
python -m pytest tests/test_embedding_model_switch.py -v
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, memorybank, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
|
||||
DIMS = 8
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
memorybank._vector_cache.clear()
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="switch@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, api_key="enc:dummy", model="test-model",
|
||||
embedding_model="model-a",
|
||||
))
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Cave", script_state={}, memory_bank_enabled=True
|
||||
)
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
for i in range(5):
|
||||
memory = models.Memory(adventure_id=adventure.id, text=f"Memory {i}")
|
||||
memorybank.set_vector(memory, [float(i)] + [0.0] * (DIMS - 1))
|
||||
setup.add(memory)
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
memorybank._vector_cache.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def memories(db):
|
||||
return db.query(models.Memory).order_by(models.Memory.id).all()
|
||||
|
||||
|
||||
def test_the_bank_starts_embedded(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = memories(db)
|
||||
assert len(rows) == 5
|
||||
assert all(m.embedded for m in rows)
|
||||
assert all(m.embedding_blob for m in rows)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_changing_the_model_clears_every_vector(client):
|
||||
r = client.put("/api/settings", json={"embedding_model": "model-b"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = memories(db)
|
||||
assert [m.embedding_blob for m in rows] == [None] * 5, \
|
||||
"the blob survived the model change"
|
||||
assert not any(m.embedded for m in rows), \
|
||||
"`embedded` stayed true, so nothing will ever re-embed these"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_cleared_memories_are_queued_for_re_embedding(client):
|
||||
"""The `embedded` flag is not cosmetic. It is the only condition
|
||||
`_embed_pending` filters on, so this test confirms the bank actually
|
||||
recovers."""
|
||||
client.put("/api/settings", json={"embedding_model": "model-b"})
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
pending = (
|
||||
db.query(models.Memory)
|
||||
.filter(models.Memory.embedded.is_(False),
|
||||
models.Memory.forgotten.is_(False))
|
||||
.all()
|
||||
)
|
||||
assert len(pending) == 5
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_retrieval_uses_no_stale_vector_after_the_switch(client, monkeypatch):
|
||||
"""Until the re-embed runs, the bank must return nothing rather than
|
||||
ranking against the old model's vectors."""
|
||||
client.put("/api/settings", json={"embedding_model": "model-b"})
|
||||
|
||||
class Embedder:
|
||||
async def embed(self, texts):
|
||||
return [[1.0] + [0.0] * (DIMS - 1) for _ in texts]
|
||||
|
||||
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: Embedder())
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
settings = db.query(models.Settings).first()
|
||||
result = asyncio.run(
|
||||
memorybank.retrieve_memories(adventure, settings, update_stats=False)
|
||||
)
|
||||
assert result["used"] == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_an_unrelated_settings_change_keeps_the_vectors(client):
|
||||
"""Only an embedding-model change may clear the bank. Re-embedding costs
|
||||
an API call per memory."""
|
||||
r = client.put("/api/settings", json={"model": "some-other-chat-model"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = memories(db)
|
||||
assert all(m.embedded for m in rows)
|
||||
assert all(m.embedding_blob for m in rows)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Which inference endpoints the product will and will not talk to.
|
||||
|
||||
The endpoint is where the whole campaign goes: prompts, narration, retrieved
|
||||
memories, embedding inputs. `app/endpoints.py` is the rule that keeps that on
|
||||
the user's own infrastructure, and these tests are what stops the rule quietly
|
||||
widening.
|
||||
|
||||
Nothing here touches the network. Hostnames are resolved through a stub, so the
|
||||
tests are about the *policy*, run identically offline, and cannot be made to
|
||||
pass or fail by whatever DNS the machine happens to have.
|
||||
|
||||
python -m pytest tests/test_endpoint_policy.py -v
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, endpoints, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.providers import OpenAICompatibleProvider, ProviderError
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def resolves(monkeypatch):
|
||||
"""Points every hostname at addresses the test names."""
|
||||
table: dict[str, list[str]] = {}
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **k):
|
||||
# An address literal resolves to itself, as the real resolver does.
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
if host not in table:
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
answers = table[host]
|
||||
else:
|
||||
answers = [host]
|
||||
return [
|
||||
(
|
||||
socket.AF_INET6 if ":" in ip else socket.AF_INET,
|
||||
socket.SOCK_STREAM, 6, "", (ip, port or 0),
|
||||
)
|
||||
for ip in answers
|
||||
]
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
return table
|
||||
|
||||
|
||||
# --- allowed: the user's own machine, and the user's own network ----------
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://127.0.0.1:11434/v1",
|
||||
"http://[::1]:11434/v1",
|
||||
"http://192.168.1.50:11434/v1", # RFC1918
|
||||
"http://10.0.0.7:11434/v1", # RFC1918
|
||||
"http://172.16.4.4:11434/v1", # RFC1918
|
||||
"https://192.168.1.50:8443/v1", # TLS on the LAN
|
||||
"http://100.100.5.6:11434/v1", # CGNAT, which is what a mesh VPN hands out
|
||||
"http://[fd00::1]:11434/v1", # IPv6 unique-local
|
||||
])
|
||||
def test_local_and_lan_addresses_are_allowed(url, resolves):
|
||||
assert endpoints.rejection_reason(url) is None
|
||||
|
||||
|
||||
def test_a_hostname_resolving_to_the_lan_is_allowed(resolves):
|
||||
resolves["ollama.home.arpa"] = ["192.168.1.50"]
|
||||
assert endpoints.rejection_reason("https://ollama.home.arpa:8443/v1") is None
|
||||
|
||||
|
||||
# --- refused: anywhere else ----------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://8.8.8.8:11434/v1",
|
||||
"http://1.1.1.1:11434/v1",
|
||||
"http://[2001:4860:4860::8888]:11434/v1",
|
||||
])
|
||||
def test_public_addresses_are_refused(url, resolves):
|
||||
reason = endpoints.rejection_reason(url)
|
||||
assert reason is not None
|
||||
assert "public Internet address" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://198.51.100.9/v1", # TEST-NET-2, a documentation range
|
||||
"http://0.0.0.0:11434/v1", # "this host", not an address to dial
|
||||
])
|
||||
def test_addresses_that_are_neither_local_nor_public_are_refused(url, resolves):
|
||||
"""Deny by default. `ipaddress` calls both of these "private", which is why
|
||||
the policy names the networks it allows instead of asking that question."""
|
||||
assert endpoints.rejection_reason(url) is not None
|
||||
|
||||
|
||||
def test_a_hostname_resolving_to_the_public_internet_is_refused(resolves):
|
||||
resolves["ollama.example.com"] = ["93.184.216.34"]
|
||||
reason = endpoints.rejection_reason("https://ollama.example.com/v1")
|
||||
assert reason is not None and "public Internet address" in reason
|
||||
|
||||
|
||||
def test_a_split_horizon_answer_is_refused(resolves):
|
||||
"""One public address among the answers is enough. A name that resolves to
|
||||
both a LAN address and a public one must not be usable: which one the
|
||||
request actually reaches is not ours to decide."""
|
||||
resolves["sneaky.example"] = ["192.168.1.50", "93.184.216.34"]
|
||||
assert endpoints.rejection_reason("http://sneaky.example:11434/v1") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"openrouter.ai", "api.openai.com", "api.anthropic.com", "api.groq.com",
|
||||
])
|
||||
def test_known_cloud_providers_are_named_in_the_refusal(host, resolves):
|
||||
"""They would be refused by address anyway — every one resolves publicly.
|
||||
Naming them turns 'your DNS might be broken' into 'this build has no cloud
|
||||
provider support'."""
|
||||
resolves[host] = ["192.168.1.50"] # even if DNS said otherwise
|
||||
reason = endpoints.rejection_reason(f"https://{host}/v1")
|
||||
assert reason is not None
|
||||
assert "cloud inference service" in reason
|
||||
|
||||
|
||||
def test_a_subdomain_of_a_cloud_provider_is_refused(resolves):
|
||||
resolves["eu.api.openai.com"] = ["192.168.1.50"]
|
||||
assert endpoints.rejection_reason("https://eu.api.openai.com/v1") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"ftp://127.0.0.1/v1",
|
||||
"file:///etc/passwd",
|
||||
"127.0.0.1:11434", # no scheme
|
||||
"",
|
||||
"http://",
|
||||
])
|
||||
def test_things_that_are_not_usable_urls_are_refused(url, resolves):
|
||||
assert endpoints.rejection_reason(url) is not None
|
||||
|
||||
|
||||
def test_an_unresolvable_host_is_refused_with_advice(resolves):
|
||||
reason = endpoints.rejection_reason("http://nope.invalid:11434/v1")
|
||||
assert reason is not None and "could not be resolved" in reason
|
||||
|
||||
|
||||
# --- the rule is applied, not merely available ---------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def test_saving_a_public_endpoint_is_refused(client, resolves):
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
r = client.put("/api/settings", json={"endpoint_url": "https://openrouter.ai/api/v1"})
|
||||
assert r.status_code == 400, r.text
|
||||
assert "can't be used" in r.json()["detail"]
|
||||
# And it was not written.
|
||||
assert client.get("/api/settings").json()["endpoint_url"] != "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_saving_a_lan_endpoint_is_allowed(client, resolves):
|
||||
r = client.put("/api/settings", json={"endpoint_url": "http://192.168.1.50:11434/v1"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/api/settings").json()["endpoint_url"] == "http://192.168.1.50:11434/v1"
|
||||
|
||||
|
||||
def test_the_connection_test_reports_a_refused_endpoint_as_such(client, resolves):
|
||||
"""A row edited by hand, or a name that has started resolving elsewhere,
|
||||
must not simply look 'unreachable'."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(models.Settings).first().endpoint_url = "https://openrouter.ai/api/v1"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
body = client.post("/api/settings/test").json()
|
||||
assert body["ok"] is False
|
||||
assert body["kind"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_the_provider_refuses_before_it_sends_anything(resolves):
|
||||
"""The check that actually matters. Whatever is stored, no request leaves
|
||||
for an address outside the policy — so a database edited behind the app's
|
||||
back cannot turn into an exfiltration path."""
|
||||
resolves["openrouter.ai"] = ["93.184.216.34"]
|
||||
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "m")
|
||||
with pytest.raises(ProviderError) as exc:
|
||||
async for _ in provider.generate(
|
||||
_parts(), temperature=0.8, max_tokens=10
|
||||
):
|
||||
pass
|
||||
assert "can't be used" in str(exc.value)
|
||||
|
||||
|
||||
def _parts():
|
||||
from app.providers.base import PromptParts
|
||||
return PromptParts(system="s", story="t")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""The context builder reads a window of the story, not all of it.
|
||||
|
||||
Walking `adventure.actions` every turn made the turn cost O(story length).
|
||||
A long adventure read hundreds of KB to use only the tail of it, and the
|
||||
cost grew with every turn played. `app.context.history` serves tails,
|
||||
slices, and counts from SQL instead.
|
||||
|
||||
Two things must hold, and both are easy to break by accident:
|
||||
|
||||
* The window must produce exactly the prompt the full story produced.
|
||||
Otherwise, the change alters behavior even though it looks like a pure
|
||||
optimization.
|
||||
* The helpers must agree with the old list arithmetic, because
|
||||
memorybank's cursors are positions in that list. A cursor off by one
|
||||
silently summarizes the wrong actions.
|
||||
|
||||
python -m pytest tests/test_history_window.py -v
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
|
||||
from app import memorybank, models
|
||||
from app.context import builder, history
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
# Long enough that a window is much smaller than the whole story.
|
||||
ACTION_COUNT = 200
|
||||
NARRATION = (
|
||||
"The scrub gives way to a shallow bowl of land where woodsmoke hangs in "
|
||||
"flat grey layers, and somewhere behind the largest tent a woman is "
|
||||
"arguing, low and fast. "
|
||||
) * 3
|
||||
|
||||
SCHEMA = {
|
||||
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "desc": "Health"}},
|
||||
"npcs": {
|
||||
"gwen": {"name": "Gwen", "keys": ["gwen"], "desc": "A scout.",
|
||||
"stats": {"trust": {"min": 0, "max": 100, "initial": 30}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def story():
|
||||
"""An adventure with ACTION_COUNT actions, plus its settings."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="window@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
settings = models.Settings(user_id=user.id, api_key="enc:dummy", model="m")
|
||||
db.add(settings)
|
||||
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA,
|
||||
prompt="A long road." * 50)
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Long", scenario_id=scenario.id, script_state={},
|
||||
memory="The hero is hunting bandits. " * 20,
|
||||
world_state={"player": {"hp": 100}, "npc": {"gwen": {"trust": 30}},
|
||||
"milestones": {}, "flags": {}, "_meta": {"last_changed": {}}},
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="Gwen", keys="gwen",
|
||||
entry="A scout with sharp eyes.", type="lore"))
|
||||
for i in range(ACTION_COUNT):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do",
|
||||
text=f"[{i}] {NARRATION}",
|
||||
world_delta={"delta": {"player.hp": -1},
|
||||
"applied": [{"path": "player.hp", "old": 100, "new": 99}]},
|
||||
))
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
adventure = db.get(models.Adventure, adventure.id)
|
||||
settings = db.get(models.Settings, settings.id)
|
||||
try:
|
||||
yield db, adventure, settings
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def full_window(adventure, budget_tokens, token_counter, exclude_action_id=None):
|
||||
"""Stand-in for `window_covering` that returns the entire story. This is
|
||||
the behavior this module replaced."""
|
||||
return history.story_actions(adventure, exclude_action_id)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def actions_loaded():
|
||||
"""Counts the `Action` rows the ORM materializes, which shows how much of
|
||||
the story was actually fetched. `rowcount` is meaningless for a SELECT
|
||||
on SQLite, so this counts the objects the mapper builds instead."""
|
||||
loaded = {"n": 0}
|
||||
|
||||
def on_load(target, context):
|
||||
loaded["n"] += 1
|
||||
|
||||
event.listen(models.Action, "load", on_load)
|
||||
try:
|
||||
yield loaded
|
||||
finally:
|
||||
event.remove(models.Action, "load", on_load)
|
||||
|
||||
|
||||
# ------------------------------------------------------- the prompt is equal
|
||||
|
||||
@pytest.mark.parametrize("budget", [1024, 4096, 8192, 16384, 65536])
|
||||
def test_window_builds_the_same_prompt_as_the_whole_story(story, budget, monkeypatch):
|
||||
db, adventure, settings = story
|
||||
settings.context_token_budget = budget
|
||||
|
||||
windowed = builder.build_context(adventure, settings)
|
||||
monkeypatch.setattr(builder.history, "window_covering", full_window)
|
||||
db.expire(adventure)
|
||||
everything = builder.build_context(adventure, settings)
|
||||
|
||||
assert windowed[0] == everything[0], "system prompt differs"
|
||||
assert windowed[1] == everything[1], "story prompt differs"
|
||||
assert windowed[2]["history"] == everything[2]["history"]
|
||||
assert windowed[2]["cards"] == everything[2]["cards"]
|
||||
|
||||
|
||||
def test_window_matches_on_the_retry_shape(story, monkeypatch):
|
||||
"""Retry excludes the action being regenerated. The exclusion must reach
|
||||
the window query, not just the in-memory filter."""
|
||||
db, adventure, settings = story
|
||||
last = history.tail(adventure, 1)[0]
|
||||
|
||||
windowed = builder.build_context(adventure, settings, exclude_action_id=last.id)
|
||||
assert f"[{last.depth}]" not in windowed[1]
|
||||
|
||||
monkeypatch.setattr(builder.history, "window_covering", full_window)
|
||||
db.expire(adventure)
|
||||
everything = builder.build_context(adventure, settings, exclude_action_id=last.id)
|
||||
assert windowed[1] == everything[1]
|
||||
|
||||
|
||||
def test_reported_total_is_the_whole_story_not_the_window(story):
|
||||
"""Insights reports "N of M actions included." M must not become the
|
||||
window size."""
|
||||
db, adventure, settings = story
|
||||
settings.context_token_budget = 4096
|
||||
report = builder.build_context(adventure, settings)[2]
|
||||
assert report["history"]["total"] == ACTION_COUNT
|
||||
assert report["history"]["included"] < ACTION_COUNT
|
||||
|
||||
|
||||
# ------------------------------------------------------------ it is bounded
|
||||
|
||||
def test_building_context_reads_far_less_than_the_whole_story(story, actions_loaded):
|
||||
db, adventure, settings = story
|
||||
# Expire first. Expiring afterward would discard the unflushed change and
|
||||
# silently put the budget back to its default.
|
||||
db.expire_all()
|
||||
# Small enough that the budget, not the length of the story, decides.
|
||||
settings.context_token_budget = 4096
|
||||
actions_loaded["n"] = 0
|
||||
|
||||
report = builder.build_context(adventure, settings)[2]
|
||||
included = report["history"]["included"]
|
||||
|
||||
assert included < ACTION_COUNT, "fixture is too short to prove anything"
|
||||
# The window targets a margin past the budget and requests more if it
|
||||
# falls short, so it reads somewhat more than it includes. What matters
|
||||
# is that the read depends on the token budget, not on the length of
|
||||
# the story.
|
||||
assert actions_loaded["n"] < ACTION_COUNT // 2, (
|
||||
f"read {actions_loaded['n']} action rows out of {ACTION_COUNT} to "
|
||||
f"include {included} — the window is not bounding the read"
|
||||
)
|
||||
|
||||
|
||||
def test_window_is_ordered_and_free_of_duplicates(story):
|
||||
"""The window grows by fetching only what it does not already hold, so an
|
||||
off-by-one in the offset would show up as a repeated or missing action."""
|
||||
db, adventure, settings = story
|
||||
window = history.window_covering(adventure, 16384, builder.count_tokens)
|
||||
ids = [a.id for a in window]
|
||||
assert len(ids) == len(set(ids)), "the same action appeared twice in the window"
|
||||
assert ids == sorted(ids), "window must be oldest-first"
|
||||
|
||||
|
||||
# ------------------------------------------- the node-anchored reads agree
|
||||
|
||||
def test_helpers_agree_with_the_full_list(story):
|
||||
db, adventure, settings = story
|
||||
actions = history.story_actions(adventure)
|
||||
assert len(actions) == ACTION_COUNT
|
||||
|
||||
assert history.count(adventure) == len(actions)
|
||||
assert [a.id for a in history.tail(adventure, 4)] == [a.id for a in actions[-4:]]
|
||||
assert [a.id for a in history.slice_(adventure, 10, 6)] == [a.id for a in actions[10:16]]
|
||||
assert [a.id for a in history.tail_range(adventure, 5, 3)] == \
|
||||
[a.id for a in actions[-8:-5]]
|
||||
assert history.newest(adventure).id == actions[-1].id
|
||||
|
||||
for probe in (0, 1, ACTION_COUNT // 2, ACTION_COUNT - 1):
|
||||
boundary = actions[probe].depth
|
||||
assert history.count_after(adventure, boundary) == ACTION_COUNT - probe - 1
|
||||
assert [a.id for a in history.after(adventure, boundary, 3)] == \
|
||||
[a.id for a in actions[probe + 1:probe + 4]]
|
||||
|
||||
|
||||
def test_a_depth_boundary_survives_a_middle_action_being_deleted(story):
|
||||
"""The case that broke the cursors twice before. This is the reason the
|
||||
cursors are depths now.
|
||||
|
||||
A position answers "how much story is past this point?" by counting
|
||||
from the start. Deleting anything in front of the mark changes which
|
||||
action the mark names. A depth names the same node either way. The
|
||||
only thing that changes is the count of what comes after, and that
|
||||
count is the one thing that should change here.
|
||||
"""
|
||||
db, adventure, settings = story
|
||||
actions = history.story_actions(adventure)
|
||||
mark = actions[30].depth
|
||||
before = history.count_after(adventure, mark)
|
||||
next_three = [a.id for a in history.after(adventure, mark, 3)]
|
||||
|
||||
victim = actions[10] # in front of the mark
|
||||
db.delete(victim)
|
||||
db.commit()
|
||||
db.expire(adventure)
|
||||
|
||||
assert history.count(adventure) == ACTION_COUNT - 1
|
||||
assert history.count_after(adventure, mark) == before, "the mark moved"
|
||||
assert [a.id for a in history.after(adventure, mark, 3)] == next_three
|
||||
|
||||
# Deleting something after the mark is the one change that does affect
|
||||
# the count, because that count reflects the story, not the coordinate
|
||||
# system.
|
||||
db.delete(history.after(adventure, mark, 1)[0])
|
||||
db.commit()
|
||||
db.expire(adventure)
|
||||
assert history.count_after(adventure, mark) == before - 1
|
||||
|
||||
|
||||
def test_blank_actions_are_excluded_the_same_way_in_sql_and_python(story):
|
||||
"""SQL and Python must agree on membership or a cursor points elsewhere."""
|
||||
db, adventure, settings = story
|
||||
for blank in ("", " ", "\n", "\t\n "):
|
||||
db.add(models.Action(adventure_id=adventure.id,
|
||||
type="story", text=blank))
|
||||
db.commit()
|
||||
db.expire(adventure)
|
||||
|
||||
# SQL path (relationship not loaded)
|
||||
from_sql = history.count(adventure)
|
||||
# Python path (relationship loaded)
|
||||
adventure.actions # noqa: B018 - force the collection into memory
|
||||
from_python = history.count(adventure)
|
||||
|
||||
assert from_sql == from_python == ACTION_COUNT
|
||||
@@ -0,0 +1,245 @@
|
||||
"""The turn prompt asks for a turn that fits inside `max_output_tokens`.
|
||||
|
||||
`max_output_tokens` is a hard limit the endpoint enforces mid-sentence. The
|
||||
state block is emitted after the narration, so a long turn hits the limit
|
||||
partway through the block, and the deltas are lost. Nothing reads
|
||||
`finish_reason`, so this loss happens silently. The prompt now carries a
|
||||
word budget derived from the cap, so the model lands just inside it.
|
||||
|
||||
Two things are easy to break here:
|
||||
|
||||
* The hint must be stated in words, not tokens. A model cannot count its
|
||||
own tokens, and a hint it cannot follow is wasted budget.
|
||||
* The hint must not displace `EMIT_REMINDER` from the last position, which
|
||||
is the whole mechanism that keeps the state block emitted at all (see
|
||||
test_worldstate.py and the emit-reliability fix).
|
||||
|
||||
python -m pytest tests/test_length_hint.py -v
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from app import models, worldstate
|
||||
from app.context import builder
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
SCHEMA = {
|
||||
"player": {"hp": {"min": 0, "max": 100, "initial": 100, "desc": "Health"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def story():
|
||||
"""A short adventure, with and without a stat schema on demand."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="length@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
settings = models.Settings(user_id=user.id, api_key="enc:dummy", model="m")
|
||||
db.add(settings)
|
||||
scenario = models.Scenario(user_id=user.id, title="S", prompt="A road.")
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="A", scenario_id=scenario.id, script_state={},
|
||||
memory="The hero hunts bandits.",
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
for i in range(4):
|
||||
db.add(models.Action(adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do", text=f"[{i}] Onward."))
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
adventure = db.get(models.Adventure, adventure.id)
|
||||
settings = db.get(models.Settings, settings.id)
|
||||
try:
|
||||
yield db, adventure, settings, scenario.id
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def with_schema(db, scenario_id, adventure):
|
||||
scenario = db.get(models.Scenario, scenario_id)
|
||||
scenario.stat_schema = SCHEMA
|
||||
adventure.world_state = worldstate.instantiate(SCHEMA)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
|
||||
# ----------------------------------------------------- the hint itself
|
||||
|
||||
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
|
||||
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
assert "506" in hint
|
||||
assert "token" not in hint.lower(), "a model cannot count its own tokens"
|
||||
|
||||
|
||||
def test_budget_tracks_the_setting():
|
||||
small = builder.length_hint(800, has_ws=True)
|
||||
large = builder.length_hint(2400, has_ws=True)
|
||||
assert small != large
|
||||
assert "1586" in large
|
||||
|
||||
|
||||
def asked_words(cap):
|
||||
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
|
||||
|
||||
|
||||
def test_buffer_leaves_room_for_overshoot():
|
||||
"""The stated number must sit meaningfully under the real ceiling, or an
|
||||
on-target-but-slightly-long turn still hits the limit."""
|
||||
for cap in (400, 800, 1500, 2400):
|
||||
asked = asked_words(cap)
|
||||
ceiling = (cap - builder.LENGTH_HEADROOM) * builder.WORDS_PER_TOKEN
|
||||
assert asked < ceiling
|
||||
assert asked >= ceiling * 0.85, "buffer so large the hint wastes the cap"
|
||||
|
||||
|
||||
def test_hint_is_phrased_as_a_ceiling_not_a_budget():
|
||||
"""Measured: budget phrasing ("keep this turn under about N words")
|
||||
reads as a target to fill. It moved the mean turn from 174 to 246
|
||||
words, toward the limit it exists to avoid. The limit framing must
|
||||
survive future prompt edits."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
assert "must not exceed" in hint
|
||||
assert "under about" not in hint
|
||||
assert "lower end" in hint, "without this the number still reads as a target"
|
||||
|
||||
|
||||
def test_hint_states_a_floor_as_well_as_a_ceiling():
|
||||
"""A ceiling alone is one-sided: a terse model has nothing to act on but
|
||||
the "only as much as the moment needs" clause and produces only two
|
||||
paragraphs. The floor is what makes the same prompt produce a similar
|
||||
length across models with different tendencies."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
assert "506" in hint and "177" in hint
|
||||
assert "should not stop short of" in hint
|
||||
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
|
||||
# soft target, and neither is phrased as a specific number to reach.
|
||||
assert hint.index("must not exceed") < hint.index("should not stop short of")
|
||||
|
||||
|
||||
def test_floor_stays_well_under_the_ceiling():
|
||||
for cap in (400, 800, 1500, 2400):
|
||||
hint = builder.length_hint(cap, has_ws=True)
|
||||
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
|
||||
assert floor < ceiling * 0.5
|
||||
|
||||
|
||||
def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
|
||||
"""At a tight cap a short turn is the correct turn. The tight-cap
|
||||
wording is the one measured to keep the state block from being
|
||||
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
|
||||
is left exactly as it was."""
|
||||
hint = builder.length_hint(250, has_ws=True)
|
||||
assert "should not stop short of" not in hint
|
||||
assert "much shorter" in hint
|
||||
|
||||
|
||||
def test_floor_does_not_grow_without_bound():
|
||||
"""A big cap means "long turns are allowed", not "every turn must be an essay":
|
||||
the share alone would demand 555 words minimum at cap 2400."""
|
||||
hint = builder.length_hint(2400, has_ws=True)
|
||||
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
|
||||
|
||||
|
||||
def test_no_hint_when_the_cap_is_too_small_to_phrase():
|
||||
"""Under the floor the hint is noise the model pays for in context."""
|
||||
assert builder.length_hint(100, has_ws=True) == ""
|
||||
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
|
||||
assert builder.length_hint(0, has_ws=True) == ""
|
||||
|
||||
|
||||
def test_no_negative_word_budget():
|
||||
"""A cap below the headroom must not ask for a negative number of words."""
|
||||
for cap in (1, 10, 49, 51):
|
||||
assert builder.length_hint(cap, has_ws=True) == ""
|
||||
|
||||
|
||||
def test_reason_given_matches_whether_state_is_tracked():
|
||||
assert "state block" in builder.length_hint(800, has_ws=True)
|
||||
assert "state block" not in builder.length_hint(800, has_ws=False)
|
||||
|
||||
|
||||
# ----------------------------------------------------- in the assembled prompt
|
||||
|
||||
def test_hint_reaches_the_story_prompt(story):
|
||||
db, adventure, settings, _ = story
|
||||
settings.max_output_tokens = 800
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
|
||||
assert "506" in story_text
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
assert "length_hint" in labels
|
||||
|
||||
|
||||
def test_emit_reminder_keeps_the_last_word(story):
|
||||
"""The hint sits above the emit reminder: the reminder's whole value is the
|
||||
recency slot, and the model has to write the narration before the block."""
|
||||
db, adventure, settings, scenario_id = story
|
||||
with_schema(db, scenario_id, adventure)
|
||||
settings.max_output_tokens = 800
|
||||
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
|
||||
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
assert labels.index("length_hint") < labels.index("world_state_reminder")
|
||||
|
||||
|
||||
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
|
||||
"""Regression guard: the hint is appended after history has already
|
||||
spent the budget, so it must be reserved up front like `EMIT_REMINDER`
|
||||
is.
|
||||
|
||||
This check is weak on purpose. The history loop stops before crossing
|
||||
its budget, so it leaves about one action of slack, and the roughly
|
||||
30-token hint fits inside that slack. This catches a hint that grows
|
||||
large, not a missing reservation. The reservation itself is not
|
||||
observable from the outside."""
|
||||
db, adventure, settings, _ = story
|
||||
for i in range(4, 120):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id, type="ai" if i % 2 else "do",
|
||||
text=f"[{i}] " + "The road bends past the burnt mill and the smoke. " * 12,
|
||||
))
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
adventure = db.get(models.Adventure, adventure.id)
|
||||
|
||||
settings.max_output_tokens = 2400
|
||||
settings.context_token_budget = 2048
|
||||
|
||||
_, _, report = builder.build_context(adventure, settings)
|
||||
assert report["history"]["included"] < 120, "budget was never actually filled"
|
||||
assert report["tokens"]["total"] <= report["tokens"]["budget"]
|
||||
|
||||
|
||||
def test_hint_is_counted_in_the_reported_totals(story):
|
||||
"""Insights reports what the turn actually costs. A section that
|
||||
reaches the model but not the accounting makes that reported cost
|
||||
inaccurate."""
|
||||
db, adventure, settings, _ = story
|
||||
settings.max_output_tokens = 800
|
||||
|
||||
_, _, report = builder.build_context(adventure, settings)
|
||||
hint = next(s for s in report["sections"] if s["label"] == "length_hint")
|
||||
assert hint["tokens"] > 0
|
||||
assert hint["text"] in report["prompt"]["story"]
|
||||
assert builder.count_tokens(report["prompt"]["story"]) <= report["tokens"]["total"]
|
||||
|
||||
|
||||
def test_no_hint_section_when_the_cap_is_tiny(story):
|
||||
"""An empty hint drops out entirely rather than leaving a blank section."""
|
||||
db, adventure, settings, _ = story
|
||||
settings.max_output_tokens = 60
|
||||
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
assert "length_hint" not in [s["label"] for s in report["sections"]]
|
||||
assert "Keep this turn" not in story_text
|
||||
@@ -0,0 +1,269 @@
|
||||
"""What M2 removed stays removed, and what it must not break stays working.
|
||||
|
||||
A subtractive milestone needs tests that fail if the surface grows back. These
|
||||
are cheap, blunt, and deliberately not clever: they assert against the running
|
||||
app's route table, the shipped configuration files, and the settings API.
|
||||
|
||||
python -m pytest tests/test_local_only_surface.py -v
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.providers.openai_compatible import (
|
||||
CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, OpenAICompatibleProvider,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _paths() -> set[str]:
|
||||
"""Every path the app serves, including those inside included routers."""
|
||||
found = set()
|
||||
|
||||
def walk(routes):
|
||||
for r in routes:
|
||||
path = getattr(r, "path", None)
|
||||
if path:
|
||||
found.add(path)
|
||||
walk(getattr(r, "routes", []) or [])
|
||||
|
||||
walk(app.routes)
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False)
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
||||
setup.commit()
|
||||
user_id = user.id
|
||||
setup.close()
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# --- the removed surfaces ------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["/api/auth", "/api/analytics", "/api/scripts"])
|
||||
def test_no_route_serves_a_removed_subsystem(prefix):
|
||||
"""Accounts, the visitor dashboard, and campaign scripting are gone as
|
||||
routes, not merely hidden behind a flag."""
|
||||
assert not [p for p in _paths() if p.startswith(prefix)], prefix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/auth/me", "/api/auth/login", "/api/auth/register", "/api/auth/logout",
|
||||
"/api/analytics/summary", "/api/analytics/collect", "/api/analytics/access",
|
||||
"/api/scripts", "/api/adventures/1/scripts", "/api/adventures/1/script-state",
|
||||
])
|
||||
def test_a_removed_endpoint_answers_404(client, path):
|
||||
assert client.get(path).status_code == 404, path
|
||||
|
||||
|
||||
def test_the_application_has_no_scripting_engine():
|
||||
with pytest.raises(ImportError):
|
||||
__import__("app.scripting")
|
||||
|
||||
|
||||
def test_no_module_imports_quickjs():
|
||||
"""The dependency is gone from requirements; this catches an import that
|
||||
would put it back."""
|
||||
for py in (REPO / "backend" / "app").rglob("*.py"):
|
||||
assert "import quickjs" not in py.read_text(), py
|
||||
|
||||
|
||||
def test_requirements_carry_no_hosted_dependencies():
|
||||
text = (REPO / "backend" / "requirements.txt").read_text()
|
||||
for gone in ("quickjs", "psycopg", "cryptography"):
|
||||
assert gone not in text, gone
|
||||
|
||||
|
||||
def test_no_render_deployment_config():
|
||||
assert not (REPO / "render.yaml").exists()
|
||||
|
||||
|
||||
# --- no cloud provider, no key ------------------------------------------
|
||||
|
||||
def test_settings_expose_no_api_key_field(client):
|
||||
body = client.get("/api/settings").json()
|
||||
assert "api_key" not in body
|
||||
assert "has_api_key" not in body
|
||||
|
||||
|
||||
def test_an_api_key_cannot_be_set_through_the_api(client):
|
||||
"""Pydantic ignores unknown fields, so this asserts the value does not
|
||||
land rather than that the request is refused."""
|
||||
client.put("/api/settings", json={"api_key": "sk-should-not-stick"})
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.query(models.Settings).first().api_key == ""
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_provider_sends_no_authorization_header():
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
assert "Authorization" not in provider._headers()
|
||||
|
||||
|
||||
# --- the model timeout ---------------------------------------------------
|
||||
|
||||
def test_the_default_timeout_is_generous_but_finite():
|
||||
"""M1 measured a cold model load exceeding the inherited hardcoded 120s on
|
||||
a CPU-only host. It must be longer than that, and it must be a number."""
|
||||
assert DEFAULT_READ_TIMEOUT > 120
|
||||
assert DEFAULT_READ_TIMEOUT <= 3600
|
||||
|
||||
|
||||
def test_connect_stays_short_while_reading_stays_patient():
|
||||
"""A wrong address should fail in seconds; a loading model should not."""
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
|
||||
timeout = provider._timeout()
|
||||
assert timeout.connect == CONNECT_TIMEOUT <= 30
|
||||
assert timeout.read == DEFAULT_READ_TIMEOUT
|
||||
|
||||
|
||||
def test_the_timeout_is_configurable(client):
|
||||
r = client.put("/api/settings", json={"model_timeout_seconds": 900})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.get("/api/settings").json()["model_timeout_seconds"] == 900
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 29, 3601, -1])
|
||||
def test_an_unusable_timeout_is_refused(client, value):
|
||||
"""Not zero, not negative, and not "wait forever" spelled as a big number."""
|
||||
assert client.put(
|
||||
"/api/settings", json={"model_timeout_seconds": value}
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_the_provider_honours_the_configured_timeout():
|
||||
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m", read_timeout=45)
|
||||
assert provider._timeout().read == 45
|
||||
|
||||
|
||||
# --- the storyteller stays on loopback -----------------------------------
|
||||
|
||||
def test_the_native_start_scripts_bind_loopback():
|
||||
for script in ("start.sh", "start.ps1"):
|
||||
text = (REPO / script).read_text(errors="ignore")
|
||||
assert "--host 127.0.0.1" in text, script
|
||||
assert "--host 0.0.0.0" not in text, script
|
||||
|
||||
|
||||
def test_compose_publishes_to_loopback_only():
|
||||
"""The container listens on 0.0.0.0 because a published port cannot reach
|
||||
anything else. What must stay loopback is the *host* side of the mapping."""
|
||||
text = (REPO / "docker-compose.yml").read_text()
|
||||
published = re.findall(r'^\s*-\s*"([^"]+)"', text, re.M)
|
||||
assert published, "no published ports found — has the file moved?"
|
||||
for mapping in published:
|
||||
assert mapping.startswith("127.0.0.1:"), mapping
|
||||
|
||||
|
||||
# --- every provider factory must actually build ---------------------------
|
||||
|
||||
def test_every_provider_factory_builds_from_a_real_settings_row(client):
|
||||
"""M2 shipped with a defect this test would have caught.
|
||||
|
||||
`Settings.api_key_plain` was removed with the API key, but `memorybank`'s
|
||||
two provider factories still read it. Nothing failed at import, and no test
|
||||
noticed, because every memory test stubs those factories out — so the break
|
||||
only appeared at runtime, in a background task, as a swallowed
|
||||
`AttributeError` that silently stopped summaries and embeddings.
|
||||
|
||||
Constructing each factory from a real row is the cheapest thing that would
|
||||
have caught it, and it catches the same shape of mistake next time a
|
||||
Settings column moves.
|
||||
"""
|
||||
from app import memorybank
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(models.Settings).first()
|
||||
settings.embedding_model = "nomic-embed-text:latest"
|
||||
settings.summary_model = ""
|
||||
db.commit()
|
||||
|
||||
summary = memorybank.summary_provider(settings)
|
||||
assert summary.model == settings.model # falls back to the narrator
|
||||
assert summary.base_url == settings.endpoint_url.rstrip("/")
|
||||
|
||||
embed = memorybank.embedding_provider(settings)
|
||||
assert embed.model == "nomic-embed-text:latest"
|
||||
|
||||
turn = OpenAICompatibleProvider(
|
||||
settings.endpoint_url, settings.model, settings.api_mode,
|
||||
settings.model_timeout_seconds,
|
||||
)
|
||||
assert turn.read_timeout == settings.model_timeout_seconds
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_configured_timeout_reaches_every_generating_client(client, monkeypatch):
|
||||
"""The second defect this review caught. The setting was stored, validated
|
||||
and exposed, and then not passed to the provider — so the turn engine kept
|
||||
using the module default and "configurable" was a claim rather than a fact.
|
||||
|
||||
Each generating path is driven for real and the constructed provider is
|
||||
recorded. Embeddings are deliberately excluded: they are short, never
|
||||
cold-load a large model, and keep their own shorter constant.
|
||||
"""
|
||||
from app import memorybank
|
||||
from app.routers import chat as chat_router
|
||||
from app.routers.adventures import turns as turns_router
|
||||
|
||||
seen = []
|
||||
|
||||
class Recorder:
|
||||
last_usage = None
|
||||
|
||||
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
|
||||
seen.append(read_timeout)
|
||||
|
||||
async def generate(self, *a, **k):
|
||||
yield ("text", "narration")
|
||||
|
||||
async def chat(self, *a, **k):
|
||||
yield ("text", "reply")
|
||||
|
||||
monkeypatch.setattr(turns_router, "OpenAICompatibleProvider", Recorder)
|
||||
monkeypatch.setattr(chat_router, "OpenAICompatibleProvider", Recorder)
|
||||
monkeypatch.setattr(memorybank, "OpenAICompatibleProvider", Recorder)
|
||||
|
||||
assert client.put("/api/settings", json={"model_timeout_seconds": 777}).status_code == 200
|
||||
|
||||
adv = client.post("/api/adventures", json={"title": "T"}).json()["id"]
|
||||
client.post(f"/api/adventures/{adv}/actions", json={"type": "story", "text": "hello"})
|
||||
assert seen and seen[-1] == 777, f"turn engine used {seen[-1]!r}"
|
||||
|
||||
client.post("/api/chat/stream", json={"messages": [{"role": "user", "content": "hi"}]})
|
||||
assert seen[-1] == 777, f"chat used {seen[-1]!r}"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memorybank.summary_provider(db.query(models.Settings).first())
|
||||
finally:
|
||||
db.close()
|
||||
assert seen[-1] == 777, f"summarizer used {seen[-1]!r}"
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Phase 14 SP3: memories attach to nodes, and the marks are nodes too.
|
||||
|
||||
Two claims, and neither fails loudly if it is wrong:
|
||||
|
||||
* A memory belongs to the path that produced it. A memory made on branch B
|
||||
must be invisible from A, and the memories of a shared ancestor must be
|
||||
visible from both, without anything being copied when a fork happens. The
|
||||
failure mode is a prompt that quietly carries a summary of a story the
|
||||
player abandoned.
|
||||
* Retrieval reads the whole lineage, and that stays affordable. The story
|
||||
is read through a window, but recall is long-range by definition and
|
||||
cannot use one. So the clause names every ancestor. The bet is that
|
||||
memories are sparse enough, one per six actions, for that to stay tens of
|
||||
small rows even twenty forks deep. This file measures that bet below
|
||||
rather than asserting it.
|
||||
|
||||
Nothing in the product forks yet, so this file builds the fork by hand,
|
||||
exactly as `test_branch_clause.py` builds it.
|
||||
|
||||
python -m pytest tests/test_memory_nodes.py -v
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app import memorybank, models, tree
|
||||
from app.context import cursors, lineage
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from tools import dbmeter
|
||||
|
||||
|
||||
class StubEmbedder:
|
||||
"""Returns whatever vector the test set, for any text."""
|
||||
|
||||
def __init__(self, vector=(1.0, 0.0, 0.0)):
|
||||
self.vector = list(vector)
|
||||
|
||||
async def embed(self, texts):
|
||||
return [list(self.vector) for _ in texts]
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the fixture
|
||||
|
||||
def make_branch(db, adventure, parent=None, fork_depth=None):
|
||||
"""A branch row whose lineage is its parent's lineage, capped, plus
|
||||
itself. This is the computation SP5 performs at fork time. The fixture
|
||||
reimplements it here so it cannot pass by agreeing with a bug in the
|
||||
code under test."""
|
||||
branch = models.Branch(
|
||||
adventure_id=adventure.id,
|
||||
parent_branch_id=parent.id if parent else None,
|
||||
fork_depth=fork_depth,
|
||||
lineage=[],
|
||||
)
|
||||
db.add(branch)
|
||||
db.flush()
|
||||
inherited = []
|
||||
if parent is not None:
|
||||
for ancestor_id, cap in lineage.entries_of(parent):
|
||||
capped = fork_depth if cap is None else min(cap, fork_depth)
|
||||
inherited.append([ancestor_id, capped])
|
||||
branch.lineage = [[branch.id, None]] + inherited
|
||||
db.flush()
|
||||
return branch
|
||||
|
||||
|
||||
def add_node(db, adventure, branch, depth, label, index=None):
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
branch_id=branch.id,
|
||||
depth=depth,
|
||||
type="ai" if depth % 2 else "do",
|
||||
text=f"{label}{depth}",
|
||||
)
|
||||
db.add(action)
|
||||
return action
|
||||
|
||||
|
||||
def add_memory(db, adventure, text, node, vector=(1.0, 0.0, 0.0), **kwargs):
|
||||
"""A memory of the block ending on `node`, attached the way the post-turn
|
||||
pass attaches one."""
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text=text,
|
||||
source_start=None if node is None else node.depth,
|
||||
source_end=None if node is None else node.depth,
|
||||
**kwargs,
|
||||
)
|
||||
if node is not None:
|
||||
tree.attach_memory(memory, node)
|
||||
else:
|
||||
tree.place_memory(db, adventure, memory)
|
||||
db.add(memory)
|
||||
db.flush()
|
||||
memorybank.set_vector(memory, list(vector))
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def forked():
|
||||
"""A0..A3, then B4 B5 off A3, then C6 C7 off B5, with a memory attached
|
||||
to one node of each branch. A keeps playing past the fork point where B
|
||||
left it.
|
||||
|
||||
The head is C, so the story is A0 A1 A2 A3 B4 B5 C6 C7, and the
|
||||
memories in play are A's, B's, and C's. The one on A5 is excluded: it
|
||||
is on a sibling of B4 and belongs to a story nobody is reading.
|
||||
"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="nodes@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
settings = models.Settings(
|
||||
user_id=user.id, api_key="enc:dummy", model="m",
|
||||
embedding_model="text-embedding-3-small", memory_top_k=10,
|
||||
memory_bank_capacity=80,
|
||||
)
|
||||
db.add(settings)
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Forked", script_state={}, memory_bank_enabled=True,
|
||||
auto_summarize=True,
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
|
||||
a = make_branch(db, adventure)
|
||||
b = make_branch(db, adventure, parent=a, fork_depth=3)
|
||||
c = make_branch(db, adventure, parent=b, fork_depth=5)
|
||||
nodes = {}
|
||||
for depth in range(4):
|
||||
nodes[f"A{depth}"] = add_node(db, adventure, a, depth, "A")
|
||||
for depth in (4, 5): # A kept playing: siblings of B4/B5
|
||||
nodes[f"A{depth}"] = add_node(db, adventure, a, depth, "A", index=100 + depth)
|
||||
for depth in (4, 5):
|
||||
nodes[f"B{depth}"] = add_node(db, adventure, b, depth, "B")
|
||||
for depth in (6, 7):
|
||||
nodes[f"C{depth}"] = add_node(db, adventure, c, depth, "C")
|
||||
db.flush()
|
||||
|
||||
memories = {
|
||||
"shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"]),
|
||||
"sibling": add_memory(db, adventure, "on A's own continuation", nodes["A5"]),
|
||||
"b": add_memory(db, adventure, "on B", nodes["B5"]),
|
||||
"c": add_memory(db, adventure, "on C", nodes["C7"]),
|
||||
}
|
||||
adventure.head_branch_id = c.id
|
||||
adventure.head_depth = 7
|
||||
db.commit()
|
||||
|
||||
ids = {"a": a.id, "b": b.id, "c": c.id, "nodes": nodes, "memories": memories}
|
||||
try:
|
||||
yield db, adventure, settings, ids
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def switch_to(db, adventure, branch_id, tip):
|
||||
adventure.head_branch_id = branch_id
|
||||
adventure.head_depth = tip
|
||||
db.commit()
|
||||
|
||||
|
||||
def retrieved(adventure, settings) -> set[str]:
|
||||
memorybank.embedding_provider = lambda s: StubEmbedder()
|
||||
result = asyncio.run(
|
||||
memorybank.retrieve_memories(adventure, settings, update_stats=False)
|
||||
)
|
||||
assert result["error"] is None, result["error"]
|
||||
return {m["text"] for m in result["used"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the isolation
|
||||
|
||||
def test_a_memory_on_a_sibling_is_not_retrieved(forked):
|
||||
"""The whole point of this file. A5 is a node of the story that was
|
||||
abandoned when B forked, and the memory attached to it must not reach a
|
||||
prompt on C."""
|
||||
db, adventure, settings, ids = forked
|
||||
assert retrieved(adventure, settings) == {
|
||||
"on the shared trunk", "on B", "on C"
|
||||
}
|
||||
|
||||
|
||||
def test_a_shared_ancestor_is_visible_from_both_branches(forked):
|
||||
"""Nothing is copied at a fork, so the trunk's memories are shared by
|
||||
construction rather than by duplication."""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
from_a = retrieved(adventure, settings)
|
||||
assert "on the shared trunk" in from_a
|
||||
# From A, the branches taken off it are the ones out of reach.
|
||||
assert from_a == {"on the shared trunk", "on A's own continuation"}
|
||||
|
||||
|
||||
def test_the_lineage_is_read_whole_not_windowed(forked):
|
||||
"""The story is read through a window. Recall is not. The trunk memory
|
||||
is four nodes and two forks back, and is still a candidate."""
|
||||
db, adventure, settings, ids = forked
|
||||
path = lineage.path_of(db, adventure)
|
||||
assert len(path) == 3
|
||||
# The window a story read would use here names one entry. Retrieval
|
||||
# names all three, which is the difference this test checks.
|
||||
assert path.prefix_covering(2) == 1
|
||||
assert "on the shared trunk" in retrieved(adventure, settings)
|
||||
|
||||
|
||||
def test_a_hand_written_memory_is_anchored_where_it_was_typed(forked):
|
||||
"""SP7: a typed memory takes the head, so it obeys the same rule as a
|
||||
summarized one.
|
||||
|
||||
It used to carry no depth, which sounded like "belongs to the whole
|
||||
adventure" and behaved like "cannot be capped at a fork." It followed
|
||||
the reader onto branches whose story it never described. Anchoring it
|
||||
makes the bank answer one question instead of two.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
typed = add_memory(db, adventure, "typed by hand", None)
|
||||
assert (typed.branch_id, typed.depth) == (ids["a"], 5), "the head it was typed at"
|
||||
|
||||
|
||||
def test_a_typed_memory_survives_a_fork_of_the_ground_it_was_typed_on(forked):
|
||||
"""The half of the old behavior that was correct, kept.
|
||||
|
||||
A memory typed on the shared trunk is still there after forking away,
|
||||
but only because the fork's path goes through that node, not because
|
||||
the memory is exempt from being capped.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 3) # the node B, and so C, forked from
|
||||
add_memory(db, adventure, "typed on the trunk", None)
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
assert "typed on the trunk" in retrieved(adventure, settings)
|
||||
|
||||
|
||||
def test_a_typed_memory_does_not_follow_you_onto_a_path_it_is_not_on(forked):
|
||||
"""The other half of the old behavior, which was wrong, is now fixed.
|
||||
|
||||
A5 is A's own continuation past the point where B left it, so it is a
|
||||
sibling of the story C tells. This is exactly where the `sibling`
|
||||
memory sits, and it is excluded for the same reason. Typing a memory
|
||||
instead of summarizing it grants no exemption from the path rule.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
add_memory(db, adventure, "typed off the path", None)
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
assert "typed off the path" not in retrieved(adventure, settings)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the marks
|
||||
|
||||
def test_a_mark_moves_to_the_node_the_memory_covers(forked):
|
||||
"""The mark and the memory are one statement about where the pass got to,
|
||||
so they are written from the same row."""
|
||||
db, adventure, settings, ids = forked
|
||||
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["B5"])
|
||||
db.commit()
|
||||
assert cursors.MEMORY.stored(adventure) == (ids["b"], 5)
|
||||
assert cursors.MEMORY.depth(db, adventure) == 5
|
||||
|
||||
|
||||
def test_a_mark_from_a_sibling_reads_as_nothing_covered(forked):
|
||||
"""A mark is a node, so switching to another story must resolve the
|
||||
mark's meaning rather than assume it. A path segment this story never
|
||||
took is not covered, and the fallback for "not covered" must be redoing
|
||||
the work, not skipping it."""
|
||||
db, adventure, settings, ids = forked
|
||||
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["C7"])
|
||||
db.commit()
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
assert cursors.MEMORY.depth(db, adventure) == cursors.NO_DEPTH
|
||||
|
||||
|
||||
def test_a_mark_on_an_ancestor_is_capped_at_the_fork(forked):
|
||||
"""A6 and A7 are past where this path left A, so a mark deeper than the
|
||||
fork cannot mean 'covered' for anything on this story."""
|
||||
db, adventure, settings, ids = forked
|
||||
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["A5"])
|
||||
db.commit()
|
||||
assert cursors.MEMORY.depth(db, adventure) == 3 # C forks off B forks off A@3
|
||||
|
||||
|
||||
def test_a_mark_never_moves_forward_on_a_rewind(forked):
|
||||
db, adventure, settings, ids = forked
|
||||
cursors.MEMORY.anchor_at(adventure, ids["nodes"]["A3"])
|
||||
cursors.rewind_all(adventure, ids["c"], 6)
|
||||
assert cursors.MEMORY.stored(adventure) == (ids["a"], 3)
|
||||
|
||||
|
||||
# ---------------------------------------------------- what the passes read
|
||||
|
||||
def test_the_summary_folds_in_only_the_path_it_is_on(forked, monkeypatch):
|
||||
"""`_update_story_summary` gathers the memories past its mark. On C
|
||||
that is B's and C's memories. It never includes the one on A's own
|
||||
continuation, even though that memory's depth would otherwise put it
|
||||
inside the range."""
|
||||
db, adventure, settings, ids = forked
|
||||
monkeypatch.setattr(memorybank, "SUMMARY_INTERVAL", 1)
|
||||
|
||||
class Stub:
|
||||
def __init__(self):
|
||||
self.prompts = []
|
||||
|
||||
async def complete(self, system, user, **kwargs):
|
||||
self.prompts.append(user)
|
||||
return "A summary."
|
||||
|
||||
stub = Stub()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
cursors.SUMMARY.anchor_at(adventure, ids["nodes"]["A3"])
|
||||
db.commit()
|
||||
|
||||
asyncio.run(memorybank._update_story_summary(adventure, settings, db))
|
||||
|
||||
[prompt] = stub.prompts
|
||||
assert "on B" in prompt and "on C" in prompt
|
||||
assert "on A's own continuation" not in prompt
|
||||
assert "on the shared trunk" not in prompt # behind the mark
|
||||
# Caught up to the end of the story. Until SP4 that was C6: the newest
|
||||
# action was held back because retrying it rewrote the row underneath the
|
||||
# mark. A retry writes a sibling now, and the withdrawal that follows takes
|
||||
# the mark back with it, so there is nothing to hold back.
|
||||
assert cursors.SUMMARY.stored(adventure) == (ids["c"], 7)
|
||||
|
||||
|
||||
def test_a_block_is_summarized_from_the_path_and_hung_off_its_last_node(
|
||||
forked, monkeypatch
|
||||
):
|
||||
db, adventure, settings, ids = forked
|
||||
monkeypatch.setattr(memorybank, "MEMORY_START", 0)
|
||||
monkeypatch.setattr(memorybank, "MEMORY_INTERVAL", 4)
|
||||
# This test is about which actions a block is read from, not about when a
|
||||
# block forms, so switch the settling slack off and let the path end on a
|
||||
# block boundary. `test_memory_settling` owns the timing rule.
|
||||
monkeypatch.setattr(memorybank, "SETTLE_SLACK", 0)
|
||||
|
||||
class Stub:
|
||||
def __init__(self):
|
||||
self.excerpts = []
|
||||
|
||||
async def complete(self, system, user, **kwargs):
|
||||
self.excerpts.append(user)
|
||||
return f"Memory {len(self.excerpts)}."
|
||||
|
||||
stub = Stub()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
|
||||
|
||||
# Two blocks of four from a path of eight, both formed in one pass.
|
||||
first, second = stub.excerpts
|
||||
assert "A5" not in first + second, "a sibling's narration reached the summarizer"
|
||||
assert ["A0", "A1", "A2", "A3"] == [line for line in first.split() if line[0] in "ABC"]
|
||||
assert ["B4", "B5", "C6", "C7"] == [line for line in second.split() if line[0] in "ABC"]
|
||||
made = db.query(models.Memory).filter_by(text="Memory 1.").one()
|
||||
assert (made.branch_id, made.depth) == (ids["a"], 3)
|
||||
# The mark ends up on the node the second block attaches to, which is the tip.
|
||||
assert cursors.MEMORY.stored(adventure) == (ids["c"], 7)
|
||||
|
||||
|
||||
# ------------------------------------------------------ the cost of forking
|
||||
|
||||
@pytest.fixture()
|
||||
def deeply_forked():
|
||||
"""A story forked twenty times, with a memory every six actions. This
|
||||
is the density the post-turn pass actually produces."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="deepmem@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.Settings(
|
||||
user_id=user.id, api_key="enc:dummy", model="m",
|
||||
embedding_model="text-embedding-3-small",
|
||||
# Every candidate is injected, so the measurement covers fetching the
|
||||
# texts too and not only ranking them.
|
||||
memory_top_k=50,
|
||||
))
|
||||
|
||||
def story(title, forks):
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title=title, script_state={}, memory_bank_enabled=True,
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
branch = make_branch(db, adventure)
|
||||
depth = 0
|
||||
nodes = []
|
||||
for _ in range(4):
|
||||
nodes.append(add_node(db, adventure, branch, depth, "n"))
|
||||
depth += 1
|
||||
for _ in range(forks):
|
||||
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
|
||||
for _ in range(2):
|
||||
nodes.append(add_node(db, adventure, branch, depth, "n"))
|
||||
depth += 1
|
||||
if forks:
|
||||
branch = make_branch(db, adventure, parent=branch, fork_depth=depth - 1)
|
||||
for _ in range(84 - depth):
|
||||
nodes.append(add_node(db, adventure, branch, depth, "n"))
|
||||
depth += 1
|
||||
db.flush()
|
||||
for node in nodes[5::6]: # one memory per six actions, as the pass makes them
|
||||
add_memory(db, adventure, f"memory at {node.depth}", node)
|
||||
adventure.head_branch_id = branch.id
|
||||
adventure.head_depth = depth - 1
|
||||
return adventure
|
||||
|
||||
forked_story = story("Forked", 20)
|
||||
flat_story = story("Flat", 0)
|
||||
db.commit()
|
||||
try:
|
||||
yield db, flat_story, forked_story
|
||||
finally:
|
||||
db.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked):
|
||||
"""The bet from the module docstring, measured in bytes. Retrieval
|
||||
names all twenty-two branches instead of one, but it fetches only an id
|
||||
and a flag per memory, and both stories return the same fourteen
|
||||
memories. The clause is where the difference shows up, and the clause
|
||||
is not what crosses the wire."""
|
||||
db, flat_story, forked_story = deeply_forked
|
||||
settings = db.query(models.Settings).one()
|
||||
flat_id, forked_id = flat_story.id, forked_story.id
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
meter = dbmeter.Meter()
|
||||
meter.attach(engine)
|
||||
try:
|
||||
with meter.scope("flat"):
|
||||
assert len(retrieved(db.get(models.Adventure, flat_id), settings)) == 14
|
||||
flat_bytes = meter.scopes[-1].total.fetched
|
||||
with meter.scope("forked"):
|
||||
assert len(retrieved(db.get(models.Adventure, forked_id), settings)) == 14
|
||||
forked_bytes = meter.scopes[-1].total.fetched
|
||||
finally:
|
||||
meter.detach()
|
||||
|
||||
# Measured 2026-08-18: 1,807 B against 1,823 B. Both figures cover the
|
||||
# same fourteen rows, named through twenty-two branch terms instead of one.
|
||||
assert flat_bytes > 0, "the meter saw nothing; it is measuring the wrong connection"
|
||||
assert forked_bytes < flat_bytes * 1.5, (
|
||||
f"retrieval on a 20-fork story cost {forked_bytes:,} B against the "
|
||||
f"{flat_bytes:,} B a flat story of the same length cost"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- the opening node
|
||||
|
||||
def test_a_typed_memory_on_the_opening_node_survives_that_node_going(forked):
|
||||
"""The one exception where a node and its memories are not withdrawn
|
||||
together.
|
||||
|
||||
A memory anchored to a node is withdrawn with the node. This is the
|
||||
rule, and it is deliberate: the memory described that turn, and the
|
||||
turn is leaving. But migration 62 parked every memory written before
|
||||
memories had coordinates on depth 0, the only landing spot visible from
|
||||
every branch. As a result, the opening node carries a whole bank of
|
||||
memories it never produced. Withdrawing it would delete all of those
|
||||
memories at once, for every adventure that predates the tree.
|
||||
|
||||
A memory with no `source_start` covers no stretch of story, so nothing
|
||||
about it can go stale. It stays.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
typed = models.Memory(
|
||||
adventure_id=adventure.id, text="Kira is the innkeeper's daughter",
|
||||
source_start=None, source_end=None,
|
||||
)
|
||||
typed.branch_id, typed.depth = ids["a"], 0
|
||||
db.add(typed)
|
||||
db.commit()
|
||||
typed_id = typed.id
|
||||
|
||||
withdrawn = memorybank.forget_node(db, adventure, ids["nodes"]["A0"])
|
||||
db.commit()
|
||||
|
||||
assert withdrawn == 0
|
||||
assert db.get(models.Memory, typed_id) is not None
|
||||
|
||||
|
||||
def test_a_summary_of_the_opening_node_is_still_withdrawn(forked):
|
||||
"""The exception is about memories that describe nothing, not about depth 0.
|
||||
|
||||
A summary that genuinely ends on the opening node describes text that
|
||||
is being removed, so the summary is removed too. Otherwise the root
|
||||
would collect exactly the dangling rows that `forget_node` replaced
|
||||
`prune_dangling_memories` to prevent.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
derived = add_memory(db, adventure, "the opening, summarised", ids["nodes"]["A0"])
|
||||
derived_id = derived.id
|
||||
|
||||
withdrawn = memorybank.forget_node(db, adventure, ids["nodes"]["A0"])
|
||||
db.commit()
|
||||
|
||||
assert withdrawn == 1
|
||||
assert db.get(models.Memory, derived_id) is None
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Ranking the memory bank without reading the memory bank.
|
||||
|
||||
Retrieval used to walk `adventure.memories`, which loaded every row with
|
||||
its vector. That vector data was 96% of everything a turn read. Retrieval
|
||||
now asks SQL which memories are in play, holds their vectors in process,
|
||||
and fetches text for only the five it picks.
|
||||
|
||||
Three things have to stay true for that to be safe, and each is a separate
|
||||
failure that no error message would report:
|
||||
|
||||
* the ranking picks the same memories it always did;
|
||||
* nothing bulk-reads a vector column again;
|
||||
* a cached vector is never served after the stored one changed.
|
||||
|
||||
python -m pytest tests/test_memory_retrieval.py -v
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, inspect as sa_inspect
|
||||
|
||||
from app import memorybank, models
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
|
||||
class StubEmbedder:
|
||||
"""Returns whatever vector the test set, and counts calls."""
|
||||
|
||||
def __init__(self, vector=(1.0, 0.0, 0.0)):
|
||||
self.vector = list(vector)
|
||||
self.calls = 0
|
||||
|
||||
async def embed(self, texts):
|
||||
self.calls += 1
|
||||
return [list(self.vector) for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
memorybank._vector_cache.clear()
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
memorybank._vector_cache.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings(db):
|
||||
user = models.User(is_guest=False, email="rank@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
row = models.Settings(
|
||||
user_id=user.id, api_key="enc:dummy", model="m",
|
||||
embedding_model="text-embedding-3-small", memory_top_k=2,
|
||||
memory_bank_capacity=80,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adventure(db, settings):
|
||||
adv = models.Adventure(
|
||||
user_id=settings.user_id, title="Cave", script_state={},
|
||||
memory_bank_enabled=True,
|
||||
)
|
||||
db.add(adv)
|
||||
db.flush()
|
||||
# Retrieval builds its query from the newest actions. With none, it
|
||||
# returns before ranking anything.
|
||||
for i in range(2):
|
||||
db.add(models.Action(
|
||||
adventure_id=adv.id, type="ai", text=f"Something happened {i}."
|
||||
))
|
||||
db.commit()
|
||||
return adv
|
||||
|
||||
|
||||
def add_memory(db, adventure, text, vector, **kwargs):
|
||||
memory = models.Memory(adventure_id=adventure.id, text=text, **kwargs)
|
||||
db.add(memory)
|
||||
db.flush()
|
||||
if vector is not None:
|
||||
memorybank.set_vector(memory, list(vector))
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bank(db, adventure):
|
||||
"""Three orthogonal vectors, so a query vector picks one unambiguously."""
|
||||
return {
|
||||
"x": add_memory(db, adventure, "about x", (1.0, 0.0, 0.0)),
|
||||
"y": add_memory(db, adventure, "about y", (0.0, 1.0, 0.0)),
|
||||
"z": add_memory(db, adventure, "about z", (0.0, 0.0, 1.0)),
|
||||
}
|
||||
|
||||
|
||||
def retrieve(adventure, settings, embedder, **kwargs):
|
||||
memorybank.embedding_provider = lambda s: embedder
|
||||
kwargs.setdefault("update_stats", False)
|
||||
return asyncio.run(memorybank.retrieve_memories(adventure, settings, **kwargs))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sql_log():
|
||||
statements: list[str] = []
|
||||
|
||||
def record(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", record)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", record)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- ranking
|
||||
|
||||
def test_ranks_by_cosine_similarity(db, adventure, settings, bank):
|
||||
result = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))
|
||||
assert [m["id"] for m in result["used"]][0] == bank["x"].id
|
||||
assert result["used"][0]["similarity"] == pytest.approx(1.0)
|
||||
assert result["used"][0]["text"] == "about x"
|
||||
|
||||
|
||||
def test_honours_top_k(db, adventure, settings, bank):
|
||||
settings.memory_top_k = 1
|
||||
db.commit()
|
||||
assert len(retrieve(adventure, settings, StubEmbedder())["used"]) == 1
|
||||
|
||||
|
||||
def test_pinned_memories_are_always_used(db, adventure, settings, bank):
|
||||
"""A pin means "always in context", however badly it scores."""
|
||||
settings.memory_top_k = 1
|
||||
bank["z"].pinned = True
|
||||
db.commit()
|
||||
|
||||
used = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))["used"]
|
||||
assert [m["id"] for m in used] == [bank["z"].id]
|
||||
assert used[0]["pinned"] is True
|
||||
|
||||
|
||||
def test_forgotten_and_unembedded_memories_are_not_candidates(db, adventure, settings):
|
||||
live = add_memory(db, adventure, "live", (1.0, 0.0, 0.0))
|
||||
add_memory(db, adventure, "evicted", (1.0, 0.0, 0.0), forgotten=True)
|
||||
add_memory(db, adventure, "no vector yet", None)
|
||||
|
||||
used = retrieve(adventure, settings, StubEmbedder())["used"]
|
||||
assert [m["id"] for m in used] == [live.id]
|
||||
|
||||
|
||||
def test_empty_bank_returns_no_error(db, adventure, settings):
|
||||
assert retrieve(adventure, settings, StubEmbedder()) == {"used": [], "error": None}
|
||||
|
||||
|
||||
def test_missing_embedding_model_is_reported(db, adventure, settings, bank):
|
||||
settings.embedding_model = ""
|
||||
db.commit()
|
||||
result = retrieve(adventure, settings, StubEmbedder())
|
||||
assert result["used"] == [] and "embedding model" in result["error"]
|
||||
|
||||
|
||||
def test_update_stats_bumps_only_the_used(db, adventure, settings, bank):
|
||||
settings.memory_top_k = 1
|
||||
db.commit()
|
||||
retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)), update_stats=True)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
|
||||
assert db.get(models.Memory, bank["x"].id).use_count == 1
|
||||
assert db.get(models.Memory, bank["x"].id).last_used_at is not None
|
||||
assert db.get(models.Memory, bank["y"].id).use_count == 0
|
||||
|
||||
|
||||
def test_dry_runs_do_not_bump_the_counters(db, adventure, settings, bank):
|
||||
"""Insights assembles a context without spending a turn. It must not
|
||||
look like the memories were used."""
|
||||
retrieve(adventure, settings, StubEmbedder(), update_stats=False)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
assert all(db.get(models.Memory, m.id).use_count == 0 for m in bank.values())
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the egress
|
||||
|
||||
def memory_selects(statements):
|
||||
return [
|
||||
s for s in statements
|
||||
if "FROM memories" in s and s.lstrip().upper().startswith("SELECT")
|
||||
]
|
||||
|
||||
|
||||
def test_the_json_column_is_gone(db):
|
||||
"""`memories.embedding` held the vectors before migration 38, and
|
||||
nothing read it afterward. Migration 42 dropped it. Restoring it would
|
||||
bring back 4 MB of dead weight and a second place vectors can be
|
||||
written from. That second place is how the model-switch bug happened
|
||||
(test_embedding_model_switch.py)."""
|
||||
columns = {c["name"] for c in sa_inspect(engine).get_columns("memories")}
|
||||
assert "embedding" not in columns
|
||||
assert {"embedding_blob", "embedded"} <= columns
|
||||
|
||||
|
||||
def test_the_catalogue_query_carries_no_vectors(db, adventure, settings, bank, sql_log):
|
||||
"""The query that decides which memories are in play must stay tiny.
|
||||
This is the query that used to pull the whole bank across the wire."""
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
catalogue = [s for s in memory_selects(sql_log) if "memories.pinned" in s]
|
||||
assert catalogue, "expected a catalogue query"
|
||||
assert not any("embedding_blob" in s for s in catalogue)
|
||||
|
||||
|
||||
def test_a_second_turn_reads_no_vectors_at_all(db, adventure, settings, bank, sql_log):
|
||||
"""The point of the cache: back-to-back turns on one adventure pay for the
|
||||
vectors once."""
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
sql_log.clear()
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|
||||
|
||||
|
||||
def test_only_the_new_memory_is_fetched_after_one_is_added(db, adventure, settings, bank, sql_log):
|
||||
"""A growing bank must not re-read the vectors it already holds."""
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
added = add_memory(db, adventure, "about w", (0.5, 0.5, 0.0))
|
||||
|
||||
sql_log.clear()
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
vector_reads = [s for s in memory_selects(sql_log) if "embedding_blob" in s]
|
||||
assert len(vector_reads) == 1
|
||||
# One placeholder means one id: the new memory, and nothing else.
|
||||
assert vector_reads[0].count("?") == 1
|
||||
assert added.id in {m["id"] for m in
|
||||
retrieve(adventure, settings, StubEmbedder((0.5, 0.5, 0.0)))["used"]}
|
||||
|
||||
|
||||
def test_only_top_k_texts_are_fetched(db, adventure, settings, bank, sql_log):
|
||||
settings.memory_top_k = 1
|
||||
db.commit()
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
text_reads = [s for s in memory_selects(sql_log) if "memories.text" in s]
|
||||
assert len(text_reads) == 1
|
||||
assert text_reads[0].count("?") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- staleness
|
||||
|
||||
def test_a_rewritten_vector_is_not_served_from_cache(db, adventure, settings, bank):
|
||||
"""The cache's one genuine hazard: a memory keeps its id while its
|
||||
vector changes, so an id-set check alone would continue serving the
|
||||
old vector. Editing a memory's text and re-embedding it does exactly
|
||||
that.
|
||||
"""
|
||||
settings.memory_top_k = 1
|
||||
db.commit()
|
||||
first = retrieve(adventure, settings, StubEmbedder((0.0, 0.0, 1.0)))
|
||||
assert [m["id"] for m in first["used"]] == [bank["z"].id]
|
||||
|
||||
# z is re-embedded onto the x axis, all within one gap between turns.
|
||||
memorybank.set_vector(bank["z"], [1.0, 0.0, 0.0])
|
||||
db.commit()
|
||||
|
||||
again = retrieve(adventure, settings, StubEmbedder((0.0, 0.0, 1.0)))
|
||||
assert [m["id"] for m in again["used"]] != [bank["z"].id]
|
||||
|
||||
|
||||
def test_a_deleted_memory_leaves_the_cache(db, adventure, settings, bank):
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
db.delete(bank["x"])
|
||||
db.commit()
|
||||
|
||||
used = retrieve(adventure, settings, StubEmbedder((1.0, 0.0, 0.0)))["used"]
|
||||
assert bank["x"].id not in {m["id"] for m in used}
|
||||
assert memorybank._vector_cache[adventure.id].keys() == {bank["y"].id, bank["z"].id}
|
||||
|
||||
|
||||
def test_the_cache_is_bounded(db, adventure, settings, bank):
|
||||
"""It holds vectors indefinitely, so without a bound a long-running process
|
||||
accumulates every adventure ever played."""
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
for fake_id in range(1000, 1000 + memorybank.VECTOR_CACHE_ADVENTURES + 2):
|
||||
memorybank._vectors_for(db, fake_id, [])
|
||||
assert len(memorybank._vector_cache) == memorybank.VECTOR_CACHE_ADVENTURES
|
||||
assert adventure.id not in memorybank._vector_cache # evicted, least recent
|
||||
|
||||
|
||||
def test_forget_cached_vectors_drops_an_adventure(db, adventure, settings, bank):
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
assert adventure.id in memorybank._vector_cache
|
||||
memorybank.forget_cached_vectors(adventure.id)
|
||||
assert adventure.id not in memorybank._vector_cache
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- eviction
|
||||
|
||||
def test_eviction_marks_the_least_recently_used(db, adventure, settings):
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
now = models.utcnow()
|
||||
recent = add_memory(db, adventure, "recent", (1.0, 0.0, 0.0), use_count=1)
|
||||
stale = add_memory(db, adventure, "stale", (0.0, 1.0, 0.0), use_count=1)
|
||||
recent.last_used_at = now
|
||||
stale.last_used_at = now - timedelta(days=30)
|
||||
db.commit()
|
||||
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
db.expire_all()
|
||||
|
||||
assert db.get(models.Memory, stale.id).forgotten is True
|
||||
assert db.get(models.Memory, recent.id).forgotten is False
|
||||
|
||||
|
||||
def test_eviction_breaks_ties_on_use_count(db, adventure, settings):
|
||||
"""Two memories last used at the same moment: the one the story has
|
||||
used less is the one that goes. This must be only a tiebreak. Ranking
|
||||
on the count first is what used to freeze the bank (see below)."""
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
now = models.utcnow()
|
||||
keep = add_memory(db, adventure, "used often", (1.0, 0.0, 0.0), use_count=5)
|
||||
drop = add_memory(db, adventure, "used once", (0.0, 1.0, 0.0), use_count=1)
|
||||
keep.last_used_at = drop.last_used_at = now
|
||||
db.commit()
|
||||
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
db.expire_all()
|
||||
|
||||
assert db.get(models.Memory, drop.id).forgotten is True
|
||||
assert db.get(models.Memory, keep.id).forgotten is False
|
||||
|
||||
|
||||
def test_a_newborn_is_not_evicted_by_the_bank_it_joins(db, adventure, settings):
|
||||
"""The bank used to stop accepting new memories. Eviction ranked on
|
||||
use_count first, and a memory written this turn has never been used.
|
||||
Once every existing memory had been retrieved even once, the newborn
|
||||
became the lowest-ranked row in the bank. Eviction then removed it in
|
||||
the same post-turn run that wrote it, before retrieval ever saw it.
|
||||
That state never recovers: counts only go up, so no memory written
|
||||
after it could get in either."""
|
||||
settings.memory_bank_capacity = 3
|
||||
db.commit()
|
||||
now = models.utcnow()
|
||||
established = [
|
||||
add_memory(db, adventure, f"used once {i}", (1.0, 0.0, 0.0),
|
||||
use_count=1, last_used_at=now - timedelta(minutes=3 - i))
|
||||
for i in range(3)
|
||||
]
|
||||
newborn = add_memory(db, adventure, "written this turn", (0.0, 1.0, 0.0))
|
||||
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
db.expire_all()
|
||||
|
||||
assert db.get(models.Memory, newborn.id).forgotten is False
|
||||
# the least recently used goes instead
|
||||
assert db.get(models.Memory, established[0].id).forgotten is True
|
||||
assert db.get(models.Memory, established[2].id).forgotten is False
|
||||
|
||||
|
||||
def test_a_full_bank_still_turns_over(db, adventure, settings):
|
||||
"""The same failure seen over several turns: a bank at capacity must
|
||||
keep accepting new memories, or the adventure stops remembering
|
||||
anything past the point where it filled up."""
|
||||
settings.memory_bank_capacity = 3
|
||||
now = models.utcnow()
|
||||
db.commit()
|
||||
for i in range(3):
|
||||
add_memory(db, adventure, f"opening {i}", (1.0, 0.0, 0.0),
|
||||
use_count=1, last_used_at=now - timedelta(minutes=10 - i))
|
||||
|
||||
later = []
|
||||
for turn in range(4):
|
||||
later.append(add_memory(db, adventure, f"turn {turn}", (0.0, 1.0, 0.0)))
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
db.expire_all()
|
||||
|
||||
active = {m.text for m in db.query(models.Memory).filter(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
models.Memory.forgotten.is_(False),
|
||||
)}
|
||||
assert active == {"turn 1", "turn 2", "turn 3"}
|
||||
|
||||
|
||||
def test_eviction_never_touches_a_pin(db, adventure, settings):
|
||||
"""Capacity yields to pins: if everything active is pinned there is nothing
|
||||
to evict, and the bank is allowed to sit over capacity."""
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
pins = [add_memory(db, adventure, f"pin {i}", (1.0, 0.0, 0.0), pinned=True)
|
||||
for i in range(3)]
|
||||
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
db.expire_all()
|
||||
|
||||
assert all(db.get(models.Memory, p.id).forgotten is False for p in pins)
|
||||
|
||||
|
||||
def test_eviction_reads_no_vectors(db, adventure, settings, bank, sql_log):
|
||||
"""It ran every turn and pulled the whole bank to count it."""
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
sql_log.clear()
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|
||||
|
||||
|
||||
def test_evicted_memories_drop_out_of_the_cache(db, adventure, settings, bank):
|
||||
retrieve(adventure, settings, StubEmbedder())
|
||||
settings.memory_bank_capacity = 1
|
||||
db.commit()
|
||||
memorybank._evict_over_capacity(adventure, settings, db)
|
||||
|
||||
used = retrieve(adventure, settings, StubEmbedder())["used"]
|
||||
assert len(used) == 1
|
||||
assert len(memorybank._vector_cache[adventure.id]) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------- embed queue
|
||||
|
||||
def test_embed_pending_picks_only_unembedded_memories(db, adventure, settings):
|
||||
done = add_memory(db, adventure, "already done", (1.0, 0.0, 0.0))
|
||||
todo = add_memory(db, adventure, "needs a vector", None)
|
||||
add_memory(db, adventure, "evicted, skip", None, forgotten=True)
|
||||
|
||||
embedder = StubEmbedder((0.0, 1.0, 0.0))
|
||||
memorybank.embedding_provider = lambda s: embedder
|
||||
asyncio.run(memorybank._embed_pending(adventure, settings, db))
|
||||
db.expire_all()
|
||||
|
||||
assert db.get(models.Memory, todo.id).embedded is True
|
||||
assert embedder.calls == 1
|
||||
# The one already embedded keeps the vector it had.
|
||||
assert db.get(models.Memory, done.id).embedding_blob == memorybank.vectors.pack(
|
||||
[1.0, 0.0, 0.0]
|
||||
)
|
||||
|
||||
|
||||
def test_embed_pending_reads_no_vectors(db, adventure, settings, bank, sql_log):
|
||||
add_memory(db, adventure, "needs a vector", None)
|
||||
embedder = StubEmbedder()
|
||||
memorybank.embedding_provider = lambda s: embedder
|
||||
|
||||
sql_log.clear()
|
||||
asyncio.run(memorybank._embed_pending(adventure, settings, db))
|
||||
assert not any("embedding_blob" in s for s in memory_selects(sql_log))
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Phase 18b: rewriting memories an older prompt wrote.
|
||||
|
||||
The prompt change only reaches memories written after it. A bank filled before
|
||||
it keeps its unnamed, second-person entries and injects them into every turn
|
||||
from then on, so there has to be a way to run the new prompt back over them.
|
||||
|
||||
Two halves, and the first is the one that can be wrong quietly:
|
||||
|
||||
* `memorybank.source_block` reads a memory's block back out of the story.
|
||||
Nothing in the app has ever had to do that. It has to read on the branch the
|
||||
memory was written on rather than the one the adventure is playing now, skip
|
||||
the sibling attempts at a retried turn, and cope with a memory whose actions
|
||||
have since been deleted.
|
||||
* `tools/rewrite_memories` replaces the text and clears the vector, leaves a
|
||||
hand-written memory alone, and writes nothing at all without `--write`.
|
||||
|
||||
python -m pytest tests/test_memory_rewrite.py -v
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app import memorybank, models, tree
|
||||
from app.context import lineage
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from tools import rewrite_memories
|
||||
|
||||
|
||||
class StubSummarizer:
|
||||
"""Returns a numbered memory, and records what it was asked.
|
||||
|
||||
The default text is what the new prompt asks for and the `OLD` text is what
|
||||
the old one produced, so an assertion says which prompt wrote a memory
|
||||
rather than counting calls.
|
||||
"""
|
||||
|
||||
OLD = "You entered the crypt {n}."
|
||||
|
||||
def __init__(self, template="Kaelen entered the crypt {n}.", error=None):
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.template = template
|
||||
self.error = error
|
||||
|
||||
async def complete(self, system, user, **kwargs):
|
||||
self.calls.append((system, user))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.template.format(n=len(self.calls))
|
||||
|
||||
|
||||
class StubEmbedder:
|
||||
def __init__(self):
|
||||
self.texts: list[str] = []
|
||||
|
||||
async def embed(self, texts):
|
||||
self.texts.extend(texts)
|
||||
return [[1.0, 0.0, 0.0] for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def make_adventure(db, *, actions=13, key="sk-test-key", email="rewrite@example.com"):
|
||||
"""An adventure whose bank was written by the old prompt.
|
||||
|
||||
Thirteen actions, not twelve: two blocks of six, plus the one action that
|
||||
settles the second of them. A block is not summarized while it ends on the
|
||||
newest action. See `memorybank.SETTLE_SLACK`.
|
||||
"""
|
||||
user = models.User(is_guest=False, email=email)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
# A key with no `enc:` prefix is stored plaintext and read back as-is; see
|
||||
# `security.decrypt_secret`. That keeps the fixture off the crypto path.
|
||||
db.add(models.Settings(user_id=user.id, api_key=key, model="test-model",
|
||||
embedding_model="text-embedding-3-small"))
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Camp", script_state={}, auto_summarize=True,
|
||||
memory="The player and Gwen are raiding a bandit camp.",
|
||||
persona_name="Kaelen", persona_pronouns="he/him",
|
||||
persona_desc="A half-elf ranger.",
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, name="Gwen",
|
||||
keys="Gwen, her", type="character",
|
||||
entry="A loyal ranger and the player's ally."))
|
||||
for i in range(actions):
|
||||
db.add(models.Action(adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do",
|
||||
text=f"You walk on. Action {i}."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
|
||||
|
||||
def fill_bank(db, adventure, *, count=2):
|
||||
"""Runs the real pass, so the memories carry the coordinates the real ones
|
||||
carry rather than coordinates this file made up."""
|
||||
stub = StubSummarizer(template=StubSummarizer.OLD)
|
||||
original = memorybank.summary_provider
|
||||
memorybank.summary_provider = lambda s: stub
|
||||
try:
|
||||
settings = (db.query(models.Settings)
|
||||
.filter(models.Settings.user_id == adventure.user_id).one())
|
||||
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
|
||||
finally:
|
||||
memorybank.summary_provider = original
|
||||
memories = (db.query(models.Memory)
|
||||
.filter(models.Memory.adventure_id == adventure.id)
|
||||
.order_by(models.Memory.id).all())
|
||||
assert len(memories) == count, f"expected {count} memories, got {len(memories)}"
|
||||
for memory in memories:
|
||||
memorybank.set_vector(memory, [1.0, 0.0, 0.0])
|
||||
db.commit()
|
||||
return memories
|
||||
|
||||
|
||||
def options(**overrides):
|
||||
args = dict(write=False, adventure=None, email=None, limit=None,
|
||||
include_forgotten=False, embed=False, endpoint=None,
|
||||
model=None)
|
||||
args.update(overrides)
|
||||
return argparse.Namespace(**args)
|
||||
|
||||
|
||||
def run_tool(args) -> int:
|
||||
return asyncio.run(rewrite_memories.main(args))
|
||||
|
||||
|
||||
# ------------------------------------------------------ reading the block back
|
||||
|
||||
def test_the_block_is_the_actions_the_memory_covers(db):
|
||||
adventure = make_adventure(db)
|
||||
first, second = fill_bank(db, adventure)
|
||||
block = memorybank.source_block(db, first)
|
||||
assert [a.depth for a in block] == list(
|
||||
range(first.source_start, first.source_end + 1))
|
||||
assert len(block) == memorybank.MEMORY_INTERVAL
|
||||
assert [a.text for a in block] == [f"You walk on. Action {i}." for i in range(6)]
|
||||
assert [a.text for a in memorybank.source_block(db, second)] == [
|
||||
f"You walk on. Action {i}." for i in range(6, 12)]
|
||||
|
||||
|
||||
def test_a_hand_written_memory_has_no_block(db):
|
||||
"""It summarizes no actions, so there is nothing to rewrite it from — and
|
||||
the player may have typed it."""
|
||||
adventure = make_adventure(db)
|
||||
memory = models.Memory(adventure_id=adventure.id, text="Kaelen owes a debt.")
|
||||
tree.place_memory(db, adventure, memory)
|
||||
db.add(memory)
|
||||
db.commit()
|
||||
assert memorybank.source_block(db, memory) == []
|
||||
|
||||
|
||||
def test_a_deleted_action_shortens_the_block(db):
|
||||
"""A memory whose block is now partial still describes what remains."""
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
db.delete(memorybank.source_block(db, first)[2])
|
||||
db.commit()
|
||||
block = memorybank.source_block(db, first)
|
||||
assert len(block) == memorybank.MEMORY_INTERVAL - 1
|
||||
assert "Action 2." not in [a.text for a in block]
|
||||
|
||||
|
||||
def test_a_retried_turn_contributes_only_its_live_attempt(db):
|
||||
"""Sibling attempts share a coordinate. The block holds the one the story
|
||||
used, not both."""
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
kept = memorybank.source_block(db, first)[3]
|
||||
db.add(models.Action(adventure_id=adventure.id, branch_id=kept.branch_id,
|
||||
depth=kept.depth, live=False, type=kept.type,
|
||||
text="A discarded attempt."))
|
||||
db.commit()
|
||||
texts = [a.text for a in memorybank.source_block(db, first)]
|
||||
assert "A discarded attempt." not in texts
|
||||
assert len(texts) == memorybank.MEMORY_INTERVAL
|
||||
|
||||
|
||||
def test_the_block_is_read_on_the_branch_the_memory_was_written_on(db):
|
||||
"""The adventure has moved to a fork since. A memory on the trunk still
|
||||
reads back, and one left on the abandoned continuation reads that
|
||||
continuation rather than the branch now being played."""
|
||||
adventure = make_adventure(db)
|
||||
trunk_memory, _ = fill_bank(db, adventure)
|
||||
trunk = tree.head_branch(db, adventure)
|
||||
|
||||
fork = models.Branch(adventure_id=adventure.id, parent_branch_id=trunk.id,
|
||||
fork_depth=5, lineage=[])
|
||||
db.add(fork)
|
||||
db.flush()
|
||||
fork.lineage = [[fork.id, None], [trunk.id, 5]]
|
||||
for depth in (6, 7, 8, 9, 10, 11):
|
||||
db.add(models.Action(adventure_id=adventure.id, branch_id=fork.id,
|
||||
depth=depth, type="do", text=f"Fork action {depth}."))
|
||||
db.flush()
|
||||
tip = (db.query(models.Action)
|
||||
.filter(models.Action.branch_id == fork.id)
|
||||
.order_by(models.Action.depth.desc()).first())
|
||||
fork_memory = models.Memory(adventure_id=adventure.id, text="On the fork.",
|
||||
source_start=6, source_end=11)
|
||||
tree.attach_memory(fork_memory, tip)
|
||||
db.add(fork_memory)
|
||||
adventure.head_branch_id = fork.id
|
||||
adventure.head_depth = 11
|
||||
db.commit()
|
||||
|
||||
# The trunk memory predates the fork and is inherited, so it reads the same
|
||||
# actions from either branch.
|
||||
assert [a.text for a in memorybank.source_block(db, trunk_memory)] == [
|
||||
f"You walk on. Action {i}." for i in range(6)]
|
||||
# The abandoned continuation's memory covers depths 6..11 on the trunk. The
|
||||
# fork covers the same depths with different actions, and the head is on the
|
||||
# fork, so a read that used the adventure's path would return the fork's.
|
||||
abandoned = (db.query(models.Memory)
|
||||
.filter(models.Memory.branch_id == trunk.id,
|
||||
models.Memory.source_start == 6).one())
|
||||
assert [a.text for a in memorybank.source_block(db, abandoned)] == [
|
||||
f"You walk on. Action {i}." for i in range(6, 12)]
|
||||
assert [a.text for a in memorybank.source_block(db, fork_memory)] == [
|
||||
f"Fork action {i}." for i in range(6, 12)]
|
||||
|
||||
|
||||
def test_the_rewrite_prompt_is_the_one_the_app_sends(db):
|
||||
"""`summarize_block` is the app's own prompt assembly. A rewrite that built
|
||||
its own would be written by a prompt that never shipped."""
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
stub = StubSummarizer()
|
||||
asyncio.run(memorybank.summarize_block(
|
||||
adventure, stub, memorybank.source_block(db, first)))
|
||||
system, user = stub.calls[0]
|
||||
assert "third person" in system
|
||||
assert user.index("Cast:") < user.index("Story excerpt:")
|
||||
assert "Kaelen (he/him) — the protagonist" in user
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- the tool
|
||||
|
||||
def test_without_write_nothing_changes(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
stub = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
assert run_tool(options()) == 0
|
||||
db.expire_all()
|
||||
assert stub.calls == [], "a dry run must not call the model"
|
||||
assert first.text == "You entered the crypt 1."
|
||||
assert first.embedded is True
|
||||
|
||||
|
||||
def test_write_replaces_the_text_and_clears_the_vector(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, second = fill_bank(db, adventure)
|
||||
stub = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
assert run_tool(options(write=True)) == 0
|
||||
db.expire_all()
|
||||
assert [first.text, second.text] == ["Kaelen entered the crypt 1.", "Kaelen entered the crypt 2."]
|
||||
# The stored vector describes wording that no longer exists, so the memory
|
||||
# leaves the ranked bank until something embeds the new text.
|
||||
assert (first.embedded, first.embedding_blob) == (False, None)
|
||||
|
||||
|
||||
def test_embed_puts_the_rewritten_memories_back_in_the_bank(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
embedder = StubEmbedder()
|
||||
summarizer = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: summarizer)
|
||||
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: embedder)
|
||||
assert run_tool(options(write=True, embed=True)) == 0
|
||||
db.expire_all()
|
||||
assert embedder.texts == ["Kaelen entered the crypt 1.", "Kaelen entered the crypt 2."]
|
||||
assert first.embedded is True
|
||||
|
||||
|
||||
def test_a_hand_written_memory_is_left_alone(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
fill_bank(db, adventure)
|
||||
typed = models.Memory(adventure_id=adventure.id, text="Kaelen owes a debt.")
|
||||
tree.place_memory(db, adventure, typed)
|
||||
db.add(typed)
|
||||
db.commit()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummarizer())
|
||||
assert run_tool(options(write=True)) == 0
|
||||
db.expire_all()
|
||||
assert typed.text == "Kaelen owes a debt."
|
||||
|
||||
|
||||
def test_an_adventure_with_no_model_configured_is_skipped(db, monkeypatch):
|
||||
"""Nothing to summarize with, so the memory is reported rather than
|
||||
silently left as it was. This used to be about the owner's API key; after
|
||||
M2 the thing that can be missing is the model name."""
|
||||
adventure = make_adventure(db)
|
||||
_blank_model(db, adventure)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
stub = StubSummarizer()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
assert run_tool(options(write=True)) == 0
|
||||
db.expire_all()
|
||||
assert stub.calls == []
|
||||
assert first.text == "You entered the crypt 1."
|
||||
|
||||
|
||||
def test_a_model_on_the_command_line_covers_that_adventure(db, monkeypatch):
|
||||
"""How to run the rewrite against a model other than the configured one."""
|
||||
adventure = make_adventure(db)
|
||||
_blank_model(db, adventure)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
built: list[tuple] = []
|
||||
|
||||
def build(*args, **kwargs):
|
||||
built.append(args)
|
||||
return StubSummarizer()
|
||||
|
||||
monkeypatch.setattr("app.providers.OpenAICompatibleProvider", build)
|
||||
assert run_tool(options(write=True, model="other-model",
|
||||
endpoint="http://127.0.0.1:8787/v1")) == 0
|
||||
db.expire_all()
|
||||
assert first.text == "Kaelen entered the crypt 1."
|
||||
assert built[0][:2] == ("http://127.0.0.1:8787/v1", "other-model")
|
||||
|
||||
|
||||
def _blank_model(db, adventure) -> None:
|
||||
settings = (
|
||||
db.query(models.Settings)
|
||||
.filter(models.Settings.user_id == adventure.user_id)
|
||||
.first()
|
||||
)
|
||||
settings.model = ""
|
||||
settings.summary_model = ""
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_limit_stops_early(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, second = fill_bank(db, adventure)
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummarizer())
|
||||
assert run_tool(options(write=True, limit=1)) == 0
|
||||
db.expire_all()
|
||||
assert first.text == "Kaelen entered the crypt 1."
|
||||
assert second.text == "You entered the crypt 2."
|
||||
|
||||
|
||||
def test_a_provider_error_leaves_the_old_text_and_reports_it(db, monkeypatch):
|
||||
from app.providers import ProviderError
|
||||
adventure = make_adventure(db)
|
||||
first, _ = fill_bank(db, adventure)
|
||||
monkeypatch.setattr(memorybank, "summary_provider",
|
||||
lambda s: StubSummarizer(error=ProviderError("nope")))
|
||||
assert run_tool(options(write=True)) == 1
|
||||
db.expire_all()
|
||||
assert first.text == "You entered the crypt 1."
|
||||
assert first.embedded is True
|
||||
|
||||
|
||||
def test_only_the_named_adventure_is_touched(db, monkeypatch):
|
||||
one = make_adventure(db)
|
||||
two = make_adventure(db, email="other@example.com")
|
||||
kept, _ = fill_bank(db, one)
|
||||
changed, _ = fill_bank(db, two)
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummarizer())
|
||||
assert run_tool(options(write=True, adventure=[two.id])) == 0
|
||||
db.expire_all()
|
||||
assert kept.text == "You entered the crypt 1."
|
||||
assert changed.text == "Kaelen entered the crypt 1."
|
||||
|
||||
|
||||
def test_only_the_named_account_is_touched(db, monkeypatch):
|
||||
"""The hosted database holds other people's stories, and each adventure is
|
||||
summarized with its owner's key."""
|
||||
mine = make_adventure(db, email="mine@example.com")
|
||||
theirs = make_adventure(db, email="theirs@example.com")
|
||||
kept, _ = fill_bank(db, theirs)
|
||||
changed, _ = fill_bank(db, mine)
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummarizer())
|
||||
assert run_tool(options(write=True, email=["MINE@example.com"])) == 0
|
||||
db.expire_all()
|
||||
assert kept.text == "You entered the crypt 1."
|
||||
assert changed.text == "Kaelen entered the crypt 1."
|
||||
|
||||
|
||||
def test_an_unknown_email_is_an_error(db):
|
||||
make_adventure(db)
|
||||
assert run_tool(options(email=["nobody@example.com"])) == 2
|
||||
|
||||
|
||||
def test_an_unknown_adventure_id_is_an_error(db):
|
||||
make_adventure(db)
|
||||
assert run_tool(options(adventure=[9999])) == 2
|
||||
|
||||
|
||||
def test_an_evicted_memory_is_left_out_unless_asked_for(db, monkeypatch):
|
||||
adventure = make_adventure(db)
|
||||
first, second = fill_bank(db, adventure)
|
||||
second.forgotten = True
|
||||
db.commit()
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummarizer())
|
||||
assert run_tool(options(write=True)) == 0
|
||||
db.expire_all()
|
||||
assert second.text == "You entered the crypt 2."
|
||||
assert run_tool(options(write=True, include_forgotten=True)) == 0
|
||||
db.expire_all()
|
||||
assert second.text.startswith("Kaelen entered")
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Memories must never describe narration that is no longer in the story, and
|
||||
must never skip a stretch of it.
|
||||
|
||||
For six phases, the answer was a holdback. Summarization stopped one action
|
||||
short of the newest, because only the last action was retryable, and a
|
||||
retry rewrote `Action.text` under a mark that had already moved past it.
|
||||
SP4 ended that: a retry writes a sibling node, and the coordinate's derived
|
||||
work is withdrawn as it happens, using the same repair that undo and delete
|
||||
already made. Correctness has rested on that withdrawal ever since, and the
|
||||
second half of this file is where it is asserted.
|
||||
|
||||
The first half is about what the withdrawal costs. Redoing a block is
|
||||
correct and it is also paid for twice, so `memorybank.SETTLE_SLACK` keeps a
|
||||
block from ending on the newest action, which is the only action retry and
|
||||
take-switching can reach. A block forms as soon as one action has settled
|
||||
past it, and changing what a coordinate says still takes back what was
|
||||
derived from it.
|
||||
|
||||
Phase 14 SP3 changed what the mark is. It used to be a count of covered
|
||||
story actions, and the second half of this file is the cost of that.
|
||||
Deleting an action from in front of a position slid a never-summarized
|
||||
action into the covered range, so every delete had to slide the cursors
|
||||
too. The mark is a node now, `(branch_id, depth)`, and a node does not move
|
||||
when something in front of it is deleted. Those tests now assert that
|
||||
nothing happens, where they used to assert that the right correction
|
||||
happened.
|
||||
|
||||
python -m pytest tests/test_memory_settling.py -v
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from app import memorybank, models, tree
|
||||
from app.context import cursors, history
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
|
||||
class StubSummarizer:
|
||||
"""Records every excerpt handed to the summarizer."""
|
||||
|
||||
def __init__(self):
|
||||
self.excerpts: list[str] = []
|
||||
|
||||
async def complete(self, system, user, **kwargs):
|
||||
self.excerpts.append(user)
|
||||
return f"Memory {len(self.excerpts)}."
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def make_adventure(db, action_count: int) -> models.Adventure:
|
||||
"""An adventure whose actions alternate player/AI, newest last."""
|
||||
user = models.User(is_guest=False, email="memory@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
settings = models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")
|
||||
db.add(settings)
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Cave", script_state={}, auto_summarize=True
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
for i in range(action_count):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do", text=f"Action {i}.",
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
|
||||
|
||||
def cover(db, adventure, position: int) -> None:
|
||||
"""Mark the first `position` story actions as already summarized.
|
||||
|
||||
Written as a position and translated to the node it names, because that is
|
||||
what every adventure in the database looked like before SP3 and what a v1
|
||||
bundle still carries.
|
||||
"""
|
||||
cursors.anchor_at_position(adventure, cursors.MEMORY, position)
|
||||
cursors.anchor_at_position(adventure, cursors.SUMMARY, position)
|
||||
db.commit()
|
||||
|
||||
|
||||
def covered_depth(db, adventure) -> int:
|
||||
"""The memory mark, as a depth on the story being played."""
|
||||
return cursors.MEMORY.depth(db, adventure)
|
||||
|
||||
|
||||
def run_memories(db, adventure, stub, monkeypatch):
|
||||
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
|
||||
settings = db.query(models.Settings).first()
|
||||
asyncio.run(memorybank._create_due_memories(adventure, settings, db))
|
||||
|
||||
|
||||
# ------------------------------------------- when a block forms (SETTLE_SLACK)
|
||||
|
||||
def test_a_block_that_ends_on_the_newest_action_waits(db, monkeypatch):
|
||||
"""Covered to action 5 with 12 actions: block 6-11 is full, but it ends on
|
||||
the newest action, so it is not written yet.
|
||||
|
||||
Writing it would be correct. A retry of node 11 withdraws it on its way
|
||||
past, which is the same repair as
|
||||
`test_deleting_a_summarized_node_withdraws_its_memory`. It would also mean
|
||||
the block was summarized once, thrown away, and summarized again, for a
|
||||
memory that says nothing the history window does not still carry.
|
||||
"""
|
||||
adventure = make_adventure(db, 12)
|
||||
cover(db, adventure, 6)
|
||||
|
||||
stub = StubSummarizer()
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
|
||||
assert stub.excerpts == []
|
||||
assert covered_depth(db, adventure) == 5 # the mark stays where it was
|
||||
|
||||
# One more action settles the block. It is written from the same six: the
|
||||
# slack asks for story past the block, it does not grow the block.
|
||||
db.add(models.Action(adventure_id=adventure.id, type="ai", text="Action 12."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
|
||||
assert len(stub.excerpts) == 1
|
||||
assert "Action 11." in stub.excerpts[0]
|
||||
assert "Action 12." not in stub.excerpts[0]
|
||||
memory = db.query(models.Memory).one()
|
||||
assert (memory.source_start, memory.source_end) == (6, 11)
|
||||
# The mark and the memory name the same node. That is what keeps them
|
||||
# from drifting apart, however gappy the underlying depths are.
|
||||
assert (memory.branch_id, memory.depth) == cursors.MEMORY.stored(adventure)
|
||||
assert covered_depth(db, adventure) == 11
|
||||
|
||||
|
||||
def test_a_retry_at_the_tip_has_no_memory_to_withdraw(db, monkeypatch):
|
||||
"""The slack, stated as the case it removes.
|
||||
|
||||
Retry and take-switching both refuse anything but the newest action
|
||||
(`takes.retry_action`, `takes.switch_take`), so holding the block end one
|
||||
action back puts every memory out of their reach. Nothing here is a claim
|
||||
about the withdrawal path, which undo and delete still reach at any node.
|
||||
"""
|
||||
adventure = make_adventure(db, 13)
|
||||
run_memories(db, adventure, StubSummarizer(), monkeypatch)
|
||||
|
||||
assert db.query(models.Memory).count() == 2 # blocks 0-5 and 6-11
|
||||
assert covered_depth(db, adventure) == 11
|
||||
|
||||
tip = history.newest(adventure)
|
||||
assert tip.depth == 12 # the deepest memory is a node behind it
|
||||
assert memorybank.forget_node(db, adventure, tip) == 0
|
||||
assert covered_depth(db, adventure) == 11 # so a retry rewinds nothing
|
||||
|
||||
|
||||
def test_the_first_memory_lands_at_memory_start(db, monkeypatch):
|
||||
adventure = make_adventure(db, memorybank.MEMORY_START - 1)
|
||||
stub = StubSummarizer()
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
assert stub.excerpts == [] # too short to have started at all
|
||||
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="do", text="Later.",
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
# MEMORY_START is 12 actions = two full blocks. The first is settled,
|
||||
# because the second sits past it. The second ends on the newest action
|
||||
# and waits.
|
||||
assert len(stub.excerpts) == 1
|
||||
assert "Later." not in stub.excerpts[-1]
|
||||
assert covered_depth(db, adventure) == memorybank.MEMORY_INTERVAL - 1
|
||||
|
||||
# One action past the second block settles it too, and both are caught up
|
||||
# in one run (MAX_MEMORIES_PER_RUN allows 5).
|
||||
db.add(models.Action(adventure_id=adventure.id, type="ai", text="Even later."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
assert len(stub.excerpts) == 2
|
||||
assert "Later." in stub.excerpts[-1]
|
||||
assert covered_depth(db, adventure) == memorybank.MEMORY_START - 1
|
||||
|
||||
|
||||
def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch):
|
||||
"""An adventure summarized under the old rule carries a cursor equal to
|
||||
its action count, one past the end of the story. That used to require a
|
||||
clamp on every post-turn pass, and clamping it to the settled count
|
||||
re-covered an action.
|
||||
|
||||
A mark that names a node has no such edge. The newest action is the
|
||||
node, and "everything after it" is empty until the story grows.
|
||||
"""
|
||||
adventure = make_adventure(db, 12)
|
||||
db.add(models.Memory(adventure_id=adventure.id, text="A", source_start=0, source_end=5))
|
||||
db.add(models.Memory(adventure_id=adventure.id, text="B", source_start=6, source_end=11))
|
||||
cover(db, adventure, 12)
|
||||
|
||||
assert covered_depth(db, adventure) == 11 # the newest action, not one past it
|
||||
assert history.count_after(adventure, covered_depth(db, adventure)) == 0
|
||||
|
||||
# Grow the story and let the next block form.
|
||||
for i in range(12, 25):
|
||||
db.add(models.Action(adventure_id=adventure.id, type="do", text=f"Action {i}."))
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
run_memories(db, adventure, StubSummarizer(), monkeypatch)
|
||||
|
||||
covered: dict[int, int] = {}
|
||||
for m in db.query(models.Memory).all():
|
||||
for i in range(m.source_start, m.source_end + 1):
|
||||
covered[i] = covered.get(i, 0) + 1
|
||||
assert [i for i, c in covered.items() if c > 1] == [] # no action summarized twice
|
||||
assert [i for i in range(max(covered) + 1) if i not in covered] == [] # no gaps
|
||||
|
||||
|
||||
def test_no_memories_before_memory_start(db, monkeypatch):
|
||||
adventure = make_adventure(db, 8)
|
||||
stub = StubSummarizer()
|
||||
run_memories(db, adventure, stub, monkeypatch)
|
||||
assert stub.excerpts == []
|
||||
|
||||
|
||||
# ------------------------------------------- deleting already-summarized ground
|
||||
|
||||
def orphans(db, adventure) -> list[int]:
|
||||
"""Depths the mark calls summarized that no memory describes.
|
||||
|
||||
The failure this whole section is about, stated once: an action behind the
|
||||
mark with nothing covering it is never summarized again, and nothing ever
|
||||
reports it.
|
||||
"""
|
||||
covered: set[int] = set()
|
||||
for m in db.query(models.Memory).filter_by(adventure_id=adventure.id):
|
||||
covered |= set(range(m.source_start, m.source_end + 1))
|
||||
mark = cursors.MEMORY.depth(db, adventure)
|
||||
return [
|
||||
a.depth for a in memorybank.story_actions(adventure)
|
||||
if a.depth <= mark and a.depth not in covered
|
||||
]
|
||||
|
||||
|
||||
def summarized_adventure(db):
|
||||
"""13 actions with two memories covering depths 0-11, the mark on node 11."""
|
||||
adventure = make_adventure(db, 13)
|
||||
for text, start, end in (("A", 0, 5), ("B", 6, 11)):
|
||||
node = db.query(models.Action).filter_by(
|
||||
adventure_id=adventure.id, depth=end
|
||||
).one()
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id, text=text, source_start=start, source_end=end
|
||||
)
|
||||
tree.attach_memory(memory, node)
|
||||
db.add(memory)
|
||||
cover(db, adventure, 12)
|
||||
db.refresh(adventure)
|
||||
return adventure
|
||||
|
||||
|
||||
def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db):
|
||||
"""The bug that motivated the old machinery, and the reason it is gone.
|
||||
|
||||
A position cursor counted actions from the start, so deleting an earlier
|
||||
one slid a never-summarized action into the covered range and every delete
|
||||
had to correct for it. A depth is not a count: node 11 is still node 11
|
||||
with node 5 gone.
|
||||
"""
|
||||
adventure = summarized_adventure(db)
|
||||
# Node 4 is inside memory A's block but is not the node it hangs off,
|
||||
# so nothing is withdrawn. The old code read it the same way: only a
|
||||
# memory whose end had fallen off the story was pruned.
|
||||
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=4).one()
|
||||
|
||||
assert memorybank.forget_node(db, adventure, victim) == 0
|
||||
db.delete(victim)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
|
||||
assert covered_depth(db, adventure) == 11
|
||||
assert [m.text for m in db.query(models.Memory).all()] == ["A", "B"]
|
||||
assert orphans(db, adventure) == []
|
||||
|
||||
|
||||
def test_deleting_a_later_action_leaves_the_mark_alone(db):
|
||||
adventure = summarized_adventure(db)
|
||||
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=12).one()
|
||||
|
||||
memorybank.forget_node(db, adventure, victim)
|
||||
db.delete(victim)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
|
||||
assert covered_depth(db, adventure) == 11
|
||||
assert orphans(db, adventure) == []
|
||||
|
||||
|
||||
def test_deleting_a_summarized_node_withdraws_its_memory(db):
|
||||
"""Discarding the memory is not enough. The story it covered is still
|
||||
behind the mark, so the mark has to move back to where that block began.
|
||||
|
||||
Memory B ends on node 11, so deleting node 11 withdraws it. The old code
|
||||
found this by scanning for a memory whose covered range had fallen off
|
||||
the end of the story. Now the memory hangs off the node, so finding it
|
||||
is a lookup.
|
||||
"""
|
||||
adventure = summarized_adventure(db)
|
||||
victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=11).one()
|
||||
|
||||
assert memorybank.forget_node(db, adventure, victim) == 1
|
||||
db.delete(victim)
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
|
||||
assert [m.text for m in db.query(models.Memory).all()] == ["A"]
|
||||
assert covered_depth(db, adventure) == 5 # back to where the discarded memory began
|
||||
assert cursors.SUMMARY.depth(db, adventure) == 5 # and the summary with it
|
||||
assert orphans(db, adventure) == []
|
||||
|
||||
|
||||
def test_repeated_deletes_never_orphan_an_action(db):
|
||||
"""The scenario that motivated this: undo/delete-last, over and over.
|
||||
|
||||
No clamp in the loop any more, and no bookkeeping call per delete beyond
|
||||
withdrawing what the node produced.
|
||||
"""
|
||||
adventure = summarized_adventure(db)
|
||||
for _ in range(6):
|
||||
actions = memorybank.story_actions(adventure)
|
||||
if not actions:
|
||||
break
|
||||
victim = max(actions, key=lambda a: a.depth)
|
||||
memorybank.forget_node(db, adventure, victim)
|
||||
db.delete(victim)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
assert orphans(db, adventure) == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user