Rewrite Python comments in Google developer documentation style (#12)
* Rewrite comments in Google developer documentation style Rewrite the comments and docstrings across the backend core modules so they read plainly. The previous prose was accurate but dense and figurative, which made it slow to skim. Applies the Google developer documentation style guide: short sentences, active voice, present tense, American spelling, and no metaphors, idioms, or rhetorical asides. Replaces em-dash chains with separate sentences.
This commit is contained in:
+45
-37
@@ -3,25 +3,25 @@
|
||||
The deliberate opposite of analytics.py. That module counts and stores nothing
|
||||
that points at a person; this one records addresses, email addresses and
|
||||
devices, because an access log that cannot identify the access is not an access
|
||||
log. They are kept in separate modules and separate tables on purpose — the
|
||||
anonymity of the counters is then a property of the code rather than of a
|
||||
convention someone has to remember.
|
||||
log. The two live in separate modules and separate tables on purpose, so that
|
||||
the anonymity of the counters is a property of the code rather than a convention
|
||||
someone has to remember.
|
||||
|
||||
Owner-only, and never shown to the people it records.
|
||||
|
||||
Four kinds of row:
|
||||
|
||||
- `session` a browser that has a session made a request — for a guest,
|
||||
their first visit;
|
||||
- `login` an existing account signed in;
|
||||
- `register` a guest upgraded to an account;
|
||||
- `login_failed` a password attempt that didn't match, with the address tried.
|
||||
- `session` A browser that has a session made a request. For a guest, this
|
||||
is their first visit.
|
||||
- `login` An existing account signed in.
|
||||
- `register` A guest upgraded to an account.
|
||||
- `login_failed` A password attempt that did not match, with the address tried.
|
||||
|
||||
Session rows are the only ones that need thinning: `/auth/me` runs on every
|
||||
page load, and a row per load would be noise rather than a log. One is written
|
||||
when the day or the address changes for that user, which is the granularity a
|
||||
log is actually read at — "seen on the 3rd from 1.2.3.4" — and it still catches
|
||||
someone moving networks mid-day.
|
||||
Session rows are the only ones that need thinning. `/auth/me` runs on every page
|
||||
load, and one row per load would be noise rather than a log. A row is written
|
||||
when the day or the address changes for that user. That is the granularity a log
|
||||
is read at, such as seen on the 3rd from 1.2.3.4, and it still records someone
|
||||
moving networks during a day.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -50,31 +50,36 @@ _MAX_TRACKED = 10_000
|
||||
|
||||
|
||||
def _client_ip(request) -> str:
|
||||
# Deferred: limits imports auth, which is imported by the routers that call
|
||||
# this, so a module-level import here would close the loop. The spoof
|
||||
# resistance lives there and must not be reimplemented — a second, laxer
|
||||
# copy of "what is the client's address" is exactly how one of them ends up
|
||||
# trusting a header it shouldn't.
|
||||
# This import is deferred. `limits` imports `auth`, which the routers that
|
||||
# call this function import, so a module-level import here would create a
|
||||
# cycle. The spoof resistance lives in `limits` and must not be
|
||||
# reimplemented. A second, looser answer to which address belongs to the
|
||||
# client is how one of them ends up trusting a header it should not.
|
||||
from . import limits
|
||||
|
||||
return limits.client_ip(request)
|
||||
|
||||
|
||||
def describe(user: models.User) -> str:
|
||||
"""How a user is named in the log. Guests have no email, and their id is
|
||||
the only handle anyone has for them. The third case is a local install's
|
||||
implicit single user, which is also email-less but is the operator rather
|
||||
than a visitor — calling that one "Guest #1" would be a small lie in the
|
||||
one row they are certain to read."""
|
||||
"""Returns how a user is named in the log.
|
||||
|
||||
A guest has no email, and their id is the only handle anyone has for them.
|
||||
The third case is a local install's implicit single user, who also has no
|
||||
email but is the operator rather than a visitor. Naming that user "Guest #1"
|
||||
would be wrong in the one row they are certain to read.
|
||||
"""
|
||||
if user.email:
|
||||
return user.email
|
||||
return f"Guest #{user.id}" if user.is_guest else f"Local user #{user.id}"
|
||||
|
||||
|
||||
def _country(request) -> str:
|
||||
"""The edge's country header, or "" when there isn't one. Blank rather than
|
||||
the counters' "(unknown)" label: a table column reads better as an em dash
|
||||
than as a word, and an empty string is the honest value for "not known"."""
|
||||
"""Returns the edge's country header, or "" when there is none.
|
||||
|
||||
The blank differs from the counters' "(unknown)" label. A table column reads
|
||||
better as a dash than as a word, and an empty string is the correct value for
|
||||
a country that is not known.
|
||||
"""
|
||||
country = analytics.country_of(request.headers)
|
||||
return "" if country == analytics.UNKNOWN else country
|
||||
|
||||
@@ -87,8 +92,11 @@ def record(
|
||||
user: models.User | None = None,
|
||||
who: str | None = None,
|
||||
) -> None:
|
||||
"""Write one row. Never raises: the log watches sign-in, it doesn't guard
|
||||
it, and a logging failure must not be able to lock anyone out."""
|
||||
"""Writes one row.
|
||||
|
||||
This function never raises. The log observes sign-in rather than guarding it,
|
||||
and a logging failure must not lock anyone out.
|
||||
"""
|
||||
try:
|
||||
event = models.AccessEvent(
|
||||
kind=kind,
|
||||
@@ -108,7 +116,7 @@ def record(
|
||||
|
||||
|
||||
def note_session(db: Session, user: models.User, request) -> None:
|
||||
"""A session made a request. Thinned to one row per day per address."""
|
||||
"""Records that a session made a request, at most one row per day per address."""
|
||||
try:
|
||||
today = analytics._today()
|
||||
ip = _client_ip(request)
|
||||
@@ -117,8 +125,8 @@ def note_session(db: Session, user: models.User, request) -> None:
|
||||
return
|
||||
_last_session[user.id] = (today, ip)
|
||||
if len(_last_session) > _MAX_TRACKED:
|
||||
# Nothing here is worth persisting; dropping the map costs at
|
||||
# most one extra row per active user.
|
||||
# Nothing here needs to persist. Clearing the map costs at most
|
||||
# one extra row per active user.
|
||||
_last_session.clear()
|
||||
_last_session[user.id] = (today, ip)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
@@ -135,11 +143,11 @@ def recent(
|
||||
kind: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict:
|
||||
"""A page of the log, newest first.
|
||||
"""Returns a page of the log, newest first.
|
||||
|
||||
Anchored on a row id rather than an offset, like the story pager: rows keep
|
||||
arriving while it is being read, and an offset would shift the page under
|
||||
whoever is reading it.
|
||||
The page is anchored on a row id rather than an offset, as the story pager
|
||||
is. Rows keep arriving while the log is read, and an offset would shift the
|
||||
page under whoever is reading it.
|
||||
"""
|
||||
statement = select(models.AccessEvent).order_by(desc(models.AccessEvent.id))
|
||||
if before_id is not None:
|
||||
@@ -153,8 +161,8 @@ def recent(
|
||||
models.AccessEvent.ip.ilike(like),
|
||||
models.AccessEvent.country.ilike(like),
|
||||
))
|
||||
# One extra row answers "is there more" without a second COUNT over the
|
||||
# whole table.
|
||||
# Requesting one extra row reports whether more rows exist, without a
|
||||
# second COUNT over the whole table.
|
||||
rows = list(db.scalars(statement.limit(limit + 1)))
|
||||
has_more = len(rows) > limit
|
||||
return {"events": rows[:limit], "has_more": has_more}
|
||||
|
||||
+149
-122
@@ -1,34 +1,38 @@
|
||||
"""Visit analytics for the hosted demo.
|
||||
|
||||
A small self-hosted counter answering "did anyone visit, and did they play?",
|
||||
built into the app rather than bolted on with a third-party script: the CSP in
|
||||
main.py allows scripts from 'self' only, adblockers eat the popular trackers,
|
||||
and none of them can see the things actually worth knowing here (turns taken,
|
||||
demo-key spend, which seeded scenario people pick).
|
||||
This is a small self-hosted counter that answers whether anyone visited and
|
||||
whether they played. It is built into the app rather than added with a
|
||||
third-party script, because the CSP in `main.py` allows scripts from 'self'
|
||||
only, ad blockers block the popular trackers, and none of those trackers can see
|
||||
what is worth knowing here: turns taken, demo-key spend, and which seeded
|
||||
scenario people pick.
|
||||
|
||||
Three rules shaped it:
|
||||
Three rules shape the design:
|
||||
|
||||
1. **Nothing personal is stored.** No IP addresses, no user agents, no user
|
||||
ids, no title of anything a player wrote. A visitor appears only as an HMAC
|
||||
of their user id — one-way and salted with the app's secret key, so these
|
||||
tables cannot be joined back to an account even by someone holding the
|
||||
database. Story content never reaches this module at all. What a *specific*
|
||||
person did is deliberately unanswerable; only totals are.
|
||||
2. **Egress is the budget.** Neon bills for bytes leaving the database and this
|
||||
project has already paid for forgetting that once. So counts are aggregated
|
||||
in memory and flushed as UPSERTs — a visit is a write, never a read — and
|
||||
every dashboard query is a GROUP BY returning tens of rows, never per-visit
|
||||
rows. A month of traffic costs a few kilobytes to read back.
|
||||
3. **The numbers are the server's, not the browser's.** The client reports one
|
||||
thing: which page was viewed. Everything that *means* something ("a turn
|
||||
happened", "an account was created") is recorded by the code that does it,
|
||||
where it can be neither faked by a stranger nor blocked by an extension.
|
||||
1. It stores nothing personal. It records no IP addresses, no user agents, no
|
||||
user ids, and no title of anything a player wrote. A visitor appears only as
|
||||
an HMAC of their user id, which is one-way and salted with the app's secret
|
||||
key, so these tables cannot be joined back to an account even by someone
|
||||
holding the database. Story content never reaches this module. What one
|
||||
specific person did is unanswerable by design, and only totals are
|
||||
available.
|
||||
2. Egress is the budget. Neon bills for bytes leaving the database, and this
|
||||
project has already paid for forgetting that once. Counts are therefore
|
||||
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
|
||||
rather than per-visit rows. A month of traffic costs a few kilobytes to read
|
||||
back.
|
||||
3. The numbers come from the server, not from the browser. The client reports
|
||||
one thing, which is the page that was viewed. Everything with meaning, such
|
||||
as a turn happening or an account being created, is recorded by the code that
|
||||
performs it, where a stranger cannot fake it and an extension cannot block
|
||||
it.
|
||||
|
||||
Storage is two tables, both bounded. `analytics_daily` is one row per (day,
|
||||
metric, label) counter — a few dozen a day. `analytics_visitor_days` is one row
|
||||
per visitor per day carrying the funnel flags, which is what makes the funnel
|
||||
count *people* rather than clicks; it is the only table that grows with traffic
|
||||
and cleanup ages it out.
|
||||
Storage is two tables, both bounded. `analytics_daily` holds one counter row per
|
||||
day, metric, and label, which is a few dozen rows a day.
|
||||
`analytics_visitor_days` holds one row per visitor per day carrying the funnel
|
||||
flags, which is what makes the funnel count people rather than clicks. It is the
|
||||
only table that grows with traffic, and cleanup ages it out.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
@@ -50,31 +54,31 @@ from . import models, security
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------- Metrics ----------
|
||||
# `metric` is the family, `label` the bucket within it. One generic counter
|
||||
# table beats a column per thing measured: adding a new question later is a
|
||||
# constant, not a migration.
|
||||
# `metric` is the family, and `label` is the bucket within it. One generic
|
||||
# counter table is better than a column per measurement, because adding a new
|
||||
# question later costs nothing rather than a migration.
|
||||
|
||||
M_PAGE = "pageview"
|
||||
M_EVENT = "event"
|
||||
M_REFERRER = "referrer"
|
||||
M_DEVICE = "device"
|
||||
M_COUNTRY = "country"
|
||||
M_SCENARIO = "scenario" # which seeded/public scenario got played
|
||||
M_ERROR = "error" # "<status> <route>" for 4xx/5xx on /api
|
||||
M_SCENARIO = "scenario" # Which seeded or public scenario was played.
|
||||
M_ERROR = "error" # "<status> <route>" for a 4xx or 5xx on /api.
|
||||
|
||||
EV_SCENARIO_OPEN = "scenario_opened"
|
||||
EV_ADVENTURE = "adventure_created"
|
||||
EV_IMPORT = "adventure_imported"
|
||||
EV_TURN = "turn"
|
||||
EV_DEMO_TURN = "demo_turn" # a turn billed to the shared demo key
|
||||
EV_DEMO_TURN = "demo_turn" # A turn billed to the shared demo key.
|
||||
EV_TURN_ERROR = "turn_error"
|
||||
EV_SIGNUP = "signup"
|
||||
EV_LOGIN = "login"
|
||||
|
||||
# Events that are also funnel steps: recording one flips a flag on the
|
||||
# visitor's row for the day, so the funnel counts distinct people-days instead
|
||||
# of repeat clicks. The name -> column map is the whole definition of the
|
||||
# funnel; the dashboard reads it back in this order.
|
||||
# Events that are also funnel steps. Recording one sets a flag on the visitor's
|
||||
# row for the day, so the funnel counts distinct visitor-days rather than repeat
|
||||
# clicks. This name-to-column map is the whole definition of the funnel, and the
|
||||
# dashboard reads it back in this order.
|
||||
FUNNEL_FLAGS = {
|
||||
EV_SCENARIO_OPEN: "opened",
|
||||
EV_ADVENTURE: "created",
|
||||
@@ -87,44 +91,45 @@ NONE_LABEL = "(direct)"
|
||||
UNKNOWN = "(unknown)"
|
||||
|
||||
# ---------- Bounds ----------
|
||||
# Everything below exists so a hostile visitor can add rows to these tables no
|
||||
# These bounds exist so that a hostile visitor can add rows to these tables no
|
||||
# faster than an honest one. The only label a client can influence is the
|
||||
# referrer, and these caps together mean the worst it can do is fill one day's
|
||||
# referrer list with junk and then be folded into "(other)".
|
||||
# referrer, and together these caps mean the worst it can do is fill one day's
|
||||
# referrer list and then be folded into "(other)".
|
||||
|
||||
MAX_LABEL_LEN = 80
|
||||
MAX_LABELS_PER_METRIC = 200 # distinct labels per metric per day, then OTHER
|
||||
MAX_PENDING = 4000 # buffered entries before an inline flush
|
||||
MAX_LABELS_PER_METRIC = 200 # Distinct labels per metric per day, then OTHER.
|
||||
MAX_PENDING = 4000 # Buffered entries before an inline flush.
|
||||
FLUSH_INTERVAL_SECONDS = 60
|
||||
|
||||
# How long the per-visitor-day rows are kept. The daily counters are tiny and
|
||||
# kept forever; these are the ones that scale with traffic. A visitor whose
|
||||
# last visit falls off the end counts as new again — a fair trade at this
|
||||
# horizon, and it keeps the table from being a permanent record of anyone.
|
||||
# How long the per-visitor-day rows are kept. The daily counters are small and
|
||||
# are kept indefinitely. These rows are the ones that scale with traffic. A
|
||||
# visitor whose last visit ages out counts as new again, which is an acceptable
|
||||
# trade at this horizon and keeps the table from being a permanent record of
|
||||
# anyone.
|
||||
RETENTION_DAYS = int(os.environ.get("AIDND_ANALYTICS_RETENTION_DAYS", "400") or 400)
|
||||
|
||||
_HOST_OK = re.compile(r"^[a-z0-9.-]+$")
|
||||
_COUNTRY_OK = re.compile(r"^[A-Z]{2}$")
|
||||
_NUMERIC_SEGMENT = re.compile(r"^\d+$")
|
||||
|
||||
# SPA routes, in the shape the dashboard should show them. Anything else a
|
||||
# client claims to have viewed becomes OTHER, so the page list can neither be
|
||||
# polluted nor accidentally record which adventure someone is reading.
|
||||
# SPA routes, in the form the dashboard shows them. Any other path a client
|
||||
# reports becomes OTHER, so the page list cannot be filled with junk and cannot
|
||||
# record which adventure someone is reading.
|
||||
KNOWN_ROUTES = {
|
||||
"/", "/adventures", "/scenarios", "/scenarios/:id", "/play/:id",
|
||||
"/scripts", "/scripts/:id", "/settings", "/chat", "/analytics",
|
||||
}
|
||||
|
||||
# ---------- In-process buffer ----------
|
||||
# Single-process deployment (same assumption as limits.py), so a plain dict
|
||||
# under a lock is the whole design. Losing up to a minute of counts to a hard
|
||||
# restart is acceptable for traffic numbers, and the flusher also runs on
|
||||
# shutdown; on Render's free tier the service is idle when it sleeps, so the
|
||||
# buffer it sleeps on is empty anyway.
|
||||
# The deployment is a single process, which is the same assumption `limits.py`
|
||||
# makes, so a plain dict under a lock is the whole design. Losing up to a minute
|
||||
# of counts to a hard restart is acceptable for traffic numbers, and the flusher
|
||||
# also runs on shutdown. On Render's free tier the service is idle when it
|
||||
# sleeps, so the buffer it sleeps on is empty.
|
||||
|
||||
_counts: dict[tuple[str, str, str], int] = {}
|
||||
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags
|
||||
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels
|
||||
_visits: dict[tuple[str, str], set[str]] = {} # (day, visitor) -> flags.
|
||||
_labels_seen: dict[tuple[str, str], set[str]] = {} # (day, metric) -> labels.
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
@@ -133,8 +138,11 @@ def _today() -> str:
|
||||
|
||||
|
||||
def record(metric: str, label: str = "", *, n: int = 1) -> None:
|
||||
"""Add `n` to one counter. Never raises: analytics must not be able to
|
||||
fail a request it is only watching."""
|
||||
"""Adds `n` to one counter.
|
||||
|
||||
This function never raises. Analytics must not fail a request that it is
|
||||
only observing.
|
||||
"""
|
||||
try:
|
||||
day = _today()
|
||||
label = (label or "").strip()[:MAX_LABEL_LEN]
|
||||
@@ -156,22 +164,26 @@ def record(metric: str, label: str = "", *, n: int = 1) -> None:
|
||||
|
||||
|
||||
def visitor_id(user: models.User) -> str:
|
||||
"""A stable but one-way handle for one visitor.
|
||||
"""Returns a stable, one-way handle for one visitor.
|
||||
|
||||
HMAC of the user id under the app's secret key. Stable, so a returning
|
||||
visitor can be told from a new one; one-way, so nothing in the analytics
|
||||
tables points back at an account; keyed, so a client cannot compute one and
|
||||
claim to be somebody else. One consequence worth knowing: rotating
|
||||
AIDND_SECRET_KEY makes every returning visitor look new again.
|
||||
The handle is an HMAC of the user id under the app's secret key. It is
|
||||
stable, so a returning visitor can be distinguished from a new one. It is
|
||||
one-way, so nothing in the analytics tables points back at an account. It is
|
||||
keyed, so a client cannot compute one and claim to be someone else. One
|
||||
consequence follows: rotating `AIDND_SECRET_KEY` makes every returning
|
||||
visitor look new.
|
||||
"""
|
||||
digest = hmac.new(security.SECRET_KEY, f"visitor:{user.id}".encode(), sha256)
|
||||
return digest.hexdigest()[:32]
|
||||
|
||||
|
||||
def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
|
||||
"""Note that this visitor was here today, optionally flipping one funnel
|
||||
flag. A no-op without a user: a page loaded before a session exists still
|
||||
counts as a pageview, just not as a person."""
|
||||
"""Records that this visitor was here today, and optionally sets one funnel
|
||||
flag.
|
||||
|
||||
Without a user the call does nothing. A page loaded before a session exists
|
||||
still counts as a pageview, but not as a person.
|
||||
"""
|
||||
if user is None:
|
||||
return
|
||||
try:
|
||||
@@ -184,8 +196,10 @@ def record_visit(user: models.User | None, *, flag: str | None = None) -> None:
|
||||
|
||||
|
||||
def record_event(name: str, user: models.User | None = None) -> None:
|
||||
"""One thing that happened: counted, and — if it is a funnel step —
|
||||
credited to the visitor's day. This is the call sites' whole interface."""
|
||||
"""Records one event, and credits the visitor's day if it is a funnel step.
|
||||
|
||||
This is the whole interface the call sites use.
|
||||
"""
|
||||
record(M_EVENT, name)
|
||||
record_visit(user, flag=FUNNEL_FLAGS.get(name))
|
||||
|
||||
@@ -193,10 +207,10 @@ def record_event(name: str, user: models.User | None = None) -> None:
|
||||
# ---------- Normalizing what the browser reports ----------
|
||||
|
||||
def normalize_route(path: str) -> str:
|
||||
"""A client-reported path, reduced to one of KNOWN_ROUTES.
|
||||
"""Reduces a client-reported path to one of `KNOWN_ROUTES`.
|
||||
|
||||
Numeric segments become ":id" — both to bound the label count and because
|
||||
*which* adventure someone opened is their business, not a statistic.
|
||||
Numeric segments become ":id". That bounds the label count, and it keeps
|
||||
which adventure someone opened out of the statistics.
|
||||
"""
|
||||
path = (path or "/").split("?")[0].split("#")[0]
|
||||
if not path.startswith("/"):
|
||||
@@ -209,8 +223,11 @@ def normalize_route(path: str) -> str:
|
||||
|
||||
|
||||
def normalize_referrer(referrer: str, own_host: str = "") -> str:
|
||||
"""The sending site as a bare host. Our own host means an internal
|
||||
navigation, which is not a referral — "" tells the caller to skip it."""
|
||||
"""Returns the sending site as a bare host.
|
||||
|
||||
This app's own host means an internal navigation, which is not a referral.
|
||||
In that case the function returns "", which tells the caller to skip it.
|
||||
"""
|
||||
if not referrer:
|
||||
return NONE_LABEL
|
||||
host = (urlsplit(referrer).hostname or "").lower().lstrip(".")
|
||||
@@ -222,21 +239,23 @@ def normalize_referrer(referrer: str, own_host: str = "") -> str:
|
||||
|
||||
|
||||
def api_route_label(scope: dict, status: int) -> str:
|
||||
"""An error bucket like "500 /api/adventures/{adventure_id}".
|
||||
"""Returns an error bucket such as "500 /api/adventures/{adventure_id}".
|
||||
|
||||
The route *template* is used, never the request path: it keeps one bucket
|
||||
per endpoint instead of one per adventure id, and — the reason it is not
|
||||
merely tidier — an unmatched path is entirely attacker-chosen, so labelling
|
||||
by it would let anyone mint rows by requesting nonsense.
|
||||
The label uses the route template, never the request path. That keeps one
|
||||
bucket per endpoint rather than one per adventure id. It also bounds the
|
||||
table: an unmatched path is chosen entirely by the caller, so labeling by it
|
||||
would let anyone create rows by requesting arbitrary paths.
|
||||
"""
|
||||
template = getattr(scope.get("route"), "path", None)
|
||||
return f"{status} {template}" if template else f"{status} (unmatched)"
|
||||
|
||||
|
||||
def device_of(user_agent: str) -> str:
|
||||
"""Mobile / tablet / desktop, and nothing finer. The UA string itself is
|
||||
never stored — it is a fingerprint, and the answer worth having is one
|
||||
word."""
|
||||
"""Returns "mobile", "tablet", or "desktop", and nothing more specific.
|
||||
|
||||
The user-agent string itself is never stored, because it is a fingerprint
|
||||
and the useful answer is one word.
|
||||
"""
|
||||
ua = (user_agent or "").lower()
|
||||
if not ua:
|
||||
return UNKNOWN
|
||||
@@ -249,10 +268,10 @@ def device_of(user_agent: str) -> str:
|
||||
return "desktop"
|
||||
|
||||
|
||||
# Geo headers an edge may add. Render fronts services with a CDN that can set
|
||||
# cf-ipcountry; the others cost nothing to look for. A value is trusted only if
|
||||
# it looks like an ISO code, since a client can send any header it likes — the
|
||||
# worst case is therefore a wrong country, never an unbounded label.
|
||||
# Geo headers an edge network may add. Render fronts services with a CDN that
|
||||
# can set `cf-ipcountry`, and the others cost nothing to check. A value is
|
||||
# trusted only if it looks like an ISO code, because a client can send any
|
||||
# header, so the worst case is a wrong country rather than an unbounded label.
|
||||
_GEO_HEADERS = ("cf-ipcountry", "x-vercel-ip-country", "x-geo-country", "x-country-code")
|
||||
|
||||
|
||||
@@ -275,8 +294,8 @@ def _drain() -> tuple[dict, dict]:
|
||||
counts, visits = _counts.copy(), _visits.copy()
|
||||
_counts.clear()
|
||||
_visits.clear()
|
||||
# The label sets only bound cardinality within a day, so let yesterday's
|
||||
# go rather than growing a map that never shrinks.
|
||||
# The label sets bound cardinality within one day, so drop the
|
||||
# previous day's rather than grow a map that never shrinks.
|
||||
today = _today()
|
||||
for key in [k for k in _labels_seen if k[0] != today]:
|
||||
del _labels_seen[key]
|
||||
@@ -284,7 +303,7 @@ def _drain() -> tuple[dict, dict]:
|
||||
|
||||
|
||||
def _restore(counts: dict, visits: dict) -> None:
|
||||
"""Put a failed flush's work back so the next one retries it."""
|
||||
"""Returns a failed flush's work to the buffer, so the next flush retries it."""
|
||||
with _guard:
|
||||
for key, n in counts.items():
|
||||
_counts[key] = _counts.get(key, 0) + n
|
||||
@@ -293,7 +312,7 @@ def _restore(counts: dict, visits: dict) -> None:
|
||||
|
||||
|
||||
def flush(db: Session | None = None) -> None:
|
||||
"""Write the buffer out. Safe to call from anywhere; never raises."""
|
||||
"""Writes the buffer out. This is safe to call from anywhere and never raises."""
|
||||
counts, visits = _drain()
|
||||
if not counts and not visits:
|
||||
return
|
||||
@@ -334,9 +353,9 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
return
|
||||
table = models.AnalyticsVisitorDay.__table__
|
||||
ids = {visitor for _, visitor in visits}
|
||||
# One indexed lookup settles new-vs-returning for the whole batch. It is
|
||||
# the only read this module does off the dashboard, and it returns short
|
||||
# hashes for visitors who are active right now — bounded by the batch.
|
||||
# One indexed lookup decides new against returning for the whole batch. It
|
||||
# is the only read this module makes outside the dashboard, and it returns
|
||||
# short hashes for the visitors active right now, so the batch bounds it.
|
||||
known = set(db.scalars(
|
||||
select(models.AnalyticsVisitorDay.visitor)
|
||||
.where(models.AnalyticsVisitorDay.visitor.in_(ids))
|
||||
@@ -354,8 +373,8 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
stmt = _insert(db)(table).values(rows)
|
||||
db.execute(stmt.on_conflict_do_update(
|
||||
index_elements=["day", "visitor"],
|
||||
# Flags only ever turn on, and `is_new` is deliberately absent: the
|
||||
# first write of a visitor's first day is the one that decided it.
|
||||
# Flags only turn on, and `is_new` is absent on purpose. The first
|
||||
# write of a visitor's first day is what decided it.
|
||||
set_={
|
||||
column: or_(table.c[column], stmt.excluded[column])
|
||||
for column in FUNNEL_FLAGS.values()
|
||||
@@ -364,9 +383,11 @@ def _write_visits(db: Session, visits: dict) -> None:
|
||||
|
||||
|
||||
def purge_old_visitor_days(db: Session) -> int:
|
||||
"""Drop visitor-day rows past the retention horizon. Called by the cleanup
|
||||
sweeper; the daily counters are never purged — they are aggregate, tiny,
|
||||
and a portfolio project wants to keep its history."""
|
||||
"""Deletes visitor-day rows past the retention horizon.
|
||||
|
||||
The cleanup sweeper calls this. The daily counters are never purged, because
|
||||
they are aggregates, they are small, and this project keeps its history.
|
||||
"""
|
||||
if RETENTION_DAYS <= 0:
|
||||
return 0
|
||||
cutoff = (models.utcnow().date() - timedelta(days=RETENTION_DAYS)).isoformat()
|
||||
@@ -378,9 +399,9 @@ def purge_old_visitor_days(db: Session) -> int:
|
||||
|
||||
|
||||
# ---------- Reading it back ----------
|
||||
# Every query below is an aggregate: the database does the counting and ships
|
||||
# back tens of rows, whatever the traffic behind them. Nothing here can return
|
||||
# a row that belongs to one visitor.
|
||||
# Every query below is an aggregate. The database does the counting and returns
|
||||
# tens of rows, however much traffic is behind them. No query here can return a
|
||||
# row that belongs to one visitor.
|
||||
|
||||
TOP_N = 12
|
||||
|
||||
@@ -390,17 +411,20 @@ def _top(rows: list[dict], limit: int = TOP_N) -> list[dict]:
|
||||
|
||||
|
||||
def summary(db: Session, days: int = 30) -> dict:
|
||||
"""Everything the dashboard shows, for the last `days` days (today
|
||||
included). Flushes first so the numbers include the last minute."""
|
||||
"""Returns everything the dashboard shows for the last `days` days, including
|
||||
today.
|
||||
|
||||
The function flushes first, so the numbers include the last minute.
|
||||
"""
|
||||
flush(db)
|
||||
today = models.utcnow().date()
|
||||
since = (today - timedelta(days=days - 1)).isoformat()
|
||||
daily = models.AnalyticsDaily
|
||||
visitor = models.AnalyticsVisitorDay
|
||||
|
||||
# 1. Every counter in the window, folded to (metric, label) totals: the
|
||||
# page/referrer/country/device/scenario/error tables all come from this
|
||||
# one pass rather than a query each.
|
||||
# 1. Every counter in the window, reduced to (metric, label) totals. The
|
||||
# page, referrer, country, device, scenario, and error tables all come
|
||||
# from this one pass rather than from a query each.
|
||||
by_metric: dict[str, list[dict]] = {}
|
||||
for metric, label, hits in db.execute(
|
||||
select(daily.metric, daily.label, func.sum(daily.hits))
|
||||
@@ -412,7 +436,7 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
rows.sort(key=lambda row: -row["hits"])
|
||||
events = {row["label"]: row["hits"] for row in by_metric.get(M_EVENT, [])}
|
||||
|
||||
# 2. Two per-day series worth drawing.
|
||||
# 2. The two per-day series the dashboard draws.
|
||||
pageviews_by_day = {
|
||||
day: int(hits)
|
||||
for day, hits in db.execute(
|
||||
@@ -430,8 +454,8 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
)
|
||||
}
|
||||
|
||||
# 3. People, per day. One row per visitor per day means COUNT(*) is already
|
||||
# the day's unique visitors — no DISTINCT needed here.
|
||||
# 3. People, per day. There is one row per visitor per day, so COUNT(*) is
|
||||
# already the day's unique visitors and no DISTINCT is needed.
|
||||
visitors_by_day: dict[str, dict] = {}
|
||||
for day, total, fresh in db.execute(
|
||||
select(
|
||||
@@ -444,9 +468,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
):
|
||||
visitors_by_day[day] = {"visitors": int(total), "new": int(fresh or 0)}
|
||||
|
||||
# 4. The funnel, over the whole window, counting *people* once each:
|
||||
# 4. The funnel over the whole window, counting each person once.
|
||||
# COUNT(DISTINCT CASE WHEN flag THEN visitor END) ignores the NULLs the
|
||||
# CASE leaves for everyone who didn't reach that step.
|
||||
# CASE leaves for everyone who did not reach that step.
|
||||
unique, unique_new, *reached = db.execute(
|
||||
select(
|
||||
func.count(func.distinct(visitor.visitor)),
|
||||
@@ -480,9 +504,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
"until": today.isoformat(),
|
||||
"generated_at": models.utcnow().isoformat(),
|
||||
"totals": {
|
||||
# `visitors` counts each person once for the window; `visits` counts
|
||||
# them once per day they came back, which is the closest honest
|
||||
# thing to "sessions" without tracking sessions.
|
||||
# `visitors` counts each person once for the window. `visits`
|
||||
# counts them once per day they returned, which is the closest
|
||||
# measure to "sessions" that does not track sessions.
|
||||
"visitors": int(unique),
|
||||
"new_visitors": int(unique_new),
|
||||
"visits": visits,
|
||||
@@ -498,8 +522,8 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
"pages_per_visit": round(pageviews / visits, 1) if visits else 0,
|
||||
},
|
||||
"series": series,
|
||||
# Step 0 is everyone who showed up, so the drop-off between it and
|
||||
# "Opened a scenario" is visible as a step like any other.
|
||||
# Step 0 is everyone who arrived, so the drop-off between it and
|
||||
# "Opened a scenario" appears as a step like any other.
|
||||
"funnel": [{"step": "Visited", "count": int(unique)}] + [
|
||||
{"step": step, "count": int(count)}
|
||||
for step, count in zip(
|
||||
@@ -518,8 +542,9 @@ def summary(db: Session, days: int = 30) -> dict:
|
||||
|
||||
|
||||
# ---------- Background flusher ----------
|
||||
# Mirrors cleanup's start/stop pair so main.py's lifespan reads the same way
|
||||
# for both. The interval is what bounds how much a hard restart can lose.
|
||||
# This matches the start and stop pair in `cleanup`, so the lifespan in
|
||||
# `main.py` reads the same way for both. The interval bounds how much a hard
|
||||
# restart can lose.
|
||||
|
||||
async def _flush_loop() -> None:
|
||||
import asyncio
|
||||
@@ -528,8 +553,8 @@ async def _flush_loop() -> None:
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
|
||||
# Blocking DB work: keep it off the event loop, which is also serving
|
||||
# SSE turn streams.
|
||||
# This is blocking database work, so keep it off the event loop, which
|
||||
# is also serving SSE turn streams.
|
||||
await run_in_threadpool(flush)
|
||||
|
||||
|
||||
@@ -540,9 +565,11 @@ def start_flusher():
|
||||
|
||||
|
||||
async def stop_flusher(task) -> None:
|
||||
"""Cancel the loop and write out whatever it was holding — a deploy is the
|
||||
one restart that is both frequent and predictable, so it should not be the
|
||||
thing that loses a minute of counts."""
|
||||
"""Cancels the loop and writes out whatever it was holding.
|
||||
|
||||
A deploy is the one restart that is both frequent and predictable, so it
|
||||
should not be what loses a minute of counts.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
+97
-94
@@ -1,33 +1,33 @@
|
||||
"""Phase 14, SP4 — the attempts at one turn.
|
||||
"""Phase 14, SP4: the attempts at one turn.
|
||||
|
||||
A retry used to rewrite the AI action in place and push the discarded take into
|
||||
a JSON list on the same row. That is where seven separate bugs came from: the
|
||||
row's `text` mirrored one entry of a repeating group, `variant_count` mirrored
|
||||
its length, and every reader that touched the story during a retry had to be
|
||||
told to pretend the row was not there.
|
||||
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, `variant_count`
|
||||
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**. Retry writes a sibling at the same
|
||||
`(branch_id, depth)` and marks it live; the previous one stays exactly as it
|
||||
was written, at the same coordinate, `live = False`. Nothing is mirrored, so
|
||||
nothing can drift.
|
||||
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:
|
||||
that maintains either one:
|
||||
|
||||
* **Exactly one sibling in a group is live.** `lineage.Path.clause` selects on
|
||||
it, so the losing attempts are invisible to every read of the story without
|
||||
any of those reads knowing that attempts exist.
|
||||
* **The assembled prompt is stored once per turn, on the live sibling.**
|
||||
A `context_snapshot` is ~163 kB of prompt that every attempt at a turn
|
||||
shares, plus a few hundred bytes that differ (`ATTEMPT_KEYS`). Giving each
|
||||
sibling its own copy would have made retry a permanent multiplier on the
|
||||
biggest column in the database — the thing the JSON list was invented to
|
||||
avoid. So the prompt moves with the live flag, and a superseded sibling keeps
|
||||
only its own slices.
|
||||
* 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 is `variant_index`, an explicit ordinal, not
|
||||
`created_at`. Two attempts made in the same second must still page in the order
|
||||
they were made, and the migration that split the old JSON lists had to be able
|
||||
Ordering inside a group comes from `variant_index`, which is an explicit ordinal
|
||||
rather than `created_at`. Two attempts made in the same second still have to page
|
||||
in the order they were made, and the migration that split the old JSON lists had
|
||||
to state the order rather than reconstruct it.
|
||||
"""
|
||||
|
||||
@@ -38,41 +38,42 @@ 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: the world-state delta it proposed and what the referee did with it,
|
||||
# the script report, the model's literal reply, and the endpoint's token
|
||||
# accounting — each attempt is its own API call, and a retry is precisely the
|
||||
# call expected to read the prompt back out of cache. Everything else in a
|
||||
# snapshot is the prompt, which is assembled once per turn.
|
||||
# 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 script report, 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", "script", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
"""Every attempt at `action`'s turn, oldest first.
|
||||
"""Returns every attempt at `action`'s turn, oldest first.
|
||||
|
||||
Keyed on the **parent**, not on the coordinate (SP9). The two agree right up
|
||||
until a take is forked onto its own branch: it keeps its parent but leaves
|
||||
the (branch, depth) its siblings are still at, so a coordinate would report
|
||||
it as the only take of its turn — `1/1` where the player is owed `1/3`.
|
||||
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 gets the nesting right without being asked. Takes under C1
|
||||
and takes under C2 share a depth and, until one of them forks, a branch;
|
||||
only the parent separates them, which is what makes a pager under C2 read
|
||||
`2/2` instead of counting C1's three as well.
|
||||
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.
|
||||
|
||||
Two fallbacks, both meaning "this row predates the key being asked about":
|
||||
a node with no branch is a pre-tree row no path contains, and a node with no
|
||||
parent is a pre-SP9 row the backfill could not place. Both are their own
|
||||
only attempt under the rule they were written with.
|
||||
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:
|
||||
# Pre-SP9, and the coordinate is the key those rows were written under.
|
||||
# Root nodes land here too and are genuinely alone: nothing is a take of
|
||||
# the opening of a story.
|
||||
# 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(
|
||||
@@ -96,18 +97,18 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
|
||||
|
||||
def on_branch(rows: list[models.Action], node: models.Action) -> list[models.Action]:
|
||||
"""The takes in `rows` that sit on `node`'s own branch.
|
||||
"""Returns the attempts in `rows` that are on `node`'s own branch.
|
||||
|
||||
`group` answers "which takes are of this turn", and since SP9 that spans
|
||||
branches — a take forked onto its own line is still a take of the same turn,
|
||||
which is the whole point of keying on the parent.
|
||||
`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.
|
||||
|
||||
Deleting is the one caller that must not follow it there. A take on another
|
||||
branch is reachable through that branch and belongs to the story somebody is
|
||||
telling on it; removing it because a turn was undone over here would delete
|
||||
a line nobody asked about. Same parent *and* same branch is the coordinate,
|
||||
which is what "every attempt at this turn" meant before a fork could move
|
||||
one out of it.
|
||||
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]
|
||||
|
||||
@@ -122,12 +123,12 @@ def live_in(rows: list[models.Action]) -> models.Action | None:
|
||||
def preceding(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> models.Action | None:
|
||||
"""The node the story tells immediately before `node`.
|
||||
"""Returns the node the story tells immediately before `node`.
|
||||
|
||||
"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 enough on
|
||||
their own. Undefers both of them because the only reason to ask for this
|
||||
row is to put back what it left behind.
|
||||
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
|
||||
@@ -150,12 +151,12 @@ def preceding(
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Put back the script scoreboard and world state `node` left behind.
|
||||
"""Restores the script state and world state that `node` left behind.
|
||||
|
||||
A NULL snapshot means "leave the live state alone", never "reset it": rows
|
||||
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 clobbering a running adventure's scoreboard with an empty dict
|
||||
would be a far worse answer than doing nothing.
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
"""
|
||||
if node is None:
|
||||
return
|
||||
@@ -166,7 +167,7 @@ def restore_state(adventure: models.Adventure, node: models.Action | None) -> No
|
||||
|
||||
|
||||
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Record on `node` what the adventure looks like now that it has played."""
|
||||
"""Records on `node` the state of the adventure now that the node has played."""
|
||||
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
node.state_after = copy.deepcopy(state)
|
||||
@@ -176,7 +177,7 @@ def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
def roll_back_before(
|
||||
db: Session, adventure: models.Adventure, node: models.Action
|
||||
) -> None:
|
||||
"""Rewind the shared state to before `node` was played."""
|
||||
"""Rewinds the shared state to what it was before `node` was played."""
|
||||
restore_state(adventure, preceding(db, adventure, node))
|
||||
|
||||
|
||||
@@ -186,26 +187,28 @@ def add_attempt(
|
||||
previous: models.Action,
|
||||
replacement: models.Action,
|
||||
) -> None:
|
||||
"""Put `replacement` beside `previous` as the newer attempt at that turn.
|
||||
"""Places `replacement` next to `previous` as the newer attempt at that turn.
|
||||
|
||||
Placed by hand rather than through `tree.place_action`, which would read the
|
||||
depth off the legacy `index` and move the head: a sibling is not a new turn,
|
||||
it is another take on the one the head is already standing on.
|
||||
The placement is done here rather than through `tree.place_action`, which
|
||||
would read the depth from the legacy `index` and move 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
|
||||
# Copied, never resolved from the path: a take belongs to the turn it is a
|
||||
# take *of*, and that is what `group` keys on. Resolving it here would ask
|
||||
# what is live one depth back, which is the same node right now and stops
|
||||
# being once this turn is forked away from.
|
||||
# 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 end of the group, not one past `previous` — which is only the same
|
||||
# thing when `previous` is the newest take. Switch a three-take turn back to
|
||||
# take 1 and retry, and `previous.variant_index + 1` collides with take 2;
|
||||
# `renumber` then breaks the tie by id and files the new attempt *between*
|
||||
# takes 2 and 3, so the pager walks the takes in an order they were not made
|
||||
# in. `group` is oldest-first, and `replacement` is not in it yet.
|
||||
# Use the end of the group rather than one past `previous`. The two match
|
||||
# only when `previous` is the newest attempt. Switch a three-attempt turn
|
||||
# back to attempt 1 and retry, and `previous.variant_index + 1` collides with
|
||||
# attempt 2. `renumber` then breaks the tie by id and places the new attempt
|
||||
# between attempts 2 and 3, so the pager walks the attempts in an order they
|
||||
# were not made in. `group` returns oldest first, and `replacement` is not in
|
||||
# it yet.
|
||||
siblings = group(db, previous)
|
||||
replacement.variant_index = 1 + max(
|
||||
(s.variant_index for s in siblings if s.variant_index is not None),
|
||||
@@ -213,18 +216,18 @@ def add_attempt(
|
||||
)
|
||||
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
|
||||
# what was its own.
|
||||
# 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]:
|
||||
"""Make `node` the attempt the story tells, and put its outcome back.
|
||||
"""Makes `node` the attempt the story tells, and restores its outcome.
|
||||
|
||||
Returns the group, renumbered, so a caller that wants to report on it does
|
||||
not read it twice.
|
||||
Returns the group, renumbered, so that a caller reporting on it does not read
|
||||
it twice.
|
||||
"""
|
||||
rows = group(db, node)
|
||||
previous = live_in(rows)
|
||||
@@ -238,11 +241,11 @@ def make_live(
|
||||
|
||||
|
||||
def renumber(rows: list[models.Action]) -> None:
|
||||
"""Refresh the group-shape cache the page response reads.
|
||||
"""Refreshes the group-shape cache that the page response reads.
|
||||
|
||||
`variant_count` is 0 rather than 1 for a turn nobody retried, because the
|
||||
pager's question is "is there anything to page through?" and the answer for
|
||||
a single attempt is no.
|
||||
pager asks whether there is anything to page through, and for a single
|
||||
attempt the answer is no.
|
||||
"""
|
||||
count = len(rows) if len(rows) > 1 else 0
|
||||
for i, row in enumerate(rows):
|
||||
@@ -253,7 +256,7 @@ def renumber(rows: list[models.Action]) -> None:
|
||||
# ------------------------------------------------- the prompt, stored once
|
||||
|
||||
def keep_own_slices(node: models.Action) -> None:
|
||||
"""Strip `node`'s snapshot back to what is only its own."""
|
||||
"""Reduces `node`'s snapshot to the slices that are only its own."""
|
||||
snapshot = node.context_snapshot
|
||||
if not isinstance(snapshot, dict):
|
||||
return
|
||||
@@ -263,11 +266,11 @@ def keep_own_slices(node: models.Action) -> None:
|
||||
|
||||
|
||||
def hand_over_the_prompt(giver: models.Action, taker: models.Action) -> None:
|
||||
"""Move the turn's assembled prompt from one attempt to another.
|
||||
"""Moves the turn's assembled prompt from one attempt to another.
|
||||
|
||||
Called when the live flag moves, so 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.
|
||||
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}
|
||||
|
||||
+87
-63
@@ -1,18 +1,19 @@
|
||||
"""Phase 8 — user resolution, sessions, and the shared demo key.
|
||||
"""Phase 8: user resolution, sessions, and the shared demo key.
|
||||
|
||||
Two modes, chosen by the AIDND_MULTI_USER env var:
|
||||
The `AIDND_MULTI_USER` environment variable selects one of two modes:
|
||||
|
||||
- Local mode (default): every request resolves to one auto-created "local
|
||||
user". No cookies, no login UI — a clone/docker-compose behaves exactly
|
||||
like the pre-Phase-8 single-user app.
|
||||
- Multi-user mode (hosted): requests carry a signed session cookie. GET
|
||||
/api/auth/me creates a guest user on first visit; registering upgrades the
|
||||
guest in place so their data survives. Requests without a valid session get
|
||||
401 and the frontend re-establishes via /me.
|
||||
* Local mode, the default. Every request resolves to one automatically created
|
||||
local user. There are no cookies and no login UI, so a clone or a
|
||||
docker-compose run behaves like the single-user app from before Phase 8.
|
||||
* Multi-user mode, used for hosted deployments. Requests carry a signed session
|
||||
cookie. `GET /api/auth/me` creates a guest user on the first visit, and
|
||||
registering upgrades that guest in place so their data survives. A request
|
||||
without a valid session gets a 401, and the frontend re-establishes the
|
||||
session through `/me`.
|
||||
|
||||
The shared demo key (BYOK fallback) is also configured here: users whose
|
||||
settings have no API key are routed to a server-funded endpoint with a model
|
||||
whitelist and a per-day turn cap.
|
||||
The shared demo key, which is the fallback when a user brings no key of their
|
||||
own, is also configured here. A user whose settings hold no API key is routed to
|
||||
a server-funded endpoint with a model allowlist and a per-day turn cap.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -33,9 +34,10 @@ def _env_flag(name: str) -> bool:
|
||||
MULTI_USER = _env_flag("AIDND_MULTI_USER")
|
||||
|
||||
SESSION_COOKIE = "aidnd_session"
|
||||
# Secure cookies default on in multi-user (hosted = HTTPS; browsers also
|
||||
# accept Secure on http://localhost). AIDND_COOKIE_SECURE=0/1 overrides —
|
||||
# e.g. 0 when testing multi-user over plain http on a LAN address.
|
||||
# Secure cookies are on by default in multi-user mode, because a hosted
|
||||
# deployment serves HTTPS and browsers also accept Secure on http://localhost.
|
||||
# `AIDND_COOKIE_SECURE` overrides the default with 0 or 1. Use 0 when testing
|
||||
# multi-user mode over plain HTTP on a LAN address.
|
||||
_cookie_secure_env = os.environ.get("AIDND_COOKIE_SECURE", "").strip().lower()
|
||||
COOKIE_SECURE = (
|
||||
_cookie_secure_env in ("1", "true", "yes", "on")
|
||||
@@ -58,18 +60,19 @@ DEMO_MODELS = [
|
||||
] or ["google/gemma-4-26b-a4b-it:free"]
|
||||
DEMO_TURNS_PER_DAY = int(os.environ.get("AIDND_DEMO_TURNS_PER_DAY", "20") or 20)
|
||||
|
||||
# Trusted testers (by email) who bypass the daily demo cap — unmetered turns on
|
||||
# the shared demo key. Comma-separated emails; matched case-insensitively.
|
||||
# Trusted testers, listed by email, who bypass the daily demo cap and take
|
||||
# unmetered turns on the shared demo key. The list is comma-separated, and the
|
||||
# match ignores case.
|
||||
POWER_USERS = {
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("AIDND_POWER_USERS", "").split(",")
|
||||
if e.strip()
|
||||
}
|
||||
|
||||
# Who can see the visit analytics. Deliberately its own list rather than
|
||||
# POWER_USERS: a trusted tester gets unmetered turns and the AI Chat page,
|
||||
# which is not a reason to hand them the site's traffic numbers. Empty (the
|
||||
# default) means nobody sees the dashboard in a hosted deployment.
|
||||
# Who can see the visit analytics. This is a separate list from `POWER_USERS` on
|
||||
# purpose. A trusted tester gets unmetered turns and the AI Chat page, which is
|
||||
# not a reason to give them the site's traffic numbers. An empty list, which is
|
||||
# the default, means nobody sees the dashboard in a hosted deployment.
|
||||
ANALYTICS_EMAILS = {
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("AIDND_ANALYTICS_EMAILS", "").split(",")
|
||||
@@ -83,15 +86,19 @@ DEMO_CAP_MESSAGE = (
|
||||
|
||||
|
||||
def demo_enabled() -> bool:
|
||||
# The demo key is a hosted-deployment feature; local installs talk to
|
||||
# whatever endpoint Settings points at, even with no API key (Ollama).
|
||||
# The demo key is a hosted-deployment feature. A local install talks to
|
||||
# whatever endpoint Settings points at, even with no API key, such as
|
||||
# Ollama.
|
||||
return MULTI_USER and bool(DEMO_API_KEY)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderConfig:
|
||||
"""What the turn engine should actually connect with, after the
|
||||
BYOK-vs-demo decision. Build these with resolve_provider_config()."""
|
||||
"""What the turn engine connects with, after the decision between a
|
||||
user-supplied key and the demo key.
|
||||
|
||||
Build one of these with `resolve_provider_config()`.
|
||||
"""
|
||||
|
||||
endpoint_url: str
|
||||
api_key: str
|
||||
@@ -99,19 +106,20 @@ class ProviderConfig:
|
||||
using_demo: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Belt and braces around server-funded turns: resolve_provider_config()
|
||||
# already pins the model, and this makes it a property of the config
|
||||
# object too, so a future caller can't construct an unpinned one.
|
||||
# Unreachable by design — a raise here means a new code path bypassed
|
||||
# the pinning, which is worth failing loudly rather than billing.
|
||||
# A second guard around server-funded turns. `resolve_provider_config()`
|
||||
# already pins the model, and this makes the pin a property of the config
|
||||
# object too, so a later caller cannot construct an unpinned one. This
|
||||
# raise is unreachable by design. Reaching it means a new code path
|
||||
# bypassed the pinning, which is worth failing on rather than billing
|
||||
# for.
|
||||
#
|
||||
# The test is `using_demo`, NOT `api_key == DEMO_API_KEY`. Keying it on
|
||||
# the key value looks stricter but is wrong: the demo key is a normal
|
||||
# The test is `using_demo`, not `api_key == DEMO_API_KEY`. Keying on the
|
||||
# key value looks stricter and is wrong. The demo key is an ordinary
|
||||
# OpenRouter key, so a user can legitimately paste that same key into
|
||||
# their own Settings as BYOK — and then every resolution raised, 500ing
|
||||
# even GET /auth/me and taking the whole SPA down with it. `using_demo`
|
||||
# is what actually means "the server is paying", and only the demo
|
||||
# branch below sets it.
|
||||
# their own Settings. Every resolution then raised, which returned a 500
|
||||
# even from `GET /auth/me` and took the whole SPA down. `using_demo` is
|
||||
# what means the server is paying, and only the demo branch below sets
|
||||
# it.
|
||||
if self.using_demo and self.model not in DEMO_MODELS:
|
||||
raise ValueError(
|
||||
f"Refusing to use the shared demo key with non-whitelisted model {self.model!r}"
|
||||
@@ -121,20 +129,23 @@ class ProviderConfig:
|
||||
def resolve_provider_config(
|
||||
settings: models.Settings, *, model_override: str | None = None
|
||||
) -> ProviderConfig:
|
||||
"""BYOK when the user has their own key, the shared demo key otherwise.
|
||||
"""Returns the user's own key when they have one, and the shared demo key
|
||||
otherwise.
|
||||
|
||||
THE security-relevant branch is the demo one, and it is the only place the
|
||||
whitelist rule lives — every caller must come through here rather than
|
||||
building a ProviderConfig itself. On the demo key:
|
||||
The demo branch is the security-relevant one, and it is the only place the
|
||||
allowlist rule lives. Every caller has to come through this function rather
|
||||
than build a `ProviderConfig` itself. On the demo key:
|
||||
|
||||
- the model is pinned to DEMO_MODELS, so a caller-supplied override (the AI
|
||||
Chat page) or a hand-edited Settings row cannot aim a server-funded key at
|
||||
a paid model; anything unrecognised falls back to DEMO_MODELS[0];
|
||||
- the endpoint is pinned to DEMO_ENDPOINT_URL, so the key itself can't be
|
||||
redirected to a URL the user controls and harvested.
|
||||
* The model is pinned to `DEMO_MODELS`, so a caller-supplied override from
|
||||
the AI Chat page, or a hand-edited Settings row, cannot point a
|
||||
server-funded key at a paid model. An unrecognized model falls back to
|
||||
`DEMO_MODELS[0]`.
|
||||
* The endpoint is pinned to `DEMO_ENDPOINT_URL`, so the key cannot be
|
||||
redirected to a URL the user controls and captured there.
|
||||
|
||||
`model_override` is a per-request preference (never a grant): it's honoured
|
||||
verbatim under BYOK, and only if whitelisted on the demo key.
|
||||
`model_override` is a per-request preference and never a grant. It is used
|
||||
verbatim with the user's own key, and on the demo key only when the model is
|
||||
on the allowlist.
|
||||
"""
|
||||
key = settings.api_key_plain
|
||||
requested = (model_override or "").strip() or settings.model
|
||||
@@ -149,27 +160,33 @@ def _today() -> str:
|
||||
|
||||
|
||||
def is_power_user(user: models.User) -> bool:
|
||||
"""Trusted testers: unmetered demo turns, plus tooling that isn't part of
|
||||
the game (the AI Chat scratchpad). Local installs are always trusted — it's
|
||||
the operator's own machine and their own API key, same reasoning as the
|
||||
provider debug log being local-only."""
|
||||
"""Returns whether this user is a trusted tester.
|
||||
|
||||
A trusted tester gets unmetered demo turns, plus tooling that is not part of
|
||||
the game, such as the AI Chat scratchpad. A local install is always trusted,
|
||||
because it runs on the operator's own machine with their own API key. The
|
||||
provider debug log is local-only for the same reason.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
return True
|
||||
return bool(user.email) and user.email.lower() in POWER_USERS
|
||||
|
||||
|
||||
def is_owner(user: models.User) -> bool:
|
||||
"""May this user see the visit analytics? Local installs always can — it is
|
||||
the operator's own machine and their own visits, same reasoning as the
|
||||
provider debug log; hosted deployments check AIDND_ANALYTICS_EMAILS."""
|
||||
"""Returns whether this user may see the visit analytics.
|
||||
|
||||
A local install always may, because it runs on the operator's own machine and
|
||||
shows their own visits. The provider debug log follows the same reasoning. A
|
||||
hosted deployment checks `AIDND_ANALYTICS_EMAILS`.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
return True
|
||||
return bool(user.email) and user.email.lower() in ANALYTICS_EMAILS
|
||||
|
||||
|
||||
def demo_turns_left(user: models.User) -> int:
|
||||
# Power users are never capped; report the full cap so the banner reads
|
||||
# "N of N" rather than a decrementing count.
|
||||
# A power user is never capped, so report the full cap and let the banner
|
||||
# read "N of N" rather than count down.
|
||||
if is_power_user(user):
|
||||
return DEMO_TURNS_PER_DAY
|
||||
used = user.demo_turns_used if user.demo_turns_date == _today() else 0
|
||||
@@ -177,9 +194,9 @@ def demo_turns_left(user: models.User) -> int:
|
||||
|
||||
|
||||
def count_demo_turn(user: models.User) -> None:
|
||||
"""Record one demo turn; the caller's commit persists it."""
|
||||
"""Records one demo turn. The caller's commit stores it."""
|
||||
if is_power_user(user):
|
||||
return # unmetered — power users don't count against the cap
|
||||
return # A power user's turns do not count against the cap.
|
||||
today = _today()
|
||||
if user.demo_turns_date != today:
|
||||
user.demo_turns_date = today
|
||||
@@ -190,8 +207,11 @@ def count_demo_turn(user: models.User) -> None:
|
||||
# ---------- User resolution ----------
|
||||
|
||||
def local_user(db: Session) -> models.User:
|
||||
"""The single implicit user in local mode (owns pre-Phase-8 data via
|
||||
migration; created lazily on a fresh database)."""
|
||||
"""Returns the single implicit user used in local mode.
|
||||
|
||||
A migration gives this user ownership of data written before Phase 8. On a
|
||||
fresh database the user is created on first use.
|
||||
"""
|
||||
user = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.email.is_(None), models.User.is_guest.is_(False))
|
||||
@@ -209,7 +229,8 @@ 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 hands DateTime columns back naive; they were stored as UTC.
|
||||
# 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
|
||||
@@ -227,8 +248,11 @@ def resolve_session_user(request: Request, db: Session) -> models.User | None:
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> models.User:
|
||||
"""Dependency used by every router. 401 in multi-user mode means the
|
||||
frontend must (re)establish a session via GET /api/auth/me."""
|
||||
"""The dependency every router uses to resolve the current user.
|
||||
|
||||
In multi-user mode a 401 means the frontend has to establish a session again
|
||||
through `GET /api/auth/me`.
|
||||
"""
|
||||
if not MULTI_USER:
|
||||
user = local_user(db)
|
||||
else:
|
||||
|
||||
+156
-139
@@ -1,51 +1,52 @@
|
||||
"""Phase 14, SP6 — the export bundle, as a tree.
|
||||
"""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 ever reach: a file downloaded today has to still import
|
||||
into a build shipped next year. So the format is versioned, both versions live
|
||||
here, and nothing else in the app knows either of them.
|
||||
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.
|
||||
|
||||
**v1** is a flat list of turns, each with an optional `variants` array — the
|
||||
repeating group SP4 unpacked into rows. Nothing writes that shape any more.
|
||||
The *reader* stays, because bundles already on people's disks still have it and
|
||||
a backup that stops importing is not a backup.
|
||||
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.
|
||||
|
||||
**v2** carries the tree. Three things it holds that v1 could not, each
|
||||
load-bearing:
|
||||
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
|
||||
can hold one — v1 export interleaved them by `index`, which read as a mangled
|
||||
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
|
||||
put back. A bundle carrying the actions but not the outcomes would import a
|
||||
* The branches, because a forked adventure is two stories and a flat list holds
|
||||
one. A version 1 export interleaved them by `index`, 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
|
||||
|
||||
**What was chosen, never what is derived.** The head *branch*, the fork points,
|
||||
the live flags and the anchors are decisions somebody made; they are in the
|
||||
file. `lineage`, the head *depth*, `index` and the variant ordinals are all
|
||||
computed from those, and they are recomputed on import instead:
|
||||
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`, the head depth, `index`, and the variant
|
||||
ordinals are all computed from those, and the import recomputes them:
|
||||
|
||||
* `lineage` is a cache of `parent` + `fork_depth`. Shipping it too would put a
|
||||
second source of truth for one fact in a file anybody can hand-edit, and the
|
||||
two could then disagree in a way no read would ever report.
|
||||
* the head depth is the tip of the head branch, which is a fact about the nodes
|
||||
* `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.
|
||||
* `index` is the legacy column SP8 drops. Its one remaining job is to hand the
|
||||
next row a number nothing else holds — a fact about the *adventure*, not
|
||||
about a path — so `depth` cannot be it: two branches have a node at depth 4.
|
||||
The import allocates one per turn instead, which keeps `max_action_index`
|
||||
honest and keeps siblings sharing an index the way SP4 leaves them.
|
||||
* the variant ordinals are `attempts.renumber`'s to maintain, and it is the
|
||||
only place allowed to.
|
||||
* `index` is the legacy column SP8 drops. Its one remaining job is to give the
|
||||
next row a number nothing else holds, which is a fact about the adventure
|
||||
rather than about a path, so `depth` cannot serve: two branches each have a
|
||||
node at depth 4. The import allocates one index per turn instead, which keeps
|
||||
`max_action_index` correct and keeps siblings sharing an index the way SP4
|
||||
leaves them.
|
||||
* `attempts.renumber` maintains the variant ordinals, and it is the only place
|
||||
allowed to.
|
||||
|
||||
Every hand-editable coordinate is therefore checked before a row is written
|
||||
(`plan`), not fixed up afterwards: an import that fails halfway leaves an
|
||||
adventure holding half a tree, and a tree missing a branch is a story that
|
||||
silently stops rather than one that reports.
|
||||
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
|
||||
@@ -60,24 +61,24 @@ from .context import cursors, lineage
|
||||
FORMAT = "ai-dnd-adventure-v2"
|
||||
LEGACY_FORMAT = "ai-dnd-adventure-v1"
|
||||
|
||||
# VARCHAR(20) on `actions.type`; a raw-dict import bypasses the schemas.
|
||||
# `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:
|
||||
"""The whole adventure as a v2 bundle.
|
||||
"""Returns the whole adventure as a version 2 bundle.
|
||||
|
||||
Deliberately un-pathed: a backup wants the entire tree, not the branch its
|
||||
owner happens to be standing on. Both after-snapshots are undeferred in the
|
||||
one query — they are per-node columns nothing else reads in bulk, and asking
|
||||
for them a row at a time would be a query per turn.
|
||||
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.
|
||||
|
||||
No context snapshots. A bundle has never carried the assembled prompts and
|
||||
still does not: they are ~163 kB a turn, they are an explanation of a
|
||||
generation rather than part of the story, and the Insights viewer they feed
|
||||
is reading the adventure it came from.
|
||||
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)
|
||||
@@ -85,8 +86,9 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
.order_by(models.Branch.id)
|
||||
.all()
|
||||
)
|
||||
# Branch ids are local to the file — positions in this list — because the
|
||||
# database ids they had here are already taken over there.
|
||||
# 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)
|
||||
@@ -112,9 +114,9 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"worldState": adventure.world_state,
|
||||
"autoSummarize": adventure.auto_summarize,
|
||||
"memoryBankEnabled": adventure.memory_bank_enabled,
|
||||
# A root entry even for an adventure whose branch row was never created
|
||||
# — a story with no branch is a pre-tree one, and the tree it belongs to
|
||||
# is the root. `_local` puts its nodes there.
|
||||
# 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),
|
||||
@@ -154,10 +156,10 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
dict(_ROOT) if parent is None
|
||||
else {"parent": parent, "forkDepth": branch.fork_depth}
|
||||
)
|
||||
# A name is something a player chose, so it travels — the same rule that
|
||||
# puts the fork points in the file and leaves `lineage` out. An unnamed
|
||||
# branch omits the key rather than carrying a null, which keeps the file
|
||||
# for a tree nobody has named byte-identical to the one SP6 wrote.
|
||||
# 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
|
||||
@@ -166,7 +168,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
node = {
|
||||
"branch": _local(action.branch_id, local),
|
||||
# A pre-tree row's depth is the number `index` already held.
|
||||
# A pre-tree row's depth is the number `index` already holds.
|
||||
"depth": action.depth if action.depth is not None else action.index,
|
||||
"live": bool(action.live),
|
||||
"type": action.type,
|
||||
@@ -175,11 +177,11 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
}
|
||||
if action.reasoning:
|
||||
node["reasoning"] = action.reasoning
|
||||
# `{}` and absent mean different things — "this node left an empty
|
||||
# scoreboard behind" against "nobody knows, leave the live state alone" —
|
||||
# so an empty snapshot is written out rather than trimmed. It costs about
|
||||
# eighteen bytes a row and it is the difference between an undo that clears
|
||||
# a score and one that leaves it standing.
|
||||
# `{}` 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:
|
||||
@@ -194,8 +196,9 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
|
||||
"text": memory.text, "pinned": memory.pinned, "forgotten": memory.forgotten,
|
||||
"sourceStart": memory.source_start, "sourceEnd": memory.source_end,
|
||||
"useCount": memory.use_count,
|
||||
# The node it hangs off. A hand-written memory summarises no node, so it
|
||||
# has a branch and no depth, and keeps that shape here.
|
||||
# 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,
|
||||
}
|
||||
@@ -214,7 +217,7 @@ def _exported_anchor(
|
||||
# ---------------------------------------------------------------- importing
|
||||
|
||||
def check_format(bundle: dict) -> str:
|
||||
"""The bundle's version, or 400."""
|
||||
"""Returns the bundle's version, or raises a 400."""
|
||||
fmt = bundle.get("format")
|
||||
if fmt in (FORMAT, LEGACY_FORMAT):
|
||||
return fmt
|
||||
@@ -225,16 +228,16 @@ def check_format(bundle: dict) -> str:
|
||||
|
||||
|
||||
def plan(bundle: dict, version: str) -> dict:
|
||||
"""The bundle's tree, checked and normalised, before a row is written.
|
||||
"""Returns the bundle's tree, checked and normalized, before a row is written.
|
||||
|
||||
Pure: no session, no adventure, nothing created. Everything a hand-edited
|
||||
file can get wrong about the *shape* of a tree is caught here, because the
|
||||
alternative is an import that fails partway and leaves an adventure holding
|
||||
a story with a hole in it.
|
||||
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 land in the same shape, so `write` never learns there are two
|
||||
formats: a v1 bundle is a linear story, which is a tree with one branch, and
|
||||
its `variants` array is a sibling group written the old way.
|
||||
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)]
|
||||
@@ -248,8 +251,9 @@ def plan(bundle: dict, version: str) -> dict:
|
||||
"nodes": nodes,
|
||||
"memories": _planned_memories(bundle, len(branches)),
|
||||
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
|
||||
# v2 knows where the derived work got to; v1 counted it, and a count
|
||||
# cannot be turned into a node until the nodes exist (see `settle`).
|
||||
# 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),
|
||||
@@ -270,11 +274,12 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
if parent is None:
|
||||
specs.append(dict(_ROOT, **({"name": name} if name else {})))
|
||||
continue
|
||||
# A branch may only fork from one listed before it. That is how the
|
||||
# export writes them — branches are numbered in creation order and a
|
||||
# parent always exists first — and requiring it here buys acyclicity for
|
||||
# the price of a comparison: a lineage is computed by walking to the
|
||||
# parent, and a cycle would be an import that never returns.
|
||||
# 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,
|
||||
@@ -296,11 +301,12 @@ def _planned_branches(bundle: dict) -> list[dict]:
|
||||
|
||||
|
||||
def _planned_branch_name(entry: dict, i: int) -> str | None:
|
||||
"""The name a branch entry carries, or None for one nobody named.
|
||||
"""Returns the name a branch entry carries, or `None` if nobody named it.
|
||||
|
||||
Checked before the row is created rather than left to the column, for the
|
||||
reason the whole planner exists: a 400 from a pure function beats a half
|
||||
written adventure and a database error from three branches in.
|
||||
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:
|
||||
@@ -351,15 +357,16 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
|
||||
|
||||
|
||||
def _planned_v1_nodes(bundle: dict) -> list[dict]:
|
||||
"""A v1 bundle's turns as the nodes they describe: one per attempt.
|
||||
"""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 is
|
||||
the same split migration 60 does — every attempt becomes a row at the turn's
|
||||
coordinate and `variantIndex` picks which is live. Clamped, because a
|
||||
hand-edited bundle can name an attempt its own list does not have, and a
|
||||
turn with no live node is a turn no read can see.
|
||||
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`: v1 is one branch, where the two agree.
|
||||
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] = []
|
||||
@@ -404,9 +411,10 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
|
||||
"sourceStart": entry.get("sourceStart"),
|
||||
"sourceEnd": entry.get("sourceEnd"),
|
||||
"useCount": _as_int(entry.get("useCount"), 0),
|
||||
# Out of range rather than absent means a file that disagrees with
|
||||
# itself; the root is the safe reading, because a memory on a branch
|
||||
# nothing can see is a memory that never reaches a prompt again.
|
||||
# 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,
|
||||
})
|
||||
@@ -429,10 +437,10 @@ def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Write a planned tree onto a freshly created adventure.
|
||||
"""Writes a planned tree onto a newly created adventure.
|
||||
|
||||
Order matters and is not negotiable: branches first, because a node needs an
|
||||
id to hang off; then the nodes, because the head and the anchors name one.
|
||||
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)
|
||||
@@ -444,13 +452,13 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
def _write_branches(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict]
|
||||
) -> list[int]:
|
||||
"""One row per branch, lineage computed rather than read.
|
||||
"""Writes one row per branch, computing the lineage rather than reading it.
|
||||
|
||||
Inserted through Core and the lineage written second, for the reason
|
||||
`tree.root_branch` spells out: the lineage names the row's own id. The
|
||||
parent's cached ancestry is capped at this fork, which is the same
|
||||
arithmetic `tree.fork` does — a fork made now and a fork made a year ago and
|
||||
exported must produce the same rows.
|
||||
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]] = []
|
||||
@@ -486,14 +494,14 @@ def _write_branches(
|
||||
def _write_nodes(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
"""The nodes, grouped into the turns they are attempts at.
|
||||
"""Writes the nodes, grouped into the turns they are attempts at.
|
||||
|
||||
Two things are allocated here rather than trusted from the file. `index` is
|
||||
handed out one per *turn*, in the order the bundle lists them, so siblings
|
||||
share one and no two coordinates do — which is what `max_action_index` needs
|
||||
to keep issuing numbers nothing holds. And exactly one attempt in each group
|
||||
is made live: a file can name none or several, and a turn with no live node
|
||||
is a turn that vanishes from the story.
|
||||
Two values are allocated here rather than read from the file. `index` is
|
||||
issued once per turn, in the order the bundle lists them, so siblings share
|
||||
one index and no two coordinates do. `max_action_index` needs that to keep
|
||||
issuing numbers nothing holds. Exactly one attempt in each group is also 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]] = {}
|
||||
indices: dict[tuple[int, int], int] = {}
|
||||
@@ -541,18 +549,19 @@ def _write_memories(
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
)
|
||||
# A v1 memory has no depth of its own; `source_end` is the index of the
|
||||
# last action it summarises, which on one branch is that node's 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 v1 memory that summarises nothing — one the player typed — has no
|
||||
# depth to derive, and leaving it NULL here would rebuild by import the
|
||||
# exact state migration 62 exists to end: `Path._entry_clause` compares
|
||||
# `depth <= max_depth`, which a NULL fails, so the memory would vanish
|
||||
# from every branch the moment the imported adventure was forked. The
|
||||
# root is the same answer the migration gives, and for the same reason
|
||||
# — 0 is at or before every fork point, so it is visible from every
|
||||
# path this adventure can grow.
|
||||
# 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)
|
||||
@@ -561,12 +570,13 @@ def _write_memories(
|
||||
def _point_the_head(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""Where the story is being played, and how deep it goes.
|
||||
"""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 nothing of its own sits at its
|
||||
fork point — the last node its story contains, borrowed but the tip all the
|
||||
same. The same rule as `tree.refresh_head`, applied before a flush.
|
||||
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]
|
||||
@@ -581,10 +591,11 @@ def _point_the_head(
|
||||
def _write_anchors(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
"""How far the memories and the summary have read — v2 only.
|
||||
"""Records how far the memories and the summary have read. Version 2 only.
|
||||
|
||||
A v1 bundle counts instead, and a count cannot be resolved to a node until
|
||||
the nodes are in the database; `settle` does that half afterwards.
|
||||
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:
|
||||
@@ -595,15 +606,17 @@ def _write_anchors(
|
||||
|
||||
|
||||
def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Line up the two coordinate systems, once the nodes exist.
|
||||
"""Aligns the two coordinate systems, once the nodes exist.
|
||||
|
||||
The anchors and the legacy counts describe the same boundary in different
|
||||
words, and each version of the bundle brings one of them. A v1 file brings
|
||||
the count, so the anchor is found by counting that far along the story; a v2
|
||||
file brings the anchor, so the count is read back off it. The legacy columns
|
||||
are still written either way — they are what a rolled-back build reads.
|
||||
terms, and each version of the bundle carries one of them. A version 1 file
|
||||
carries the count, so the anchor is found by counting that far along the
|
||||
story. A version 2 file carries the anchor, so the count is read back from
|
||||
it. The legacy columns are written either way, because a rolled-back build
|
||||
reads them.
|
||||
|
||||
Called after the flush, because both directions need the actions queryable.
|
||||
The caller runs this after the flush, because both directions need the
|
||||
actions to be queryable.
|
||||
"""
|
||||
positions = story["positions"]
|
||||
for cursor in cursors.ALL:
|
||||
@@ -618,11 +631,12 @@ def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
# Small coercions. A raw-dict import bypasses the schemas entirely, so
|
||||
# everything out of a bundle is whatever JSON happened to hold.
|
||||
# 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:
|
||||
"""`True` is an `int` in Python, and is not one in a coordinate."""
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -631,9 +645,12 @@ def _as_int(value, default: int) -> int:
|
||||
|
||||
|
||||
def _as_index(value, count: int, default: int | None = None) -> int | None:
|
||||
"""A local branch number, or `default` when the file names one that is not
|
||||
there. Out of range is a file disagreeing with itself, not a shape a read
|
||||
can be handed."""
|
||||
"""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
|
||||
|
||||
|
||||
|
||||
+35
-31
@@ -1,10 +1,11 @@
|
||||
"""Retention policy for throwaway guest accounts.
|
||||
|
||||
In multi-user mode every first visit mints a `users` row (GET /api/auth/me),
|
||||
so a public demo accumulates one account per curious visitor — most of whom
|
||||
never come back, each leaving behind whatever scenarios, adventures, actions
|
||||
and memories they generated. This drops guests that have gone quiet for
|
||||
AIDND_GUEST_RETENTION_DAYS (default 5) along with everything they made.
|
||||
In multi-user mode every first visit creates a `users` row through
|
||||
`GET /api/auth/me`, so a public demo accumulates one account per visitor. Most
|
||||
of those visitors never return, and each one leaves behind whatever scenarios,
|
||||
adventures, actions, and memories they generated. This module deletes guests
|
||||
that have been inactive for `AIDND_GUEST_RETENTION_DAYS`, which defaults to 5,
|
||||
along with everything they made.
|
||||
|
||||
Why this is safe to run unattended:
|
||||
|
||||
@@ -12,25 +13,26 @@ Why this is safe to run unattended:
|
||||
clauses are checked rather than either alone. Registering upgrades the row
|
||||
in place (is_guest -> False), so a guest who signs up keeps everything;
|
||||
local mode's implicit single user is also is_guest=False.
|
||||
- Idle time is COALESCE(last_seen_at, created_at). `auth._touch` only writes
|
||||
last_seen_at once an hour, and a guest minted by /auth/me has NULL until
|
||||
its *second* request, so created_at is the honest floor for a brand-new
|
||||
visitor — without the coalesce those rows look infinitely old.
|
||||
- Nothing a guest owns is reachable by anyone else: `is_public` is an
|
||||
output-only field (see schemas.ScenarioBase), so only seeded scenarios —
|
||||
which have user_id NULL and are therefore outside this filter entirely —
|
||||
are shared. Deleting a guest can't take content away from another user.
|
||||
- Idle time is `COALESCE(last_seen_at, created_at)`. `auth._touch` writes
|
||||
`last_seen_at` at most once an hour, and a guest created by `/auth/me` has
|
||||
NULL there until its second request, so `created_at` is the correct floor for
|
||||
a new visitor. Without the coalesce, those rows look arbitrarily old.
|
||||
- Nothing a guest owns is reachable by anyone else. `is_public` is an
|
||||
output-only field, as `schemas.ScenarioBase` shows, so the only shared
|
||||
scenarios are the seeded ones, which have a NULL `user_id` and are outside
|
||||
this filter. Deleting a guest cannot remove content from another user.
|
||||
|
||||
Why one Core DELETE instead of an ORM cascade: `db.delete(user)` would SELECT
|
||||
every adventure, action, memory and story card into Python purely to delete
|
||||
them, which on Neon is exactly the egress pattern that has already cost this
|
||||
project once. Every foreign key from users downwards is ON DELETE CASCADE
|
||||
(users -> scenarios/adventures/scripts/settings -> actions/memories/cards), so
|
||||
the database does the whole graph in one statement and ships back a row count.
|
||||
The sweep uses one Core DELETE rather than an ORM cascade. `db.delete(user)`
|
||||
would SELECT every adventure, action, memory, and story card into Python only to
|
||||
delete them, which on Neon is the egress pattern that has already cost this
|
||||
project once. Every foreign key from `users` downward is ON DELETE CASCADE, from
|
||||
users to scenarios, adventures, scripts, and settings, and from those to actions,
|
||||
memories, and cards, so the database deletes the whole graph in one statement and
|
||||
returns a row count.
|
||||
|
||||
No index is added for the scan: the sweep runs a handful of times a day
|
||||
against a table with at most a few thousand rows, which is not worth a
|
||||
migration and the schema surface that comes with it.
|
||||
The scan gets no index. The sweep runs a few times a day against a table holding
|
||||
at most a few thousand rows, which does not justify a migration and the schema
|
||||
surface it adds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -56,8 +58,8 @@ def _int_env(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
# Days of inactivity before a guest account is dropped. 0 or less disables the
|
||||
# policy entirely, for a deployment that would rather keep everything.
|
||||
# Days of inactivity before a guest account is deleted. A value of 0 or less
|
||||
# disables the policy, for a deployment that keeps everything.
|
||||
RETENTION_DAYS = _int_env("AIDND_GUEST_RETENTION_DAYS", 5)
|
||||
|
||||
# How often a long-lived process re-checks. Hours, not minutes: nothing here is
|
||||
@@ -86,10 +88,11 @@ def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
|
||||
"""
|
||||
if RETENTION_DAYS <= 0:
|
||||
return 0
|
||||
# Stored timestamps are naive UTC on both backends (SQLite drops tzinfo;
|
||||
# Postgres columns are TIMESTAMP WITHOUT TIME ZONE with the session pinned
|
||||
# to UTC in database.py). Match that exactly so the comparison can't hinge
|
||||
# on how a given dialect renders an aware value.
|
||||
# Stored timestamps are UTC without a timezone on both backends. SQLite
|
||||
# drops the timezone, and the Postgres columns are TIMESTAMP WITHOUT TIME
|
||||
# ZONE with the session pinned to UTC in `database.py`. Match that, so the
|
||||
# comparison does not depend on how a dialect renders a value that carries a
|
||||
# timezone.
|
||||
reference = now or models.utcnow()
|
||||
cutoff = reference.replace(tzinfo=None) - timedelta(days=RETENTION_DAYS)
|
||||
|
||||
@@ -100,9 +103,10 @@ def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
|
||||
models.User.email.is_(None),
|
||||
func.coalesce(models.User.last_seen_at, models.User.created_at) < cutoff,
|
||||
)
|
||||
# Without this, "auto" can't evaluate coalesce in Python and falls back
|
||||
# to fetching every matching primary key first — a second round trip
|
||||
# for nothing, since this session holds no User objects to synchronize.
|
||||
# Without this option, the "auto" strategy cannot evaluate coalesce in
|
||||
# Python and falls back to fetching every matching primary key first.
|
||||
# That is a second round trip for no benefit, because this session holds
|
||||
# no User objects to synchronize.
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
removed = db.execute(stmt).rowcount or 0
|
||||
|
||||
+11
-11
@@ -1,18 +1,18 @@
|
||||
"""Storing a JSON column compressed.
|
||||
|
||||
`actions.context_snapshot` holds the entire assembled prompt for a turn. It is
|
||||
89% of the database — 150.8 MB of JSON across 944 actions on production, and
|
||||
232 KB a 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 cliff.
|
||||
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 it. TOAST brings 150.8 MB down to ~89 MB, a factor
|
||||
of 1.7 — pglz is chosen for decompression speed on data a query might filter
|
||||
on, which this never is. Nothing filters on a prompt; it is written once and
|
||||
read whole, occasionally, by one screen. zlib at the application layer gets
|
||||
three to four times on the same text, and the cost is a decompress on a
|
||||
request that already costs an LLM call.
|
||||
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
|
||||
|
||||
+112
-98
@@ -10,11 +10,11 @@
|
||||
[Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history
|
||||
[Latest player action] (+ script frontMemory right after it, Phase 4)
|
||||
|
||||
Which components are present is AI Dungeon's design, above. The *order* they
|
||||
are laid out in is not: everything fixed is emitted first and everything that
|
||||
moves after the history, because prompt caching bills on a shared prefix and
|
||||
one mutable section high up re-prices the whole prompt under it. See the two
|
||||
"static block" / "live sections" comments in `build_context`.
|
||||
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`.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -30,33 +30,33 @@ CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may
|
||||
NPC_WINDOW = 6 # actions of story searched for NPC trigger words ("in scene")
|
||||
SEPARATOR = "\n\n"
|
||||
|
||||
# Output-length guidance. max_output_tokens is a hard wall the endpoint enforces
|
||||
# mid-sentence: hitting it truncates whatever is being written, and since the
|
||||
# state block is emitted last, it is what gets lost. Asking the model to land
|
||||
# just inside the wall keeps the cut from happening in the first place.
|
||||
LENGTH_HEADROOM = 50 # tokens held back from the cap for the state block itself
|
||||
# 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
|
||||
# reserved budget is stated in words. ~0.75 words per token for English prose.
|
||||
# hint states a number of words. English prose averages 0.75 words per token.
|
||||
WORDS_PER_TOKEN = 0.75
|
||||
# A word budget is a suggestion the model routinely overshoots, and the cap it is
|
||||
# protecting is a hard wall — so aim 10% short of the real ceiling and let the
|
||||
# overshoot land in the slack instead of in the state block.
|
||||
# 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 is noise; a tiny cap speaks for itself
|
||||
# A ceiling alone is a one-sided instruction, and models read it very differently:
|
||||
# a verbose one is held back by it, while a terse one has nothing to act on except
|
||||
# the "write only as much as the moment needs" clause and collapses to two
|
||||
# paragraphs. Stating a floor as well turns the guidance into a band, so the same
|
||||
# prompt lands in the same place regardless of which way the model leans. Set as a
|
||||
# share of the ceiling so the floor can never approach it.
|
||||
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 a floor is meaningless — at a tight cap a short turn is the correct
|
||||
# turn — and the tight-cap wording is the one measured to keep the state block
|
||||
# alive, so it is left exactly as it was.
|
||||
# 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 exists to stop a collapse to two paragraphs, not to demand an essay:
|
||||
# at a 2400-token cap the share alone would ask for 555 words *minimum*. Past this
|
||||
# point a reader wanting more length can say so in the author's note.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ class Section:
|
||||
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 "" when the cap is too small to phrase usefully — the hint is a
|
||||
suggestion the model can drift past, so it only earns its tokens when there
|
||||
is enough room for the drift to still land inside the wall.
|
||||
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:
|
||||
@@ -102,25 +102,27 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
else " Bring the turn to a close well inside the limit rather than "
|
||||
"stopping mid-sentence."
|
||||
)
|
||||
# Phrased as a ceiling, never as a budget. Measured against this model, "keep
|
||||
# this turn under about N words" reads as a target to fill: it moved a 174-word
|
||||
# average to 246 (n=5, every run longer than every unhinted one), i.e. the hint
|
||||
# pushed turns toward the very wall it exists to keep them away from. Naming
|
||||
# the number as a limit, plus saying a typical turn is far shorter, left the
|
||||
# average at 170 while still rescuing the state block at tight caps.
|
||||
# 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 deliberately asymmetric ones: "must not exceed"
|
||||
# for the wall the endpoint enforces, "should not stop short of" for the floor.
|
||||
# Neither is a target, which is what the measurement above says matters. The
|
||||
# "prefer the lower end" clause does the job the old "a typical turn is much
|
||||
# shorter" line did — holding a verbose model off the wall — but now with a
|
||||
# number under it, so a terse model reading the same clause lands on the floor
|
||||
# instead of at forty words.
|
||||
# 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 "
|
||||
@@ -136,16 +138,19 @@ def _script_memory(adventure: models.Adventure) -> dict:
|
||||
|
||||
|
||||
def _history_text(action: models.Action) -> str:
|
||||
"""An AI turn as the model should see it in replayed history: its narration
|
||||
with the state block it emitted re-appended (reconstructed from the stored
|
||||
delta). The block is stripped before storage/UI, so without this every past
|
||||
AI turn would look like one that emitted nothing — biasing the model, by
|
||||
imitation, to stop emitting too. Player turns and blockless turns are
|
||||
returned unchanged.
|
||||
"""Returns an AI turn as the model should see it in replayed history.
|
||||
|
||||
Reads `world_delta`, not `context_snapshot`: this runs for every action in
|
||||
the replayed history, and the snapshot is deferred precisely so a turn
|
||||
never drags the prompt archive out of the database."""
|
||||
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.
|
||||
|
||||
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:
|
||||
@@ -156,10 +161,13 @@ def _history_text(action: models.Action) -> str:
|
||||
|
||||
|
||||
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
|
||||
"""Defined NPCs whose trigger words appear in the recent story — the ones
|
||||
"in scene", so only their stats get injected. Maps npc id -> display name.
|
||||
"""Returns the NPCs whose trigger words appear in the recent story.
|
||||
|
||||
`actions` is already the last handful (see NPC_WINDOW)."""
|
||||
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():
|
||||
@@ -171,8 +179,11 @@ def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str,
|
||||
|
||||
|
||||
def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]:
|
||||
"""AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word
|
||||
('boat' triggers on 'boats'). Returns one record per card with the keyword that fired."""
|
||||
"""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".
|
||||
"""
|
||||
haystack = window_text.lower()
|
||||
matched = []
|
||||
for card in cards:
|
||||
@@ -196,20 +207,19 @@ def build_context(
|
||||
`exclude_action_id` omits one action from the story (see history.py)."""
|
||||
script_mem = _script_memory(adventure)
|
||||
|
||||
# ----- The static block: byte-for-byte the same prompt every turn -----
|
||||
# The ordering here is a billing decision, not a stylistic one. Prompt
|
||||
# caching matches a *prefix*: an endpoint reuses the prompt up to the first
|
||||
# byte that differs from last time and no further. So one mutable section
|
||||
# near the top re-prices everything below it, and what is below it is the
|
||||
# story history, which is the bulk of the prompt. Anything that changes
|
||||
# turn to turn therefore goes *after* the history, in the live sections —
|
||||
# which is also where recency serves it best, the same reasoning that
|
||||
# already puts EMIT_REMINDER last.
|
||||
# ----- 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): how to report changes. The live values are a
|
||||
# live section below; the schema-derived guide and the emit rule are fixed
|
||||
# for as long as the scenario is.
|
||||
# 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)
|
||||
if has_ws:
|
||||
@@ -227,13 +237,14 @@ def build_context(
|
||||
Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}")
|
||||
)
|
||||
|
||||
# ----- Live sections: everything that moves, built here, placed after the
|
||||
# history further down. Ordered least-volatile first, so a turn that
|
||||
# changes only the fastest-moving one keeps the others cached too: the
|
||||
# summary is rewritten every few turns, lore turns over with the scene, the
|
||||
# retrieved memories change on most turns, the stat values on nearly all.
|
||||
# `world_lore` joins them below — it is the history window that triggers
|
||||
# the cards, so it cannot be known yet.
|
||||
# ----- 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()
|
||||
@@ -265,9 +276,9 @@ def build_context(
|
||||
|
||||
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
|
||||
|
||||
# The live sections moved below the history but they are still in the
|
||||
# prompt, so they are still reserved against the budget. (`world_lore` is
|
||||
# not: it is budgeted out of `available` further down, as it always was.)
|
||||
# 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(
|
||||
@@ -282,10 +293,11 @@ def build_context(
|
||||
)
|
||||
available = max(256, settings.context_token_budget - reserved)
|
||||
|
||||
# Only the newest actions can reach the prompt: everything below is either
|
||||
# truncated to `available` tokens or stops at the budget. Fetch a window
|
||||
# that is provably larger than that and no more — a long adventure would
|
||||
# otherwise read its entire history every turn to use the tail of it.
|
||||
# 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
|
||||
)
|
||||
@@ -319,8 +331,8 @@ def build_context(
|
||||
spent = 0
|
||||
oldest_truncated = False
|
||||
for action in reversed(actions):
|
||||
# Budget on the text as it will actually appear — with the re-attached
|
||||
# state block (B) when this adventure tracks world state.
|
||||
# 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:
|
||||
@@ -339,9 +351,9 @@ def build_context(
|
||||
spent += tokens
|
||||
included_actions.reverse()
|
||||
|
||||
# ----- Assemble story text with author's note near the end -----
|
||||
# Re-attach each AI turn's state block (stripped before storage) so recent
|
||||
# history shows the model its own emit pattern to imitate.
|
||||
# ----- 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:
|
||||
@@ -353,21 +365,22 @@ def build_context(
|
||||
note_sections.append(Section("recent_history", SEPARATOR.join(after)))
|
||||
else:
|
||||
note_sections.append(Section("history", SEPARATOR.join(texts)))
|
||||
# The live sections, least volatile first (see where they are built). They
|
||||
# sit below the history so the history caches, and above the tail so the
|
||||
# three sections that are last for a reason stay last.
|
||||
# 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))
|
||||
# Sits just above the emit reminder, which keeps the strongest recency slot:
|
||||
# the length budget is about the narration, the reminder about the block that
|
||||
# comes after it, so this is also the order the model has to act in.
|
||||
# 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:
|
||||
# Terminal reminder: the emit rule sits up in the system block, far from
|
||||
# where the model generates; repeat it last, in the strongest recency slot.
|
||||
# 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]
|
||||
@@ -388,8 +401,9 @@ def build_context(
|
||||
"memories": memory_bank,
|
||||
"history": {
|
||||
"included": len(included_actions),
|
||||
# The whole story, not just the window fetched above — Insights
|
||||
# reports "N of M actions included" and M is the real total.
|
||||
# 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,
|
||||
},
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
"""Phase 14 — how far along a story the derived work has got.
|
||||
"""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 know where they left off, and that mark used to be
|
||||
a *count* — "the first 12 story actions are covered". A count is a position in
|
||||
a list, and this list moves: delete an action from in front of the mark and
|
||||
every later action slides down a slot, so the mark now covers one it has never
|
||||
seen. Every rule in `memorybank` about sliding cursors, rewinding them and
|
||||
translating between positions and `Action.index` existed to patch that up, and
|
||||
each was a separate chance to get it wrong in a way nothing reports.
|
||||
Two things are built from the story and stored beside it: the memories and the
|
||||
story summary. Both need to record where they stopped.
|
||||
|
||||
A cursor here is an **anchor**: `(branch_id, depth)`, the node up to and
|
||||
including which the work is done. Deleting an action does not move it, because
|
||||
a depth is not a position — it is a coordinate along a path. "What is not
|
||||
covered yet" becomes `history.count_after(anchor)`, which is a question about
|
||||
the story rather than about a list index, and it answers correctly whatever has
|
||||
been deleted from in front of it.
|
||||
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.
|
||||
|
||||
The branch half is what makes it survive forking. A depth alone is ambiguous
|
||||
once two branches have a node 41; the anchor says which one, and
|
||||
`Path.depth_on` reads it back as a depth on whatever story is being played —
|
||||
capped at the fork, or "nothing covered" if the anchor sits on ground this path
|
||||
never travelled. Until forking ships there is one branch and that is always a
|
||||
no-op, which is the point: the coordinate system is right before anything needs
|
||||
it to be.
|
||||
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.
|
||||
|
||||
`NO_DEPTH` (-1) is "nothing covered", so a fresh adventure needs no special
|
||||
case: every node is deeper than -1.
|
||||
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
|
||||
@@ -37,12 +38,11 @@ NO_DEPTH = lineage.NO_DEPTH
|
||||
|
||||
|
||||
class Cursor:
|
||||
"""One anchor on the adventure row: the memory bank's, or the summary's.
|
||||
"""One anchor on the adventure row, for either the memory bank or the summary.
|
||||
|
||||
A pair of columns rather than a foreign key to the node. The node can be
|
||||
deleted — that is most of what undo does — and the boundary is still
|
||||
meaningful afterwards, so a pointer that has to resolve would be a pointer
|
||||
that keeps not resolving.
|
||||
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):
|
||||
@@ -53,14 +53,14 @@ class Cursor:
|
||||
# ------------------------------------------------------------- reading
|
||||
|
||||
def stored(self, adventure: models.Adventure) -> tuple[int | None, int]:
|
||||
"""The anchor exactly as written, unread by any path."""
|
||||
"""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:
|
||||
"""The anchor as a depth on the story currently being played."""
|
||||
"""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)
|
||||
|
||||
@@ -69,44 +69,47 @@ class Cursor:
|
||||
def anchor(
|
||||
self, adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Put the anchor at a coordinate given outright.
|
||||
"""Sets the anchor to a coordinate supplied directly.
|
||||
|
||||
The plain setter under `anchor_at`. Only an import has a coordinate
|
||||
without a node to read it off — a v2 bundle carries the anchor itself
|
||||
(`app/bundle.py`), and the node it named lives in another database.
|
||||
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:
|
||||
"""Mark the work done up to and including `node`.
|
||||
"""Marks the work done up to and including `node`.
|
||||
|
||||
Takes the node's own branch, not the adventure's head: a block of six
|
||||
actions can end before the fork this branch was made at, and the
|
||||
coverage belongs where the ground is.
|
||||
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:
|
||||
"""Forget the anchor entirely: nothing is covered.
|
||||
"""Clears the anchor, so that nothing counts as covered.
|
||||
|
||||
For when the ground the anchor stood on is gone — a deleted branch. On
|
||||
Postgres a stale branch id would simply never resolve, but SQLite hands
|
||||
a freed id to the next fork, and an anchor that resolves onto a branch
|
||||
it has never seen would report a stretch of story as already
|
||||
summarized. Clearing costs a re-summarize, which is the safe direction.
|
||||
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:
|
||||
"""Move the anchor back to `depth` if it is past it; never forward.
|
||||
"""Moves the anchor back to `depth` if it is past that depth.
|
||||
|
||||
The one direction that is safe without knowing what else has happened:
|
||||
re-covering ground costs a summarizer call, skipping it loses a stretch
|
||||
of story out of the memories for good.
|
||||
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:
|
||||
@@ -123,12 +126,12 @@ ALL = (MEMORY, SUMMARY)
|
||||
def rewind_all(
|
||||
adventure: models.Adventure, branch_id: int | None, depth: int
|
||||
) -> None:
|
||||
"""Hand a stretch of story back to *both* passes.
|
||||
"""Returns a stretch of story to both the memory pass and the summary pass.
|
||||
|
||||
They move together because they cover the same ground from different sides:
|
||||
the summary folds in the memories, so a memory withdrawn without rewinding
|
||||
the summary leaves the summary claiming to have read something no longer
|
||||
there.
|
||||
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)
|
||||
@@ -137,18 +140,19 @@ def rewind_all(
|
||||
def anchor_at_position(
|
||||
adventure: models.Adventure, cursor: Cursor, position: int
|
||||
) -> None:
|
||||
"""Set `cursor` from a count of covered story actions — a v1 bundle's mark,
|
||||
or a database written before the anchors existed.
|
||||
"""Sets `cursor` from a count of covered story actions.
|
||||
|
||||
The position-th story action in depth order is the node that says the same
|
||||
thing, and goes on saying it once something in front of it is deleted. A
|
||||
position past the end of the story is not a bad value: an adventure caught
|
||||
up under the older rule can carry one, and it means the same thing the tip
|
||||
does, so that is where it lands.
|
||||
A v1 bundle stores its mark as a count, and so does a database written
|
||||
before the anchors existed.
|
||||
|
||||
The SQL half of this rule is `migrations._backfill_cursor_anchors`, which
|
||||
has to do it for every adventure at once without loading any of them; the
|
||||
two must agree.
|
||||
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
|
||||
@@ -158,11 +162,11 @@ def anchor_at_position(
|
||||
|
||||
|
||||
def position_of(adventure: models.Adventure, depth: int) -> int:
|
||||
"""How many story actions lie at or before `depth` — an anchor read back as
|
||||
a count.
|
||||
"""Returns how many story actions lie at or before `depth`.
|
||||
|
||||
The v1 export bundle stores the cursors as positions, and a v1 bundle is
|
||||
read by builds that have never heard of a depth. This is the one place that
|
||||
still speaks that coordinate system, and SP6's v2 format retires it.
|
||||
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)
|
||||
|
||||
+140
-124
@@ -1,42 +1,44 @@
|
||||
"""Reading the story without reading all of it.
|
||||
"""Reads part of a story without loading all of it.
|
||||
|
||||
`story_actions()` walked `adventure.actions`, which loads every row of the
|
||||
adventure — then every caller threw almost all of it away. The context builder
|
||||
concatenates the story and immediately cuts it back to the token budget; the
|
||||
NPC-in-scene check looks at the last 6; memory retrieval looks at the last 4;
|
||||
the post-turn cursor clamp only wants a count. So a turn on a 200-action
|
||||
adventure read ~840 KB to use maybe 70 KB of it, and the cost grew with every
|
||||
turn played.
|
||||
`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 directly from SQL — a tail, a slice, a count —
|
||||
so the read is bounded by the context budget instead of by the length of the
|
||||
story.
|
||||
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 everything together:
|
||||
Three rules hold the module together:
|
||||
|
||||
* **One definition of "story action".** Membership decides what a reader sees
|
||||
and what the summarizer is handed, so SQL and Python must agree on it
|
||||
exactly. `_STORY_TEXT` and `is_story_text()` are that one definition, written
|
||||
twice; keep them in step.
|
||||
* **Never load twice.** If `adventure.actions` is already in memory (the
|
||||
scripting pipeline hands the whole history to user scripts, as AI Dungeon
|
||||
does), every helper here slices that instead of issuing a query, so a
|
||||
scripted adventure pays what it always paid and nothing more.
|
||||
* **Every read goes through the branch clause** (Phase 14). `adventure.actions`
|
||||
is every branch's actions, not the story being played — so the shortcut above
|
||||
cuts the loaded collection down to the path before slicing it, exactly as the
|
||||
SQL does. This is the line that would silently assemble a prompt out of two
|
||||
different stories, which is why `lineage.Path` owns both halves of it.
|
||||
- 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.
|
||||
|
||||
Ordering is by `depth` now, not `index`. Since SP4 the two can hold the same
|
||||
number on *different rows* — attempts at one turn share both — so only `depth`
|
||||
plus the branch clause's `live` test says which of them the story is.
|
||||
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`, `newest`. The memory bank used to ask for
|
||||
"positions 12 to 18 of the story", which is a question whose answer moves when
|
||||
an action is deleted from in front of it. It now asks for "the six actions
|
||||
after depth 41", which is the same question a fork has to answer anyway.
|
||||
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
|
||||
@@ -45,17 +47,17 @@ 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 the token
|
||||
# budget is covered. When it isn't, the next size is worked out from the
|
||||
# average action length just measured rather than by blind doubling — guessing
|
||||
# high means reading hundreds of actions to use sixty of them.
|
||||
# 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 one more round is rare
|
||||
WINDOW_STEP = 8 # ...and at least this many more actions each round
|
||||
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; id breaks the tie that a pre-tree row (depth NULL,
|
||||
# and so invisible anyway) or a future sibling pair would otherwise leave to
|
||||
# the database's mood.
|
||||
# `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())
|
||||
|
||||
@@ -63,12 +65,13 @@ _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 single-argument `trim()`, but it strips
|
||||
spaces only — Python's `.strip()` also drops newlines and tabs, and an
|
||||
action of nothing but a newline would otherwise count as story text here
|
||||
and not in Python. `replace()` and `trim()` are the two string functions
|
||||
both dialects spell identically, so fold the other whitespace into spaces
|
||||
first. (Form feed and vertical tab are not covered; nothing produces them.)
|
||||
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"):
|
||||
@@ -80,15 +83,18 @@ _STORY_TEXT = _sql_stripped(models.Action.text) != ""
|
||||
|
||||
|
||||
def is_story_text(text: str) -> bool:
|
||||
"""The Python half of `_STORY_TEXT` — keep the two in step."""
|
||||
"""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 an already-loaded collection is free; issuing a query beside it
|
||||
would mean paying for the same rows twice.
|
||||
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:
|
||||
@@ -101,11 +107,14 @@ def _from_memory(
|
||||
) -> list[models.Action] | None:
|
||||
"""The story, from the already-loaded collection, or None to go to SQL.
|
||||
|
||||
The collection is the *adventure's* actions — every branch of it. Cutting
|
||||
it down to the path here is the same filter the SQL applies, and skipping
|
||||
it would hand the context builder a prompt assembled from siblings of the
|
||||
story being played. The path needs a session to read the branch row from;
|
||||
without one there is no answer to give, so say so rather than guess.
|
||||
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:
|
||||
@@ -131,11 +140,10 @@ def _filters(
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
) -> list:
|
||||
# adventure_id is redundant beside the branch clause — branch ids are
|
||||
# unique, so a branch already names one adventure. It stays because it is
|
||||
# the cheap half of the check that catches a node written onto the wrong
|
||||
# adventure's branch, and because a clause nobody can read is a clause
|
||||
# nobody maintains.
|
||||
# `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),
|
||||
@@ -171,11 +179,11 @@ def _count_query(
|
||||
):
|
||||
"""A real `SELECT count(...)`.
|
||||
|
||||
Deliberately not `_query(...).count()`: that wraps the entity select in a
|
||||
subquery, so the emitted SQL names every column — including the deferred
|
||||
ones this whole design exists to keep off the wire. No bytes come back
|
||||
either way, but the database still has to read them, and an egress guard
|
||||
that greps the SQL cannot tell the two apart.
|
||||
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)
|
||||
@@ -197,14 +205,14 @@ def story_actions(
|
||||
) -> list[models.Action]:
|
||||
"""Every story action, oldest first.
|
||||
|
||||
Still the right call where the whole story is genuinely wanted — user
|
||||
scripts receive it, per AI Dungeon's scripting API. Prefer `tail`, `slice_`
|
||||
or `count` anywhere the caller only needs part of it.
|
||||
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` drops one action from the story — used by retry, where
|
||||
the attempt being replaced is still the live node of its turn (it stays
|
||||
live until a replacement exists) but must not appear in the context
|
||||
assembled to replace it.
|
||||
`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:
|
||||
@@ -241,18 +249,20 @@ def tail_range(
|
||||
) -> list[models.Action]:
|
||||
"""`limit` story actions ending `skip` actions before the end, oldest first.
|
||||
|
||||
`skip=0` is the newest slice; `skip=32, limit=16` is the 16 actions just
|
||||
older than the newest 32. Lets a growing window fetch only the part it
|
||||
doesn't already have.
|
||||
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 is the read the lineage window exists for. The path's ranges are
|
||||
disjoint and descending, so the newest N nodes come from the newest few
|
||||
lineage entries and the rest of the ancestry need not be named at all: a
|
||||
story forked two hundred times reads its tail with as few clauses as one
|
||||
forked never. `prefix_covering` estimates how many entries that takes from
|
||||
depth arithmetic alone; the estimate is only ever short where a middle
|
||||
action was deleted, and then the read widens to the whole lineage and pays
|
||||
one more query.
|
||||
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 []
|
||||
@@ -295,11 +305,12 @@ def slice_(
|
||||
) -> list[models.Action]:
|
||||
"""Story actions at positions [start, start + length), oldest first.
|
||||
|
||||
Positions are into the same filtered, depth-ordered list the memory cursors
|
||||
count in, which is why the filter has to match Python's exactly.
|
||||
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.
|
||||
|
||||
Counts from the oldest end, so it names the whole lineage: there is no
|
||||
prefix of the ancestry that holds "the story's first ten actions".
|
||||
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 []
|
||||
@@ -321,9 +332,9 @@ def slice_(
|
||||
def depth_of(action: models.Action) -> int:
|
||||
"""`action.depth`, with the no-depth case spelled once.
|
||||
|
||||
A row with no depth is a pre-tree row, which no path contains — so it can
|
||||
only turn up in an already-loaded collection, and it sorts before the story
|
||||
rather than after it.
|
||||
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
|
||||
|
||||
@@ -333,14 +344,14 @@ def count_after(
|
||||
) -> int:
|
||||
"""How many story actions lie past `depth` on the path.
|
||||
|
||||
The node-anchored replacement for "the story is N long and the cursor is at
|
||||
M". Deleting an action from in front of the boundary makes this number
|
||||
smaller, which is true; it does not make the boundary point somewhere else,
|
||||
which is the bug the positions had.
|
||||
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` says exactly which lineage entries can hold a node deeper
|
||||
than the boundary, so a cursor near the tip names one branch however many
|
||||
forks are below it.
|
||||
`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:
|
||||
@@ -367,8 +378,9 @@ def after(
|
||||
) -> list[models.Action]:
|
||||
"""The oldest `limit` story actions past `depth`, oldest first.
|
||||
|
||||
"The next block the summarizer has not seen", asked as a fact about the
|
||||
story rather than as an offset into a list that shifts underneath it.
|
||||
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 []
|
||||
@@ -391,14 +403,15 @@ def after(
|
||||
def newest(adventure: models.Adventure) -> models.Action | None:
|
||||
"""The newest story action, or None on an empty story.
|
||||
|
||||
A row, not a count and an offset: this is the node an anchor moves to when
|
||||
derived work catches up with the end of the 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 be the *second* newest — the memory bank held one action back
|
||||
because retry rewrote a row, so a memory covering the newest action could
|
||||
end up describing narration the player had retried away. Since SP4 a retry
|
||||
writes a sibling instead, and the coordinate's derived work is withdrawn
|
||||
when the story at it changes, so there is nothing left to hold back.
|
||||
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
|
||||
@@ -407,11 +420,12 @@ def newest(adventure: models.Adventure) -> models.Action | None:
|
||||
def max_action_index(adventure: models.Adventure) -> int:
|
||||
"""Highest `Action.index` in the adventure, story text or not. -1 if empty.
|
||||
|
||||
The one read here that is deliberately *not* path-scoped. `index` is the
|
||||
legacy column, kept unread until SP8 drops it, and its only remaining job
|
||||
is to hand the next row a number nothing else holds — which is a fact about
|
||||
the adventure, not about the story being played. Scoping it to a branch
|
||||
would let two branches issue the same index.
|
||||
This is the only read in the module that is deliberately not scoped to a
|
||||
path. `index` is a legacy column that remains unread until SP8 drops it. Its
|
||||
one remaining job is to give the next row a number that no other row holds,
|
||||
which is a fact about the adventure rather than about the story being
|
||||
played. Scoping the query to a branch would let two branches issue the same
|
||||
index.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is not None:
|
||||
@@ -433,16 +447,18 @@ def window_covering(
|
||||
token_counter,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> list[models.Action]:
|
||||
"""The newest story actions whose combined text exceeds `budget_tokens` —
|
||||
i.e. more than the context builder can possibly include, and never less.
|
||||
"""Returns the newest story actions whose combined text exceeds `budget_tokens`.
|
||||
|
||||
Measures rather than guesses a chars-per-token ratio, so the prompt is
|
||||
byte-for-byte what loading the whole story would have produced. Budgets on
|
||||
the raw text, which is never longer than the rendered history text, so
|
||||
erring here can only mean fetching slightly too much.
|
||||
The result always holds at least as much text as the context builder can
|
||||
include, and never less.
|
||||
|
||||
Each round fetches only the actions it doesn't already hold, so no row is
|
||||
ever read twice however many rounds it takes.
|
||||
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
|
||||
@@ -452,15 +468,15 @@ def window_covering(
|
||||
adventure, len(actions), size - len(actions), exclude_action_id
|
||||
)
|
||||
if not older:
|
||||
return actions # already holding the whole story
|
||||
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
|
||||
return actions # That was the whole story.
|
||||
if tokens > budget_tokens:
|
||||
return actions
|
||||
# Short. Project how many actions the budget takes at the length these
|
||||
# ones turned out to be, and go straight there.
|
||||
# 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)
|
||||
|
||||
+116
-106
@@ -1,35 +1,35 @@
|
||||
"""Phase 14 — which nodes are "this story".
|
||||
"""Phase 14: decides which nodes make up one story.
|
||||
|
||||
`tree.py` decides where a node is written. This module is the other half: it
|
||||
decides which nodes a read can see, and it is the **only** place that knows.
|
||||
`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 *borrows* everything before its fork
|
||||
point from its ancestors, so "the story on branch C" is not a column you can
|
||||
filter on — it is an OR of ranges::
|
||||
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, to the tip
|
||||
(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)
|
||||
|
||||
which is exactly what `branches.lineage` spells out, newest first, computed
|
||||
once when the fork happens. Reads never walk parent pointers to rebuild it.
|
||||
The `branches.lineage` column records exactly that list, newest first. The fork
|
||||
computes it once, so no read walks parent pointers to rebuild it.
|
||||
|
||||
Two properties fall out of the shape, and both are load-bearing:
|
||||
The shape of the list gives two properties that the windowed reads depend on:
|
||||
|
||||
* **The ranges are disjoint and descending.** 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 beneath it. So ordering the whole clause by `depth`
|
||||
descending is the same as reading entry 0's nodes, then entry 1's, then
|
||||
entry 2's — which is what lets a tail read use only the newest few entries
|
||||
and stop.
|
||||
* **Clause count is bounded by the context window, not by fork count.** A
|
||||
200-fork story whose newest branch is 40 turns long reads with one clause,
|
||||
because the window is covered before the second entry is reached. That is
|
||||
`prefix_covering`, and it is why `history.window_covering` can keep its shape.
|
||||
- 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 here is a read. Nothing in this module creates a branch or writes a
|
||||
row: an adventure with no branch has no story, and healing that is the write
|
||||
side's job (`tree.place_action`, and the flush guard in `models.py` behind it).
|
||||
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_
|
||||
@@ -37,31 +37,33 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
|
||||
# The depth of an adventure with no actions. Mirrors tree.NO_DEPTH; kept
|
||||
# separately so a read never has to import the write half.
|
||||
# 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 — a
|
||||
# fork starts its own nodes after the depth it forked at — so this names one
|
||||
# node per adventure, not one per branch. It is also where migration 62 parked
|
||||
# every memory written before memories had coordinates, which is why the two
|
||||
# places that can retire a memory (`memorybank.forget_node`, and a v1 import
|
||||
# with no depth to read) both have to say something about it.
|
||||
# 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]]:
|
||||
"""`branch.lineage` as (branch_id, max_depth) pairs, newest first.
|
||||
"""Returns `branch.lineage` as (branch_id, max_depth) pairs, newest first.
|
||||
|
||||
An empty lineage reads as "this branch alone, to its tip" rather than as an
|
||||
error. That is what a root branch's lineage means, and it is what a branch
|
||||
row looks like in the moment between being inserted and having its own id
|
||||
to name — so the fallback is the truth, not a guess.
|
||||
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; a hand-written row might hold tuples.
|
||||
# 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]
|
||||
@@ -73,10 +75,10 @@ def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
|
||||
|
||||
|
||||
class Path:
|
||||
"""One story, as a clause and as a predicate.
|
||||
"""One story, expressed as a SQL clause and as a Python predicate.
|
||||
|
||||
Holds the lineage entries newest first, plus the depth of the tip, which is
|
||||
only used to estimate how much story each entry covers.
|
||||
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):
|
||||
@@ -96,28 +98,29 @@ class Path:
|
||||
model=models.Action,
|
||||
count: int | None = None,
|
||||
):
|
||||
"""The branch clause, over `model` (`Action` or `Memory`).
|
||||
"""Returns the branch clause over `model`, which is `Action` or `Memory`.
|
||||
|
||||
`count` limits it to the newest `count` lineage entries — the windowed
|
||||
read. `None` is the whole lineage, which is what anything counting from
|
||||
the *oldest* end (a slice, a total) has to use.
|
||||
`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 reads has a depth. Memories used to be the exception —
|
||||
a hand-written one had a branch and no depth, and needed an escape
|
||||
clause here to survive being capped at a fork. SP7 anchors them at the
|
||||
head instead (`tree.place_memory`), which is a better answer to the same
|
||||
problem: the memory is not exempt from the path, it is *on* one. A row
|
||||
with no depth is now a pre-tree leftover that no read should see.
|
||||
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 also have to be *live* (SP4). A coordinate can hold several
|
||||
attempts at the same turn, and the story tells one of them; the losing
|
||||
siblings sit at the same branch and depth and are excluded here, once,
|
||||
so that no read of the story has to know that retries exist. Only
|
||||
`app/attempts.py` looks past this.
|
||||
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 yields `false`, not "no filter": an adventure whose nodes
|
||||
carry no branch has no story, and the loud version of that is an empty
|
||||
page, not every branch at once.
|
||||
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:
|
||||
@@ -136,14 +139,16 @@ class Path:
|
||||
# ------------------------------------------------------------- Python
|
||||
|
||||
def contains(self, node) -> bool:
|
||||
"""The Python half of `clause()` — keep the two in step.
|
||||
"""Returns whether `node` is on this path.
|
||||
|
||||
Used where the rows are already in memory (the scripting pipeline hands
|
||||
user scripts the whole history), so an already-loaded collection can be
|
||||
cut down to the path without a second read.
|
||||
This is the Python equivalent of `clause()`. Keep the two in step.
|
||||
|
||||
`live` is checked first, and only on rows that have the attribute:
|
||||
memories have no siblings to lose to.
|
||||
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
|
||||
@@ -157,22 +162,25 @@ class Path:
|
||||
return False
|
||||
|
||||
def sort_key(self, node) -> tuple[int, int]:
|
||||
"""Oldest-first ordering. `depth` is the ordering key; `id` breaks the
|
||||
tie a pre-tree row (depth NULL) or a future sibling pair would leave."""
|
||||
"""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:
|
||||
"""How many lineage entries it takes to hold the newest `rows` nodes.
|
||||
"""Returns how many lineage entries hold the newest `rows` nodes.
|
||||
|
||||
An estimate from depth arithmetic, not a query: entry *i* covers the
|
||||
depths between its own cap and the cap of the entry below it, and there
|
||||
is at most one node per depth on a path. So the count it returns is
|
||||
never too many, and is too few only where the story has gaps — an
|
||||
action deleted from the middle. The caller widens to the full lineage
|
||||
if the window comes up short, which costs a second query on a story
|
||||
somebody has deleted from, and nothing at all otherwise.
|
||||
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:
|
||||
@@ -182,9 +190,9 @@ class Path:
|
||||
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:
|
||||
# No tip recorded, or a cap missing from a hand-written row:
|
||||
# nothing to estimate from, so read the lot rather than guess
|
||||
# short and hide the older half of the story.
|
||||
# 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:
|
||||
@@ -192,15 +200,15 @@ class Path:
|
||||
return total
|
||||
|
||||
def covering_after(self, depth: int) -> int:
|
||||
"""How many lineage entries can hold a node deeper than `depth`.
|
||||
"""Returns how many lineage entries can hold a node deeper than `depth`.
|
||||
|
||||
The counterpart to `prefix_covering`, and unlike it this 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 — it and everything older is behind the
|
||||
boundary. Reading "the story after the cursor" therefore names one
|
||||
branch on any story whose cursor is on its newest branch, however
|
||||
often it has forked.
|
||||
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:
|
||||
@@ -208,27 +216,28 @@ class Path:
|
||||
return len(self.entries)
|
||||
|
||||
def depth_on(self, branch_id: int | None, depth: int) -> int:
|
||||
"""A stored `(branch_id, depth)` anchor, read as a depth on *this* path.
|
||||
"""Reads a stored `(branch_id, depth)` anchor as a depth on this path.
|
||||
|
||||
An anchor is how far along a story some derived work has got — which
|
||||
memories cover, what the summary has folded in. It names a node, so
|
||||
moving to another path has to be answered rather than assumed:
|
||||
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 the path takes off that branch, because nothing past the fork is
|
||||
on this story;
|
||||
* the branch is not on this path at all — the work was done on ground
|
||||
this story never travelled, so nothing here is covered.
|
||||
- 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 arise while an adventure has one branch: the
|
||||
anchor is always set from a node on it. It exists because the fallback
|
||||
for "I don't know" must be to redo the work, not to skip it.
|
||||
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:
|
||||
# A pre-tree anchor, or one set by hand. There is one story, so the
|
||||
# depth is a position in it and means what it says.
|
||||
# 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:
|
||||
@@ -237,18 +246,19 @@ class Path:
|
||||
|
||||
|
||||
def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
|
||||
"""The branch this adventure is being read at, or None if it has none.
|
||||
"""Returns the branch this adventure is read at, or None if it has none.
|
||||
|
||||
Deliberately not `tree.head_branch`, which creates one: a GET must not
|
||||
write. An adventure with no branch row also has no nodes carrying a branch,
|
||||
so the two agree — both say "no story here".
|
||||
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
|
||||
# A head naming a branch that is gone: fall through to the root, the
|
||||
# same recovery `tree.head_branch` makes on the write side.
|
||||
# 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(
|
||||
@@ -261,7 +271,7 @@ def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
|
||||
|
||||
|
||||
def path_of(db: Session, adventure: models.Adventure) -> Path:
|
||||
"""The story the adventure's head is currently on."""
|
||||
"""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)
|
||||
|
||||
@@ -15,9 +15,10 @@ DB_PATH = (
|
||||
else Path(__file__).resolve().parent.parent / "data.db"
|
||||
)
|
||||
|
||||
# AIDND_DATABASE_URL (or the platform-conventional DATABASE_URL) switches the
|
||||
# app to a server database — any SQLAlchemy URL works, but Postgres is what
|
||||
# hosted deploys use (Phase 9 decision: Neon). Unset = SQLite, as always.
|
||||
# `AIDND_DATABASE_URL`, or the conventional `DATABASE_URL`, switches the app to
|
||||
# a server database. Any SQLAlchemy URL works, and hosted deploys use Postgres,
|
||||
# which Phase 9 settled on Neon for. If neither variable is set, the app uses
|
||||
# SQLite.
|
||||
DATABASE_URL = (
|
||||
os.environ.get("AIDND_DATABASE_URL", "").strip()
|
||||
or os.environ.get("DATABASE_URL", "").strip()
|
||||
|
||||
@@ -54,10 +54,13 @@ def finish_entry(
|
||||
error: str | None = None,
|
||||
usage: dict | None = None,
|
||||
) -> None:
|
||||
"""`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 read on whether the prompt prefix is actually being
|
||||
cached — a number worth seeing beside the request that produced it."""
|
||||
"""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
|
||||
|
||||
@@ -22,9 +22,9 @@ DATA_URI_RE = re.compile(
|
||||
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` (any object with a stable repr — normally the row's updated_at)
|
||||
becomes a cache-buster, letting the image response be marked immutable
|
||||
while still refreshing the moment the author swaps the picture.
|
||||
`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 ""
|
||||
@@ -40,9 +40,9 @@ def public_url(scenario_id: int, image: str, version: object) -> str:
|
||||
def sanitize(value: object, max_length: int) -> str:
|
||||
"""Coerce an untrusted `image` value from an import bundle to a safe one.
|
||||
|
||||
Anything that isn't a supported data URI or an https URL — or that is too
|
||||
large to store — becomes "", so a hostile or merely foreign bundle can't
|
||||
smuggle in a `javascript:` URI or blow past the column cap.
|
||||
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 ""
|
||||
@@ -62,5 +62,6 @@ def decode(image: str) -> tuple[bytes, str] | None:
|
||||
payload = re.sub(r"\s+", "", match.group(2))
|
||||
return base64.b64decode(payload, validate=True), match.group(1).lower()
|
||||
except (binascii.Error, ValueError):
|
||||
# Truncated or hand-edited base64 — treat as "no image" rather than 500.
|
||||
# The base64 is truncated or hand-edited. Treat it as no image rather
|
||||
# than return a 500.
|
||||
return None
|
||||
|
||||
+102
-77
@@ -1,9 +1,10 @@
|
||||
"""Phase 9 — abuse guards for hosted (multi-user) deployments.
|
||||
"""Phase 9: abuse guards for hosted, multi-user deployments.
|
||||
|
||||
Rate limits and row caps are no-ops in local mode: a single local player
|
||||
should never be throttled by their own app. Values are hardcoded on purpose —
|
||||
generous enough that a legitimate player never notices, tight enough that a
|
||||
hostile visitor can't burn the demo key, peg the CPU, or bloat the database.
|
||||
Rate limits and row caps do nothing in local mode, because a single local player
|
||||
should never be throttled by their own app. The values are hardcoded on purpose.
|
||||
They are generous enough that a legitimate player never notices them, and tight
|
||||
enough that a hostile visitor cannot exhaust the demo key, saturate the CPU, or
|
||||
fill the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,22 +20,22 @@ from sqlalchemy.orm import Session
|
||||
from . import auth, models
|
||||
|
||||
# ---------- Rate limiting ----------
|
||||
# Fixed windows per (scope, caller). In-memory: fine for the single-process
|
||||
# deployment this app targets (and the worst case after a restart is a brief
|
||||
# extra allowance).
|
||||
# Fixed windows per scope and caller. The windows live in memory, which is
|
||||
# enough for the single-process deployment this app targets. The worst case
|
||||
# after a restart is a brief extra allowance.
|
||||
|
||||
# scope -> (max requests, window seconds)
|
||||
# Maps a scope to (max requests, window seconds).
|
||||
RATE_LIMITS: dict[str, tuple[int, int]] = {
|
||||
"turn": (10, 60), # AI turn generation (demo key also has a daily cap)
|
||||
"chat": (30, 60), # AI Chat scratchpad (power users only)
|
||||
"script-test": (30, 60), # sandboxed, but each run costs up to 2s CPU
|
||||
"connection-test": (10, 60), # outbound HTTP to a user-supplied URL
|
||||
"import": (30, 60), # large writes
|
||||
"auth": (10, 300), # register/login attempts, per IP
|
||||
"guest": (30, 300), # new guest users, per IP (each is a DB row)
|
||||
# Pageview beacons. Generous — a real reader clicking around a SPA fires a
|
||||
# handful a minute — but low enough that nobody can inflate the traffic
|
||||
# numbers faster than they could by actually reloading the page.
|
||||
"turn": (10, 60), # AI turn generation. The demo key also has a daily cap.
|
||||
"chat": (30, 60), # The AI Chat scratchpad, for power users.
|
||||
"script-test": (30, 60), # Sandboxed, but each run costs up to 2s of CPU.
|
||||
"connection-test": (10, 60), # Outbound HTTP to a user-supplied URL.
|
||||
"import": (30, 60), # Large writes.
|
||||
"auth": (10, 300), # Register and login attempts, per IP.
|
||||
"guest": (30, 300), # New guest users, per IP. Each one is a database row.
|
||||
# Pageview beacons. The limit is generous, because a real reader clicking
|
||||
# around a SPA sends a handful a minute, and it is low enough that nobody
|
||||
# can inflate the traffic numbers faster than by reloading the page.
|
||||
"analytics": (120, 60),
|
||||
}
|
||||
|
||||
@@ -42,26 +43,30 @@ _windows: dict[tuple[str, str], deque] = defaultdict(deque)
|
||||
_windows_guard = threading.Lock()
|
||||
|
||||
|
||||
# How many proxy hops sit between the app and the real client. On Render (and
|
||||
# most PaaS) that's one: the platform's edge appends the connecting IP to the
|
||||
# RIGHT of X-Forwarded-For. A client can prepend anything it likes to the left,
|
||||
# but it cannot push a value past the edge's own append — so the trustworthy
|
||||
# client IP is the (hops)-th entry from the right, NOT uvicorn's leftmost pick.
|
||||
# Trusting the leftmost let anyone rotate X-Forwarded-For to mint a fresh
|
||||
# rate-limit bucket per request and bypass the auth/guest limits entirely.
|
||||
# Override with AIDND_TRUSTED_PROXY_HOPS if the deployment adds more hops.
|
||||
# How many proxy hops sit between the app and the real client. On Render, and on
|
||||
# most platforms, that is one, because the platform's edge appends the connecting
|
||||
# IP to the right of `X-Forwarded-For`. A client can prepend any value on the
|
||||
# left, but it cannot push a value past the edge's own append, so the trustworthy
|
||||
# client IP is the entry that many places from the right rather than uvicorn's
|
||||
# leftmost choice. Trusting the leftmost entry let anyone rotate
|
||||
# `X-Forwarded-For` to get a fresh rate-limit bucket per request and bypass the
|
||||
# auth and guest limits. If the deployment adds more hops, set
|
||||
# `AIDND_TRUSTED_PROXY_HOPS`.
|
||||
TRUSTED_PROXY_HOPS = max(1, int(os.environ.get("AIDND_TRUSTED_PROXY_HOPS", "1") or 1))
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""The real client IP, resistant to a spoofed X-Forwarded-For. Takes the
|
||||
hop the trusted edge appended (rightmost minus any extra trusted hops);
|
||||
falls back to the socket peer when no forwarded header is present
|
||||
(local/dev, or a direct connection).
|
||||
"""Returns the real client IP, resisting a spoofed `X-Forwarded-For`.
|
||||
|
||||
Public because the access log needs the same answer, and two functions that
|
||||
both decide "which address is the caller's" is how one of them ends up
|
||||
trusting a header it shouldn't."""
|
||||
The function reads the hop the trusted edge appended, which is the rightmost
|
||||
entry minus any extra trusted hops. If no forwarded header is present, which
|
||||
happens locally, in development, and on a direct connection, it falls back to
|
||||
the socket peer.
|
||||
|
||||
The function is public because the access log needs the same answer. Two
|
||||
functions that each decide which address belongs to the caller is how one of
|
||||
them ends up trusting a header it should not.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
parts = [p.strip() for p in forwarded.split(",") if p.strip()]
|
||||
@@ -71,8 +76,11 @@ def client_ip(request: Request) -> str:
|
||||
|
||||
|
||||
def rate_limit(scope: str, request: Request, user: models.User | None = None) -> None:
|
||||
"""429 when the caller exceeds the scope's window. Keyed per user when one
|
||||
is known (accounts survive IP changes), per IP otherwise."""
|
||||
"""Raises a 429 when the caller exceeds the scope's window.
|
||||
|
||||
The window is keyed per user when a user is known, because an account
|
||||
survives an IP change, and per IP otherwise.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
limit, window_seconds = RATE_LIMITS[scope]
|
||||
@@ -92,25 +100,30 @@ def rate_limit(scope: str, request: Request, user: models.User | None = None) ->
|
||||
|
||||
|
||||
# ---------- Per-account login throttle ----------
|
||||
# Defense in depth beside the per-IP `auth` limit: that one can be diluted by a
|
||||
# botnet (many real source IPs, one bucket each), so it can't by itself stop a
|
||||
# distributed guessing run against a single account. This cap keys on the target
|
||||
# email instead of the caller, so guessing ONE account's password stays
|
||||
# expensive regardless of how many addresses it comes from. Failures only — a
|
||||
# correct password clears the record — and it's a short sliding window, not a
|
||||
# hard lock, so a user mistyping a few times recovers on their own in minutes.
|
||||
# Tradeoff: an attacker can keep a known account throttled (a nuisance), which
|
||||
# is strictly preferable to letting it be brute-forced.
|
||||
LOGIN_FAIL_LIMIT = 8 # failed attempts per account...
|
||||
LOGIN_FAIL_WINDOW = 900 # ...within this many seconds (15 min)
|
||||
# This is defense in depth next to the per-IP `auth` limit. A botnet dilutes
|
||||
# that limit, because many real source IPs each get their own bucket, so it
|
||||
# cannot by itself stop a distributed guessing run against one account. This cap
|
||||
# keys on the target email rather than on the caller, so guessing one account's
|
||||
# password stays expensive however many addresses the guesses come from.
|
||||
#
|
||||
# Only failures count, and a correct password clears the record. The window
|
||||
# slides over a short period rather than locking the account, so a user who
|
||||
# mistypes a few times recovers within minutes. The trade-off is that an
|
||||
# attacker can keep a known account throttled, which is an inconvenience and is
|
||||
# preferable to letting the account be brute-forced.
|
||||
LOGIN_FAIL_LIMIT = 8 # Failed attempts per account.
|
||||
LOGIN_FAIL_WINDOW = 900 # The window in seconds, which is 15 minutes.
|
||||
|
||||
_login_fails: dict[str, deque] = defaultdict(deque)
|
||||
_login_guard = threading.Lock()
|
||||
|
||||
|
||||
def check_login_allowed(email: str) -> None:
|
||||
"""429 when an account has too many recent failed logins. Call before
|
||||
verifying the password so guesses don't even reach the hash."""
|
||||
"""Raises a 429 when an account has too many recent failed logins.
|
||||
|
||||
Call this before verifying the password, so that a guess never reaches the
|
||||
hash.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
now = time.time()
|
||||
@@ -127,13 +140,13 @@ def check_login_allowed(email: str) -> None:
|
||||
|
||||
|
||||
def note_login_failure(email: str) -> None:
|
||||
"""Record one failed attempt against `email`."""
|
||||
"""Records one failed attempt against `email`."""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
now = time.time()
|
||||
with _login_guard:
|
||||
_login_fails[email].append(now)
|
||||
if len(_login_fails) > 10_000: # bound the map on a flood of unique emails
|
||||
if len(_login_fails) > 10_000: # Bound the map against a flood of unique emails.
|
||||
stale = [
|
||||
key for key, window in _login_fails.items()
|
||||
if not window or window[-1] < now - LOGIN_FAIL_WINDOW
|
||||
@@ -143,14 +156,16 @@ def note_login_failure(email: str) -> None:
|
||||
|
||||
|
||||
def note_login_success(email: str) -> None:
|
||||
"""A correct password wipes the account's failure streak."""
|
||||
"""Clears the account's failure record after a correct password."""
|
||||
with _login_guard:
|
||||
_login_fails.pop(email, None)
|
||||
|
||||
|
||||
def _prune(now: float) -> None:
|
||||
"""Drop callers whose whole window has expired (call with guard held) so
|
||||
the per-IP dict can't grow without bound."""
|
||||
"""Drops callers whose whole window has expired, so the per-IP dict stays bounded.
|
||||
|
||||
Call this with the guard held.
|
||||
"""
|
||||
longest = max(seconds for _, seconds in RATE_LIMITS.values())
|
||||
stale = [key for key, window in _windows.items()
|
||||
if not window or window[-1] < now - longest]
|
||||
@@ -163,13 +178,14 @@ def _prune(now: float) -> None:
|
||||
MAX_ADVENTURES_PER_USER = 100
|
||||
MAX_SCENARIOS_PER_USER = 200
|
||||
MAX_SCRIPTS_PER_USER = 200
|
||||
MAX_STORY_CARDS_PER_OWNER = 200 # per scenario or adventure
|
||||
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 branch per divergence somebody built a story on, so a tree
|
||||
# with more of them than a story has turns is a file, not a game. Import-only
|
||||
# for now: 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 it.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -182,9 +198,11 @@ def check_row_cap(
|
||||
scenario_id: int | None = None,
|
||||
adventure_id: int | None = None,
|
||||
) -> None:
|
||||
"""409 with a friendly message when creating one more row of `kind` would
|
||||
exceed its cap. Ownership of the passed scenario/adventure has already
|
||||
been checked by the caller."""
|
||||
"""Raises a 409 when creating one more row of `kind` would exceed its cap.
|
||||
|
||||
The caller has already checked ownership of the scenario or adventure passed
|
||||
in.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
if kind == "adventures":
|
||||
@@ -220,18 +238,18 @@ def check_row_cap(
|
||||
"delete some to make room",
|
||||
)
|
||||
elif kind == "actions":
|
||||
# Every action of the adventure — the whole tree, not the path being
|
||||
# played. That is the number that costs storage, and nothing is ever
|
||||
# auto-pruned, so it is the right one to cap on. 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 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 — programming error, not user input
|
||||
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}.")
|
||||
@@ -250,8 +268,11 @@ _BUNDLE_LIST_CAPS = {
|
||||
|
||||
|
||||
def check_bundle_lists(**lists) -> None:
|
||||
"""409 when an import bundle's lists exceed the same caps live creation
|
||||
enforces (kwargs: story_cards=, memories=, actions=, branches=)."""
|
||||
"""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`.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
return
|
||||
for name, value in lists.items():
|
||||
@@ -264,18 +285,22 @@ def check_bundle_lists(**lists) -> None:
|
||||
|
||||
|
||||
# ---------- Request body size ----------
|
||||
# Generous enough for the biggest legitimate payload (an adventure export with
|
||||
# thousands of actions), applied in every mode — no honest request comes close.
|
||||
# The limit is generous enough for the largest legitimate payload, which is an
|
||||
# adventure export holding thousands of actions. It applies in every mode, and 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 declared Content-Length. Pure ASGI
|
||||
(not BaseHTTPMiddleware) so SSE responses stream through untouched.
|
||||
Chunked uploads without a length are refused — every real client of this
|
||||
API (browser fetch, curl with a file) sends Content-Length."""
|
||||
"""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
|
||||
|
||||
+6
-6
@@ -106,12 +106,12 @@ class SecurityHeadersMiddleware:
|
||||
class ApiErrorMiddleware:
|
||||
"""Counts failed API responses for the analytics dashboard.
|
||||
|
||||
Here rather than in an exception handler because it sees what the client
|
||||
actually got: a 429 from a rate limiter, a 404 from routing, a 500 from a
|
||||
handler that never returned, all the same way. Pure ASGI for the same
|
||||
reason as the headers above — an SSE turn must not be buffered on its way
|
||||
out. Only /api is watched; a 404 on the SPA mount is a page load, not a
|
||||
fault.
|
||||
This is middleware rather than an exception handler, because it observes
|
||||
what the client received. A 429 from a rate limiter, a 404 from routing, and
|
||||
a 500 from a handler that never returned all reach it the same way. It is
|
||||
pure ASGI for the same reason as the headers above: an SSE turn must not be
|
||||
buffered on its way out. It watches `/api` only, because a 404 on the SPA
|
||||
mount is a page load rather than a fault.
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
|
||||
+186
-151
@@ -1,20 +1,28 @@
|
||||
"""Phase 6 — auto summarization + embedding memory bank
|
||||
(per help.aidungeon.com/faq/the-memory-system).
|
||||
"""Phase 6: automatic summarization and the embedding memory bank.
|
||||
|
||||
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
|
||||
session:
|
||||
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
|
||||
block of actions is summarized into a short "memory";
|
||||
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
|
||||
the new memories (the user-edited text is always the base, never clobbered);
|
||||
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
|
||||
is evicted down to capacity ("forgotten" memories are kept for the UI).
|
||||
This follows AI Dungeon's memory system. See
|
||||
help.aidungeon.com/faq/the-memory-system.
|
||||
|
||||
At generation time, `retrieve_memories` embeds the recent story text and ranks
|
||||
the bank by cosine similarity; the top-K become the "Memories" context section.
|
||||
After each turn, `run_post_turn` runs as a fire-and-forget task with its own
|
||||
database session. It does three things:
|
||||
|
||||
All AI calls here are best-effort: failures are logged (debug page) and retried
|
||||
on a later turn because the cursors only advance on success.
|
||||
- Every `MEMORY_INTERVAL` actions, starting once the adventure reaches
|
||||
`MEMORY_START` actions, it summarizes each uncovered block of actions into a
|
||||
short memory.
|
||||
- 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
|
||||
@@ -53,17 +61,19 @@ SUMMARY_SYSTEM_PROMPT = (
|
||||
|
||||
# Adventures with a post-turn task currently running (single-process app).
|
||||
_running: set[int] = set()
|
||||
# Strong refs to in-flight tasks — the event loop only keeps weak references,
|
||||
# so a fire-and-forget task can otherwise be garbage-collected mid-run.
|
||||
# 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()
|
||||
|
||||
|
||||
# BYOK-only by construction: both factories below take the user's own
|
||||
# endpoint/key straight from Settings and never auth.DEMO_*, so summarization
|
||||
# and embedding can't spend the shared demo key (their call sites are also
|
||||
# skipped when using_demo). Don't "fix" this by passing a ProviderConfig in —
|
||||
# summary_model/embedding_model are free-form user input and are not on the
|
||||
# demo whitelist.
|
||||
# Both factories below use the user's own key by construction. They read the
|
||||
# endpoint and key from `Settings` and never from `auth.DEMO_*`, so
|
||||
# summarization and embedding cannot spend the shared demo key. Their call sites
|
||||
# are also skipped when `using_demo` is true.
|
||||
#
|
||||
# Do not change these to accept a `ProviderConfig`. `summary_model` and
|
||||
# `embedding_model` are free-form user input and are not on the demo allowlist.
|
||||
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url,
|
||||
@@ -83,15 +93,15 @@ def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
|
||||
def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
|
||||
"""Store (or clear) a memory's embedding.
|
||||
|
||||
Every column that describes the vector moves together: `embedding_blob` is
|
||||
what the ranking reads and `embedded` is the flag everything else reads.
|
||||
Going through one function is what keeps them in step — and it is also the
|
||||
only place a stored vector can change, which is what makes the cache below
|
||||
safe to invalidate here and nowhere else.
|
||||
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.
|
||||
|
||||
The one caller that legitimately cannot come through here is the bulk
|
||||
clear in `routers/settings.py` when the embedding model changes. It has to
|
||||
set the same two columns by hand; see the note there.
|
||||
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
|
||||
@@ -102,31 +112,36 @@ def set_vector(memory: models.Memory, vector: list[float] | None) -> None:
|
||||
|
||||
# ---------- The vector cache ----------
|
||||
|
||||
# adventure id -> {memory id: vector}, most-recently-used last.
|
||||
# Maps an adventure id to a dict of memory id to vector, with the
|
||||
# most recently used adventure last.
|
||||
#
|
||||
# Turns for one adventure arrive back to back, and the bank barely changes
|
||||
# between them, so re-reading every vector each turn is the same 600 KB over
|
||||
# and over. Vectors are held as array("f") — 4 bytes a component, the same
|
||||
# 6 KB the column holds. A list of Python floats would be eight times that.
|
||||
# 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.
|
||||
#
|
||||
# Correctness rests on two things. Anything that *changes* a vector goes
|
||||
# through set_vector, which drops that one entry. Anything that *removes* a
|
||||
# memory from play — eviction, deletion, pruning, an edit clearing the vector —
|
||||
# takes it out of the catalogue query below, and entries missing from the
|
||||
# catalogue are dropped on the next read. So nothing has to remember to call an
|
||||
# invalidate, which is the failure this design is chosen to avoid.
|
||||
# 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.
|
||||
#
|
||||
# In-process, so it assumes one worker. That is what the deploy runs; a second
|
||||
# worker would each keep their own copy and both would still be correct on
|
||||
# eviction and deletion, but a vector rewritten by one could go stale in the
|
||||
# other until that memory next leaves the catalogue.
|
||||
# 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:
|
||||
"""Drop an adventure's cached vectors. Only needed when the adventure
|
||||
itself goes away — everything else self-corrects (see above)."""
|
||||
"""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)
|
||||
|
||||
|
||||
@@ -157,27 +172,27 @@ def _vectors_for(db: Session, adventure_id: int, ids: list[int]) -> dict[int, ar
|
||||
def forget_node(db: Session, adventure: models.Adventure, action: models.Action) -> int:
|
||||
"""Withdraw what a node produced, because the node is being removed.
|
||||
|
||||
Call it before deleting `action` (undo, delete-an-action). A memory hangs
|
||||
off the node whose block it ends on, so "which memories described this?" is
|
||||
a lookup on `(branch_id, depth)` rather than a scan for rows whose covered
|
||||
range has fallen off the end of the story — which is what
|
||||
`prune_dangling_memories` did, and it could only ever notice the damage
|
||||
after the fact.
|
||||
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.
|
||||
|
||||
Discarding the memory is half of it. The stretch of story it covered is
|
||||
still behind the cursors, so without a rewind those actions read as
|
||||
summarized with nothing describing them, silently, for the rest of the
|
||||
adventure. `source_start` is where that stretch began; the anchor goes to
|
||||
the node before it, which is a depth whether or not anything still sits
|
||||
there.
|
||||
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 parked the
|
||||
whole pre-coordinate bank on it — see the comment on `lineage.ROOT_DEPTH`.
|
||||
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 how many memories were withdrawn.
|
||||
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 hangs off it
|
||||
return 0 # A pre-tree row. No path contains it, so nothing refers to it.
|
||||
doomed = (
|
||||
db.query(models.Memory)
|
||||
.filter(
|
||||
@@ -188,15 +203,16 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
|
||||
.all()
|
||||
)
|
||||
if action.depth == lineage.ROOT_DEPTH:
|
||||
# The opening node is special, and only for memories that describe no
|
||||
# stretch of story. Migration 62 parked every memory written before
|
||||
# memories had coordinates at depth 0 — that was the choice that took
|
||||
# nothing away from anybody, but it also collected them all onto one
|
||||
# node, so withdrawing that node would retire a player's whole bank in
|
||||
# a single click. A memory with no `source_start` was typed (or
|
||||
# migrated), describes nothing that can fall off the end, and so has
|
||||
# nothing to be withdrawn *from*: it stays. A summary that genuinely
|
||||
# ends here is still withdrawn, because the text it describes is going.
|
||||
# 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
|
||||
@@ -217,11 +233,19 @@ async def retrieve_memories(
|
||||
update_stats: bool,
|
||||
exclude_action_id: int | None = None,
|
||||
) -> dict | None:
|
||||
"""Returns {"used": [{id, text, similarity, pinned}], "error": str|None},
|
||||
or None when the memory bank is off for this adventure. `update_stats`
|
||||
bumps use counters (real turns only, not Insights dry runs).
|
||||
`exclude_action_id` drops the action being retried from the similarity
|
||||
query, so the discarded attempt can't steer which memories come back."""
|
||||
"""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():
|
||||
@@ -230,16 +254,17 @@ async def retrieve_memories(
|
||||
if db is None:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Which memories are in play, and nothing else about them. This used to
|
||||
# walk adventure.memories, which loaded every row of the bank *including
|
||||
# its vector* — ~31 KB a memory, three megabytes a turn, 96% of everything
|
||||
# a turn read. Two ids and a flag per row is about eight bytes.
|
||||
# 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 is the *whole* lineage here, not the window the story
|
||||
# is read through: retrieval is long-range recall, and a memory of what
|
||||
# happened forty turns ago is exactly what it exists to find. It stays
|
||||
# affordable because memories are sparse — one per six actions — so the
|
||||
# ancestry of even a heavily forked story returns tens of tiny rows.
|
||||
# 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,
|
||||
@@ -273,8 +298,9 @@ async def retrieve_memories(
|
||||
key=lambda row: row[0],
|
||||
reverse=True,
|
||||
)
|
||||
# Pinned memories are always used and count toward top_k, so the injected
|
||||
# set never exceeds the configured budget (unless pinned alone exceed it).
|
||||
# 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))
|
||||
@@ -283,7 +309,7 @@ async def retrieve_memories(
|
||||
if not used:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# Only now, for at most top_k rows, is the text worth fetching.
|
||||
# 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(
|
||||
@@ -293,9 +319,10 @@ async def retrieve_memories(
|
||||
)
|
||||
|
||||
if update_stats:
|
||||
# synchronize_session=False: nothing in this request reads the counters
|
||||
# back, and matching the UPDATE against loaded objects would mean having
|
||||
# loaded them, which is the cost this whole path exists to avoid.
|
||||
# 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))
|
||||
@@ -343,14 +370,16 @@ async def run_post_turn(adventure_id: int) -> None:
|
||||
)
|
||||
if settings is None:
|
||||
return
|
||||
# No cursor clamp here any more. Undo can leave the story shorter than
|
||||
# the mark, and a *position* past the end of the list was a stalled
|
||||
# pass until the story grew back past it — hence a clamp on every
|
||||
# post-turn run, which had its own trap (clamping to the settled count
|
||||
# rewound a caught-up adventure a step and re-covered an action). An
|
||||
# anchor past the tip is not a broken value: `settled_after` just
|
||||
# reports nothing to do, and the story growing back past it resumes
|
||||
# exactly where it left off.
|
||||
# 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)
|
||||
@@ -367,16 +396,17 @@ async def _create_due_memories(
|
||||
) -> None:
|
||||
provider = summary_provider(settings)
|
||||
for _ in range(MAX_MEMORIES_PER_RUN):
|
||||
# Re-read each pass: a memory just committed doesn't change the story,
|
||||
# but this loop is the only thing that moves the anchor, so both
|
||||
# numbers have to be current.
|
||||
# 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:
|
||||
return # no full block of story past the mark
|
||||
return # No full block of story sits past the mark.
|
||||
if history.count(adventure) < MEMORY_START:
|
||||
return # ...and the adventure is too short to have started at all
|
||||
# (that order on purpose: the common answer is "nothing due", and the
|
||||
# first question answers it without asking how long the story is)
|
||||
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
|
||||
@@ -386,7 +416,8 @@ async def _create_due_memories(
|
||||
MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:"
|
||||
)
|
||||
except ProviderError:
|
||||
return # logged in the debug page; cursor unchanged → retried next turn
|
||||
return # Logged on the debug page. The cursor is unchanged, so the
|
||||
# next turn retries this block.
|
||||
if not text:
|
||||
return
|
||||
memory = models.Memory(
|
||||
@@ -395,11 +426,11 @@ async def _create_due_memories(
|
||||
source_start=block[0].depth,
|
||||
source_end=block[-1].depth,
|
||||
)
|
||||
# Hang it off the node it summarised, so a fork inherits the memories of
|
||||
# the path it forked from and nothing else — and move the mark to that
|
||||
# same node. The two are one statement about where this pass has got to,
|
||||
# and writing them from the same row is what keeps them in step however
|
||||
# gappy the depths underneath are.
|
||||
# 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])
|
||||
@@ -413,18 +444,19 @@ async def _update_story_summary(
|
||||
uncovered = history.count_after(adventure, anchor)
|
||||
if uncovered < SUMMARY_INTERVAL:
|
||||
return
|
||||
# Where the summary will stand once this run succeeds. Read before the AI
|
||||
# call, not after: the mark is the end of the story as this pass saw it,
|
||||
# and a turn landing meanwhile must not be quietly claimed as read.
|
||||
# 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
|
||||
|
||||
# Fold in the memories of the stretch the summary has not read — every
|
||||
# memory hanging off a node past the anchor. Both marks and every memory
|
||||
# are now depths on one path, so there is no translation between coordinate
|
||||
# systems left to get wrong. Falls back to raw story text if memory
|
||||
# creation is lagging (e.g. it just failed).
|
||||
# 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(
|
||||
@@ -462,15 +494,17 @@ async def _update_story_summary(
|
||||
async def _embed_pending(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# A query, not a walk of adventure.memories: this ran every turn and pulled
|
||||
# the whole bank's vectors to find the handful that had 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.
|
||||
#
|
||||
# No branch clause, deliberately, here and in the eviction below. Being
|
||||
# embedded is a fact about the row, not about the path being played:
|
||||
# skipping a sibling's memories would only mean embedding them later, at
|
||||
# the moment somebody switched branches and wanted them ranked. Capacity is
|
||||
# the same — the bank belongs to the adventure, and evicting the memories
|
||||
# of a story nobody is reading is exactly the right thing to evict first.
|
||||
# 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(
|
||||
@@ -496,9 +530,10 @@ async def _embed_pending(
|
||||
def _evict_over_capacity(
|
||||
adventure: models.Adventure, settings: models.Settings, db: Session
|
||||
) -> None:
|
||||
# Counting and ranking are both things the database does without sending
|
||||
# anything back. Walking adventure.memories to count them fetched every
|
||||
# vector in the bank, every turn, whether or not anything was over capacity.
|
||||
# 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(
|
||||
@@ -507,23 +542,23 @@ def _evict_over_capacity(
|
||||
overflow = active - max(1, settings.memory_bank_capacity)
|
||||
if overflow <= 0:
|
||||
return
|
||||
# Least recently touched goes first, and how often it was used only breaks
|
||||
# a tie. The other way round — use_count first — shut the bank. A memory
|
||||
# written this turn has never been used, so once every survivor had been
|
||||
# retrieved even once the newborn was the lowest row in the bank and was
|
||||
# evicted by the same post-turn run that wrote it, in the pass right after
|
||||
# the one that embedded it. Counts only ever go up, so that state never
|
||||
# ends: the bank an adventure happened to hold when it first filled is the
|
||||
# bank it keeps for good, and everything the story does afterwards is
|
||||
# summarized, marked forgotten, and never ranked.
|
||||
# Evict the least recently used memory first, and use the use count only to
|
||||
# break ties.
|
||||
#
|
||||
# Recency does not have that hole, because a new memory carries the newest
|
||||
# timestamp there is — it is the safest row in the bank rather than the
|
||||
# most doomed, and it gets the whole span until something outlives it to
|
||||
# prove itself. Little is given up by demoting the count: a memory that is
|
||||
# genuinely used stays recently-used by being retrieved, so the two only
|
||||
# disagree about memories that mattered once and have not been wanted
|
||||
# since, which is what a full bank should be dropping anyway.
|
||||
# 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))
|
||||
@@ -534,7 +569,7 @@ def _evict_over_capacity(
|
||||
.limit(overflow)
|
||||
).scalars().all()
|
||||
if not doomed:
|
||||
return # every active memory is pinned; capacity yields to the pins
|
||||
return # Every active memory is pinned, so the pins override capacity.
|
||||
db.execute(
|
||||
update(models.Memory)
|
||||
.where(models.Memory.id.in_(doomed))
|
||||
@@ -542,6 +577,6 @@ def _evict_over_capacity(
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
# The bulk UPDATE went around any loaded objects, so anything still holding
|
||||
# the collection would see the evicted memories as active.
|
||||
# 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"])
|
||||
|
||||
+384
-349
File diff suppressed because it is too large
Load Diff
+247
-223
@@ -15,15 +15,18 @@ def utcnow() -> datetime:
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Phase 8 — optional accounts.
|
||||
"""Phase 8: optional accounts.
|
||||
|
||||
Three kinds of rows share this table:
|
||||
- the "local user" (email NULL, is_guest False): auto-created in
|
||||
single-user/local mode; owns everything a pre-Phase-8 DB had;
|
||||
- guests (email NULL, is_guest True): created on first visit in
|
||||
multi-user mode, identified only by their session cookie;
|
||||
- registered users (email set): a guest upgraded in place, so their
|
||||
data survives registration with no re-parenting.
|
||||
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"
|
||||
@@ -64,18 +67,20 @@ class Scenario(Base):
|
||||
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. Either an external "https://…" URL or an inline
|
||||
# "data:image/…;base64,…" URI (the editor downscales uploads before storing
|
||||
# one). Empty means the UI falls back to an emoji sigil or generated art.
|
||||
# Kept in the row rather than on disk because Render's free tier has no
|
||||
# persistent volume, and it makes export bundles self-contained.
|
||||
# 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 there's no `image` — cheap art for
|
||||
# scenarios nobody wants to find a picture for. Separate from `image`
|
||||
# because it's a character, not a locator: no fetch, no cache, no bytes.
|
||||
# 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: RPG world-state template — stat definitions (bands, rules) and
|
||||
# milestones. NULL/empty means this scenario has no RPG layer.
|
||||
# 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)
|
||||
@@ -113,31 +118,36 @@ class Adventure(Base):
|
||||
# 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)
|
||||
# LEGACY (Phase 6): how many actions had been folded into memories / the
|
||||
# story summary, as a *position* in the story. Unread since SP3, and
|
||||
# unwritten except by a v1 import which is handed one; kept for one release
|
||||
# so a rollback resumes from a real number, and dropped in SP8 beside
|
||||
# `actions.index`. The live mark is the anchor pair below.
|
||||
# Legacy columns from Phase 6. Each holds a count of the actions that were
|
||||
# folded into the memories or the story summary, expressed as a position in
|
||||
# the story. Nothing has read them since SP3, and nothing writes them except
|
||||
# a v1 import. They remain for one release so that a rollback resumes from a
|
||||
# real number. SP8 drops them along with `actions.index`. The columns below
|
||||
# hold the marks that this code actually uses.
|
||||
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Phase 14, SP3: the same two marks as nodes — (branch, depth) of the last
|
||||
# action each pass covered. A position slides when an action in front of it
|
||||
# is deleted and silently starts covering one it has never read; a depth
|
||||
# does not move, because it is a coordinate along a path rather than an
|
||||
# offset into a list. NO_DEPTH (-1) is "nothing covered yet", so the first
|
||||
# block needs no special case. Plain integers, not foreign keys, for the
|
||||
# same reason `head_branch_id` below is one. See `context/cursors.py`.
|
||||
# Phase 14, SP3: the same two marks expressed as coordinates. 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 — which branch, and the depth of
|
||||
# its newest node. Deliberately NOT a ForeignKey: branches.adventure_id
|
||||
# already points this way, and a second constraint back would make the two
|
||||
# tables a cycle that create_all cannot order (the fix for that is
|
||||
# use_alter, which SQLite has no ALTER for). It is a cache of a pointer, and
|
||||
# `tree.head_branch` treats a head naming a branch that no longer exists as
|
||||
# a bug to recover from rather than a state to honour.
|
||||
# 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.
|
||||
@@ -149,12 +159,12 @@ class Adventure(Base):
|
||||
story_cards: Mapped[list["StoryCard"]] = relationship(
|
||||
back_populates="adventure", cascade="all, delete-orphan"
|
||||
)
|
||||
# Every action of the adventure — that is, every *branch's*. Not the story
|
||||
# being played, and re-ordering it by depth would not make it one: the
|
||||
# collection is the tree, and a path is a selection out of it. Anything
|
||||
# showing a reader a story goes through `context.history`, which goes
|
||||
# through the branch clause. What is left here is ownership and the
|
||||
# delete-orphan cascade, which are facts about the adventure.
|
||||
# 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",
|
||||
@@ -173,24 +183,25 @@ class Adventure(Base):
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
"""Phase 14 — one path through an adventure's story tree.
|
||||
"""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 *borrows* everything before its fork point from its ancestors. Reading
|
||||
branch C means reading C's nodes, plus B's up to where C left it, plus A's
|
||||
up to where B left it — which is what `lineage` spells out, so a read is an
|
||||
OR-clause per entry instead of a walk up parent pointers.
|
||||
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 there is exactly one root branch per adventure and
|
||||
every node hangs off it. That is not a half-migrated state: a linear story
|
||||
*is* a tree with one branch, which is why writing these columns changes
|
||||
nothing anyone can observe.
|
||||
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.
|
||||
|
||||
No ORM relationships on purpose. `actions.branch_id` and `memories
|
||||
.branch_id` carry ON DELETE CASCADE, so the database removes a deleted
|
||||
branch's nodes; a relationship would have SQLAlchemy load them all to do
|
||||
the same thing, and loading every action of a branch is the exact cost the
|
||||
windowed reads exist to avoid.
|
||||
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"
|
||||
@@ -201,22 +212,23 @@ class Branch(Base):
|
||||
parent_branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# The depth this branch left its parent at, stored when the fork happens and
|
||||
# never inferred afterwards. Inferring it from where two branches' nodes
|
||||
# first differ would be a guess about how the story was played — and a wrong
|
||||
# one as soon as an attempt happens to repeat its parent's text.
|
||||
# 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: [[branch_id, max_depth], ...] where max_depth
|
||||
# is NULL for "to the tip" and otherwise the fork_depth of the branch
|
||||
# beneath it, inclusive. Computed once at fork from the parent's lineage
|
||||
# plus one entry, so no read ever reconstructs it.
|
||||
# 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)
|
||||
# What the player called this line of the story, or NULL for one nobody has
|
||||
# named. NULL rather than a generated "branch 4", because a generated name
|
||||
# is derived and this column is for what was chosen — the same rule the v2
|
||||
# bundle is built on. A stored default would also become a lie the moment a
|
||||
# branch before it is deleted and the ordinals shift under it; an unnamed
|
||||
# branch is drawn from its fork depth instead, which nothing can shift.
|
||||
# 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)
|
||||
|
||||
@@ -228,11 +240,10 @@ class Memory(Base):
|
||||
NULL until embedded, which also marks it for backfill when an embedding
|
||||
model becomes available.
|
||||
|
||||
Cosine ranking happens in Python, which means the vectors cross the wire.
|
||||
The original comment here sized that by count — "fine at a few hundred" —
|
||||
and it was wrong by the only measure that mattered: a few hundred JSON
|
||||
vectors is ten megabytes, fetched fresh every turn. Weigh new columns in
|
||||
bytes.
|
||||
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"
|
||||
@@ -240,39 +251,42 @@ class Memory(Base):
|
||||
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, little-endian float32. Deferred because it is wider than the
|
||||
# rest of the row put together and exactly one code path wants it: anything
|
||||
# bulk-loading memories (the Memories drawer, eviction, the embed queue)
|
||||
# must project the columns it needs rather than load whole entities.
|
||||
# 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, as depths on `branch_id`
|
||||
# (null for a hand-written memory, which summarizes nothing). Written as
|
||||
# `Action.index` values before SP3, which held the same numbers.
|
||||
# `source_end` is the depth of the node the memory hangs off, mirrored into
|
||||
# `depth` below; `source_start` is where it began, which is where the
|
||||
# summarizer has to resume from if the memory is ever withdrawn.
|
||||
# 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. Before SP3 these columns held `Action.index` values, which were
|
||||
# the same numbers. `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 — the last action it
|
||||
# summarises. Anything derived attaches to the node it came from, which is
|
||||
# what makes a fork free: a shared ancestor's memories are shared
|
||||
# automatically, and a memory covering a stretch of branch B is invisible
|
||||
# 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 one, including a hand-written one: it takes the head at
|
||||
# the moment it was written (SP7). A NULL depth used to mean "belongs to the
|
||||
# adventure, not to a path", which is a category no fork could cap — the
|
||||
# memory followed the reader onto branches whose story it never described.
|
||||
# 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. Maintained on write by memorybank
|
||||
# .set_vector, for the same reason actions.variant_count exists beside
|
||||
# actions.variants: every reader wants the one-bit answer and none of them
|
||||
# should have to fetch six kilobytes of vector to get it.
|
||||
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this
|
||||
# column current, for the same reason that `actions.variant_count` sits
|
||||
# beside `actions.variants`. 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
|
||||
@@ -300,10 +314,12 @@ class StoryCard(Base):
|
||||
keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers
|
||||
entry: Mapped[str] = mapped_column(Text, default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
# Adventure copies only: which piece of the scenario this card came from —
|
||||
# "card:<scenario_card_id>" or "npc:<npc_key>". "Update from scenario"
|
||||
# refreshes/removes exactly these; NULL means player-authored (left alone),
|
||||
# or a copy predating the column (matched by name, then adopted).
|
||||
# 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")
|
||||
@@ -312,72 +328,76 @@ class StoryCard(Base):
|
||||
|
||||
class Action(Base):
|
||||
__tablename__ = "actions"
|
||||
# Phase 14: every read of a story is "this branch up to this depth, or that
|
||||
# branch up to that depth, ...", so (branch_id, depth) is the shape every
|
||||
# one of those clauses wants an index on.
|
||||
# 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"))
|
||||
index: Mapped[int] = mapped_column(Integer)
|
||||
# Phase 14: the node's place in the tree. `depth` is a position along *a*
|
||||
# path, not a global turn number — A4 and B4 are alternatives, not
|
||||
# duplicates — and it replaces `index` as the ordering key.
|
||||
# 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. `depth` replaces `index` as the ordering
|
||||
# key.
|
||||
#
|
||||
# Nullable because ALTER TABLE cannot add a NOT NULL column with no
|
||||
# default and there is no sensible default for "which branch": the
|
||||
# migration fills them, `tree.place_action` fills them for new nodes, and
|
||||
# from SP2 on a NULL branch_id is a row no read can see. Legacy `index`
|
||||
# stays beside them, unread, until the tree is proven live (SP8 drops it).
|
||||
# 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. The legacy `index` column stays alongside, unread, until the tree
|
||||
# is proven in production. SP8 drops it.
|
||||
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 this one was played after — the take that was
|
||||
# live when it was written, not merely whatever sits at depth - 1 now.
|
||||
# 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.
|
||||
#
|
||||
# It exists for one question: which takes belong to the same turn. A
|
||||
# coordinate cannot answer it, because a take that gets forked onto its own
|
||||
# branch leaves the coordinate its siblings are still at and would read
|
||||
# `1/1` next to their `1/3`. A parent does not move when a branch does.
|
||||
# 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.
|
||||
#
|
||||
# Read only to group takes — one indexed lookup, never a walk. Paths still
|
||||
# resolve through `lineage`, which is why this column can be added without
|
||||
# touching a single read of the story.
|
||||
# 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.
|
||||
#
|
||||
# NULL on a root node, and on every pre-SP9 row the migration could not
|
||||
# place: `attempts.group` falls back to the coordinate there, which is what
|
||||
# those rows were written under.
|
||||
# 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 tells at its
|
||||
# coordinate. Retry no longer rewrites a row — it writes a *sibling* at the
|
||||
# same (branch, depth), so a coordinate can hold several attempts and
|
||||
# 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, for the same reason it is the only place that
|
||||
# knows about branches: an attempt leaking into a read is a story quietly
|
||||
# telling itself twice.
|
||||
# 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, which is why the default is True and why
|
||||
# every pre-SP4 row is correct without being visited.
|
||||
# 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, for the Insights viewer. By far
|
||||
# the biggest column in the database — 163 KB a row averaged over
|
||||
# production and 232 KB on the longest adventure, 89% of everything stored
|
||||
# — and needed by exactly one endpoint, one action at a time.
|
||||
# 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.
|
||||
#
|
||||
# Two separate defences, because it is expensive in two separate ways.
|
||||
# `deferred=True` is the read defence: never loaded unless something
|
||||
# touches the attribute, so a page load pays nothing for it. Bulk readers
|
||||
# must NOT touch it; that is what `world_delta` below exists for.
|
||||
# CompressedJSON is the *storage* defence: this is the column that decides
|
||||
# when the free tier's 512 MB runs out. Still a dict either way — see
|
||||
# compression.py.
|
||||
# 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
|
||||
)
|
||||
@@ -386,58 +406,58 @@ class Action(Base):
|
||||
# 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)
|
||||
# LEGACY (SP4): Adventure.script_state / world_state as they were
|
||||
# immediately BEFORE this action's script hooks ran. Unwritten since SP4
|
||||
# and read by nothing — the *after* pair below replaced them, because a
|
||||
# sibling attempt needs its own outcome and a "before" picture is shared by
|
||||
# every attempt at the turn. Kept for one release so a rolled-back build
|
||||
# still finds a real snapshot on every row it wrote itself; SP8 drops them
|
||||
# beside `index` and `variants`.
|
||||
# Legacy columns from before SP4. They hold `Adventure.script_state` and
|
||||
# `Adventure.world_state` as they were immediately before this action's
|
||||
# script hooks ran. Nothing has written or read them since SP4. The pair of
|
||||
# "after" columns below replaced them, because each sibling attempt needs
|
||||
# its own outcome and every attempt at a turn shares the same starting
|
||||
# state. These columns remain for one release so that a rolled-back build
|
||||
# still finds a real snapshot on the rows it wrote. SP8 drops them along
|
||||
# with `index` and `variants`.
|
||||
state_before: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
world_state_before: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
# Phase 14, SP4: the shared script scoreboard and the RPG world state as
|
||||
# they stood once this node had been played — *its* outcome, not its
|
||||
# starting position.
|
||||
# Phase 14, SP4: the shared script state and the RPG world state as they
|
||||
# stood after this node was played. These columns record the node's outcome
|
||||
# rather than its starting position.
|
||||
#
|
||||
# Two things want this and neither can use a "before" picture. Switching
|
||||
# between siblings has to put back the state the chosen attempt produced,
|
||||
# and the attempts differ precisely in what they produced. And rolling back
|
||||
# to before a turn is "the state the node in front of it left behind",
|
||||
# which is one lookup on the path rather than a snapshot that has to be
|
||||
# taken from inside the turn being rolled back.
|
||||
# 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.
|
||||
#
|
||||
# NULL on rows written before SP4 that the migration could not derive one
|
||||
# for, and tolerated everywhere: a missing snapshot means "leave the live
|
||||
# state alone", never "reset it".
|
||||
# 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.
|
||||
#
|
||||
# Deferred: only ever read for the one node being switched to, undone or
|
||||
# retried past.
|
||||
# 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
|
||||
)
|
||||
# LEGACY (SP4): retry history as a JSON repeating group. Every attempt at
|
||||
# this turn is its own row now — see `live` above and `app/attempts.py` —
|
||||
# so nothing reads this. Kept until SP8 for the same reason `index` is, and
|
||||
# read exactly once more on the way out: migration 60 is what turns each
|
||||
# entry into the sibling row it should always have been.
|
||||
# A legacy column from before SP4. It holds the retry history as a repeating
|
||||
# group inside a JSON list. Every attempt at a turn is now its own row, as
|
||||
# described on `live` above and in `app/attempts.py`, so nothing reads this
|
||||
# column. It remains until SP8 for the same reason `index` does. Migration
|
||||
# 60 reads it once more, to turn each entry into a sibling row.
|
||||
variants: Mapped[list | None] = mapped_column(JSON, nullable=True, deferred=True)
|
||||
# Where the row sits in its sibling group: `variant_index` is this
|
||||
# attempt's ordinal, oldest first, and `variant_count` is how many attempts
|
||||
# the group holds (0, not 1, when the turn was never retried — the pager
|
||||
# reads that as "nothing to page through").
|
||||
# Where the row sits in its sibling group. `variant_index` is this
|
||||
# attempt's ordinal, counting from the oldest. `variant_count` is the number
|
||||
# of attempts in the group. It is 0 rather than 1 when the turn was never
|
||||
# retried, which the pager treats as having nothing to page through.
|
||||
#
|
||||
# A cache of two facts about the group, maintained in one place
|
||||
# (`attempts.renumber`) for the same reason it used to be a cache of
|
||||
# `len(variants)`: a page response wants them for every row and must not
|
||||
# pay a query per turn to get them. SP7 replaces the pager with the branch
|
||||
# view and SP8 drops both columns.
|
||||
# Both columns are caches, and `attempts.renumber` is the only place that
|
||||
# maintains them. The reason matches why `variant_count` once cached
|
||||
# `len(variants)`: a page response needs both numbers for every row and
|
||||
# cannot afford one query per turn. SP7 replaces the pager with the branch
|
||||
# view, and SP8 drops both columns.
|
||||
variant_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
variant_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
@@ -450,9 +470,9 @@ class Action(Base):
|
||||
under an AI message. Labels are path-based (no schema needed):
|
||||
`npc.gwen.trust` -> "gwen trust".
|
||||
|
||||
Reads `world_delta`, never `context_snapshot` — this runs for every
|
||||
action in a list response, and touching the deferred snapshot here
|
||||
would drag the whole prompt archive out of the database."""
|
||||
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 []
|
||||
@@ -500,10 +520,10 @@ class AdventureScript(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
# The library Script this copy was made from, so it can be re-synced on
|
||||
# demand. NULL for legacy copies (predate this column) and demo-derived
|
||||
# ones whose source isn't owned by the player — those fall back to a
|
||||
# name match, or simply aren't syncable.
|
||||
# The library Script that this copy was made from, which lets the player
|
||||
# re-sync it on demand. The value is NULL for legacy copies that predate
|
||||
# this column, and for demo-derived copies whose source the player does not
|
||||
# own. Those copies fall back to matching by name, or cannot be synced.
|
||||
source_script_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -529,7 +549,8 @@ class Settings(Base):
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, unique=True
|
||||
)
|
||||
endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1")
|
||||
# Fernet-encrypted at rest ("enc:..." — see security.py); use api_key_plain.
|
||||
# Encrypted at rest with Fernet, which produces a value that starts with
|
||||
# "enc:". See security.py. To read the key, use `api_key_plain`.
|
||||
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
|
||||
@@ -557,10 +578,10 @@ class Settings(Base):
|
||||
# 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
|
||||
# Was 200. Lowered on retrieval-quality grounds first: ranking two hundred
|
||||
# memories to pick five means the five are chosen out of a lot of noise,
|
||||
# and older memories describe a story the player has moved on from. That it
|
||||
# also cuts what the bank costs to read is the smaller reason.
|
||||
# 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)
|
||||
|
||||
@@ -576,18 +597,19 @@ class Settings(Base):
|
||||
|
||||
|
||||
# ---------- Visit analytics (see analytics.py) ----------
|
||||
# Two deliberately dumb tables. Neither can hold anything a player wrote, and
|
||||
# neither can be joined back to a `users` row: the visitor column is an HMAC,
|
||||
# with no foreign key, so guest cleanup deleting an account leaves the history
|
||||
# it contributed to intact and anonymous.
|
||||
# Two intentionally simple tables. Neither can hold text that a player wrote,
|
||||
# and neither can be joined back to a `users` row, because the visitor column
|
||||
# holds an HMAC and has no foreign key. When guest cleanup deletes an account,
|
||||
# the history that account contributed remains intact and anonymous.
|
||||
|
||||
|
||||
class AnalyticsDaily(Base):
|
||||
"""One counter: how many times `label` happened within `metric` on `day`.
|
||||
|
||||
A generic (metric, label, hits) triple rather than a column per statistic,
|
||||
so measuring something new later costs a constant instead of a migration.
|
||||
Written only by UPSERT, from a buffer — see analytics.flush.
|
||||
The table stores a generic triple of metric, label, and hits rather than one
|
||||
column per statistic. Measuring something new therefore costs a constant
|
||||
rather than a migration. The only writer is an UPSERT that runs from a
|
||||
buffer. See `analytics.flush`.
|
||||
"""
|
||||
|
||||
__tablename__ = "analytics_daily"
|
||||
@@ -607,10 +629,10 @@ class AnalyticsDaily(Base):
|
||||
class AnalyticsVisitorDay(Base):
|
||||
"""One visitor, one day, and which funnel steps they reached on it.
|
||||
|
||||
Exists so the funnel counts people rather than clicks — a player who starts
|
||||
six adventures is one person who started an adventure. `is_new` is set when
|
||||
the visitor has no earlier row, which is also why the visitor column is
|
||||
indexed on its own.
|
||||
This table exists so that the funnel counts people rather than clicks. A
|
||||
player who starts six adventures counts as one person who started an
|
||||
adventure. `is_new` is set when the visitor has no earlier row, which is why
|
||||
the visitor column also has an index of its own.
|
||||
"""
|
||||
|
||||
__tablename__ = "analytics_visitor_days"
|
||||
@@ -634,15 +656,16 @@ class AnalyticsVisitorDay(Base):
|
||||
class AccessEvent(Base):
|
||||
"""One sign-in, registration, failed attempt, or session first-seen.
|
||||
|
||||
The counterpart to the two tables above, and deliberately not mixed in with
|
||||
them: this one identifies people on purpose — address, email, device — so
|
||||
keeping it in its own table (and its own module) means the anonymity of the
|
||||
counters stays a property of the code rather than of a convention.
|
||||
This table is the counterpart to the two above, and it is kept separate from
|
||||
them on purpose. It identifies people by design, recording address, email,
|
||||
and device. Keeping it in its own table and its own module means that the
|
||||
structure enforces the anonymity of the counters rather than a convention.
|
||||
|
||||
`user_id` is a plain integer with no foreign key. An access log that
|
||||
disappeared when the account did would not be an access log, and guest
|
||||
cleanup deletes accounts on a schedule; `who` and `is_guest` are snapshots
|
||||
for the same reason, so a row still reads correctly afterwards.
|
||||
disappeared when the account did would not serve its purpose, and guest
|
||||
cleanup deletes accounts on a schedule. `who` and `is_guest` are snapshots
|
||||
for the same reason, so a row still reads correctly after the account is
|
||||
gone.
|
||||
"""
|
||||
|
||||
__tablename__ = "access_events"
|
||||
@@ -652,8 +675,9 @@ class AccessEvent(Base):
|
||||
# session | login | register | login_failed
|
||||
kind: Mapped[str] = mapped_column(String(16))
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Email for a registered account, "Guest #12" otherwise; for a failed
|
||||
# sign-in, the address that was tried — which is the point of the row.
|
||||
# The email for a registered account, or a label such as "Guest #12"
|
||||
# otherwise. For a failed sign-in, this holds the address that was tried,
|
||||
# which is the reason the row exists.
|
||||
who: Mapped[str] = mapped_column(String(320), default="")
|
||||
is_guest: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
ip: Mapped[str] = mapped_column(String(45), default="") # 45 = max IPv6
|
||||
@@ -662,19 +686,19 @@ class AccessEvent(Base):
|
||||
user_agent: Mapped[str] = mapped_column(String(200), default="")
|
||||
|
||||
|
||||
# Phase 14 — the floor under `tree.place_action`.
|
||||
# Phase 14: the fallback under `tree.place_action`.
|
||||
#
|
||||
# From SP2 a read selects on (branch_id, depth): a node written without them is
|
||||
# a node no page, no context build and no memory pass can see, and it fails by
|
||||
# disappearing rather than by raising. The writers all place their nodes
|
||||
# explicitly, but "all the writers remember" is a promise that has to hold for
|
||||
# every fixture, script and test written from here on, so the session enforces
|
||||
# it on the way to the database instead.
|
||||
# Since SP2, reads filter on `branch_id` and `depth`. A node written without
|
||||
# them is invisible to every page, every context build, and every memory pass.
|
||||
# The failure is silent, because nothing raises an error. Every current writer
|
||||
# places its nodes explicitly, but relying on that would also mean relying on
|
||||
# every fixture, script, and test written from now on. The session therefore
|
||||
# enforces the rule as rows travel to the database.
|
||||
#
|
||||
# Registered here rather than in tree.py so that importing the models is enough
|
||||
# to arm it — the invariant belongs to the rows, not to the module that usually
|
||||
# writes them. The import is deferred into the callback because tree.py imports
|
||||
# this module.
|
||||
# This listener is registered here rather than in tree.py so that importing the
|
||||
# models is enough to enable it. The invariant belongs to the rows, not to the
|
||||
# module that usually writes them. The import sits inside the callback because
|
||||
# tree.py imports this module.
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _place_new_nodes_on_the_tree(session, flush_context, instances):
|
||||
from . import tree
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""SSRF guard for the one place the server makes an outbound request to a
|
||||
user-supplied address: the BYOK `endpoint_url` (connection test + turns/chat).
|
||||
|
||||
Without this, a hosted user could point endpoint_url at an internal service or
|
||||
the cloud metadata endpoint (169.254.169.254) and have the server fetch it —
|
||||
the connection test even echoes part of the response back. We refuse any URL
|
||||
that resolves to a non-public address.
|
||||
Without this guard, a hosted user could point `endpoint_url` at an internal
|
||||
service or at the cloud metadata endpoint, 169.254.169.254, and have the server
|
||||
fetch it. The connection test even returns part of the response. The guard
|
||||
therefore refuses any URL that resolves to a non-public address.
|
||||
|
||||
No-op in local mode: a local install talking to http://localhost:11434 (Ollama)
|
||||
is the normal, intended case — the guard only applies to the hosted, multi-user
|
||||
deployment where the endpoint comes from an untrusted visitor.
|
||||
The guard does nothing in local mode. A local install talking to
|
||||
http://localhost:11434, which is Ollama, is the intended case. The guard applies
|
||||
only to a hosted, multi-user deployment, where the endpoint comes from an
|
||||
untrusted visitor.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
|
||||
@@ -17,10 +17,10 @@ class ProviderError(Exception):
|
||||
|
||||
class Provider(ABC):
|
||||
# The endpoint's own token accounting for the most recent call, when it
|
||||
# reported any — notably `prompt_tokens_details.cached_tokens`, which is
|
||||
# the only direct read on whether the prompt prefix is being cached.
|
||||
# Callers read it after the call they made; one provider is built per
|
||||
# request, so there is nothing to race.
|
||||
# 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
|
||||
|
||||
@@ -6,32 +6,33 @@ import httpx
|
||||
from .. import debuglog, netguard
|
||||
from .base import PromptParts, Provider, ProviderError
|
||||
|
||||
# Framing appended after the story text in chat mode, so chat-tuned models keep
|
||||
# continuing prose instead of replying conversationally.
|
||||
# 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.]"
|
||||
|
||||
# OpenRouter serves one model from whichever upstream is available, and every
|
||||
# upstream holds its own prompt cache — so a request that lands somewhere new
|
||||
# starts cold however stable the prompt is. Naming a preferred upstream makes
|
||||
# routing deterministic, which is what lets a cache be hit at all.
|
||||
# upstream holds its own prompt cache, so a request routed somewhere new starts
|
||||
# with a cold cache however stable the prompt is. Naming a preferred upstream
|
||||
# makes routing deterministic, which is what allows a cache hit at all.
|
||||
#
|
||||
# `allow_fallbacks` is deliberately left at its default of true: this is a
|
||||
# preference, not a restriction. If the named upstream is down the request
|
||||
# still goes through somewhere else and merely misses the cache, which is the
|
||||
# behaviour we had anyway.
|
||||
# `allow_fallbacks` stays at its default of true on purpose, because this is a
|
||||
# preference rather than a restriction. If the named upstream is down, the
|
||||
# request still goes elsewhere and only misses the cache, which is the behavior
|
||||
# without this setting.
|
||||
#
|
||||
# A whitelist rather than a derivation from the model slug. The vendor half of
|
||||
# a slug is *usually* the provider slug ("deepseek/..." -> "deepseek", verified
|
||||
# against /api/v1/providers) but not reliably: Google's models are served by
|
||||
# "google-ai-studio" and "google-vertex", and there is no "google". Look a
|
||||
# vendor up on the model's Providers tab before adding it here — a slug that
|
||||
# does not exist is a routing preference that silently does nothing at best.
|
||||
# This is a list rather than a value derived from the model slug. The vendor half
|
||||
# of a slug is usually the provider slug, such as "deepseek/..." mapping to
|
||||
# "deepseek", which was verified against /api/v1/providers, but not reliably.
|
||||
# Google's models are served by "google-ai-studio" and "google-vertex", and there
|
||||
# is no "google". Look a vendor up on the model's Providers tab before adding it
|
||||
# here. A slug that does not exist is a routing preference that, at best, does
|
||||
# nothing.
|
||||
_OPENROUTER_HOST = "openrouter.ai"
|
||||
_PREFERRED_UPSTREAM = {"deepseek": "deepseek"}
|
||||
|
||||
|
||||
# Completion endpoints have no roles, so a plain chat has to be flattened into
|
||||
# one labelled transcript that trails off on "Assistant:" for the model to continue.
|
||||
# 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"}
|
||||
|
||||
|
||||
@@ -43,7 +44,11 @@ def flatten_messages(messages: list[dict]) -> str:
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(Provider):
|
||||
"""Adapter for any /v1-style endpoint: Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq…"""
|
||||
"""Adapter for any /v1-style endpoint.
|
||||
|
||||
This covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, and Groq, among
|
||||
others.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -56,17 +61,18 @@ class OpenAICompatibleProvider(Provider):
|
||||
self.base_url = endpoint_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.api_mode = api_mode # "chat" | "completion"
|
||||
# Thinking budget for reasoning models, on top of max_tokens. 0 = the
|
||||
# `reasoning` param is not sent (endpoints that don't know it may
|
||||
# reject unknown fields); negative = explicitly ask the endpoint to
|
||||
# turn reasoning off.
|
||||
self.api_mode = api_mode # Either "chat" or "completion".
|
||||
# The thinking budget for reasoning models, on top of `max_tokens`. A
|
||||
# value of 0 means the `reasoning` parameter is not sent, because an
|
||||
# endpoint that does not know the field may reject it. A negative value
|
||||
# asks the endpoint to turn reasoning off.
|
||||
self.reasoning_max_tokens = reasoning_max_tokens
|
||||
# Token accounting from the last call, when the endpoint reported any:
|
||||
# prompt/completion counts plus, on OpenRouter, `prompt_tokens_details.
|
||||
# cached_tokens` — the number of prompt tokens read from cache instead
|
||||
# of billed in full. Written by every request method, so a caller reads
|
||||
# it after the call it made; one provider is built per request.
|
||||
# The token accounting from the last call, when the endpoint reported
|
||||
# any. It holds the prompt and completion counts, plus, on OpenRouter,
|
||||
# `prompt_tokens_details.cached_tokens`, which is the number of prompt
|
||||
# tokens read from cache rather than billed in full. 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:
|
||||
@@ -76,14 +82,16 @@ class OpenAICompatibleProvider(Provider):
|
||||
return headers
|
||||
|
||||
def _apply_reasoning_budget(self, body: dict) -> None:
|
||||
"""Give reasoning models their own thinking budget (OpenRouter-style),
|
||||
raising max_tokens so the actual output keeps its full budget.
|
||||
"""Gives reasoning models their own thinking budget, in the OpenRouter style.
|
||||
|
||||
A negative budget means the opposite: send `effort: "none"` to switch
|
||||
reasoning off on models that do it by default (DeepSeek V4 Flash, say).
|
||||
That's distinct from `exclude: true`, which still thinks — and bills —
|
||||
but hides the trace. Zero stays "send nothing at all" so endpoints that
|
||||
reject unknown fields (Ollama) keep working."""
|
||||
The method raises `max_tokens`, so the output keeps its full budget.
|
||||
|
||||
A negative budget does the opposite. It sends `effort: "none"` to turn
|
||||
reasoning off on a model that reasons by default, such as DeepSeek V4
|
||||
Flash. That differs from `exclude: true`, which still reasons and still
|
||||
bills for it while hiding the trace. Zero still means send nothing, so an
|
||||
endpoint that rejects unknown fields, such as Ollama, keeps working.
|
||||
"""
|
||||
if self.api_mode != "chat":
|
||||
return
|
||||
if self.reasoning_max_tokens < 0:
|
||||
@@ -93,11 +101,12 @@ class OpenAICompatibleProvider(Provider):
|
||||
body["max_tokens"] += self.reasoning_max_tokens
|
||||
|
||||
def _apply_provider_routing(self, body: dict) -> None:
|
||||
"""Prefer one upstream on OpenRouter, so the prompt cache is warm.
|
||||
"""Prefers one upstream on OpenRouter, so the prompt cache stays warm.
|
||||
|
||||
Silent no-op everywhere else: `provider` is an OpenRouter extension and
|
||||
Ollama and friends reject fields they do not know — the same trap the
|
||||
`reasoning` param above is written around."""
|
||||
The method does nothing anywhere else. `provider` is an OpenRouter
|
||||
extension, and Ollama and similar servers reject fields they do not know.
|
||||
The `reasoning` parameter above is written around the same constraint.
|
||||
"""
|
||||
if _OPENROUTER_HOST not in self.base_url:
|
||||
return
|
||||
upstream = _PREFERRED_UPSTREAM.get(self.model.split("/", 1)[0].lower())
|
||||
@@ -105,12 +114,13 @@ class OpenAICompatibleProvider(Provider):
|
||||
body["provider"] = {"order": [upstream]}
|
||||
|
||||
def _record_usage(self, payload: dict) -> None:
|
||||
"""Record the endpoint's own token accounting, if it reported any.
|
||||
"""Records the endpoint's own token accounting, if it reported any.
|
||||
|
||||
OpenRouter always reports usage now (`usage: {include: true}` and
|
||||
`stream_options` are deprecated no-ops), and in a stream it rides on a
|
||||
final chunk that carries no choices — which is why this is read
|
||||
separately from the text extraction rather than beside it."""
|
||||
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
|
||||
@@ -147,8 +157,8 @@ class OpenAICompatibleProvider(Provider):
|
||||
if not choices:
|
||||
return ""
|
||||
choice = choices[0]
|
||||
# chat stream → delta.content; completion stream → text;
|
||||
# non-stream fallbacks → message.content / text
|
||||
# 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")
|
||||
@@ -159,8 +169,11 @@ class OpenAICompatibleProvider(Provider):
|
||||
|
||||
@staticmethod
|
||||
def _extract_reasoning(payload: dict) -> str:
|
||||
"""Reasoning-model thinking: OpenRouter normalizes to `reasoning`;
|
||||
DeepSeek-style servers use `reasoning_content`."""
|
||||
"""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 ""
|
||||
@@ -178,7 +191,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def generate(
|
||||
self, parts: PromptParts, *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Yields ("text" | "reasoning", chunk) pairs."""
|
||||
"""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)
|
||||
@@ -188,9 +201,12 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def chat(
|
||||
self, messages: list[dict], *, temperature: float, max_tokens: int
|
||||
) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Plain multi-turn chat — no story framing, no context assembly. Takes
|
||||
[{"role", "content"}, ...] straight to the endpoint. Used by the AI Chat
|
||||
scratchpad; the turn engine uses generate()."""
|
||||
"""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":
|
||||
@@ -217,10 +233,14 @@ class OpenAICompatibleProvider(Provider):
|
||||
yield event
|
||||
|
||||
async def _stream(self, url: str, body: dict) -> AsyncIterator[tuple[str, str]]:
|
||||
"""Shared SSE plumbing for generate()/chat(): POST a streaming request
|
||||
and yield ("text" | "reasoning", chunk) pairs, logging the exchange."""
|
||||
# SSRF guard (hosted mode): a user-supplied endpoint_url must not point
|
||||
# at an internal/metadata address. No-op for local installs.
|
||||
"""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.
|
||||
"""
|
||||
# SSRF guard for hosted mode. A user-supplied `endpoint_url` must not
|
||||
# point at an internal or metadata address. This does nothing for a
|
||||
# local install.
|
||||
reason = netguard.endpoint_block_reason(url)
|
||||
if reason:
|
||||
raise ProviderError(f"This endpoint can't be used — {reason}.")
|
||||
@@ -232,8 +252,8 @@ class OpenAICompatibleProvider(Provider):
|
||||
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; buffer non-SSE lines so we can fall back to it.
|
||||
# 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():
|
||||
@@ -301,8 +321,11 @@ class OpenAICompatibleProvider(Provider):
|
||||
async def complete(
|
||||
self, system: str, user: str, *, temperature: float = 0.3, max_tokens: int = 400
|
||||
) -> str:
|
||||
"""Single non-streaming completion for background calls (summarization).
|
||||
Unlike generate(), no story-continuation framing is added."""
|
||||
"""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":
|
||||
@@ -351,7 +374,7 @@ class OpenAICompatibleProvider(Provider):
|
||||
return text.strip()
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""POST /v1/embeddings; self.model is the embedding model here."""
|
||||
"""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"
|
||||
@@ -388,8 +411,9 @@ class OpenAICompatibleProvider(Provider):
|
||||
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 is actionable.
|
||||
# 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 "
|
||||
|
||||
+591
-524
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""Visit analytics: one endpoint the browser writes to, one the owner reads.
|
||||
|
||||
The split matters. `/collect` is public and takes exactly one fact — which
|
||||
page was viewed — because anything a stranger can POST is a number a stranger
|
||||
can invent. Everything the dashboard actually relies on (turns, adventures,
|
||||
sign-ups, demo spend, errors) is recorded server-side by the code performing
|
||||
it, so those counts are as trustworthy as the app itself.
|
||||
The split matters. `/collect` is public and accepts one fact, which page was
|
||||
viewed, because anything a stranger can POST is a number a stranger can invent.
|
||||
Everything the dashboard relies on, meaning turns, adventures, sign-ups, demo
|
||||
spend, and errors, is recorded on the server by the code that performs it, so
|
||||
those counts are as trustworthy as the app itself.
|
||||
|
||||
The two reading endpoints are owner-only and 404 for everyone else, the same
|
||||
way the AI Chat router does: a feature nobody else can use is better off not
|
||||
@@ -27,7 +27,8 @@ def owner(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
) -> models.User:
|
||||
"""Gate for the reading half. 404, not 403 — see the module docstring."""
|
||||
"""Gates the reading half. It returns 404 rather than 403. See the module
|
||||
docstring."""
|
||||
if not auth.is_owner(user):
|
||||
raise HTTPException(404, "Not found")
|
||||
return user
|
||||
@@ -39,10 +40,10 @@ Owner = Depends(owner)
|
||||
class Pageview(BaseModel):
|
||||
"""What the SPA reports on a page load or a route change.
|
||||
|
||||
`first` marks a real page load rather than a client-side navigation: the
|
||||
things that describe a *visit* rather than a *view* — where it came from,
|
||||
on what kind of device, from which country — are recorded only then, so a
|
||||
visitor who clicks around five pages is still one referral.
|
||||
`first` marks a real page load rather than a client-side navigation. The
|
||||
facts that describe a visit rather than a view, which are where it came from,
|
||||
on what kind of device, and from which country, are recorded only on a page
|
||||
load, so a visitor who clicks through five pages is still one referral.
|
||||
"""
|
||||
|
||||
path: str = Field("", max_length=300)
|
||||
@@ -67,9 +68,9 @@ def collect(
|
||||
if auth.MULTI_USER
|
||||
else auth.local_user(db)
|
||||
)
|
||||
# The operator's own clicking is not traffic. Only in multi-user mode —
|
||||
# locally everyone is the owner, and excluding them would leave the
|
||||
# dashboard permanently empty on the machine it is developed on.
|
||||
# The operator's own clicks are not traffic. This applies only in
|
||||
# multi-user mode. Locally every user is the owner, and excluding them would
|
||||
# leave the dashboard empty on the machine the app is developed on.
|
||||
if auth.MULTI_USER and user is not None and auth.is_owner(user):
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -95,8 +96,10 @@ def summary(
|
||||
db: Session = Depends(get_db),
|
||||
_user: models.User = Owner,
|
||||
) -> dict:
|
||||
"""The whole dashboard in one aggregate response — a few kilobytes however
|
||||
much traffic sits behind it."""
|
||||
"""Returns the whole dashboard in one aggregate response.
|
||||
|
||||
The response is a few kilobytes however much traffic is behind it.
|
||||
"""
|
||||
return analytics.summary(db, days)
|
||||
|
||||
|
||||
@@ -111,9 +114,9 @@ def access_log(
|
||||
) -> dict:
|
||||
"""A page of the access log, newest first.
|
||||
|
||||
Unlike /summary this returns rows about people, which is the whole point of
|
||||
it — so it is behind the same owner gate, paged rather than dumped, and has
|
||||
no counterpart the people it describes can reach.
|
||||
Unlike `/summary`, this returns rows about people, which is what it is for.
|
||||
It is therefore behind the same owner gate, it is paged rather than returned
|
||||
in full, and the people it describes have no endpoint that reaches it.
|
||||
"""
|
||||
page = accesslog.recent(
|
||||
db, limit=limit, before_id=before_id, kind=kind, query=q
|
||||
|
||||
@@ -53,15 +53,19 @@ def me_payload(user: models.User, db: Session) -> dict:
|
||||
|
||||
@router.get("/me")
|
||||
def me(request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Who am I? In multi-user mode this also bootstraps the session: with no
|
||||
(or an invalid) cookie it creates a guest user and sets one — the
|
||||
frontend calls this on load and after any 401."""
|
||||
"""Returns the current user.
|
||||
|
||||
In multi-user mode this also establishes the session. If the cookie is
|
||||
missing or invalid, the endpoint creates a guest user and sets a cookie. The
|
||||
frontend calls it on load and after any 401.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
user = auth.local_user(db)
|
||||
else:
|
||||
user = auth.resolve_session_user(request, db)
|
||||
if user is None:
|
||||
# Each new guest is a database row — cap how fast one IP can mint them.
|
||||
# Each new guest is a database row, so cap how fast one IP can
|
||||
# create them.
|
||||
limits.rate_limit("guest", request)
|
||||
user = models.User(is_guest=True)
|
||||
db.add(user)
|
||||
@@ -80,8 +84,11 @@ def register(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Upgrade the current guest in place — same user_id, so every adventure,
|
||||
scenario, script and setting they created as a guest is kept."""
|
||||
"""Upgrades the current guest in place.
|
||||
|
||||
The `user_id` does not change, so every adventure, scenario, script, and
|
||||
setting they created as a guest is kept.
|
||||
"""
|
||||
if not auth.MULTI_USER:
|
||||
raise HTTPException(400, "Accounts are disabled in local mode.")
|
||||
limits.rate_limit("auth", request)
|
||||
|
||||
+28
-18
@@ -1,14 +1,15 @@
|
||||
"""AI Chat — a plain scratchpad for talking to a model directly.
|
||||
"""AI Chat: a plain scratchpad for talking to a model directly.
|
||||
|
||||
Power users only (the AIDND_POWER_USERS email allowlist). Deliberately thin:
|
||||
no story context, no scripts, no world state, and nothing persisted — the
|
||||
conversation lives in the browser and is posted up whole on each turn. It
|
||||
exists to poke at models, prompts and endpoints without starting an adventure.
|
||||
Power users reach it, which means the `AIDND_POWER_USERS` email allowlist. It is
|
||||
deliberately thin. It adds no story context, no scripts, 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 testing models, prompts, and endpoints without
|
||||
starting an adventure.
|
||||
|
||||
Model choice is free-form when the user brought their own API key. On the
|
||||
shared demo key it stays pinned to the AIDND_DEMO_MODELS whitelist, exactly as
|
||||
turns are: the server funds that key, so it must not be able to reach paid
|
||||
models by way of this page.
|
||||
Model choice is free when the user brought their own API key. On the shared demo
|
||||
key the model stays pinned to the `AIDND_DEMO_MODELS` allowlist, exactly as it is
|
||||
for turns. The server funds that key, so this page must not let it reach paid
|
||||
models.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -41,10 +42,13 @@ PowerUser = Depends(power_user)
|
||||
def _resolve_model(
|
||||
settings: models.Settings, requested: str | None
|
||||
) -> tuple[auth.ProviderConfig, str | None]:
|
||||
"""Provider config for this chat, plus a note when the requested model was
|
||||
not honoured. The pinning rule itself lives in resolve_provider_config —
|
||||
this only reports the substitution it made, so there is exactly one place
|
||||
that decides what the demo key is allowed to talk to."""
|
||||
"""Returns the provider config for this chat, plus a note when the requested
|
||||
model was not used.
|
||||
|
||||
The pinning rule lives in `resolve_provider_config`. This function only
|
||||
reports the substitution that call made, so one place decides what the demo
|
||||
key may talk to.
|
||||
"""
|
||||
cfg = auth.resolve_provider_config(settings, model_override=requested)
|
||||
wanted = (requested or "").strip()
|
||||
if wanted and wanted != cfg.model:
|
||||
@@ -60,9 +64,12 @@ async def chat_config(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = PowerUser,
|
||||
):
|
||||
"""What this page can talk to: the resolved endpoint/model, whether model
|
||||
choice is pinned to the demo whitelist, and the endpoint's model listing
|
||||
(best effort — an unreachable endpoint just yields an empty list)."""
|
||||
"""Returns what this page can talk to.
|
||||
|
||||
The response holds the resolved endpoint and model, whether model choice is
|
||||
pinned to the demo allowlist, and the endpoint's model listing. The listing
|
||||
is best effort, and an unreachable endpoint returns an empty list.
|
||||
"""
|
||||
settings = get_settings(db, user)
|
||||
cfg = auth.resolve_provider_config(settings)
|
||||
listing = await list_endpoint_models(cfg)
|
||||
@@ -82,8 +89,11 @@ async def chat_config(
|
||||
|
||||
async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload: schemas.ChatRequest,
|
||||
note: str | None, db: Session, user: models.User):
|
||||
"""SSE generator mirroring the turn stream's event shape: reasoning/chunk
|
||||
while generating, then done — so the frontend reuses the same plumbing."""
|
||||
"""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.
|
||||
"""
|
||||
if note:
|
||||
yield sse({"type": "note", "detail": note})
|
||||
provider = OpenAICompatibleProvider(
|
||||
|
||||
@@ -9,9 +9,11 @@ router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||
def recent_requests():
|
||||
"""Most-recent-first log of provider requests/responses (no API keys).
|
||||
|
||||
The log is a single process-wide ring buffer with no per-user
|
||||
attribution, so in multi-user (hosted) mode it would leak other players'
|
||||
prompts — disabled there, available on local installs."""
|
||||
The log is a single process-wide ring buffer with no per-user attribution,
|
||||
so in multi-user mode, which is how a hosted deployment runs, it would expose
|
||||
other players' prompts. It is disabled there and available on a local
|
||||
install.
|
||||
"""
|
||||
if auth.MULTI_USER:
|
||||
raise HTTPException(403, "The debug log is only available on local installs.")
|
||||
return debuglog.recent()
|
||||
|
||||
@@ -54,9 +54,10 @@ def get_scenario(
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
scenario = get_scenario_or_404(scenario_id, db, user)
|
||||
# Funnel step. Only for shared scenarios: opening one is the first sign a
|
||||
# visitor is interested, whereas someone editing their own is already past
|
||||
# this point — and their titles are theirs, not a statistic.
|
||||
# A funnel step, recorded for shared scenarios only. Opening one is the
|
||||
# first sign that a visitor is interested, and someone editing their own
|
||||
# scenario is already past this point. Their titles are theirs rather than a
|
||||
# statistic.
|
||||
if scenario.is_public:
|
||||
analytics.record_event(analytics.EV_SCENARIO_OPEN, user)
|
||||
return scenario
|
||||
@@ -204,8 +205,9 @@ def import_scenario(
|
||||
if isinstance(schema, dict):
|
||||
fields["stat_schema"] = schema
|
||||
|
||||
# AI Dungeon bundles carry an `image` too, so this is worth honouring — but
|
||||
# it's untrusted input, hence sanitize() rather than a straight assignment.
|
||||
# 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
|
||||
@@ -216,10 +218,10 @@ def import_scenario(
|
||||
scenario = models.Scenario(**fields, user_id=user.id)
|
||||
if not scenario.title:
|
||||
scenario.title = "Imported Scenario"
|
||||
# Raw-dict import bypasses the schemas — clamp to VARCHAR widths
|
||||
# (Postgres enforces them; see schemas.py). Column defaults haven't been
|
||||
# applied yet at this point (that happens at flush), so a bundle with no
|
||||
# `tags` key leaves the attribute None — hence the `or ""`.
|
||||
# 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)
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_script(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Dry-run one hook against sample text — no AI call, no persistence."""
|
||||
"""Runs one hook against sample text, making no AI call and storing nothing."""
|
||||
script = get_script_or_404(script_id, db, user)
|
||||
limits.rate_limit("script-test", request, user)
|
||||
result = run_hook(
|
||||
@@ -145,7 +145,8 @@ def import_script(
|
||||
|
||||
script = models.Script(
|
||||
user_id=user.id,
|
||||
# Raw-dict import bypasses the schemas — clamp to the VARCHAR width.
|
||||
# A raw-dict import bypasses the schemas, so truncate to the VARCHAR
|
||||
# width.
|
||||
name=(pick("name") or "Imported Script")[:schemas.NAME_MAX],
|
||||
description=pick("description"),
|
||||
library_js=pick("library", "library_js", "sharedLibrary"),
|
||||
|
||||
@@ -10,8 +10,11 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_settings(db: Session, user: models.User) -> models.Settings:
|
||||
"""Per-user settings row, created on first access (Phase 8: settings —
|
||||
endpoint, key, models, memory config — are per user, not global)."""
|
||||
"""Returns the user's settings row, creating it on first access.
|
||||
|
||||
Phase 8 made settings per user rather than global. They cover the endpoint,
|
||||
the key, the models, and the memory configuration.
|
||||
"""
|
||||
settings = (
|
||||
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
|
||||
)
|
||||
@@ -50,14 +53,16 @@ def update_settings(
|
||||
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.
|
||||
# (This user's adventures only — settings are per-user now.)
|
||||
# This covers only this user's adventures, because settings are per
|
||||
# user now.
|
||||
#
|
||||
# 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 looks for embedded IS FALSE — never picked the rows up. The
|
||||
# bank went on ranking against the previous model's vectors forever.
|
||||
# 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)
|
||||
@@ -70,15 +75,18 @@ def update_settings(
|
||||
# `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 self-corrects.
|
||||
# holds: anything that removes a memory from play corrects itself.
|
||||
db.commit()
|
||||
return settings
|
||||
|
||||
|
||||
async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
|
||||
"""GET the endpoint's /models listing. Doubles as a connectivity check, so
|
||||
failures come back as {"ok": False, "detail": ...} rather than raising."""
|
||||
# SSRF guard: never probe a non-public address the user pointed us at.
|
||||
"""Fetches the endpoint's /models listing.
|
||||
|
||||
The call also serves as a connectivity check, so a failure returns
|
||||
`{"ok": False, "detail": ...}` rather than raising.
|
||||
"""
|
||||
# SSRF guard. Never probe a non-public address the user supplied.
|
||||
reason = await run_in_threadpool(netguard.endpoint_block_reason, cfg.endpoint_url)
|
||||
if reason:
|
||||
return {"ok": False, "detail": f"Can't reach that endpoint — {reason}."}
|
||||
@@ -100,7 +108,8 @@ async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
|
||||
data = resp.json()
|
||||
models_available = [m.get("id", "?") for m in data.get("data", [])]
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
pass # non-JSON or unexpected shape — connectivity is still confirmed
|
||||
pass # The body is not JSON or has an unexpected shape. The endpoint
|
||||
# is still reachable.
|
||||
return {"ok": True, "models": models_available}
|
||||
|
||||
|
||||
@@ -110,8 +119,11 @@ async def test_connection(
|
||||
db: Session = Depends(get_db),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
):
|
||||
"""Cheap connectivity check against whatever the turn engine would actually
|
||||
use — including the shared demo endpoint when the user has no key."""
|
||||
"""Runs a cheap connectivity check against whatever the turn engine would use.
|
||||
|
||||
That includes the shared demo endpoint, when the user has no key of their
|
||||
own.
|
||||
"""
|
||||
limits.rate_limit("connection-test", request, user)
|
||||
settings = get_settings(db, user)
|
||||
return await list_endpoint_models(auth.resolve_provider_config(settings))
|
||||
|
||||
@@ -6,15 +6,19 @@ from ..database import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/story-cards", tags=["story-cards"])
|
||||
|
||||
# AI Dungeon world-info / story-card array format. Its field names differ from
|
||||
# our columns: value<->entry, title<->name, description<->notes. The extra
|
||||
# `useForCharacterCreation` flag has no equivalent here — ignored on import,
|
||||
# emitted as false on export so round-tripping through AI Dungeon stays valid.
|
||||
# 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):
|
||||
"""Resolve the scenario/adventure a caller may *read* cards from (public
|
||||
demo scenarios included), or 404/422."""
|
||||
"""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:
|
||||
|
||||
+110
-97
@@ -5,24 +5,25 @@ from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from . import images
|
||||
|
||||
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
|
||||
# limits: Postgres enforces column lengths (SQLite never did), so anything
|
||||
# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are
|
||||
# generous abuse ceilings a legitimate player won't hit.
|
||||
NAME_MAX = 200 # titles/names — VARCHAR(200)
|
||||
TAGS_MAX = 500 # VARCHAR(500)
|
||||
CARD_TYPE_MAX = 100 # VARCHAR(100)
|
||||
PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
|
||||
SCRIPT_MAX = 200_000 # one JS source
|
||||
ACTION_MAX = 20_000 # one player action
|
||||
# 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.
|
||||
SCRIPT_MAX = 200_000 # One JavaScript source file.
|
||||
ACTION_MAX = 20_000 # One player action.
|
||||
MEMORY_TEXT_MAX = 5_000
|
||||
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
|
||||
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
|
||||
# a client that downscales less aggressively without letting anyone park a
|
||||
# multi-megabyte PNG in a row that gets read on every list request.
|
||||
# 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/glyph — VARCHAR(16)
|
||||
BRANCH_NAME_MAX = 80 # what a player called one line of the story — VARCHAR(80)
|
||||
ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
|
||||
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
|
||||
|
||||
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
||||
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
||||
@@ -77,12 +78,12 @@ class ScenarioBase(BaseModel):
|
||||
authors_note: Prose = ""
|
||||
ai_instructions: Prose = ""
|
||||
tags: Tags = ""
|
||||
# Cover art — an https URL or a base64 data URI. See app/images.py.
|
||||
# Cover art, either an https URL or a base64 data URI. See `app/images.py`.
|
||||
image: Image = ""
|
||||
# Emoji/glyph shown when `image` is empty.
|
||||
# The emoji or glyph shown when `image` is empty.
|
||||
icon: Icon = ""
|
||||
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
|
||||
# None means no RPG layer.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -106,7 +107,7 @@ class ScenarioUpdate(BaseModel):
|
||||
|
||||
class ScenarioOut(ORMModel, ScenarioBase):
|
||||
id: int
|
||||
is_public: bool = False # shared demo content — read-only for everyone
|
||||
is_public: bool = False # Shared demo content, read-only for everyone.
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
story_cards: list[StoryCardOut] = []
|
||||
@@ -120,8 +121,9 @@ class ScenarioListItem(ORMModel):
|
||||
tags: str
|
||||
is_public: bool = False
|
||||
updated_at: datetime
|
||||
# Read off the row so `image_url` can be derived, but excluded from the
|
||||
# response: a list of base64 data URIs would be megabytes of JSON.
|
||||
# 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 = ""
|
||||
|
||||
@@ -136,7 +138,8 @@ class ScenarioListItem(ORMModel):
|
||||
class AdventureCreate(BaseModel):
|
||||
scenario_id: int | None = None
|
||||
title: Name | None = None
|
||||
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
|
||||
# The `${Placeholder}` values collected from the player at the start, which
|
||||
# is the AI Dungeon behavior.
|
||||
placeholders: dict[str, str] = {}
|
||||
|
||||
|
||||
@@ -151,27 +154,32 @@ class AdventureUpdate(BaseModel):
|
||||
|
||||
|
||||
class AdventureRefresh(BaseModel):
|
||||
"""Body for "Update from scenario". `placeholders` supplies answers the
|
||||
adventure has no stored value for (see AdventureCreate.placeholders); they
|
||||
are merged over the stored ones and saved."""
|
||||
"""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 — drives the confirm dialog."""
|
||||
"""What a refresh would change. The confirm dialog is built from this."""
|
||||
|
||||
scenario_id: int
|
||||
scenario_title: str
|
||||
has_changes: bool
|
||||
# field name -> {"old": ..., "new": ...}, only for fields that differ.
|
||||
# Maps a field name to `{"old": ..., "new": ...}`, for differing fields
|
||||
# only.
|
||||
fields: dict[str, dict] = {}
|
||||
# {"added"|"updated"|"removed": [card name, ...]}
|
||||
# Maps "added", "updated", or "removed" to a list of card names.
|
||||
cards: dict[str, list[str]] = {}
|
||||
# {"added"|"removed": [stat path, ...]} — live values are otherwise kept.
|
||||
# Maps "added" or "removed" to a list of stat paths. Live values are
|
||||
# otherwise kept.
|
||||
world_state: dict[str, list[str]] = {}
|
||||
# ${Placeholder} names the scenario asks for that the adventure has no
|
||||
# stored answer to; the client must collect these and send them back.
|
||||
# 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] = []
|
||||
|
||||
|
||||
@@ -182,42 +190,44 @@ class ActionOut(ORMModel):
|
||||
type: str
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# Phase 12: compact RPG state changes for this turn (from the model property).
|
||||
# Phase 12: the compact RPG state changes for this turn, read from the
|
||||
# model property.
|
||||
world_changes: list[dict] = []
|
||||
# Retry history: how many attempts exist for this turn (0 = never retried)
|
||||
# and which one is live. The attempts themselves come from
|
||||
# GET /actions/{id}/variants so this payload stays small.
|
||||
# Retry history: how many attempts exist for this turn, where 0 means the
|
||||
# turn was never retried, and which attempt is live. The attempts themselves
|
||||
# come from `GET /actions/{id}/variants`, so this payload stays small.
|
||||
variant_count: int = 0
|
||||
variant_index: int = 0
|
||||
# SP9: the pager. How many takes this turn has, and which one is on screen —
|
||||
# `2/4`. Keyed on the parent, so it counts the takes of *this* turn and not
|
||||
# every node that happens to share a depth, and so it keeps counting them
|
||||
# after one has been forked onto a branch of its own.
|
||||
# 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.
|
||||
#
|
||||
# 1/1 for a turn nobody has retaken, which is most of them; the client draws
|
||||
# no pager for a count of one. That is a different convention from
|
||||
# `variant_count`, which says 0 for the same case — those two are the
|
||||
# pre-SP9 pair and SP8 drops them.
|
||||
# A turn nobody has retaken reads 1/1, which is most turns, and the client
|
||||
# draws no pager for a count of one. `variant_count` uses a different
|
||||
# convention and reports 0 for the same case. Those two fields are the
|
||||
# pre-SP9 pair, and SP8 drops them.
|
||||
take_count: int = 1
|
||||
take_index: int = 0
|
||||
# Which line this node is on, so the pager can tell the two kinds of step
|
||||
# apart without asking the server first: a take on this branch is a leaf
|
||||
# with nothing under it, and showing it is a local matter; a take on another
|
||||
# branch has a story of its own, and going there is a branch switch.
|
||||
# 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 it: forking is addressed by the attempt being taken, not by
|
||||
# its ordinal in a group that renumbers whenever one is added.
|
||||
# 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 take is a local
|
||||
# step or a branch switch.
|
||||
# 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
|
||||
@@ -230,12 +240,11 @@ class VariantSelect(BaseModel):
|
||||
class BranchOut(ORMModel):
|
||||
"""One line through the story tree (Phase 14, SP5).
|
||||
|
||||
Enough to draw the tree and nothing more: `fork_depth` is where this line
|
||||
leaves its parent and `depth` is where it currently ends, so a fork is two
|
||||
numbers rather than a walk. `own_actions` counts the turns played on this
|
||||
branch itself — the rest of its story is borrowed from its ancestors, which
|
||||
is the whole point and also why the number is smaller than the reader
|
||||
expects.
|
||||
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
|
||||
@@ -244,14 +253,14 @@ class BranchOut(ORMModel):
|
||||
depth: int
|
||||
own_actions: int = 0
|
||||
is_head: bool = False
|
||||
# NULL for a branch nobody has named. The client draws those from the fork
|
||||
# depth rather than the server inventing one — see the column comment.
|
||||
# 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 go back to being unnamed."""
|
||||
"""A name a player chose, or `null` to make the branch unnamed again."""
|
||||
|
||||
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
||||
|
||||
@@ -263,23 +272,23 @@ class ActionUpdate(BaseModel):
|
||||
class ActionCreate(BaseModel):
|
||||
type: Literal["do", "say", "story", "continue"]
|
||||
text: ActionText = ""
|
||||
# The node this action is played after (SP9). Omitted means "the tip",
|
||||
# which is every ordinary turn.
|
||||
# The node this action is played after (SP9). Omitting it means the tip,
|
||||
# which is what every ordinary turn uses.
|
||||
#
|
||||
# Naming a take the story moved past is how a branch gets made: stepping
|
||||
# between takes costs nothing and creates nothing, and the fork happens on
|
||||
# the first thing written below one. That is the only moment the player has
|
||||
# said which line they mean — before it, they were reading.
|
||||
# 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 take of a turn (SP9).
|
||||
"""Another attempt at a turn (SP9).
|
||||
|
||||
`text` is what the player is saying instead, and is theirs to write only
|
||||
when the turn was theirs. An AI turn's other take is generated, so the field
|
||||
is ignored there rather than refused — the client asks the same way for both
|
||||
and the node type decides what happens.
|
||||
`text` 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 = ""
|
||||
@@ -298,9 +307,9 @@ class AdventureOut(ORMModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
story_cards: list[StoryCardOut] = []
|
||||
# The NEWEST window of the story, not all of it — older pages arrive from
|
||||
# GET /{id}/actions as the reader scrolls up. `action_count` is the whole
|
||||
# story's length, which is how the client knows there is more above.
|
||||
# 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
|
||||
|
||||
@@ -310,7 +319,7 @@ class ActionPage(BaseModel):
|
||||
|
||||
actions: list[ActionOut] = []
|
||||
total: int = 0
|
||||
# Whether anything older than this slice exists. Computed server-side so
|
||||
# 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
|
||||
|
||||
@@ -348,10 +357,10 @@ class AdventureListItem(ORMModel):
|
||||
title: str
|
||||
updated_at: datetime
|
||||
action_count: int = 0
|
||||
# "Where you left off" — the tail of the most recent narrative beat, so a
|
||||
# Continue card can show the story instead of just a turn count.
|
||||
# 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).
|
||||
# Cover art inherited from the parent scenario. See `app/images.py`.
|
||||
image_url: str = ""
|
||||
icon: str = ""
|
||||
|
||||
@@ -403,8 +412,9 @@ class AdventureScriptOut(ORMModel):
|
||||
input_js: str
|
||||
context_js: str
|
||||
output_js: str
|
||||
# Set by the router (not stored): True when a syncable library version
|
||||
# exists whose code differs from this copy; None when nothing to sync.
|
||||
# The router sets this field, which is not stored. It is `True` when a
|
||||
# syncable library version exists whose code differs from this copy, and
|
||||
# `None` when there is nothing to sync from.
|
||||
out_of_date: bool | None = None
|
||||
|
||||
|
||||
@@ -419,9 +429,9 @@ class AdventureScriptUpdate(BaseModel):
|
||||
# ---------- Auth (Phase 8) ----------
|
||||
|
||||
class AuthCredentials(BaseModel):
|
||||
email: Annotated[str, Field(max_length=320)] # VARCHAR(320)
|
||||
# Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU
|
||||
# an attacker would otherwise get for free.
|
||||
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)]
|
||||
|
||||
|
||||
@@ -429,7 +439,8 @@ class AuthCredentials(BaseModel):
|
||||
|
||||
class SettingsOut(ORMModel):
|
||||
endpoint_url: str
|
||||
# The key itself is never echoed back (encrypted at rest, write-only).
|
||||
# The key itself is never returned. It is encrypted at rest and
|
||||
# write-only.
|
||||
has_api_key: bool
|
||||
model: str
|
||||
api_mode: str
|
||||
@@ -449,12 +460,12 @@ ScenarioOut.model_rebuild()
|
||||
|
||||
|
||||
# ---------- AI Chat (power users) ----------
|
||||
# A scratchpad for talking to a model directly, with no story framing. Nothing
|
||||
# is persisted server-side, so these caps are purely per-request abuse limits.
|
||||
# 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 # whole conversation sent up per request
|
||||
CHAT_MESSAGES_MAX = 200 # turns per request
|
||||
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):
|
||||
@@ -464,22 +475,24 @@ class ChatMessage(BaseModel):
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
messages: Annotated[list[ChatMessage], Field(min_length=1, max_length=CHAT_MESSAGES_MAX)]
|
||||
# Empty/omitted = fall back to the user's configured model.
|
||||
# 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)
|
||||
# Encryption expands the stored value ~4/3 into the same VARCHAR(500):
|
||||
# 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64).
|
||||
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500).
|
||||
# Encryption expands the stored value by about four thirds into the same
|
||||
# VARCHAR(500), so 256 plaintext characters is the largest safe input. The
|
||||
# stored form is "enc:" plus Fernet plus base64.
|
||||
api_key: Annotated[str, Field(max_length=256)] | None = None
|
||||
model: Name | None = None
|
||||
api_mode: Annotated[str, Field(max_length=20)] | None = None
|
||||
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
||||
max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
||||
# -1 = explicitly off (sends `reasoning: {effort: none}`); 0 = send nothing.
|
||||
# A value of -1 turns reasoning off explicitly, which sends
|
||||
# `reasoning: {effort: none}`. A value of 0 sends nothing.
|
||||
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
|
||||
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
|
||||
narrator_prompt: Prose | None = None
|
||||
|
||||
@@ -94,8 +94,9 @@ def run_hook(
|
||||
story_cards: list[dict],
|
||||
info: dict,
|
||||
) -> HookResult:
|
||||
"""Run one modifier hook. Never raises: failures come back as .error with
|
||||
text/state/cards unchanged, so a bad script can't break a turn."""
|
||||
"""Run one modifier hook. This function never raises. Failures return as
|
||||
`.error` with text, state, and cards unchanged, so a bad script cannot
|
||||
break a turn."""
|
||||
unchanged = HookResult(text=text, state=state, story_cards=story_cards)
|
||||
source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js
|
||||
if not source.strip():
|
||||
|
||||
@@ -24,19 +24,20 @@ class ScriptPipeline:
|
||||
return msg if isinstance(msg, str) and msg.strip() else None
|
||||
|
||||
def _history(self) -> list[dict]:
|
||||
# The path, not `adventure.actions` — that collection is every branch's
|
||||
# actions, and this is the documented history API a user script reads.
|
||||
# Handing a script the siblings of the turn it is running on would be
|
||||
# the same bug as building a prompt from them, only user-visible.
|
||||
# Read the path rather than `adventure.actions`. That collection holds
|
||||
# every branch's actions, and this is the documented history API a user
|
||||
# script reads. Giving a script the siblings of the turn it is running on
|
||||
# would be the same bug as building a prompt from them, and visible to
|
||||
# the user.
|
||||
#
|
||||
# `story_actions` also drops blank-text rows, which `adventure.actions`
|
||||
# kept, so this array is shorter than it used to be for an adventure
|
||||
# that has any — and `info.actionCount` counts the same way. That is
|
||||
# deliberate: a row with no text is this app's bookkeeping, it has no
|
||||
# counterpart in the AI Dungeon history a ported script was written
|
||||
# against, and the prompt has never included one. A script keyed on
|
||||
# "every N actions" will land on different turns than it did before
|
||||
# phase 14; there is no reading of this that is compatible with both.
|
||||
# `story_actions` also drops rows with blank text, which
|
||||
# `adventure.actions` kept, so this array is shorter than it was for an
|
||||
# adventure that has any such rows. `info.actionCount` counts the same
|
||||
# way. That is intended. A row with no text is this app's bookkeeping, it
|
||||
# has no counterpart in the AI Dungeon history a ported script was
|
||||
# written against, and the prompt has never included one. A script keyed
|
||||
# on every N actions lands on different turns than it did before phase
|
||||
# 14, and no reading of this is compatible with both.
|
||||
return [
|
||||
{"text": a.text, "rawText": a.text, "type": a.type}
|
||||
for a in context_history.story_actions(self.adventure)
|
||||
|
||||
+15
-13
@@ -1,18 +1,19 @@
|
||||
"""Phase 8 — secrets and crypto primitives for optional accounts.
|
||||
"""Phase 8: secrets and crypto primitives for optional accounts.
|
||||
|
||||
Everything keys off one server-side secret:
|
||||
- session cookies are HMAC-signed with it,
|
||||
- stored LLM API keys are Fernet-encrypted with a key derived from it.
|
||||
Everything derives from one server-side secret:
|
||||
|
||||
The secret comes from AIDND_SECRET_KEY, or is auto-generated once into
|
||||
`secret.key` next to the database so local installs and Docker volumes work
|
||||
with zero configuration (losing the file logs everyone out and orphans
|
||||
stored API keys — users just re-enter them). Multi-user deploys must set the
|
||||
env var: hosted filesystems are ephemeral, and a secret.key regenerated on
|
||||
every deploy would silently log out all users each time.
|
||||
* Session cookies are HMAC-signed with it.
|
||||
* Stored LLM API keys are Fernet-encrypted with a key derived from it.
|
||||
|
||||
Passwords use hashlib.scrypt (stdlib, OpenSSL-backed) so we don't need a
|
||||
separate hashing dependency.
|
||||
The secret comes from `AIDND_SECRET_KEY`, or it is generated once into
|
||||
`secret.key` next to the database, so a local install and a Docker volume work
|
||||
with no configuration. Losing that file logs everyone out and makes the stored
|
||||
API keys unreadable, and users then re-enter them. A multi-user deployment has
|
||||
to set the environment variable, because a hosted filesystem is ephemeral and a
|
||||
`secret.key` regenerated on every deploy would log out every user each time.
|
||||
|
||||
Passwords use `hashlib.scrypt`, which is in the standard library and backed by
|
||||
OpenSSL, so this needs no separate hashing dependency.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -81,7 +82,8 @@ def verify_password(password: str, stored: str) -> bool:
|
||||
|
||||
|
||||
# ---------- Session tokens ----------
|
||||
# "v1.<user_id>.<hmac>" — no expiry (long-lived guest sessions are the point).
|
||||
# The token is "v1.<user_id>.<hmac>". It does not expire, because a long-lived
|
||||
# guest session is what this is for.
|
||||
|
||||
def sign_session(user_id: int) -> str:
|
||||
payload = f"v1.{user_id}"
|
||||
|
||||
+8
-7
@@ -8,11 +8,11 @@ adventure copies the scenario's story cards and scripts into the adventure, so
|
||||
the seeded scripts run for guests too.
|
||||
|
||||
Seed files are the source of truth for demo content: a scenario is inserted if
|
||||
missing, and reconciled in place when a seed file's content changes (so edits
|
||||
ship on the next deploy). When a seed already matches, nothing is written, so
|
||||
this stays cheap to run on every boot. Existing adventures already started from
|
||||
a demo keep their own copied cards/scripts and are unaffected — only new
|
||||
adventures pick up the updated content.
|
||||
missing, and reconciled in place when a seed file's content changes, so an edit
|
||||
ships on the next deploy. 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 scripts and is unchanged. Only a new adventure
|
||||
picks up the updated content.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -122,8 +122,9 @@ def _insert_scenario(db, data: dict) -> None:
|
||||
|
||||
def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
|
||||
_apply_scalars(scenario, data)
|
||||
# Replace child content wholesale — demo content is server-owned and cheap
|
||||
# to rebuild, and this keeps the scenario row (and adventure FKs) intact.
|
||||
# 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)
|
||||
for script in list(scenario.scripts):
|
||||
|
||||
+144
-142
@@ -1,25 +1,24 @@
|
||||
"""Phase 14 — putting nodes on the story tree.
|
||||
"""Phase 14: writes nodes onto the story tree.
|
||||
|
||||
The write half of the tree. Which branch a new node hangs off, what depth it
|
||||
gets, and where an adventure's head points all live here, because every one of
|
||||
them is the kind of thing that is silently wrong when it is spread across four
|
||||
call sites: a node written without a branch is a node no read can see, and it
|
||||
fails by disappearing rather than by raising.
|
||||
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 — the lineage clause that turns a branch into "this story" —
|
||||
lives beside it in `context/lineage.py`.
|
||||
The read half is `context/lineage.py`. It turns a branch into the set of nodes
|
||||
that make up one story.
|
||||
|
||||
Until forking ships there is exactly one branch per adventure and `depth` is
|
||||
the number `index` already held, so everything in this module is bookkeeping
|
||||
that changes nothing observable. That is the point: by the time a read depends
|
||||
on these columns, every row has them — including the rows written between the
|
||||
two deploys, which no migration will ever visit.
|
||||
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 the session calls on every flush. Wiring the
|
||||
call sites was enough while nothing read the columns; now that reads select on
|
||||
them, "every writer remembers" is a promise that has to hold for every fixture,
|
||||
script and test ever written too, and its breach is a story quietly missing
|
||||
turns. So the invariant is enforced at the flush instead of asked for.
|
||||
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
|
||||
@@ -30,18 +29,19 @@ from sqlalchemy.orm import Session
|
||||
from . import models
|
||||
from .context import lineage
|
||||
|
||||
# The head depth of an adventure with no actions. Keeps "the next node goes at
|
||||
# head_depth + 1" true with no special case, and mirrors migrations.NO_DEPTH.
|
||||
# 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:
|
||||
"""The adventure's root branch, created on first use.
|
||||
"""Returns the adventure's root branch, creating it on first use.
|
||||
|
||||
Get-or-create rather than created-with-the-adventure, because the adventures
|
||||
that need one most are the ones that already exist: a bundle being imported,
|
||||
a fixture built straight through the ORM, or a database whose migration ran
|
||||
before this code shipped.
|
||||
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)
|
||||
@@ -54,11 +54,10 @@ def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
)
|
||||
if branch is not None:
|
||||
return branch
|
||||
# Inserted through Core rather than through the unit of work, because this
|
||||
# also runs from `place_new_nodes` inside a flush, and a nested ORM flush
|
||||
# inside a flush raises. Same transaction either way, so it rolls back with
|
||||
# everything else. The lineage names the branch's own id, so it takes a
|
||||
# second statement — once per adventure, ever.
|
||||
# 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,
|
||||
@@ -77,52 +76,50 @@ def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
|
||||
|
||||
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
"""The branch new nodes are played onto."""
|
||||
"""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
|
||||
# A head naming a branch that is gone is a bug somewhere else. Recover
|
||||
# onto the root instead of refusing to play — the alternative is an
|
||||
# adventure nobody can add to.
|
||||
# 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:
|
||||
"""Take the story down `node`, on a branch of its own.
|
||||
"""Moves `node` onto a new branch so the story can continue from it.
|
||||
|
||||
`node` is a discarded attempt at a turn the story has already moved past.
|
||||
Making it live where it stands would orphan every turn played after it —
|
||||
they were written as a continuation of the attempt that won — so it moves
|
||||
onto a new branch instead, forked from the depth just before it. The parent
|
||||
keeps its story, complete and untouched; the new branch borrows everything
|
||||
up to the fork and owns exactly one node.
|
||||
`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.
|
||||
|
||||
**One row is inserted and one row is moved. Nothing is copied.** That is
|
||||
the whole claim of the design: a fork costs a `branches` row and the
|
||||
ancestry cached on it, whatever the story behind it is worth.
|
||||
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.
|
||||
|
||||
Nothing derived moves with it, and that is not an omission. A memory hangs
|
||||
off the coordinate its block ends on, and what it describes is whatever
|
||||
attempt was live there — which stays on the parent. From the new branch it
|
||||
is simply out of range: the lineage caps the parent at `fork_depth`, so the
|
||||
memory sits one depth past the border and neither the retrieval clause nor
|
||||
the cursors can see it. The block is summarized again, from the text this
|
||||
branch actually tells, without a line of bookkeeping.
|
||||
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.
|
||||
|
||||
One thing does stay behind: the attempts this node leaves. They are still
|
||||
takes on the parent's turn, and one of them has to be the parent's story —
|
||||
the oldest, so the line the parent keeps is the one it was written on.
|
||||
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
|
||||
# The attempts this node is leaving, read *before* it moves. The session
|
||||
# does not autoflush, so asking afterwards would still find the node here
|
||||
# and renumber it back into the group it just left.
|
||||
# Read the sibling attempts before moving the node. The session does not
|
||||
# autoflush, so a later read still finds the node here and renumbers it back
|
||||
# into the group it just left.
|
||||
remaining = [
|
||||
row for row in db.query(models.Action)
|
||||
.filter(
|
||||
@@ -134,17 +131,17 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
|
||||
.all()
|
||||
if row is not node
|
||||
]
|
||||
# The parent's ancestry, every entry capped at the fork. Only the first can
|
||||
# actually move — an older entry is already capped at the fork depth of the
|
||||
# branch beneath it, which is shallower than any node on the parent — but
|
||||
# capping them all says the invariant instead of relying on it.
|
||||
# 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)
|
||||
]
|
||||
# Inserted through Core, and its lineage written second, for the reason
|
||||
# `root_branch` spells out: this can run inside a flush, and the lineage
|
||||
# names the row's own id.
|
||||
# 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,
|
||||
@@ -180,26 +177,29 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
|
||||
def branch_at(
|
||||
db: Session, adventure: models.Adventure, fork_depth: int
|
||||
) -> models.Branch:
|
||||
"""An empty branch leaving the path being read at `fork_depth`.
|
||||
"""Creates an empty branch that leaves the current path at `fork_depth`.
|
||||
|
||||
`fork` moves a node that already exists onto a line of its own. This is the
|
||||
same branch with nothing in it yet, for the case where the take that will
|
||||
live there has not been written: the player asking for another take of a
|
||||
turn the story has moved past (SP9). The head lands at `fork_depth`, so the
|
||||
next node written is the new take, at the same depth as the one it is a take
|
||||
of, with the same parent — `place_action` resolves that from the path, and
|
||||
the path now ends at exactly the node the original hangs off.
|
||||
`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 line being left is not touched at all. It keeps its node at that depth,
|
||||
it keeps that node live, and it keeps everything played after it.
|
||||
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 `root_branch`
|
||||
# spells out: this can run inside a flush, and the lineage names its own id.
|
||||
# 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,
|
||||
@@ -226,20 +226,20 @@ def place_action(
|
||||
branch: models.Branch | None = None,
|
||||
parent: models.Action | None = None,
|
||||
) -> models.Branch:
|
||||
"""Put `action` on the head branch and move the head to it.
|
||||
"""Puts `action` on the head branch and moves the head to it.
|
||||
|
||||
`depth` follows `index` while the two coexist. They have to agree: a read
|
||||
ordering by depth and a cursor counting in index space are describing the
|
||||
same story, and SP2 swaps one for the other under everything at once.
|
||||
`depth` follows `index` while both columns exist. The two must agree,
|
||||
because a read that orders by depth and a cursor that counts in index space
|
||||
describe the same story, and SP2 swaps one for the other in a single step.
|
||||
|
||||
`branch` is the head, already resolved, for a caller placing several nodes
|
||||
at once — see `place_new_nodes` for why that is worth a parameter.
|
||||
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 this node was played after (SP9), and is what groups a
|
||||
turn's takes. Resolved from the path when the caller does not say, which is
|
||||
the honest default: a node written now follows whatever the player is
|
||||
reading now. A caller placing several nodes in one flush should chain it —
|
||||
the second node's parent is the first, and the database has not seen either.
|
||||
`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
|
||||
@@ -258,12 +258,12 @@ def place_action(
|
||||
def _preceding_id(
|
||||
db: Session, adventure: models.Adventure, branch: models.Branch, depth: int
|
||||
) -> int | None:
|
||||
"""The id of the live node one step back along `branch`'s path.
|
||||
"""Returns the id of the live node one step back along `branch`'s path.
|
||||
|
||||
Asked of the whole lineage rather than of `branch` alone, because a branch
|
||||
borrows the story before its fork point: the node in front of a forked
|
||||
branch's first turn lives on an ancestor, and that is exactly the parent a
|
||||
pager needs to find its siblings through.
|
||||
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 (
|
||||
@@ -286,19 +286,19 @@ def place_memory(
|
||||
memory: models.Memory,
|
||||
branch: models.Branch | None = None,
|
||||
) -> models.Branch:
|
||||
"""Attach a memory to the node it belongs to.
|
||||
"""Attaches a memory to the node it belongs to.
|
||||
|
||||
`source_end` is the index of the last action the memory summarises, which is
|
||||
that node's depth. A hand-written memory summarises nothing, so it takes the
|
||||
head instead: **the story you were reading when you wrote it.**
|
||||
`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.
|
||||
|
||||
That anchor is what makes a memory mean one thing (SP7). Before it, a
|
||||
hand-written memory kept a NULL depth and "belonged to the adventure rather
|
||||
than to a path" — which sounded harmless and meant it followed you onto
|
||||
branches whose story it did not describe, because a NULL cannot be capped at
|
||||
a fork. Every memory now sits at a coordinate, so "is this part of the story
|
||||
I am reading?" has one answer for every row in the bank, and it is the same
|
||||
answer the lineage already gives for nodes.
|
||||
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
|
||||
@@ -311,35 +311,37 @@ def place_memory(
|
||||
|
||||
|
||||
def attach_memory(memory: models.Memory, node: models.Action) -> None:
|
||||
"""Hang a memory off the node it was derived from.
|
||||
"""Attaches a memory to the node it was derived from.
|
||||
|
||||
The general rule, of which the memory bank is the first instance: anything
|
||||
derived from the story attaches to the node that produced it, and is then
|
||||
visible from exactly the paths that node is on. A fork inherits its
|
||||
ancestors' memories because it inherits their nodes — nothing is copied and
|
||||
nothing is recreated — and a memory made on a sibling is invisible here
|
||||
because that node is not on this path.
|
||||
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.
|
||||
|
||||
Not `place_memory`: this takes the branch from the *node*, which is not
|
||||
always the head. A block of story can end before the fork the current
|
||||
branch was made at, and the memory belongs where the ground is.
|
||||
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:
|
||||
"""Give a node the state it left behind, if its writer did not.
|
||||
"""Records the state a node left behind, if the writer did not record it.
|
||||
|
||||
The floor under `attempts.snapshot_outcome`, and it is here for the same
|
||||
reason `place_action` has one: from SP4 a node with no outcome is a node
|
||||
undo and retry cannot roll back past, and it fails by leaving the
|
||||
scoreboard where it was rather than by raising. The turn engine records the
|
||||
outcome itself and this skips those rows; what it catches is every fixture,
|
||||
script and import that writes a story straight through the ORM.
|
||||
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.
|
||||
|
||||
What it writes is the truth as of the flush: a writer that changes no state
|
||||
between two nodes leaves the same state behind both of them.
|
||||
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 {}
|
||||
@@ -350,25 +352,25 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
|
||||
|
||||
|
||||
def place_new_nodes(session: Session) -> None:
|
||||
"""Place every unplaced node about to be inserted. Runs on every flush.
|
||||
"""Places every unplaced node that is about to be inserted.
|
||||
|
||||
The call sites still call `place_action` / `place_memory` themselves, and
|
||||
should: a node placed at the call site is placed *before* the code around
|
||||
it reads the row back, and the explicit call is what makes the ordering
|
||||
visible. This is the floor under them — a fixture built straight through
|
||||
the ORM, a script, a test, or a call site added next year gets a branch
|
||||
without knowing the tree exists.
|
||||
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 left alone: there is no
|
||||
id to hang a branch off, and an Action needs `adventure_id` to be written
|
||||
at all, so the case does not arise from any writer we have.
|
||||
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 held in `heads` for
|
||||
the length of the call. That is not just saving a dictionary lookup: the
|
||||
identity map holds *weak* references, so a branch row nobody keeps a strong
|
||||
reference to is collected between two nodes and read back from the database
|
||||
for the next one. Resolving per node turned a fixture writing two hundred
|
||||
actions in one flush into two hundred SELECTs on `branches`.
|
||||
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):
|
||||
@@ -394,11 +396,11 @@ def place_new_nodes(session: Session) -> None:
|
||||
|
||||
|
||||
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
||||
"""Re-derive the head depth after nodes were removed (undo, delete).
|
||||
"""Recomputes the head depth after nodes are removed by undo or delete.
|
||||
|
||||
A branch with nothing on it sits at its fork point, because that is the last
|
||||
node its story contains — borrowed from the parent, but the tip all the
|
||||
same. A root branch with nothing on it has no story at all.
|
||||
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 = (
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Storing and comparing embedding vectors.
|
||||
|
||||
A 1536-dimension vector written as a JSON list is about 31 KB, because every
|
||||
component is spelled out as a decimal string of seventeen-odd digits. The same
|
||||
vector as packed float32 is 6,144 bytes — a straight 5x, and the memory bank is
|
||||
read in full on every turn, so those bytes are paid over and over.
|
||||
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, rendered into JSON as the shortest decimal string
|
||||
that round-trips through a double; converting that back to float32 recovers the
|
||||
original bits exactly. Nothing is lost that was ever there, which is why the
|
||||
conversion needs no re-embedding and carries no retrieval-quality risk.
|
||||
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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Phase 12 — RPG world state: the AI proposes stat/milestone deltas, this
|
||||
module validates and clamps them against a scenario's stat_schema."""
|
||||
"""Phase 12: RPG world state.
|
||||
|
||||
The AI proposes stat and milestone deltas, and this module validates and clamps
|
||||
them against a scenario's `stat_schema`.
|
||||
"""
|
||||
|
||||
from .engine import (
|
||||
EMIT_REMINDER,
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"""RPG world-state engine.
|
||||
|
||||
The scenario carries a `stat_schema` (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* (only what changed);
|
||||
`apply_delta` is the referee — it clamps to min/max, caps per-turn change,
|
||||
enforces cooldowns, and marks milestones sticky.
|
||||
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 ever raises on bad AI output: a malformed delta yields `{}` and the
|
||||
turn continues, exactly like a broken script never breaks a turn.
|
||||
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.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
|
||||
# stat_schema top-level sections that hold stat definitions.
|
||||
# The `stat_schema` top-level sections that hold stat definitions.
|
||||
STAT_SECTIONS = ("world", "player")
|
||||
|
||||
# Appended once to the system prompt so the model knows how to report changes.
|
||||
# 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 "
|
||||
@@ -45,8 +47,8 @@ EMIT_RULE = (
|
||||
'"player.outfit": "torn traveling cloak"}\n```'
|
||||
)
|
||||
|
||||
# Short terminal reminder placed at the very end of the prompt (strongest
|
||||
# recency position) so the emit rule is fresh right where the model generates.
|
||||
# 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.]"
|
||||
@@ -54,9 +56,12 @@ EMIT_REMINDER = (
|
||||
|
||||
|
||||
def render_delta_block(delta: dict) -> str:
|
||||
"""Render a stored delta back into the fenced `state` block the AI emitted,
|
||||
for re-injecting past turns into context so the model imitates the format.
|
||||
Empty delta -> empty string (the turn legitimately changed nothing)."""
|
||||
"""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```"
|
||||
@@ -68,7 +73,7 @@ _TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL)
|
||||
|
||||
|
||||
def has_schema(stat_schema: dict | None) -> bool:
|
||||
"""True when a scenario actually defines an RPG layer."""
|
||||
"""Returns `True` when a scenario defines an RPG layer."""
|
||||
if not isinstance(stat_schema, dict):
|
||||
return False
|
||||
return any(
|
||||
@@ -83,8 +88,11 @@ def npc_name(ndef: dict, key: str) -> str:
|
||||
|
||||
|
||||
def npc_triggers(ndef: dict, key: str) -> list[str]:
|
||||
"""Lower-cased trigger words for detecting an NPC in scene: its `keys`
|
||||
field, falling back to its display name."""
|
||||
"""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()]
|
||||
|
||||
@@ -98,7 +106,7 @@ def _initials(defs: dict) -> dict:
|
||||
|
||||
|
||||
def instantiate(stat_schema: dict | None) -> dict:
|
||||
"""Build a fresh live world_state from a schema (initial values only)."""
|
||||
"""Builds a fresh live `world_state` from a schema, using initial values only."""
|
||||
if not has_schema(stat_schema):
|
||||
return {}
|
||||
ws: dict = {}
|
||||
@@ -121,24 +129,26 @@ def instantiate(stat_schema: dict | None) -> dict:
|
||||
|
||||
|
||||
def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]:
|
||||
"""Bring a live world_state back in line with an edited schema.
|
||||
"""Brings a live `world_state` back in line with an edited schema.
|
||||
|
||||
Deliberately NOT `instantiate`: a value the schema still defines keeps
|
||||
whatever it has reached in play (re-instantiating would heal the player to
|
||||
full and wipe 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 dropped, along with their `last_changed`
|
||||
bookkeeping. Returns (new_state, report) where the report lists paths under
|
||||
`added` / `removed` so the UI can show what a refresh would do.
|
||||
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.
|
||||
|
||||
Note the additions are mostly cosmetic: rendering and delta-application both
|
||||
fall back to a stat def's `initial` when the live state has no value for it,
|
||||
so a newly added stat already behaves correctly. This materialises it (and,
|
||||
unlike those read-through paths, actually cleans up removals).
|
||||
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 entirely — so does the adventure.
|
||||
# The scenario dropped its RPG layer, so the adventure drops it too.
|
||||
stale = bool(world_state)
|
||||
if stale:
|
||||
report["removed"].append("(all world state)")
|
||||
@@ -199,9 +209,10 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
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 simply absent. Drop reached ones the scenario
|
||||
# no longer defines, or they'd sit in "Achieved" forever with no label.
|
||||
# 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}
|
||||
@@ -209,8 +220,8 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
if k not in milestone_defs:
|
||||
report["removed"].append(f"milestones.{k}")
|
||||
|
||||
# Cooldown bookkeeping for paths that no longer exist would never be read,
|
||||
# but it accumulates in every stored snapshot — prune it with the rest.
|
||||
# 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):
|
||||
@@ -226,10 +237,10 @@ def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict,
|
||||
|
||||
|
||||
def band_label(stat_def: dict, value) -> str | None:
|
||||
"""The word label for `value` from a stat def's bands, if any.
|
||||
"""Returns the word label for `value` from a stat definition's bands, if any.
|
||||
|
||||
Bands are [lo, hi, label]; matched as lo <= value < hi, with the top band
|
||||
inclusive of its upper bound so a maxed stat still gets a label.
|
||||
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)):
|
||||
@@ -265,12 +276,12 @@ def _tolerant_load(blob: str) -> dict:
|
||||
|
||||
|
||||
def extract_delta(text: str) -> tuple[str, dict]:
|
||||
"""Pull the trailing state block out of an AI response.
|
||||
"""Removes the trailing state block from an AI response.
|
||||
|
||||
Returns (clean_text, delta). `delta` is `{}` when there is no block or it
|
||||
can't be parsed; `clean_text` has the block removed. Only strips a bare
|
||||
trailing object when it actually parses to a delta, so ordinary prose
|
||||
ending in `}` is never eaten.
|
||||
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:
|
||||
@@ -293,7 +304,7 @@ def extract_delta(text: str) -> tuple[str, dict]:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _coerce_number(value):
|
||||
if isinstance(value, bool): # bool is an int subclass — reject here
|
||||
if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here.
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
@@ -349,9 +360,12 @@ def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
path: str, action_index: int, meta: dict, report: dict) -> None:
|
||||
"""Free-text stats replace rather than add: the AI sends the new value in
|
||||
full, not a delta. No clamping/bands apply — only an optional cooldown and
|
||||
an optional max_length truncation."""
|
||||
"""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"})
|
||||
return
|
||||
@@ -369,7 +383,7 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
old = container.get(key, stat_def.get("initial", ""))
|
||||
if new == old:
|
||||
return # no actual change — silent no-op
|
||||
return # Nothing changed, so do nothing.
|
||||
|
||||
container[key] = new
|
||||
meta["last_changed"][path] = action_index
|
||||
@@ -377,14 +391,17 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
|
||||
|
||||
|
||||
def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]:
|
||||
"""Directly set live values — a manual author/admin edit, not an AI turn.
|
||||
"""Sets live values directly, as a manual author edit rather than an AI turn.
|
||||
|
||||
Unlike `apply_delta`: numeric stats are SET rather than added to, and
|
||||
`cooldown`/`max_delta_per_turn`/the counter-can't-decrease rule are all
|
||||
ignored (a deliberate correction, not an AI move to police). Milestones
|
||||
can be toggled either way, not only marked reached. Values are still
|
||||
validated against the schema (unknown path/type is rejected) and numeric
|
||||
values still clamp to min/max."""
|
||||
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)
|
||||
@@ -492,8 +509,11 @@ def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tup
|
||||
|
||||
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index: int) -> tuple[dict, dict]:
|
||||
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
|
||||
`world_state`. Returns (new_world_state, report)."""
|
||||
"""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)
|
||||
@@ -512,7 +532,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
path = str(raw_path)
|
||||
parts = path.split(".")
|
||||
|
||||
# flags.<name> — free two-way boolean, either value accepted.
|
||||
# `flags.<name>` is a two-way boolean, and either value is accepted.
|
||||
if parts[0] == "flags" and len(parts) == 2:
|
||||
fid = parts[1]
|
||||
if fid not in flag_defs:
|
||||
@@ -528,7 +548,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
report["applied"].append({"path": path, "old": old, "new": change})
|
||||
continue
|
||||
|
||||
# milestones.<id> — sticky boolean, only `true` accepted.
|
||||
# `milestones.<id>` is a sticky boolean, and only `true` is accepted.
|
||||
if parts[0] == "milestones" and len(parts) == 2:
|
||||
mid = parts[1]
|
||||
if mid not in milestones:
|
||||
@@ -539,7 +559,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
continue
|
||||
reached = ws.setdefault("milestones", {})
|
||||
if reached.get(mid, {}).get("reached"):
|
||||
continue # already done — silent no-op
|
||||
continue # Already reached, so do nothing.
|
||||
reached[mid] = {"reached": True, "at": action_index}
|
||||
report["applied"].append({"path": path, "old": False, "new": True})
|
||||
continue
|
||||
@@ -559,7 +579,7 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||
action_index, meta, report)
|
||||
continue
|
||||
|
||||
# npc.<npcId>.<stat> — each NPC has its own stat defs.
|
||||
# `npc.<npcId>.<stat>`. Each NPC has its own stat definitions.
|
||||
if parts[0] == "npc" and len(parts) == 3:
|
||||
ndef = npcs.get(parts[1])
|
||||
if not isinstance(ndef, dict):
|
||||
@@ -608,8 +628,11 @@ def _stat_line(defs: dict, values: dict) -> str:
|
||||
|
||||
def render_state_section(world_state: dict, stat_schema: dict,
|
||||
visible_npcs: dict[str, str]) -> str:
|
||||
"""Compact, always-included context block. `visible_npcs` maps card-id ->
|
||||
display name for NPCs currently in scene."""
|
||||
"""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.
|
||||
"""
|
||||
ws = world_state if isinstance(world_state, dict) else {}
|
||||
lines: list[str] = []
|
||||
|
||||
@@ -658,13 +681,16 @@ def render_state_section(world_state: dict, stat_schema: dict,
|
||||
|
||||
|
||||
def _describe_stat(name: str, d: dict) -> str | None:
|
||||
"""One reference line for a stat. Description and band-ladder are independent —
|
||||
each is included only when present, so a stat may have either, both, or neither."""
|
||||
"""Returns one reference line for a stat.
|
||||
|
||||
The description and the band ladder are independent, and each is included
|
||||
only when present, so a stat may have either, both, or neither.
|
||||
"""
|
||||
bits: list[str] = []
|
||||
desc = d.get("desc")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
# Fragments are joined with "; " and end with a single ".", so drop any
|
||||
# trailing period the author already put on the description.
|
||||
# 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 d.get("type") == "text":
|
||||
bits.append("free text")
|
||||
@@ -684,8 +710,12 @@ def _describe_stat(name: str, d: dict) -> str | None:
|
||||
|
||||
|
||||
def render_reference(stat_schema: dict) -> str:
|
||||
"""A fixed, per-scenario legend describing what each stat means (its `desc`)
|
||||
and its band ladder. Static across turns — separate from the live values."""
|
||||
"""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.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for section in STAT_SECTIONS:
|
||||
for name, d in (stat_schema.get(section) or {}).items():
|
||||
|
||||
Reference in New Issue
Block a user