Compare commits

...
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00
JesseMarkowitzandClaude Opus 5 279a871a77 Planning: add the M4 implementation review report and rotate M3's
Reporting pass only. No application code, no test, and no product
requirement changes.

Result: PASS WITH CORRECTIVE WORK REQUIRED.

M4's Definition of Done is met and demonstrated at the API level, including
across a real two-process restart. The load-bearing constraint holds under
inspection rather than assertion: the only head-field assignment M4 added
anywhere in the backend is one line in head.py, and an exhaustive grep of the
diff finds no second mechanism that forks, prunes memories, reconstructs
state, filters the transcript or recomputes Redo.

Three corrective items, all M4's own, none in the head model:

- GET /checkpoints is an N+1 fetching whole Action rows including prose --
  measured at 53 SELECTs for 25 Save Points against 4 for the branch panel,
  in a codebase that keeps test_egress.py for this exact class of mistake;
- deleting a branch silently deletes Save Points naming it, and the branch
  panel's confirmation does not say so. M4 added the consequence to an
  existing destructive action without updating its warning;
- the shipped D11/L03 tests restart a client, not a process, so the suite is
  weaker than the acceptance items it is named for. Both pass here only
  because the report re-ran them across a real process boundary by hand.

The browser smoke test is NOT PERFORMED, for M4 and still for M3. Firefox is
a snap that hangs past 90s on a trivial headless screenshot; there is no
Xvfb, no display, no driver library. Two consecutive milestones now carry an
unperformed browser requirement, which the report raises as a standing
acceptance risk rather than a defect in either milestone's code.

Evidence recorded: 680 backend tests pass (42 M4, 130 M3 invariants, 93
security/local-only), frontend lint and build clean, Docker build clean,
migration 80 verified against a representative pre-M4 database with both
cascades and zero possible orphans, and I04 verified through a real round
trip with branch ids remapped 1->3 and 2->4.

M4 is NOT accepted by this report, and M5 is NOT authorized. That decision
belongs to whoever reviews this.

Rotation: planning/reports/M3-IMPLEMENTATION-REPORT.md moves to
planning/archive/milestone-reports/ as a pure rename, contents unedited
(git reports 100% similarity, 0 insertions, 0 deletions). Six path
references in five active documents are updated because the path changed
and for no other reason -- no status claim, no wording change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 19:15:53 -04:00
JesseMarkowitzandClaude Opus 5 e08d49c3eb M4: add durable named Save Points
A Save Point is a name for a story position, and restoring one is head
movement. That is the whole architecture, and it is what ADR 012 and
BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored
(branch, depth) and made arriving at one a row lookup plus a state restore,
so a Save Point needs no restore machinery of its own.

What the user gets:

- Name the moment they are reading, keep playing, restart the app, and come
  back to it. Restoring moves the story back and deletes nothing: the later
  turns stay, Redo still walks forward into them, and writing something
  different is what starts a new line while the old one is kept.
- Rename, delete, and a list, in a Save Points panel beside the branch panel,
  with a Save Point button next to Undo and Redo. Both confirmations say what
  is *not* destroyed, because that is the part the screen cannot show.
- Save Points survive export and import.

What was deliberately not built:

- No second restore path. `head.move_to_node` is the only new movement: its
  depth half is M3's `head.move_to` unchanged, and its branch half is the
  single assignment `switch_branch` already makes. No head field is written
  in the checkpoint router, nothing reconstructs state, nothing prunes a
  memory, nothing copies or deletes a turn, and restore never forks — the
  first write below the restored head does, through `fork_if_behind_head`.
- No automatic cleanup. A Save Point behind the head, or naming a line the
  story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal
  is a cascade: deleting a branch takes its Save Points, as it takes its
  memories, because the story they named went with it.
- No new ADR. ADR 012 already decides the architecture, and a table is not a
  decision.

The one call the planning package did not already make: restore moves the
branch half of the head only when the coordinate is off the path being read.
Doing it unconditionally would quietly hand back an abandoned continuation
whenever a Save Point in a shared prefix was restored; never doing it would
make a Save Point on a departed line unrestorable, which contradicts §19.
TECHNICAL-DESIGN §8.8 records it.

Schema: a `checkpoints` table holding a name, an optional note and a
(branch, depth) coordinate — no copy of any story. `create_all` builds it as
it did `memories` and `branches`; migration 80 adds the index. No backfill,
because nobody had named a position before M4.

The coordinate is deliberately not an action id: one coordinate holds every
attempt at a turn and exactly one is live, so a coordinate follows a retry
where a row id would pin a take the story no longer tells.

Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering
D11-D14, I04, L03, E-series lineage and memory isolation after restore and
divergence, the edge cases, and an M3-database migration. One pre-existing
fixture in test_tree_migration.py needed `checkpoints` added to its drop
list — SQLite refuses to drop a table another table references.

Not verified: the browser. No session has had a usable one, so the Save
Point panel's DOM behaviour is unobserved — as M3's Redo control still is.
The twenty-step sequence was driven over HTTP against a live server with a
real process restart instead, and all seventeen checks pass. M4 is
implemented, not accepted: no review has been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 18:48:54 -04:00
JesseMarkowitzandClaude Opus 5 3c8e91f644 Docs: correct post-M3 status and Ollama configuration
Three stale claims found in active documentation after the post-M3
consolidation:

- BUILD-MILESTONES.md still opened "M1 and M2 complete; M3 next", which
  contradicted its own M3 "Status: COMPLETE" block, planning/README.md and
  VERSION.md. It now states M1, M2 and M3 complete and accepted, M4 next.
- DEVELOPMENT.md described the settings row as "endpoint, model and (unused)
  API key" and sent "api_key":"" in its curl example. M2 removed the field;
  schemas.SettingsUpdate has no api_key. The prose and the example now match
  the real request shape. No application code was changed.
- README.md listed LM Studio as a supported local endpoint. ADR 002 and ADR 011
  make Ollama the only v1 backend; LM Studio is a rejected alternative there.
  The row is removed and the surrounding wording now says Ollama is the
  supported backend, same-host is the default, trusted-LAN Ollama is supported,
  public/cloud is prohibited, and the OpenAI-compatible adapter is an
  implementation detail rather than a support promise. The claude_shim section
  stays, relabelled "(development only)".

Documentation only; no code, schema or test changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 16:39:29 -04:00
JesseMarkowitzandClaude Opus 5 d27ee34901 Docs: consolidate active planning and archive historical material
The planning package had grown to where a new agent could not tell what was
authoritative. Phase 0 execution prompts sat beside the specification; four
completed milestone reports sat beside the current one; and upstream AI-DnD's
own `plan/` build log and `docs/` project site still described a hosted,
scripted, multi-user product with accounts — every screenshot in it showed a
Scripts tab and a Sign up button, none of which has existed since M2.

`planning/archive/` now holds the history and says so in its own README:
`phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and
M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied.
`planning/reports/` holds only the current milestone's report, because that is
the one M4 planning has to read; it moves to the archive when M4's replaces it.

Deleted rather than archived: the Phase 0B execution prompts and the
handoff/status/summary documents, the Phase 0A discovery and triage reports,
upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's
template boilerplate. All of it is in Git history, and the two recommendation
reports carry every conclusion the deleted research reached.

Archived documents are kept verbatim. Paths written inside them point at where
those files were when the document was written, which is the point: an evidence
record that has been quietly edited is no longer evidence.

Active documentation is corrected where it pointed at the removed trees or
described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not
touch" list had gone stale at M2 and claimed QuickJS scripting was still tested;
its test count was 604 against an actual 638. `README.md` loses the upstream CI
badge, which reported upstream's pipeline rather than this fork's, and a
reference to `backend/app/worldstate/engine.py`, a file that does not exist.
`planning/README.md` is rewritten as the documentation index.

New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the
manifest of what belongs in the ChatGPT project's Sources.

Source comments referring to the deleted trees are reworded; no behaviour
changes. 638 backend tests pass, frontend lints and builds, and a reference scan
over all 48 tracked Markdown files reports no unresolved path in active
documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
2026-09-03 14:33:07 -04:00
JesseMarkowitzandClaude Opus 5 c8755c21c2 Planning: close M3 and record active-head architecture
M3's review recommended planning changes and, following the M2 pattern,
reported rather than applied them. This applies them, and adds the ADR the
review asked for.

ADR 012 records the architecture rather than the requirement. ADR 005
already says that going backward must preserve abandoned history and that
the user sees Undo/Redo/Retry rather than branch management; it names a
movable active head as the direction and stops. What M3 settled is the
shape: the head is stored rather than derived, every read of the story is
capped at it in one place, one mechanism moves it, the state of a position
comes off the node rather than from a replay, the first write below a
moved-back head is the divergence, and whether Redo exists is decided by
the lineage rather than by a flag that could be stale. The last of those
is the property worth keeping — a flag can be wrong and make the story
wrong; a lineage cannot.

Two semantics are ratified in STORY-BRANCH-SEMANTICS.md, both of them
reversals or narrowings that a reader would otherwise take for bugs. Undo
now crosses fork points and continues to the campaign opening, because
refusing at the fork was a consequence of deleting rows the parent line
was also reading, and nothing is deleted any more. And the system refuses
to switch which take is live while a later story is off screen, because
doing it quietly would leave retained history continuing from words the
story no longer says.

A new §14A covers editing in place. §14-15 describe the finished
behaviour — the edit becomes authoritative, the state it implies is
re-evaluated, a new continuation is created, the original is retained —
and that requirement is intact and explicitly not weakened here. It is
also not built, because re-evaluating state from prose a user typed needs
M5's extraction pass. §14A says what exists in the meantime and why
refusing is the minimum that holds the invariant rather than the
destination.

TECHNICAL-DESIGN.md gains §8.7 and §9.1, recording the implemented model
and the bundle behaviour as fact in the way §5.2 records M1 and M2. §10.4
gains a constraint that is easy to lose: the snapshot half of the hybrid
state model is a requirement, not an optimization. Head movement is a row
lookup plus a restore, which is why Undo, Redo and Save Point restore cost
the same at any distance into a campaign; a state model recoverable only
by replaying from the opening would make all three proportional to
campaign length, on exactly the long campaigns this product is for.

DATA-MODEL.md records the head as stored on the campaign rather than
derived from its newest turn — two campaigns holding identical turns can
be read at different places, and nothing about the turns can tell them
apart — and the branch disposition as implemented: the depth a divergent
write left the branch at, deliberately advisory, and carried through
export because every row of an abandoned line is exported either way.

BUILD-MILESTONES.md marks M3 complete and states the one condition still
open. M4 is told a Save Point is a durable pointer and that restoring one
is head movement with a bounds check, not a restore system: a second
mover is the specific failure to avoid, because the two paths would
silently disagree about what restore means. M5 gets three constraints —
keep state efficiently recoverable, move the test instrumentation rather
than the assertions when the world-state protocol goes, and finish the
narrator edit §14A defers.

V1-ACCEPTANCE-TESTS.md clarifies ownership without lowering a bar. D10
keeps all three pass conditions and is explicitly recorded as *not*
satisfied at the end of M3; what changed is that the document now says
which milestone delivers which condition. D03's result is recorded as a
full pass rather than the partial the text allowed for, I07 gains the
pre-M3 bundle clause, and L01 gains the note that resolves its apparent
conflict with A05 — a failed turn does advance the head by one, onto the
player's retained input, and that is A05 working rather than L01 failing.

README.md described a different application: a hosted demo, guest
accounts, cloud providers, Postgres, a Render blueprint, an analytics
dashboard, a QuickJS scripting engine, and 549 tests. M2 removed all of
that and the README was never updated — a gap M2's own debt table missed.
It now describes what this fork is, including the endpoint policy and the
TLS behaviour, and the numbers in it are the current ones.

M3's report is included here as its own evidence record: no separate
baseline report was produced, so it carries the raw counts and runtime
observations as well as the review, and §W records this closeout.

SPECIFICATION.md and SECURITY-THREAT-MODEL.md are unchanged. M3 altered no
product requirement and touched no path in the threat model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:44 -04:00
JesseMarkowitzandClaude Opus 5 7f082b61d8 M3: complete non-destructive history and active-head export
The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.

Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.

Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.

The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.

An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.

The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.

Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.

tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.

638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.

Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:43 -04:00
JesseMarkowitzandClaude Opus 5 903fa7a74f M3: move the story's head instead of deleting its turns
Undo deleted. It removed the trailing AI action and the player action in
front of it, pruned the memories covering them, and let the tip fall back
to whatever survived. That made it the one operation in the application
that destroyed accepted story, and it was why there was no Redo: the
turns to move forward into no longer existed. Phase 0B demonstrated the
head-cursor alternative in a disposable spike; this is that concept as
production code.

backend/app/head.py is the whole of it. Three questions that used to be
one — where the story is being read, how far it is retained, and where it
opens — are now three functions, and every caller that moves the head or
asks about it goes through this module. The spike put the fork check in
the write path and left Retry and Add-take on the old one; sharing the
rules is what stops that divergence coming back.

lineage.Path now caps every entry at the head, so hiding the retained
future costs nothing at the call sites: the transcript, the context
builder, attempts.preceding and memory retrieval already funnelled
through path_of and narrow together. Path.uncapped() is the deliberate
exception, and only Redo and the fork check may use it. The memory bank
needs no pruning for the same reason — a memory carries the coordinate of
the node its block ends on, so one derived past the head falls outside
the capped clause and becomes retrievable again on Redo without having
been deleted and re-embedded.

Undo alone does not fork. Moving the head is not a decision to abandon
anything, since the user may be reading or about to Redo; the first write
below the head is where the story states which continuation it means. A
head already at the tip forks nothing, so a story that is never undone
forks exactly as often as it did before and the branch table does not
fill up with one branch per turn. Redo follows the lineage rather than
choosing among branches, which is what invalidates it after a divergence
with no flag to set or clear.

Migrations 78 and 79 give a branch superseded_at and superseded_depth.
Nothing reads them to decide behaviour — Redo is decided by the lineage,
so a stale or hand-edited value here cannot make the story wrong. They
exist so the cleanup and discarded-history features left to a later
version have something to select on, and so a divergence is observable in
a test.

Deleting an action no longer drags a moved-back head forward to the
recomputed tip, which would have silently redone the story. can_undo and
can_redo ride on AdventureOut and ActionPage because the client can work
out neither for itself: the campaign opening may be off the top of the
loaded window, and the retained future is never sent to it.

This is a checkpoint, not the finished milestone. 601 backend tests pass.
Five still assert the destructive contract — they count rows after an
undo and expect the story to be shorter — and need rewriting against the
new one; the world-state assertions inside them already pass. Export and
import do not yet carry the head coordinate, so a bundle still reopens at
the deepest node and can silently redo an undone story, which is the
Phase 0B finding this milestone exists to close. The browser has no Redo
control yet. None of the M3 acceptance coverage (D01-D10, E01-E04,
I01-I03, I07, L01-L02) is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 11:45:01 -04:00
JesseMarkowitzandClaude Opus 5 2fdd2547f0 Planning: record M2 closeout decisions
M2's review reported six planning recommendations rather than applying them,
three marked before M3. All six are applied here, plus three additions drawn
from the same evidence. No implementation file is touched.

The endpoint policy was the gap that mattered. It is the most consequential
setting in the application — the storyteller sends the player's prose, the
context, the memories and the embedding inputs to whatever address it names —
and it existed only as a module docstring. It is now ADR 011 and a new §10A in
the threat model, which also retires the assumption in §71A that the inherited
guard was a starting point. It was not: AI-DnD's SSRF guard blocked private
addresses to stop a hosted server reaching its own internal network, which is
the exact opposite of what a local storyteller needs. It was removed, not
adapted.

Both documents state the rule as implemented — an allowlist of explicit
local-network CIDRs, every resolved address checked, enforced on save and again
before every outbound request, TLS never traded against it — and both state the
two residual limits plainly rather than implying they are covered: a hostile
host already on the trusted LAN is inside the permitted boundary, and a
rebinding interval exists between the policy's resolution and the client's
connection. Accepted risks, not M3 work.

The CIDRs are spelled out rather than derived from is_private/is_reserved, and
the ADR records why: is_private is true of the documentation ranges and
0.0.0.0/8, and is_reserved is true of IPv6 loopback, so a rule built on it
refuses an ordinary same-host Ollama on [::1].

TECHNICAL-DESIGN §5.1 items 3 and 4 are marked done, closing all five hardening
items. A new §5.2 records the M1/M2 architecture as fact rather than intention,
so later milestones inherit what the code does. A new §18.1 carries the lesson
of M2's two regressions: when removing a setting, test a real consumer
construction path; when adding one, prove it reaches the component that uses
it. Both defects hid behind a green suite because the tests at that boundary
were mocks.

BUILD-MILESTONES records M2 complete, with the capabilities later milestones
inherit and the debt carried forward. Two notes go to milestones that would
otherwise misread what M2 left them. M5 is told that eight rollback tests now
use the world-state engine as instrumentation and not as endorsement — the
instrumentation moves when the protocol does, and those tests are reworked
rather than deleted. M6 is told that the memory bank died silently under a
green suite, so background failure must be observable and at least one real
provider-construction path must be tested.

The security contract gains what M2 demonstrated. H10 now names the two
conditions that were defects during M2: a wildcard origin must be refused at
startup, and an unknown /api path must 404 rather than returning the SPA with
200. New H12 covers endpoint enforcement, and its fourth pass condition is the
one that matters — a public endpoint written into the database behind the
settings API must still be refused at the wire. A build passing the first three
and failing that one has configuration validation only.

SPECIFICATION.md is deliberately unchanged. M2 altered no product requirement;
it removed capability the specification never asked for.

The two M2 reports gain appended closeout notes rather than edits. Their
original wording about an uncommitted working tree was true when written, and
the note records what happened afterwards: the six-file correction is 8652fe7,
8c65ae9 remains the implementation commit, and the two were never squashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsZBU8sWRuYTyLgWsu2oQ6
2026-09-03 01:52:03 -04:00
JesseMarkowitzandClaude Opus 5 8652fe7cd8 M2 review: two regressions the green suite hid, and the reports
The post-implementation review of M2, plus the three corrections it took
to make the evidence true. Reports:

  planning/reports/M2-BASELINE-REPORT.md        868 lines, the measurements
  planning/reports/M2-IMPLEMENTATION-REPORT.md  758 lines, the reading of them

Verdict is PASS, accept with non-blocking debt, proceed to M3. Every M2
requirement is met and the ones that matter were tested by running the
build rather than reading it: a cloud endpoint written straight into
SQLite with sqlite3, behind the API's back, still refused at the wire;
trusted-LAN HTTPS against the real second machine with verification on;
captures showing zero packets outside loopback and the approved host.

Three defects, all found by running the shipped image.

The memory bank was dead. M2 removed Settings.api_key_plain with the API
key, and memorybank's two provider factories still read it. It failed
inside a fire-and-forget task, so no user error, no log anyone would
read, and no test — every memory test stubs those factories. All 604
tests passed with summaries and embeddings silently not happening.

The configurable model timeout never reached the turn engine. Stored,
validated, exposed in the API, rendered in the UI, and not passed to the
provider. M2's own exit criterion was half met: the constant had moved
but the setting did nothing.

And requirements.lock still pinned quickjs, psycopg and cryptography, so
the setup path DEVELOPMENT.md gives a new developer would have
reinstalled all three.

Both code defects now have the test that would have caught them: one
constructs every provider factory from a real Settings row, one drives
the turn endpoint, the chat endpoint and the summariser and asserts the
configured timeout arrives at each. That is the lesson worth keeping from
this milestone — after removing an attribute, build each consumer from a
real object; after adding a setting, prove it lands. Both failures were
in background or plumbing paths, which is exactly where a subtractive
change cannot see itself.

606 tests pass, up from 604. Lint, build and image are clean. Every
runtime result in the baseline report came from an image built after
these fixes; the reports say plainly that commit 8c65ae9 itself does not
contain them.

Also recorded: 88 test node IDs disappeared and every one is accounted
for — 64 whole files whose subject was removed, 5 replaced by a better
file, 15 individually retired with their features, and 4 renames. No
meaningful coverage was lost, and the eight files that used a JavaScript
counter as instrumentation kept their assertions by moving the counter to
the world-state engine.

Six planning recommendations are reported, not applied. Three are marked
before M3: the threat model still describes the inherited SSRF guard's
opposite rule, TECHNICAL-DESIGN §5.1 still marks two hardening items
open, and the endpoint policy is a load-bearing security decision that
exists only as a module docstring and deserves an ADR.

M3 is clear to start. Its chokepoints are untouched or simplified — the
rollback paths now carry one shared state instead of two — and the Phase
0B undo/redo spike still applies. No M3 work here: Undo still deletes and
there is still no Redo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017foPNqFjAJa2Ngebf5mEfL
2026-09-02 15:07:33 -04:00
JesseMarkowitz 8c65ae99de M2: cut the hosted product away from the local one
94 files, +1,395 -6,578. Three files are new; twenty-four are gone. The
milestone is subtraction, and what is left is the single-user local
storyteller the specification describes.

Removed in full: campaign scripting and its QuickJS sandbox; multi-user
accounts, guest sessions, login, registration and the shared demo key;
the visitor-analytics tables, dashboard and page beacon; the access log
of sign-ins, addresses and devices; per-IP and per-user rate limiting
and quotas; Render deployment config; Postgres and psycopg; cloud
inference providers, the API-key field and the key encryption that
existed to store it; session-cookie signing. None of it was hidden
behind a flag — the routes are gone and answer 404.

Two things were kept that the brief allowed keeping. The `users` table
and its foreign keys stay as an internal ownership detail, because
rewriting them out means a migration across most of the schema to
delete a column that costs nothing; nothing creates a second user and
no request carries an identity. Five inert tables and four inert
columns stay for the same reason, so an M1 campaign database opens
unchanged.

The one addition is app/endpoints.py, which decides where a story may
be sent. Loopback, RFC1918, link-local, unique-local and CGNAT — an
explicit allowlist of networks, not a guess at what `ipaddress` means
by "private", which calls the documentation ranges private and IPv6
loopback reserved. Every address a hostname resolves to must be in it,
so a split answer does not squeak through, and the rule runs both when
the endpoint is saved and before every outbound request, because a name
that resolved to the LAN this morning can resolve elsewhere this
afternoon. Known cloud hosts are named in the refusal so the error says
why rather than looking like broken DNS. TLS is never traded against
it: M1's shared trust context is intact on all four clients and there
is no way to skip verification.

The hardcoded 120-second model timeout is now a setting. That was not
theoretical — on this GPU-less four-core host a cold load of
qwen2.5:3b-instruct took 648.9 seconds to produce the first turn, while
turns 2 to 5 of the same campaign took 3.6 to 13.1. Connect stays short
at 10s so a wrong address still fails fast; the read timeout defaults
to 300s and is bounded at 3600, because "wait longer" must stay a
number.

Two defects found while testing and fixed here. An unknown /api path
fell through the SPA catch-all and came back as HTML with status 200,
so a client asking for JSON parsed a web page instead of learning the
route was gone. And AIDND_CORS_ORIGINS accepted "*", which on an
unauthenticated loopback API would hand every page on the Internet a
write handle on the campaign database; it now refuses to start.

Verified rather than assumed. Offline, on a network with no route out
and no DNS: five turns, retry with both takes retained, restart with an
identical transcript digest, a failed model call leaving the accepted
AI-turn count untouched, and a capture with zero non-loopback unicast
packets. Against a real second machine on the LAN over HTTPS with a
private CA: four turns, restart, and a capture showing 289 packets to
the approved host, 344 loopback, zero anywhere else, zero DNS queries.
Cloud and public endpoints refused with their reasons; no API key
settable; every removed route 404.

604 backend tests pass, down from 648 by the fifteen retired with the
subsystems they tested and up by the twenty-nine added for the endpoint
policy and the removed surface. The scripting tests were not deleted:
eight files used a JavaScript counter as instrumentation for the state
snapshot and rollback machinery, which M2 does not touch, so the
counter moved to the world-state engine and those tests still assert
what they always did. Frontend lint and build are clean; the image
builds, and its wheel-building stage is gone with quickjs.

No M3 work. Undo is still destructive and there is still no Redo.
2026-09-02 11:27:14 -04:00
215 changed files with 20330 additions and 18395 deletions
+95 -21
View File
@@ -46,6 +46,9 @@ ollama pull qwen2.5:3b-instruct
ollama pull nomic-embed-text # only if you want the memory bank
```
There is no account to create and nothing to log in to. The application is
single-user: whoever can reach it on loopback is its owner.
## Running
**Development** — backend on `:8000`, Vite dev server on `:5173`:
@@ -88,21 +91,52 @@ decision that this project's threat model does not cover
## Pointing the storyteller at Ollama
The endpoint, model and (unused) API key are **runtime settings stored in the
database**, not environment variables. Set them on the app's Settings page, or
with one request:
The endpoint, the model and the generation parameters are **runtime settings
stored in the database**, not environment variables. There is no API key field:
M2 removed it along with the cloud providers, and Ollama does not use one. Set
them on the app's Settings page, or with one request:
```bash
curl -X PUT http://127.0.0.1:8000/api/settings \
-H 'Content-Type: application/json' \
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
"api_mode":"chat","api_key":"","max_output_tokens":200,
"api_mode":"chat","max_output_tokens":200,
"context_token_budget":4096}'
```
`POST /api/settings/test` (the **Test connection** button) returns
`{"ok": true, "models": [...]}` and is the fastest way to tell a wrong endpoint
from a missing model.
from a missing model. When it fails it says which kind of failure it was, and
they need different things done about them:
| `kind` | What it means |
| --- | --- |
| `rejected` | The endpoint is outside the policy below. Not a network problem. |
| `unreachable` | Nothing answered. Ollama is not running there, or the port is wrong. |
| `tls` | The certificate did not verify — install the CA (see below). |
| `timeout` | It accepted the connection and then said nothing. |
| `http` | It answered with an error status; the body is included. |
A successful test also warns when the endpoint is reachable but has no model by
the configured name, which is the commonest way for a correct endpoint to still
fail every turn.
### Which endpoints are allowed
`backend/app/endpoints.py` decides, and it is deliberately narrow: **loopback,
your own LAN, or nothing.** The allowed networks are `127.0.0.0/8`, the three
RFC1918 ranges, link-local, IPv6 loopback and unique-local, and `100.64.0.0/10`
(carrier-grade NAT, which is what a mesh VPN such as Tailscale hands out).
Every address the endpoint's hostname resolves to must be in one of them. A
public address is refused, a name resolving to both a private and a public
address is refused, and known cloud inference hosts are refused by name so the
error says why rather than looking like a DNS fault.
The rule is applied when you save the endpoint *and* again before every
outbound request, so a database edited by hand or a hostname that starts
resolving somewhere new cannot turn a local install into an exfiltration path.
There is no setting to relax it.
### Same host (the default)
@@ -137,9 +171,8 @@ Use an IP address or a name your own network resolves. Then:
- the inference machine needs the models installed, not the storyteller;
- no Internet is involved in either direction.
`app/netguard.py` refuses private addresses only in hosted multi-user mode
(`AIDND_MULTI_USER=1`), which local installs never turn on, so a LAN endpoint
is accepted as configured.
A LAN endpoint is accepted because it is on one of the allowed networks above.
Nothing else about it is special.
#### If that endpoint is HTTPS with your own CA
@@ -176,7 +209,7 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 648 tests
cd backend && .venv/bin/python -m pytest tests/ -q # 756 tests
cd frontend && npm run lint && npm run build
```
@@ -192,6 +225,44 @@ suite as complete evidence.
is lost from the union, or if a new HTTP client is added without the shared
verification context.
M5 added `test_narrative_state.py`, which fails if the state stops being
genre-neutral, if an event outside the allowlist is ever applied, if a malformed
proposal mutates anything, if campaign canon stops outranking the narration, or
if a turn's narration and its state can be committed apart from each other.
`test_narrative_realistic.py` is the one suite that needs a real model, and it is
skipped unless you point it at one:
```bash
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \
AIDND_TEST_MODEL=qwen2.5:3b-instruct \
backend/.venv/bin/python -m pytest backend/tests/test_narrative_realistic.py -v -s
```
It exists because Phase 0B found that structured-state behaviour can look
correct on a small prompt and fail under a full one — and it has already earned
its place, catching a case where a model echoed its own instruction into the
narration.
M4 added `test_save_points.py`, which fails if restoring a Save Point starts
deleting history, stops going through the active head, forks on its own, lets a
Save Point on one campaign be restored through another, or lets deleting a branch
take a Save Point with it. It also fails if listing Save Points goes back to one
query per Save Point, or starts fetching narration to render the list.
`test_process_restart.py` is the durability guard: it starts the application as a
real subprocess, kills it, and starts a second one against the same database. A
Save Point that survived only because a Python object was still alive would pass
an in-process test and fail a user's restart.
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
addresses widens, or if either place the rule is applied stops applying it —
it resolves hostnames through a stub, so it tests the policy rather than
whatever DNS the machine has. `test_local_only_surface.py` fails if a removed
subsystem comes back as a route, if an API key becomes settable again, if the
model timeout stops being configurable or becomes unbounded, or if a supported
start path stops binding loopback.
## What was made offline-safe, and how to check
Two runtime downloads were removed in Milestone M1. Both were invisible on a
@@ -226,21 +297,24 @@ docker exec app python -c "import socket; socket.create_connection(('1.1.1.1',44
# -> OSError: Network is unreachable, and story turns still work
```
`planning/reports/M1-BASELINE-REPORT.md` records the run this procedure is
`planning/archive/milestone-reports/M1-BASELINE-REPORT.md` records the run this procedure is
taken from, including the packet captures.
## Things inherited from upstream that M1 deliberately did not touch
## Things still inherited from upstream
These are M2's scope (`planning/BUILD-MILESTONES.md`), listed here so nobody
reports them as new:
M2 removed the hosted, cloud, account, analytics, Postgres/Render and QuickJS
scripting surfaces outright — `PROVENANCE.md` lists exactly what went. What is
left of upstream that a newcomer might report as a defect:
- hosted/multi-user/account/demo-key code, analytics tables, Postgres and
Render deployment paths, and the OpenRouter default endpoint constant all
still exist in the tree. None of them is reachable from a default local run,
and none requires a cloud service.
- `docs/*.html` is upstream's GitHub Pages project site and still links Google
Fonts. It is not served by the application and is not part of any build.
- `.github/workflows/ci.yml` is upstream's GitHub Actions pipeline. This
- **Inert legacy tables and columns.** Five tables and four columns M2 emptied
of meaning are still in the schema, unmapped, so an M1-era campaign database
opens unchanged. Nothing reads or writes them. A cleanup migration waits for
the schema to settle after M5 (`planning/BUILD-MILESTONES.md`).
- **Dual-dialect migration code.** `backend/app/migrations.py` still carries
SQLite/Postgres branches from upstream, although Postgres support itself is
gone and SQLite is the only store. Same cleanup, same milestone.
- **`.github/workflows/ci.yml`** is upstream's GitHub Actions pipeline. This
repository lives on a self-hosted Gitea; the workflow is kept for provenance
and is not what runs the tests here.
- QuickJS campaign scripting is still present and still tested.
- **No frontend tests.** `npm run lint && npm run build` is the whole frontend
check. A test runner is M8's job.
+14 -22
View File
@@ -7,20 +7,15 @@ RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2 — build Python wheels (quickjs compiles from source if no wheel
# matches, so keep the toolchain out of the final image)
FROM python:3.12-slim AS python-build
RUN apt-get update && apt-get install -y --no-install-recommends gcc make \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip wheel --no-cache-dir -r /tmp/requirements.txt -w /wheels
# Stage 3 — runtime
# Stage 2 — runtime. Every remaining dependency ships a wheel, so there is no
# compile step and no toolchain to keep out of the image. The wheel-building
# stage that used to sit here existed for quickjs, which compiled from source
# and which M2 removed with campaign scripting.
FROM python:3.12-slim
WORKDIR /app
COPY --from=python-build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY backend/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
# Layout mirrors the repo: main.py finds the SPA at ../../frontend/dist
# relative to backend/app/main.py.
@@ -38,14 +33,11 @@ EXPOSE 8000
# invitation to put the storyteller on the LAN, which is single-user and
# unauthenticated in local mode.
WORKDIR /app/backend
# --proxy-headers lets uvicorn fix up the request scheme (https) behind the
# platform's edge. We deliberately do NOT pass --forwarded-allow-ips "*": that
# made uvicorn trust the LEFTMOST X-Forwarded-For value, which the client fully
# controls, so anyone could rotate the header to dodge the per-IP rate limits.
# The client IP used for rate limiting is derived in limits._client_ip from the
# hop the edge appends (rightmost), which a client cannot spoof past; tune with
# AIDND_TRUSTED_PROXY_HOPS if the platform adds more proxy hops.
# Single worker on purpose: the turn lock, rate limiter, and debug log are
# in-process state.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--proxy-headers"]
# Single worker on purpose: the turn lock and the debug log are in-process
# state.
#
# No --proxy-headers. That existed for a hosted deployment behind a platform
# edge, along with the per-IP rate limiting that read X-Forwarded-For. Neither
# survives M2, and trusting a forwarded header on a loopback-published port
# would be a way to lie to the app rather than a feature.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+29 -4
View File
@@ -26,9 +26,11 @@ import is a merge of the pinned commit with `--allow-unrelated-histories`, so:
- `git log d72f7c1bda0f34fccd84afb7a25c34eb01c901de` shows the real upstream
history, not a squashed snapshot;
- upstream paths are unchanged (`backend/`, `frontend/`, `docs/`, …), so a
later upstream commit can still be fetched and cherry-picked against
matching files;
- upstream code paths are unchanged (`backend/`, `frontend/`, …), so a later
upstream commit can still be fetched and cherry-picked against matching
files. Upstream's own documentation trees, `plan/` and `docs/`, were removed
on 2026-09-03: they described the hosted, scripted, multi-user product this
fork is not. They remain in this repository's history and in upstream;
- the planning package that predates the fork keeps its own history on the
other parent of the merge.
@@ -78,10 +80,33 @@ text ships beside them as `OFL-cinzel.txt`, `OFL-crimsonpro.txt` and
Regenerate with `python3 frontend/tools/vendor_fonts.py`, which also rewrites
`frontend/src/styles/fonts.css`.
## What this fork changed in Milestone M2
M2 is subtractive. It reduced the inherited application to the intended
single-user, local-first trust boundary. **Nothing was added that upstream did
not have, except the endpoint policy and the tests that hold these removals in
place.**
Removed in full: campaign scripting and the QuickJS sandbox; multi-user
accounts, guest sessions, login, registration and the shared demo key; the
visitor analytics tables, dashboard and beacon; the access log; per-IP and
per-user rate limiting and quotas; Render deployment config; Postgres/Neon
support; cloud inference providers and the API-key field; session-cookie
signing and API-key encryption at rest.
Added: `backend/app/endpoints.py`, which decides what an inference endpoint may
be, and a configurable model timeout.
Three database tables (`scripts`, `adventure_scripts`, `analytics_daily`,
`analytics_visitor_days`, `access_log`) and four columns (`adventures.script_state`,
`settings.api_key`, `users.demo_turns_used`, `users.demo_turns_date`) are left
in place, unmapped or inert, so that an existing M1 campaign database opens
unchanged. They are not product functionality and nothing reads or writes them.
## What this fork changed in Milestone M1
Nothing was removed from upstream. The changes are the offline/locality
hardening M1 called for; see `planning/reports/M1-BASELINE-REPORT.md` for the
hardening M1 called for; see `planning/archive/milestone-reports/M1-BASELINE-REPORT.md` for the
evidence.
- `backend/app/context/encoding.py` (new) and `backend/app/context/builder.py` —
+142 -151
View File
@@ -1,40 +1,41 @@
# AI D&D
# Adventure Storyteller
[![CI](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml/badge.svg)](https://github.com/parththakkar106/AI-DnD/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
An AI Dungeon-style interactive storytelling app that runs entirely on your own machine, with
your own AI model. Create scenarios, play open-ended adventures where an LLM narrates the
world, and extend the engine with **JavaScript scripts compatible with real AI Dungeon
scripting**.
An interactive storytelling app that runs entirely on your own machine, with your own model.
Create scenarios and play open-ended adventures where a local LLM narrates the world, keeps
track of what is true, and remembers what happened.
> ### ▶️ Try it live: **[parththakkar106.github.io/AI-DnD](https://parththakkar106.github.io/AI-DnD/)**
> The project page loads instantly and launches the hosted demo in one tap. Play a scenario as
> a guest: no sign-up and no API key needed. The demo runs on a free tier that sleeps, so the
> first load after it's been idle takes about 30 to 60 seconds to wake up.
This is the **Adventure Storyteller** fork of [AI-DnD](https://github.com/parththakkar106/AI-DnD).
It is deliberately narrower than its upstream: single-user, local-only, and pointed at a model
you run yourself. The hosted deployment, the accounts and sessions, the cloud provider support,
the Postgres path, and the JavaScript scripting engine have all been removed rather than
disabled. What is left is a storyteller you can run offline.
> **Local-only, by design.** The app talks to one place — an Ollama-compatible endpoint on this
> machine or on a machine you control on your own network — and it refuses to be pointed at a
> public address. There is no telemetry, no account, no cloud inference, and nothing is fetched
> at runtime from the Internet.
>
> For the internals, read the **[design notes](https://parththakkar106.github.io/AI-DnD/guide.html)**.
> They walk through the context budgeting, the world-state referee, and the memory bank, and
> state the reasoning behind each one ([Markdown version](docs/GUIDE.md)).
> For the internals, read [`planning/TECHNICAL-DESIGN.md`](planning/TECHNICAL-DESIGN.md) and
> [`planning/CONTEXT-AND-MEMORY.md`](planning/CONTEXT-AND-MEMORY.md), which cover the context
> budgeting, the state model and the memory bank as this fork builds them.
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, running on
SQLite locally and Postgres in the cloud. It works with **any OpenAI-compatible endpoint**:
Ollama and LM Studio locally, or OpenRouter, OpenAI, Groq, or vLLM in the cloud. Endpoint, key,
and model are all runtime settings, and OpenRouter's free-tier models make the whole experience
cost nothing.
Built with FastAPI and SQLAlchemy on the backend and React (Vite) on the frontend, storing
everything in one SQLite file.
![The play screen, with the world-state rail open](docs/images/play-world-state.jpg)
*The play screen. The left rail shows live world state. The AI proposes changes each turn, and
a Python engine decides what actually sticks. The chip under the narration reports what
changed. The `‹ 2/2 ›` under a turn steps between the takes it has. Writing below a take that
isn't the live one starts a new branch.*
On the play screen, the left rail carries live world state. The AI proposes changes each turn
and a Python engine decides what actually sticks; the chip under the narration reports what
changed. The `‹ 2/2 ›` under a turn steps between the takes it has, and writing below a take
that isn't the live one starts a new branch.
## Features
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
retry, undo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
💭 panel with its own token budget.
retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction
becomes a new continuation carrying the state it implies, and the original narration keeps its
own future as retained history. Reasoning models are supported: "thinking" streams into a
collapsible 💭 panel with its own token budget.
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
and the server is told nothing. Writing below a take that isn't the live one is what makes a
@@ -43,53 +44,71 @@ isn't the live one starts a new branch.*
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
deletes; **⌗ See the tree** draws every line against the story's own clock
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
monotonic and milestones sticky, then strips the machine-readable block out of the prose
(`backend/app/worldstate/engine.py`). Word-labeled bands (`40–60: minor damage`) make the
model reliable at it. No dice and no scripting are required.
- **Authoritative narrative state, and the application owns it.** The story tracks who exists,
where they are, what they hold, what is true, how they are tied to each other, and what is
still open — as generic entities, facts, relationships and threads, with no genre baked in.
The same schema holds a silver key in an abbey and a data crystal on an orbital station.
The AI proposes **typed events with absolute values** (`set_possession`, `add_fact`,
`set_current_location` …), and a Python validator decides what is accepted: unknown event
types are refused, references must resolve, campaign canon outranks the narration, and the
machine-readable block never reaches the reader (`backend/app/narrative/`). Every accepted
change is recorded with what it was before and which turn caused it, so the Story State panel
can show what changed and why. You can correct it by hand, and your correction outranks the
story.
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
info) are triggered by keywords in recent story text, then assembled under a token budget
(`backend/app/context/builder.py`).
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
included.
- **JavaScript scripting, AI Dungeon-compatible.** `onInput` / `onModelContext` / `onOutput`
modifiers share `state` and a `worldEntries` API, and run in an embedded quickjs sandbox
(`backend/app/scripting/`). Real AI Dungeon scripts import and run as is. An in-app CodeMirror
editor is included.
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
memories every few actions, a running story summary, and embedding-based retrieval that
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
(`backend/app/memorybank.py`).
- **Undo and retry that actually roll back state.** Undo and retry roll back the world state
and script state to a per-node snapshot, not just the text, and prune the memories that
covered the removed turns. Nothing a retry replaces is discarded: the old attempt stays as
another take of that turn, one keystroke and one click from becoming a branch of its own.
- **Import and export.** AI Dungeon-compatible formats for scripts and scenarios; JSON for
everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, and the fork points, since those were chosen rather than computed.
Files saved in the old single-line format still import.
- **Optional accounts for hosted deployments.** By default the app is single-user with zero
auth friction. Set `AIDND_MULTI_USER=1` and visitors play instantly as guests (signed
session cookie), can register (email and password) at any point to keep their data, and each
user gets isolated data plus their own encrypted-at-rest API key. A server-funded **shared
demo key** with a daily turn cap lets people try it without bringing a key
(`backend/app/auth.py`). Each new guest is also given a copy of a short pre-played
adventure, so the first screen shows real turns and their world-state changes without
spending a demo turn (`backend/app/starter.py`).
- **Undo, Redo, and retry that roll back state and delete nothing.** Undo moves where the story
is being read; it removes no accepted turn, so Redo can walk forward into the turns it stepped
over. Both restore the world state from a per-node snapshot rather than just the text, and a
memory derived from a turn now behind the head stops being retrieved without being deleted or
re-embedded. Writing a new turn below a moved-back head is the moment the story forks: the
displaced future stays on the line it was written for, and ordinary Redo stops offering it.
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
turn, one keystroke and one click from becoming a branch of its own.
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
restart the app, and come back to it. Restoring one moves the story back to
that moment and deletes nothing: the turns you wrote after it stay, Redo still
walks forward into them, and writing something different from the Save Point
is what starts a new line while the old one is kept. A Save Point is a name for
a position and holds no copy of the story, so restoring it is the same
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
`backend/app/head.py`). They last until *you* delete them: deleting one deletes
no story, and deleting a branch a Save Point is kept on is refused until you
remove the Save Point yourself, so nothing takes a named moment away behind
your back.
- **Import and export.** AI Dungeon-compatible scenario format; JSON for everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, the fork points, which branches the story has left behind, the Save
Points and the position it is being read at — all of them chosen rather than computed, which is
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
merely because it has one. A campaign exported after two Undos imports still undone, with its
retained future intact, instead of silently reopening at its newest turn. Files that predate
the head position, and files saved in the old single-line format, still import.
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
anywhere in the product. The storyteller API binds to loopback and is unauthenticated by
design, because the only person who can reach it is the person running it. A new install
starts with a short pre-played adventure, so the first screen shows real turns and their
world-state changes rather than an empty page (`backend/app/starter.py`).
- **A refusal you can rely on.** The inference endpoint is checked against an address
allowlist when you save it and again before every request, so a public endpoint is refused
even if the setting is edited in the database directly. TLS verification is never traded
against reachability: a privately issued certificate is verified against your machine's own
trust store, and there is no bypass switch.
## Screenshots
| | |
|---|---|
| ![Insights panel](docs/images/insights.jpg) | ![Scenario editor](docs/images/scenario-editor-npcs.jpg) |
| **Insights**: the exact prompt for the next turn, broken into components with token counts and the trigger word that pulled each story card in. | **Authoring**: stats with ranges, per-turn caps, cooldowns, and word-labeled bands; NPCs the AI addresses by id. |
| ![Script editor](docs/images/script-editor.jpg) | ![Home](docs/images/home.jpg) |
| **Scripting**: the three AI Dungeon hooks with shared persistent `state`, run in a quickjs sandbox. | **Home**: continue a story in progress or start from a scenario. |
| ![The branch map](docs/images/branch-map.jpg) | ![The branches panel](docs/images/branches-panel.jpg) |
| **The tree**: one lane per line, from the moment it left its parent to the moment it ends. The horizontal axis is the story's own clock, so a short branch reads as short. | **Branches**: every line the story has taken, and the three things you can do to one. A line the one you're reading was forked from can't be deleted, and says so. |
None yet. The inherited screenshots showed upstream's UI — a Scripts tab, Log in and Sign up,
a guest banner, scripting demo scenarios — none of which this fork has since M2, so they were
removed rather than left standing as a picture of a product that no longer exists. The M4
closeout drove the real application in a real browser, so the screens exist and work; taking
presentable screenshots of them is a job for the UI pass in M8.
## Quick start
@@ -121,58 +140,67 @@ Open http://localhost:5173.
## Connect a model
Open **Settings** in the app and point it at any OpenAI-compatible endpoint:
Ollama is the inference backend v1 supports. Open **Settings** in the app and point it at one:
| Provider | Endpoint URL | Notes |
| Where Ollama runs | Endpoint URL | Notes |
|---|---|---|
| Ollama (local) | `http://localhost:11434/v1` | free, private; also serves embedding models for the Memory Bank (e.g. `nomic-embed-text`) |
| LM Studio (local) | `http://localhost:1234/v1` | free, private |
| OpenRouter | `https://openrouter.ai/api/v1` | `:free` models cost nothing (no embeddings on the free tier) |
| OpenAI / Groq / vLLM / … | provider's `/v1` URL | anything speaking `/v1/chat/completions` |
| Claude Code CLI (local) | `http://127.0.0.1:8787/v1` | your Claude subscription instead of an API key; see [Playing against Claude locally](#playing-against-claude-locally) |
| The same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
| A machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | explicitly configured; see below |
Model name, API key, generation parameters, and (optionally) summary and embedding models for
the Memory Bank are all configured there too. No config files and no rebuild are needed.
Model name, generation parameters, and (optionally) summary and embedding models for the
Memory Bank are configured there too. No config files and no rebuild are needed. There is no
API key field, because there is nothing to authenticate to.
### Playing against Claude locally
The adapter underneath speaks the OpenAI-compatible protocol, because that is what Ollama
serves. That is an implementation detail, not a promise of support for arbitrary local
servers that happen to speak the same protocol. Public and cloud inference endpoints are
prohibited outright — see `planning/DECISIONS/002-ollama-only-v1.md` and
`planning/DECISIONS/011-local-inference-endpoint-policy.md`.
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint backed by the
`claude` command line tool, so you can play the demos against a real model without an
API key. Each request spawns one `claude --print` process, which suits the turn engine:
the app assembles the whole prompt every turn and expects a stateless endpoint.
### What the endpoint policy allows
The address is checked when you save it and again before every request. Only loopback and
private-network addresses are accepted; every public address is refused, by address rather than
by hostname, so a name that resolves outward is refused too. A well-known cloud inference host
is named in the error message only so the refusal says *why*.
Running the model on a second machine you control is supported and expected — that machine
does the inference while the storyteller itself stays bound to loopback on yours. If that
machine serves HTTPS with a certificate from a CA you installed, it works: certificates are
verified against your operating system's trust store as well as the bundled one. Verification
itself is never relaxed, and there is no option to turn it off.
### Playing against a local shim (development only)
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint on `127.0.0.1:8787`
backed by a command-line tool, which is useful for testing the turn engine against a stronger
model. Each request spawns one process, which suits the engine: the app assembles the whole
prompt every turn and expects a stateless endpoint.
```sh
cd backend
.venv/Scripts/python.exe tools/claude_shim.py # listens on 127.0.0.1:8787
.venv/bin/python tools/claude_shim.py # listens on 127.0.0.1:8787
```
In Settings, choose the OpenAI-compatible provider, set the base URL to
`http://127.0.0.1:8787/v1`, put any non-empty string in the API key field, and pick
`sonnet`. The shim ignores the key and authenticates as you, through the CLI. Set the
reasoning budget to `0` or `-1`: a positive budget sends a `reasoning.max_tokens` field
that Claude 5 models reject.
Set the base URL to `http://127.0.0.1:8787/v1` and pick a model the tool offers. Set the
reasoning budget to `0` or `-1`: a positive budget sends a `reasoning.max_tokens` field that
some models reject. Embeddings are not served — leave the embedding model blank, or point the
Memory Bank at an endpoint that serves one.
Embeddings are not served. Leave the embedding model blank, or point the Memory Bank at
a real endpoint.
Run it against a local backend only. The endpoint has no authentication, and anything
reaching it spends your Claude quota. `app/netguard.py` blocks localhost endpoints when
`AIDND_MULTI_USER` is set, so a deployed instance cannot be pointed at it.
The shim has no authentication and spends whatever quota backs it, so run it on loopback and
leave it there.
## How a turn works
```
player input
→ onInput script modifier
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
+ [plot essentials] + [story summary] + [retrieved memories]
+ [triggered story cards] + [history along this branch, token-budgeted]
+ [author's note] + [player action]
→ onModelContext script modifier
→ snapshot context (Insights)
→ provider adapter → AI (streamed)
→ extract + referee the world-state delta block, strip it from the prose
→ onOutput script modifier
→ store & render
```
@@ -180,18 +208,19 @@ player input
```
frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ routers/ auth, scenarios, adventures, story cards, scripts, chat, settings, analytics, debug
├─ models.py SQLAlchemy: User, Scenario, Adventure, Branch, Action, StoryCard, Script, Settings, Memory
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (64 and counting)
├─ auth.py guest/registered users, sessions, shared demo key
├─ security.py password hashing, cookie signing, API-key encryption
├─ routers/ scenarios, adventures, story cards, chat, settings, debug
├─ models.py SQLAlchemy: Scenario, Adventure, Branch, Action, StoryCard, Settings, Memory
├─ migrations.py hand-rolled, versioned via PRAGMA user_version (79 and counting)
├─ endpoints.py the inference-endpoint address policy
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
├─ tree.py forking, promotion, and where a node is placed
├─ head.py the active head: where the story is read, and what moving it costs
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
├─ attempts.py the takes of one turn, grouped by parent
├─ context/ prompt assembly under a token budget + lineage/history windowing
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
├─ scripting/ quickjs sandbox + AI Dungeon API surface
├─ narrative/ the authoritative state: typed events, validation, snapshots
├─ worldstate/ the inherited RPG stat engine — legacy, no longer authoritative
├─ memorybank.py auto-summarization + embedding retrieval
├─ analytics.py buffered visit counters + the owner's dashboard query
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
├─ providers/ OpenAI-compatible adapter, streaming
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
@@ -202,9 +231,10 @@ development, Vite proxies `/api` to FastAPI.
## Tests
549 backend tests: unit tests plus full HTTP integration through the real quickjs scripting
engine, with the LLM provider mocked. CI runs them on every push, alongside the frontend
lint/build and a Docker image build.
756 backend tests: unit tests plus full HTTP integration through the real turn engine, with
the model provider mocked. They run with no route to the Internet, which is a requirement
rather than a convenience — an offline claim proved on a machine that has been online once
proves nothing.
```sh
cd backend && pip install -r requirements.txt -r requirements-dev.txt
@@ -233,59 +263,20 @@ most interesting engineering in the repo.
the number of SQL clauses is bounded by the context window rather than by the number of
forks.
## Visit analytics
The hosted demo keeps its own analytics: an owner-only dashboard at `/analytics` shows
traffic, which shared scenarios get played, turns and demo-key spend, errors, and a funnel
from *visited* to *played a turn* to *signed up*. It is visible only to the emails listed in
`AIDND_ANALYTICS_EMAILS`, and the route returns 404 for everyone else.
This is built into the app rather than added with a third-party script, for reasons specific
to this project: the CSP allows only `script-src 'self'`, ad blockers block the popular
trackers, and none of those trackers can see the measurement that matters here, a turn. Counts
are aggregated in memory and flushed as UPSERTs, so a visit is a write and never a read, and
every dashboard query is a `GROUP BY` that returns tens of rows regardless of traffic volume.
That matters: see the egress note above for what reading rows per request costs on this stack.
## Deploy (Render)
The repo ships a [`render.yaml`](render.yaml) blueprint: one Docker web service that serves
the SPA and API same-origin, backed by external [Neon](https://neon.tech) Postgres. The free
Render tier has no persistent disk, so the database lives off-box.
1. Create a **Neon** project and copy its pooled connection string.
2. In Render, choose **New → Blueprint** and point it at this repo. Render reads
`render.yaml`.
3. Fill in the secrets it prompts for (`sync: false` vars): `AIDND_DATABASE_URL` (the Neon
string); `AIDND_DEMO_API_KEY` and `AIDND_DEMO_MODELS` to offer a no-signup demo; and
`AIDND_ANALYTICS_EMAILS` (your own account's email) to see the Visitors dashboard.
`AIDND_SECRET_KEY` is generated automatically and stays stable across deploys.
4. Deploy. Pushes to `main` auto-deploy after this. The health check is `/api/health`.
On the free tier the service sleeps after about 15 minutes idle, and the first request after
that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/api/health`,
which deliberately doesn't touch the database: waking the database around the clock costs far
more than the cold start saves.
If you put another proxy or CDN in front of Render, set `AIDND_TRUSTED_PROXY_HOPS` to the
number of proxies in the chain. It defaults to 1. The rate limiter reads the client IP that
many entries from the right of `X-Forwarded-For`, because the trusted edge appends the real
one last. Leave it at 1 behind two proxies and the limiter reads an entry the caller
supplied, so anyone can rotate the header for a fresh rate-limit bucket per request.
## Repo notes
- `plan/` holds the phased implementation plan this project was built from, kept as a build
log. All fourteen phases are complete. The later files (11, 12, 14) also serve as design
notes for the state-revert, world-state, and story-tree work.
[`plan/STATUS.md`](plan/STATUS.md) is the running thread: what shipped, what was measured,
and what is owed next.
- [`docs/GUIDE.md`](docs/GUIDE.md) holds design notes: how each subsystem works and why it was
built that way, with the measurements behind the decisions. It is also rendered as a
[reading page](https://parththakkar106.github.io/AI-DnD/guide.html).
- `backend/.env.example` lists the few environment variables the backend reads.
- [`docs/self-review.md`](docs/self-review.md) records a full-codebase self-review pass and
what came out of it. All correctness findings are resolved.
- `planning/` is this fork's own package: the product specification, the architecture
decisions, the milestone plan, the acceptance contract, and a review report for every
milestone shipped. Start at [`planning/README.md`](planning/README.md).
- [`planning/archive/`](planning/archive/README.md) holds the Phase 0 research that chose this
base and the completed milestone reports. It is history, not instruction.
- [`DEVELOPMENT.md`](DEVELOPMENT.md) is how to set the project up, point it at a model, and run
the tests. [`PROVENANCE.md`](PROVENANCE.md) records what came from upstream and what changed.
- `backend/.env.example` lists the two environment variables the backend reads. Everything
about the model is a runtime setting on the Settings page instead.
- Upstream's own `plan/` build log and `docs/` project site were removed in the 2026-09-03
documentation pass: they described AI-DnD's hosted, scripted, multi-user product. Both are
still in Git history, and in upstream.
## License
+14 -100
View File
@@ -1,110 +1,24 @@
# Environment variables read by the backend.
#
# NOTE: the app reads real environment variables — it does NOT auto-load this
# file. Set them in your shell, in docker-compose.yml, or in your host's
# dashboard. This file is documentation (and a template for deploy configs).
# file. Set them in your shell or in docker-compose.yml. This file is
# documentation.
#
# There are two, and neither is required. Everything about the model — the
# endpoint, the model names, the timeout, the context budget — is a runtime
# setting stored in the database and edited on the Settings page, because it is
# a preference rather than a deployment detail.
# Absolute path for the SQLite database file. Parent directory is created if
# missing. Default when unset: backend/data.db
# Docker compose sets this to /data/data.db (a named volume).
# Absolute path for the SQLite database file. The parent directory is created
# if missing. Default when unset: backend/data.db
# docker-compose.yml sets this to /data/data.db (a named volume).
AIDND_DB_PATH=
# ---------------------------------------------------------------------------
# Phase 9 — production hardening
# ---------------------------------------------------------------------------
# Switch from SQLite to a server database (hosted deploys use Neon Postgres).
# Any SQLAlchemy URL; postgres:// and postgresql:// schemes are rewritten to
# the psycopg3 driver automatically. The platform-conventional DATABASE_URL
# is honored too (AIDND_DATABASE_URL wins if both are set). Unset = SQLite.
AIDND_DATABASE_URL=
# Comma-separated list of allowed CORS origins. Only needed when the frontend
# is served from a different origin than the API; the production build is
# served same-origin by FastAPI, so hosted deploys can leave this unset.
# served same-origin by FastAPI, so a normal run can leave this unset.
# Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server).
#
# A wildcard is rejected. The storyteller API is unauthenticated by design and
# bound to loopback; letting any origin call it would undo that.
AIDND_CORS_ORIGINS=
# How many proxy hops the rate limiter trusts in `X-Forwarded-For`. It reads
# the entry that many places from the right, because the trusted edge appends
# the real client IP last. Set this to the number of proxies in front of the
# app. Default: 1, which is correct for a single edge such as Render.
#
# Get it wrong in either direction and the rate limits weaken. Too low reads an
# entry the caller supplied, so anyone can rotate the header for a fresh
# rate-limit bucket per request and walk past the auth and guest limits. Too
# high reads past the real client. Only multi-user mode rate-limits at all, so
# local installs can ignore this.
AIDND_TRUSTED_PROXY_HOPS=
# ---------------------------------------------------------------------------
# Phase 8 — optional accounts & multi-user (all optional; defaults keep the
# app in frictionless single-user "local mode")
# ---------------------------------------------------------------------------
# "1"/"true" turns on multi-user mode: guest sessions via signed cookies,
# register/login UI, per-user data. Leave unset for local installs.
AIDND_MULTI_USER=
# Secret for signing session cookies and encrypting stored API keys at rest.
# If unset in local mode, one is auto-generated into `secret.key` next to the
# database (fine for local/docker-volume runs). REQUIRED when
# AIDND_MULTI_USER is on — the app refuses to start without it, because a
# regenerated secret on an ephemeral hosted filesystem would log out every
# user on each deploy. Generate one:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
AIDND_SECRET_KEY=
# Session cookie Secure flag (HTTPS-only). Defaults to on when
# AIDND_MULTI_USER is on, off otherwise — set 0/1 only to override (e.g. 0
# when testing multi-user mode over plain http on a LAN address).
AIDND_COOKIE_SECURE=
# --- Shared demo key (BYOK fallback; only active when AIDND_MULTI_USER=1) ---
# Users with no API key of their own get this server-funded endpoint with a
# model whitelist and a per-day turn cap. Unset = no demo, users must bring
# their own key. Memory bank/auto-summarization are disabled on demo turns.
AIDND_DEMO_API_KEY=
# Default endpoint if unset: https://openrouter.ai/api/v1
AIDND_DEMO_ENDPOINT_URL=
# Comma-separated model whitelist. Default: google/gemma-4-26b-a4b-it:free
AIDND_DEMO_MODELS=
# Successful AI turns per user per day on the demo key. Default: 20
AIDND_DEMO_TURNS_PER_DAY=
# Comma-separated emails of "power users" (trusted testers) who bypass the daily
# demo cap entirely — unmetered turns on the shared demo key — and get the AI Chat
# page (a plain scratchpad for talking to a model, hidden from everyone else).
# Registered accounts only (guests have no email). Matched case-insensitively.
# Local (single-user) installs are always treated as power users.
AIDND_POWER_USERS=
# --- Visit analytics ---
# Comma-separated emails allowed to see the Visitors dashboard (/analytics) and
# its nav link. Deliberately separate from AIDND_POWER_USERS: a trusted tester
# gets unmetered turns, which is no reason to hand them the traffic numbers.
# Unset = nobody sees it in a hosted deploy. Local installs always can, and are
# the only mode where the viewer's own visits are still counted (excluding them
# would leave the page permanently empty on the machine it's developed on).
# Collection itself is always on; only the dashboard is gated.
AIDND_ANALYTICS_EMAILS=
# Days to keep the one-row-per-visitor-per-day table that makes the funnel
# count people rather than clicks. The daily counters are aggregate and kept
# forever. Default: 400. Set 0 to keep visitor-days forever.
AIDND_ANALYTICS_RETENTION_DAYS=
# --- Guest retention (only active when AIDND_MULTI_USER=1) ---
# Every first visit mints a guest account, so a public demo collects one row
# per visitor. A guest with no activity for this many days is deleted along
# with its scenarios, adventures and actions. Registered accounts are never
# touched. Default: 5. Set 0 to keep guests forever.
AIDND_GUEST_RETENTION_DAYS=
# How often a running process re-checks. The sweep also runs once at startup,
# which is what actually fires on hosts that sleep. Default: 6
AIDND_CLEANUP_INTERVAL_HOURS=
# The AI endpoint/API key/model are NOT env vars — they are configured at
# runtime in the app's Settings page and stored (encrypted) in the database.
#
# Rate limits, request size limits, and per-user row caps are hardcoded with
# generous values (see backend/app/limits.py) and active only in multi-user
# mode — local installs are never throttled.
-168
View File
@@ -1,168 +0,0 @@
"""The access log: who arrived, when, and from where.
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. 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, 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 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
import threading
from sqlalchemy import desc, or_, select
from sqlalchemy.orm import Session
from . import analytics, models
logger = logging.getLogger(__name__)
SESSION = "session"
LOGIN = "login"
REGISTER = "register"
LOGIN_FAILED = "login_failed"
MAX_UA = 200
# user id -> (day, ip) of the last session row written for them. Process-local
# like the rate limiter's windows, and for the same reason: this is a single
# process, and the worst case after a restart is one redundant row per user.
_last_session: dict[int, tuple[str, str]] = {}
_guard = threading.Lock()
_MAX_TRACKED = 10_000
def _client_ip(request) -> str:
# 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:
"""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:
"""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
def record(
db: Session,
kind: str,
request,
*,
user: models.User | None = None,
who: str | None = None,
) -> None:
"""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,
user_id=user.id if user is not None else None,
who=(who if who is not None else describe(user) if user else "")[:320],
is_guest=bool(user.is_guest) if user is not None else False,
ip=_client_ip(request)[:45],
country=_country(request),
device=analytics.device_of(request.headers.get("user-agent", "")),
user_agent=(request.headers.get("user-agent") or "")[:MAX_UA],
)
db.add(event)
db.commit()
except Exception: # pragma: no cover - defensive
db.rollback()
logger.exception("Access log write failed; continuing.")
def note_session(db: Session, user: models.User, request) -> None:
"""Records that a session made a request, at most one row per day per address."""
try:
today = analytics._today()
ip = _client_ip(request)
with _guard:
if _last_session.get(user.id) == (today, ip):
return
_last_session[user.id] = (today, ip)
if len(_last_session) > _MAX_TRACKED:
# 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
logger.exception("Access log session check failed; continuing.")
return
record(db, SESSION, request, user=user)
def recent(
db: Session,
*,
limit: int = 50,
before_id: int | None = None,
kind: str | None = None,
query: str | None = None,
) -> dict:
"""Returns a page of the log, newest first.
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:
statement = statement.where(models.AccessEvent.id < before_id)
if kind:
statement = statement.where(models.AccessEvent.kind == kind)
if query:
like = f"%{query.strip()}%"
statement = statement.where(or_(
models.AccessEvent.who.ilike(like),
models.AccessEvent.ip.ilike(like),
models.AccessEvent.country.ilike(like),
))
# 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}
-583
View File
@@ -1,583 +0,0 @@
"""Visit analytics for the hosted demo.
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 shape the design:
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` 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
import logging
import os
import re
import threading
from datetime import timedelta
from hashlib import sha256
from urllib.parse import urlsplit
from sqlalchemy import case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session
from . import models, security
logger = logging.getLogger(__name__)
# ---------- Metrics ----------
# `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 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_TURN_ERROR = "turn_error"
EV_SIGNUP = "signup"
EV_LOGIN = "login"
# 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",
EV_TURN: "played",
EV_SIGNUP: "signed_up",
}
OTHER = "(other)"
NONE_LABEL = "(direct)"
UNKNOWN = "(unknown)"
# ---------- Bounds ----------
# 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 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.
FLUSH_INTERVAL_SECONDS = 60
# 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 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 ----------
# 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.
_guard = threading.Lock()
def _today() -> str:
return models.utcnow().date().isoformat()
def record(metric: str, label: str = "", *, n: int = 1) -> None:
"""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]
with _guard:
seen = _labels_seen.setdefault((day, metric), set())
if label not in seen:
if len(seen) >= MAX_LABELS_PER_METRIC:
label = OTHER
else:
seen.add(label)
key = (day, metric, label)
_counts[key] = _counts.get(key, 0) + n
pending = len(_counts) + len(_visits)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics counter failed; continuing.")
return
if pending >= MAX_PENDING:
flush()
def visitor_id(user: models.User) -> str:
"""Returns a stable, one-way handle for one visitor.
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:
"""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:
with _guard:
flags = _visits.setdefault((_today(), visitor_id(user)), set())
if flag:
flags.add(flag)
except Exception: # pragma: no cover - defensive
logger.exception("Analytics visit failed; continuing.")
def record_event(name: str, user: models.User | None = None) -> None:
"""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))
# ---------- Normalizing what the browser reports ----------
def normalize_route(path: str) -> str:
"""Reduces a client-reported path to one of `KNOWN_ROUTES`.
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("/"):
path = "/" + path
if len(path) > 1:
path = path.rstrip("/")
parts = [":id" if _NUMERIC_SEGMENT.match(p) else p for p in path.split("/")]
route = "/".join(parts) or "/"
return route if route in KNOWN_ROUTES else OTHER
def normalize_referrer(referrer: str, own_host: str = "") -> str:
"""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(".")
if not host or not _HOST_OK.match(host) or len(host) > MAX_LABEL_LEN:
return OTHER
if host == (own_host or "").lower() or host in ("localhost", "127.0.0.1"):
return ""
return host[4:] if host.startswith("www.") else host
def api_route_label(scope: dict, status: int) -> str:
"""Returns an error bucket such as "500 /api/adventures/{adventure_id}".
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:
"""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
if any(bot in ua for bot in ("bot", "crawler", "spider", "headless", "preview")):
return "bot"
if "ipad" in ua or "tablet" in ua or ("android" in ua and "mobile" not in ua):
return "tablet"
if any(m in ua for m in ("mobi", "iphone", "ipod", "android", "phone")):
return "mobile"
return "desktop"
# 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")
def country_of(headers) -> str:
for name in _GEO_HEADERS:
value = (headers.get(name) or "").strip().upper()
if _COUNTRY_OK.match(value) and value != "XX":
return value
return UNKNOWN
# ---------- Flushing ----------
def _insert(db: Session):
return sqlite_insert if db.get_bind().dialect.name == "sqlite" else pg_insert
def _drain() -> tuple[dict, dict]:
with _guard:
counts, visits = _counts.copy(), _visits.copy()
_counts.clear()
_visits.clear()
# 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]
return counts, visits
def _restore(counts: dict, visits: dict) -> None:
"""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
for key, flags in visits.items():
_visits.setdefault(key, set()).update(flags)
def flush(db: Session | None = None) -> None:
"""Writes the buffer out. This is safe to call from anywhere and never raises."""
counts, visits = _drain()
if not counts and not visits:
return
own_session = db is None
if own_session:
from .database import SessionLocal
db = SessionLocal()
try:
_write_counts(db, counts)
_write_visits(db, visits)
db.commit()
except Exception:
db.rollback()
_restore(counts, visits)
logger.exception("Analytics flush failed; counts held for the next one.")
finally:
if own_session:
db.close()
def _write_counts(db: Session, counts: dict) -> None:
if not counts:
return
table = models.AnalyticsDaily.__table__
rows = [
{"day": day, "metric": metric, "label": label, "hits": hits}
for (day, metric, label), hits in counts.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "metric", "label"],
set_={"hits": table.c.hits + stmt.excluded.hits},
))
def _write_visits(db: Session, visits: dict) -> None:
if not visits:
return
table = models.AnalyticsVisitorDay.__table__
ids = {visitor for _, visitor in visits}
# 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))
.distinct()
))
rows = [
{
"day": day,
"visitor": visitor,
"is_new": visitor not in known,
**{column: column in flags for column in FUNNEL_FLAGS.values()},
}
for (day, visitor), flags in visits.items()
]
stmt = _insert(db)(table).values(rows)
db.execute(stmt.on_conflict_do_update(
index_elements=["day", "visitor"],
# 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()
},
))
def purge_old_visitor_days(db: Session) -> int:
"""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()
removed = db.query(models.AnalyticsVisitorDay).filter(
models.AnalyticsVisitorDay.day < cutoff
).delete(synchronize_session=False)
db.commit()
return removed or 0
# ---------- Reading it back ----------
# 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
def _top(rows: list[dict], limit: int = TOP_N) -> list[dict]:
return rows[:limit]
def summary(db: Session, days: int = 30) -> dict:
"""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, 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))
.where(daily.day >= since)
.group_by(daily.metric, daily.label)
):
by_metric.setdefault(metric, []).append({"label": label, "hits": int(hits)})
for rows in by_metric.values():
rows.sort(key=lambda row: -row["hits"])
events = {row["label"]: row["hits"] for row in by_metric.get(M_EVENT, [])}
# 2. The two per-day series the dashboard draws.
pageviews_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_PAGE)
.group_by(daily.day)
)
}
turns_by_day = {
day: int(hits)
for day, hits in db.execute(
select(daily.day, func.sum(daily.hits))
.where(daily.day >= since, daily.metric == M_EVENT, daily.label == EV_TURN)
.group_by(daily.day)
)
}
# 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(
visitor.day,
func.count(),
func.sum(case((visitor.is_new, 1), else_=0)),
)
.where(visitor.day >= since)
.group_by(visitor.day)
):
visitors_by_day[day] = {"visitors": int(total), "new": int(fresh or 0)}
# 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 did not reach that step.
unique, unique_new, *reached = db.execute(
select(
func.count(func.distinct(visitor.visitor)),
func.count(func.distinct(case((visitor.is_new, visitor.visitor)))),
*[
func.count(func.distinct(case((visitor.__table__.c[column], visitor.visitor))))
for column in FUNNEL_FLAGS.values()
],
).where(visitor.day >= since)
).one()
series = []
for offset in range(days):
day = (today - timedelta(days=days - 1 - offset)).isoformat()
counted = visitors_by_day.get(day, {})
series.append({
"day": day,
"visitors": counted.get("visitors", 0),
"new": counted.get("new", 0),
"pageviews": pageviews_by_day.get(day, 0),
"turns": turns_by_day.get(day, 0),
})
visits = sum(row["visitors"] for row in series)
pageviews = sum(pageviews_by_day.values())
turns = events.get(EV_TURN, 0)
errors = by_metric.get(M_ERROR, [])
return {
"days": days,
"since": since,
"until": today.isoformat(),
"generated_at": models.utcnow().isoformat(),
"totals": {
# `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,
"pageviews": pageviews,
"turns": turns,
"demo_turns": events.get(EV_DEMO_TURN, 0),
"adventures": events.get(EV_ADVENTURE, 0),
"signups": events.get(EV_SIGNUP, 0),
"logins": events.get(EV_LOGIN, 0),
"turn_errors": events.get(EV_TURN_ERROR, 0),
"errors": sum(row["hits"] for row in errors),
"turns_per_visit": round(turns / visits, 1) if visits else 0,
"pages_per_visit": round(pageviews / visits, 1) if visits else 0,
},
"series": series,
# 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(
["Opened a scenario", "Started an adventure", "Played a turn", "Signed up"],
reached,
)
],
"pages": _top(by_metric.get(M_PAGE, [])),
"referrers": _top(by_metric.get(M_REFERRER, [])),
"countries": _top(by_metric.get(M_COUNTRY, [])),
"devices": by_metric.get(M_DEVICE, []),
"scenarios": _top(by_metric.get(M_SCENARIO, [])),
"errors": _top(errors),
"events": by_metric.get(M_EVENT, []),
}
# ---------- Background flusher ----------
# 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
from starlette.concurrency import run_in_threadpool
while True:
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
# This is blocking database work, so keep it off the event loop, which
# is also serving SSE turn streams.
await run_in_threadpool(flush)
def start_flusher():
import asyncio
return asyncio.create_task(_flush_loop())
async def stop_flusher(task) -> None:
"""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
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await run_in_threadpool(flush)
+53 -12
View File
@@ -38,14 +38,15 @@ from sqlalchemy.orm import Session, undefer
from . import models
from .context import lineage
from .narrative import model as narrative_model
# 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
# did with it, the model's literal reply, and the endpoint's
# token accounting. Each attempt is its own API call, and a retry is the call
# most likely to read the prompt back out of cache. Everything else in a snapshot
# is the prompt, which is assembled once per turn.
ATTEMPT_KEYS = ("world_state", "script", "raw_output", "usage")
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
# ------------------------------------------------------------------ reading
@@ -152,27 +153,67 @@ def preceding(
# ------------------------------------------------------------------ writing
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
"""Restores the script state and world state that `node` left behind.
"""Restores the state that `node` left behind.
A NULL snapshot means leave the live state as it is, never reset it. Rows
written before SP4 that the migration could not derive an outcome for carry
NULLs, and overwriting a running adventure's state with an empty dict would
be worse than doing nothing.
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
the same at any distance: the destination node carries its own outcome, so
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
M5 changed what is restored, not how — the narrative state document takes
the place the RPG world state held, through the same single function.
The two columns follow **different** rules about a NULL, and the difference
is not an oversight.
For the narrative document, a NULL means *this position established
nothing*, and it is restored as the empty document. Leaving the live state
alone instead is what the M5 review caught (Finding 3): arriving at a
migrated pre-M5 node left a later position's entities, facts and threads
standing, so the transcript said depth 2 while the state described depth 6.
The invariant this module exists to hold is that the visible position, the
head and the authoritative state agree, and "keep whatever was there" cannot
hold it. An empty document at an old position is honest — the narrative
state system knew nothing then, because it did not exist — where retained
state from elsewhere is a claim about a story that had not been told yet.
Migration backfills those rows explicitly, so this fallback is the belt to
that pair of braces: it also covers a node arriving from an older export,
which the migration never sees.
For the legacy RPG world state a NULL still means leave it alone. Those rows
predate SP4, nothing consults the values to decide anything, and overwriting
a running adventure's numbers with an empty dict would be worse than doing
nothing.
"""
if node is None:
return
if isinstance(node.state_after, dict):
adventure.script_state = copy.deepcopy(node.state_after)
adventure.narrative_state = (
copy.deepcopy(node.narrative_state_after)
if isinstance(node.narrative_state_after, dict)
else narrative_model.empty()
)
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
# coherent with the position being read, so an old save is not left showing
# a future's values. Nothing consults them to decide anything.
if isinstance(node.world_state_after, dict):
adventure.world_state = copy.deepcopy(node.world_state_after)
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
"""Records on `node` the state of the adventure now that the node has played."""
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
"""Records on `node` the state of the adventure now that the node has played.
Every node, including a player's action that changed nothing. A position
without a snapshot is a position the head cannot be restored to, and the
head can rest on any node.
"""
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
node.state_after = copy.deepcopy(state)
# `state_after` held the scripting engine's shared state, which M2 removed.
# The column stays for schema compatibility and is written empty.
node.state_after = {}
node.world_state_after = copy.deepcopy(world)
narrative = adventure.narrative_state
node.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def roll_back_before(
+34 -219
View File
@@ -1,216 +1,44 @@
"""Phase 8: user resolution, sessions, and the shared demo key.
"""Resolving the one local user. **There is no authentication in this product.**
The `AIDND_MULTI_USER` environment variable selects one of two modes:
The module keeps its name so the dependency every router already depends on
keeps working, but nothing here authenticates anybody. The Adventure
Storyteller is a single-user application that binds to loopback: whoever can
reach the API is the person who started it, and there is nobody else to tell
them apart from.
* 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`.
Upstream had two modes. `AIDND_MULTI_USER` selected a hosted deployment with
signed session cookies, guest accounts, registration, login, a shared demo API
key with a per-day cap, "power users", and an owner allowlist for the analytics
dashboard. M2 removed all of it: this product has no hosted mode to protect, and
every one of those surfaces was a way for the application to be reached by
someone other than its owner.
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.
What is left is the local path that upstream already had. Every request
resolves to one automatically created user row.
The `users` table and the `user_id` foreign keys on scenarios, adventures and
settings stay. They are an **internal ownership detail**, not an account
system: nothing creates a second user, nothing logs in, and no request carries
an identity. They remain because rewriting them out would mean a migration
across most of the schema to delete a column that costs nothing and keeps every
existing M1 database readable.
"""
import os
from dataclasses import dataclass
from datetime import timezone
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from . import models, security
from . import models
from .database import get_db
def _env_flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
MULTI_USER = _env_flag("AIDND_MULTI_USER")
SESSION_COOKIE = "aidnd_session"
# 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")
if _cookie_secure_env
else MULTI_USER
)
COOKIE_MAX_AGE = 60 * 60 * 24 * 365
# ---------- Shared demo key (BYOK fallback) ----------
DEMO_API_KEY = os.environ.get("AIDND_DEMO_API_KEY", "").strip()
DEMO_ENDPOINT_URL = (
os.environ.get("AIDND_DEMO_ENDPOINT_URL", "").strip()
or "https://openrouter.ai/api/v1"
)
DEMO_MODELS = [
m.strip()
for m in os.environ.get("AIDND_DEMO_MODELS", "").split(",")
if m.strip()
] 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, 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. 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(",")
if e.strip()
}
DEMO_CAP_MESSAGE = (
f"You've used all {DEMO_TURNS_PER_DAY} free demo turns for today. "
"Add your own API key in Settings to keep playing (it resets tomorrow)."
)
def demo_enabled() -> bool:
# 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 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
model: str
using_demo: bool
def __post_init__(self) -> None:
# 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 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. 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}"
)
def resolve_provider_config(
settings: models.Settings, *, model_override: str | None = None
) -> ProviderConfig:
"""Returns the user's own key when they have one, and the shared demo key
otherwise.
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 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 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
if key or not demo_enabled():
return ProviderConfig(settings.endpoint_url, key, requested, False)
model = requested if requested in DEMO_MODELS else DEMO_MODELS[0]
return ProviderConfig(DEMO_ENDPOINT_URL, DEMO_API_KEY, model, True)
def _today() -> str:
return models.utcnow().date().isoformat()
def is_power_user(user: models.User) -> bool:
"""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:
"""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:
# 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
return max(0, DEMO_TURNS_PER_DAY - used)
def count_demo_turn(user: models.User) -> None:
"""Records one demo turn. The caller's commit stores it."""
if is_power_user(user):
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
user.demo_turns_used = 0
user.demo_turns_used += 1
# ---------- User resolution ----------
def local_user(db: Session) -> models.User:
"""Returns the single implicit user used in local mode.
"""Returns the single implicit user, creating it on first use.
A migration gives this user ownership of data written before Phase 8. On a
fresh database the user is created on first use.
A migration gives this user ownership of data written before per-user rows
existed, so an older database resolves to the row that already owns its
campaigns rather than to a fresh empty one.
"""
user = (
db.query(models.User)
@@ -237,27 +65,14 @@ def _touch(user: models.User, db: Session) -> None:
db.commit()
def resolve_session_user(request: Request, db: Session) -> models.User | None:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
user_id = security.verify_session(token)
if user_id is None:
return None
return db.get(models.User, user_id)
def get_current_user(
request: Request, db: Session = Depends(get_db)
) -> models.User:
"""The dependency every router uses. It always succeeds.
def get_current_user(request: Request, db: Session = Depends(get_db)) -> models.User:
"""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`.
`request` is unused and kept so the signature stays a FastAPI dependency
the routers can depend on unchanged.
"""
if not MULTI_USER:
user = local_user(db)
else:
user = resolve_session_user(request, db)
if user is None:
raise HTTPException(401, "No session. Call GET /api/auth/me first.")
user = local_user(db)
_touch(user, db)
return user
+273 -39
View File
@@ -32,8 +32,19 @@ the import recomputes them:
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
put a second source of truth for one fact into a file anyone can hand-edit,
and the two could then disagree without any read reporting it.
* The head depth is the tip of the head branch, which is a fact about the nodes
that arrived with it.
* The head depth used to be derived the same way — the tip of the head branch,
a fact about the nodes that arrived with it. M3 moved it to the other side of
the rule. Undo no longer deletes, so a story can be read at a position behind
its retained tip, and where the reader stopped is a decision nobody can
recompute from the rows: the same tree exports identically whether the user
undid three turns or none. `headDepth` is therefore written, and an import
that ignored it would silently Redo the story to its newest retained turn,
which is the Phase 0B export finding this milestone exists to close.
A bundle written before M3 has no `headDepth` key, and there is nothing lost in
that: at the time it was written the head could not sit behind the tip, so the
derived answer *was* the recorded one. Such a file imports by deriving, exactly
as it always did.
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
@@ -49,6 +60,7 @@ from sqlalchemy.orm import Session, undefer
from . import attempts, models, schemas
from .context import cursors, lineage
from .narrative import model as narrative_model
FORMAT = "ai-dnd-adventure-v2"
LEGACY_FORMAT = "ai-dnd-adventure-v1"
@@ -109,8 +121,15 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"pronouns": adventure.persona_pronouns,
"desc": adventure.persona_desc,
},
"scriptState": adventure.script_state,
"worldState": adventure.world_state,
# M5. The authoritative narrative state, and the campaign's own rules.
# Both are decisions rather than derivations — the state is what the
# campaign established, and canon is what its owner wrote — so both go
# in the file by the rule at the top of this module. A bundle written
# before M5 has neither key and imports with an empty state, which is
# what such a campaign had.
"narrativeState": adventure.narrative_state,
"campaignCanon": adventure.campaign_canon,
"autoSummarize": adventure.auto_summarize,
"memoryBankEnabled": adventure.memory_bank_enabled,
# Write a root entry even for an adventure whose branch row was never
@@ -118,23 +137,27 @@ def export(db: Session, adventure: models.Adventure) -> dict:
# 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),
# M3. Where the story is being read, which is not always where it
# ends. See the head-depth rule at the top of this module.
"headDepth": adventure.head_depth,
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
"memories": [_exported_memory(m, local) for m in adventure.memories],
# M4. A Save Point is a decision — someone chose this position and gave
# it a name — so it goes in the file by the rule at the top of this
# module. Nothing here is derived: the coordinate is the one stored, not
# one recomputed from the rows, because the whole point of the pointer
# is that no amount of reading the turns can tell you which one somebody
# named. A bundle written before M4 has no key here and imports with no
# Save Points, which is what such a campaign had.
"checkpoints": [
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys,
"entry": c.entry, "notes": c.notes}
for c in adventure.story_cards
],
"scripts": [
{
"position": s.position, "enabled": s.enabled,
"name": s.name, "description": s.description,
"library": s.library_js, "input": s.input_js,
"context": s.context_js, "output": s.output_js,
}
for s in adventure.scripts
],
"actions": [_exported_node(a, local) for a in nodes],
}
@@ -161,6 +184,15 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
# unnamed tree byte-identical to the one SP6 wrote.
if branch.name:
out["name"] = branch.name
# M3. A branch the story left behind is a decision too — the depth a
# divergent write departed at — so it goes in the file by the same rule that
# puts the fork points in it. A restored backup that could not tell abandoned
# history from active history would have lost the only thing distinguishing
# them, since every row of both arrives either way. Omitted when the branch
# is active, which keeps a file for a never-undone tree as it was.
if branch.superseded_at is not None:
out["supersededAt"] = branch.superseded_at.isoformat()
out["supersededDepth"] = branch.superseded_depth
return out
@@ -184,6 +216,14 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
node["stateAfter"] = action.state_after
if action.world_state_after is not None:
node["worldStateAfter"] = action.world_state_after
# M5. Without this a restored campaign could be read but not moved around
# inside: every Undo, Redo and Save Point restore reads the destination
# node's snapshot, so a bundle carrying the turns and not the snapshots
# imports a story whose history cannot be walked.
if action.narrative_state_after is not None:
node["narrativeStateAfter"] = action.narrative_state_after
if action.state_changes:
node["stateChanges"] = action.state_changes
if action.world_delta:
node["worldDelta"] = action.world_delta
return node
@@ -202,6 +242,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
}
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
"""Returns the campaign's Save Points in creation order.
Read with a query rather than through a relationship, for the reason
`models.Branch` declares none: a relationship on `Adventure` would be loaded
by anything that touches an adventure, and the export is the only thing in
the application that wants every Save Point at once.
"""
return (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.id)
.all()
)
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
return {
"name": checkpoint.name,
"note": checkpoint.note,
# The branch as a position in this file's list, like every other branch
# reference in the bundle. The depth is a coordinate along it and needs
# no translation.
"branch": _local(checkpoint.branch_id, local),
"depth": checkpoint.depth,
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
}
def _imported_persona(persona) -> dict:
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
@@ -262,11 +331,23 @@ def plan(bundle: dict, version: str) -> dict:
_planned_nodes(bundle, len(branches)) if version == FORMAT
else _planned_v1_nodes(bundle)
)
head = _as_index(bundle.get("headBranch"), len(branches), default=0)
return {
"branches": branches,
"nodes": nodes,
"memories": _planned_memories(bundle, len(branches)),
"head": _as_index(bundle.get("headBranch"), len(branches), default=0),
# M4. Empty for a version 1 bundle and for any version 2 bundle written
# before Save Points existed, which is the same answer: no one had named
# a position in those campaigns.
"checkpoints": (
_planned_checkpoints(bundle, len(branches), nodes)
if version == FORMAT else []
),
"head": head,
# None means the file does not say, which is every version 1 bundle and
# every version 2 bundle written before M3. `_point_the_head` derives it
# then, which is what those files were written expecting.
"headDepth": _planned_head_depth(bundle, branches, nodes, head),
# 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`.
@@ -278,6 +359,58 @@ def plan(bundle: dict, version: str) -> dict:
}
def _derived_tip(branches: list[dict], nodes: list[dict], head: int) -> int:
"""Returns where the head branch's story ends, which is where a file that
does not state a head depth is opened.
This is the rule `tree.refresh_head` applies and the one every bundle
written before M3 was exported under: the deepest node that arrived on the
head branch, or, for a branch that carries none of its own, the fork point
it inherits its story up to.
"""
depths = [n["depth"] for n in nodes if n["branch"] == head]
if depths:
return max(depths)
fork_depth = branches[head].get("forkDepth")
return fork_depth if fork_depth is not None else lineage.NO_DEPTH
def _planned_head_depth(
bundle: dict, branches: list[dict], nodes: list[dict], head: int
) -> int | None:
"""Returns the active head depth the file states, or None if it states none.
Checked here rather than repaired later, for the reason the module docstring
gives: a coordinate that disagrees with the rows is not a value any read can
be given, and an import that discovers it afterwards has already written
half a tree.
Two bounds. A head past the retained tip is a file claiming the story is
read somewhere it does not reach — the shape a truncated or hand-edited
export takes, and the one that would silently move the reader forward.
Below, `NO_DEPTH` is the floor, because an adventure with no story at all
sits one step in front of its first node.
A head *behind* the tip is not an error. It is the whole point: it is a
story the user undid and did not redo, and it must import undone.
"""
stated = bundle.get("headDepth")
if stated is None:
return None
tip = _derived_tip(branches, nodes, head)
if not _is_int(stated) or stated < lineage.NO_DEPTH:
raise HTTPException(
400, f"The file gives the active head depth as {stated!r}."
)
if stated > tip:
raise HTTPException(
400,
f"The file reads its story at depth {stated}, but branch {head} "
f"ends at {tip}.",
)
return stated
def _planned_branches(bundle: dict) -> list[dict]:
raw = bundle.get("branches")
entries = [b for b in raw if isinstance(b, dict)] if isinstance(raw, list) else []
@@ -287,8 +420,9 @@ def _planned_branches(bundle: dict) -> list[dict]:
for i, entry in enumerate(entries):
parent = entry.get("parent")
name = _planned_branch_name(entry, i)
left = _planned_supersession(entry)
if parent is None:
specs.append(dict(_ROOT, **({"name": name} if name else {})))
specs.append(dict(_ROOT, **({"name": name} if name else {}), **left))
continue
# A branch may fork only from a branch listed before it. The export
# writes them that way, because branches are numbered in creation order
@@ -312,10 +446,27 @@ def _planned_branches(bundle: dict) -> list[dict]:
specs.append({
"parent": parent, "forkDepth": fork_depth,
**({"name": name} if name else {}),
**left,
})
return specs
def _planned_supersession(entry: dict) -> dict:
"""Returns the branch's disposition as the file gives it, or nothing.
Both keys or neither. A time with no depth cannot say what was displaced and
a depth with no time is not a record of anything having happened, so a file
carrying one of them is treated as carrying neither rather than half of a
fact — no read depends on these columns, and inventing the missing half
would be the only way to get a wrong answer out of them.
"""
at = _as_time(entry.get("supersededAt"))
depth = entry.get("supersededDepth")
if at is None or not _is_int(depth):
return {}
return {"supersededAt": at, "supersededDepth": depth}
def _planned_branch_name(entry: dict, i: int) -> str | None:
"""Returns the name a branch entry carries, or `None` if nobody named it.
@@ -361,6 +512,8 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
"branch": branch,
"depth": depth,
"live": bool(entry.get("live", True)),
"narrativeStateAfter": _as_dict(entry.get("narrativeStateAfter")),
"stateChanges": _as_dict(entry.get("stateChanges")),
"type": str(entry.get("type") or "story")[:TYPE_MAX],
"text": str(entry.get("text") or ""),
"reasoning": _as_text(entry.get("reasoning")),
@@ -437,6 +590,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
return out
def _planned_checkpoints(
bundle: dict, branches: int, nodes: list[dict]
) -> list[dict]:
"""Returns the file's Save Points, checked against the tree it also carries.
A Save Point whose coordinate names no turn in the file is dropped rather
than imported, and dropped rather than raising. The two halves of that are
each deliberate:
* Dropped, because an imported pointer to a position the imported story does
not contain is a Save Point that can only ever refuse to restore. It would
be a row that exists to disappoint.
* Not a 400, unlike the head depth. The head is a position the campaign is
read at, so a file that misplaces it opens the story in the wrong place
and every read is affected. A Save Point is a bookmark, and a bad one
spoils nothing else in the file — refusing to import a whole campaign
because one bookmark is wrong would lose the story to save the bookmark.
A name that is blank once trimmed is dropped for the same reason the create
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
"""
# Every coordinate the file writes, not only the ones it marks live.
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
# the file says, so a coordinate that exists is a coordinate that will
# resolve — and reading the flags here would drop a Save Point over a
# question the writer has already settled.
written = {(n["branch"], n["depth"]) for n in nodes}
raw = bundle.get("checkpoints")
out: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
if not name:
continue
depth = entry.get("depth")
if not _is_int(depth):
continue
branch = _as_index(entry.get("branch"), branches, default=None)
if branch is None or (branch, depth) not in written:
continue
out.append({
"name": name,
"note": str(entry.get("note") or ""),
"branch": branch,
"depth": depth,
"createdAt": _as_time(entry.get("createdAt")),
})
return out
def _planned_anchors(bundle: dict, branches: int) -> dict:
anchors = {}
for name in ("memory", "summary"):
@@ -462,6 +666,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
_write_nodes(db, adventure, story["nodes"], ids)
_write_memories(db, adventure, story["memories"], ids)
_point_the_head(adventure, story, ids)
_write_checkpoints(db, adventure, story["checkpoints"], ids)
_write_anchors(adventure, story, ids)
@@ -489,6 +694,8 @@ def _write_branches(
lineage=[],
name=spec.get("name"),
created_at=models.utcnow(),
superseded_at=spec.get("supersededAt"),
superseded_depth=spec.get("supersededDepth"),
)
).inserted_primary_key[0]
entries = [[new_id, None]]
@@ -530,6 +737,8 @@ def _write_nodes(
state_after=spec["stateAfter"],
world_state_after=spec["worldStateAfter"],
world_delta=spec["worldDelta"],
narrative_state_after=spec.get("narrativeStateAfter"),
state_changes=spec.get("stateChanges"),
)
if spec["createdAt"] is not None:
action.created_at = spec["createdAt"]
@@ -575,25 +784,53 @@ def _write_memories(
db.add(memory)
def _write_checkpoints(
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
) -> None:
"""Writes the Save Points, and moves nothing.
Note what this function does not touch. The head is pointed by
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
a Save Point must not disturb it — a campaign exported at turn 30 with a
Save Point at turn 12 opens at turn 30. The bundle records where the story
was being read and, separately, which positions someone named; restoring one
of them is a thing the user does afterwards, not a thing an import does for
them.
"""
for spec in specs:
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=spec["name"],
note=spec["note"],
branch_id=ids[spec["branch"]],
depth=spec["depth"],
)
if spec["createdAt"] is not None:
checkpoint.created_at = spec["createdAt"]
db.add(checkpoint)
def _point_the_head(
adventure: models.Adventure, story: dict, ids: list[int]
) -> None:
"""Sets which branch the story is played on, and how deep it goes.
"""Sets which branch the story is played on, and where it is being read.
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.
Both come from the file now (M3). A bundle that states its head depth is
opened exactly where its owner left it, undone turns and all, and the turns
past that point arrive as the retained future they were exported as.
A file that states none is opened at the tip of its head branch — whatever
arrived on it, or, for a branch carrying no nodes of its own, the fork point
that is the last node its story contains. That is the rule
`tree.refresh_head` applies and the only answer a pre-M3 bundle can be given,
because at the time it was written the head could not be anywhere else.
"""
head = story["head"]
adventure.head_branch_id = ids[head]
depths = [n["depth"] for n in story["nodes"] if n["branch"] == head]
if depths:
adventure.head_depth = max(depths)
if story["headDepth"] is not None:
adventure.head_depth = story["headDepth"]
return
fork_depth = story["branches"][head].get("forkDepth")
adventure.head_depth = fork_depth if fork_depth is not None else lineage.NO_DEPTH
adventure.head_depth = _derived_tip(story["branches"], story["nodes"], head)
def _write_anchors(
@@ -654,8 +891,14 @@ def materialize(
authors_note=str(payload.get("authorsNote") or ""),
ai_instructions=str(payload.get("aiInstructions") or ""),
story_summary=str(payload.get("storySummary") or ""),
script_state=payload.get("scriptState") or {},
world_state=payload.get("worldState") or {},
# M5. Normalised on the way in, so a hand-edited or truncated state
# section costs the section rather than the campaign — the story is the
# valuable thing, and a malformed document should not refuse an import.
narrative_state=narrative_model.normalize(payload.get("narrativeState"))
if isinstance(payload.get("narrativeState"), dict) else None,
campaign_canon=payload.get("campaignCanon")
if isinstance(payload.get("campaignCanon"), dict) else None,
auto_summarize=bool(payload.get("autoSummarize", False)),
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
**_imported_persona(payload.get("persona")),
@@ -674,19 +917,10 @@ def materialize(
notes=str(card.get("notes") or ""),
))
for i, item in enumerate(payload.get("scripts") or []):
if isinstance(item, dict):
db.add(models.AdventureScript(
adventure_id=adventure.id,
position=int(item.get("position", i)),
enabled=bool(item.get("enabled", True)),
name=str(item.get("name") or "Imported Script")[:schemas.NAME_MAX],
description=str(item.get("description") or ""),
library_js=str(item.get("library") or ""),
input_js=str(item.get("input") or ""),
context_js=str(item.get("context") or ""),
output_js=str(item.get("output") or ""),
))
# A bundle exported before M2 may carry "scripts" and "scriptState".
# Campaign scripting is gone, so both are ignored rather than rejected: the
# story, its tree, its cards and its memories still import intact, which is
# what the bundle is for.
write(db, adventure, story)
db.flush()
-178
View File
@@ -1,178 +0,0 @@
"""Retention policy for throwaway guest accounts.
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:
- Only rows with `is_guest` AND `email IS NULL` are ever touched, and both
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` 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.
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.
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
import logging
import os
from datetime import datetime, timedelta
from sqlalchemy import delete, func
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from . import analytics, auth, models
from .database import SessionLocal
logger = logging.getLogger(__name__)
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip() or default)
except ValueError:
logger.warning("%s is not an integer; using %d.", name, default)
return default
# 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
# time-critical, and on Render's free tier the service sleeps and cold-starts
# often enough that the startup sweep does most of the work by itself.
SWEEP_INTERVAL_SECONDS = _int_env("AIDND_CLEANUP_INTERVAL_HOURS", 6) * 3600
def enabled() -> bool:
"""Guests only exist in multi-user mode, so local runs skip the sweep
rather than pointing a DELETE at a database that has nothing to collect."""
return auth.MULTI_USER and RETENTION_DAYS > 0
def anything_to_sweep() -> bool:
"""Whether the periodic task is worth starting at all. The two jobs it runs
are independent: a deployment can keep every guest forever and still want
its analytics rows aged out, and vice versa."""
return enabled() or analytics.RETENTION_DAYS > 0
def delete_stale_guests(db: Session, *, now: datetime | None = None) -> int:
"""Delete guests idle for RETENTION_DAYS or more. Returns the row count.
The caller owns error handling; `sweep` is the safe wrapper.
"""
if RETENTION_DAYS <= 0:
return 0
# 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)
stmt = (
delete(models.User)
.where(
models.User.is_guest.is_(True),
models.User.email.is_(None),
func.coalesce(models.User.last_seen_at, models.User.created_at) < cutoff,
)
# 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
db.commit()
return removed
def sweep() -> int:
"""One pass, with its own session. Never raises: a failed cleanup must not
be able to take the app down (same rule as seeding). Returns the guest
count, which is the number worth logging about."""
if not anything_to_sweep():
return 0
db = SessionLocal()
try:
# Ages out the per-visitor analytics rows, on its own terms: it is not
# about guests, and it must still happen on a deployment that has
# chosen to keep every account it ever minted.
aged = analytics.purge_old_visitor_days(db)
if aged:
logger.info("Aged out %d analytics visitor-day row(s).", aged)
removed = delete_stale_guests(db) if enabled() else 0
if removed:
logger.info(
"Cleaned up %d guest account(s) idle for %d+ days.",
removed,
RETENTION_DAYS,
)
return removed
except Exception:
db.rollback()
logger.exception("Guest cleanup failed; continuing without it.")
return 0
finally:
db.close()
async def _sweep_loop() -> None:
while True:
# Blocking DB work: keep it off the event loop, which is also serving
# SSE turn streams.
await run_in_threadpool(sweep)
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
def start_sweeper() -> asyncio.Task | None:
"""Kick off the periodic sweep; None when there is nothing to sweep."""
if not enabled():
logger.info("Guest cleanup disabled (multi_user=%s, retention_days=%d).",
auth.MULTI_USER, RETENTION_DAYS)
else:
logger.info(
"Guest cleanup on: deleting guests idle %d+ days, every %d hour(s).",
RETENTION_DAYS,
SWEEP_INTERVAL_SECONDS // 3600,
)
if not anything_to_sweep():
return None
return asyncio.create_task(_sweep_loop())
async def stop_sweeper(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
+86 -61
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
import tiktoken
from .. import models, worldstate
from .. import models, narrative, worldstate
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
@@ -88,7 +88,7 @@ class Section:
return count_tokens(self.text)
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
def length_hint(max_output_tokens: int) -> str:
"""Ask for a turn that fits inside the output cap, stated as a word budget.
Returns an empty string when the cap is too small to state usefully. The
@@ -98,12 +98,8 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
if words < MIN_LENGTH_HINT_WORDS:
return ""
tail = (
" Finish the narration and append the state block well inside the limit."
if has_ws
else " Bring the turn to a close well inside the limit rather than "
"stopping mid-sentence."
)
tail = " Finish the narration and append the state block well inside the limit."
# 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
@@ -166,30 +162,58 @@ def _script_memory(adventure: models.Adventure) -> dict:
def _history_text(action: models.Action) -> str:
"""Returns an AI turn as the model should see it in replayed history.
The result is the narration with its state block appended again,
reconstructed from the stored delta. The app strips that block before
storing and displaying the turn. Without this function, every past AI turn
would appear to have emitted no state, and the model would copy that pattern
and stop emitting state itself. Player turns and turns with no block pass
through unchanged.
Replayed history is **prose only**. The protocol block is not reconstructed
into it, and the M5 corrective pass is why (review Finding 4).
The block replays the changes the engine ACCEPTED, not the ones the model
sent. Replaying what was sent showed the model a refused change standing as
though it had been applied, while the live values in the same prompt
disagreed with it. Nothing marked which of the two was true, so the model
read its own refused change as correct and sent it again.
Replaying the block was meant to teach the model the output format by
example. What it actually did was put a second, older account of the world
into the same prompt as the authoritative one, with nothing marking which
governed. A fact the reader had explicitly withdrawn through a manual
correction was dropped from the state section and then handed straight back
in the history section, as an accepted event, phrased exactly as the model
had first asserted it. C04 requires a correction to reach the narrator's
context; a correction the next prompt contradicts has not reached it.
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.
Two other things were wrong with it. The blocks are implementation
metadata, not story, and every other consumer of stored text — memory,
summaries, export, the transcript — treats an action's text as prose. And a
turn's accepted events are a record of what was true *then*, which is
precisely what a later correction, retcon or invalidation revises.
The format instruction survives without the examples: `EMIT_RULE` carries a
worked example in the system block and `EMIT_REMINDER` repeats the demand
last, where recency is strongest.
"""
text = action.text
wd = action.world_delta if isinstance(action.world_delta, dict) else None
if wd:
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
if block:
text = f"{text}\n{block}"
return text
return action.text
def _canon_section(adventure: models.Adventure) -> str:
"""The campaign's own rules, rendered for the system block.
Canon is configuration (C01, J03): the campaign writes what is true and what
is forbidden, and both the prompt and the validator read the same field.
Putting it in the system block is what makes C01 a narration-time constraint
as well as a validation-time one — the model is told the rule rather than
only refused after breaking it.
"""
canon = adventure.campaign_canon
if not isinstance(canon, dict):
return ""
lines: list[str] = []
rules = canon.get("rules")
if isinstance(rules, list):
lines += [f"- {rule}" for rule in rules if isinstance(rule, str) and rule.strip()]
forbidden = canon.get("forbidden_status_changes")
if isinstance(forbidden, list):
for rule in forbidden:
if isinstance(rule, dict) and rule.get("from") and rule.get("to"):
lines.append(
f"- Nothing that is {rule['from']} can become {rule['to']}."
)
if not lines:
return ""
body = "\n".join(lines)
return f"Campaign canon (these are true and may not be contradicted):\n{body}"
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
@@ -259,11 +283,14 @@ def build_context(
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
has_ws = worldstate.has_schema(stat_schema)
persona_name = adventure.persona_name.strip()
if has_ws:
guide = worldstate.render_reference(stat_schema, persona_name)
if guide:
system_sections.append(Section("world_state_guide", guide))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
# M5: the typed-event protocol replaces the delta rule for every campaign,
# with or without an inherited stat schema. State is no longer an opt-in
# RPG layer — a story has entities, places and possessions whatever genre it
# is, so the rule is unconditional.
system_sections.append(Section("state_rule", narrative.extract.EMIT_RULE))
canon_text = _canon_section(adventure)
if canon_text:
system_sections.append(Section("campaign_canon", canon_text))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
@@ -301,21 +328,20 @@ def build_context(
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
world_state_section = None
refusal_note = ""
if has_ws:
# One read serves both the in-scene NPCs and the refusal note below.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
persona_name,
)
if block:
world_state_section = Section("world_state", block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = worldstate.render_refusals(last_ai.world_delta)
# M5: the authoritative narrative state, as the model is shown it. Read from
# the campaign's live document, which head movement keeps pointed at the
# position being read — so an undone story is described by the state it had
# then, not by the state it reached later.
state_block = narrative.render.for_prompt(adventure.narrative_state)
if state_block:
world_state_section = Section("narrative_state", state_block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = narrative.extract.render_rejections(last_ai.state_rejections)
authors_note_text = adventure.authors_note.strip()
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
@@ -326,7 +352,7 @@ def build_context(
if isinstance(script_mem.get("frontMemory"), str):
front_memory = script_mem["frontMemory"].strip()
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
length_note = length_hint(settings.max_output_tokens)
# 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
@@ -341,7 +367,7 @@ def build_context(
+ count_tokens(authors_note)
+ count_tokens(front_memory)
+ count_tokens(length_note)
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
+ count_tokens(narrative.extract.EMIT_REMINDER)
+ count_tokens(refusal_note)
)
available = max(256, settings.context_token_budget - reserved)
@@ -386,7 +412,7 @@ def build_context(
for action in reversed(actions):
# Budget against the text as it appears in the prompt, which includes
# the state block when this adventure tracks world state.
rendered = _history_text(action) if has_ws else action.text
rendered = _history_text(action)
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
if spent + tokens > history_budget:
if not included_actions:
@@ -407,7 +433,7 @@ def build_context(
# ----- 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]
texts = [_history_text(a) for a in included_actions]
note_sections: list[Section] = []
if authors_note:
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
@@ -431,14 +457,13 @@ def build_context(
# applies to the block that follows it, so this is also the order in which
# the model acts.
note_sections.append(Section("length_hint", length_note))
if has_ws:
# A correction for the previous turn sits directly above the reminder
# to emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("world_state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
# A correction for the previous turn sits directly above the reminder to
# emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("state_reminder", narrative.extract.EMIT_REMINDER))
story_sections = [s for s in note_sections if s.text]
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
+55 -7
View File
@@ -78,7 +78,16 @@ class Path:
"""One story, expressed as a SQL clause and as a Python predicate.
The object holds the lineage entries newest first, plus the depth of the
tip. The tip is used only to estimate how much story each entry covers.
head. Every entry is read as capped at the head, which is what makes the
active head a position the whole application honours (M3).
Before M3 the head was always the deepest node, so the cap never bit and the
tip was used only to estimate how much story each entry covers. Undo now
moves the head backward without deleting anything, so a path can have live
nodes past its head, and those nodes are not part of the story being told.
Capping here is what hides them, and it hides them from every read at once:
the transcript, the context builder, `attempts.preceding`, and memory
retrieval all funnel through `path_of`.
"""
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
@@ -91,6 +100,40 @@ class Path:
def __len__(self) -> int:
return len(self.entries)
# ------------------------------------------------------------- the head
def _cap(self, max_depth: int | None) -> int | None:
"""Returns `max_depth` limited by the head, which no read may pass.
Three cases, and the third is the one M3 added:
* No head recorded (`tip is None`). The caller asked for the lineage
without a position, so the entry's own cap stands. `tree` builds such
a path when it resolves the node in front of a depth.
* An uncapped entry, which means "this branch through to its tip". The
head is the cap.
* A capped entry, which is an ancestor capped at the fork depth. The
head still wins when it sits behind that fork, because undoing below
a fork point is undoing into the shared prefix. Taking the smaller of
the two is what lets Undo walk back past a fork instead of stopping
there — safe now that it deletes nothing.
"""
if self.tip is None:
return max_depth
if max_depth is None:
return self.tip
return min(max_depth, self.tip)
def uncapped(self) -> "Path":
"""Returns the same lineage read through to its retained tip.
This is the retained history, head or no head: what Redo can still walk
forward into, and what a write below the head has to fork away from.
Only those two callers should use it. Every read of *the story* wants
the capped path.
"""
return Path(self.entries, None)
# ---------------------------------------------------------------- SQL
def clause(
@@ -125,7 +168,9 @@ class Path:
entries = self.entries if count is None else self.entries[:count]
if not entries:
return false()
on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries])
on_path = or_(
*[self._entry_clause(model, b, self._cap(d)) for b, d in entries]
)
if model is models.Action:
return and_(on_path, models.Action.live.is_(True))
return on_path
@@ -155,9 +200,10 @@ class Path:
for branch_id, max_depth in self.entries:
if node.branch_id != branch_id:
continue
if max_depth is None:
cap = self._cap(max_depth)
if cap is None:
return True
if node.depth is not None and node.depth <= max_depth:
if node.depth is not None and node.depth <= cap:
return True
return False
@@ -187,7 +233,7 @@ class Path:
return total
covered = 0
for i, (_, max_depth) in enumerate(self.entries):
top = self.tip if max_depth is None else max_depth
top = self._cap(max_depth)
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
if top is None or below is None:
# Either no tip was recorded, or a hand-written row is missing a
@@ -211,7 +257,8 @@ class Path:
story has forked.
"""
for i, (_, max_depth) in enumerate(self.entries):
if max_depth is not None and max_depth <= depth:
cap = self._cap(max_depth)
if cap is not None and cap <= depth:
return i
return len(self.entries)
@@ -241,7 +288,8 @@ class Path:
return depth
for entry_branch, max_depth in self.entries:
if entry_branch == branch_id:
return depth if max_depth is None else min(depth, max_depth)
cap = self._cap(max_depth)
return depth if cap is None else min(depth, cap)
return NO_DEPTH
+22 -43
View File
@@ -4,10 +4,17 @@ from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# AIDND_DB_PATH lets deployments (Docker volume, hosted disk) relocate the
# SQLite database; default stays backend/data.db for local runs. The parent
# directory also hosts the auto-generated secret.key (see security.py), so
# DB_PATH stays defined even when Postgres is in use.
# The one database. It is SQLite, on this machine, in a file.
#
# Upstream could also point at a server database — `AIDND_DATABASE_URL` or the
# platform-conventional `DATABASE_URL`, normalised onto psycopg3, with
# pre-ping for a serverless Postgres that suspends when idle. That existed for
# a hosted deployment. M2 removed it along with the deployment: a local
# single-user storyteller has one reader, and a network database would be one
# more thing that has to be running, and one more place the story lives.
#
# `AIDND_DB_PATH` stays. It is how the Docker image puts the database on a
# volume, and how a test points at a throwaway file.
_env_db_path = os.environ.get("AIDND_DB_PATH")
DB_PATH = (
Path(_env_db_path).resolve()
@@ -15,48 +22,20 @@ DB_PATH = (
else Path(__file__).resolve().parent.parent / "data.db"
)
# `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()
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
def _normalize_url(url: str) -> str:
"""Map the postgres:// / postgresql:// schemes hosts hand out to the
psycopg3 driver installed in requirements.txt."""
for prefix in ("postgres://", "postgresql://"):
if url.startswith(prefix):
return "postgresql+psycopg://" + url[len(prefix):]
return url
if DATABASE_URL:
engine = create_engine(
_normalize_url(DATABASE_URL),
# Serverless Postgres (Neon) suspends idle databases; pre-ping
# replaces silently-dead pooled connections instead of erroring.
pool_pre_ping=True,
# Store/read naive UTC like SQLite does, regardless of server default.
connect_args={"options": "-c timezone=UTC"},
)
else:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _enable_sqlite_foreign_keys(dbapi_connection, _record):
# SQLite ships with foreign keys OFF per connection; without this every
# ondelete=CASCADE/SET NULL in models.py is silently ignored.
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
@event.listens_for(engine, "connect")
def _enable_sqlite_foreign_keys(dbapi_connection, _record):
# SQLite ships with foreign keys OFF per connection; without this every
# ondelete=CASCADE/SET NULL in models.py is silently ignored.
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
+179
View File
@@ -0,0 +1,179 @@
"""Which inference endpoints this product is willing to talk to.
The Adventure Storyteller sends the player's prose, the assembled context, the
retrieved memories, and the embedding inputs to whatever address the model
endpoint names. That makes the endpoint the single most consequential setting
in the application: point it somewhere else and the whole campaign goes there.
The v1 rule (`planning/DECISIONS/002-ollama-only-v1.md`, ADR 004) is that
inference runs on user-controlled local infrastructure. Two deployments are
supported and no third is:
* **same-host** — Ollama on loopback, the default;
* **explicitly configured trusted LAN** — Ollama on another machine the user
controls, named by them, reached over HTTP or over HTTPS with a certificate
their machine trusts.
Everything on the public Internet is refused. Not discouraged in the UI, not
absent from a dropdown — refused, here, on the way out, so that a hand-edited
database row or a hostname that starts resolving somewhere new cannot quietly
turn a local install into an exfiltration path.
## How the line is drawn
By **address**, not by name, and against an explicit allowlist of networks:
loopback, the three RFC1918 ranges, link-local, IPv6 unique-local, and
carrier-grade NAT — the last of which is what a mesh VPN such as Tailscale
hands out and is as user-controlled as a LAN.
Every address the host resolves to must be in one of them. One address outside
is enough to refuse the endpoint, so a name resolving to both a private and a
public address does not squeak through.
The networks are spelled out rather than inferred from `ipaddress`'s own
classifications, which do not mean what this rule needs: `is_private` is true
of the documentation ranges and of `0.0.0.0/8`, and `is_reserved` is true of
IPv6 loopback — so a rule written around it refuses `http://[::1]:11434/v1`,
which is an ordinary same-host Ollama. Naming the networks keeps the policy
readable and makes anything unnamed refused by default.
Checking addresses rather than hostnames is what makes the rule hard to talk
around. A cloud provider cannot be reached by spelling its name differently,
and `localhost.` or a DNS entry pointing at a public host is judged on where it
actually goes.
## What this is not
It is not a general network-policy framework, and there is nothing to configure.
There is one predicate, and it is applied in two places: when the endpoint is
saved, so the user gets a clear error immediately, and again before every
outbound request, because a name that resolved to `192.168.1.50` this morning
can resolve to something else this afternoon.
TLS is a separate matter and is never traded against this one. See
`tlstrust.py`: certificates are verified in full, and no endpoint — however
private its address — may skip that.
"""
import ipaddress
import socket
from urllib.parse import urlparse
#: The networks an inference endpoint may live on. Anything else is refused.
ALLOWED_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"127.0.0.0/8", # this machine
"10.0.0.0/8", # RFC1918
"172.16.0.0/12", # RFC1918
"192.168.0.0/16", # RFC1918
"169.254.0.0/16", # link-local
"100.64.0.0/10", # carrier-grade NAT, which mesh VPNs use
"::1/128", # this machine, v6
"fc00::/7", # unique-local, v6
"fe80::/10", # link-local, v6
)
)
def _is_local(ip) -> bool:
return any(ip in net for net in ALLOWED_NETWORKS)
#: Hosts that are only ever a cloud inference service. The address rule below
#: already refuses every one of them, because they all resolve to public
#: addresses; this list exists solely so the error says *why* rather than
#: leaving the user to wonder whether their DNS is broken.
CLOUD_HOSTS = (
"openrouter.ai",
"api.openai.com",
"api.anthropic.com",
"api.groq.com",
"api.mistral.ai",
"api.together.xyz",
"api.deepseek.com",
"generativelanguage.googleapis.com",
"api.cohere.ai",
"api.perplexity.ai",
)
_CLOUD_REASON = (
"this build talks to Ollama on your own machine or on your own network, "
"and has no cloud provider support"
)
def _cloud_host(host: str) -> bool:
host = host.lower().rstrip(".")
return any(host == h or host.endswith("." + h) for h in CLOUD_HOSTS)
def rejection_reason(url: str) -> str | None:
"""Why this endpoint may not be used, or None if it may.
The string is shown to the user, so it says what to do rather than what
went wrong internally.
"""
parsed = urlparse((url or "").strip())
if parsed.scheme not in ("http", "https"):
return "the endpoint URL must start with http:// or https://"
host = parsed.hostname
if not host:
return "the endpoint URL has no host"
if _cloud_host(host):
return f"{host} is a cloud inference service — {_CLOUD_REASON}"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror:
return (
f"the host {host!r} could not be resolved — check the address, and "
"that the machine running Ollama is reachable from here"
)
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return "the endpoint host resolved to an address that could not be read"
if _is_local(ip):
continue
if ip.is_global:
return (
f"{host} resolves to {ip}, which is a public Internet address — "
f"{_CLOUD_REASON}. Use Ollama on this machine "
"(http://127.0.0.1:11434/v1) or on a machine on your own network"
)
return (
f"{host} resolves to {ip}, which is not on this machine and not on "
"your own network. Use http://127.0.0.1:11434/v1, or the address of "
"a machine on your network"
)
return None
def check(url: str) -> None:
"""Raises `EndpointRejected` if this endpoint is outside the policy."""
reason = rejection_reason(url)
if reason is not None:
raise EndpointRejected(reason)
class EndpointRejected(Exception):
"""The configured endpoint is not one this product will send a story to."""
def is_loopback(url: str) -> bool:
"""Whether this endpoint is on this machine. Used for reporting, not for
gating: a trusted-LAN endpoint is equally allowed."""
host = urlparse((url or "").strip()).hostname
if not host:
return False
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror:
return False
try:
return all(ipaddress.ip_address(i[4][0]).is_loopback for i in infos)
except ValueError:
return False
+405
View File
@@ -0,0 +1,405 @@
"""M3: where the story is being read, and what moving that point costs.
Undo used to delete. It removed the trailing nodes, let `tree.refresh_head`
recompute the tip from what survived, and the story was wherever the rows ended.
That made the head a derived value and made Redo impossible, because the turns it
would have moved forward into were gone.
The head is now a stored position that can sit behind the retained tip. Nothing
is deleted, so three things that used to be the same question are now three
different ones:
* **the active head** — `adventure.head_branch_id` and `adventure.head_depth`,
the end of the story being told. Every read of the story stops here, because
`lineage.Path` caps every entry at it.
* **the retained tip** — the deepest live node still on the lineage. Redo walks
toward it. It is read through `Path.uncapped()`, and only this module and the
divergence check may look at it.
* **the opening** — the shallowest node on the story, which is the floor Undo
may not pass.
Everything that moves the head or asks a question about it lives here, so the
turn engine, Retry, Add-take, Undo, Redo and Edit share one set of rules rather
than four similar ones. The Phase 0B spike put the fork check in the write path
and left Retry and Add-take on the old one, which is exactly the divergence this
module exists to prevent.
The state that belongs to a position is not recomputed. Every node carries the
world state it left behind (`attempts.snapshot_outcome`), so moving the head is a
row lookup plus `attempts.restore_state`, at any distance, in either direction.
"""
from sqlalchemy.orm import Session, undefer
from . import attempts, models, tree
from .context import lineage
# The kinds of node a player writes. An undo or a redo steps over a whole turn,
# which is one of these followed by the reply to it, so both ends need to agree
# on what "a player's half of a turn" is.
PLAYER_TYPES = ("do", "say", "story", "continue")
# ------------------------------------------------------------------ reading
def opening_depth(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth of the first node of the story, or None if there is none.
This is Undo's floor. The Phase 0B spike moved the head to -1 and rendered an
empty transcript, because its guard tested for a node of type `start` and an
adventure opened with a player-written `story` action has none. Asking the
path for its shallowest node needs no such special case: whatever the opening
is called, it is the node with the smallest depth, and the story keeps it.
The read is uncapped. The opening does not move when the head does, and
capping would make the floor depend on where the head already is.
"""
return (
db.query(models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
)
.order_by(models.Action.depth.asc(), models.Action.id.asc())
.limit(1)
.scalar()
)
def retained_tip(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth of the deepest live node still retained on this lineage.
This is what the head would be if the story had never been undone, and it is
what Redo can reach. It is not the head, and no read of the story may use it.
"""
return (
db.query(models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(1)
.scalar()
)
def behind_tip(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether retained story sits past the head.
This one predicate answers every "does this write need to fork?" question in
the application. It is true exactly when the user has undone and not redone,
which is the only situation in which writing can displace an accepted future.
It is also what makes "is this turn the tip?" answerable again. Retry,
Add-take and `stand_on` each decide between amending a turn in place and
giving it a branch, and each used to ask `last_action`, which reads the
*capped* path and therefore reports the node at the head as the newest one.
Under a moved-back head that answer is wrong in the dangerous direction: it
says a turn with an accepted future is a leaf, and amending it in place would
leave that future descending from a take that is no longer live.
"""
tip = retained_tip(db, adventure)
return tip is not None and tip > adventure.head_depth
def node_at(
db: Session, adventure: models.Adventure, depth: int
) -> models.Action | None:
"""Returns the live node at `depth` on the retained lineage, outcome loaded.
The read is uncapped on purpose: Redo asks for a node it is about to move the
head onto, which is by definition past the head at the time of asking. The
outcome columns are undeferred because the only reason to fetch this row is
to restore the state it left behind.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
models.Action.depth == depth,
)
.options(
undefer(models.Action.state_after),
undefer(models.Action.world_state_after),
)
.order_by(models.Action.id)
.first()
)
def redo_target(db: Session, adventure: models.Adventure) -> int | None:
"""Returns the depth the head moves to on Redo, or None if there is nowhere.
Redo steps over a whole turn, the same unit Undo steps back over, so a
player's action and the reply to it move together. Landing between them would
show the story an input with no answer and would leave the next Undo undoing
half a turn.
The walk is along the retained lineage, which is what makes Redo follow the
continuation that was active rather than choosing among branches. After a
divergence the new branch *is* the lineage, and the displaced future is no
longer on it, so this returns None without having to know that a divergence
happened. That is `STORY-BRANCH-SEMANTICS.md` §8 falling out of the lineage
rather than being enforced by a flag.
"""
ahead = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
models.Action.depth > adventure.head_depth,
)
.order_by(models.Action.depth.asc(), models.Action.id.asc())
.limit(2)
.all()
)
if not ahead:
return None
first = ahead[0]
if (
first.type in PLAYER_TYPES
and len(ahead) > 1
and ahead[1].type == "ai"
and ahead[1].depth == (first.depth or 0) + 1
):
return ahead[1].depth
return first.depth
def can_redo(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether an ordinary Redo is available from where the head is."""
return redo_target(db, adventure) is not None
def undo_target(
db: Session, adventure: models.Adventure
) -> tuple[int, models.Action] | None:
"""Returns where Undo moves the head, and the first node it steps back over.
None means there is nothing to undo, which is either an empty story or a head
already resting on the opening. The caller turns that into a 400; this
function does not raise, so that the same question can be asked without
committing to undoing.
A turn is the player's node plus the reply to it, and both move together for
the reason given in `redo_target`. The player half is only claimed when it is
directly in front of the reply, so a bare `continue`, which writes no player
node, steps back over the reply alone.
"""
newest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(2)
.all()
)
if not newest:
return None
last = newest[0]
first_stepped = last
before = newest[1] if len(newest) > 1 else None
if (
last.type == "ai"
and before is not None
and before.type in PLAYER_TYPES
and before.depth == (last.depth or 0) - 1
):
first_stepped = before
floor = opening_depth(db, adventure)
if first_stepped.depth is None or floor is None:
return None
if first_stepped.depth <= floor:
# Stepping back over this turn would hide the opening of the campaign,
# which is the pre-campaign state `STORY-BRANCH-SEMANTICS.md` §4 stops
# at. The floor is the opening node itself rather than depth -1, so an
# adventure that opens on a player-written `story` action stops in the
# same place as one that opens on a `start` node.
return None
return first_stepped.depth - 1, first_stepped
def can_undo(db: Session, adventure: models.Adventure) -> bool:
"""Returns whether an ordinary Undo is available from where the head is."""
return undo_target(db, adventure) is not None
def displaced_history_under(
db: Session, adventure: models.Adventure, node: models.Action
) -> bool:
"""Returns whether story the reader cannot see descends from `node`.
This is the question an in-place edit has to ask. Editing rewrites one row
and re-evaluates nothing, which is what makes it a correction rather than a
new continuation. That is harmless while everything descending from the row
is on screen: the reader can see what their correction has to stay
consistent with. It stops being harmless the moment a continuation descends
from the row and is *not* on screen, because the edit then silently changes
the words an invisible stretch of story was written from. That is the one
way M3's retained history can be made to contradict itself.
Refusing is deliberately the whole of the fix. Making such an edit fork, so
the original text and its future stay whole, is
`STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
state the edited prose implies, which is M5's extraction pass. Neither is
started here.
The question is asked as one shape rather than two, because the two ways a
descendant becomes invisible turn out to be the same fact. An undone future
sits past the head on this very lineage; a displaced line sits past a fork
on a branch the story left. In both cases there is a live node, deeper than
this one, that descends from it and is not on the path being read — and the
departed branch is usually an *ancestor* of the branch now being read, which
is why "branches other than the active one" is the wrong set to look at.
Only the deepest live node on each descending branch is examined. Whether a
node is on the read path is monotone in depth: a branch is on the path with
a cap, and a node is visible when its depth is at or under that cap. So if
the deepest one is visible, every shallower one is too, and if it is not,
the answer is already yes.
A node that is not live has no descendants of its own — a take the story
moved past keeps a continuation only by being forked, and that fork is a
branch this loop asks about anyway — so editing one is always safe.
"""
if not node.live or node.depth is None:
return False
read = lineage.path_of(db, adventure)
branches = (
db.query(models.Branch)
.filter(models.Branch.adventure_id == adventure.id)
.all()
)
for branch in branches:
# Uncapped: the question is what this branch's story descends from, not
# how much of it the reader is currently being shown.
if not lineage.Path(lineage.entries_of(branch)).contains(node):
continue
deepest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch.id,
models.Action.live.is_(True),
models.Action.depth > node.depth,
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.first()
)
if deepest is not None and not read.contains(deepest):
return True
return False
# ------------------------------------------------------------------ writing
def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
"""Moves the active head to `depth` and restores the state recorded there.
This is the whole of Undo and Redo. Nothing is deleted, nothing is
recomputed, and the direction of travel does not matter: the node at the
destination carries the world state it left behind, so arriving from in front
of it and arriving from behind it restore the same value.
A destination with no node — the head resting one step in front of the
opening — leaves the live state alone, which is `attempts.restore_state`'s
rule for a missing snapshot and the reason it is not this function's job to
invent an empty one.
"""
adventure.head_depth = depth
attempts.restore_state(adventure, node_at(db, adventure, depth))
def move_to_node(db: Session, adventure: models.Adventure, node: models.Action) -> bool:
"""Moves the head onto `node`, changing line only if it is not on this one.
M4 restores a Save Point through this, and it adds no restoring of its own:
the depth half is `move_to` unchanged, so the state, the transcript, the
assembled context and memory eligibility all arrive exactly as they do for
Undo and Redo. Returns whether the line had to change as well as the depth,
which is the one thing about a restore a caller cannot work out afterwards.
The head is two values, and the two halves move for different reasons. A
Save Point almost always names a position on the story being read — its own
line, or the shared prefix that line inherits — and then only the depth
moves. Leaving the branch alone is what makes the restored position keep the
continuation it has: after a divergence, restoring to the shared prefix must
put the reader back on the *new* line, where Redo walks into the turns they
are still writing, not into the future they left. Reaching for the Save
Point's own branch there would quietly hand back the abandoned story.
The other case is real and has to work. A Save Point survives divergence
(`STORY-BRANCH-SEMANTICS.md` §19), so one can name a position on a line the
story has since left, and no amount of depth movement reaches a branch this
path does not contain. The line then moves as well — one assignment, the
same one `switch_branch` makes — and the depth still moves through
`move_to`. Nothing is created: a restore never forks, whichever case it
takes. The first write below the restored head does, through
`fork_if_behind_head`, like every other write.
"""
switched = not lineage.path_of(db, adventure).uncapped().contains(node)
if switched:
adventure.head_branch_id = node.branch_id
move_to(db, adventure, node.depth)
return switched
def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool:
"""Gives the story a new branch when a write would displace a retained future.
Returns whether a branch was created, which is what a caller reports as a
divergence.
Called before every write that continues the story, and it does nothing on
the ordinary path where the head is already at the tip. That is the property
worth keeping: a story that is never undone forks exactly as often as it did
before M3, so the branch table does not fill up with one branch per turn.
Undo alone must not fork. Moving the head is not a decision to abandon
anything — the user may be reading, or about to Redo. Only the first write
below the head states which continuation they mean, which is
`STORY-BRANCH-SEMANTICS.md` §8 and §20 and what makes Redo survive an Undo.
`tree.branch_at` leaves the departed branch exactly as it is: its nodes stay
live, at their depths, on their branch. The new branch inherits the story up
to the head and owns everything written from here, so the displaced future
remains reachable through the branch it was written on.
"""
if not behind_tip(db, adventure):
return False
departed = lineage.branch_of(db, adventure)
at_depth = adventure.head_depth
tree.branch_at(db, adventure, at_depth)
if departed is not None:
mark_superseded(departed, at_depth)
return True
def mark_superseded(branch: models.Branch, depth: int) -> None:
"""Records that this branch's story past `depth` was displaced.
`DATA-MODEL.md` §5 gives a branch a disposition of active, retained or
disposable. This is that disposition, stored as the fact that produced it
rather than as a word: the depth the story left at, and when. A branch with
no `superseded_at` is active; one with a value has retained history past that
depth which no active head is reading.
Nothing in the application reads these columns to make a decision, and that
is deliberate. Redo is decided by the lineage, not by a flag, so a stale or
hand-edited value here cannot make the story wrong. They exist so that the
cleanup and discarded-history features `STORY-BRANCH-SEMANTICS.md` §28 and
§29 leave to a later version have something to select on, and so that a
divergence is observable in a test.
The shallowest departure wins. A branch left at depth 9 and later left again
at depth 4 has retained history from 4 onward, and recording the later, deeper
value would understate what was displaced.
"""
if branch.superseded_depth is None or depth < branch.superseded_depth:
branch.superseded_depth = depth
if branch.superseded_at is None:
branch.superseded_at = models.utcnow()
+30 -194
View File
@@ -1,183 +1,35 @@
"""Phase 9: abuse guards for hosted, multi-user deployments.
"""Resource bounds on what a single request or a single story may cost.
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.
Upstream carried three things here, and only one of them belongs in a local
single-user product. Per-IP and per-user **rate limiting**, the login-attempt
throttle, and the per-user **quotas** were hosted-service policy: they existed
to stop a hostile visitor exhausting a shared demo key or filling a shared
database. M2 removed all of it. There are no visitors, and throttling the one
person who started the application would be a bug rather than a guard.
What is left is defensive programming, and it applies whatever the deployment:
* a ceiling on the **request body**, so a malformed or hostile payload cannot
be read into memory before anything looks at it;
* ceilings on how large **one adventure** may grow, in actions, memories,
story cards and branches. These bound storage and the cost of the queries
that walk them. They are per-story, not per-user: nothing here counts how
many campaigns a person may have.
An import is checked against the same per-adventure ceilings that live creation
uses, so a bundle cannot carry a story past a limit that play could not reach.
"""
import json
import os
import threading
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from . import auth, models
from . import models
# ---------- Rate limiting ----------
# 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.
# ---------- Per-story row caps ----------
# Maps a scope to (max requests, window seconds).
RATE_LIMITS: dict[str, tuple[int, int]] = {
"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),
}
_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 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:
"""Returns the real client IP, resisting a spoofed `X-Forwarded-For`.
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()]
if parts:
return parts[-min(TRUSTED_PROXY_HOPS, len(parts))]
return request.client.host if request.client else "unknown"
def rate_limit(scope: str, request: Request, user: models.User | None = None) -> None:
"""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]
key = (scope, f"u{user.id}" if user else f"ip{client_ip(request)}")
now = time.time()
with _windows_guard:
window = _windows[key]
while window and window[0] < now - window_seconds:
window.popleft()
if len(window) >= limit:
raise HTTPException(
429, "You're doing that too fast — wait a minute and try again."
)
window.append(now)
if len(_windows) > 10_000:
_prune(now)
# ---------- Per-account login throttle ----------
# 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:
"""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()
with _login_guard:
window = _login_fails[email]
while window and window[0] < now - LOGIN_FAIL_WINDOW:
window.popleft()
if len(window) >= LOGIN_FAIL_LIMIT:
raise HTTPException(
429,
"Too many failed sign-in attempts for this account — "
"wait a few minutes and try again.",
)
def note_login_failure(email: str) -> None:
"""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 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
]
for key in stale:
del _login_fails[key]
def note_login_success(email: str) -> None:
"""Clears the account's failure record after a correct password."""
with _login_guard:
_login_fails.pop(email, None)
def _prune(now: float) -> None:
"""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]
for key in stale:
del _windows[key]
# ---------- Per-user row caps ----------
MAX_ADVENTURES_PER_USER = 100
MAX_SCENARIOS_PER_USER = 200
MAX_SCRIPTS_PER_USER = 200
MAX_STORY_CARDS_PER_OWNER = 200 # Per scenario or per adventure.
MAX_MEMORIES_PER_ADVENTURE = 1000
MAX_ACTIONS_PER_ADVENTURE = 5000
@@ -200,28 +52,14 @@ def check_row_cap(
) -> None:
"""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.
Only per-story kinds are capped. `adventures` and `scenarios` were per-user
quotas and are no longer checked; the callers still pass them, and they are
accepted and ignored so that adding a cap back is a change here rather than
at every call site.
"""
if not auth.MULTI_USER:
if kind in ("adventures", "scenarios"):
return
if kind == "adventures":
count = _count(db, models.Adventure, models.Adventure.user_id == user.id)
cap, subject, hint = (
MAX_ADVENTURES_PER_USER, "adventures",
"delete one you no longer play to make room",
)
elif kind == "scenarios":
count = _count(db, models.Scenario, models.Scenario.user_id == user.id)
cap, subject, hint = (
MAX_SCENARIOS_PER_USER, "scenarios", "delete one to make room"
)
elif kind == "scripts":
count = _count(db, models.Script, models.Script.user_id == user.id)
cap, subject, hint = (
MAX_SCRIPTS_PER_USER, "scripts", "delete one to make room"
)
elif kind == "story_cards":
if kind == "story_cards":
owner_filter = (
models.StoryCard.scenario_id == scenario_id
if scenario_id is not None
@@ -273,8 +111,6 @@ def check_bundle_lists(**lists) -> None:
The keyword arguments are `story_cards`, `memories`, `actions`, and
`branches`.
"""
if not auth.MULTI_USER:
return
for name, value in lists.items():
cap = _BUNDLE_LIST_CAPS[name]
if isinstance(value, list) and len(value) > cap:
@@ -286,8 +122,8 @@ def check_bundle_lists(**lists) -> None:
# ---------- Request body size ----------
# 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.
# adventure export holding thousands of actions. No honest request approaches
# it.
MAX_BODY_BYTES = 2 * 1024 * 1024
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
+31 -68
View File
@@ -1,6 +1,5 @@
import mimetypes
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
@@ -8,15 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from . import analytics, cleanup
from .auth import MULTI_USER
from .database import engine
from .limits import BodySizeLimitMiddleware
from .migrations import bootstrap
from .routers import (
adventures, analytics as analytics_router, auth, chat, debug, scenarios, scripts,
settings, story_cards,
)
from .routers import adventures, chat, debug, scenarios, settings, story_cards
from .seed import seed_public_scenarios
bootstrap(engine)
@@ -24,37 +18,32 @@ seed_public_scenarios(engine)
# Production serves the SPA same-origin, so CORS only matters for the Vite dev
# server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup.
#
# A wildcard is refused rather than honoured. This API is unauthenticated by
# design and bound to loopback, so its only protection from a page the user
# happens to have open in another tab is the same-origin policy. `*` would hand
# every site on the Internet a write handle on the local campaign database. If the
# value is wrong the app refuses to start, because a permissive CORS policy that
# nobody notices is worse than one that fails loudly.
CORS_ORIGINS = [
o.strip()
for o in os.environ.get("AIDND_CORS_ORIGINS", "").split(",")
if o.strip()
] or ["http://localhost:5173", "http://127.0.0.1:5173"]
@asynccontextmanager
async def lifespan(_app: FastAPI):
# Sweeps once on boot, then on an interval. Booting is the reliable
# trigger on Render's free tier, where the service sleeps after ~15
# minutes and a long-running timer rarely gets to fire.
sweeper = cleanup.start_sweeper()
# Visit counters are buffered in memory and written in batches; this is
# what turns them into rows, and stop_flusher writes out the last batch so
# a deploy doesn't drop it.
flusher = analytics.start_flusher()
try:
yield
finally:
await cleanup.stop_sweeper(sweeper)
await analytics.stop_flusher(flusher)
if any(o == "*" or o.strip() == "*" for o in CORS_ORIGINS):
raise RuntimeError(
"AIDND_CORS_ORIGINS must not contain '*'. The storyteller API is "
"unauthenticated and loopback-bound; a wildcard origin would let any "
"web page read and rewrite every campaign. List the exact origins "
"instead."
)
# The interactive API docs stay local-only: in multi-user mode they just hand
# strangers a map of the API surface.
app = FastAPI(
title="AI D&D",
docs_url=None if MULTI_USER else "/docs",
title="Adventure Storyteller",
docs_url="/docs",
redoc_url=None,
openapi_url=None if MULTI_USER else "/openapi.json",
lifespan=lifespan,
openapi_url="/openapi.json",
)
app.add_middleware(
@@ -117,50 +106,14 @@ class SecurityHeadersMiddleware:
await self.app(scope, receive, send_with_headers)
class ApiErrorMiddleware:
"""Counts failed API responses for the analytics dashboard.
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):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or not scope.get("path", "").startswith("/api"):
return await self.app(scope, receive, send)
async def send_counting(message):
if message["type"] == "http.response.start" and message["status"] >= 400:
# The router has already put the matched route on the scope by
# the time a response starts, so the label can name the
# endpoint rather than the caller's path.
analytics.record(
analytics.M_ERROR,
analytics.api_route_label(scope, message["status"]),
)
await send(message)
await self.app(scope, receive, send_counting)
app.add_middleware(ApiErrorMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(auth.router)
app.include_router(scenarios.router)
app.include_router(adventures.router)
app.include_router(story_cards.router)
app.include_router(scripts.router)
app.include_router(settings.router)
app.include_router(chat.router)
app.include_router(debug.router)
app.include_router(analytics_router.router)
@app.get("/api/health")
@@ -171,7 +124,8 @@ def health():
# In production, serve the built frontend (frontend/dist) as static files.
class SPAStaticFiles(StaticFiles):
"""Serve index.html for unknown paths so client-side routes (/play/3)
survive a page reload. API routes are matched before this mount."""
survive a page reload. API routes are matched before this mount, and an
unmatched one 404s rather than falling through to the page."""
async def get_response(self, path, scope):
try:
@@ -179,11 +133,20 @@ class SPAStaticFiles(StaticFiles):
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
return await super().get_response("index.html", scope)
return await self._fallback(path, scope)
if response.status_code == 404:
return await super().get_response("index.html", scope)
return await self._fallback(path, scope)
return response
async def _fallback(self, path, scope):
# The mount is a catch-all, so an /api path no router claims — a typo,
# or an endpoint this build removed — used to come back as the SPA's
# HTML with status 200, and a client asking for JSON parsed a web page
# instead of seeing that the route is not there.
if path == "api" or path.startswith("api/"):
raise StarletteHTTPException(status_code=404)
return await super().get_response("index.html", scope)
# Python's mimetypes table has no entry for woff2 on a slim Debian image, so
# StaticFiles served the self-hosted fonts as application/octet-stream. Browsers
+6 -10
View File
@@ -142,26 +142,22 @@ _running: set[int] = set()
_tasks: set[asyncio.Task] = set()
# 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.
# Both factories read the endpoint and the model names straight off `Settings`.
# They used to also read an API key, which is gone: Ollama does not use one and
# M2 removed cloud providers. `summary_model` and `embedding_model` fall back to
# the narrator model when the user has not named a separate one.
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
settings.endpoint_url,
settings.api_key_plain,
settings.summary_model or settings.model,
settings.api_mode,
settings.reasoning_max_tokens,
settings.model_timeout_seconds,
)
def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key_plain, settings.embedding_model
settings.endpoint_url, settings.embedding_model
)
+98 -24
View File
@@ -301,7 +301,8 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
(63, "ALTER TABLE actions ADD COLUMN parent_id INTEGER REFERENCES actions(id) ON DELETE SET NULL"),
(64, "CREATE INDEX IF NOT EXISTS ix_actions_parent ON actions (parent_id)"),
# Phase 17: `Settings.stream` was dead state. Nothing ever read it, and every
# turn streams. This is item S1 in `docs/self-review.md`. The table holds one
# turn streams; upstream's self-review log flagged it as dead state. The
# table holds one
# row per user, so the rewrite is small and needs no VACUUM FULL.
(65, "ALTER TABLE settings DROP COLUMN stream"),
# Phase 17, SP8: drop the eight columns the story tree replaced. Each one was
@@ -338,6 +339,69 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
(74, "ALTER TABLE adventures ADD COLUMN persona_name VARCHAR(80) NOT NULL DEFAULT ''"),
(75, "ALTER TABLE adventures ADD COLUMN persona_pronouns VARCHAR(40) NOT NULL DEFAULT ''"),
(76, "ALTER TABLE adventures ADD COLUMN persona_desc TEXT NOT NULL DEFAULT ''"),
# M2: how long to wait for the model. Upstream hardcoded 120s in the HTTP
# client, which a cold model load on a CPU-only machine can exceed. The
# default matches `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
(77, "ALTER TABLE settings ADD COLUMN model_timeout_seconds INTEGER NOT NULL DEFAULT 300"),
# M3. A branch left behind by a divergent write records where the story left
# it. NULL means active, which is what every existing branch is: before M3
# the head could not sit behind the tip, so no branch had been superseded.
# No backfill.
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
# M4: Save Points. `create_all` creates the `checkpoints` table itself, on
# existing databases as well as fresh ones, exactly as it did for
# `memories` at version 2 and `branches` at version 46. What it does not
# create is the index every list and every cascade reads, so that is what
# this version is.
#
# No backfill. A Save Point records a decision someone made, and nobody has
# made one yet: an M3 database has no position a user chose to name, and
# inventing one would be inventing the decision.
(80, "CREATE INDEX IF NOT EXISTS ix_checkpoints_adventure "
"ON checkpoints (adventure_id)"),
# M5: genre-neutral authoritative narrative state. `create_all` builds the
# two new tables — `state_proposals` and `state_events` — as it did
# `memories`, `branches` and `checkpoints`; these are the columns it cannot
# add to tables that already exist, plus the indexes the audit reads need.
#
# **No backfill, deliberately.** The inherited RPG world state is numbers
# against a stat schema: `player.gold = 70`, `npc.gwen.trust = 3`. Nothing
# in that says who Gwen is, where anyone stands, or what anyone holds, and a
# narrative fact invented from a number would be fiction the campaign never
# established — exactly what the M5 brief forbids. So the old columns are
# left intact and non-authoritative, and every campaign starts M5 with an
# empty narrative state that its next turns fill in.
#
# The campaign's own `narrative_state` is left NULL: an adventure with no
# M5 turns yet has no document, and the first one writes it.
#
# Per-action snapshots are a different question, and the M5 corrective pass
# settled it the other way (review Finding 3). This block originally left
# those NULL too, reasoning that an empty document would be "a claim, not an
# absence". The consequence was worse than the claim: restoring to an old
# position left the state of a *later* position standing, so the transcript
# and the state described different moments. Backfilling the empty document
# at version 88 says the only true thing about a pre-M5 position — the
# narrative-state system established nothing there, because it did not yet
# exist — and keeps head, transcript and state in agreement. The legacy RPG
# columns are untouched and still restored beside it.
(81, "ALTER TABLE adventures ADD COLUMN narrative_state BLOB"),
(82, "ALTER TABLE adventures ADD COLUMN campaign_canon JSON"),
(83, "ALTER TABLE actions ADD COLUMN narrative_state_after BLOB"),
(84, "ALTER TABLE actions ADD COLUMN state_changes JSON"),
(85, "CREATE INDEX IF NOT EXISTS ix_state_events_adventure "
"ON state_events (adventure_id, id)"),
(86, "CREATE INDEX IF NOT EXISTS ix_state_events_action "
"ON state_events (action_id)"),
(87, "CREATE INDEX IF NOT EXISTS ix_state_proposals_adventure "
"ON state_proposals (adventure_id, id)"),
# M5 corrective pass. No DDL — 83 already added the column. This version
# exists to carry the data pass that fills it in for rows that predate it,
# so that every position an existing campaign can be restored to has a
# snapshot. See `_backfill_narrative_snapshots`.
(88, "-- narrative snapshot backfill (data pass only)"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -351,6 +415,7 @@ TREE_BACKFILL_VERSION = 52
CURSOR_ANCHOR_VERSION = 56
SIBLING_SPLIT_VERSION = 60
PARENT_BACKFILL_VERSION = 64
NARRATIVE_SNAPSHOT_VERSION = 88
# An adventure with no actions has no tip. A value of -1 keeps the rule that the
# next node goes at `head_depth + 1` true without a special case. This matches
@@ -368,6 +433,30 @@ SNAPSHOT_BATCH = 50
BACKFILL_BATCH = 200
def _backfill_narrative_snapshots(conn) -> None:
"""Gives every pre-M5 action the empty narrative document as its outcome.
One statement, no row loop: the document is identical for every row, so it
is encoded once in Python and bound as a single parameter. `narrative.model`
owns the shape and `compression.pack` owns the encoding, so this cannot
drift from what `snapshot_outcome` writes.
Why the empty document rather than NULL is argued at migration 81. In short:
a position with no snapshot used to mean "leave the live state alone", which
let a later position's state stand while the reader was somewhere else.
"""
from .compression import pack
from .narrative import model as narrative_model
conn.execute(
text(
"UPDATE actions SET narrative_state_after = :document "
"WHERE narrative_state_after IS NULL"
),
{"document": pack(narrative_model.empty())},
)
def _backfill_world_delta(conn) -> None:
"""Populates `actions.world_delta` from the existing `context_snapshot`.
@@ -1072,9 +1161,12 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
if current < version <= through:
statement = _for_dialect(sql, conn.dialect.name)
# Skip the DDL when it has already run. The data pass below it
# still runs.
if not (_column_already_there(conn, statement)
or _column_already_gone(conn, statement)):
# still runs. A version whose whole content is a data pass
# carries a comment in place of DDL and executes nothing.
if not statement.lstrip().startswith("--") and not (
_column_already_there(conn, statement)
or _column_already_gone(conn, statement)
):
conn.execute(text(statement))
if version == WORLD_DELTA_VERSION:
_backfill_world_delta(conn)
@@ -1104,25 +1196,7 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
# exist.
if version == PARENT_BACKFILL_VERSION:
_backfill_parents(conn)
if version == NARRATIVE_SNAPSHOT_VERSION:
_backfill_narrative_snapshots(conn)
current = version
_set_version(conn, current)
_encrypt_plaintext_api_keys(conn)
def _encrypt_plaintext_api_keys(conn) -> None:
"""Encrypts API keys saved before encryption at rest existed (Phase 8).
Those keys are stored in plain text, so this pass wraps them in Fernet. Plain
SQL cannot do it. The pass runs on every start, and it matches no rows once
every row carries the `enc:` prefix.
"""
from . import security # Deferred: security derives its key from DB_PATH setup.
rows = conn.execute(text(
"SELECT id, api_key FROM settings WHERE api_key != '' AND api_key NOT LIKE 'enc:%'"
)).all()
for row_id, plain in rows:
conn.execute(
text("UPDATE settings SET api_key = :key WHERE id = :id"),
{"key": security.encrypt_secret(plain), "id": row_id},
)
+282 -183
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy import (
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Table, Text, UniqueConstraint, event,
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Text, event,
)
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
@@ -37,19 +37,12 @@ class User(Base):
is_guest: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Shared demo key usage (resets when the UTC date changes).
# Was the shared demo key's per-day tally. M2 removed the demo key; these
# columns stay so existing databases open unchanged and are never written.
demo_turns_used: Mapped[int] = mapped_column(Integer, default=0)
demo_turns_date: Mapped[str] = mapped_column(String(10), default="")
scenario_scripts = Table(
"scenario_scripts",
Base.metadata,
Column("scenario_id", ForeignKey("scenarios.id", ondelete="CASCADE"), primary_key=True),
Column("script_id", ForeignKey("scripts.id", ondelete="CASCADE"), primary_key=True),
)
class Scenario(Base):
__tablename__ = "scenarios"
@@ -89,7 +82,6 @@ class Scenario(Base):
back_populates="scenario", cascade="all, delete-orphan"
)
adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario")
scripts: Mapped[list["Script"]] = relationship(secondary=scenario_scripts)
class Adventure(Base):
@@ -121,10 +113,34 @@ class Adventure(Base):
persona_name: Mapped[str] = mapped_column(String(80), default="")
persona_pronouns: Mapped[str] = mapped_column(String(40), default="")
persona_desc: Mapped[str] = mapped_column(Text, default="")
# Was the campaign scripting engine's shared `state` object. M2 removed
# scripting; the column stays so existing databases open unchanged, and it
# is never written with anything but an empty dict.
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
#
# **Legacy as of M5**, and no longer authoritative. M5 replaced the
# relative-delta protocol this column served (ADR 010); the turn engine no
# longer writes it, and nothing reads it to decide anything. It stays so
# that a pre-M5 database opens unchanged and its numbers remain visible to
# whoever wants to look — `narrative_state` below is what the story means
# now. Reinterpreting these values as generic narrative facts would be
# inventing meaning the data does not carry, which the M5 brief forbids.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# M5: the authoritative narrative state, as it stands at the active head.
# Genre-neutral (ADR 006), written only by validated typed events (ADR 010),
# and restored from the destination node's snapshot whenever the head moves,
# so it always describes the story being read rather than a story the reader
# has stepped back from.
narrative_state: Mapped[dict] = mapped_column(
CompressedJSON, nullable=True, default=None
)
# Campaign canon: rules the story may not contradict, as configuration
# rather than code (C01, J03). A fantasy campaign forbidding resurrection
# and a science-fiction one forbidding faster-than-light travel use the same
# field and the same validator; neither word appears in the application.
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
@@ -177,11 +193,6 @@ class Adventure(Base):
cascade="all, delete-orphan",
order_by="Action.id",
)
scripts: Mapped[list["AdventureScript"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="AdventureScript.position",
)
memories: Mapped[list["Memory"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
@@ -238,6 +249,169 @@ class Branch(Base):
# 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)
# M3: the disposition `DATA-MODEL.md` §5 gives a branch, stored as the fact
# that produced it. NULL means active. A value means a divergent write left
# this branch at `superseded_depth`, so its nodes past that depth are
# retained history that no active head is reading.
#
# Nothing reads these to decide behaviour. Redo follows the lineage, so a
# wrong value here cannot make the story wrong; they exist for the cleanup
# and discarded-history features that `STORY-BRANCH-SEMANTICS.md` §28-29
# leave to a later version. See `head.mark_superseded`.
superseded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Checkpoint(Base):
"""M4: a Save Point — a durable named pointer to a story position.
"Save Point" is what the user reads; `checkpoint` is what the code calls it
(`BROWSER-UX-SPEC.md` §23).
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
describes the pointer as naming a turn; the coordinate here is
`(branch_id, depth)`, which is what M3 made the head and what
`head.node_at` resolves. Restoring one is therefore head movement with a
bounds check rather than a restore system of its own — see ADR 012 and
`head.move_to_node`.
A coordinate rather than an action id, deliberately. One coordinate can
hold several attempts at a turn and exactly one of them is live, so a
retry replaces the row a Save Point would have pinned. "Turn 42 of this
line" survives a retry; "action 918" would point at a take the story no
longer tells.
`branch_id` is the branch the node itself sits on, not the branch that was
being read when the Save Point was made. Those differ whenever the head is
resting in a shared prefix, and the node's own branch is the one that still
names the position after the reader has moved elsewhere.
**Nothing removes a Save Point but the user.** They are not cleaned up for
going stale, for being behind the head, or for pointing into a future the
story has left (`STORY-BRANCH-SEMANTICS.md` §19).
That includes deleting a branch. `branch_id` carries `ON DELETE CASCADE` as
referential integrity — a Save Point must never point at a branch that is
gone — but the branch endpoint refuses to delete a branch any Save Point
names, so the cascade does not fire through the application
(`routers/adventures/branches.py`, `STORY-BRANCH-SEMANTICS.md` §19.1). The
user deletes the Save Point first, which deletes no story, and then the
branch. Deleting the whole campaign does cascade, and should: that is what
the user asked for.
"""
__tablename__ = "checkpoints"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120), default="")
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
# optional second field. Empty is the ordinary case.
note: Mapped[str] = mapped_column(Text, default="")
branch_id: Mapped[int] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE")
)
depth: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# Bumped by a rename, which is the only edit a Save Point allows. The
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
# Save Point's meaning auditable by making "move it" delete-and-recreate.
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
class StateProposal(Base):
"""M5: what the model proposed, and what the application did about it.
`DATA-MODEL.md` §19 requires the model's proposal to be *distinct from*
accepted state, and this table is that separation made physical. The model
writes here; it never writes `state_events`, and it never writes a snapshot.
A row exists whether the proposal was accepted, partly accepted, rejected or
unparseable. A rejected proposal is not authoritative and changes nothing,
but it is the record that explains why the state does not say what the
narration seems to say — without it, a wrong-looking campaign has no trail
to follow. `raw_output` is kept for exactly the case that matters most: the
block that did not parse, which no structured column could hold.
"""
__tablename__ = "state_proposals"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
# The node whose narration produced this. NULL only for a manual correction,
# which has a coordinate but no narration behind it.
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Which model produced it, so a later comparison of extraction quality has
# something to group by. Empty for a manual correction.
model_name: Mapped[str] = mapped_column(String(200), default="")
# `accepted_story` or `manual_correction` — who is asserting this.
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
# accepted | partially_accepted | rejected | unparseable
status: Mapped[str] = mapped_column(String(30), default="accepted")
# The block as written, including when it did not parse.
raw_output: Mapped[str] = mapped_column(Text, default="")
# The parsed payload, the events accepted, and every rejection with its
# reason. Compressed for the same reason the prompt is: a busy turn's
# rejections are the largest thing here and nothing reads them in bulk.
detail: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class StateEvent(Base):
"""M5: one accepted change to the authoritative narrative state.
The audit half of `DATA-MODEL.md` §17's hybrid. Append-only, ordered, and
**never read to reconstruct state** — that is the snapshot's job, and mixing
the two would make restore proportional to campaign length, which ADR 012
and M4 both forbid.
What this table answers is §8's list: what changed, why, which turn caused
it, whether a model or the user asserted it, and what the value was before.
`before` is stored per event rather than derived, because deriving it would
mean replaying — the thing the hybrid exists to avoid.
Events carry the story coordinate as well as the action id. The coordinate
survives a retry replacing the live take at that position, exactly as a Save
Point's does; the action id says which attempt actually proposed it.
"""
__tablename__ = "state_events"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("state_proposals.id", ondelete="SET NULL"), nullable=True
)
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Order within one proposal, so a turn's events replay for a reader in the
# order they were applied.
sequence: Mapped[int] = mapped_column(Integer, default=0)
event_type: Mapped[str] = mapped_column(String(60), default="")
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# What the affected value was immediately before this event, so the audit
# can answer "what did it used to be" without reconstruction. NULL when the
# event established something that did not exist.
before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class Memory(Base):
@@ -419,9 +593,10 @@ 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)
# 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.
# Phase 14, SP4: the RPG world state as it stood after this node was
# played. These columns record the node's outcome rather than its starting
# position. (`state_after` held the scripting engine's state, which M2
# removed; it is now always written empty.)
#
# Two operations need this outcome, and neither can use a snapshot taken
# before the turn. Switching between siblings must restore the state that
@@ -441,10 +616,75 @@ class Action(Base):
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# M5: the authoritative narrative state as it stood after this node played.
# The genre-neutral successor to `world_state_after`, and the reason Undo,
# Redo and Save Point restore stay bounded: a position's state is one row
# read, not a replay of every event since the campaign began
# (`TECHNICAL-DESIGN.md` §10.4, and the M4 note that made it load-bearing
# for Save Points too).
#
# `DATA-MODEL.md` §17 selects the hybrid — validated events for audit, a
# snapshot for reads and restore. `state_events` is the audit half; this
# column is the restore half, and nothing reconstructs a document from
# events.
#
# Deferred and compressed for the reasons `context_snapshot` is: only the
# single node being moved to reads it, and a document carrying a campaign's
# entities and facts is larger than the RPG dict it replaces. `world_delta`
# has an M5 counterpart in `state_changes` for the bulk read.
narrative_state_after: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
# The small slice needed in bulk: the events accepted here, the ones
# refused, and short lines for the chip under an AI message. Same role
# `world_delta` played, and a separate column for the same reason — the
# context builder reads it for every action in the replayed history, and
# the snapshot beside it is deferred so a turn never loads the prompt
# archive. Shape: {"accepted": [...], "rejected": [...], "summary": [...]}.
state_changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@property
def state_events_replay(self) -> list[dict]:
"""M5: the accepted events this turn produced, for replay into the prompt.
Read from `state_changes`' companion slice in the bulk-read column
rather than from the deferred snapshot, because the context builder
calls this for every action in the replayed history and loading the
prompt archive per action is the egress mistake this project keeps a
regression test about.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
events = changes.get("accepted")
return events if isinstance(events, list) else []
@property
def state_rejections(self) -> list[dict]:
"""M5: what this turn proposed that the application refused.
Fed back to the model as a correction for one turn only. A refusal it
has already had a chance to fix is stale, and repeating it forever would
price one bad turn into the rest of the campaign.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
rejected = changes.get("rejected")
return rejected if isinstance(rejected, list) else []
@property
def state_summary(self) -> list[str]:
"""M5: the short lines shown under an AI message: what changed here."""
changes = self.state_changes
if not isinstance(changes, dict):
return []
lines = changes.get("summary")
return [str(line) for line in lines] if isinstance(lines, list) else []
@property
def world_changes(self) -> list[dict]:
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
@@ -508,52 +748,6 @@ class Action(Base):
return out
class Script(Base):
__tablename__ = "scripts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
class AdventureScript(Base):
"""A script copied into an adventure at creation, so library edits don't
change running adventures unless the player explicitly re-syncs it from
`source_script_id`. `state` lives on Adventure.script_state (one shared
state per adventure, as in AI Dungeon)."""
__tablename__ = "adventure_scripts"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
# 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
)
position: Mapped[int] = mapped_column(Integer, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
name: Mapped[str] = mapped_column(String(200), default="Untitled Script")
description: Mapped[str] = mapped_column(Text, default="")
library_js: Mapped[str] = mapped_column(Text, default="")
input_js: Mapped[str] = mapped_column(Text, default="")
context_js: Mapped[str] = mapped_column(Text, default="")
output_js: Mapped[str] = mapped_column(Text, default="")
adventure: Mapped[Adventure] = relationship(back_populates="scripts")
class Settings(Base):
__tablename__ = "settings"
@@ -564,8 +758,10 @@ 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")
# Encrypted at rest with Fernet, which produces a value that starts with
# "enc:". See security.py. To read the key, use `api_key_plain`.
# Was a cloud provider's API key, encrypted at rest. Ollama does not use
# one and M2 removed cloud providers, so nothing reads or writes this now.
# The column stays so existing databases open unchanged; an old value is
# left where it is rather than migrated or decrypted.
api_key: Mapped[str] = mapped_column(String(500), default="")
model: Mapped[str] = mapped_column(String(200), default="")
api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion
@@ -573,12 +769,17 @@ class Settings(Base):
# 800 leaves room for a full scene; 400 tended to truncate mid-paragraph
# and left reasoning models with nothing after their thinking.
max_output_tokens: Mapped[int] = mapped_column(Integer, default=800)
# Separate thinking budget for reasoning models (OpenRouter-style
# `reasoning: {max_tokens}`); 0 = param not sent, -1 = reasoning explicitly
# off (`reasoning: {effort: none}`). Added on top of
# max_output_tokens so story output keeps its full budget.
# Was an OpenRouter-style thinking budget. Ollama's OpenAI-compatible
# endpoint ignores the field, so M2 stopped sending it and removed it from
# the Settings API and UI. The column stays so existing databases open
# unchanged and is never read.
reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0)
context_token_budget: Mapped[int] = mapped_column(Integer, default=16384)
# How long to wait for the model, in seconds, before giving up on a turn.
# A cold load of a mid-sized model on a CPU-only machine can take minutes,
# while the same turn takes seconds once the model is resident. See
# `providers.openai_compatible.DEFAULT_READ_TIMEOUT`.
model_timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
narrator_prompt: Mapped[str] = mapped_column(
Text,
default=(
@@ -599,120 +800,18 @@ class Settings(Base):
memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=80)
memory_top_k: Mapped[int] = mapped_column(Integer, default=5)
@property
def has_api_key(self) -> bool:
return bool(self.api_key)
@property
def api_key_plain(self) -> str:
from . import security # local import: models is imported before security
return security.decrypt_secret(self.api_key)
# ---------- Visit analytics (see analytics.py) ----------
# 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`.
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"
id: Mapped[int] = mapped_column(primary_key=True)
day: Mapped[str] = mapped_column(String(10), index=True) # YYYY-MM-DD, UTC
metric: Mapped[str] = mapped_column(String(32))
label: Mapped[str] = mapped_column(String(80), default="")
hits: Mapped[int] = mapped_column(Integer, default=0)
# The upsert target: one row per bucket per day, created or incremented.
__table_args__ = (
UniqueConstraint("day", "metric", "label", name="uq_analytics_daily_bucket"),
)
class AnalyticsVisitorDay(Base):
"""One visitor, one day, and which funnel steps they reached on it.
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"
id: Mapped[int] = mapped_column(primary_key=True)
day: Mapped[str] = mapped_column(String(10))
# HMAC of the user id under the app secret; not reversible, not a key.
visitor: Mapped[str] = mapped_column(String(32))
is_new: Mapped[bool] = mapped_column(Boolean, default=False)
opened: Mapped[bool] = mapped_column(Boolean, default=False)
created: Mapped[bool] = mapped_column(Boolean, default=False)
played: Mapped[bool] = mapped_column(Boolean, default=False)
signed_up: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
UniqueConstraint("day", "visitor", name="uq_analytics_visitor_day"),
Index("ix_analytics_visitor", "visitor"),
)
class AccessEvent(Base):
"""One sign-in, registration, failed attempt, or session first-seen.
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 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"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
# session | login | register | login_failed
kind: Mapped[str] = mapped_column(String(16))
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 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
country: Mapped[str] = mapped_column(String(16), default="")
device: Mapped[str] = mapped_column(String(16), default="")
user_agent: Mapped[str] = mapped_column(String(200), default="")
# Phase 14: the fallback under `tree.place_action`.
# The hosted visitor dashboard's two counter tables and the access log that
# recorded sign-ins, addresses and devices used to be mapped here. M2 removed
# the hosted deployment they served.
#
# 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.
#
# 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.
# The tables are left in the database rather than dropped: they are inert,
# nothing reads or writes them, and a destructive migration would risk an
# existing campaign database for tidiness alone. They are not product
# functionality.
@event.listens_for(Session, "before_flush")
def _place_new_nodes_on_the_tree(session, flush_context, instances):
from . import tree
+23
View File
@@ -0,0 +1,23 @@
"""M5: the authoritative narrative state.
Genre-neutral state (ADR 006), written by explicit typed events with absolute
values (ADR 010), owned by the application rather than the model (ADR 003), and
recovered per story position rather than replayed (ADR 012 and
`TECHNICAL-DESIGN.md` §10.4).
extract.split(reply) prose out, proposal out, block kept for audit
|
validate.review(...) allowlist, schema, references, semantics
|
apply.apply_events(...) accepted events -> a new state document
|
store.commit_proposal(...) events, provenance and snapshot, in one transaction
`model.py` says what a state document is. `render.py` shows it to the model and
to the reader. Nothing outside this package writes authoritative state, and
nothing inside it executes anything a proposal names.
"""
from . import apply, events, extract, model, render, store, validate # noqa: F401
__all__ = ["apply", "events", "extract", "model", "render", "store", "validate"]
+272
View File
@@ -0,0 +1,272 @@
"""M5: turning accepted events into a new state document.
Pure and total. Every function here takes a document and returns a new one; none
touches the database, and none can fail on an event `validate.review` accepted —
validation is the only place an event is refused, so this module never has to
decide anything twice.
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
table keyed on the payload. The difference matters: a table maps a string a model
supplied to a callable, and the security of that arrangement rests entirely on
the allowlist being correct. A chain of literal comparisons cannot be steered by
a payload at all, whatever the allowlist does.
"""
from __future__ import annotations
import copy
from . import model
def apply_events(
state: dict,
accepted: list[dict],
*,
branch_id: int | None = None,
depth: int | None = None,
source: str = "accepted_story",
) -> dict:
"""Returns `state` with every event in `accepted` applied, in order.
The input document is never mutated: head movement stores snapshots by
reference in places, and a mutation here would edit the past.
`branch_id`/`depth` stamp facts and relationships with where they were
established, which is what makes the audit trail answer "which turn caused
this" without a join. `source` records whether the campaign, the story or
the user established it — C04's provenance, carried on the value itself.
"""
document = model.normalize(state)
for event in accepted:
_apply_one(document, event, branch_id, depth, source)
return document
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
kind = event["type"]
if kind == "create_entity":
state["entities"][event["entity"]] = model.new_entity(
type=event.get("entity_type") or "other",
name=event["name"],
description=event.get("description") or "",
aliases=event.get("aliases") or [],
)
elif kind == "set_entity_status":
_entity(state, event["entity"])["status"] = event["status"]
elif kind == "set_entity_attribute":
# Absolute assignment. The whole reason ADR 010 exists.
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
elif kind == "set_entity_conditions":
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
elif kind == "set_current_location":
_entity(state, event["entity"])["location"] = event["location"]
elif kind == "set_possession":
state["possessions"][event["item"]] = event["owner"]
elif kind == "clear_possession":
state["possessions"].pop(event["item"], None)
elif kind == "add_fact":
state["facts"].append({
"id": event.get("fact_id") or _fact_id(state),
"subject": event.get("subject"),
"predicate": event["predicate"],
"object": event.get("object"),
"value": event.get("value"),
"authority": _authority(source),
"source": source,
"status": "active",
"branch_id": branch_id,
"depth": depth,
})
elif kind == "invalidate_fact":
for fact in state["facts"]:
if fact.get("id") == event["fact_id"]:
# Withdrawn, not removed: C04 needs the record of what the
# campaign used to believe, and a deleted row audits nothing.
fact["status"] = "invalidated"
fact["invalidated_by"] = source
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
if event.get("reason"):
fact["invalidated_reason"] = event["reason"]
elif kind == "add_relationship":
state["relationships"].append({
"id": _relationship_id(state),
"source": event["source"],
"target": event["target"],
"type": event["relationship"],
"description": event.get("description") or "",
"status": "active",
"established_by": source,
"branch_id": branch_id,
"depth": depth,
})
elif kind == "end_relationship":
for relationship in state["relationships"]:
if (
relationship.get("source") == event["source"]
and relationship.get("target") == event["target"]
and relationship.get("type") == event["relationship"]
and relationship.get("status") == "active"
):
relationship["status"] = "ended"
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "open_story_thread":
state["threads"][event["thread"]] = {
"title": event["title"],
"description": event.get("description") or "",
"status": "open",
"opened_at": {"branch_id": branch_id, "depth": depth},
}
elif kind == "resolve_story_thread":
thread = state["threads"].get(event["thread"])
if isinstance(thread, dict):
thread["status"] = "resolved"
thread["resolution"] = event.get("resolution") or ""
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "set_scene":
scene = dict(state.get("scene") or {})
if "summary" in event:
scene["summary"] = event["summary"]
if "location" in event:
scene["location"] = event["location"]
if "present" in event:
scene["present"] = list(event["present"] or [])
scene["at"] = {"branch_id": branch_id, "depth": depth}
state["scene"] = scene
# No `else`. Every allowed type is handled above, and an unhandled one
# cannot arrive: `validate.review` refuses anything outside the allowlist,
# and the allowlist is this list. A silent fall-through would be the one way
# an event could appear accepted and do nothing.
def _entity(state: dict, key: str) -> dict:
"""The entity record for `key`, created bare if a snapshot lost it.
Validation guarantees the entity exists, so this is a repair path for a
hand-edited or partially imported document rather than a normal branch. A
bare record is better than a KeyError: the story is still readable, and the
inspector shows an entity with nothing known about it, which is true.
"""
entities = state["entities"]
found = entities.get(key)
if not isinstance(found, dict):
found = model.new_entity(name=key)
entities[key] = found
found.setdefault("attributes", {})
found.setdefault("conditions", [])
return found
def _authority(source: str) -> str:
"""Which authority band a source's assertions carry.
A user's correction outranks the story (C04); the story outranks a guess.
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
"""
if source == "manual_correction":
return "manual_correction"
if source == "campaign_canon":
return "campaign_canon"
return "accepted_story"
def _fact_id(state: dict) -> str:
return f"f{len(state['facts']) + 1}"
def _relationship_id(state: dict) -> str:
return f"r{len(state['relationships']) + 1}"
def diff(before: dict, after: dict) -> list[str]:
"""A short human-readable list of what changed between two documents.
Shown under a turn the way the world-state chip used to be, and recorded on
the node for the bulk read. Text rather than structure, because its only
consumer is a person reading "Aldric now holds the silver key".
"""
before = model.normalize(before)
after = model.normalize(after)
lines: list[str] = []
for key, entity in after["entities"].items():
was = before["entities"].get(key)
name = model.entity_name(after, key)
if was is None:
lines.append(f"{name} enters the story")
continue
if was.get("status") != entity.get("status"):
lines.append(f"{name} is now {entity.get('status')}")
if was.get("location") != entity.get("location") and entity.get("location"):
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
now = ", ".join(entity.get("conditions") or []) or "nothing"
lines.append(f"{name}: {now}")
for attribute, value in (entity.get("attributes") or {}).items():
if (was.get("attributes") or {}).get(attribute) != value:
lines.append(f"{name} {attribute} = {value}")
for item, owner in after["possessions"].items():
if before["possessions"].get(item) != owner:
lines.append(
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
)
for item in before["possessions"]:
if item not in after["possessions"]:
lines.append(f"{model.entity_name(after, item)} is held by nobody")
known = {f.get("id") for f in before["facts"]}
for fact in after["facts"]:
if fact.get("id") not in known:
lines.append(f"fact: {_fact_text(after, fact)}")
was_active = {f["id"] for f in model.active_facts(before)}
for fact in before["facts"]:
if fact.get("id") in was_active and fact.get("id") not in {
f["id"] for f in model.active_facts(after)
}:
lines.append(f"withdrawn: {_fact_text(after, fact)}")
known = {r.get("id") for r in before["relationships"]}
for relationship in after["relationships"]:
if relationship.get("id") not in known:
lines.append(
f"{model.entity_name(after, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(after, relationship['target'])}"
)
for key, thread in after["threads"].items():
was = before["threads"].get(key)
if was is None:
lines.append(f"opened: {thread.get('title', key)}")
elif was.get("status") != thread.get("status"):
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
return lines
def _fact_text(state: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(state, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(state, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
return " ".join(p for p in parts if p)
+194
View File
@@ -0,0 +1,194 @@
"""M5: the typed event vocabulary, and the allowlist that bounds it.
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
architectural: a number in a delta field is syntactically legal whether the
model meant "add 50" or "set to 50", and no validator can tell which. Every
event here therefore states its operation in its `type`, and every value it
carries is **absolute**. There is no event whose meaning depends on a prompt
instruction having been followed.
## The allowlist is a security boundary, not a convenience
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
the entire set of things a model may cause to happen. H05's
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
recognised and blocked, but because it is not in `SPECS`, and nothing outside
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
name-to-callable lookup that a payload could steer.
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
in the application can widen the vocabulary, which is what keeps
"state extraction" from drifting into "tool execution".
## Shape of a spec
required fields that must be present and non-empty
optional fields that may be present
refs fields naming an entity that must already exist
creates the field naming an entity this event may bring into being
`refs` is what `validate.py` uses for referential integrity, and `creates` is
the deliberate exception: exactly one event type may introduce an entity, so a
typo in any other event surfaces as an unknown reference rather than silently
creating a second, empty Mara.
"""
from __future__ import annotations
# Field types the schema layer enforces. Kept deliberately small: a narrative
# state event carries names, labels and plain values, and nothing here needs a
# nested structure a model could hide something inside.
TEXT = "text"
KEY = "key" # an entity/thread identifier: a slug the campaign chose
VALUE = "value" # a JSON scalar — str, int, float, bool or None
LABELS = "labels" # a list of short strings
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
SPECS: dict[str, dict] = {
"create_entity": {
"required": {"entity": KEY, "name": TEXT},
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
"refs": (),
"creates": "entity",
"summary": "brings a person, place, thing or group into the story",
},
"set_entity_status": {
"required": {"entity": KEY, "status": TEXT},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets whether an entity is active, gone, destroyed …",
},
"set_entity_attribute": {
# The one numeric-capable event, and it is an assignment. ADR 010's
# `set_value`: the operation is in the name, so a value of 50 can only
# mean fifty. An `increment_value` could be added later without
# ambiguity, because it would be a different `type`.
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets a named value on an entity, absolutely",
},
"set_entity_conditions": {
# Absolute too: the full set replaces the old one. "Add a condition"
# would need the current set to be known by the model, which is exactly
# the assumption that made deltas unreliable.
"required": {"entity": KEY, "conditions": LABELS},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "replaces the conditions an entity is under",
},
"set_current_location": {
"required": {"entity": KEY, "location": KEY},
"optional": {},
"refs": ("entity", "location"),
"creates": None,
"summary": "moves an entity to a location",
},
"set_possession": {
"required": {"item": KEY, "owner": KEY},
"optional": {},
"refs": ("item", "owner"),
"creates": None,
"summary": "gives an item to an owner",
},
"clear_possession": {
"required": {"item": KEY},
"optional": {},
"refs": ("item",),
"creates": None,
"summary": "leaves an item held by nobody",
},
"add_fact": {
"required": {"predicate": TEXT},
"optional": {
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
},
# Only the subject is checked as an entity. The *object* of a fact is
# routinely not one — "Mara knows where the key was found" has another
# fact as its object, and C03 needs exactly that — so it is checked
# against entities *and* known facts in `validate._check`. Requiring an
# entity here would make the knowledge distinction C03 asks for
# unrepresentable.
"refs": ("subject",),
"creates": None,
"summary": "asserts something about the world",
},
"invalidate_fact": {
"required": {"fact_id": TEXT},
"optional": {"reason": TEXT},
"refs": (),
"creates": None,
"summary": "withdraws a fact without deleting the record of it",
},
"add_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {"description": TEXT},
"refs": ("source", "target"),
"creates": None,
"summary": "ties two entities together",
},
"end_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {},
"refs": ("source", "target"),
"creates": None,
"summary": "ends a tie without erasing that it existed",
},
"open_story_thread": {
"required": {"thread": KEY, "title": TEXT},
"optional": {"description": TEXT},
"refs": (),
"creates": None,
"summary": "records narrative business left open",
},
"resolve_story_thread": {
"required": {"thread": KEY},
"optional": {"resolution": TEXT},
"refs": (),
"creates": None,
"summary": "closes narrative business",
},
"set_scene": {
"required": {},
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
"refs": ("location",),
"creates": None,
"summary": "records the immediate situation",
},
}
#: The allowlist itself, as a set, for the one question that matters most.
ALLOWED = frozenset(SPECS)
def is_allowed(event_type) -> bool:
"""Whether `event_type` names an event this application will ever apply.
A string is required: a dict, a list or None is not a type, and coercing one
with `str()` would turn a malformed payload into a lookup that might
accidentally succeed.
"""
return isinstance(event_type, str) and event_type in ALLOWED
def spec(event_type: str) -> dict | None:
return SPECS.get(event_type)
def vocabulary_for_prompt() -> str:
"""The event list as the narrator prompt describes it.
Generated from `SPECS` rather than written out beside it, so the model can
never be told about an event the application does not implement — the drift
that would produce proposals rejected for reasons nobody could see.
"""
lines = []
for name, definition in SPECS.items():
fields = list(definition["required"]) + [
f"{field}?" for field in definition["optional"]
]
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
return "\n".join(lines)
+259
View File
@@ -0,0 +1,259 @@
"""M5: getting a typed proposal out of a narration, and keeping it out of the prose.
The model writes the story and, after it, one fenced block of typed events. This
module holds the instruction it is given, the parser that survives the ways a
model gets a format wrong, and the separation that keeps machine-readable output
from reaching the reader.
Two properties matter more than elegance here:
* **The prose must never carry the protocol.** A reader should not see a JSON
block under their story, and a stored narration should not contain one either,
because everything downstream — memory, summaries, export, the transcript —
treats stored text as the story. The block is removed before the text is
stored, not before it is displayed.
* **An unreadable block must not be a failed turn.** A narration the user watched
arrive is worth keeping even when the state block after it is garbage. Parsing
returns "no events" rather than raising, the turn commits with the state
unchanged, and the proposal record keeps the raw output so the failure is
visible in the audit rather than only in a log.
"""
from __future__ import annotations
import json
import re
from . import events
# The block the model is asked to append. Built from the vocabulary rather than
# written beside it, so the instruction cannot describe an event the application
# would then reject (`events.vocabulary_for_prompt`).
EMIT_RULE = (
"After your narration, append a fenced code block labelled `state` containing "
"a JSON object with an \"events\" list, recording what your own narration made "
"true. Treat your narration as authoritative: if you wrote that someone moved, "
"took something, learned something, was hurt, or that a new person or place "
"appeared, record it.\n"
"\n"
"Every value is ABSOLUTE — the new state of things, never a change or a "
"difference. Use only these events:\n"
f"{events.vocabulary_for_prompt()}\n"
"\n"
"Identifiers are short lower-case slugs (mara, silver-key, old-abbey) and must "
"match the ones already in the state you were shown. Introduce a person, place "
"or thing with create_entity before referring to it. If the turn established "
"nothing, send an empty events list.\n"
"Example:\n"
'```state\n'
'{"events": [{"type": "set_possession", "item": "silver-key", "owner": "aldric"},'
' {"type": "set_current_location", "entity": "aldric", "location": "old-abbey"}]}\n'
'```'
)
# Placed last, where recency is strongest, the same way the delta protocol did.
EMIT_REMINDER = (
"[Reminder: end your reply with a ```state block listing the events your "
"narration made true, with absolute values. Send an empty events list if "
"nothing changed.]"
)
# Three patterns, and the difference between them is the whole of this module's
# safety. A story is allowed to contain code, and taking a code block out of
# someone's prose is a worse failure than leaving a stray proposal in it.
#
# `state` is the label the application asks for, so a fence carrying it is ours
# whatever is inside it — including a truncated `{oh no` that no JSON parser
# will take. That block must still leave the prose, and must still be recorded,
# because an unparseable proposal is exactly the failure the audit exists to
# make visible.
_STATE_FENCE_RE = re.compile(
r"```state[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# `json` is *not* our label. Models reach for it anyway, so a ```json fence is
# taken only when what it contains is actually a proposal. A character who
# writes `{"name": "Mara"}` into a terminal keeps their code block (M5 review,
# Finding 6).
_JSON_FENCE_RE = re.compile(
r"```json[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# An *unlabelled* fence is ours on the same terms: it has to be a proposal, not
# merely JSON-shaped.
_BARE_FENCE_RE = re.compile(r"```\s*([\[{].*?[\]}])\s*```", re.DOTALL)
# A bare object hugging the end of the text, for a model that forgets the fence.
_TRAILING_RE = re.compile(r"(\{.*\})\s*$", re.DOTALL)
# An opener with no closing fence. A model that runs out of output tokens
# mid-block leaves one of these, and everything after it is protocol rather than
# story — so the story ends where the opener begins.
#
# Our own label ends the story unconditionally. A dangling ```json fence is
# judged on what follows it, because an unterminated code block in a story is
# still the author's (M5 review, Finding 6).
_DANGLING_STATE_RE = re.compile(r"\n?```state\b.*\Z", re.DOTALL | re.IGNORECASE)
_DANGLING_JSON_RE = re.compile(r"\n?```json\b(.*)\Z", re.DOTALL | re.IGNORECASE)
# The reminder, parroted back. Small local models reproduce the bracketed
# instruction they were given, and it arrives as ordinary prose — no fence, so
# nothing above strips it, and the reader is shown a piece of the prompt.
#
# The bracket is *found* broadly and *judged* narrowly. Merely naming the
# protocol is not enough: a story may end on an aside about a state block, and
# deleting that sentence is the worse failure (M5 review, Finding 6). What marks
# the echo is the shape of the instruction itself — the fence token, the word it
# opens with, or the pair of phrases the reminder uses together.
_TRAILING_BRACKET_RE = re.compile(r"\n?\[([^\]]*)\]\s*\Z", re.DOTALL)
def _is_echoed_instruction(inner: str) -> bool:
"""Whether a trailing bracketed segment is the prompt's own reminder."""
low = inner.lower()
if "```state" in low:
return True
if low.lstrip().startswith("reminder:"):
return True
# The reminder names both; prose about the protocol rarely names either the
# way the instruction does, and effectively never both.
return "state block" in low and "events list" in low
def _clean(prose: str) -> str:
"""Removes protocol the block extraction could not, and nothing else.
Found by the M5 realistic-context run (§12), which is the failure class
Phase 0B warned about: under a full prompt the model echoed its own
instruction into the narration, and the reader would have been shown it.
Neither case here is hypothetical — both were observed against a real local
model.
"""
cleaned = prose
bracket = _TRAILING_BRACKET_RE.search(cleaned)
if bracket is not None and _is_echoed_instruction(bracket.group(1)):
cleaned = cleaned[: bracket.start()]
cleaned = _DANGLING_STATE_RE.sub("", cleaned)
dangling = _DANGLING_JSON_RE.search(cleaned)
if dangling is not None and _reads_as_protocol(dangling.group(1)):
cleaned = cleaned[: dangling.start()]
return cleaned.strip()
def _reads_as_protocol(tail: str) -> bool:
"""Whether a truncated fence was on its way to being a proposal."""
if '"events"' in tail:
return True
return any(f'"{name}"' in tail for name in events.SPECS)
def _tolerant_load(blob: str):
"""Parses a block, forgiving what small local models get wrong.
Trailing commas and a leading `+` on a number are both common and both
rejected by strict JSON. Repairing them is not guessing at meaning — the
intended value is unambiguous — which is the line this function stays on the
right side of. Anything it cannot parse returns None, and the caller treats
that as no proposal rather than as an empty one.
"""
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
return parsed
def split(text: str) -> tuple[str, dict | None, str]:
"""Separates a reply into `(prose, proposal, raw_block)`.
`proposal` is None when there is no block or it cannot be parsed at all,
which the caller records as a malformed proposal. `raw_block` is what the
model actually wrote, kept for the audit record even — especially — when it
did not parse.
A bare trailing object is only stripped when it parses *and* looks like a
proposal. Prose that happens to end in a brace is left alone, because
removing a sentence from someone's story to satisfy a regex is a worse
failure than leaving a stray brace in it.
"""
matches = list(_STATE_FENCE_RE.finditer(text))
if matches:
match = matches[-1]
raw = match.group(1).strip()
prose = _clean(text[: match.start()] + text[match.end():])
return prose, _tolerant_load(raw), raw
# A `json` or unlabelled fence is ours only when its contents are this
# protocol. That is judged two ways, and it needs both: a block that parses
# into a proposal, or one that plainly reads as protocol even though it does
# not parse. The second half matters — a small model that mangles its own
# JSON must not have the wreckage shown to the reader, which is what the
# realistic-model run caught during the corrective pass.
for pattern in (_JSON_FENCE_RE, _BARE_FENCE_RE):
for match in reversed(list(pattern.finditer(text))):
raw = match.group(1).strip()
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed) or _reads_as_protocol(raw):
prose = _clean(text[: match.start()] + text[match.end():])
return prose, parsed, raw
match = _TRAILING_RE.search(text)
if match:
raw = match.group(1)
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed):
return _clean(text[: match.start()]), parsed, raw
# No block at all — but the reply may still carry protocol the model wrote
# as prose, or a fence it never closed.
cleaned = _clean(text)
if cleaned != text.strip():
return cleaned, None, text.strip()[len(cleaned):].strip()
return cleaned, None, ""
def _looks_like_proposal(parsed) -> bool:
"""Whether a bare trailing object is this protocol rather than prose."""
if not isinstance(parsed, dict):
return False
if isinstance(parsed.get("events"), list):
return True
return isinstance(parsed.get("type"), str) and events.is_allowed(parsed["type"])
def render_block(accepted: list[dict]) -> str:
"""Renders accepted events back into the block the model emitted.
Replayed into the prompt for past turns so the model copies the format it is
being asked for. **Accepted** events rather than proposed ones, for the
reason the delta protocol learned the hard way: showing the model a refused
event standing as though it had worked, contradicted by the state in the
same prompt, teaches it to send the event again.
"""
if not accepted:
return ""
return "```state\n" + json.dumps({"events": accepted}, ensure_ascii=False) + "\n```"
def render_rejections(rejected: list[dict]) -> str:
"""The correction note appended after the most recent AI turn.
Only what was lost. A model that is told what it got wrong can fix it next
turn; a model told nothing repeats it.
"""
if not rejected:
return ""
lines = []
for entry in rejected[:6]:
if not isinstance(entry, dict):
continue
detail = entry.get("detail") or entry.get("reason") or ""
if detail:
lines.append(f"- {detail}")
if not lines:
return ""
body = "\n".join(lines)
return (
"[Part of your last state block was not accepted. Correct it in this "
f"turn's block:\n{body}]"
)
+307
View File
@@ -0,0 +1,307 @@
"""M5: the authoritative narrative state, and what shape it has.
This is the genre-neutral state ADR 006 requires and ADR 010's typed events
write into. It replaces the inherited RPG world state, which assumed stats,
bands, cooldowns and per-turn delta caps — assumptions that are a *game system*,
not a story.
## What a state document is
One JSON document per story position, holding what the campaign currently
believes:
entities the things that exist: who, where, what
possessions which entity holds which item
facts assertions about the world, with an authority
relationships directed ties between entities
threads narrative business that is open or resolved
scene the immediate situation
Nothing here names a genre. A character, a location, an organization, an item
and a vehicle are all `entities` with a `type`, which is a descriptive label the
campaign chooses, not a branch in the code (`DATA-MODEL.md` §9). The same
document holds Aldric in an abbey and the Persephone at Ceres Station, and
`J03` is satisfied because moving between them is data.
## Why a document rather than normalised tables
`DATA-MODEL.md` §17 selects the **hybrid**: validated events for audit, plus a
snapshot for reads and restore. M3 and M4 make that choice load-bearing rather
than an optimisation. Every position in a retained story must be recoverable in
bounded time — `TECHNICAL-DESIGN.md` §10.4 — because Undo, Redo and Save Point
restore all resolve a coordinate and read the state recorded there. Current-value
tables would leave the *future's* values standing when the head moves back, which
`BUILD-MILESTONES.md` M5 forbids in as many words, and rebuilding them would mean
replaying the campaign.
So the authoritative current state is this document, snapshotted per node exactly
as the world state was, and the event log beside it is the audit record rather
than the reconstruction path. The events say *why* the document changed; the
document says what is true now.
Everything in this module is pure. It builds and reads documents; it does not
touch the database, and it does not decide whether a proposal is acceptable —
that is `validate.py`, and applying an accepted event is `apply.py`.
"""
from __future__ import annotations
import copy
# The document version, so a later milestone can migrate a stored snapshot
# without guessing what it was written by. Bump only for a shape change that a
# reader cannot infer.
VERSION = 1
# Entity categories the product suggests. This is a vocabulary, not a
# constraint: `DATA-MODEL.md` §9 calls these "descriptive categories, not
# separate game systems", so an unknown type is accepted and simply described.
# Rejecting one would make the schema genre-specific by the back door.
SUGGESTED_TYPES = (
"character", "location", "organization", "item", "vehicle",
"creature", "structure", "concept", "other",
)
# Entity lifecycle status. `DATA-MODEL.md` §9.
ENTITY_STATUSES = ("active", "inactive", "destroyed", "dead", "unknown")
# Where a fact came from, in descending authority. `DATA-MODEL.md` §14 lists the
# minimum categories; the order here is what a later context builder ranks by.
AUTHORITIES = (
"campaign_canon", # the campaign's own rules — the highest
"manual_correction", # the user said so, explicitly (C04)
"accepted_story", # derived from narration the user accepted
"current_state",
"imported_canon", # M7
"reference", # M7
"heuristic",
"inspiration", # M7
)
FACT_STATUSES = ("active", "superseded", "disputed", "invalidated")
THREAD_STATUSES = ("open", "dormant", "resolved", "abandoned")
RELATIONSHIP_STATUSES = ("active", "ended")
def empty() -> dict:
"""A campaign that has established nothing yet.
Every key is present, so no reader needs a `.get` with a default and no
writer has to decide whether a section exists. An empty document is a real
document, not a missing one.
"""
return {
"version": VERSION,
"entities": {},
"possessions": {},
"facts": [],
"relationships": [],
"threads": {},
"scene": {},
}
def normalize(state) -> dict:
"""Returns `state` as a well-formed document, repairing what it can.
Called on every read of a stored snapshot. A document can arrive from a
hand-edited database, an imported bundle, or a snapshot written by an older
version of this module, and a read must not raise on any of them: the story
is the valuable thing, and a malformed state section should cost the
section, not the campaign.
Repair is deliberately shallow — wrong-typed sections are replaced with
empty ones rather than coerced, because guessing what a malformed section
meant is exactly the kind of invention `§19` of the M5 brief forbids.
"""
if not isinstance(state, dict):
return empty()
out = empty()
out["version"] = state.get("version") if isinstance(state.get("version"), int) else VERSION
for key in ("entities", "possessions", "threads", "scene"):
value = state.get(key)
if isinstance(value, dict):
out[key] = copy.deepcopy(value)
for key in ("facts", "relationships"):
value = state.get(key)
if isinstance(value, list):
out[key] = copy.deepcopy([item for item in value if isinstance(item, dict)])
return out
def is_empty(state) -> bool:
"""Whether a document says nothing about the world.
`version` alone does not count as content, so a freshly created campaign
reads as empty and the prompt builder can leave the section out entirely
rather than showing a heading with nothing under it.
"""
document = normalize(state)
return not any(
document[key] for key in
("entities", "possessions", "facts", "relationships", "threads", "scene")
)
# ------------------------------------------------------------------ entities
def entity(state: dict, key: str) -> dict | None:
"""Returns the entity stored under `key`, or None."""
entities = state.get("entities")
if not isinstance(entities, dict):
return None
found = entities.get(key)
return found if isinstance(found, dict) else None
def entity_name(state: dict, key: str) -> str:
"""The display name for `key`, falling back to the key itself.
A key is a slug the campaign chose, so it is readable enough to show when an
entity was referenced before it was described.
"""
found = entity(state, key)
if found and isinstance(found.get("name"), str) and found["name"].strip():
return found["name"]
return key
def new_entity(
*, type: str = "other", name: str = "", description: str = "",
status: str = "active", aliases: list | None = None,
) -> dict:
return {
"type": type or "other",
"name": name,
"description": description,
"status": status or "active",
"aliases": list(aliases or []),
# Where this entity currently is, as another entity's key. None means
# the campaign has not placed it, which is different from placing it
# nowhere.
"location": None,
# Free-form condition labels: "injured", "depressurised", "asleep".
# Labels rather than numbers, because a number implies a scale and a
# scale implies a game system.
"conditions": [],
# Named values the campaign cares about. Genre-neutral by construction:
# the campaign chooses the names, and every write is an absolute
# assignment (ADR 010).
"attributes": {},
}
def entities_of_type(state: dict, wanted: str) -> dict:
"""Every entity whose `type` matches, keyed as they are stored."""
entities = state.get("entities")
if not isinstance(entities, dict):
return {}
return {
key: value for key, value in entities.items()
if isinstance(value, dict) and value.get("type") == wanted
}
# --------------------------------------------------------------- possessions
def owner_of(state: dict, item_key: str) -> str | None:
"""Which entity holds `item_key`, or None if nobody does.
Possession is stored as one map from item to owner rather than as a list per
owner, because an item has exactly one holder and the map makes that
structural. Two owners for one item is then unrepresentable rather than
merely invalid.
"""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return None
owner = possessions.get(item_key)
return owner if isinstance(owner, str) else None
def held_by(state: dict, owner_key: str) -> list[str]:
"""Every item `owner_key` currently holds, in stable order."""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return []
return sorted(
item for item, owner in possessions.items() if owner == owner_key
)
# ------------------------------------------------------------------- facts
def withdrawn_facts(state: dict) -> list[dict]:
"""Facts a correction or retcon took back, newest last.
The prompt needs these as well as the ones that stand. Dropping a withdrawn
fact silently leaves the narration that first asserted it as the only
account in the prompt, and the model reads surviving prose as current truth
(M5 review, Finding 4). Naming the withdrawal is what makes the reader's
correction win.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status") == "invalidated"
]
def active_facts(state: dict) -> list[dict]:
"""Facts that still stand, newest last.
An invalidated fact stays in the document rather than being removed. C04
requires a correction to be auditable, and a fact that vanished would leave
nothing to audit — the record of what the campaign used to believe is the
point.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status", "active") == "active"
]
def facts_about(state: dict, subject_key: str) -> list[dict]:
return [f for f in active_facts(state) if f.get("subject") == subject_key]
def knows(state: dict, subject_key: str, object_key: str) -> bool:
"""Whether an accepted fact says `subject` knows `object`.
C03's question, asked the way the state model can answer it. "The campaign
knows X" is a fact with no subject; "Mara knows X" is a fact whose subject
is Mara. The distinction is structural, so nothing has to infer it.
"""
return any(
fact.get("predicate") == "knows" and fact.get("object") == object_key
for fact in facts_about(state, subject_key)
)
# ----------------------------------------------------------- relationships
def active_relationships(state: dict) -> list[dict]:
relationships = state.get("relationships")
if not isinstance(relationships, list):
return []
return [
r for r in relationships
if isinstance(r, dict) and r.get("status", "active") == "active"
]
# ---------------------------------------------------------------- threads
def open_threads(state: dict) -> dict:
threads = state.get("threads")
if not isinstance(threads, dict):
return {}
return {
key: value for key, value in threads.items()
if isinstance(value, dict) and value.get("status", "open") in ("open", "dormant")
}
+291
View File
@@ -0,0 +1,291 @@
"""M5: showing the narrative state — to the model, and to the reader.
Two audiences, one document, and they want different things. The model needs the
state compactly, in the vocabulary it must answer in, close to where it
generates. The reader needs it grouped and named, in the words the campaign uses.
Both are read-only views. Neither can change state, and the browser gets its own
data from the API rather than from anything assembled here, because
`BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and
must not become the owner of state.
"""
from __future__ import annotations
from . import model
# How much of a long section reaches the prompt. A campaign accumulates facts
# faster than it accumulates anything else, and the context budget is finite;
# the newest are the ones the current scene is most likely to need. M6 owns
# retrieval-ranked selection, so this is deliberately a simple recency cut and
# is documented as such rather than pretending to be a relevance model.
PROMPT_FACTS = 30
PROMPT_RELATIONSHIPS = 20
PROMPT_THREADS = 12
def for_prompt(state) -> str:
"""The current state as the narrator is shown it.
Empty string when the campaign has established nothing, so a new story's
prompt carries no heading with nothing under it.
"""
document = model.normalize(state)
if model.is_empty(document):
return ""
lines: list[str] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
where = scene.get("location")
head = "Scene: " + str(scene.get("summary") or "").strip()
if where:
head += f" (at {model.entity_name(document, where)})"
lines.append(head.strip())
entities = document["entities"]
if entities:
lines.append("")
lines.append("Who and what exists:")
for key, entity in entities.items():
lines.append(f" {key}: {_entity_line(document, key, entity)}")
possessions = document["possessions"]
if possessions:
lines.append("")
lines.append("Held:")
for item, owner in sorted(possessions.items()):
lines.append(
f" {model.entity_name(document, item)} — "
f"{model.entity_name(document, owner)}"
)
facts = model.active_facts(document)
if facts:
lines.append("")
lines.append("Established:")
for fact in facts[-PROMPT_FACTS:]:
lines.append(f" {_fact_line(document, fact)}")
# What the campaign has taken back. Placed straight after what stands, so
# the contradiction is resolved in the same breath it could be raised: the
# story above may still narrate the moment, and this says it did not hold
# (C04, M5 review Finding 4).
withdrawn = model.withdrawn_facts(document)
if withdrawn:
lines.append("")
lines.append("No longer true — do not treat these as established:")
for fact in withdrawn[-PROMPT_FACTS:]:
line = f" {_fact_line(document, fact)}"
reason = fact.get("invalidated_reason")
if reason:
line += f" — {reason}"
lines.append(line)
relationships = model.active_relationships(document)
if relationships:
lines.append("")
lines.append("Between them:")
for relationship in relationships[-PROMPT_RELATIONSHIPS:]:
lines.append(
f" {model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
)
threads = model.open_threads(document)
if threads:
lines.append("")
lines.append("Still open:")
for key, thread in list(threads.items())[:PROMPT_THREADS]:
lines.append(f" {key}: {thread.get('title', key)}")
return "\n".join(lines).strip()
def _entity_line(document: dict, key: str, entity: dict) -> str:
parts = [entity.get("name") or key]
kind = entity.get("type")
if kind and kind != "other":
parts.append(f"({kind})")
status = entity.get("status")
if status and status != "active":
parts.append(f"[{status}]")
where = entity.get("location")
if where:
parts.append(f"at {model.entity_name(document, where)}")
conditions = entity.get("conditions") or []
if conditions:
parts.append("— " + ", ".join(conditions))
attributes = entity.get("attributes") or {}
if attributes:
parts.append(
"— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items()))
)
return " ".join(str(p) for p in parts)
def _fact_line(document: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(document, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(document, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
line = " ".join(str(p) for p in parts if p)
if fact.get("authority") == "manual_correction":
# The reader corrected this. Saying so in the prompt is what stops the
# model re-deriving the thing the correction removed.
line += " [corrected by the player]"
return line
def for_inspector(state) -> dict:
"""The current state grouped for the browser panel.
Only categories that actually hold something are returned, so the panel can
render what it is given without deciding what to hide — a category with no
rows is a heading that tells the reader nothing.
Every entry carries the key as well as the name. The key is what a manual
correction has to name, so the panel can offer a correction without the user
having to guess at an identifier.
"""
document = model.normalize(state)
groups: list[dict] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
rows = []
if scene.get("summary"):
rows.append({"key": "summary", "label": str(scene["summary"])})
if scene.get("location"):
rows.append({
"key": scene["location"],
"label": model.entity_name(document, scene["location"]),
"detail": "location",
})
groups.append({"title": "Current Scene", "rows": rows})
by_type: dict[str, list] = {}
for key, entity in document["entities"].items():
by_type.setdefault(entity.get("type") or "other", []).append((key, entity))
# Characters and locations first because they are what a reader looks for;
# everything else in whatever categories the campaign actually used, so a
# science-fiction campaign's `vehicle` appears without this code knowing the
# word (J02).
order = ["character", "location"] + sorted(
set(by_type) - {"character", "location"}
)
for kind in order:
members = by_type.get(kind)
if not members:
continue
rows = []
for key, entity in sorted(members):
detail = []
if entity.get("status") and entity["status"] != "active":
detail.append(str(entity["status"]))
if entity.get("location"):
detail.append("at " + model.entity_name(document, entity["location"]))
if entity.get("conditions"):
detail.append(", ".join(entity["conditions"]))
for name, value in sorted((entity.get("attributes") or {}).items()):
detail.append(f"{name}: {value}")
held = model.held_by(document, key)
if held:
detail.append(
"carrying " + ", ".join(model.entity_name(document, i) for i in held)
)
rows.append({
"key": key,
"label": entity.get("name") or key,
"detail": " · ".join(detail),
})
groups.append({"title": _title_for(kind), "rows": rows})
possessions = document["possessions"]
if possessions:
groups.append({"title": "Possessions", "rows": [
{
"key": item,
"label": model.entity_name(document, item),
"detail": "held by " + model.entity_name(document, owner),
}
for item, owner in sorted(possessions.items())
]})
facts = model.active_facts(document)
if facts:
groups.append({"title": "Important Facts", "rows": [
{
"key": fact.get("id") or "",
"label": _fact_line(document, fact),
"detail": _source_label(fact),
}
for fact in facts
]})
relationships = model.active_relationships(document)
if relationships:
groups.append({"title": "Relationships", "rows": [
{
"key": relationship.get("id") or "",
"label": (
f"{model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
),
"detail": relationship.get("description") or "",
}
for relationship in relationships
]})
threads = model.open_threads(document)
if threads:
groups.append({"title": "Open Story Threads", "rows": [
{
"key": key,
"label": thread.get("title") or key,
"detail": thread.get("description") or "",
}
for key, thread in sorted(threads.items())
]})
return {"groups": groups, "empty": not groups}
def _title_for(kind: str) -> str:
"""A heading for an entity category the campaign chose.
Pluralised generically rather than from a table, because the categories are
open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would
silently mislabel the tenth.
"""
known = {
"character": "Characters",
"location": "Locations",
"organization": "Organizations",
"item": "Items",
"vehicle": "Vehicles",
"creature": "Creatures",
"structure": "Structures",
"concept": "Concepts",
"other": "Other",
}
if kind in known:
return known[kind]
word = kind.replace("_", " ").strip().title()
return word if word.endswith("s") else word + "s"
def _source_label(fact: dict) -> str:
source = fact.get("authority") or fact.get("source") or ""
return {
"manual_correction": "your correction",
"campaign_canon": "campaign canon",
"accepted_story": "from the story",
}.get(source, str(source).replace("_", " "))
+195
View File
@@ -0,0 +1,195 @@
"""M5: writing accepted state, atomically with the turn that caused it.
This is the only module in the package that touches the database, and the only
place authoritative narrative state is written.
## The atomicity rule (L01)
Everything a turn establishes goes in one transaction: the narration, the head
movement, the accepted events, the resulting snapshot, and the provenance. This
function *adds* to the caller's session and never commits — the turn engine's
single `db.commit()` remains the one commit point, so a failure anywhere before
it rolls the whole turn back rather than leaving narration accepted with half its
state written.
That ordering is deliberate and load-bearing. `L01` forbids a head position that
implies an accepted reply whose state commit did not complete, and the cheapest
way to guarantee that is to never have two commits to get out of step.
## What is not here
No reconstruction. Nothing in this module reads `state_events` to rebuild a
document — the snapshot on the node is the restore path
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
trail that the system depends on for correctness stops being an audit trail and
becomes a replay engine.
"""
from __future__ import annotations
import copy
from sqlalchemy.orm import Session
from .. import models
from . import apply as apply_module
from . import model
def current(adventure: models.Adventure) -> dict:
"""The campaign's authoritative state right now, as a document.
Normalised on the way out, so every caller gets the same shape whatever a
hand-edited row or an older snapshot contains.
"""
return model.normalize(adventure.narrative_state)
def set_current(adventure: models.Adventure, state: dict) -> None:
adventure.narrative_state = model.normalize(state)
def canon_of(adventure: models.Adventure) -> dict:
"""The campaign's own rules, which outrank anything a narration proposes.
Configuration rather than code (C01, J03): the campaign says what it forbids,
and `validate` enforces it without knowing what the rule means.
"""
canon = adventure.campaign_canon
return canon if isinstance(canon, dict) else {}
def record(
db: Session,
adventure: models.Adventure,
*,
review,
raw_block: str = "",
parsed=None,
action: models.Action | None = None,
branch_id: int | None = None,
depth: int | None = None,
model_name: str = "",
source: str = "accepted_story",
) -> tuple[dict, models.StateProposal]:
"""Applies a reviewed proposal and records everything about it.
Returns `(new_state, proposal_row)`. The caller is responsible for putting
the new state where it belongs — on the campaign, and on the node's snapshot
— because only the caller knows whether this is a turn, a retry or a
correction.
Nothing is committed here. See the module docstring.
"""
before = current(adventure)
after = apply_module.apply_events(
before, review.accepted, branch_id=branch_id, depth=depth, source=source
)
proposal = models.StateProposal(
adventure_id=adventure.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
model_name=model_name or "",
source=source,
status=review.status,
raw_output=raw_block or "",
detail={
"parsed": parsed,
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
},
)
db.add(proposal)
# The proposal needs an id before its events can point at it, and the
# session does not autoflush. This is a flush, not a commit: still one
# transaction, still all-or-nothing.
db.flush()
for sequence, event in enumerate(review.accepted):
db.add(models.StateEvent(
adventure_id=adventure.id,
proposal_id=proposal.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
sequence=sequence,
event_type=event.get("type", ""),
payload=copy.deepcopy(event),
before=_before_value(before, event),
source=source,
))
return after, proposal
def _before_value(state: dict, event: dict) -> dict | None:
"""What the value this event changes was, immediately beforehand.
Recorded per event so §8's "what was the previous value" is answerable
without replaying anything. Only the slice the event touches: a whole
document per event would duplicate the snapshot for no extra answer.
"""
kind = event.get("type")
if kind in ("set_entity_status", "set_entity_attribute",
"set_entity_conditions", "set_current_location"):
entity = model.entity(state, event.get("entity", ""))
if entity is None:
return None
if kind == "set_entity_status":
return {"status": entity.get("status")}
if kind == "set_entity_attribute":
attribute = event.get("attribute")
return {"attribute": attribute,
"value": (entity.get("attributes") or {}).get(attribute)}
if kind == "set_entity_conditions":
return {"conditions": list(entity.get("conditions") or [])}
return {"location": entity.get("location")}
if kind in ("set_possession", "clear_possession"):
return {"owner": model.owner_of(state, event.get("item", ""))}
if kind == "invalidate_fact":
for fact in state.get("facts") or []:
if fact.get("id") == event.get("fact_id"):
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
return None
if kind == "resolve_story_thread":
thread = (state.get("threads") or {}).get(event.get("thread", ""))
return {"status": thread.get("status")} if isinstance(thread, dict) else None
if kind == "end_relationship":
return {"status": "active"}
return None
# ------------------------------------------------------------------ reading
def events_for(
db: Session, adventure: models.Adventure, action_id: int
) -> list[models.StateEvent]:
"""The accepted events one node's narration produced, in order."""
return (
db.query(models.StateEvent)
.filter(
models.StateEvent.adventure_id == adventure.id,
models.StateEvent.action_id == action_id,
)
.order_by(models.StateEvent.sequence, models.StateEvent.id)
.all()
)
def history(
db: Session, adventure: models.Adventure, limit: int = 200
) -> list[models.StateEvent]:
"""The campaign's accepted state events, newest first.
Bounded by default: this is an audit view, and an unbounded read of a long
campaign's every event is the kind of query this project keeps a regression
test about.
"""
return (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adventure.id)
.order_by(models.StateEvent.id.desc())
.limit(limit)
.all()
)
+317
View File
@@ -0,0 +1,317 @@
"""M5: deciding which proposed events the application will accept.
A proposal is untrusted model output. This module is the gate between it and the
authoritative state, and it is layered so that a rejection can say *which* rule
refused and a test can aim at one layer at a time:
1. envelope is this a proposal at all — a dict with a list of events?
2. allowlist is each event type one this application implements? (H05)
3. schema are the required fields present, and the right shape?
4. referential do the entities and threads it names exist?
5. semantic does it contradict campaign canon, or itself?
Layer 2 is the security boundary and runs before any field is read, so a payload
carrying `command` or `path` alongside an unknown type is discarded without those
fields ever being looked at.
## What rejection means
Nothing is partially applied. `review` returns accepted and rejected events
separately and the caller decides; `apply.py` is only ever handed the accepted
list. A proposal with one bad event out of four therefore lands three, which is
`partially_accepted` — the alternative, discarding all four because the model
misspelled one entity, loses story the user watched happen.
What is *never* allowed is a rejected event mutating anything, or a rejection
being silent: every refusal carries a reason, is counted, and is stored on the
proposal record for §8's audit.
## What this module does not do
It does not decide whether the model was *right*. A typed event can be
well-formed, reference real entities, contradict nothing, and still describe
something the narration did not say. That is C06's territory and no validator
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
proposal is wrong in a way a person can see in the audit trail, rather than one
that silently means something other than it appears to.
"""
from __future__ import annotations
from . import events, model
# A rejected event carries one of these, so tests and the debug view can assert
# on the reason rather than on prose.
UNKNOWN_TYPE = "unknown_event_type"
NOT_AN_OBJECT = "not_an_object"
MISSING_FIELD = "missing_field"
BAD_FIELD_TYPE = "bad_field_type"
UNKNOWN_REFERENCE = "unknown_reference"
CANON_CONFLICT = "canon_conflict"
SELF_CONTRADICTION = "self_contradiction"
DUPLICATE_ENTITY = "duplicate_entity"
# How many events one proposal may carry. A narration describes a turn, not a
# migration; a hundred events is a runaway model or a payload trying to be
# something else, and either way the cap bounds the work before it is done.
MAX_EVENTS = 40
# How long a text field may be. Long enough for a description, short enough that
# a proposal cannot smuggle a document into the state.
MAX_TEXT = 2_000
MAX_LABELS = 40
class Rejection:
"""One event that will not be applied, and why."""
__slots__ = ("event", "reason", "detail")
def __init__(self, event, reason: str, detail: str = ""):
self.event = event
self.reason = reason
self.detail = detail
def as_dict(self) -> dict:
return {"event": self.event, "reason": self.reason, "detail": self.detail}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Rejection {self.reason}: {self.detail}>"
class Review:
"""The verdict on one proposal."""
__slots__ = ("accepted", "rejected")
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
self.accepted = accepted
self.rejected = rejected
@property
def status(self) -> str:
"""`DATA-MODEL.md` §19's validation_status."""
if self.rejected and self.accepted:
return "partially_accepted"
if self.rejected:
return "rejected"
return "accepted"
def as_dict(self) -> dict:
return {
"status": self.status,
"accepted": self.accepted,
"rejected": [r.as_dict() for r in self.rejected],
}
def review(payload, state: dict, canon: dict | None = None) -> Review:
"""Returns which of `payload`'s events may be applied to `state`.
`state` is the document the events would apply to, needed because
referential checks ask what already exists. `canon` carries the campaign's
own rules, which outrank anything a narration proposes (C01).
The state is **not** mutated. Events are checked against a running view that
accounts for entities earlier events in the same proposal create, so a
proposal may introduce Mara and then move her, but nothing is written until
the caller applies the accepted list.
"""
accepted: list[dict] = []
rejected: list[Rejection] = []
proposed = _events_of(payload)
if proposed is None:
return Review([], [Rejection(payload, NOT_AN_OBJECT,
"the proposal is not an object with an event list")])
# Entities this proposal has introduced, so a later event in the same
# proposal may refer to them. Kept separately from `state` so that a
# rejected create cannot make a later reference resolve.
introduced: set[str] = set()
for raw in proposed[:MAX_EVENTS]:
problem = _check(raw, state, introduced, canon)
if problem is not None:
rejected.append(problem)
continue
accepted.append(raw)
spec = events.spec(raw["type"])
if spec and spec["creates"]:
introduced.add(str(raw[spec["creates"]]))
for extra in proposed[MAX_EVENTS:]:
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
f"more than {MAX_EVENTS} events in one proposal"))
return Review(accepted, rejected)
def _events_of(payload) -> list | None:
"""The event list, from either shape a proposal may legitimately take."""
if isinstance(payload, list):
return [e for e in payload]
if not isinstance(payload, dict):
return None
found = payload.get("events")
if found is None:
return []
if not isinstance(found, list):
return None
return found
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
"""Returns why `raw` is unacceptable, or None if it may be applied."""
# ---- layer 1: is it an event-shaped object at all ----
if not isinstance(raw, dict):
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
# ---- layer 2: the allowlist, before any field is read ----
#
# H05 lands here. `execute_shell` is refused because it is not in the
# vocabulary, and its `command` field is never looked at — there is no
# branch in this application that could reach it.
event_type = raw.get("type", raw.get("event_type"))
if not events.is_allowed(event_type):
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
raw["type"] = event_type
spec = events.spec(event_type)
# ---- layer 3: schema ----
for field, kind in spec["required"].items():
if field not in raw:
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
for field, kind in spec["optional"].items():
if field in raw and raw[field] is not None:
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
# ---- layer 4: referential integrity ----
known = set(state.get("entities") or {}) | introduced
for field in spec["refs"]:
named = raw.get(field)
if named is None or field not in raw:
continue # optional reference, absent
if not isinstance(named, str) or named not in known:
return Rejection(raw, UNKNOWN_REFERENCE,
f"{event_type} names {field}={named!r}, which does not exist")
if event_type == "add_fact" and raw.get("object") is not None:
# An object may name an entity or another fact. Checking both keeps the
# reference meaningful — a typo is still caught — without forcing every
# thing a fact can be about to be promoted to an entity first.
known_facts = {f.get("id") for f in (state.get("facts") or [])}
target = raw["object"]
if not isinstance(target, str) or (target not in known and target not in known_facts):
return Rejection(raw, UNKNOWN_REFERENCE,
f"add_fact names object={target!r}, which does not exist")
if event_type == "invalidate_fact":
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no fact {raw['fact_id']!r} to invalidate")
if event_type == "resolve_story_thread":
if raw["thread"] not in (state.get("threads") or {}):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no story thread {raw['thread']!r} to resolve")
if spec["creates"]:
key = raw[spec["creates"]]
if key in known:
return Rejection(raw, DUPLICATE_ENTITY,
f"{key!r} already exists; use set_* to change it")
# ---- layer 5: semantics ----
return _semantic(raw, state, canon)
def _bad_shape(value, kind: str, field: str) -> str | None:
"""Returns why `value` is the wrong shape for `kind`, or None."""
if kind in (events.TEXT, events.KEY):
if not isinstance(value, str) or not value.strip():
return f"{field!r} must be a non-empty string"
if len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.VALUE:
# A scalar. Explicitly not a dict or a list: a nested payload is how a
# value field becomes somewhere to hide a second protocol.
if not isinstance(value, (str, int, float, bool)) and value is not None:
return f"{field!r} must be a plain value, not a structure"
if isinstance(value, str) and len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.LABELS:
if not isinstance(value, list):
return f"{field!r} must be a list"
if len(value) > MAX_LABELS:
return f"{field!r} has more than {MAX_LABELS} entries"
for item in value:
if not isinstance(item, str) or not item.strip():
return f"{field!r} must contain only non-empty strings"
if len(item) > MAX_TEXT:
return f"{field!r} contains an over-long entry"
return None
return f"{field!r} has an unknown field kind" # pragma: no cover
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
"""Deterministic checks the application can actually make.
Deliberately modest. ADR 010 is explicit that typed events do not make a
model correct, and pretending arbitrary fiction can be validated would be
worse than admitting it cannot: it would produce confident rejections of
perfectly good story. So this refuses only what the application *knows* is
wrong — a self-contradiction, or a collision with a rule the campaign wrote
down.
"""
event_type = raw["type"]
# An entity cannot hold itself, and cannot be in itself.
if event_type == "set_possession" and raw["item"] == raw["owner"]:
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
return Rejection(raw, SELF_CONTRADICTION,
"a relationship needs two different entities")
# C01: campaign canon outranks narration. The rule is generic — a campaign
# declares transitions it forbids, and any event proposing one is refused.
# Nothing here knows what any of those transitions mean; the campaign
# says which it forbids, in data.
conflict = _canon_conflict(raw, state, canon)
if conflict is not None:
return Rejection(raw, CANON_CONFLICT, conflict)
return None
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
"""Whether campaign canon forbids what this event proposes.
Canon is configuration, not code (J03). A campaign writes:
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
and a narration that tries to bring a dead character back is refused —
without this module, or any other, containing the word for what that is. A
science-fiction campaign forbidding a different transition uses the same
field and the same code path.
"""
if not isinstance(canon, dict):
return None
if raw["type"] != "set_entity_status":
return None
forbidden = canon.get("forbidden_status_changes")
if not isinstance(forbidden, list):
return None
current = (model.entity(state, raw["entity"]) or {}).get("status")
for rule in forbidden:
if not isinstance(rule, dict):
continue
if rule.get("from") == current and rule.get("to") == raw["status"]:
return (
f"campaign canon does not allow {raw['entity']!r} to go from "
f"{current!r} to {raw['status']!r}"
)
return None
-52
View File
@@ -1,52 +0,0 @@
"""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 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.
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
import socket
from urllib.parse import urlparse
from . import auth
def endpoint_block_reason(url: str) -> str | None:
"""A human-readable reason this URL must NOT be fetched server-side, or None
if it's allowed. Resolves the host and rejects it if any resulting address
is non-public (private, loopback, link-local/metadata, reserved, …).
Checking at request time (not just on save) is deliberate: it resists a DNS
record that flips to a private IP after the value was stored.
"""
if not auth.MULTI_USER:
return None
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return "the endpoint URL must start with http:// or https://"
host = parsed.hostname
if not host:
return "the endpoint URL has no host"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except socket.gaierror:
return "the endpoint host could not be resolved"
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return "the endpoint host resolved to an unrecognized address"
# is_global is the strict allowlist: private/loopback/link-local/CGNAT
# all report False, so this one check covers the metadata IP too.
if not ip.is_global or ip.is_multicast or ip.is_reserved:
return "the endpoint URL resolves to a non-public address"
return None
+54 -82
View File
@@ -3,32 +3,27 @@ from typing import AsyncIterator
import httpx
from .. import debuglog, netguard, tlstrust
from .. import debuglog, endpoints, tlstrust
from .base import PromptParts, Provider, ProviderError
# Appended after the story text in chat mode, so a chat-tuned model continues
# the prose rather than replying conversationally.
CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]"
# OpenRouter serves one model from whichever upstream is available, and every
# 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` 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.
#
# 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"}
# A machine that is not listening refuses in milliseconds, so a slow connect
# means the wrong address rather than a busy model.
CONNECT_TIMEOUT = 10.0
# How long to wait for generation when Settings names no value. Upstream
# hardcoded 120s, and M1 measured a *cold* load of a 3B model on a GPU-less
# four-core host exceeding it three times while the same turn took 6-9 seconds
# once the model was resident. 300s covers a cold start on modest hardware and
# is still a number: a wedged endpoint fails rather than hanging forever.
DEFAULT_READ_TIMEOUT = 300.0
# Embeddings are short and never cold-load a large model.
EMBED_READ_TIMEOUT = 60.0
# Completion endpoints have no roles, so a chat has to be flattened into one
@@ -44,74 +39,47 @@ def flatten_messages(messages: list[dict]) -> str:
class OpenAICompatibleProvider(Provider):
"""Adapter for any /v1-style endpoint.
"""Adapter for Ollama's OpenAI-compatible `/v1` API.
This covers Ollama, LM Studio, OpenAI, OpenRouter, vLLM, and Groq, among
others.
The protocol is OpenAI's, which is what the module is named for; the
product speaks it to Ollama and to nothing else. `endpoints.py` decides
which addresses may be reached, and every request re-checks — the shape of
the wire format is not the same thing as permission to use it.
"""
def __init__(
self,
endpoint_url: str,
api_key: str,
model: str,
api_mode: str = "chat",
reasoning_max_tokens: int = 0,
read_timeout: float | None = None,
):
self.base_url = endpoint_url.rstrip("/")
self.api_key = api_key
self.model = model
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
# How long to wait for the model, in seconds. Cold-loading a model on a
# CPU-only machine can take minutes, and a fixed short timeout reports
# that as a failure. See `DEFAULT_READ_TIMEOUT`.
self.read_timeout = read_timeout or DEFAULT_READ_TIMEOUT
# The token accounting from the last call, when the endpoint reported
# any. 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.
# any. Every request method writes it, so a caller reads it after the
# call it made. One provider is built per request.
self.last_usage: dict | None = None
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
# No Authorization header: Ollama does not use one, and this build has
# no cloud provider to carry a key for.
return {"Content-Type": "application/json"}
def _apply_reasoning_budget(self, body: dict) -> None:
"""Gives reasoning models their own thinking budget, in the OpenRouter style.
def _timeout(self, seconds: float | None = None) -> httpx.Timeout:
"""Short to connect, patient to read.
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.
A machine that is not listening says so in milliseconds, so a slow
connect is a wrong address rather than a busy model and should fail
fast. Generation is the opposite: the first token can be minutes away
while a model loads.
"""
if self.api_mode != "chat":
return
if self.reasoning_max_tokens < 0:
body["reasoning"] = {"effort": "none"}
elif self.reasoning_max_tokens > 0:
body["reasoning"] = {"max_tokens": self.reasoning_max_tokens}
body["max_tokens"] += self.reasoning_max_tokens
def _apply_provider_routing(self, body: dict) -> None:
"""Prefers one upstream on OpenRouter, so the prompt cache stays warm.
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())
if upstream:
body["provider"] = {"order": [upstream]}
return httpx.Timeout(seconds or self.read_timeout, connect=CONNECT_TIMEOUT)
def _record_usage(self, payload: dict) -> None:
"""Records the endpoint's own token accounting, if it reported any.
@@ -147,8 +115,6 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": True,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
return url, body
@staticmethod
@@ -227,8 +193,6 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": True,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
async for event in self._stream(url, body):
yield event
@@ -238,17 +202,17 @@ class OpenAICompatibleProvider(Provider):
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)
# Re-checked on every request, not only when the endpoint was saved: a
# hostname that resolved to a LAN address yesterday can resolve
# somewhere else today, and a database row can be edited by hand.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
received: list[str] = []
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(), verify=tlstrust.ssl_context()
) as client:
async with client.stream("POST", url, json=body, headers=self._headers()) as resp:
if resp.status_code != 200:
@@ -351,13 +315,16 @@ class OpenAICompatibleProvider(Provider):
"max_tokens": max_tokens,
"stream": False,
}
self._apply_reasoning_budget(body)
self._apply_provider_routing(body)
# Same check as `_stream`: every outbound request re-tests the
# endpoint, so no path reaches an address the policy refuses.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(120, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
@@ -383,10 +350,15 @@ class OpenAICompatibleProvider(Provider):
raise ProviderError("No embedding model configured — set one in Settings.")
url = f"{self.base_url}/embeddings"
body = {"model": self.model, "input": texts}
# Same check as `_stream`: every outbound request re-tests the
# endpoint, so no path reaches an address the policy refuses.
reason = endpoints.rejection_reason(url)
if reason:
raise ProviderError(f"This endpoint can't be used — {reason}.")
log = debuglog.start_entry(url, self.model, body)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=10), verify=tlstrust.ssl_context()
timeout=self._timeout(EMBED_READ_TIMEOUT), verify=tlstrust.ssl_context()
) as client:
resp = await client.post(url, json=body, headers=self._headers())
except httpx.HTTPError as exc:
+6 -2
View File
@@ -13,6 +13,8 @@ Read the modules in this order to follow a turn from end to end:
turns playing a turn, and the lock that allows only one at a time
takes retries and the attempts that collect at one coordinate
branches where a story splits
checkpoints Save Points: durable names for positions the head can return to
state the authoritative narrative state, and correcting it by hand
What this package re-exports, and what it deliberately does not:
@@ -31,8 +33,9 @@ from . import ( # noqa: F401
turns,
takes,
branches,
checkpoints,
state,
bundle_io,
scripts,
refresh,
insights,
memories,
@@ -41,7 +44,7 @@ from . import ( # noqa: F401
from ... import limits # noqa: F401 `adventures.limits` is patched by tests.
from .crud import SNIPPET_MAX, _snippet
from .paging import ACTION_PAGE
from .takes import retry_action, undo_turn
from .takes import redo_turn, retry_action, undo_turn
from .turns import world_delta_of
__all__ = [
@@ -49,6 +52,7 @@ __all__ = [
"SNIPPET_MAX",
"_snippet",
"limits",
"redo_turn",
"retry_action",
"router",
"undo_turn",
+200 -6
View File
@@ -8,7 +8,8 @@ coordinate through `nodes.delete_turn`.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, models, schemas, tree
from ... import attempts, head, memorybank, models, narrative, schemas, tree
from ...context import cursors, lineage
from ...database import get_db
from . import turns
@@ -41,6 +42,11 @@ def list_actions(
],
total=total,
has_more=has_more,
# Every page carries them, not just the newest window: the client reads
# the flags off whichever page arrived last, and scrolling up must not
# be able to grey out a Redo that is still available (M3).
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
@@ -55,14 +61,194 @@ def update_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# One row holds one text. Nothing mirrors it now, so nothing else has to be
# updated. The edit used to have to be written into the live variant entry
# as well, or paging away and back reverted it.
action.text = payload.text
db.commit()
# A narrator turn the story is currently telling is corrected through the
# §§14-15 path, which forks. A take the story is *not* telling is a
# different thing: it has no continuation of its own — keeping one is what
# forking is for — so correcting its words cannot contradict anything, and
# it stays the plain in-place edit it has always been.
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
return _edit_narration(db, adventure, action, payload.text)
# A player's own words. Editing one rewrites this row and re-evaluates
# nothing after it, which is what makes it a correction rather than a new
# continuation. That is safe while everything descending from the row is on
# screen, and unsafe the moment something descends from it that is not — an
# undone future, or a line a divergence left behind. The reader cannot see
# that story, so they cannot see what their correction has just contradicted
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
"This turn has a later story that is not on screen — undone, or "
"left behind by a new continuation. Editing it here would change "
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
turns.acquire_turn_lock(adventure_id)
try:
action.text = payload.text
db.commit()
finally:
turns._active_turns.discard(adventure_id)
db.refresh(action)
return action
def _edit_narration(
db: Session, adventure: models.Adventure, action: models.Action, text: str
) -> models.Action:
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
A narrator edit is not a rewrite of a row. It is a continuation written from
the same place the original was written from, using the reader's words
instead of the model's. §15 lists what that has to mean, and each clause
maps to a step below:
1. return to the state immediately before the edited narration — the
preceding node's snapshot, one row read;
2. treat the edited text as the accepted narrator output — it is stored
verbatim, with only the protocol block stripped, and no model is called;
3. re-evaluate the state that output implies — the normal M5 extraction and
validation path, run against that starting state;
4. create a new active continuation — a new node, and the head on it;
5. retain the original narration and its future as disposable history —
nothing on the old line is written to at all.
The M5 review found the previous implementation failing 3-5 together: it
edited the row in place and rewound the campaign's live state to that
position while the head stayed at the tip, so the reader saw a full
transcript over a state document describing an earlier moment, and the
snapshots below the edit still described prose that no longer existed
(Finding 1). Forking is what fixes it, and no new machinery is needed to
fork — this function is the ⑂ path from `takes.py` with the reader's text in
place of a generated one.
Two shapes, chosen by whether anything was written after the turn:
at the tip the attempts of the turn are still leaves, so the
correction joins them as a sibling take and the
original is retained beside it in the pager;
anything below the story after the turn was written as a
continuation of the words that are there now, so it
keeps them: the correction leaves the path just
before the turn and the old line keeps its node, its
future, and its live flag.
The §14A refusal is gone from this path, and this is what replaces it. It
refused an in-place edit under an off-screen future because the edit would
silently change the words that story was written from. Nothing is changed
now — the off-screen future keeps the exact narration it descends from — so
the case that had to be refused is simply handled.
"""
if action.depth is None:
raise HTTPException(400, "That turn is not on the story you are reading.")
turns.acquire_turn_lock(adventure.id)
try:
# §15.2. The reader's words are the narration; a block they pasted in is
# protocol and is stripped before storage, exactly as a model's is.
prose, parsed, raw_block = narrative.extract.split(text)
# §15.1. Not the campaign's current state — the state this turn was
# played from. One row read, not a replay (ADR 012).
before = attempts.preceding(db, adventure, action)
starting_state = (
narrative.model.normalize(before.narrative_state_after)
if before is not None and isinstance(before.narrative_state_after, dict)
else narrative.model.empty()
)
corrected = models.Action(
adventure_id=adventure.id,
type="ai",
text=prose,
# No model was called, so there is no prompt to show for this node.
# In the sibling case the turn's assembled prompt moves to whichever
# attempt is live, which is what the Insights viewer reads; in the
# forked case the original keeps it, because the original is still
# the live node of its own line.
context_snapshot=None,
)
tip = db_tip(db, adventure)
# A turn the head rests on is not a leaf while a retained future
# descends from it, and `db_tip` reads the capped path and cannot see
# that future. Ask the head module as well (M3).
at_the_tip = (
tip is not None
and tip.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# §15.4-5 as a take. The original stays at this coordinate as a
# prior attempt, reachable through the pager, and the correction
# becomes the one the story tells.
attempts.hand_over_the_prompt(action, corrected)
attempts.add_attempt(db, adventure, action, corrected)
db.add(corrected)
# The words at this coordinate changed, so anything derived from
# them no longer describes the story.
memorybank.forget_node(db, adventure, action)
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
db.flush()
else:
# §15.4-5 as a branch. Nothing on the departed line is written to:
# the original node keeps its text, its live flag and every turn
# that was played after it.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
tree.place_action(db, adventure, corrected)
db.add(corrected)
db.flush()
# §15.3. The same validation path a generated turn takes, so a hand
# -typed event is no more trusted than a model's: the allowlist, the
# schema, the references and the canon all still apply.
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
starting_state,
narrative.store.canon_of(adventure),
)
# `record` writes the events and the provenance. Its returned document
# applies them to the campaign's *current* state, which is not what an
# edit derives from, so the document this node leaves behind is computed
# from the turn's own starting point below.
narrative.store.record(
db, adventure,
review=review,
raw_block=raw_block,
parsed=parsed,
action=corrected,
branch_id=corrected.branch_id,
depth=corrected.depth,
source="narrator_edit",
)
new_state = narrative.apply.apply_events(
starting_state, review.accepted,
branch_id=corrected.branch_id, depth=corrected.depth,
source="narrator_edit",
)
corrected.state_changes = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"summary": narrative.apply.diff(starting_state, new_state),
}
# The head is on the corrected node, so the campaign's live state is
# what that node leaves behind, and the node's own snapshot is the same
# document. That equality is the invariant the review found broken:
# visible position == head == authoritative state.
narrative.store.set_current(adventure, new_state)
attempts.snapshot_outcome(adventure, corrected)
adventure.updated_at = models.utcnow()
db.commit()
finally:
turns._active_turns.discard(adventure.id)
db.refresh(corrected)
return corrected
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
@@ -81,6 +267,7 @@ def delete_action(
# This works like undo. The turn is deleted with all of its attempts,
# and whatever it produced is withdrawn. The marks are depths, and a
# depth does not move when an action before it is deleted.
was_at = adventure.head_depth
delete_turn(db, adventure, action)
db.flush()
db.expire(adventure, ["actions"])
@@ -88,6 +275,13 @@ def delete_action(
# middle leaves a gap in the depths, which is intended. See
# `_backfill_tree`.
tree.refresh_head(db, adventure)
# `refresh_head` recomputes the tip, which since M3 is not the head. A
# story sitting behind its retained tip must not be dragged forward to
# the tip by an unrelated delete — that would silently Redo it. Keep the
# head where the reader left it, unless the delete took the ground out
# from under it, in which case the new tip is as far as it can stay.
if was_at < adventure.head_depth:
adventure.head_depth = was_at
# The script state and the world state belong to the adventure, not to
# the node, so deleting the node does not take back what it did to
# them. Put them back to what the story now ends with, which is the
+90 -6
View File
@@ -56,6 +56,19 @@ def list_branches(
.group_by(models.Action.branch_id)
.all()
}
# M4 closeout: how many Save Points name a position on each line. Deleting a
# branch deletes them along with its story, and the panel has to be able to
# say so before the button is pressed (review §R B-2). One grouped query for
# the whole tree, like the one above it — never one per branch.
save_points = {
branch_id: count
for branch_id, count in db.query(
models.Checkpoint.branch_id, func.count(models.Checkpoint.id)
)
.filter(models.Checkpoint.adventure_id == adventure.id)
.group_by(models.Checkpoint.branch_id)
.all()
}
out = []
for branch in branches:
count, tip = owned.get(branch.id, (0, None))
@@ -70,6 +83,7 @@ def list_branches(
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
),
own_actions=count,
save_points=save_points.get(branch.id, 0),
is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at,
@@ -151,15 +165,31 @@ def delete_branch(
heavily retried adventure from growing without bound. That is why it ships
with the view that first lets anyone create a fork rather than after it.
Two kinds of branch cannot be deleted. The root cannot, because it holds the
turns every other branch borrows, so deleting it deletes the whole story. The
branch currently being read cannot, and neither can any branch it was forked
from, because the cascade would remove the head under the player and leave
`head_branch_id` dangling. Switch branches first.
Three kinds of branch cannot be deleted. The root cannot, because it holds
the turns every other branch borrows, so deleting it deletes the whole story.
The branch currently being read cannot, and neither can any branch it was
forked from, because the cascade would remove the head under the player and
leave `head_branch_id` dangling. Switch branches first.
The third is M4's: **a branch a Save Point names cannot be deleted while that
Save Point exists.** `STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint
remains until explicitly deleted, and §28 says a future cleanup feature must
retain paths referenced by checkpoints. A cascade that removed Save Points
along with a branch would break both, and would break them silently: the
story the user asked to delete is the visible thing, and the named moments
would go without ever being named in the request. So the deletion is refused,
the Save Points are listed, and the user decides — delete the Save Point
first, then the branch. Deleting a Save Point still deletes no story (§25),
so the recovery costs nothing but a click.
The check covers the whole doomed subtree, not just this branch, because
deleting a branch takes everything forked from it.
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
cascade on `branches.parent_branch_id`, so the delete is a single statement
however deep the subtree is.
however deep the subtree is. `checkpoints.branch_id` also carries a cascade,
as referential integrity — a Save Point must never point at a branch that is
gone — but the guard above means it does not fire through this endpoint.
"""
branch = get_branch_or_404(adventure, branch_id, db)
if branch.parent_branch_id is None:
@@ -178,6 +208,11 @@ def delete_branch(
400, "You are reading this branch, or one forked from it. Switch to "
"another branch first.",
)
# Refused before the lock is taken: this is a decision about the request, not
# a race with a turn.
protecting = _save_points_protecting(db, adventure, branch)
if protecting:
raise HTTPException(409, _protected_message(protecting))
turns.acquire_turn_lock(adventure_id)
try:
# Collect the subtree before the delete, because afterwards there is no
@@ -199,6 +234,55 @@ def delete_branch(
finally:
turns._active_turns.discard(adventure_id)
# How many Save Point names to spell out before the message starts summarising.
# Enough to be actionable, few enough to stay a sentence.
NAMED_IN_REFUSAL = 3
def _save_points_protecting(
db: Session, adventure: models.Adventure, branch: models.Branch
) -> list[models.Checkpoint]:
"""Returns the Save Points that deleting `branch` would destroy.
The whole subtree, because deleting a branch takes everything forked from
it, and a check that looked only at this branch would let a Save Point on a
child be deleted without a word.
"""
doomed = _branch_subtree(db, adventure, branch)
return (
db.query(models.Checkpoint)
.filter(
models.Checkpoint.adventure_id == adventure.id,
models.Checkpoint.branch_id.in_(doomed),
)
.order_by(models.Checkpoint.created_at, models.Checkpoint.id)
.all()
)
def _protected_message(protecting: list[models.Checkpoint]) -> str:
"""Says which Save Points stand in the way, and what to do about it.
Named rather than counted, because "2 Save Points" leaves the user hunting
for which ones. A long list is truncated so the message stays readable; the
Save Points panel shows the rest.
"""
names = [f"“{c.name}”" for c in protecting[:NAMED_IN_REFUSAL]]
listed = ", ".join(names)
extra = len(protecting) - len(names)
if extra > 0:
listed += f" and {extra} more"
subject = "a Save Point" if len(protecting) == 1 else "Save Points"
return (
f"This branch, or a branch forked from it, is where {subject} "
f"{listed} {'is' if len(protecting) == 1 else 'are'} saved. Delete "
f"{'that Save Point' if len(protecting) == 1 else 'those Save Points'} "
f"first if you no longer need "
f"{'it' if len(protecting) == 1 else 'them'}, then delete the branch. "
f"Deleting a Save Point does not delete any story."
)
def _branch_subtree(
db: Session, adventure: models.Adventure, root: models.Branch
) -> set[int]:
+9 -4
View File
@@ -7,7 +7,7 @@ only check ownership and hand the work over.
from fastapi import Body, Depends, Request
from sqlalchemy.orm import Session
from ... import analytics, bundle, limits, models, schemas
from ... import bundle, head, limits, models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -34,7 +34,6 @@ def import_adventure(
user: models.User = CurrentUser,
):
version = bundle.check_format(payload)
limits.rate_limit("import", request, user)
limits.check_row_cap("adventures", db, user)
limits.check_bundle_lists(
story_cards=payload.get("storyCards"),
@@ -63,8 +62,14 @@ def import_adventure(
db.commit()
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.AdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
analytics.record_event(analytics.EV_IMPORT, user)
return adventure
return out
@@ -0,0 +1,351 @@
"""M4: Save Points — create, list, rename, delete, and restore.
A Save Point is a durable named pointer to a story position and nothing else.
It stores a coordinate, never a copy of any story, and restoring one moves the
active head to that coordinate. That is the whole design, and it is what
`BUILD-MILESTONES.md`'s note on M4 and ADR 012 ask for: M3 made the head a
stored `(branch, depth)` and made arriving at one a row lookup plus a state
restore, so a Save Point needs no restore machinery of its own.
What is deliberately absent from this module, because a second copy of any of it
would be the failure M4 is warned about:
* no head fields are assigned here — `head.move_to_node` moves the head, and
`head.move_to` under it restores the state, exactly as Undo and Redo do;
* nothing reconstructs state, prunes a memory, copies a turn, or deletes one;
* nothing forks. Restore is not a decision to abandon anything, so it creates no
branch. The first write below the restored head forks, through the same
`fork_if_behind_head` every other write goes through, and the displaced future
stays retained (`STORY-BRANCH-SEMANTICS.md` §20).
The user-facing word is "Save Point" and the internal one is `checkpoint`
(`BROWSER-UX-SPEC.md` §23). Error strings here are read by a player, so they say
Save Point.
"""
from dataclasses import dataclass
from fastapi import Depends, HTTPException
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ... import head, models, schemas
from ...context import lineage
from ...database import get_db
from . import turns
from .deps import current_adventure, router
from .paging import current_window
def _node_at(
db: Session, adventure: models.Adventure, branch_id: int, depth: int
) -> models.Action | None:
"""Returns the live turn a Save Point's coordinate names, or None.
The lookup is by coordinate and is not scoped to any path. That is the
point of it: a Save Point outlives the reader moving away, so the question
it has to answer is "is this position still in this campaign's retained
history", not "is it on the story being read now". Whether it is on the
current path is a separate question, and `head.move_to_node` is what acts on
the answer.
`live` is what makes the coordinate follow a retry. One coordinate can hold
several attempts at a turn, and a Save Point names the turn rather than the
attempt, so it lands on whichever take the story currently tells.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch_id,
models.Action.depth == depth,
models.Action.live.is_(True),
)
.order_by(models.Action.id)
.first()
)
@dataclass(frozen=True)
class _Coordinate:
"""The shape `lineage.Path` reads, without loading a story row.
`Path.contains` asks three things of a node: its branch, its depth, and
whether it is live. A coordinate already known to resolve has all three, so
the membership question can be put to the coordinate itself. That keeps the
single implementation of "is this on the path being read" in `lineage`,
where M3 put it, while costing no query and no prose.
"""
branch_id: int
depth: int
live: bool = True
def _live_coordinates(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> set[tuple[int, int]]:
"""Returns which of these Save Points' coordinates still name a live turn.
One query for the whole list, selecting two integer columns.
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
query each and loaded whole `Action` entities — narration included — to
answer a question that is only ever "does a row exist here". `paging.py`
states the rule this now follows: a bulk read names the columns it needs, so
a new column costs nothing until someone adds it to the list.
The clause is an OR of exact `(branch, depth)` pairs rather than
`branch IN (…) AND depth IN (…)`, which would match the cross product and
report a Save Point as resolved because *some other* Save Point's depth
exists on *this* one's branch.
"""
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
if not coordinates:
return set()
rows = (
db.query(models.Action.branch_id, models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.live.is_(True),
or_(*[
and_(models.Action.branch_id == branch, models.Action.depth == depth)
for branch, depth in coordinates
]),
)
.all()
)
return {(branch, depth) for branch, depth in rows}
def _render_all(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> list[schemas.CheckpointOut]:
"""Reads Save Points out with the three facts the panel needs about them.
Bounded work whatever the length of the list: one query for the coordinates
and one lineage for the campaign, both computed before the loop. Rendering
one Save Point and rendering fifty differ in Python, not in round trips.
"""
live = _live_coordinates(db, adventure, checkpoints)
# The path is a property of the campaign, not of any Save Point, so it is
# read once. Reading it per row was the other half of the N+1.
path = lineage.path_of(db, adventure).uncapped()
out = []
for checkpoint in checkpoints:
coordinate = (checkpoint.branch_id, checkpoint.depth)
resolved = coordinate in live
rendered = schemas.CheckpointOut.model_validate(checkpoint)
# The same `depth + 1` the branch list counts with, so a moment number
# means the same thing in both places.
rendered.turn = checkpoint.depth + 1
rendered.resolved = resolved
rendered.on_path = resolved and path.contains(
_Coordinate(checkpoint.branch_id, checkpoint.depth)
)
out.append(rendered)
return out
def _rendered(
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
) -> schemas.CheckpointOut:
"""Reads one Save Point out, through the same path the list uses."""
return _render_all(db, adventure, [checkpoint])[0]
def _get_or_404(
db: Session, adventure: models.Adventure, checkpoint_id: int
) -> models.Checkpoint:
"""Resolves a Save Point id, refusing one that belongs to another campaign.
The ownership check is the reason this is a function rather than a `db.get`
at each call site. A Save Point names a position in one campaign's history,
and a coordinate from another campaign would name a different story's turn —
or, worse, resolve against this one by arithmetic coincidence. So the id is
matched against this adventure, and a Save Point belonging to another is a
404 rather than a restore of the wrong story.
"""
checkpoint = db.get(models.Checkpoint, checkpoint_id)
if checkpoint is None or checkpoint.adventure_id != adventure.id:
raise HTTPException(404, "Save Point not found")
return checkpoint
def _clean_name(raw: str) -> str:
"""Returns the trimmed name, refusing one that is blank once trimmed."""
name = (raw or "").strip()
if not name:
raise HTTPException(400, "A Save Point needs a name.")
return name
@router.get("/{adventure_id}/checkpoints", response_model=list[schemas.CheckpointOut])
def list_checkpoints(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the campaign's Save Points, newest first.
Newest first rather than in story order, because story order is not
something this list can honestly claim. Depths are positions along a path,
and two Save Points on lines that parted company are not comparable by depth
at all — ordering by it would draw a sequence that no reading of the story
passes through. When they were made is a fact about all of them.
"""
rows = (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
.all()
)
return _render_all(db, adventure, rows)
@router.post(
"/{adventure_id}/checkpoints",
response_model=schemas.CheckpointOut,
status_code=201,
)
def create_checkpoint(
adventure_id: int,
payload: schemas.CheckpointCreate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Names the position the story is currently being read at.
The active head, not the retained tip. Creating a Save Point after two Undos
saves the undone position, because that is where the reader is and the
position they are looking at is the one they mean. The distinction only
exists at all because M3 stopped Undo from deleting.
The node at the head is resolved before the row is written, and its own
branch is what gets stored — which is not always the branch being read. A
head resting in a shared prefix sits on an ancestor's node, and the
ancestor is the branch that still names that position after the reader has
forked away from it.
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
where I am" has to name one committed position, and the head is exactly what
a turn in flight is about to move. Without the lock this endpoint could read
`head_depth` while a turn was mid-commit and store a coordinate for a
position the story had already left — a Save Point silently naming the wrong
moment, which no later operation could detect. It is the same lock Undo,
Redo and Restore take, for the same reason, and not a new mechanism.
Rename and Delete deliberately do **not** take it: neither reads nor moves a
story position, so there is nothing for a turn in flight to race them over.
"""
name = _clean_name(payload.name)
turns.acquire_turn_lock(adventure_id)
try:
node = head.node_at(db, adventure, adventure.head_depth)
if node is None:
raise HTTPException(400, "There is no turn here to save yet.")
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=name,
note=payload.note or "",
branch_id=node.branch_id,
depth=node.depth,
)
db.add(checkpoint)
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
finally:
turns._active_turns.discard(adventure_id)
@router.patch(
"/{adventure_id}/checkpoints/{checkpoint_id}",
response_model=schemas.CheckpointOut,
)
def rename_checkpoint(
checkpoint_id: int,
payload: schemas.CheckpointRename,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Changes a Save Point's label. Nothing else about it moves.
Not the coordinate, not the head, not a row of story. A Save Point that has
been renamed restores to exactly the position it did before, which is
`STORY-BRANCH-SEMANTICS.md` §23.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
if payload.name is not None:
checkpoint.name = _clean_name(payload.name)
if payload.note is not None:
checkpoint.note = payload.note
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
@router.delete("/{adventure_id}/checkpoints/{checkpoint_id}", status_code=204)
def delete_checkpoint(
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Removes the named pointer, and only the pointer.
The turn it named stays, its branch stays, the future past it stays, and the
head does not move. This endpoint deletes one row of the `checkpoints`
table. `STORY-BRANCH-SEMANTICS.md` §25.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
db.delete(checkpoint)
db.commit()
return None
@router.post(
"/{adventure_id}/checkpoints/{checkpoint_id}/restore",
response_model=schemas.ActionPage,
)
def restore_checkpoint(
adventure_id: int,
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the story to a Save Point, deleting nothing.
Four steps, and the last one is not this module's code: resolve the
coordinate, refuse it if it no longer names a live turn, hand it to
`head.move_to_node`, and answer with the window the head now caps. The
transcript, the world state, the assembled context and which memories can be
retrieved all move together, because all four already read through the one
path object the head caps — the same reason Undo needed no memory pruning.
The turns past the restored position are retained, exactly as they are after
an Undo, and ordinary Redo can still walk forward into them until the user
writes something different. Restore does not fork; the first write below the
head does.
A coordinate that no longer resolves is refused rather than approximated.
Moving the head to the nearest surviving turn would be the one outcome worse
than doing nothing: a Save Point that silently means somewhere else.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
turns.acquire_turn_lock(adventure_id)
try:
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
if node is None:
raise HTTPException(
409,
"That Save Point's position is no longer part of this story.",
)
head.move_to_node(db, adventure, node)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# A window, not the whole story, for the reason Undo gives: the client
# replaces its transcript with this, and the transcript is a window.
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
+8 -35
View File
@@ -9,7 +9,9 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import set_committed_value
from ... import analytics, attempts, images, limits, memorybank, models, schemas, tree, worldstate
from ... import (
attempts, head, images, limits, memorybank, models, schemas, tree, worldstate,
)
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@@ -195,20 +197,6 @@ def create_adventure(
if scenario:
for ref, spec in scenario_card_specs(scenario, values).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
for position, script in enumerate(scenario.scripts):
db.add(
models.AdventureScript(
adventure_id=adventure.id,
source_script_id=script.id,
position=position,
name=script.name,
description=script.description,
library_js=script.library_js,
input_js=script.input_js,
context_js=script.context_js,
output_js=script.output_js,
)
)
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
@@ -223,12 +211,6 @@ def create_adventure(
db.commit()
db.refresh(adventure)
analytics.record_event(analytics.EV_ADVENTURE, user)
# Track which shared scenarios players pick. This is the only content this
# module records, and it records only public scenarios. A player's own
# scenario titles stay private.
if scenario is not None and scenario.is_public:
analytics.record(analytics.M_SCENARIO, scenario.title)
return adventure
@@ -259,23 +241,14 @@ def get_adventure(
set_committed_value(adventure, "actions", actions)
out = schemas.AdventureOut.model_validate(adventure)
out.action_count = total
# M3. Opening a story has to render its history controls correctly, and a
# campaign whose head sits behind the retained tip — undone and then closed —
# must come back with Redo available.
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
return out
@router.get("/{adventure_id}/script-state")
def get_script_state(
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the scripting `state` object.
The object holds every variable that scripts read and write through
`state.x`, persisted after each hook. It stays `{}` until a script sets a
variable.
"""
state = adventure.script_state if isinstance(adventure.script_state, dict) else {}
return {"state": state}
@router.get("/{adventure_id}/world-state")
def get_world_state(
adventure: models.Adventure = Depends(current_adventure),
+2 -9
View File
@@ -7,7 +7,7 @@ returns the prompt a turn was actually generated from. Neither writes anything.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import auth, memorybank, models
from ... import memorybank, models
from ...context import build_context
from ...database import get_db
from ..settings import get_settings
@@ -23,14 +23,7 @@ async def dry_run_context(
):
"""Returns what the app would send to the AI if the player continued now."""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo:
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
_, _, report = build_context(adventure, settings, memories)
return report
+5 -1
View File
@@ -8,7 +8,7 @@ module in the package can import them.
from fastapi import HTTPException
from sqlalchemy.orm import Session, undefer
from ... import attempts, memorybank, models, tree
from ... import attempts, head, memorybank, models, tree
from ...context import cursors
from ...context import lineage
@@ -125,6 +125,10 @@ def stand_on(
newest is not None
and newest.branch_id == action.branch_id
and newest.depth == action.depth
# A turn the head rests on is still not a leaf while a retained future
# descends from it. `last_action` reads the capped path and cannot see
# that future, so switching in place here would strand it (M3).
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# The story at this coordinate is about to change, so withdraw whatever
+10 -1
View File
@@ -8,7 +8,7 @@ columns and apply the same numbering, so both live here.
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from ... import models, schemas
from ... import head, models, schemas
from ...context import lineage
@@ -28,6 +28,13 @@ ACTION_LIST_COLUMNS = (
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
# above it. `ActionOut.state_summary` reads it for every row on the page, so
# leaving it out of the bulk read cost one lazy load per action — 51 rows
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
# events and its summary lines, the same order of size as `world_delta`, not
# the deferred snapshot.
models.Action.state_changes,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
@@ -165,4 +172,6 @@ def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPa
],
total=total,
has_more=has_more,
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
-116
View File
@@ -1,116 +0,0 @@
"""The per-adventure copies of library scripts.
An adventure snapshots a library `Script` when it starts, so editing the library
does not change a story in progress. These endpoints report whether a snapshot
has fallen behind its library original, and copy the original over on request.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
# Fields that are copied from a library Script into its adventure-script
# snapshot, and compared to decide whether a copy is out of date.
SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js")
def resolve_library_script(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.Script | None:
"""Returns the library Script an adventure script can re-sync from.
The result is the script this copy was made from. For a legacy copy with no
link, it is one of the player's own scripts with the same name. Only the
player's own scripts are considered, so a copy derived from a demo scenario
has nothing to sync to.
"""
if adv_script.source_script_id is not None:
script = db.get(models.Script, adv_script.source_script_id)
if script is not None and script.user_id == user.id:
return script
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id, models.Script.name == adv_script.name)
.order_by(models.Script.updated_at.desc())
.first()
)
def _mark_out_of_date(
adv_script: models.AdventureScript, db: Session, user: models.User
) -> models.AdventureScript:
"""Attaches a transient `out_of_date` flag, which `AdventureScriptOut` reads.
The flag is `True` or `False` when a syncable library version exists, and
`None` when none exists.
"""
library = resolve_library_script(adv_script, db, user)
adv_script.out_of_date = (
None if library is None
else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS)
)
return adv_script
@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut])
def list_adventure_scripts(
db: Session = Depends(get_db),
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
return [_mark_out_of_date(s, db, user) for s in adventure.scripts]
@router.post(
"/{adventure_id}/scripts/{adv_script_id}/sync",
response_model=schemas.AdventureScriptOut,
)
def sync_adventure_script(
adventure_id: int,
adv_script_id: int,
db: Session = Depends(get_db),
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
"""Overwrites this copy's code with the latest from its library script.
`enabled`, `position`, and the adventure's shared `script_state` are kept.
"""
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
library = resolve_library_script(script, db, user)
if library is None:
raise HTTPException(404, "No library script to sync from")
for field in SYNC_FIELDS:
setattr(script, field, getattr(library, field))
# Store the link, so that a name-matched legacy copy syncs by id next
# time.
script.source_script_id = library.id
db.commit()
db.refresh(script)
return _mark_out_of_date(script, db, user)
@router.patch(
"/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut
)
def update_adventure_script(
adventure_id: int,
adv_script_id: int,
payload: schemas.AdventureScriptUpdate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
script = db.get(models.AdventureScript, adv_script_id)
if script is None or script.adventure_id != adventure_id:
raise HTTPException(404, "Script not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
+173
View File
@@ -0,0 +1,173 @@
"""M5: reading the authoritative narrative state, and correcting it by hand.
Three endpoints, and the split between them is the point:
GET /state what the campaign currently believes
POST /state/corrections the user overruling it (C04)
GET /state/events how it came to believe that (§8's audit)
The browser reads the first and writes the second. It never writes state
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
presentation layer and must not become the owner of state — so a correction goes
through the same validator, the same applier and the same event log as a
narration does. The only difference is the `source` recorded on it, and that
difference is the whole of C04's audit requirement.
The state returned here is always the state at the **active head**, because that
is what `adventure.narrative_state` holds: head movement restores it from the
destination node's snapshot, so an undone story is described by what was true
then rather than by what the campaign later became.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import head, models, narrative, schemas
from ...database import get_db
from . import turns
from .deps import current_adventure, router
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
def read_state(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The authoritative state at the position the story is being read at.
Grouped for display, with only the categories that actually hold something —
a heading with no rows under it tells a reader nothing, and the panel should
not have to decide what to hide.
"""
state = narrative.store.current(adventure)
view = narrative.render.for_inspector(state)
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
# The raw document, for the correction form to name a key with and for a
# test to assert on without parsing prose.
document=state,
)
@router.post(
"/{adventure_id}/state/corrections",
response_model=schemas.NarrativeStateOut,
status_code=201,
)
def correct_state(
adventure_id: int,
payload: schemas.StateCorrection,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Applies the user's own state events, as an explicit correction.
C04. The user says "Mara never learned where the silver key was found", and
that becomes authoritative for everything that follows — while the transcript
stays exactly as it was written. Correcting the world is not editing the
story, and conflating them would rewrite prose the user did not ask to
change.
The events go through the **same validator** as a narration's. A user is
trusted more than a model, but not with references that do not resolve or
with an event type the application does not implement: a typo should be a
clear refusal, not a corrupt document. What being trusted buys is authority —
the resulting facts carry `manual_correction`, which outranks
`accepted_story` when the two disagree, and which the prompt renders so the
model is told the reader overruled it.
Held under the turn lock, for the reason creating a Save Point is: this reads
the head and writes a snapshot onto the node the head rests on, and a turn in
flight is about to move both.
"""
if not payload.events:
raise HTTPException(400, "A correction needs at least one change.")
turns.acquire_turn_lock(adventure_id)
try:
state = narrative.store.current(adventure)
review = narrative.validate.review(
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
state,
narrative.store.canon_of(adventure),
)
if not review.accepted:
raise HTTPException(400, _refusal_message(review))
node = head.node_at(db, adventure, adventure.head_depth)
new_state, _proposal = narrative.store.record(
db, adventure,
review=review,
raw_block=payload.note or "",
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
action=node,
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
depth=node.depth if node is not None else adventure.head_depth,
source="manual_correction",
)
narrative.store.set_current(adventure, new_state)
# The correction belongs to the position it was made at, so a later Undo
# past it drops it and a Redo back brings it again — the same rule every
# other state change follows. Without re-snapshotting the node, the
# correction would survive a head movement that stepped over it.
if node is not None:
node.narrative_state_after = new_state
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
finally:
turns._active_turns.discard(adventure_id)
view = narrative.render.for_inspector(narrative.store.current(adventure))
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
document=narrative.store.current(adventure),
)
def _refusal_message(review) -> str:
"""Why a correction was refused, in the words the user needs.
The first rejection's detail, because a correction is usually one or two
events and a wall of them helps nobody.
"""
if review.rejected:
first = review.rejected[0]
return f"That correction can't be applied — {first.detail or first.reason}."
return "That correction can't be applied."
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
def read_state_events(
limit: int = 100,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The accepted state changes, newest first: §8's audit trail.
What changed, which turn caused it, whether the model or the user asserted
it, and what the value was before. Bounded by default — this is an audit
view, and an unbounded read of a long campaign's every event is the query
shape this project keeps a regression test about.
"""
limit = max(1, min(limit, 500))
rows = narrative.store.history(db, adventure, limit=limit)
return [
schemas.StateEventOut(
id=row.id,
action_id=row.action_id,
branch_id=row.branch_id,
depth=row.depth,
turn=(row.depth + 1) if row.depth is not None else None,
sequence=row.sequence,
event_type=row.event_type,
payload=row.payload or {},
before=row.before,
source=row.source,
created_at=row.created_at,
)
for row in rows
]
+116 -76
View File
@@ -11,17 +11,16 @@ from fastapi import Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import attempts, limits, memorybank, models, schemas, tree
from ... import attempts, head, limits, memorybank, models, schemas, tree
from ...context import cursors
from ...context import lineage
from ...database import get_db
from ...scripting import ScriptPipeline
from ...sse import SSE_HEADERS
from . import turns
from .deps import CurrentUser, current_adventure, router
from .nodes import delete_turn, last_action, stand_on
from .paging import action_window, annotate_takes, current_window
from .nodes import last_action, stand_on
from .paging import current_window
@router.post("/{adventure_id}/retry")
@@ -34,26 +33,44 @@ def retry_action(
):
"""Regenerates the last AI action and keeps the discarded attempt.
The attempt on screen stays as it was written. The shared script state and
world state roll back to what the node before it left behind, and the new
attempt is stored as a sibling at the same coordinate. No text the AI wrote
is rewritten or deleted.
The attempt on screen stays as it was written. The world state rolls back to
what the node before it left behind, and the new attempt is stored as a
sibling at the same coordinate. No text the AI wrote is rewritten or deleted.
M3 added the one case that cannot be a sibling. Retrying the turn the head
rests on while a retained future still descends from it would leave that
future hanging off a take that is no longer live — the story after it was
written to continue the old text. So a retry from behind the tip takes a
branch instead, exactly as `add_take` does for a turn the story has moved
past. It is the same operation reached from a different button.
"""
limits.rate_limit("turn", request, user)
turns.check_demo_cap(db, user)
turns.acquire_turn_lock(adventure_id)
last_ai = None
try:
newest = last_action(adventure, db)
if newest is not None and newest.type == "ai":
last_ai = newest
# Read this before anything moves, and note it is *not*
# `fork_if_behind_head`: this fork leaves the path just in front of
# the turn being retried rather than at the head, so the new take
# lands at the same depth under the same parent.
diverging = head.behind_tip(db, adventure)
if diverging:
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, (newest.depth or 0) - 1)
if departed is not None:
head.mark_superseded(departed, (newest.depth or 0) - 1)
else:
# Only a sibling attempt names the node it replaces. A branched
# take is a fresh node at the same coordinate, so `generate_turn`
# places it through the tree rather than through `add_attempt`.
last_ai = newest
# Roll the state back to before this AI turn's hooks ran, so that
# regenerating starts from a clean state rather than applying output
# mutations on top of the attempt being replaced. If the preceding
# node has no snapshot, which happens for a pre-SP4 row that the
# migration could not derive one for, this call does nothing and
# leaves the state as it is.
attempts.roll_back_before(db, adventure, last_ai)
attempts.roll_back_before(db, adventure, newest)
db.commit()
db.refresh(adventure)
except BaseException:
@@ -62,9 +79,7 @@ def retry_action(
return StreamingResponse(
turns.with_turn_lock(
adventure_id,
turns.generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai
),
turns.generate_turn(adventure, db, user, retry_of=last_ai),
),
media_type="text/event-stream",
headers=SSE_HEADERS,
@@ -142,6 +157,17 @@ def select_variant(
"Only the latest message can be switched — the story has already "
"continued from this one.",
)
if head.behind_tip(db, adventure):
# The head is behind the retained tip, so this turn reads as the newest
# one but still has an accepted future descending from it. Switching the
# live take in place would leave that future continuing text the story
# no longer tells. Forking is the operation that does this safely, and
# `/fork` is where it lives.
raise HTTPException(
400,
"This turn has a later story that was undone but kept. Redo first, "
"or use another take to start a new line from here.",
)
turns.acquire_turn_lock(adventure_id)
try:
chosen = rows[payload.index]
@@ -254,9 +280,7 @@ def add_take(
turn, so the new attempt is written at the same depth under the same parent,
and the line it leaves is unchanged. No node below is copied.
"""
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
turns.check_demo_cap(db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
@@ -270,7 +294,15 @@ def add_take(
retry_of = None
try:
newest = last_action(adventure, db)
at_the_tip = newest is not None and newest.id == action.id
# `last_action` reads the capped path, so under a moved-back head it
# reports the node at the head as the newest one. A turn with a retained
# future is not a leaf, whatever the capped read says, so ask the head
# module rather than trusting the depth comparison alone (M3).
at_the_tip = (
newest is not None
and newest.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip and action.type == "ai":
# Nothing was played after it, so its attempts are still leaves and
# a branch would serve no purpose. This is the `retry` path.
@@ -281,7 +313,10 @@ def add_take(
# text that is there now. The new attempt leaves the path just
# before the turn, so that story keeps the attempt it was written
# for.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
attempts.roll_back_before(db, adventure, action)
adventure.updated_at = models.utcnow()
db.commit()
@@ -292,9 +327,7 @@ def add_take(
if action.type == "ai":
# There is no player action to write. The action this turn answers is
# already on the path, borrowed from the line being left.
stream = turns.generate_turn(
adventure, db, ScriptPipeline(adventure, db), user, retry_of=retry_of
)
stream = turns.generate_turn(adventure, db, user, retry_of=retry_of)
else:
stream = turns.run_player_turn(
adventure,
@@ -318,71 +351,78 @@ def undo_turn(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Deletes the last turn: the trailing AI action and its player action, if any.
"""Moves the story back one turn. Deletes nothing (M3).
The endpoint also rolls the shared `script_state` back to before that turn
ran, and it prunes any memory that summarized the removed actions. The turn
lock prevents an undo while a turn is still generating.
This endpoint used to remove the trailing AI action and the player action in
front of it, prune the memories that covered them, and let the tip fall back
to whatever survived. Undoing was therefore the one operation in the
application that destroyed accepted story, and it was why there was no Redo:
the turns to move forward into no longer existed.
Now it moves `adventure.head_depth`. The rows stay exactly where they are,
still live, still on their branch, and `lineage.Path` stops every read at the
head instead. The transcript, the assembled context, `attempts.preceding` and
memory retrieval all narrow together, because all four already funnelled
through the same path object.
The memory bank needs no pruning for the same reason. A memory carries the
coordinate of the node its block ends on, so a memory derived from a turn
that is now past the head falls outside the capped clause and stops being
retrievable — and becomes eligible again on Redo, without having been deleted
and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33 for free.
The state comes back from the node the story now ends on, which recorded what
it left behind when it played. See `head.move_to`.
"""
turns.acquire_turn_lock(adventure_id)
try:
# Only the last turn is removed, so fetch the two actions it can
# consist of rather than the whole story.
newest = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.limit(2)
.all()
)
if not newest or newest[0].type == "start":
target = head.undo_target(db, adventure)
if target is None:
raise HTTPException(400, "Nothing to undo")
last = newest[0]
before_that = newest[1] if len(newest) > 1 else None
# Undo only what this branch owns. Everything before the fork is
# borrowed from an ancestor and is part of that ancestor's story too, so
# an undo here must never delete a turn out of another branch. The test
# reads the row's own branch rather than the fork depth, because the
# branch is what decides the case.
if last.branch_id != adventure.head_branch_id:
raise HTTPException(
400, "Nothing to undo on this branch — the turns before it "
"belong to the branch it was forked from.",
)
first_removed = last
if (last.type == "ai" and before_that is not None
and before_that.type in ("do", "say", "story")
and before_that.branch_id == adventure.head_branch_id):
first_removed = before_that
# The state the story returns to once the turn is gone, which is what
# the node before the earliest removed one left behind. Read it before
# the deletes, while those rows are still in the story.
restore_to = attempts.preceding(db, adventure, first_removed)
delete_turn(db, adventure, last)
if first_removed is not last:
delete_turn(db, adventure, first_removed)
attempts.restore_state(adventure, restore_to)
db.flush() # Apply the deletes before anything reads the story back.
db.expire(adventure, ["actions"])
# The tip moves back with the deleted rows.
tree.refresh_head(db, adventure)
depth, _first_stepped = target
head.move_to(db, adventure, depth)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# Return the newest window rather than the whole story. The client
# replaces its transcript with this response, and the transcript is a
# window. Returning everything would defeat the paging on the action a
# player is most likely to repeat several times in a row.
actions, total, has_more = action_window(db, adventure)
return schemas.ActionPage(
actions=[
schemas.ActionOut.model_validate(a)
for a in annotate_takes(db, adventure.id, actions)
],
total=total,
has_more=has_more,
)
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
@router.post("/{adventure_id}/redo", response_model=schemas.ActionPage)
def redo_turn(
adventure_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Moves the story forward again into the continuation Undo stepped out of.
Redo exists because Undo stopped deleting. It walks the head forward over one
whole turn along the retained lineage, and restores the state that turn left
behind.
It follows the lineage rather than choosing among branches, which is what
makes it invalidate itself correctly. Writing below a moved-back head forks,
and from the new branch the displaced future is no longer on the lineage at
all — so there is nothing ahead to walk into and this returns 400 without any
flag having to be set or cleared. `STORY-BRANCH-SEMANTICS.md` §8.
400 is also what a head already at the tip gets, which is the ordinary case
for a story that has never been undone.
"""
turns.acquire_turn_lock(adventure_id)
try:
depth = head.redo_target(db, adventure)
if depth is None:
raise HTTPException(400, "Nothing to redo")
head.move_to(db, adventure, depth)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
+93 -115
View File
@@ -3,8 +3,8 @@
Everything a test needs to intercept lives here, and other modules reach it as
`turns.<name>` rather than importing it by value. That matters twice. The turn
lock guards one set only while one module owns it. And a test that replaces
`OpenAICompatibleProvider`, `generate_turn`, or `check_demo_cap` patches this
module, which every caller reads through.
`OpenAICompatibleProvider` or `generate_turn` patches this module, which every
caller reads through.
"""
import threading
@@ -13,12 +13,12 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import (
analytics, attempts, auth, limits, memorybank, models, schemas, tree, worldstate,
attempts, head, limits, memorybank, models, narrative, schemas, tree,
worldstate,
)
from ...context import build_context, cursors
from ...database import get_db
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
from ...scripting import ScriptPipeline
from ...sse import SSE_HEADERS, sse, turn_error
from ..settings import get_settings
@@ -115,14 +115,11 @@ def action_json(action: models.Action, db: Session | None = None) -> dict:
async def generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
"""Streams the AI continuation as SSE, then stores the result.
The continuation passes through the `context` and `output` script hooks.
If `retry_of` is set, the result is stored as a sibling of that AI action, at
the same turn and the same coordinate, and the discarded attempt stays where
it was written. Before calling, the caller must roll the adventure back to
@@ -132,15 +129,15 @@ async def generate_turn(
"""
saved = False
try:
async for event in _generate_turn(adventure, db, pipeline, user, retry_of):
async for event in _generate_turn(adventure, db, user, retry_of):
if event is _SAVED:
saved = True
continue
yield event
finally:
if retry_of is not None and not saved:
# The turn failed with a provider error, an empty reply, a script
# stop, or a disconnected client. No sibling was written, so the
# The turn failed with a provider error, an empty reply, or a
# disconnected client. No sibling was written, so the
# attempt on screen is still the live one. Restore the state it
# produced.
attempts.restore_state(adventure, retry_of)
@@ -155,55 +152,27 @@ _SAVED = object()
async def _generate_turn(
adventure: models.Adventure,
db: Session,
pipeline: ScriptPipeline,
user: models.User,
retry_of: models.Action | None = None,
):
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
# On a retry, the attempt being replaced is still the live node of its turn,
# because it stays live until a replacement exists. Filter it out of the
# context. Otherwise the model reads the attempt it is replacing as
# established story and writes a sequel to it.
replacing_id = retry_of.id if retry_of is not None else None
if cfg.using_demo:
# The server-funded key makes no embedding or summarization calls, so
# memory retrieval is skipped. If the bank is on, return a note.
memories = (
{"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."}
if adventure.memory_bank_enabled
else None
)
else:
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
system_text, story_text, snapshot = build_context(
adventure, settings, memories, exclude_action_id=replacing_id
)
# onModelContext: scripts read, and can rewrite, the whole assembled
# context.
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
modified, stop = pipeline.run("context", combined)
if stop:
yield sse({"type": "stopped", "script": pipeline.report()})
return
context_changed = modified != combined
parts = (
PromptParts(system="", story=modified)
if context_changed
else PromptParts(system=system_text, story=story_text)
)
snapshot["script"] = pipeline.report() | {
"context_changed": context_changed,
"context_before": combined if context_changed else None,
"context_after": modified if context_changed else None,
}
parts = PromptParts(system=system_text, story=story_text)
provider = OpenAICompatibleProvider(
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens,
settings.endpoint_url, settings.model, settings.api_mode,
settings.model_timeout_seconds,
)
chunks: list[str] = []
reasoning_chunks: list[str] = []
@@ -239,32 +208,46 @@ async def _generate_turn(
yield turn_error(detail)
return
# onOutput
text, _ = pipeline.run("output", text)
if not text.strip():
yield turn_error("A script's output modifier returned empty text.")
return
snapshot["script"] = snapshot["script"] | pipeline.report()
# RPG world state (Phase 12): read the AI's state delta out of the reply,
# apply it through the engine, and strip the block from the displayed text.
# M5: read the typed state proposal out of the reply, validate it, apply
# what survives, and strip the block from the displayed text.
#
# A retry re-runs the same turn, so it is played at that turn's depth. The
# cooldown rules run on a position in the story, and a second attempt at turn
# 12 is still turn 12. This was `retry_of.index`, which held the same number
# until SP4. Depth stays correct once a branch has its own numbering.
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
# protocol, not the lifecycle. What changed is that the referee now works on
# explicit typed events with absolute values, so an accepted proposal cannot
# mean something other than it says.
#
# A retry re-runs the same turn, so it is played at that turn's depth. This
# was `retry_of.index`, which held the same number until SP4. Depth stays
# correct once a branch has its own numbering.
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_depth
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
text, parsed, raw_block = narrative.extract.split(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
narrative.store.current(adventure),
narrative.store.canon_of(adventure),
)
# Held until the action exists, because a proposal record names the node
# whose narration produced it and the node has no id yet. Everything lands
# in the single commit below (L01).
# The coordinate is read off the node after it is placed, not guessed here:
# `tree.place_action` assigns the branch, and a retry inherits the branch of
# the attempt it replaces.
pending_state = {
"review": review,
"parsed": parsed,
"raw_block": raw_block,
"unparseable": parsed is None and bool(raw_block),
}
snapshot["narrative_state"] = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"status": review.status,
}
snapshot["raw_output"] = raw_output
# The cost the endpoint reports for the call, including how much of the
@@ -282,7 +265,6 @@ async def _generate_turn(
context_snapshot=snapshot,
world_delta=world_delta_of(snapshot),
)
attempts.snapshot_outcome(adventure, ai_action)
if retry_of is not None:
attempts.add_attempt(db, adventure, retry_of, ai_action)
db.add(ai_action)
@@ -303,40 +285,43 @@ async def _generate_turn(
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
db.flush()
# The state lands after the node exists and before the one commit, so the
# narration, the head, the accepted events, the provenance and the snapshot
# are one transaction. L01 forbids any window in which a turn looks accepted
# while its state is half-written, and the cheapest guarantee is to have a
# single commit rather than two that could get out of step.
new_state, _proposal = narrative.store.record(
db, adventure,
review=pending_state["review"],
raw_block=pending_state["raw_block"],
parsed=pending_state["parsed"],
action=ai_action,
branch_id=ai_action.branch_id,
depth=ai_action.depth,
model_name=settings.model or "",
source="accepted_story",
)
if pending_state["unparseable"]:
_proposal.status = "unparseable"
before_state = narrative.store.current(adventure)
narrative.store.set_current(adventure, new_state)
ai_action.state_changes = {
"accepted": pending_state["review"].accepted,
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
"summary": narrative.apply.diff(before_state, new_state),
}
attempts.snapshot_outcome(adventure, ai_action)
adventure.updated_at = models.utcnow()
if cfg.using_demo:
# Successful demo turns count against the daily cap, which the endpoint
# checks before the turn starts. A failed provider call above returns
# before this line.
auth.count_demo_turn(user)
db.commit()
# Count the turn here, after every path on which it could still have failed,
# so the number means "stories advanced" rather than "requests attempted".
# The demo tally counts those same turns as spend on the server-funded key.
analytics.record_event(analytics.EV_TURN, user)
if cfg.using_demo:
analytics.record(analytics.M_EVENT, analytics.EV_DEMO_TURN)
db.refresh(ai_action)
yield _SAVED
yield sse({"type": "done", "action": action_json(ai_action, db), "script": pipeline.report()})
yield sse({"type": "done", "action": action_json(ai_action, db)})
# Phase 6: schedule summarization and embedding without waiting for them.
# The task opens its own database session. It is skipped on the demo key,
# because background AI calls are unmetered spend on the server-funded
# key.
if not cfg.using_demo:
memorybank.schedule_post_turn(adventure)
# The task opens its own database session.
memorybank.schedule_post_turn(adventure)
def check_demo_cap(db: Session, user: models.User) -> None:
"""Checks the demo cap before a turn starts.
Checking first avoids storing a capped player's input and then leaving it
without a reply.
"""
settings = get_settings(db, user)
if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0:
raise HTTPException(429, auth.DEMO_CAP_MESSAGE)
async def run_player_turn(
adventure: models.Adventure,
@@ -353,29 +338,20 @@ async def run_player_turn(
formatted, and a plain edit puts that same text in the box and writes it back
verbatim. Formatting it a second time produces `> You > You ...`.
"""
pipeline = ScriptPipeline(adventure, db)
# An empty do, say, or story action behaves as a continue.
if payload.type != "continue" and payload.text.strip():
# onInput reads the formatted text, as in AI Dungeon: "> You ...".
formatted = (
payload.text.strip() if preformatted
else format_player_input(payload.type, payload.text)
)
modified, stop = pipeline.run("input", formatted)
if not modified.strip():
yield turn_error("A script's input modifier returned empty text.",
script=pipeline.report())
return
player_action = models.Action(
adventure_id=adventure.id,
depth=next_depth(adventure),
type=payload.type,
text=modified,
text=formatted,
)
# The state after the input hook has run. The node leaves this state
# behind. The AI turn after it starts here, and a retry of that turn
# rolls back to here.
# The state this node leaves behind. The AI turn after it starts here,
# and a retry of that turn rolls back to here.
attempts.snapshot_outcome(adventure, player_action)
tree.place_action(db, adventure, player_action)
db.add(player_action)
@@ -387,12 +363,8 @@ async def run_player_turn(
# that was just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action, db)})
if stop:
# If onInput returns `{ stop: true }`, skip the AI call.
yield sse({"type": "stopped", "script": pipeline.report()})
return
async for event in generate_turn(adventure, db, pipeline, user):
async for event in generate_turn(adventure, db, user):
yield event
@@ -405,12 +377,18 @@ def create_action(
user: models.User = CurrentUser,
adventure: models.Adventure = Depends(current_adventure),
):
limits.rate_limit("turn", request, user)
limits.check_row_cap("actions", db, user, adventure=adventure)
check_demo_cap(db, user)
acquire_turn_lock(adventure_id)
try:
_move_to_after(db, adventure, payload.after_id)
# The first write below a moved-back head is where a divergence happens
# (M3). Undo alone does not fork — the user may be reading, or about to
# Redo — so this is the moment the story states which continuation it
# means. The displaced future keeps its rows on the branch being left.
# A head already at the tip, which is every ordinary turn, forks nothing.
if head.fork_if_behind_head(db, adventure):
db.commit()
db.refresh(adventure)
except BaseException:
_active_turns.discard(adventure_id)
raise
-140
View File
@@ -1,140 +0,0 @@
"""Visit analytics: one endpoint the browser writes to, one the owner reads.
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
appearing to exist. `/summary` serves the anonymous counters (analytics.py,
which stores nothing that points at a person) and `/access` serves the access
log (accesslog.py, which identifies people on purpose).
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import accesslog, analytics, auth, limits, models
from ..database import get_db
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
def owner(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
) -> models.User:
"""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
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
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)
referrer: str = Field("", max_length=500)
first: bool = False
@router.post("/collect", status_code=204)
def collect(
payload: Pageview,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""Record one pageview. Always 204, even when nothing was counted: the
browser has no business knowing whether it was."""
limits.rate_limit("analytics", request)
# Resolved by hand rather than through get_current_user: a pageview that
# arrives before /auth/me has minted a session should still be counted as a
# view, not turned into a 401 the SPA has to handle.
user = (
auth.resolve_session_user(request, db)
if auth.MULTI_USER
else auth.local_user(db)
)
# 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)
analytics.record(analytics.M_PAGE, analytics.normalize_route(payload.path))
analytics.record_visit(user)
if payload.first:
referrer = analytics.normalize_referrer(
payload.referrer, request.url.hostname or ""
)
if referrer: # "" means same-origin, which is not a referral
analytics.record(analytics.M_REFERRER, referrer)
analytics.record(
analytics.M_DEVICE,
analytics.device_of(request.headers.get("user-agent", "")),
)
analytics.record(analytics.M_COUNTRY, analytics.country_of(request.headers))
return Response(status_code=204)
@router.get("/summary")
def summary(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
_user: models.User = Owner,
) -> dict:
"""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)
@router.get("/access")
def access_log(
limit: int = Query(50, ge=1, le=200),
before_id: int | None = Query(None),
kind: str | None = Query(None),
q: str | None = Query(None, max_length=120),
db: Session = Depends(get_db),
_user: models.User = Owner,
) -> dict:
"""A page of the access log, newest first.
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
)
return {
"events": [
{
"id": event.id,
"at": event.at.isoformat(),
"kind": event.kind,
"who": event.who,
"is_guest": event.is_guest,
"ip": event.ip,
"country": event.country,
"device": event.device,
"user_agent": event.user_agent,
}
for event in page["events"]
],
"has_more": page["has_more"],
}
-158
View File
@@ -1,158 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from .. import (accesslog, analytics, auth, cleanup, limits, models, schemas,
security, starter)
from ..database import get_db
from .settings import get_settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _set_session_cookie(response: Response, user_id: int) -> None:
response.set_cookie(
auth.SESSION_COOKIE,
security.sign_session(user_id),
max_age=auth.COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=auth.COOKIE_SECURE,
path="/",
)
def me_payload(user: models.User, db: Session) -> dict:
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
return {
"multi_user": auth.MULTI_USER,
"id": user.id,
"email": user.email,
"is_guest": user.is_guest,
# Trusted testers: unmetered demo turns, plus the AI Chat scratchpad.
"power_user": auth.is_power_user(user),
# Separate allowlist: shows the visit-analytics page and its nav link.
"analytics": auth.is_owner(user),
# How long an idle guest is kept before cleanup deletes it (None when
# the policy is off). Served rather than hardcoded in the UI so the
# number a guest is shown is the number actually enforced.
"guest_retention_days": cleanup.RETENTION_DAYS if cleanup.enabled() else None,
"demo": {
"enabled": auth.demo_enabled(),
"using_demo": cfg.using_demo,
"model": cfg.model if cfg.using_demo else None,
"turns_per_day": auth.DEMO_TURNS_PER_DAY,
"turns_left": auth.demo_turns_left(user) if auth.demo_enabled() else None,
"models": auth.DEMO_MODELS if auth.demo_enabled() else [],
},
}
@router.get("/me")
def me(request: Request, response: Response, db: Session = Depends(get_db)):
"""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, so cap how fast one IP can
# create them.
limits.rate_limit("guest", request)
user = models.User(is_guest=True)
db.add(user)
db.commit()
# The guest is committed first, so a failure while copying the
# starter adventure still leaves them with an account.
starter.give(db, user)
db.commit()
_set_session_cookie(response, user.id)
# This endpoint is the SPA's bootstrap call, so it is where a session first
# shows itself; accesslog thins the rows down to one per day per address.
accesslog.note_session(db, user, request)
return me_payload(user, db)
@router.post("/register")
def register(
payload: schemas.AuthCredentials,
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""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)
email = payload.email.strip().lower()
if not EMAIL_RE.match(email):
raise HTTPException(422, "Enter a valid email address.")
if len(payload.password) < 8:
raise HTTPException(422, "Password must be at least 8 characters.")
if not user.is_guest:
raise HTTPException(400, "This session is already registered.")
if db.query(models.User).filter(models.User.email == email).first():
raise HTTPException(409, "An account with this email already exists — log in instead.")
user.email = email
user.password_hash = security.hash_password(payload.password)
user.is_guest = False
db.commit()
analytics.record_event(analytics.EV_SIGNUP, user)
accesslog.record(db, accesslog.REGISTER, request, user=user)
return me_payload(user, db)
@router.post("/login")
def login(
payload: schemas.AuthCredentials,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Point this browser's session at an existing account. Any current guest
session is simply abandoned (its data stays under the guest user)."""
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
limits.rate_limit("auth", request)
email = payload.email.strip().lower()
# Per-account throttle: stops distributed guessing against one email even
# when the per-IP limit above is diluted across many source addresses.
limits.check_login_allowed(email)
user = db.query(models.User).filter(models.User.email == email).first()
if (
user is None
or not user.password_hash
or not security.verify_password(payload.password, user.password_hash)
):
limits.note_login_failure(email)
# Logged with the address that was tried, not the account that owns it:
# a guessing run against an address that has no account is exactly the
# thing worth being able to see.
accesslog.record(db, accesslog.LOGIN_FAILED, request, who=email)
raise HTTPException(401, "Incorrect email or password.")
limits.note_login_success(email)
_set_session_cookie(response, user.id)
analytics.record_event(analytics.EV_LOGIN, user)
accesslog.record(db, accesslog.LOGIN, request, user=user)
return me_payload(user, db)
@router.post("/logout")
def logout(response: Response):
if not auth.MULTI_USER:
raise HTTPException(400, "Accounts are disabled in local mode.")
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
+37 -79
View File
@@ -1,22 +1,23 @@
"""AI Chat: a plain scratchpad for talking to a model directly.
"""AI Chat: a plain scratchpad for talking to the configured model directly.
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.
Deliberately thin. It adds no story context and no world state, and it persists
nothing. The conversation lives in the browser and is posted in full on each
turn. It exists for checking a model, a prompt, or an endpoint without starting
an adventure — which is exactly the kind of thing a local single-user install
wants a page for.
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.
Upstream gated this behind a "power user" email allowlist and pinned the model
when a shared demo key was in play. M2 removed both: there is one local user,
who owns the endpoint, and there is no server-funded key to protect. The model
this page talks to is the one in Settings, or one the user names per request —
either way it is their own Ollama.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas
from .. import auth, models, schemas
from ..database import get_db
from ..providers import OpenAICompatibleProvider, ProviderError
from ..sse import SSE_HEADERS, sse
@@ -25,80 +26,41 @@ from .settings import get_settings, list_endpoint_models
router = APIRouter(prefix="/api/chat", tags=["chat"])
def power_user(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
) -> models.User:
"""Gate for the whole router. 404 rather than 403 so the feature simply
doesn't appear to exist for everyone else."""
if not auth.is_power_user(user):
raise HTTPException(404, "Not found")
return user
PowerUser = Depends(power_user)
def _resolve_model(
settings: models.Settings, requested: str | None
) -> tuple[auth.ProviderConfig, str | None]:
"""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:
return cfg, (
f"'{wanted}' isn't available on the shared demo key — using "
f"{cfg.model}. Add your own API key in Settings to use any model."
)
return cfg, None
@router.get("/config")
async def chat_config(
db: Session = Depends(get_db),
user: models.User = PowerUser,
user: models.User = Depends(auth.get_current_user),
):
"""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.
The model listing is best effort: an unreachable endpoint returns an empty
list and the reason, rather than failing the page.
"""
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
listing = await list_endpoint_models(cfg)
listing = await list_endpoint_models(settings.endpoint_url)
return {
"endpoint_url": cfg.endpoint_url,
"model": cfg.model,
"using_demo": cfg.using_demo,
"endpoint_url": settings.endpoint_url,
"model": settings.model,
"api_mode": settings.api_mode,
"temperature": settings.temperature,
"max_tokens": settings.max_output_tokens,
# On the demo key the whitelist IS the list of choices; otherwise it's
# whatever the endpoint advertises (suggestions, not a restriction).
"models": auth.DEMO_MODELS if cfg.using_demo else listing.get("models", []),
# Suggestions from the endpoint, not a restriction.
"models": listing.get("models", []),
"models_error": None if listing.get("ok") else listing.get("detail"),
}
async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload: schemas.ChatRequest,
note: str | None, db: Session, user: models.User):
async def run_chat(
settings: models.Settings, model: str, payload: schemas.ChatRequest
):
"""Streams the reply as SSE, using the turn stream's event shape.
The generator emits `reasoning` and `chunk` events while generating and then
a `done` event, so the frontend reuses the same code.
"""
if note:
yield sse({"type": "note", "detail": note})
provider = OpenAICompatibleProvider(
cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode,
settings.reasoning_max_tokens,
settings.endpoint_url, model, settings.api_mode,
settings.model_timeout_seconds,
)
messages = [m.model_dump() for m in payload.messages]
chunks: list[str] = []
@@ -106,7 +68,11 @@ async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload:
try:
async for kind, chunk in provider.chat(
messages,
temperature=payload.temperature if payload.temperature is not None else settings.temperature,
temperature=(
payload.temperature
if payload.temperature is not None
else settings.temperature
),
max_tokens=payload.max_tokens or settings.max_output_tokens,
):
if kind == "reasoning":
@@ -123,33 +89,26 @@ async def run_chat(cfg: auth.ProviderConfig, settings: models.Settings, payload:
if not text:
detail = (
"The model used its entire token budget on reasoning and returned no "
"reply — raise max tokens, cap the reasoning budget in Settings, or "
"use a non-reasoning model."
"reply — raise max tokens or use a non-reasoning model."
if reasoning_chunks
else "The AI returned an empty response."
)
yield sse({"type": "error", "detail": detail})
return
if cfg.using_demo:
# Unmetered for power users (count_demo_turn is a no-op for them), but
# keep the call so the accounting stays right if the gate ever widens.
auth.count_demo_turn(user)
db.commit()
yield sse({
"type": "done",
"text": text,
"reasoning": "".join(reasoning_chunks).strip() or None,
"model": cfg.model,
"model": model,
})
@router.post("/stream")
def chat_stream(
payload: schemas.ChatRequest,
request: Request,
db: Session = Depends(get_db),
user: models.User = PowerUser,
user: models.User = Depends(auth.get_current_user),
):
total = sum(len(m.content) for m in payload.messages)
if total > schemas.CHAT_TOTAL_MAX:
@@ -157,13 +116,12 @@ def chat_stream(
413, f"This conversation is too long to send ({total:,} characters) — "
"clear it or start a new one."
)
limits.rate_limit("chat", request, user)
settings = get_settings(db, user)
cfg, note = _resolve_model(settings, payload.model)
if not cfg.model:
model = (payload.model or "").strip() or settings.model
if not model:
raise HTTPException(400, "No model configured — set one in Settings or pick one here.")
return StreamingResponse(
run_chat(cfg, settings, payload, note, db, user),
run_chat(settings, model, payload),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
+6 -9
View File
@@ -1,19 +1,16 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from .. import auth, debuglog
from .. import debuglog
router = APIRouter(prefix="/api/debug", tags=["debug"])
@router.get("/requests")
def recent_requests():
"""Most-recent-first log of provider requests/responses (no API keys).
"""Most-recent-first log of provider requests and responses.
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.
A single process-wide ring buffer. It holds the prompts this install sent
to its own Ollama, which is exactly what the person running it needs to
diagnose a turn, and there is nobody else it could expose them to.
"""
if auth.MULTI_USER:
raise HTTPException(403, "The debug log is only available on local installs.")
return debuglog.recent()
+1 -40
View File
@@ -3,7 +3,7 @@ from fastapi.responses import Response
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .. import analytics, auth, images, limits, models, schemas
from .. import auth, images, limits, models, schemas
from ..database import get_db
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@@ -54,12 +54,6 @@ def get_scenario(
user: models.User = Depends(auth.get_current_user),
):
scenario = get_scenario_or_404(scenario_id, db, user)
# 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
@@ -96,18 +90,8 @@ def update_scenario(
):
scenario = get_scenario_or_404(scenario_id, db, user, edit=True)
data = payload.model_dump(exclude_unset=True)
script_ids = data.pop("script_ids", None)
for field, value in data.items():
setattr(scenario, field, value)
if script_ids is not None:
scripts = (
db.query(models.Script)
.filter(models.Script.id.in_(script_ids), models.Script.user_id == user.id)
.all()
)
if len(scripts) != len(set(script_ids)):
raise HTTPException(404, "One or more scripts not found")
scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id))
db.commit()
return scenario
@@ -148,13 +132,6 @@ def export_scenario(
{"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes}
for c in s.story_cards
],
"scripts": [
{
"name": sc.name, "description": sc.description, "library": sc.library_js,
"input": sc.input_js, "context": sc.context_js, "output": sc.output_js,
}
for sc in s.scripts
],
}
@@ -185,7 +162,6 @@ def import_scenario(
):
"""Accepts our export format and AI Dungeon scenario exports best-effort;
reports any keys it didn't understand."""
limits.rate_limit("import", request, user)
limits.check_row_cap("scenarios", db, user)
fields: dict = {}
unmapped: list[str] = []
@@ -250,21 +226,6 @@ def import_scenario(
)
)
for item in bundle.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
user_id=user.id,
name=str(item.get("name") or "Imported Script")[:schemas.NAME_MAX],
description=str(item.get("description") or ""),
library_js=str(item.get("library") or item.get("sharedLibrary") or ""),
input_js=str(item.get("input") or item.get("onInput") or ""),
context_js=str(item.get("context") or item.get("onModelContext") or ""),
output_js=str(item.get("output") or item.get("onOutput") or ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)
db.commit()
out = schemas.ScenarioOut.model_validate(scenario).model_dump(mode="json")
-159
View File
@@ -1,159 +0,0 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import auth, limits, models, schemas
from ..database import get_db
from ..scripting import run_hook
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"}
def get_script_or_404(script_id: int, db: Session, user: models.User) -> models.Script:
script = db.get(models.Script, script_id)
if script is None or script.user_id != user.id:
raise HTTPException(404, "Script not found")
return script
@router.get("", response_model=list[schemas.ScriptOut])
def list_scripts(
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return (
db.query(models.Script)
.filter(models.Script.user_id == user.id)
.order_by(models.Script.updated_at.desc())
.all()
)
@router.post("", response_model=schemas.ScriptOut, status_code=201)
def create_script(
payload: schemas.ScriptCreate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
limits.check_row_cap("scripts", db, user)
script = models.Script(**payload.model_dump(), user_id=user.id)
db.add(script)
db.commit()
return script
@router.get("/{script_id}", response_model=schemas.ScriptOut)
def get_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
return get_script_or_404(script_id, db, user)
@router.patch("/{script_id}", response_model=schemas.ScriptOut)
def update_script(
script_id: int,
payload: schemas.ScriptUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
script = get_script_or_404(script_id, db, user)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(script, field, value)
db.commit()
return script
@router.delete("/{script_id}", status_code=204)
def delete_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
db.delete(get_script_or_404(script_id, db, user))
db.commit()
@router.post("/{script_id}/test")
def test_script(
script_id: int,
payload: schemas.ScriptTestRequest,
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""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(
script.library_js,
getattr(script, HOOK_FIELDS[payload.hook]),
payload.text,
payload.state,
history=[],
story_cards=[],
info={"actionCount": 0, "characterNames": [], "memoryLength": 0, "maxChars": 0},
)
return {
"text": result.text,
"stop": result.stop,
"state": result.state,
"storyCards": result.story_cards,
"logs": result.logs,
"error": result.error,
}
# ---------- Import / Export ----------
@router.get("/{script_id}/export")
def export_script(
script_id: int,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""JSON bundle matching how AI Dungeon scripts circulate."""
script = get_script_or_404(script_id, db, user)
return {
"name": script.name,
"description": script.description,
"library": script.library_js,
"input": script.input_js,
"context": script.context_js,
"output": script.output_js,
}
@router.post("/import", response_model=schemas.ScriptOut, status_code=201)
def import_script(
request: Request,
bundle: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""Accepts our export bundle; tolerates *_js key names too."""
limits.rate_limit("import", request, user)
limits.check_row_cap("scripts", db, user)
def pick(*keys: str) -> str:
for key in keys:
value = bundle.get(key)
if isinstance(value, str):
return value
return ""
script = models.Script(
user_id=user.id,
# 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"),
input_js=pick("input", "input_js", "onInput"),
context_js=pick("context", "context_js", "onModelContext"),
output_js=pick("output", "output_js", "onOutput"),
)
db.add(script)
db.commit()
return script
+93 -37
View File
@@ -1,20 +1,33 @@
"""The model settings, and the connection test that tells you why they don't work.
There is one settings row, belonging to the one local user. It describes an
Ollama: where it is, which model to narrate with, which to embed with, and how
long to wait for it.
Upstream let this row name any OpenAI-compatible endpoint and carry an
encrypted API key for it. M2 narrowed both: `endpoints.py` decides which
addresses may be named, and there is no key field, because Ollama does not use
one and this build has no cloud provider to carry a key for.
"""
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from .. import auth, limits, models, netguard, schemas, security, tlstrust
from .. import auth, endpoints, models, schemas, tlstrust
from ..database import get_db
from ..providers.openai_compatible import CONNECT_TIMEOUT
router = APIRouter(prefix="/api/settings", tags=["settings"])
#: The connection test is a listing, not a generation, so it never waits on a
#: model load and does not need the turn engine's patience.
TEST_TIMEOUT = 15.0
def get_settings(db: Session, user: models.User) -> models.Settings:
"""Returns the 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.
"""
"""Returns the settings row, creating it on first access."""
settings = (
db.query(models.Settings).filter(models.Settings.user_id == user.id).first()
)
@@ -34,16 +47,24 @@ def read_settings(
@router.put("", response_model=schemas.SettingsOut)
def update_settings(
async def update_settings(
payload: schemas.SettingsUpdate,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
settings = get_settings(db, user)
fields = payload.model_dump(exclude_unset=True)
# Write-only API key: absent = unchanged, "" = cleared, else encrypted.
if "api_key" in fields:
fields["api_key"] = security.encrypt_secret(fields["api_key"].strip())
if "endpoint_url" in fields:
# Refused here so the user finds out while they are looking at the
# field, rather than on their next turn. The provider re-checks before
# every request regardless; this is the friendly half of the same rule.
reason = await run_in_threadpool(
endpoints.rejection_reason, fields["endpoint_url"]
)
if reason is not None:
raise HTTPException(400, f"That endpoint can't be used — {reason}.")
embedding_model_changed = (
"embedding_model" in fields
and fields["embedding_model"] != settings.embedding_model
@@ -53,8 +74,6 @@ 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 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
@@ -80,28 +99,61 @@ def update_settings(
return settings
async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
"""Fetches the endpoint's /models listing.
async def list_endpoint_models(endpoint_url: str) -> dict:
"""Fetches the endpoint's `/models` listing, and doubles as the connection test.
The call also serves as a connectivity check, so a failure returns
`{"ok": False, "detail": ...}` rather than raising.
Returns `{"ok": False, "detail": ...}` rather than raising, because every
caller wants to show the reason rather than fail the page.
The failure cases are told apart on purpose. "Ollama isn't running", "that
address isn't allowed", "the certificate doesn't verify" and "it answered,
but with an error" need four different things done about them, and a single
"connection failed" leaves the user guessing which they have.
"""
# 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}."}
url = cfg.endpoint_url.rstrip("/") + "/models"
headers = {}
if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}"
reason = await run_in_threadpool(endpoints.rejection_reason, endpoint_url)
if reason is not None:
return {
"ok": False, "kind": "rejected",
"detail": f"That endpoint can't be used — {reason}.",
}
url = endpoint_url.rstrip("/") + "/models"
try:
async with httpx.AsyncClient(timeout=10, verify=tlstrust.ssl_context()) as client:
resp = await client.get(url, headers=headers)
async with httpx.AsyncClient(
timeout=httpx.Timeout(TEST_TIMEOUT, connect=CONNECT_TIMEOUT),
verify=tlstrust.ssl_context(),
) as client:
resp = await client.get(url)
except httpx.ConnectError as exc:
# A TLS failure arrives as a ConnectError too, and it needs a different
# answer from "nothing is listening": install the CA, don't start Ollama.
if "CERTIFICATE_VERIFY" in str(exc).upper() or "SSL" in str(exc).upper():
return {
"ok": False, "kind": "tls",
"detail": (
"The endpoint's TLS certificate could not be verified. If it "
"uses a private or self-signed CA, install that CA on this "
"machine so the system trusts it. Certificate checking is "
"not optional."
),
}
return {
"ok": False, "kind": "unreachable",
"detail": f"Could not connect to {endpoint_url} — is Ollama running there?",
}
except httpx.TimeoutException:
return {
"ok": False, "kind": "timeout",
"detail": f"{endpoint_url} did not answer within {TEST_TIMEOUT:.0f}s.",
}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"Connection failed: {exc}"}
return {"ok": False, "kind": "error", "detail": f"Connection failed: {exc}"}
if resp.status_code != 200:
return {"ok": False, "detail": f"HTTP {resp.status_code}: {resp.text[:300]}"}
return {
"ok": False, "kind": "http",
"detail": f"HTTP {resp.status_code}: {resp.text[:300]}",
}
models_available: list[str] = []
try:
@@ -115,15 +167,19 @@ async def list_endpoint_models(cfg: auth.ProviderConfig) -> dict:
@router.post("/test")
async def test_connection(
request: Request,
db: Session = Depends(get_db),
user: models.User = Depends(auth.get_current_user),
):
"""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)
"""Checks the endpoint the turn engine would use, and lists its models."""
settings = get_settings(db, user)
return await list_endpoint_models(auth.resolve_provider_config(settings))
result = await list_endpoint_models(settings.endpoint_url)
if result.get("ok") and settings.model and settings.model not in result["models"]:
# Reachable, but pointed at a model that is not installed there — the
# commonest way for a correct endpoint to still fail every turn.
return result | {
"warning": (
f"{settings.endpoint_url} is reachable, but has no model named "
f"'{settings.model}'. Pull it there, or pick one from the list."
)
}
return result
-1
View File
@@ -159,7 +159,6 @@ def import_story_cards(
raise HTTPException(422, 'Expected a "cards" array of story cards.')
cards_in = [c for c in cards_in if isinstance(c, dict)]
limits.rate_limit("import", request, user)
limits.check_bundle_lists(story_cards=cards_in)
existing = len(owner.story_cards)
if auth.MULTI_USER and existing + len(cards_in) > limits.MAX_STORY_CARDS_PER_OWNER:
+163 -77
View File
@@ -14,7 +14,6 @@ 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. A 400x300 WebP at
@@ -26,17 +25,21 @@ ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
# M4: what a player called a Save Point. VARCHAR(120). Wider than a branch name
# because these are sentences rather than labels — "Before entering the abbey"
# is the example the specification uses throughout.
CHECKPOINT_NAME_MAX = 120
Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)]
Prose = Annotated[str, Field(max_length=PROSE_MAX)]
ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)]
ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
class ORMModel(BaseModel):
@@ -106,7 +109,6 @@ class ScenarioUpdate(BaseModel):
image: Image | None = None
icon: Icon | None = None
stat_schema: dict | None = None
script_ids: list[int] | None = None
class ScenarioOut(ORMModel, ScenarioBase):
@@ -115,7 +117,6 @@ class ScenarioOut(ORMModel, ScenarioBase):
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
scripts: list["ScriptOut"] = []
class ScenarioListItem(ORMModel):
@@ -203,8 +204,13 @@ class ActionOut(ORMModel):
text: str
reasoning: str | None = None
# Phase 12: the compact RPG state changes for this turn, read from the
# model property.
# model property. Legacy as of M5 and empty on new turns; kept so a pre-M5
# campaign's chips still render.
world_changes: list[dict] = []
# M5: what this turn changed, as short lines for the chip under an AI
# message. Read from `Action.state_summary`, which reads the small
# bulk-loaded column rather than the deferred snapshot.
state_summary: list[str] = []
# 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
@@ -258,6 +264,10 @@ class BranchOut(ORMModel):
fork_depth: int | None = None
depth: int
own_actions: int = 0
# M4: how many Save Points name a position on this line. Deleting the branch
# deletes them with its story, so the panel warns with a number rather than
# a vague caution. Zero for a line nobody has bookmarked, which is most.
save_points: int = 0
is_head: bool = False
# NULL for a branch nobody has named. The client labels those from the fork
# depth rather than the server inventing a name. See the column comment.
@@ -271,6 +281,139 @@ class BranchRename(BaseModel):
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
# ---------- Narrative state (M5) ----------
class StateGroup(BaseModel):
"""One labelled section of the state inspector.
Rows carry the key as well as the label, because a manual correction has to
name an entity and the user should not have to guess the identifier.
"""
title: str
rows: list[dict] = []
class NarrativeStateOut(BaseModel):
"""The authoritative state at the active head.
`groups` is the display form and `document` is the state itself. Both are
returned because they answer different questions: the panel renders the
first, and a correction form — or a test — needs the second to name a key.
"""
groups: list[StateGroup] = []
empty: bool = True
document: dict = {}
class StateEventIn(BaseModel):
"""One typed event, as a client proposes it.
Deliberately loose about which fields are present: the event vocabulary is
defined in `narrative/events.py` and enforced by `narrative/validate.py`,
and duplicating those rules here would create a second, drifting copy of the
allowlist. What this model does is bound the shapes — a type that is a
string, values that are scalars, labels that are short strings — so a
payload cannot smuggle a structure past Pydantic and reach the validator as
something other than an event.
"""
model_config = ConfigDict(extra="allow")
type: Annotated[str, Field(max_length=60)]
class StateCorrection(BaseModel):
"""A manual correction: the user overruling what the story established.
`note` records why, in the user's words, and is kept on the proposal record
so the audit says more than "the user changed this".
"""
events: Annotated[list[StateEventIn], Field(min_length=1, max_length=20)]
note: Prose = ""
class StateEventOut(ORMModel):
"""One accepted change, for the audit view."""
id: int
action_id: int | None = None
branch_id: int | None = None
depth: int | None = None
# The reader-facing position, matching the Save Point panel's vocabulary.
turn: int | None = None
sequence: int = 0
event_type: str
payload: dict = {}
before: dict | None = None
source: str = "accepted_story"
created_at: datetime
# ---------- Save Points (M4) ----------
#
# "Save Point" is the user-facing term and `checkpoint` is the internal one
# (`BROWSER-UX-SPEC.md` §23). The wire format uses the internal name, as the
# rest of this module does.
class CheckpointOut(ORMModel):
"""One Save Point: a name and the position it names.
The position is reported three ways because the panel needs three different
things from it. `turn` is what a reader counts — the same `depth + 1` the
branch list shows. `depth` and `branch_id` are the coordinate itself.
`on_path` says whether the position lies on the story being read, which is
how the panel can tell a Save Point on this line from one naming a line the
story has left; restoring either works, but they are not the same offer.
`resolved` is false when the coordinate no longer names a live turn, which
an action deleted out of the middle of a story can do. Restore refuses such
a Save Point rather than moving the head somewhere approximate, so the list
says so before the button is pressed.
"""
id: int
adventure_id: int
name: str
note: str = ""
branch_id: int
depth: int
turn: int = 0
on_path: bool = True
resolved: bool = True
created_at: datetime
updated_at: datetime
class CheckpointCreate(BaseModel):
"""A Save Point at wherever the story is being read.
The position is not a field. A Save Point is made at the campaign's active
head, which the server already knows, and accepting a coordinate from the
client would be the second way to name a position — the thing this milestone
exists not to build.
"""
name: CheckpointName
note: Prose = ""
class CheckpointRename(BaseModel):
"""A new label, and nothing else.
There is deliberately no coordinate here. `STORY-BRANCH-SEMANTICS.md` §24
keeps a Save Point's meaning auditable by refusing to move one: rename it,
or delete it and make another where you are.
"""
name: CheckpointName | None = None
note: Prose | None = None
class ActionUpdate(BaseModel):
text: ActionText
@@ -321,6 +464,12 @@ class AdventureOut(ORMModel):
# story's length, which is how the client knows more actions exist above.
actions: list[ActionOut] = []
action_count: int = 0
# M3. Whether the history controls have anywhere to go from where the story
# is. The client cannot work either out for itself: `can_undo` needs the
# campaign opening, which may be off the top of the loaded window, and
# `can_redo` needs the retained future, which the client is never sent.
can_undo: bool = False
can_redo: bool = False
class ActionPage(BaseModel):
@@ -331,6 +480,10 @@ class ActionPage(BaseModel):
# 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
# The same two flags `AdventureOut` carries, so that the response to Undo,
# Redo or a turn updates the controls without a second request.
can_undo: bool = False
can_redo: bool = False
# ---------- Memory bank (Phase 6) ----------
@@ -374,67 +527,6 @@ class AdventureListItem(ORMModel):
icon: str = ""
# ---------- Scripts ----------
class ScriptBase(BaseModel):
name: Name = "Untitled Script"
description: Prose = ""
library_js: ScriptSource = ""
input_js: ScriptSource = ""
context_js: ScriptSource = ""
output_js: ScriptSource = ""
class ScriptCreate(ScriptBase):
pass
class ScriptUpdate(BaseModel):
name: Name | None = None
description: Prose | None = None
library_js: ScriptSource | None = None
input_js: ScriptSource | None = None
context_js: ScriptSource | None = None
output_js: ScriptSource | None = None
class ScriptOut(ORMModel, ScriptBase):
id: int
created_at: datetime
updated_at: datetime
class ScriptTestRequest(BaseModel):
hook: Literal["input", "context", "output"]
text: Prose = ""
state: dict = {}
class AdventureScriptOut(ORMModel):
id: int
adventure_id: int
position: int
enabled: bool
name: str
description: str
library_js: str
input_js: str
context_js: str
output_js: str
# 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
class AdventureScriptUpdate(BaseModel):
enabled: bool | None = None
library_js: ScriptSource | None = None
input_js: ScriptSource | None = None
context_js: ScriptSource | None = None
output_js: ScriptSource | None = None
# ---------- Auth (Phase 8) ----------
class AuthCredentials(BaseModel):
@@ -448,15 +540,12 @@ class AuthCredentials(BaseModel):
class SettingsOut(ORMModel):
endpoint_url: str
# The key itself is never returned. It is encrypted at rest and
# write-only.
has_api_key: bool
model: str
api_mode: str
temperature: float
max_output_tokens: int
reasoning_max_tokens: int
context_token_budget: int
model_timeout_seconds: int
narrator_prompt: str
summary_model: str
embedding_model: str
@@ -491,18 +580,15 @@ class ChatRequest(BaseModel):
class SettingsUpdate(BaseModel):
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
# 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
# Seconds to wait for the model. The floor is high enough that a normal
# turn cannot trip it; the ceiling exists so that "wait longer" stays a
# number rather than becoming "wait forever".
model_timeout_seconds: Annotated[int, Field(ge=30, le=3600)] | None = None
narrator_prompt: Prose | None = None
summary_model: Name | None = None
embedding_model: Name | None = None
-4
View File
@@ -1,4 +0,0 @@
from .engine import HookResult, run_hook
from .pipeline import ScriptPipeline
__all__ = ["HookResult", "ScriptPipeline", "run_hook"]
-146
View File
@@ -1,146 +0,0 @@
"""AI Dungeon-compatible script execution in an embedded QuickJS sandbox.
Each hook run is fully isolated (fresh Context), capped at 16 MB memory and
2 seconds CPU, with no filesystem/network/process access (QuickJS has none by
default). Scripts follow the AI Dungeon contract: define a `modifier(text)`
and call it as the last line; its return value `{ text, stop }` is the result.
"""
import json
from dataclasses import dataclass, field
import quickjs
MEMORY_LIMIT = 16 * 1024 * 1024
TIME_LIMIT_SECONDS = 2
HISTORY_WINDOW = 100 # recent actions exposed as `history`
# Globals per the official docs: text, state, history, storyCards, info,
# log/console.log, story card functions, plus legacy worldInfo aliases.
PRELUDE = """
"use strict";
var __logs = [];
var state = __DATA__.state;
var text = __DATA__.text;
var history = __DATA__.history;
var storyCards = __DATA__.storyCards;
var info = __DATA__.info;
function log(msg) {
__logs.push(typeof msg === "string" ? msg : JSON.stringify(msg));
}
var console = { log: log };
// Returns the new card's index, or false if a card with those keys exists —
// matching real AI Dungeon. Note index 0 is falsy; that quirk is upstream's.
function addStoryCard(keys, entry, type) {
for (var i = 0; i < storyCards.length; i++) {
if (storyCards[i].keys === keys) return false;
}
storyCards.push({ id: null, keys: keys || "", entry: entry || "", type: type || "" });
return storyCards.length - 1;
}
function updateStoryCard(index, keys, entry, type) {
var card = storyCards[index];
if (!card) throw new Error("Story card not found");
card.keys = keys;
card.entry = entry;
card.type = type;
}
function removeStoryCard(index) {
if (!storyCards[index]) throw new Error("Story card not found");
storyCards.splice(index, 1);
}
// Legacy aliases used by older AI Dungeon scripts.
var worldInfo = storyCards;
var worldEntries = storyCards;
function addWorldEntry(keys, entry) { return addStoryCard(keys, entry, ""); }
function updateWorldEntry(index, keys, entry) {
var card = storyCards[index];
if (!card) throw new Error("World entry not found");
card.keys = keys;
card.entry = entry;
}
function removeWorldEntry(index) { return removeStoryCard(index); }
"""
COLLECT = """
JSON.stringify({
result: (typeof __result === "undefined" || __result === null) ? null : __result,
state: state,
storyCards: storyCards,
logs: __logs
})
"""
@dataclass
class HookResult:
text: str
stop: bool = False
state: dict = field(default_factory=dict)
story_cards: list = field(default_factory=list)
logs: list = field(default_factory=list)
error: str | None = None
def run_hook(
library_js: str,
hook_js: str,
text: str,
state: dict,
history: list[dict],
story_cards: list[dict],
info: dict,
) -> HookResult:
"""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():
return unchanged
data = {
"state": state,
"text": text,
"history": history[-HISTORY_WINDOW:],
"storyCards": story_cards,
"info": info,
}
try:
ctx = quickjs.Context()
ctx.set_memory_limit(MEMORY_LIMIT)
ctx.set_time_limit(TIME_LIMIT_SECONDS)
ctx.eval(f"var __DATA__ = {json.dumps(data)};")
ctx.eval(PRELUDE)
ctx.eval(f"var __SRC__ = {json.dumps(source)};")
# Indirect eval keeps the script in global scope, so `modifier(text)` as the
# script's final expression statement becomes the completion value.
ctx.eval("var __result = (0, eval)(__SRC__);")
collected = json.loads(ctx.eval(COLLECT))
except quickjs.JSException as exc:
unchanged.error = f"Script error: {exc}"
return unchanged
except Exception as exc: # memory limit, invalid JSON state, engine faults
unchanged.error = f"Script execution failed: {exc}"
return unchanged
result = collected.get("result")
new_text, stop = text, False
if isinstance(result, dict):
if isinstance(result.get("text"), str):
new_text = result["text"]
stop = bool(result.get("stop"))
elif isinstance(result, str):
new_text = result
new_state = collected.get("state")
return HookResult(
text=new_text,
stop=stop,
state=new_state if isinstance(new_state, dict) else {},
story_cards=collected.get("storyCards") or [],
logs=collected.get("logs") or [],
)
-112
View File
@@ -1,112 +0,0 @@
"""Runs an adventure's enabled scripts through a turn's hook points, applying
state and story-card mutations back to the database after each hook."""
from sqlalchemy.orm import Session
from .. import models
from ..context import history as context_history
from .engine import run_hook
MAX_STORY_CARDS = 5000 # AI Dungeon's per-adventure sanity cap
class ScriptPipeline:
def __init__(self, adventure: models.Adventure, db: Session):
self.adventure = adventure
self.db = db
self.logs: list[str] = []
self.errors: list[str] = []
@property
def message(self) -> str | None:
state = self.adventure.script_state
msg = state.get("message") if isinstance(state, dict) else None
return msg if isinstance(msg, str) and msg.strip() else None
def _history(self) -> list[dict]:
# 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 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)
]
def _cards(self) -> list[dict]:
return [
{"id": c.id, "keys": c.keys, "entry": c.entry, "type": c.type}
for c in self.adventure.story_cards
]
def _info(self) -> dict:
return {
"actionCount": context_history.count(self.adventure),
"characterNames": [],
"memoryLength": len(self.adventure.memory),
"maxChars": 0,
}
def _apply_cards(self, returned: list) -> None:
existing = {c.id: c for c in self.adventure.story_cards}
seen_ids = set()
added = 0
for item in returned:
if not isinstance(item, dict):
continue
card_id = item.get("id")
keys = str(item.get("keys") or "")
entry = str(item.get("entry") or "")
card_type = str(item.get("type") or "")
if card_id in existing:
seen_ids.add(card_id)
card = existing[card_id]
card.keys, card.entry, card.type = keys, entry, card_type
elif len(existing) + added < MAX_STORY_CARDS:
self.db.add(
models.StoryCard(
adventure_id=self.adventure.id,
keys=keys, entry=entry, type=card_type,
)
)
added += 1
for card_id, card in existing.items():
if card_id not in seen_ids:
self.db.delete(card)
def run(self, hook: str, text: str) -> tuple[str, bool]:
"""Chain `hook` across all enabled scripts. Returns (text, stop)."""
state = self.adventure.script_state if isinstance(self.adventure.script_state, dict) else {}
for script in self.adventure.scripts:
hook_js = getattr(script, f"{hook}_js")
if not script.enabled or not hook_js.strip():
continue
result = run_hook(
script.library_js, hook_js, text, state,
self._history(), self._cards(), self._info(),
)
if result.error:
self.errors.append(f"{script.name} ({hook}): {result.error}")
continue # a broken script never breaks the turn
self.logs.extend(f"[{script.name}/{hook}] {line}" for line in result.logs)
self._apply_cards(result.story_cards)
state = result.state
self.adventure.script_state = state
self.db.commit()
text = result.text
if result.stop:
return text, True
return text, False
def report(self) -> dict:
return {"logs": self.logs, "errors": self.errors, "message": self.message}
-131
View File
@@ -1,131 +0,0 @@
"""Phase 8: secrets and crypto primitives for optional accounts.
Everything derives from one server-side secret:
* Session cookies are HMAC-signed with it.
* Stored LLM API keys are Fernet-encrypted with a key derived from it.
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
import hashlib
import hmac
import os
import secrets
from cryptography.fernet import Fernet, InvalidToken
from .database import DB_PATH
_SECRET_FILE = DB_PATH.parent / "secret.key"
def _load_secret() -> bytes:
env = os.environ.get("AIDND_SECRET_KEY", "").strip()
if env:
return env.encode()
# Same flag parse as auth.MULTI_USER (auth imports this module, so it
# can't be imported from there).
if os.environ.get("AIDND_MULTI_USER", "").strip().lower() in ("1", "true", "yes", "on"):
raise RuntimeError(
"AIDND_SECRET_KEY must be set when AIDND_MULTI_USER is on: an "
"auto-generated secret.key on an ephemeral hosted filesystem would "
"rotate on every deploy, logging out every user and orphaning "
"their stored API keys. Generate one with: "
"python -c \"import secrets; print(secrets.token_urlsafe(48))\""
)
if _SECRET_FILE.exists():
return _SECRET_FILE.read_bytes().strip()
secret = secrets.token_urlsafe(48).encode()
_SECRET_FILE.write_bytes(secret)
return secret
SECRET_KEY = _load_secret()
_fernet = Fernet(base64.urlsafe_b64encode(hashlib.sha256(SECRET_KEY).digest()))
# ---------- Password hashing (scrypt) ----------
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${key.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt_hex, key_hex = stored.split("$")
if scheme != "scrypt":
return False
key = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt_hex),
n=int(n), r=int(r), p=int(p),
)
return hmac.compare_digest(key, bytes.fromhex(key_hex))
except (ValueError, AttributeError):
return False
# ---------- Session tokens ----------
# 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}"
sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def verify_session(token: str) -> int | None:
try:
version, user_id, sig = token.split(".")
if version != "v1":
return None
payload = f"{version}.{user_id}"
expected = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
return int(user_id)
except (ValueError, AttributeError):
return None
# ---------- API-key encryption at rest ----------
# Stored values carry an "enc:" prefix so plaintext keys from pre-Phase-8
# databases can be recognized and migrated.
ENC_PREFIX = "enc:"
def encrypt_secret(plain: str) -> str:
if not plain:
return ""
return ENC_PREFIX + _fernet.encrypt(plain.encode()).decode()
def decrypt_secret(stored: str) -> str:
"""Returns the plaintext key. Tolerates legacy plaintext values (returned
as-is) and undecryptable tokens (secret rotated → treated as unset)."""
if not stored:
return ""
if not stored.startswith(ENC_PREFIX):
return stored
try:
return _fernet.decrypt(stored[len(ENC_PREFIX):].encode()).decode()
except (InvalidToken, ValueError):
return ""
+4 -41
View File
@@ -4,8 +4,7 @@ Every JSON file in ``seed_data/`` describes one demo scenario in the same
model-native shape the export endpoint produces. Seeded scenarios have a NULL
owner and ``is_public=True``, so every visitor (including guests) sees them and
can start an adventure from them, while nobody can edit them. Starting an
adventure copies the scenario's story cards and scripts into the adventure, so
the seeded scripts run for guests too.
adventure copies the scenario's story cards into the adventure.
Seed files are the source of truth for demo content: a scenario is inserted if
missing, reconciled in place when a seed file's content changes, and deleted
@@ -13,7 +12,7 @@ when no file claims its title any more, so an edit ships on the next deploy.
Rename a seed by changing its `title` and listing the old one under
`previous_titles`, which moves the rename onto the existing row. When a seed already matches, nothing is written, so
this stays cheap to run on every boot. An adventure already started from a demo
keeps its own copied cards and scripts and is unchanged. Only a new adventure
keeps its own copied cards and is unchanged. Only a new adventure
picks up the updated content.
"""
@@ -35,7 +34,6 @@ SEED_DIR = Path(__file__).resolve().parent / "seed_data"
_SCALARS = ("title", "description", "prompt", "memory", "authors_note", "ai_instructions",
"tags", "image", "icon")
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
_SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js")
def seed_public_scenarios(engine: Engine) -> None:
@@ -98,7 +96,7 @@ def _sweep_unclaimed(db, claimed: set[str]) -> int:
nothing anybody created can be reached from here.
An adventure started from a deleted demo survives. `adventures.scenario_id`
is `ON DELETE SET NULL`, so the story, its cards, and its scripts are its
is `ON DELETE SET NULL`, so the story and its cards are its
own copies and stay; the adventure loses the cover art it inherited.
The caller skips this when a seed file failed to parse. A file that cannot
@@ -117,11 +115,6 @@ def _sweep_unclaimed(db, claimed: set[str]) -> int:
)
for scenario in stale:
logger.info("Removing seeded scenario %r; no seed file claims it.", scenario.title)
# The scripts are joined through a secondary table, so nothing cascades
# to them. They have a NULL owner and no other reader.
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.delete(scenario)
return len(stale)
@@ -130,10 +123,6 @@ def _card_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _CARD_FIELDS)
def _script_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _SCRIPT_FIELDS)
def find_seeded(db, title: str) -> models.Scenario | None:
"""Returns the seeded scenario with this exact title, if there is one."""
return (
@@ -178,14 +167,7 @@ def _matches(scenario: models.Scenario, data: dict) -> bool:
_card_tuple(c, lambda o, f: o.get(f, ""))
for c in (data.get("story_cards") or []) if isinstance(c, dict)
)
if have_cards != want_cards:
return False
have_scripts = sorted(_script_tuple(s, lambda o, f: getattr(o, f)) for s in scenario.scripts)
want_scripts = sorted(
_script_tuple(s, lambda o, f: (o.get(f, "") or ("Script" if f == "name" else "")))
for s in (data.get("scripts") or []) if isinstance(s, dict)
)
return have_scripts == want_scripts
return have_cards == want_cards
def _insert_scenario(db, data: dict) -> None:
@@ -203,9 +185,6 @@ def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
# adventure foreign keys that point at it, intact.
for card in list(scenario.story_cards):
db.delete(card)
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.flush()
_populate_children(db, scenario, data)
@@ -231,19 +210,3 @@ def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
notes=card.get("notes", ""),
)
)
for item in data.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
user_id=None,
name=item.get("name", "Script"),
description=item.get("description", ""),
library_js=item.get("library_js", ""),
input_js=item.get("input_js", ""),
context_js=item.get("context_js", ""),
output_js=item.get("output_js", ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)
+5 -9
View File
@@ -6,11 +6,9 @@ frames, so the format lives here rather than in either one.
"""
import json
from . import analytics
# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering`
# makes nginx-style reverse proxies, which hosted deploys use, flush each event
# immediately rather than buffer it.
# makes an nginx-style reverse proxy flush each event immediately rather than
# buffer it, which matters if anyone puts one in front of the app.
SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
@@ -20,11 +18,9 @@ def sse(obj: dict) -> str:
def turn_error(detail: str, **extra) -> str:
"""Returns an SSE error for a turn that could not be produced, and counts it.
"""Returns an SSE error for a turn that could not be produced.
A failed turn is still an HTTP 200 response, so the middleware's status-code
tally cannot see it. This metric exists so that a demo whose model refuses
every request does not report as healthy.
A failed turn is still an HTTP 200 response, because the error is reported
inside the stream the client is already reading.
"""
analytics.record(analytics.M_EVENT, analytics.EV_TURN_ERROR)
return sse({"type": "error", "detail": detail, **extra})
+17
View File
@@ -348,6 +348,23 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
if action.world_state_after is None:
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
action.world_state_after = copy.deepcopy(world)
if action.narrative_state_after is None:
# M5, and the same rule: a node with no narrative snapshot is a position
# the head cannot be restored to, and the failure is silent — the state
# simply stays where it was. A campaign's opening node is written by the
# fixture that creates the adventure rather than by the turn engine, so
# without this it would be the one position Undo could not return to.
#
# An empty document rather than NULL, because this node is being written
# *now*, by a writer that knows the campaign has no state yet. That is
# different from a pre-M5 row, whose NULL means "there was no such thing
# as narrative state when this played" and must leave the live state
# alone.
from .narrative import model as narrative_model
narrative = adventure.narrative_state
action.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def place_new_nodes(session: Session) -> None:
-6
View File
@@ -19,10 +19,8 @@ annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.14.2
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
cryptography==50.0.1
fastapi==0.141.1
greenlet==3.5.5
h11==0.16.0
@@ -33,16 +31,12 @@ idna==3.19
iniconfig==2.3.0
packaging==26.3
pluggy==1.6.0
psycopg==3.3.5
psycopg-binary==3.3.5
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
Pygments==2.21.0
pytest==9.1.1
python-dotenv==1.2.3
PyYAML==6.0.3
quickjs==1.19.4
regex==2026.9.3
requests==2.34.2
SQLAlchemy==2.0.52
-3
View File
@@ -8,6 +8,3 @@ httpx>=0.27
# code imports it by name.
certifi
tiktoken>=0.7
quickjs>=1.19
cryptography>=42
psycopg[binary]>=3.2
+67
View File
@@ -0,0 +1,67 @@
"""The storyteller, run as a real OS process for `test_process_restart.py`.
Not a test module, and named so pytest does not collect it: it is the program
the test starts, twice, against one database file.
The model is replaced with a deterministic fake before the app is imported, so
the process needs no Ollama, no network and no configuration. Everything else —
the engine, the migrations, the routers, the session lifecycle — is the real
application, which is the whole point of spawning a process at all.
python _restart_server.py <db_path> <port>
"""
import itertools
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # backend/, so `app` imports
sys.path.insert(0, str(HERE)) # tests/, so `fakes` imports
db_path, port = sys.argv[1], int(sys.argv[2])
os.environ["AIDND_DB_PATH"] = db_path
# A developer's shell may point these at Postgres, and `app.database` prefers
# either over the SQLite path. The suite's conftest clears them for the same
# reason; a spawned process does not inherit that, so clear them here too.
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import TALLY_PER_TURN, tally_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""Records a running tally per reply, numbered so the text is checkable.
The same instrumentation `test_head_cursor.py` and `test_save_points.py`
use, for the same reason: it makes "the state at this position" a number the
test can assert rather than a paragraph it has to interpret. M5 replaces the
machinery underneath; what this measures is where the story is being read.
"""
last_usage = None
def __init__(self, *a, **k):
pass
async def generate(self, parts, *, temperature, max_tokens):
n = next(_turn)
# An absolute running total (M5, ADR 010): turn n states n * 10, so the
# value a position holds is a fact about that position rather than about
# how many times something was added.
yield ("text", tally_reply(f"Beat {n}.", n * TALLY_PER_TURN))
from app.routers.adventures import turns # noqa: E402
turns.OpenAICompatibleProvider = DeterministicProvider
from app.main import app # noqa: E402
if __name__ == "__main__":
import uvicorn
# Loopback only, as every supported start path does.
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
+117
View File
@@ -5,6 +5,7 @@ their own `ScriptedProvider`, and the copies had drifted into four different
feature sets, so a test that needed to raise a provider error had to be written
in one of the files whose copy supported it.
"""
import json
class ScriptedProvider:
@@ -39,3 +40,119 @@ class ScriptedProvider:
if isinstance(reply, Exception):
raise reply
yield ("text", reply)
# ---------------------------------------------------------------------------
# Deterministic per-turn state instrumentation
# ---------------------------------------------------------------------------
# Several tests need a value that changes by a fixed amount on every turn, so
# that a rollback failure is arithmetic rather than a judgement call: if a take
# stacks instead of replacing, the total is off by exactly one turn's worth.
#
# The instrument has moved twice, and both moves were the same move: it follows
# whatever the production state path is, so the tests exercise real code rather
# than a test hook. It began as a QuickJS `state.gold += 10` (removed with
# scripting in M2), became an RPG world-state delta block (M3/M4), and is now a
# typed narrative-state event (M5).
#
# What the tests using it measure is unchanged, and worth restating because it
# is why they were re-instrumented rather than deleted: the state at a story
# position, rollback, Redo restoration, retry, alternate takes, divergence,
# abandoned-future isolation, and Save Point restore. None of that was ever
# about gold, or about RPG stats.
#
# The M5 instrument is deliberately genre-neutral: a `chronicle` entity — a
# concept, not a character, not an item — carrying one named attribute. Every
# reply sets it to an ABSOLUTE total, which is ADR 010's whole point. A delta
# protocol could not tell "+10" from "= 10"; here the event type says which, so
# `TALLY_PER_TURN * n` after n turns is arithmetic rather than an assumption.
#: The instrument is a fact, not an entity attribute, and deliberately so.
#: `set_entity_attribute` names an entity that must already exist, which is the
#: right rule for the product and the wrong one for an instrument that tests
#: script in isolation — a one-off reply in the middle of a test would be
#: refused for a reference the test never meant to be about. `add_fact` needs no
#: subject, so any reply can state the tally on its own. Entity creation,
#: possession and the referential rule get their own tests in
#: `test_narrative_state.py`, where they are the subject rather than scaffolding.
TALLY_PREDICATE = "tally"
TALLY_PER_TURN = 10
# Kept as an alias so the many tests that speak in these terms keep reading
# naturally. The number is the same; only the protocol underneath changed.
GOLD_PER_TURN = TALLY_PER_TURN
#: A scenario schema is no longer needed for state to work — narrative state is
#: not an opt-in RPG layer. The name survives for fixtures that still pass
#: something, and empty is the honest value: this campaign has no RPG layer, and
#: under M5 it does not need one to have state.
GOLD_SCHEMA: dict = {}
def state_block(events: list) -> str:
"""The fenced block the model is asked to emit, around `events`."""
return "```state\n" + json.dumps({"events": events}, ensure_ascii=False) + "\n```"
def tally_reply(text: str, total: int) -> str:
"""A reply that narrates `text` and records the tally as `total`.
Absolute, always — which is the whole of ADR 010. A delta protocol could not
tell "+10" from "= 10"; here the event says which, so `TALLY_PER_TURN * n`
after n turns is arithmetic rather than an assumption, and a replayed or
duplicated reply cannot silently double it.
Each reply supersedes the last, so the newest active tally fact is the
current one and the document does not grow without bound.
"""
return f"{text}\n" + state_block([{
"type": "add_fact",
"predicate": TALLY_PREDICATE,
"value": total,
"fact_id": f"tally-{total}",
}])
def gold_reply(text: str, amount: int = TALLY_PER_TURN) -> str:
"""One reply banking `amount`, for tests that build a single reply.
The value is absolute underneath, so a caller asking for the default gets
the first turn's total, which is what those call sites mean.
"""
return tally_reply(text, amount)
def tally_replies(prefix: str = "Take", count: int = 40) -> list:
"""`count` numbered replies whose tally runs 10, 20, 30 …"""
return [
tally_reply(f"{prefix} {n}.", n * TALLY_PER_TURN)
for n in range(1, count + 1)
]
#: The historical name, unchanged in meaning for every caller.
gold_replies = tally_replies
def tally_of(state) -> int:
"""Reads the instrument back out of a narrative state document.
The newest active tally fact wins, which is what "absolute assignment"
means when the assignments are appended. Returns 0 when the campaign has
recorded none — what "no turns have been played" means, and what a restore
to before the first turn should produce.
"""
if not isinstance(state, dict):
return 0
facts = state.get("facts")
if not isinstance(facts, list):
return 0
for fact in reversed(facts):
if (
isinstance(fact, dict)
and fact.get("predicate") == TALLY_PREDICATE
and fact.get("status", "active") == "active"
and isinstance(fact.get("value"), (int, float))
):
return fact["value"]
return 0
-208
View File
@@ -1,208 +0,0 @@
"""The access log: app/accesslog.py and GET /api/analytics/access.
This is the half of the analytics work that identifies people on purpose,
so these tests pin the details that would quietly make it wrong. The
address recorded must be the hardened one, not a header a client chose.
Session rows must be thinned instead of written on every page load. And a
row must outlive the account it describes, because guest cleanup deletes
accounts on a schedule, and a log that deletes itself is not a log.
python -m pytest tests/test_accesslog.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import accesslog, auth, limits, models, security
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
EDGE = "198.51.100.77" # what the trusted proxy appended
SPOOF = "10.0.0.1" # what a client put in front of it
@pytest.fixture(autouse=True)
def clean_state():
accesslog._last_session.clear()
yield
accesslog._last_session.clear()
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
owner = models.User(is_guest=False, email="owner@example.com")
member = models.User(
is_guest=False, email="player@example.com",
password_hash=security.hash_password("hunter2long"),
)
setup.add_all([owner, member])
setup.commit()
ids = {"owner": owner.id, "member": member.id}
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_login_allowed", lambda *a, **k: None)
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
# /auth/me resolves its own session, so the cookie flow below is the real
# one. Every other endpoint goes through get_current_user, and `act_as`
# decides who that is.
acting = {"id": ids["owner"]}
def _current(db=Depends(get_db)):
return db.get(models.User, acting["id"])
app.dependency_overrides[auth.get_current_user] = _current
try:
test_client = TestClient(app)
test_client.ids = ids
test_client.act_as = lambda user_id: acting.update(id=user_id)
yield test_client
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def visit(client, ip=EDGE, ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64)"):
return client.get(
"/api/auth/me",
headers={"x-forwarded-for": f"{SPOOF}, {ip}", "user-agent": ua},
)
def rows(kind=None):
db = SessionLocal()
try:
query = db.query(models.AccessEvent).order_by(models.AccessEvent.id)
if kind:
query = query.filter_by(kind=kind)
return query.all()
finally:
db.close()
def read_log(client, **params):
return client.get("/api/analytics/access", params=params)
# ---------- Writing ----------
def test_a_new_session_is_logged(client):
visit(client)
logged = rows()
assert len(logged) == 1
entry = logged[0]
assert entry.kind == accesslog.SESSION
assert entry.is_guest and entry.who.startswith("Guest #")
assert entry.device == "desktop"
def test_the_address_is_the_hardened_one_not_the_clients(client):
visit(client)
# The client prepended its own value. Only the hop the edge appended counts.
# Recording the leftmost value would make every row forgeable, which is
# worse for a log than having no log at all.
assert rows()[0].ip == EDGE
def test_session_rows_are_thinned_to_one_per_day_per_address(client):
for _ in range(4):
visit(client)
assert len(rows(accesslog.SESSION)) == 1
def test_a_changed_address_writes_a_new_row(client):
visit(client)
visit(client, ip="203.0.113.9")
logged = rows(accesslog.SESSION)
assert [entry.ip for entry in logged] == [EDGE, "203.0.113.9"]
# Same session throughout, so both rows name the same visitor.
assert logged[0].who == logged[1].who
def test_sign_in_and_failure_are_both_logged(client):
client.post("/api/auth/login", json={"email": "player@example.com", "password": "wrong"},
headers={"x-forwarded-for": EDGE})
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
headers={"x-forwarded-for": EDGE})
kinds = [entry.kind for entry in rows()]
assert accesslog.LOGIN_FAILED in kinds and accesslog.LOGIN in kinds
failure = rows(accesslog.LOGIN_FAILED)[0]
# This records the address that was tried, not the account it belongs to.
# A failed attempt against an address with no matching account is
# exactly what this row exists to capture.
assert failure.who == "player@example.com"
assert failure.user_id is None
assert rows(accesslog.LOGIN)[0].user_id == client.ids["member"]
def test_registering_is_logged_against_the_upgraded_account(client):
visit(client) # creates the guest whose session then registers
client.act_as(rows()[0].user_id)
client.post("/api/auth/register", json={"email": "new@example.com", "password": "hunter2long"})
entry = rows(accesslog.REGISTER)[0]
assert entry.who == "new@example.com" and not entry.is_guest
def test_a_row_outlives_the_account_it_describes(client):
visit(client)
entry = rows()[0]
db = SessionLocal()
try:
db.delete(db.get(models.User, entry.user_id))
db.commit()
finally:
db.close()
# There is no foreign key, and `who` is a snapshot. Guest cleanup deletes
# accounts on a schedule, and a log that vanishes along with them is not
# a log.
survivor = rows()[0]
assert survivor.who == entry.who and survivor.ip == EDGE
def test_a_long_user_agent_is_truncated(client):
visit(client, ua="Mozilla/" + "x" * 500)
assert len(rows()[0].user_agent) == accesslog.MAX_UA
def test_a_logging_failure_does_not_break_the_request(client, monkeypatch):
monkeypatch.setattr(accesslog, "_client_ip", lambda request: 1 / 0)
# The log observes sign-in. A logging failure must not block the request.
assert visit(client).status_code == 200
# ---------- Reading ----------
def test_the_log_is_invisible_to_everyone_but_the_owner(client):
visit(client)
assert read_log(client).status_code == 200
client.act_as(client.ids["member"])
assert read_log(client).status_code == 404
def test_the_log_reads_newest_first_and_pages_backwards(client):
for index in range(5):
visit(client, ip=f"203.0.113.{index}")
first = read_log(client, limit=2).json()
assert [event["ip"] for event in first["events"]] == ["203.0.113.4", "203.0.113.3"]
assert first["has_more"]
older = read_log(client, limit=2, before_id=first["events"][-1]["id"]).json()
assert [event["ip"] for event in older["events"]] == ["203.0.113.2", "203.0.113.1"]
def test_the_log_filters_by_kind_and_searches(client):
visit(client)
client.post("/api/auth/login", json={"email": "player@example.com", "password": "hunter2long"},
headers={"x-forwarded-for": "203.0.113.44"})
assert len(read_log(client, kind="login").json()["events"]) == 1
by_email = read_log(client, q="player@example.com").json()["events"]
assert len(by_email) == 1 and by_email[0]["kind"] == "login"
by_ip = read_log(client, q="203.0.113.44").json()["events"]
assert len(by_ip) == 1
assert read_log(client, q="nobody@example.com").json()["events"] == []
-1
View File
@@ -48,7 +48,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-385
View File
@@ -1,385 +0,0 @@
"""Visit analytics: app/analytics.py and the two endpoints in front of it.
This file tests three things, and the rest is arithmetic. The counters must
survive the buffer/UPSERT round trip: a flush adds to what is already
stored instead of replacing it, or every number would show only the last
minute. The funnel counts people rather than clicks, which is the only
reason the visitor-day table exists. The gate holds: a stranger cannot read
the dashboard, and cannot inflate what it reports beyond hitting the page.
python -m pytest tests/test_analytics.py -v
"""
from datetime import timedelta
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import analytics, auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
@pytest.fixture(autouse=True)
def clean_buffer():
"""The buffer is process-wide, so a test that leaves counts in it would
show up inside the next one's flush."""
analytics._counts.clear()
analytics._visits.clear()
analytics._labels_seen.clear()
yield
analytics._counts.clear()
analytics._visits.clear()
analytics._labels_seen.clear()
@pytest.fixture()
def db():
Base.metadata.create_all(bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
def counter(db, metric, label):
row = (
db.query(models.AnalyticsDaily)
.filter_by(metric=metric, label=label)
.one_or_none()
)
return row.hits if row else 0
def make_user(db, email=None):
user = models.User(is_guest=email is None, email=email)
db.add(user)
db.commit()
return user
# ---------- The buffer and its flush ----------
def test_counts_accumulate_across_flushes(db):
analytics.record(analytics.M_PAGE, "/")
analytics.record(analytics.M_PAGE, "/")
analytics.flush(db)
analytics.record(analytics.M_PAGE, "/")
analytics.flush(db)
# The second flush has to find the existing row and add to it. Replacing it
# would leave every counter showing only the newest minute of traffic.
assert counter(db, analytics.M_PAGE, "/") == 3
def test_flush_is_a_no_op_when_nothing_happened(db):
analytics.flush(db)
assert db.query(models.AnalyticsDaily).count() == 0
def test_a_failed_flush_keeps_the_counts(db, monkeypatch):
analytics.record(analytics.M_PAGE, "/")
monkeypatch.setattr(analytics, "_write_counts", lambda *a: 1 / 0)
analytics.flush(db) # must not raise
monkeypatch.undo()
analytics.flush(db)
assert counter(db, analytics.M_PAGE, "/") == 1
def test_label_cardinality_is_capped(db):
for i in range(analytics.MAX_LABELS_PER_METRIC + 25):
analytics.record(analytics.M_REFERRER, f"host{i}.example")
analytics.flush(db)
labels = db.query(models.AnalyticsDaily).filter_by(metric=analytics.M_REFERRER).count()
# Everything past the cap is folded into one bucket, so a referrer flood
# cannot create unlimited rows.
assert labels == analytics.MAX_LABELS_PER_METRIC + 1
assert counter(db, analytics.M_REFERRER, analytics.OTHER) == 25
# ---------- Visitors ----------
def test_visitor_id_is_stable_and_keyed(db, monkeypatch):
user = make_user(db)
handle = analytics.visitor_id(user)
assert handle == analytics.visitor_id(user) # a returning visitor
assert handle != analytics.visitor_id(make_user(db)) # is still one visitor
assert len(handle) == 32 and int(handle, 16) >= 0 # opaque hex, not an id
# Keyed on the app secret, not a bare hash of the user id. Otherwise
# anyone holding this table could rebuild the mapping by hashing
# sequential ids.
monkeypatch.setattr(analytics.security, "SECRET_KEY", b"a-different-secret")
assert analytics.visitor_id(user) != handle
def test_a_repeat_visitor_is_new_only_once(db):
user = make_user(db)
analytics.record_visit(user)
analytics.flush(db)
rows = db.query(models.AnalyticsVisitorDay).all()
assert len(rows) == 1 and rows[0].is_new
# Same visitor, a later day: seen before, so not new. The row is not
# merged into the first day's row either.
tomorrow = (models.utcnow().date() + timedelta(days=1)).isoformat()
analytics._visits[(tomorrow, analytics.visitor_id(user))] = set()
analytics.flush(db)
rows = db.query(models.AnalyticsVisitorDay).order_by(models.AnalyticsVisitorDay.day).all()
assert [row.is_new for row in rows] == [True, False]
def test_one_row_per_visitor_per_day_however_much_they_do(db):
user = make_user(db)
for _ in range(5):
analytics.record_event(analytics.EV_ADVENTURE, user)
analytics.flush(db)
assert db.query(models.AnalyticsVisitorDay).count() == 1
assert counter(db, analytics.M_EVENT, analytics.EV_ADVENTURE) == 5
def test_funnel_flags_only_ever_turn_on(db):
user = make_user(db)
analytics.record_event(analytics.EV_TURN, user)
analytics.flush(db)
# A later visit that reaches no funnel step must not clear the earlier one.
analytics.record_visit(user)
analytics.flush(db)
row = db.query(models.AnalyticsVisitorDay).one()
assert row.played and not row.created
def test_purge_drops_only_rows_past_the_horizon(db):
old = (models.utcnow().date() - timedelta(days=analytics.RETENTION_DAYS + 1)).isoformat()
db.add(models.AnalyticsVisitorDay(day=old, visitor="a" * 32))
db.add(models.AnalyticsVisitorDay(day=analytics._today(), visitor="b" * 32))
db.commit()
assert analytics.purge_old_visitor_days(db) == 1
assert [r.visitor for r in db.query(models.AnalyticsVisitorDay)] == ["b" * 32]
# ---------- Normalizing what a browser claims ----------
@pytest.mark.parametrize("path, expected", [
("/", "/"),
("/adventures", "/adventures"),
("/adventures/", "/adventures"),
("/play/12?x=1", "/play/:id"),
("/scenarios/9#top", "/scenarios/:id"),
("/wp-admin", "(other)"),
("/play/../../etc", "(other)"),
("", "/"),
])
def test_route_normalization(path, expected):
assert analytics.normalize_route(path) == expected
@pytest.mark.parametrize("referrer, expected", [
("", "(direct)"),
("https://news.ycombinator.com/item?id=1", "news.ycombinator.com"),
("https://www.google.com/", "google.com"),
("https://ai-dnd.example/scenarios", ""), # our own host: not a referral
("javascript:alert(1)", "(other)"),
("https://" + "x" * 200 + ".com", "(other)"),
])
def test_referrer_normalization(referrer, expected):
assert analytics.normalize_referrer(referrer, "ai-dnd.example") == expected
@pytest.mark.parametrize("ua, expected", [
("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit", "mobile"),
("Mozilla/5.0 (iPad; CPU OS 17_0) AppleWebKit", "tablet"),
("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "desktop"),
("Googlebot/2.1", "bot"),
("", "(unknown)"),
])
def test_device_detection(ua, expected):
assert analytics.device_of(ua) == expected
def test_only_iso_looking_country_headers_are_trusted():
assert analytics.country_of({"cf-ipcountry": "de"}) == "DE"
assert analytics.country_of({"cf-ipcountry": "Norway"}) == analytics.UNKNOWN
assert analytics.country_of({"cf-ipcountry": "XX"}) == analytics.UNKNOWN
assert analytics.country_of({}) == analytics.UNKNOWN
def test_error_labels_use_the_route_not_the_path():
class Route:
path = "/api/adventures/{adventure_id}"
assert analytics.api_route_label({"route": Route()}, 500) == "500 /api/adventures/{adventure_id}"
# An unmatched path is entirely attacker-chosen, so it never becomes a label.
assert analytics.api_route_label({}, 404) == "404 (unmatched)"
# ---------- The summary ----------
def test_summary_counts_people_once_per_step(db):
one, two = make_user(db), make_user(db)
for _ in range(3):
analytics.record_event(analytics.EV_SCENARIO_OPEN, one)
analytics.record_event(analytics.EV_TURN, one)
analytics.record_event(analytics.EV_SCENARIO_OPEN, two)
result = analytics.summary(db, days=7)
steps = {row["step"]: row["count"] for row in result["funnel"]}
assert steps["Visited"] == 2
assert steps["Opened a scenario"] == 2
assert steps["Played a turn"] == 1 # not 3, because one person made three turns
assert steps["Signed up"] == 0
# Raw event totals still count every occurrence.
assert result["totals"]["turns"] == 3
assert result["totals"]["visitors"] == 2
def test_summary_series_covers_every_day_including_empty_ones(db):
analytics.record(analytics.M_PAGE, "/")
result = analytics.summary(db, days=7)
assert len(result["series"]) == 7
assert result["series"][-1]["day"] == models.utcnow().date().isoformat()
assert result["series"][-1]["pageviews"] == 1
assert result["series"][0]["pageviews"] == 0
def test_summary_flushes_before_reading(db):
analytics.record(analytics.M_EVENT, analytics.EV_TURN)
# Never flushed by hand: the dashboard must not be up to a minute stale.
assert analytics.summary(db, days=1)["totals"]["turns"] == 1
def test_summary_reports_pages_referrers_and_errors(db):
analytics.record(analytics.M_PAGE, "/play/:id", n=4)
analytics.record(analytics.M_REFERRER, "news.ycombinator.com", n=2)
analytics.record(analytics.M_ERROR, "500 /api/adventures/{adventure_id}")
result = analytics.summary(db, days=30)
assert result["pages"][0] == {"label": "/play/:id", "hits": 4}
assert result["referrers"][0]["label"] == "news.ycombinator.com"
assert result["totals"]["errors"] == 1
# ---------- The endpoints ----------
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
visitor = models.User(is_guest=True)
owner = models.User(is_guest=False, email="owner@example.com")
setup.add_all([visitor, owner])
setup.commit()
ids = {"visitor": visitor.id, "owner": owner.id}
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
# Multi-user is what makes the gate mean anything: local mode trusts
# whoever is at the keyboard, because it is the operator's own machine.
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "ANALYTICS_EMAILS", {"owner@example.com"})
current = {"id": ids["visitor"]}
def _current_user(db=Depends(get_db)):
return db.get(models.User, current["id"])
app.dependency_overrides[auth.get_current_user] = _current_user
monkeypatch.setattr(
auth, "resolve_session_user", lambda request, db: db.get(models.User, current["id"])
)
try:
client = TestClient(app)
client.ids, client.current = ids, current
yield client
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def read_summary(client, days=30):
return client.get(f"/api/analytics/summary?days={days}")
def test_dashboard_is_invisible_to_everyone_but_the_owner(client):
assert read_summary(client).status_code == 404
client.current["id"] = client.ids["owner"]
assert read_summary(client).status_code == 200
def test_collect_records_a_pageview_and_the_visit(client):
resp = client.post("/api/analytics/collect", json={"path": "/play/7", "first": True,
"referrer": "https://news.ycombinator.com/"})
assert resp.status_code == 204
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert body["pages"][0] == {"label": "/play/:id", "hits": 1}
assert body["referrers"][0]["label"] == "news.ycombinator.com"
assert body["totals"]["visitors"] == 1
def test_referrer_and_device_are_recorded_once_per_visit_not_per_view(client):
for path in ("/", "/scenarios", "/adventures"):
client.post("/api/analytics/collect", json={"path": path, "first": path == "/"})
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert body["totals"]["pageviews"] == 3
# Three views, one visit: the referral and the device are facts about the
# visit, so counting them per view would multiply every one of them.
assert sum(row["hits"] for row in body["devices"]) == 1
def test_the_owners_own_visits_are_not_traffic(client):
client.current["id"] = client.ids["owner"]
client.post("/api/analytics/collect", json={"path": "/", "first": True})
assert read_summary(client).json()["totals"]["pageviews"] == 0
def test_a_client_cannot_invent_pages_or_events(client):
client.post("/api/analytics/collect", json={"path": "/../../admin", "first": True})
# There is no field for it, so a made-up event is not even expressible.
client.post("/api/analytics/collect", json={"path": "/", "event": "signup"})
client.current["id"] = client.ids["owner"]
body = read_summary(client).json()
assert {row["label"] for row in body["pages"]} == {"(other)", "/"}
assert body["totals"]["signups"] == 0
def test_api_errors_are_counted_by_route(client):
client.get("/api/adventures/999999")
client.current["id"] = client.ids["owner"]
errors = read_summary(client).json()["errors"]
assert errors and errors[0]["label"].startswith("404 /api/adventures/")
# ---------- The dialect the tests never run on ----------
def test_the_upserts_compile_for_postgres():
"""Prod runs on Neon, but these tests run on SQLite, and a failed flush
is caught and logged instead of raised. A dialect mistake would
therefore stay invisible until the dashboard quietly stayed empty. This
test compiles both statements against Postgres without connecting to
one.
"""
from sqlalchemy import create_engine
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import sessionmaker
session = sessionmaker(bind=create_engine("postgresql+psycopg://u:p@localhost/db"))()
compiled = []
def capture(statement, *args, **kwargs):
compiled.append(str(statement.compile(dialect=postgresql.dialect())))
session.execute = capture
session.scalars = lambda *a, **k: []
analytics._write_counts(session, {("2026-01-01", "pageview", "/"): 2})
analytics._write_visits(session, {("2026-01-01", "f" * 32): {"played"}})
counts, visits = compiled
assert "ON CONFLICT (day, metric, label) DO UPDATE" in counts
assert "analytics_daily.hits + excluded.hits" in counts
assert "ON CONFLICT (day, visitor) DO UPDATE" in visits
assert "analytics_visitor_days.played OR excluded.played" in visits
# is_new is settled by the first write of a visitor's first day and must
# not be in the update clause at all.
assert "is_new" not in visits.split("DO UPDATE")[1]
+29 -20
View File
@@ -19,17 +19,10 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -45,14 +38,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -61,9 +51,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -213,18 +200,40 @@ def test_the_assembled_prompt_is_stored_once_per_turn(client):
assert len(moved) == 1 and moved != live_holder, "the prompt follows the story"
# ------------------------------------------------------- removing the turn
# ------------------------------------------------- stepping behind the turn
def test_undo_takes_every_attempt_with_it(client):
def test_undo_hides_every_attempt_and_keeps_them_all(client):
"""M3 rewrote this test. Undo used to delete the turn, and the assertion was
that it took the whole sibling group with it rather than leaving orphaned
attempts at a coordinate the story no longer reached.
The group still moves as one, but it moves out of the story rather than out
of the database: one Undo steps behind the turn, so none of its three
attempts is in what the story tells, and all three are still on disk for the
Redo that walks back into them. The old assertion is kept as the second half
— what the story reads — and the row count is the new first half.
"""
ScriptedProvider.replies = ["One.", "Two.", "Three."]
_play(client)
_retry(client)
_retry(client)
assert len([a for a in _rows(client.adv_id) if a.type == "ai"]) == 3
before = _rows(client.adv_id)
assert len([a for a in before if a.type == "ai"]) == 3
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert [a.type for a in _rows(client.adv_id)] == ["start"]
# What the story tells: the opening, and none of the turn's attempts.
assert [a["type"] for a in r.json()["actions"]] == ["start"]
# What it holds: every row that was there before, attempts included.
after = _rows(client.adv_id)
assert len(after) == len(before)
assert {a.id for a in after} == {a.id for a in before}
assert len([a for a in after if a.type == "ai"]) == 3
# And the group is reachable again, whole, with the same take live.
live_before = [a.id for a in before if a.type == "ai" and a.live]
r = client.post(f"/api/adventures/{client.adv_id}/redo")
assert r.status_code == 200, r.text
assert [a.id for a in _rows(client.adv_id) if a.type == "ai" and a.live] == live_before
def test_deleting_a_retried_turn_deletes_its_attempts(client):
-14
View File
@@ -30,7 +30,6 @@ from app import auth, limits, models, tree
from app.context import history, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.scripting import ScriptPipeline
from tools import dbmeter
@@ -224,24 +223,11 @@ def test_an_already_loaded_collection_is_cut_down_to_the_path(forked):
assert labels(history.tail(adventure, 3)) == ["B5", "C6", "C7"]
def test_user_scripts_are_handed_the_path(forked):
"""The same risk one layer up, in code visible to users:
`pipeline._history()` is the documented scripting history API."""
db, adventure, _ = forked
list(adventure.actions) # the pipeline's caller has usually loaded these
pipeline = ScriptPipeline(adventure, db)
assert [h["text"] for h in pipeline._history()] == [
"A0", "A1", "A2", "A3", "B4", "B5", "C6", "C7"
]
assert pipeline._info()["actionCount"] == 8
# ------------------------------------------------------------ over the wire
@pytest.fixture()
def client(forked, monkeypatch):
db, adventure, ids = forked
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(session=Depends(get_db)):
+81 -61
View File
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance shows up as a change the referee should have rejected.
@@ -30,16 +30,13 @@ SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
# The per-turn counter these tests measure rollbacks with. Unbounded and
# uncapped on purpose, so every turn's +10 lands in full. See
# `fakes.gold_reply`.
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -55,14 +52,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -71,9 +65,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -118,10 +109,17 @@ def _fork(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -322,70 +320,77 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
# -------------------------------------------------------------- the state
def test_switching_restores_the_script_and_world_state(client):
def test_switching_restores_the_state_a_branch_left_behind(client):
"""Each attempt records its own total, so a switch that restored the wrong
snapshot shows a number no position on that line ever held."""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
"Onward.",
tally_reply("A scratch.", 10),
tally_reply("A beating.", 40),
tally_reply("Onward.", 70),
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 70
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
script_state, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert script_state == {"gold": 10}, "one turn of gold, not three"
player, _document = _state(client.adv_id)
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_the_cooldown_clock_travels_with_the_branch(client):
"""The world-state clock is a depth, and depths repeat across branches,
so it can only be correct if each branch carries its own. It does,
without extra work: the clock lives inside `_meta.last_changed`, which
is part of the world state a switch restores."""
def test_state_travels_with_the_branch(client):
"""Each line carries its own state, and a switch restores that line's.
This was written about the RPG cooldown clock, which was a depth stored
inside the world state — and depths repeat across branches, so the clock
could only be right if each branch carried its own. M5 removed that
machinery; the property it demonstrated is general and still holds, because
a branch's state is whatever its own tip recorded.
"""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Untouched.",
"Onward.",
tally_reply("Drained.", 10),
tally_reply("Untouched.", 20),
tally_reply("Onward.", 30),
]
_play(client)
_retry(client)
_play(client, "go deeper")
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
on_parent = _state(client.adv_id)[1]
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 30
_fork(client, discarded)
forked = _state(client.adv_id)[1]
assert forked["player"]["mana"] == 40
assert forked["_meta"]["last_changed"]["player.mana"] == 2
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id)[1] == on_parent
assert _state(client.adv_id) == on_parent
def test_a_retry_does_not_advance_the_cooldown_clock(client):
"""SP5's one carried-over open item. A retry re-runs the same turn, so
the clock the cooldown rules read must not move. The reused `index`
used to guarantee this; the reused depth guarantees it now."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
]
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
"""SP5's carried-over item, restated for M5.
A retry re-runs the same turn, so it lands at that turn's coordinate and its
state replaces rather than accumulates. The original form of this test
measured it through the cooldown clock, which read a depth; the depth is
still what makes it true, and the state document is now where it shows.
"""
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
_play(client)
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
_retry(client)
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
# The second attempt's drain must land, instead of being rejected for a
# cooldown it was never actually subject to.
assert _state(client.adv_id)[1]["player"]["mana"] == 40
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
assert live.depth == first.depth, "the retry moved the turn's coordinate"
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
# --------------------------------------------------------- derived work
@@ -439,26 +444,41 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
# ------------------------------------------------------------------- undo
def test_undo_stops_at_the_fork(client):
"""Undoing a turn on a fork must never reach into the branch it forked
from. Those turns belong to that branch's story too."""
def test_undo_walks_off_a_fork_into_the_story_it_inherits(client):
"""M3 rewrote this test, and reversed half of it.
Undo used to refuse at a fork point, and it had to: it deleted the turns it
stepped over, and the turns before the fork belong to the parent branch's
story as well. Refusing was the only way to stop one branch's Undo from
removing rows another branch was reading.
Nothing is deleted now, so there is nothing to protect the parent from. A
forked branch inherits the story up to its fork, that inherited story is
part of what this branch tells, and Undo walks back through it like any
other retained history. The floor is the campaign opening, not the fork.
"""
discarded = _divergent_story(client)
_fork(client, discarded)
rows_before = len(_rows(client.adv_id))
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
# The promoted attempt is removed, and the player action before it
# stays, because that action belongs to the parent and the parent
# still has it.
assert len(_rows(client.adv_id)) == rows_before - 1
assert _texts(client) == ["You enter a cave.", "> You look around."]
# One Undo steps over a whole turn, so it takes the player's action with the
# reply to it — and that player action is the parent's row, sitting in front
# of the fork. Stepping behind it is a read moving backwards, not a branch
# reaching into another branch's rows: nothing moved either way.
assert len(_rows(client.adv_id)) == rows_before
assert _texts(client) == ["You enter a cave."]
# Nothing is left of this branch's own turns, so undo must refuse
# instead of removing the parent's turns.
# The opening is the floor, and it is the parent's node too.
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 400
assert "forked from" in r.json()["detail"]
assert "Nothing to undo" in r.json()["detail"]
# Redo walks back out to where the fork was left, taking the turn whole.
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
assert _texts(client) == ["You enter a cave.", "> You look around.", "Attempt one."]
assert len(_rows(client.adv_id)) == rows_before
# ----------------------------------------------------------- the tree view
-3
View File
@@ -55,9 +55,6 @@ def client(monkeypatch):
ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+16 -30
View File
@@ -33,19 +33,12 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, so the stored gold total tells how many turns the
# story behind it played. This makes an after-snapshot visible from outside.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
OPENING = "You enter a cave."
@@ -63,14 +56,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -78,9 +68,6 @@ def client(monkeypatch):
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -183,10 +170,11 @@ def _branch_rows(adv_id) -> list[models.Branch]:
db.close()
def _script_state(adv_id) -> dict:
def _tally(adv_id) -> int:
"""The narrative-state instrument, as it stands at the active head."""
db = SessionLocal()
try:
return db.get(models.Adventure, adv_id).script_state
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -263,29 +251,29 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
"""This test justifies why the bundle carries after-snapshots.
The gold script adds ten a turn, so the stored gold total counts the
turns behind it. A bundle that carried the actions but not the
outcomes would import a tree that reads correctly but switches to the
wrong state.
Each line ends on its own recorded total. A bundle that carried the actions
but not the outcomes would import a tree that reads correctly and then
switches to the wrong state — which is exactly what M5's snapshot column
had to be added to the bundle to prevent.
"""
original = _forked_story(client)
# Play one more turn on the fork, so the two tips end up at
# genuinely different totals. Turn for turn, both branches earn the
# same gold, so a switch that restored nothing would still look right.
ScriptedProvider.replies = ["Further still."]
# Play one more turn on the fork, so the two tips end up at genuinely
# different totals. If both lines ended on the same number, a switch that
# restored nothing would still look right.
ScriptedProvider.replies = [tally_reply("Further still.", 70)]
_play(client, original, "press on")
per_branch = []
for branch in _branches(client, original):
_switch(client, original, branch["id"])
per_branch.append(_script_state(original).get("gold"))
per_branch.append(_tally(original))
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
copy = _imported(client, _export(client, original))
restored = []
for branch in _branches(client, copy):
_switch(client, copy, branch["id"])
restored.append(_script_state(copy).get("gold"))
restored.append(_tally(copy))
assert restored == per_branch
@@ -398,7 +386,6 @@ def test_a_fork_with_no_depth_is_refused(client):
def test_more_branches_than_the_cap_is_refused(client, monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
payload = {
"format": bundle.FORMAT, "title": "Too many",
"branches": [{"parent": None, "forkDepth": None}]
@@ -558,7 +545,6 @@ def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypat
multiple of it. The body-size limit does not help here: the text is
tiny, and the row count is the actual cost.
"""
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
+51 -13
View File
@@ -207,29 +207,67 @@ def test_the_demo_asks_for_the_turn_counter():
# What the model is told about its own refused changes
# --------------------------------------------------------------------------- #
def test_history_replays_what_was_accepted_not_what_was_sent():
"""The contradiction that taught the model to repeat itself.
def test_history_replays_prose_without_the_protocol_block():
"""M5 corrective pass (review Finding 4): replayed history is prose only.
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
delta showed the model a change the live values disagreed with.
The block used to be reconstructed into each past AI turn so the model would
copy the output format. That put a second, older account of the world into
the same prompt as the authoritative one with nothing marking which
governed — and a fact the reader had explicitly withdrawn came back as an
accepted event, phrased as the model first asserted it. The format
instruction survives in `EMIT_RULE` and `EMIT_REMINDER`; the contradiction
does not.
"""
from app.context.builder import _history_text
a = action({"player.arrows": 2, "player.hp": -10})
a.text = "The arrow flies."
a = models.Action(
type="ai",
text="The arrow flies.",
state_changes={
"accepted": [{"type": "add_fact", "predicate": "the arrow struck"}],
"rejected": [{"event": {"type": "set_possession", "item": "ghost",
"owner": "mara"},
"reason": "unknown_reference", "detail": "no ghost"}],
"summary": ["fact: the arrow struck"],
},
)
replayed = _history_text(a)
assert '"player.hp": -10' in replayed
assert "arrows" not in replayed
assert replayed == "The arrow flies."
assert "```state" not in replayed
assert "add_fact" not in replayed
# Neither the accepted event nor the refused one is asserted again.
assert "ghost" not in replayed
def test_history_replay_keeps_flags_and_milestones_and_text():
def test_history_replay_carries_no_machine_readable_payload():
"""Whatever a turn accepted, the history the model reads is the story."""
from app.context.builder import _history_text
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
a.text = "The lock gives."
a = models.Action(
type="ai",
text="The lock gives.",
state_changes={
"accepted": [
{"type": "open_story_thread", "thread": "the-vault",
"title": "Open the vault"},
],
"rejected": [],
"summary": [],
},
)
replayed = _history_text(a)
assert '"flags.has_key": true' in replayed
assert '"milestones.rescue_gwen": true' in replayed
assert replayed == "The lock gives."
assert "open_story_thread" not in replayed
assert "the-vault" not in replayed
def test_a_turn_that_changed_nothing_replays_as_prose_alone():
"""An empty block in the replayed history reads as a turn worth reporting
nothing about, which is not the same as a turn that reported nothing."""
from app.context.builder import _history_text
a = models.Action(type="ai", text="Silence.", state_changes=None)
assert _history_text(a) == "Silence."
def test_a_refusal_reaches_the_model_with_the_valid_names():
+43 -165
View File
@@ -1,8 +1,14 @@
"""HTTP tests for the AI Chat scratchpad (power users only).
"""HTTP tests for the AI Chat scratchpad.
Covers the access gate, the streamed reply, and the demo-key model pinning.
This pinning must not let a public visitor reach paid models through this
page.
Most of this file used to be about the shared demo key: an access gate on a
"power user" email allowlist, and a pinning rule that stopped a public visitor
reaching paid models on a server-funded key. M2 removed the hosted deployment
those defended, so the rules they tested no longer exist to be tested. See
`planning/archive/milestone-reports/M2-*` for the accounting.
What remains is what the page still does: stream a reply from the configured
model, honour a system prompt and a per-request model override, and refuse a
conversation too large to send.
python -m pytest tests/test_chat.py -v
"""
@@ -10,25 +16,27 @@ import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app import auth, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import chat
class FakeProvider:
"""Records what it was constructed with, then streams a fixed reply. Stands
in for the real egress point, so asserting on last_key/last_model is
asserting on exactly what would have gone over the wire."""
"""Records what it was constructed with, then streams a fixed reply.
It stands in for the real egress point, so asserting on `last_endpoint` and
`last_model` is asserting on exactly what would have gone over the wire.
There is no `last_key` any more: the provider takes no API key, because
Ollama does not use one.
"""
last_usage = None
last_model = None
last_key = None
last_endpoint = None
last_messages = None
def __init__(self, endpoint_url, api_key, model, api_mode="chat", reasoning_max_tokens=0):
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
FakeProvider.last_model = model
FakeProvider.last_key = api_key
FakeProvider.last_endpoint = endpoint_url
async def chat(self, messages, *, temperature, max_tokens):
@@ -41,68 +49,35 @@ class FakeProvider:
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="power@example.com")
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
# Multi-user mode is what makes the power-user gate meaningful, because
# local mode trusts everyone. The allowlist is set per test.
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(auth, "POWER_USERS", {"power@example.com"})
# These tests deliberately do not stub resolve_provider_config. The
# point is to exercise the real BYOK-vs-demo decision, since that
# decision is what keeps the shared key off paid models. Each test
# picks a mode with _byok/_demo below.
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield TestClient(app)
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _send(client, **extra):
return client.post("/api/chat/stream", json={"messages": [{"role": "user", "content": "hi"}], **extra})
def _send(client, **body):
payload = {"messages": [{"role": "user", "content": "hi"}]}
payload.update(body)
return client.post("/api/chat/stream", json=payload)
def _byok(monkeypatch):
"""The user brought their own key: no demo key in play, any model allowed."""
monkeypatch.setattr(auth, "demo_enabled", lambda: False)
db = SessionLocal()
try:
settings = db.query(models.Settings).first()
settings.api_key = "sk-my-own-key" # legacy-plaintext path: used as-is
db.commit()
finally:
db.close()
def _demo(monkeypatch, whitelist=("free/allowed",)):
"""The user has no key, so turns run on the server-funded demo key."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", list(whitelist))
def test_non_power_user_gets_404(client, monkeypatch):
monkeypatch.setattr(auth, "POWER_USERS", set())
assert _send(client).status_code == 404
assert client.get("/api/chat/config").status_code == 404
def test_power_user_streams_a_reply(client, monkeypatch):
_byok(monkeypatch)
def test_the_page_streams_a_reply(client):
resp = _send(client)
assert resp.status_code == 200, resp.text
assert '"type": "reasoning"' in resp.text
@@ -111,136 +86,39 @@ def test_power_user_streams_a_reply(client, monkeypatch):
assert FakeProvider.last_messages == [{"role": "user", "content": "hi"}]
def test_system_prompt_and_model_override_are_honoured(client, monkeypatch):
_byok(monkeypatch)
def test_it_uses_the_configured_endpoint_and_model(client):
_send(client)
assert FakeProvider.last_model == "test-model"
# The default from `models.Settings`, and the only kind of address the
# endpoint policy allows without configuration.
assert FakeProvider.last_endpoint == "http://localhost:11434/v1"
def test_system_prompt_and_model_override_are_honoured(client):
resp = client.post("/api/chat/stream", json={
"messages": [
{"role": "system", "content": "Be terse."},
{"role": "user", "content": "hi"},
],
"model": "some/other-model",
"model": "some-other-model",
})
assert resp.status_code == 200, resp.text
# BYOK: any model the user names is passed straight through, on their key.
assert FakeProvider.last_model == "some/other-model"
assert FakeProvider.last_key == "sk-my-own-key"
assert FakeProvider.last_model == "some-other-model"
assert FakeProvider.last_messages[0] == {"role": "system", "content": "Be terse."}
def test_demo_key_pins_model_to_whitelist(client, monkeypatch):
_demo(monkeypatch)
resp = _send(client, model="expensive/paid-model")
assert resp.status_code == 200, resp.text
# Refused visibly: the whitelisted model runs instead, with a note. The
# paid slug must never reach the wire alongside the server-funded key.
assert FakeProvider.last_model == "free/allowed"
assert FakeProvider.last_key == "demo-key"
assert '"type": "note"' in resp.text
# A whitelisted model is still selectable on the demo key.
_demo(monkeypatch, ["free/allowed", "free/second"])
resp = _send(client, model="free/second")
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "free/second"
def test_demo_key_ignores_an_off_whitelist_settings_model(client, monkeypatch):
"""The override is not the only untrusted input. `Settings.model` is
also user-set, and it must be pinned the same way when there is no
BYOK key."""
_demo(monkeypatch)
db = SessionLocal()
try:
db.query(models.Settings).first().model = "expensive/paid-model"
db.commit()
finally:
db.close()
resp = _send(client)
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "free/allowed"
def test_demo_key_endpoint_cannot_be_redirected(client, monkeypatch):
"""A user-controlled `endpoint_url` would leak the key itself, which is
worse than spending it. The demo branch pins the URL too."""
_demo(monkeypatch)
db = SessionLocal()
try:
db.query(models.Settings).first().endpoint_url = "http://attacker.example/v1"
db.commit()
finally:
db.close()
assert _send(client).status_code == 200
assert FakeProvider.last_endpoint == "http://demo"
assert FakeProvider.last_key == "demo-key"
def test_provider_config_refuses_server_funded_paid_model(monkeypatch):
"""The structural backstop: a hand-built config (a future code path that
forgets to go through resolve_provider_config) cannot run a
server-funded turn on an off-whitelist model."""
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
with pytest.raises(ValueError):
auth.ProviderConfig("http://demo", "demo-key", "expensive/paid-model", True)
auth.ProviderConfig("http://demo", "demo-key", "free/allowed", True) # whitelisted: fine
# The user's own key with any model stays fine.
auth.ProviderConfig("http://any", "sk-mine", "expensive/paid-model", False)
def test_byok_user_may_reuse_the_demo_keys_value(client, monkeypatch):
"""Regression: the demo key is just an OpenRouter key, so a user can paste
that same value into their own Settings. That is still BYOK, because
the user is paying, and it must not trip the guard. It used to raise
on every resolution, which returned a 500 from `GET /auth/me` and
broke the entire SPA (no nav, no chat)."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "shared-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
def test_a_request_with_no_model_anywhere_is_refused(client):
db = SessionLocal()
try:
settings = db.query(models.Settings).first()
settings.api_key = "shared-key" # same value, but supplied by the user
settings.model = "expensive/paid-model" # their spend, their choice
settings.model = ""
db.commit()
finally:
db.close()
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/chat/config").status_code == 200
resp = _send(client)
assert resp.status_code == 200, resp.text
assert FakeProvider.last_model == "expensive/paid-model"
assert FakeProvider.last_key == "shared-key"
assert _send(client).status_code == 400
def test_resolve_provider_config_is_the_single_choke_point(monkeypatch):
"""Turns, AI Chat, and the connection test all resolve through this one
function, so pinning it here pins every caller. No DB or HTTP needed."""
monkeypatch.setattr(auth, "demo_enabled", lambda: True)
monkeypatch.setattr(auth, "DEMO_API_KEY", "demo-key")
monkeypatch.setattr(auth, "DEMO_ENDPOINT_URL", "http://demo")
monkeypatch.setattr(auth, "DEMO_MODELS", ["free/allowed"])
# No key of their own: both endpoint and model are pinned, regardless
# of what they set.
no_key = models.Settings(endpoint_url="http://mine/v1", api_key="", model="expensive/paid")
assert auth.resolve_provider_config(no_key) == auth.ProviderConfig(
"http://demo", "demo-key", "free/allowed", True)
assert auth.resolve_provider_config(
no_key, model_override="expensive/paid").model == "free/allowed"
assert auth.resolve_provider_config(
no_key, model_override="free/allowed").model == "free/allowed"
# Their own key: their endpoint, their key, their choice of model.
byok = models.Settings(endpoint_url="http://mine/v1", api_key="sk-mine", model="expensive/paid")
assert auth.resolve_provider_config(byok) == auth.ProviderConfig(
"http://mine/v1", "sk-mine", "expensive/paid", False)
def test_oversized_conversation_is_refused(client, monkeypatch):
_byok(monkeypatch)
def test_oversized_conversation_is_refused(client):
huge = "x" * 90_000
resp = client.post("/api/chat/stream", json={
"messages": [{"role": "user", "content": huge} for _ in range(5)],
+54 -38
View File
@@ -1,6 +1,6 @@
"""Deleting a turn puts the shared state back.
`script_state` and `world_state` belong to the adventure, not to the node
`world_state` belongs to the adventure, not to the node
that changed them. Undo, retry, a take and a branch switch all restore them;
the delete endpoint did not. Deleting an AI turn removed the text and left
everything the turn did to the numbers standing.
@@ -23,7 +23,7 @@ from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
# a refusal rather than as a number that is merely off.
@@ -31,20 +31,23 @@ SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"mana": {"min": 0, "max": 50, "initial": 50, "cooldown": 2},
# The per-turn counter these tests measure rollbacks with. Unbounded and
# uncapped on purpose, so every turn's +10 lands in full. See
# `fakes.gold_reply`.
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
# Ten gold a turn. A total that only ever climbs makes a missing rollback
# obvious: it is off by exactly one turn's worth.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
# The instrument is a typed narrative fact with an absolute value (M5,
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
# measure — that deleting a turn puts the state back to what the position
# before it left behind — is unchanged, and is now measured through the
# production state path rather than through a removed game system.
DRAIN = tally_reply("Drained.", 10)
DRAIN_AND_GOLD = DRAIN
@pytest.fixture()
@@ -60,24 +63,18 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tower", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100, "mana": 50}},
world_state={"player": {"hp": 100, "mana": 50, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = [DRAIN]
ScriptedProvider.replies = [DRAIN_AND_GOLD]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -113,10 +110,17 @@ def _delete(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -134,11 +138,16 @@ def _ai_rows(adv_id):
db.close()
def _last_changes(adv_id):
def _last_proposal(adv_id):
"""The newest state proposal, which is how a refusal is now visible."""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].world_changes
return (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
finally:
db.close()
@@ -147,38 +156,44 @@ def _last_changes(adv_id):
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
_play(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
_, world = _state(client.adv_id)
assert world["player"]["mana"] == 50, "the drain went with the turn"
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
"""The bug as a player meets it: delete the reply, press Continue, and
the change it proposes is refused as one that already happened."""
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
"""The bug as a player meets it: delete the reply, press Continue, and the
turn that replaces it lands cleanly.
Under M5 this is a statement about the *state document* rather than about a
cooldown clock — the RPG cooldown machinery the original bug surfaced
through is no longer in the turn path — but the failure it guards is the
same one: a deleted turn leaving something behind that makes the next turn
behave as though it had already happened.
"""
_play(client)
_delete(client, _ai_rows(client.adv_id)[-1].id)
_continue(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
proposal = _last_proposal(client.adv_id)
assert proposal.status == "accepted", "the replacement's state was refused"
def test_deleting_the_ai_turn_rewinds_the_script_state(client):
"""The same restore, on the other half of the shared state. Without it a
replayed turn stacks its script run on top of the deleted one's."""
def test_deleting_the_ai_turn_rewinds_the_counter(client):
"""The same restore, seen through a value that only ever climbs. Without it
a replayed turn stacks its effects on top of the deleted one's."""
_play(client)
assert _state(client.adv_id)[0] == {"gold": 10}
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
assert _state(client.adv_id)[0] == {}
assert _state(client.adv_id)[0]["gold"] == 0
_continue(client)
assert _state(client.adv_id)[0] == {"gold": 10}, "one turn of gold, not two"
assert _state(client.adv_id)[0]["gold"] == 10, "one turn of gold, not two"
# ------------------------------------------------- deleting further back
@@ -188,9 +203,10 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
neighbour, so removing a turn from the middle of the story does not roll
the numbers back to that point. The text goes; the state stays."""
_play(client)
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
_play(client, "press on")
before = _state(client.adv_id)
assert before[0] == {"gold": 20}
assert before[0]["gold"] == 20
first_ai = _ai_rows(client.adv_id)[0]
assert _delete(client, first_ai.id).status_code == 204
+45 -1
View File
@@ -99,7 +99,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -143,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
assert offenders == [], f"{column} was fetched in bulk"
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
"""M5's rollback snapshot follows the same rule as the two before it: only
the one node being restored to ever needs it."""
client.get(f"/api/adventures/{client.adv_id}")
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
assert offenders == [], "narrative_state_after was fetched in bulk"
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
`ActionOut.state_summary` reads `state_changes` for every row on the page.
The column was added to the model without being added to
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
actions cost 53 extra queries, and the cost grew with the story.
Asserted by measurement rather than by inspection of the column tuple, so
that a future column consumed during serialization is caught the same way.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(40):
db.add(models.Action(
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Extra {i}.",
state_changes={"accepted": [], "rejected": [],
"summary": [f"fact: extra {i}"]},
))
db.commit()
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
rows = len(r.json()["actions"])
assert rows >= 50, "the fixture needs enough rows for the growth to show"
assert len(action_selects(sql_log)) < rows, (
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
"the list is paying one query per action"
)
# And the summaries still arrive.
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
assert summaries, "state_summary came back empty, so the column is not being read"
def test_world_changes_still_works_without_the_snapshot(client):
"""The chips under an AI message must survive the snapshot being deferred."""
r = client.get(f"/api/adventures/{client.adv_id}")
@@ -57,7 +57,6 @@ def client(monkeypatch):
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+224
View File
@@ -0,0 +1,224 @@
"""Which inference endpoints the product will and will not talk to.
The endpoint is where the whole campaign goes: prompts, narration, retrieved
memories, embedding inputs. `app/endpoints.py` is the rule that keeps that on
the user's own infrastructure, and these tests are what stops the rule quietly
widening.
Nothing here touches the network. Hostnames are resolved through a stub, so the
tests are about the *policy*, run identically offline, and cannot be made to
pass or fail by whatever DNS the machine happens to have.
python -m pytest tests/test_endpoint_policy.py -v
"""
import ipaddress
import socket
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, endpoints, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import OpenAICompatibleProvider, ProviderError
@pytest.fixture()
def resolves(monkeypatch):
"""Points every hostname at addresses the test names."""
table: dict[str, list[str]] = {}
def fake_getaddrinfo(host, port, *a, **k):
# An address literal resolves to itself, as the real resolver does.
try:
ipaddress.ip_address(host)
except ValueError:
if host not in table:
raise socket.gaierror(-2, "Name or service not known")
answers = table[host]
else:
answers = [host]
return [
(
socket.AF_INET6 if ":" in ip else socket.AF_INET,
socket.SOCK_STREAM, 6, "", (ip, port or 0),
)
for ip in answers
]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
return table
# --- allowed: the user's own machine, and the user's own network ----------
@pytest.mark.parametrize("url", [
"http://127.0.0.1:11434/v1",
"http://[::1]:11434/v1",
"http://192.168.1.50:11434/v1", # RFC1918
"http://10.0.0.7:11434/v1", # RFC1918
"http://172.16.4.4:11434/v1", # RFC1918
"https://192.168.1.50:8443/v1", # TLS on the LAN
"http://100.100.5.6:11434/v1", # CGNAT, which is what a mesh VPN hands out
"http://[fd00::1]:11434/v1", # IPv6 unique-local
])
def test_local_and_lan_addresses_are_allowed(url, resolves):
assert endpoints.rejection_reason(url) is None
def test_a_hostname_resolving_to_the_lan_is_allowed(resolves):
resolves["ollama.home.arpa"] = ["192.168.1.50"]
assert endpoints.rejection_reason("https://ollama.home.arpa:8443/v1") is None
# --- refused: anywhere else ----------------------------------------------
@pytest.mark.parametrize("url", [
"http://8.8.8.8:11434/v1",
"http://1.1.1.1:11434/v1",
"http://[2001:4860:4860::8888]:11434/v1",
])
def test_public_addresses_are_refused(url, resolves):
reason = endpoints.rejection_reason(url)
assert reason is not None
assert "public Internet address" in reason
@pytest.mark.parametrize("url", [
"https://198.51.100.9/v1", # TEST-NET-2, a documentation range
"http://0.0.0.0:11434/v1", # "this host", not an address to dial
])
def test_addresses_that_are_neither_local_nor_public_are_refused(url, resolves):
"""Deny by default. `ipaddress` calls both of these "private", which is why
the policy names the networks it allows instead of asking that question."""
assert endpoints.rejection_reason(url) is not None
def test_a_hostname_resolving_to_the_public_internet_is_refused(resolves):
resolves["ollama.example.com"] = ["93.184.216.34"]
reason = endpoints.rejection_reason("https://ollama.example.com/v1")
assert reason is not None and "public Internet address" in reason
def test_a_split_horizon_answer_is_refused(resolves):
"""One public address among the answers is enough. A name that resolves to
both a LAN address and a public one must not be usable: which one the
request actually reaches is not ours to decide."""
resolves["sneaky.example"] = ["192.168.1.50", "93.184.216.34"]
assert endpoints.rejection_reason("http://sneaky.example:11434/v1") is not None
@pytest.mark.parametrize("host", [
"openrouter.ai", "api.openai.com", "api.anthropic.com", "api.groq.com",
])
def test_known_cloud_providers_are_named_in_the_refusal(host, resolves):
"""They would be refused by address anyway — every one resolves publicly.
Naming them turns 'your DNS might be broken' into 'this build has no cloud
provider support'."""
resolves[host] = ["192.168.1.50"] # even if DNS said otherwise
reason = endpoints.rejection_reason(f"https://{host}/v1")
assert reason is not None
assert "cloud inference service" in reason
def test_a_subdomain_of_a_cloud_provider_is_refused(resolves):
resolves["eu.api.openai.com"] = ["192.168.1.50"]
assert endpoints.rejection_reason("https://eu.api.openai.com/v1") is not None
@pytest.mark.parametrize("url", [
"ftp://127.0.0.1/v1",
"file:///etc/passwd",
"127.0.0.1:11434", # no scheme
"",
"http://",
])
def test_things_that_are_not_usable_urls_are_refused(url, resolves):
assert endpoints.rejection_reason(url) is not None
def test_an_unresolvable_host_is_refused_with_advice(resolves):
reason = endpoints.rejection_reason("http://nope.invalid:11434/v1")
assert reason is not None and "could not be resolved" in reason
# --- the rule is applied, not merely available ---------------------------
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def test_saving_a_public_endpoint_is_refused(client, resolves):
resolves["openrouter.ai"] = ["93.184.216.34"]
r = client.put("/api/settings", json={"endpoint_url": "https://openrouter.ai/api/v1"})
assert r.status_code == 400, r.text
assert "can't be used" in r.json()["detail"]
# And it was not written.
assert client.get("/api/settings").json()["endpoint_url"] != "https://openrouter.ai/api/v1"
def test_saving_a_lan_endpoint_is_allowed(client, resolves):
r = client.put("/api/settings", json={"endpoint_url": "http://192.168.1.50:11434/v1"})
assert r.status_code == 200, r.text
assert client.get("/api/settings").json()["endpoint_url"] == "http://192.168.1.50:11434/v1"
def test_the_connection_test_reports_a_refused_endpoint_as_such(client, resolves):
"""A row edited by hand, or a name that has started resolving elsewhere,
must not simply look 'unreachable'."""
db = SessionLocal()
try:
db.query(models.Settings).first().endpoint_url = "https://openrouter.ai/api/v1"
db.commit()
finally:
db.close()
resolves["openrouter.ai"] = ["93.184.216.34"]
body = client.post("/api/settings/test").json()
assert body["ok"] is False
assert body["kind"] == "rejected"
@pytest.mark.anyio
async def test_the_provider_refuses_before_it_sends_anything(resolves):
"""The check that actually matters. Whatever is stored, no request leaves
for an address outside the policy — so a database edited behind the app's
back cannot turn into an exfiltration path."""
resolves["openrouter.ai"] = ["93.184.216.34"]
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "m")
with pytest.raises(ProviderError) as exc:
async for _ in provider.generate(
_parts(), temperature=0.8, max_tokens=10
):
pass
assert "can't be used" in str(exc.value)
def _parts():
from app.providers.base import PromptParts
return PromptParts(system="s", story="t")
@pytest.fixture
def anyio_backend():
return "asyncio"
-205
View File
@@ -1,205 +0,0 @@
"""Guest retention policy: app/cleanup.py.
These tests cover the two things that matter. Idle guests and their whole
data graph must actually be deleted, and nothing else must ever be
deleted.
python -m pytest tests/test_guest_cleanup.py -v
"""
from datetime import timedelta
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import cleanup, models
from app.database import Base
from app.migrations import bootstrap
@pytest.fixture()
def db(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}", connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _fk(dbapi_connection, _record):
# The whole policy relies on ON DELETE CASCADE. SQLite ignores every
# one of them unless this is set, the same as database.py does.
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
bootstrap(engine)
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
yield session
session.close()
NOW = models.utcnow().replace(tzinfo=None)
def make_user(db, *, days_idle=None, days_old=0, guest=True, email=None):
"""A user last seen `days_idle` ago (None = never seen, only created)."""
user = models.User(
is_guest=guest,
email=email,
password_hash=None if email is None else "x",
created_at=NOW - timedelta(days=days_old),
last_seen_at=None if days_idle is None else NOW - timedelta(days=days_idle),
)
db.add(user)
db.commit()
return user
def sweep(db):
return cleanup.delete_stale_guests(db, now=NOW)
def alive(db, user_id):
# A count, not db.get: the sweep deletes with synchronize_session=False, so
# the session's identity map still holds the object and db.get would answer
# from memory without ever asking the database.
return db.query(models.User).filter(models.User.id == user_id).count() == 1
# ---------- what goes ----------
def test_deletes_guest_idle_past_the_window(db):
user = make_user(db, days_idle=6)
assert sweep(db) == 1
assert not alive(db, user.id)
def test_keeps_guest_inside_the_window(db):
user = make_user(db, days_idle=4)
assert sweep(db) == 0
assert alive(db, user.id)
def test_boundary_is_not_yet_stale(db):
# Exactly 5 days survives. The comparison is strict.
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
assert sweep(db) == 0
assert alive(db, user.id)
def test_never_seen_guest_falls_back_to_created_at(db):
"""last_seen_at is NULL until a guest's second request (auth._touch runs
hourly), so a coalesce-less query would delete brand-new visitors."""
fresh = make_user(db, days_idle=None, days_old=0)
stale = make_user(db, days_idle=None, days_old=9)
assert sweep(db) == 1
assert alive(db, fresh.id)
assert not alive(db, stale.id)
def test_recent_visit_beats_an_old_created_at(db):
# A long-standing guest who came back yesterday stays.
user = make_user(db, days_idle=1, days_old=90)
assert sweep(db) == 0
assert alive(db, user.id)
# ---------- what must never go ----------
def test_spares_registered_users(db):
"""Registering upgrades the guest row in place. An idle account here is
a real user with real data, which is what signing up is meant to
protect."""
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_the_local_mode_user(db):
# email NULL but is_guest False: local mode's implicit owner of everything.
user = make_user(db, days_idle=400, guest=False)
assert sweep(db) == 0
assert alive(db, user.id)
def test_spares_a_guest_flagged_row_that_has_an_email(db):
# This row should not exist, but both clauses are checked so it cannot be collected.
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
assert sweep(db) == 0
assert alive(db, user.id)
def test_leaves_seeded_public_scenarios_alone(db):
"""Seeded demo content has user_id NULL, so it is outside the filter."""
seeded = models.Scenario(user_id=None, is_public=True, title="Demo")
db.add(seeded)
make_user(db, days_idle=30)
db.commit()
assert sweep(db) == 1
assert db.query(models.Scenario).filter(models.Scenario.id == seeded.id).count() == 1
def test_disabled_when_retention_is_zero(db, monkeypatch):
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
user = make_user(db, days_idle=999)
assert sweep(db) == 0
assert alive(db, user.id)
def test_enabled_requires_multi_user(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "MULTI_USER", False)
assert cleanup.enabled() is False
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 5)
assert cleanup.enabled() is True
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
assert cleanup.enabled() is False
# ---------- the cascade ----------
def test_deletes_the_whole_data_graph(db):
"""One DELETE must remove the adventure, its actions and memories, the
story cards, and the settings row. Nothing is loaded into Python, so if
the FK cascade does not reach a table, its rows are silently orphaned,
or the statement fails, instead of being removed."""
user = make_user(db, days_idle=30)
scenario = models.Scenario(user_id=user.id, title="S")
db.add(scenario)
db.commit()
adventure = models.Adventure(user_id=user.id, scenario_id=scenario.id, title="A")
db.add(adventure)
db.commit()
db.add_all([
models.Action(adventure_id=adventure.id, type="ai", text="t"),
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
models.StoryCard(adventure_id=adventure.id, name="c"),
models.Settings(user_id=user.id),
])
db.commit()
assert sweep(db) == 1
for model in (models.Scenario, models.Adventure, models.Action,
models.Memory, models.StoryCard, models.Settings):
assert db.query(model).count() == 0, f"{model.__name__} rows survived"
def test_one_users_cleanup_does_not_touch_another(db):
keeper = make_user(db, days_idle=1)
keep_adv = models.Adventure(user_id=keeper.id, title="mine")
goner = make_user(db, days_idle=30)
db.add_all([keep_adv, models.Adventure(user_id=goner.id, title="theirs")])
db.commit()
assert sweep(db) == 1
remaining = db.query(models.Adventure).all()
assert [a.title for a in remaining] == ["mine"]
def test_sweep_swallows_errors(monkeypatch):
"""A broken cleanup must not take the app down (same rule as seeding)."""
monkeypatch.setattr(cleanup.auth, "MULTI_USER", True)
monkeypatch.setattr(cleanup, "delete_stale_guests",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert cleanup.sweep() == 0
+895
View File
@@ -0,0 +1,895 @@
"""M3: the active head, and what moving it costs.
This file is the acceptance contract for the milestone that stopped Undo from
deleting. Its subject is one invariant and the behaviour that follows from it:
Undo deletes zero accepted turns.
Everything else here is a consequence. Redo exists because the turns are still
there. Divergence retires a future rather than removing it. Memory and summary
coverage narrow and widen again as the head moves, without anything being
re-embedded. Export carries where the reader stopped, because that is now a
decision rather than a fact about the newest row.
The tests are named for the acceptance items they discharge — D01-D10, E01-E04,
I01-I03, I07, L01-L02 in `planning/V1-ACCEPTANCE-TESTS.md` — so a reader can go
from a failing test to the requirement it belongs to without a map.
The world state is instrumentation here, not the subject. Each scripted reply
banks ten gold, which makes "the state at this position" a number a test can
assert instead of a paragraph it has to interpret. M5 replaces that machinery
with genre-neutral narrative state; these tests then need the *instrumentation*
moved, not the assertions removed, because what they measure is where the story
is being read.
python -m pytest tests/test_head_cursor.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import limits, models
from app.providers import ProviderError
from app.context import cursors, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app import auth, tree
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="head@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = models.Scenario(user_id=user.id, title="S", stat_schema=GOLD_SCHEMA)
setup.add(scenario)
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter the tavern."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = gold_replies()
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------------------------ helpers
def _play(client, text="look around", type="do", after_id=None):
payload = {"type": type, "text": text}
if after_id is not None:
payload["after_id"] = after_id
r = client.post(f"/api/adventures/{client.adv_id}/actions", json=payload)
assert r.status_code == 200, r.text
return r
def _turns(client, count):
for n in range(count):
_play(client, f"turn {n}")
def _undo(client):
return client.post(f"/api/adventures/{client.adv_id}/undo")
def _redo(client):
return client.post(f"/api/adventures/{client.adv_id}/redo")
def _adventure(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
return r.json()
def _texts(client) -> list[str]:
return [a["text"] for a in _adventure(client)["actions"]]
def _rows(adv_id) -> list[models.Action]:
"""Every action row, story or not, live or not, head or no head."""
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter(models.Action.adventure_id == adv_id)
.order_by(models.Action.branch_id, models.Action.depth, models.Action.id)
.all()
)
finally:
db.close()
def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return tally_of(adv.narrative_state)
finally:
db.close()
def _head(adv_id) -> tuple[int, int]:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.head_branch_id, adv.head_depth
finally:
db.close()
# ------------------------------------------------------- D01, D02, D03: undo
def test_d01_one_undo_returns_the_transcript_and_the_state(client):
_turns(client, 2)
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
told = _texts(client)
page = _undo(client)
assert page.status_code == 200, page.text
# Both halves of the turn step back together: a player's action and the
# reply to it are one accepted story step.
assert [a["text"] for a in page.json()["actions"]] == told[:-2]
assert _gold(client.adv_id) == 1 * GOLD_PER_TURN
def test_d02_five_consecutive_undos_each_land_where_they_should(client):
_turns(client, 7)
assert _gold(client.adv_id) == 7 * GOLD_PER_TURN
for step in range(1, 6):
assert _undo(client).status_code == 200
assert _gold(client.adv_id) == (7 - step) * GOLD_PER_TURN
# Two rows per turn, plus the opening.
assert len(_texts(client)) == 1 + 2 * (7 - step)
def test_d03_undo_walks_back_to_the_campaign_opening_and_stops(client):
_turns(client, 7)
for _ in range(7):
assert _undo(client).status_code == 200
assert _texts(client) == ["You enter the tavern."]
assert _gold(client.adv_id) == 0
# The opening is the floor. There is no pre-campaign position to reach.
refused = _undo(client)
assert refused.status_code == 400
assert "Nothing to undo" in refused.json()["detail"]
assert _adventure(client)["can_undo"] is False
def test_the_m3_invariant_undo_deletes_zero_accepted_turns(client):
"""The row count is the milestone. Everything else in this file is a
consequence of it, so it is asserted on its own, over the whole retained
tree rather than over the story being told."""
_turns(client, 5)
before = [a.id for a in _rows(client.adv_id)]
for _ in range(5):
assert _undo(client).status_code == 200
after = [a.id for a in _rows(client.adv_id)]
assert after == before
assert len(_texts(client)) == 1 # and yet the story is back at its opening
# -------------------------------------------------------------- D04, L02: redo
def test_d04_redo_restores_the_continuation_and_its_state(client):
_turns(client, 4)
whole = _texts(client)
_undo(client)
_undo(client)
assert _gold(client.adv_id) == 2 * GOLD_PER_TURN
assert _redo(client).status_code == 200
assert _gold(client.adv_id) == 3 * GOLD_PER_TURN
assert _redo(client).status_code == 200
assert _gold(client.adv_id) == 4 * GOLD_PER_TURN
assert _texts(client) == whole
# Nowhere further forward to go, and the control says so.
assert _adventure(client)["can_redo"] is False
assert _redo(client).status_code == 400
def test_l02_state_matches_the_position_in_both_directions(client):
"""Each position's state is the snapshot the node left behind, so arriving
from in front of it and arriving from behind it must agree."""
_turns(client, 5)
going_back = []
for _ in range(5):
_undo(client)
going_back.append(_gold(client.adv_id))
coming_forward = []
for _ in range(5):
_redo(client)
coming_forward.append(_gold(client.adv_id))
assert going_back == [40, 30, 20, 10, 0]
assert coming_forward == [10, 20, 30, 40, 50]
# ------------------------------------------------- D05, E01, E04: divergence
def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client):
_turns(client, 4)
abandoned = {a.id for a in _rows(client.adv_id)}
_undo(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
assert _adventure(client)["can_redo"] is False
assert _redo(client).status_code == 400
# ...and not one row of it was deleted to achieve that.
kept = {a.id for a in _rows(client.adv_id)}
assert abandoned <= kept
# The story now tells the new continuation.
assert _texts(client)[-1].startswith("A different road.")
def test_the_departed_branch_records_where_the_story_left_it(client):
"""`DATA-MODEL.md` §5 gives a branch a disposition. It is stored as the fact
that produced it — the depth the story left at — and nothing reads it to
decide behaviour, so this test is what makes a divergence observable."""
_turns(client, 3)
_undo(client)
left_at = _head(client.adv_id)[1]
_play(client, "elsewhere")
db = SessionLocal()
try:
superseded = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == client.adv_id,
models.Branch.superseded_at.isnot(None),
)
.all()
)
assert len(superseded) == 1
assert superseded[0].superseded_depth == left_at
finally:
db.close()
def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
"""E01 and E04 are the same mechanism measured twice: the state that is
current is the one belonging to the position the story is read at, so a
number only the abandoned future ever reached cannot survive a divergence."""
ScriptedProvider.replies = [
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
assert _gold(client.adv_id) == 510
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
_play(client, "go home")
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
# ------------------------------------------------------ E02, E03: derived work
def _attach_memory(adv_id, text, at_depth):
"""Writes a memory ending on the live node at `at_depth`, the way the
summarizer would, and anchors the memory cursor there."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
node = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adv_id,
models.Action.depth == at_depth,
models.Action.live.is_(True),
)
.first()
)
memory = models.Memory(
adventure_id=adv_id, text=text, source_start=0, source_end=node.depth,
)
tree.attach_memory(memory, node)
db.add(memory)
cursors.MEMORY.anchor_at(adventure, node)
cursors.SUMMARY.anchor_at(adventure, node)
db.commit()
finally:
db.close()
def _retrievable(adv_id) -> set[str]:
"""The memories the story can reach, through the clause `memorybank`
retrieves with."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
rows = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adv_id,
lineage.path_of(db, adventure).clause(models.Memory),
)
.all()
)
return {m.text for m in rows}
finally:
db.close()
def test_e02_an_abandoned_memory_is_unreachable_and_still_on_disk(client):
_turns(client, 3)
tip_depth = _head(client.adv_id)[1]
_attach_memory(client.adv_id, "Mara reveals she is a spy.", tip_depth)
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
_undo(client)
# Negative control: undone, the revelation is not retrievable...
assert _retrievable(client.adv_id) == set()
_play(client, "talk about the weather")
# ...and after diverging it stays unreachable, on a line the story left.
assert _retrievable(client.adv_id) == set()
db = SessionLocal()
try:
assert db.query(models.Memory).count() == 1, "unreachable, not deleted"
finally:
db.close()
def test_e02_positive_control_the_memory_returns_on_the_line_it_belongs_to(client):
"""The negative control alone would pass if memories were simply broken.
Redo puts the head back on the lineage the memory was written for, and it
must be retrievable again — without having been re-embedded."""
_turns(client, 3)
_attach_memory(client.adv_id, "Mara reveals she is a spy.", _head(client.adv_id)[1])
_undo(client)
assert _retrievable(client.adv_id) == set()
_redo(client)
assert _retrievable(client.adv_id) == {"Mara reveals she is a spy."}
def test_e03_a_summary_anchor_cannot_claim_coverage_past_the_head(client):
"""E03 without redesigning the summarizer. The anchor says how far the
derived work has read; resolved against a head-capped path it can never
report a stretch in the abandoned future as already covered, so that content
is re-derived for the new line rather than carried into it."""
_turns(client, 3)
tip_depth = _head(client.adv_id)[1]
_attach_memory(client.adv_id, "Everything up to the reveal.", tip_depth)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
assert cursors.SUMMARY.depth(db, adventure) == tip_depth
finally:
db.close()
_undo(client)
_play(client, "a different question")
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
covered = cursors.SUMMARY.depth(db, adventure)
assert covered < tip_depth, "coverage from the abandoned line was claimed"
finally:
db.close()
# ------------------------------------------------ D06-D08: retry and takes
def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client):
ScriptedProvider.replies = gold_replies("Take")
_play(client, "knock")
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
ai_rows = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert len(ai_rows) == 2, "the earlier take is retained"
assert first.id in {a.id for a in ai_rows}
# Both takes sit at the same coordinate, which is what makes them takes
# rather than turns, and the state is one turn's worth either way — the
# retry replaced the take rather than stacking on top of it.
assert {a.depth for a in ai_rows} == {first.depth}
assert _gold(client.adv_id) == GOLD_PER_TURN
def test_d07_a_retry_from_behind_the_tip_branches_instead_of_amending(client):
"""A turn with an accepted future is not a leaf, whatever the capped read
says. Retrying it has to leave that future on the line it was written for,
which is a branch — the same operation `add_take` performs for a turn the
story has already moved past."""
_turns(client, 3)
_undo(client)
before = {a.id for a in _rows(client.adv_id)}
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
after = {a.id for a in _rows(client.adv_id)}
assert before <= after, "the retained future kept every row"
db = SessionLocal()
try:
assert db.query(models.Branch).filter_by(adventure_id=client.adv_id).count() == 2
finally:
db.close()
def test_switching_a_take_is_refused_while_a_kept_future_hangs_off_it(client):
"""The one operation that cannot be made safe by branching, because it
changes which take is live in place. It reports rather than guesses."""
ScriptedProvider.replies = gold_replies()
_play(client, "knock")
client.post(f"/api/adventures/{client.adv_id}/retry")
_play(client, "go in")
_undo(client)
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{newest.id}/variant", json={"index": 0}
)
assert r.status_code == 400
assert "undone but kept" in r.json()["detail"]
# ---------------------------------------------------------- D09, D10: editing
def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
"""D09 and D10 through the operation the product actually offers. Editing
an earlier turn and replaying it is one endpoint — `takes` — and it is
already the "return to the parent state, then continue differently" shape
the semantics ask for. The plain text edit beside it is a correction to
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
tally_reply("You accuse her.", 10),
tally_reply("She draws a knife.", 30),
]
_play(client, "I accuse Mara of stealing the key.")
_play(client, "wait")
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
old_future = {a.id for a in _rows(client.adv_id)}
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
json={"text": "I quietly ask Mara whether she has seen the key."},
)
assert r.status_code == 200, r.text
# The old future is retained whole...
assert old_future <= {a.id for a in _rows(client.adv_id)}
# ...the edited input is what the story now tells...
told = _texts(client)
assert any("quietly ask Mara" in t for t in told)
assert not any("accuse her" in t for t in told)
# ...and no state from the abandoned line leaked into it.
assert _gold(client.adv_id) == 1
# ------------------------------------------------------------------ L01
def test_l01_a_failed_turn_accepts_no_narration_and_strands_no_state(client):
"""A provider that fails must not leave the story half-advanced.
The head does move, by exactly one node, and that is deliberate rather than
a gap in the guarantee: A05 keeps the player's submitted text so it can be
tried again, and the head sits on it. What L01 forbids is an *accepted*
narration with half-written state behind it, a head that has moved past a
turn that did not happen, or earlier story becoming unreachable — so those
are what this asserts.
"""
_turns(client, 2)
branch, depth = _head(client.adv_id)
told = _texts(client)
banked = _gold(client.adv_id)
ai_before = [a.id for a in _rows(client.adv_id) if a.type == "ai"]
ScriptedProvider.replies = [ProviderError("the model refused")]
client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": "the turn that fails"})
# One step, onto the retained player action, on the same branch.
assert _head(client.adv_id) == (branch, depth + 1)
# No narration was accepted, and no state moved with a turn that did not
# finish.
assert [a.id for a in _rows(client.adv_id) if a.type == "ai"] == ai_before
assert _gold(client.adv_id) == banked
# Every earlier turn is still readable, in order, unchanged.
assert _texts(client)[:len(told)] == told
# And the story still moves: one Undo steps back over the stranded input
# and the position is a complete turn again.
ScriptedProvider.replies = gold_replies()
assert _undo(client).status_code == 200
assert _texts(client) == told
assert _gold(client.adv_id) == banked
# ------------------------------------------------- I01, I02, I03, I07: bundles
def _export(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/export")
assert r.status_code == 200, r.text
return r.json()
def _import(client, bundle) -> dict:
"""Imports a bundle and returns the new adventure.
"A fresh data directory" is an adventure this file has never touched: the
import allocates its own branch rows and its own nodes, and resolves the
file's local branch numbers against them, which is the whole of what the
round trip has to get right. Sharing a database with the original does not
weaken that — the two adventures share no row.
"""
r = client.post("/api/adventures/import", json=bundle)
assert r.status_code == 201, r.text
return r.json()
def _story_of(adv_id) -> list[str]:
"""The story an imported adventure tells, read through its own head."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adv_id)
rows = (
db.query(models.Action)
.filter(
models.Action.adventure_id == adv_id,
lineage.path_of(db, adventure).clause(models.Action),
)
.order_by(models.Action.depth, models.Action.id)
.all()
)
return [a.text for a in rows]
finally:
db.close()
def test_i01_i02_a_campaign_round_trips(client):
_turns(client, 3)
told = _texts(client)
imported = _import(client, _export(client))
assert _story_of(imported["id"]) == told
assert _gold(imported["id"]) == 3 * GOLD_PER_TURN
def test_i03_the_bundle_carries_the_history_the_story_no_longer_tells(client):
"""A backup that keeps only the active line is not a backup: the retained
future is what Redo and every later recovery feature read."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
active = _texts(client)
bundle = _export(client)
assert len(bundle["branches"]) == 2
imported = _import(client, bundle)
assert _story_of(imported["id"]) == active
# Every row of the abandoned line arrived too, on its own branch.
db = SessionLocal()
try:
assert (
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
== len(_rows(client.adv_id))
)
assert db.query(models.Branch).filter_by(adventure_id=imported["id"]).count() == 2
finally:
db.close()
def test_i07_an_undone_head_survives_export_and_import(client):
"""The Phase 0B finding this milestone exists to close. Before M3 the head
depth was derived on import from the newest retained row, so a campaign
exported after two Undos came back silently redone to its tip."""
_turns(client, 5)
whole = _texts(client)
_undo(client)
_undo(client)
undone = _texts(client)
assert len(undone) == len(whole) - 4
bundle = _export(client)
assert bundle["headDepth"] == _head(client.adv_id)[1]
imported = _import(client, bundle)
# It opens exactly where it was left...
assert _story_of(imported["id"]) == undone
assert imported["can_redo"] is True
# ...the later turns arrived as retained history...
db = SessionLocal()
try:
assert (
db.query(models.Action).filter_by(adventure_id=imported["id"]).count()
== len(_rows(client.adv_id))
)
finally:
db.close()
# ...and Redo still walks forward into them, twice, to the same story.
for _ in range(2):
r = client.post(f"/api/adventures/{imported['id']}/redo")
assert r.status_code == 200, r.text
assert _story_of(imported["id"]) == whole
def test_a_bundle_written_before_m3_opens_at_its_tip(client):
"""Backward compatibility. A file with no `headDepth` is one written when
the head could not be anywhere but the tip, so deriving it is not a
fallback — it is the position that file recorded."""
_turns(client, 3)
told = _texts(client)
bundle = _export(client)
del bundle["headDepth"]
imported = _import(client, bundle)
assert _story_of(imported["id"]) == told
def test_a_bundle_that_reads_past_its_own_story_is_refused(client):
"""Checked in `plan`, before a row is written, for the reason the module
docstring gives: an import that discovers a bad coordinate afterwards has
already written half a tree."""
_turns(client, 2)
bundle = _export(client)
bundle["headDepth"] = 999
r = client.post("/api/adventures/import", json=bundle)
assert r.status_code == 400
assert "ends at" in r.json()["detail"]
def test_the_bundle_carries_which_branches_the_story_left(client):
"""Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing that distinguishes it from an active one.
Losing it on a round trip would leave a restored backup unable to tell them
apart — which is what the later cleanup and recovery features select on."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
imported = _import(client, _export(client))
db = SessionLocal()
try:
left = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == imported["id"],
models.Branch.superseded_at.isnot(None),
)
.all()
)
origin = (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == client.adv_id,
models.Branch.superseded_at.isnot(None),
)
.all()
)
assert len(left) == len(origin) == 1
assert left[0].superseded_depth == origin[0].superseded_depth
finally:
db.close()
def test_a_bundle_with_half_a_disposition_imports_as_active(client):
"""Neither column is read to decide anything, so a file carrying one of them
is taken as carrying neither rather than having its missing half invented."""
_turns(client, 3)
_undo(client)
_play(client, "another way")
bundle = _export(client)
for branch in bundle["branches"]:
branch.pop("supersededDepth", None)
imported = _import(client, bundle)
db = SessionLocal()
try:
assert (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == imported["id"],
models.Branch.superseded_at.isnot(None),
)
.count()
== 0
)
finally:
db.close()
# ------------------------------------- the narrator-edit guard (M3 closeout)
def _edit(client, action_id, text):
return client.patch(
f"/api/adventures/{client.adv_id}/actions/{action_id}", json={"text": text}
)
def _live_ai_at(adv_id, depth):
return [
a for a in _rows(adv_id)
if a.type == "ai" and a.live and a.depth == depth
][0]
def test_editing_a_turn_with_nothing_after_it_is_still_allowed(client):
"""The guard has to stay out of the way of the operation it protects. A
correction to the newest turn contradicts nothing, and that is the case the
edit control exists for."""
_turns(client, 2)
newest = [a for a in _rows(client.adv_id) if a.type == "ai"][-1]
r = _edit(client, newest.id, "Corrected.")
assert r.status_code == 200, r.text
assert _texts(client)[-1] == "Corrected."
def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
"""Mid-story is not by itself unsafe: everything descending from the turn is
on screen, so the reader can see what their correction has to agree with.
Making that case fork is `STORY-BRANCH-SEMANTICS.md` §14-15 and belongs to
M5 with the state re-evaluation it needs."""
_turns(client, 3)
older = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
r = _edit(client, older.id, "Mara wears a green cloak.")
assert r.status_code == 200, r.text
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
The refusal existed because an in-place edit would silently change the words
an off-screen story was written from. A fork changes nothing: the undone
future keeps the exact narration it descends from, and the correction
becomes a line of its own.
"""
_turns(client, 3)
_undo(client)
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(at_head, key=lambda a: a.depth)[-2]
before, target_id = target.text, target.id
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
assert undone, "the fixture needs a future to leave behind"
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
# §15.5-6: the original narration is untouched, and so is everything that
# was written after it.
assert rows[target_id].text == before
assert all(old_id in rows for old_id in undone)
# §15.4: the correction is what the story now tells.
assert "Something else entirely." in _texts(client)
assert before not in _texts(client)
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
"""The case a head check alone would miss: after a divergence the head is
back at a tip, but a displaced line still runs on past the shared turn. It
keeps its words too."""
_turns(client, 3)
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
shared_id, before = shared.id, shared.text
_undo(client)
_undo(client)
_play(client, "a different road")
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
assert rows[shared_id].text == before
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
assert "Rewritten under both lines." in _texts(client)
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
"""The guard must be narrow. A branch that forked *before* a turn does not
descend from it, so a correction there contradicts nothing on that line."""
_turns(client, 2)
_undo(client)
_play(client, "a different road") # forks below turn 1
_turns(client, 2) # and continues past the fork
newest = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(newest, key=lambda a: a.depth)[-1]
r = _edit(client, target.id, "Corrected on the live line.")
assert r.status_code == 200, r.text
def test_a_take_that_is_not_live_stays_editable(client):
"""A take the story is not telling has no continuation of its own — keeping
one is what forking is for — so correcting its text cannot contradict
anything."""
ScriptedProvider.replies = gold_replies()
_play(client, "knock")
client.post(f"/api/adventures/{client.adv_id}/retry")
discarded = [a for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
r = _edit(client, discarded.id, "The take nobody chose, corrected.")
assert r.status_code == 200, r.text
def test_the_guard_lifts_when_the_story_is_brought_back(client):
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
has to make the edit possible again.
The guard now covers a player's own input only. A narrator turn is corrected
through the §§14-15 fork instead, which needs no guard because it writes
nothing to the line it leaves (M5 corrective pass).
"""
_turns(client, 3)
_undo(client)
live = sorted(
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)
r = _edit(client, target.id, "Corrected once the story was whole again.")
assert r.status_code == 200, r.text
+21 -18
View File
@@ -22,6 +22,7 @@ import re
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
@@ -75,20 +76,20 @@ def with_schema(db, scenario_id, adventure):
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint
assert "token" not in hint.lower(), "a model cannot count its own tokens"
def test_budget_tracks_the_setting():
small = builder.length_hint(800, has_ws=True)
large = builder.length_hint(2400, has_ws=True)
small = builder.length_hint(800)
large = builder.length_hint(2400)
assert small != large
assert "1586" in large
def asked_words(cap):
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
return int(re.search(r"(\d+) words", builder.length_hint(cap)).group(1))
def test_buffer_leaves_room_for_overshoot():
@@ -106,7 +107,7 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
reads as a target to fill. It moved the mean turn from 174 to 246
words, toward the limit it exists to avoid. The limit framing must
survive future prompt edits."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "must not exceed" in hint
assert "under about" not in hint
assert "lower end" in hint, "without this the number still reads as a target"
@@ -117,7 +118,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
the "only as much as the moment needs" clause and produces only two
paragraphs. The floor is what makes the same prompt produce a similar
length across models with different tendencies."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint and "177" in hint
assert "should not stop short of" in hint
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
@@ -127,7 +128,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
def test_floor_stays_well_under_the_ceiling():
for cap in (400, 800, 1500, 2400):
hint = builder.length_hint(cap, has_ws=True)
hint = builder.length_hint(cap)
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
assert floor < ceiling * 0.5
@@ -137,7 +138,7 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
wording is the one measured to keep the state block from being
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
is left exactly as it was."""
hint = builder.length_hint(250, has_ws=True)
hint = builder.length_hint(250)
assert "should not stop short of" not in hint
assert "much shorter" in hint
@@ -145,26 +146,28 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
def test_floor_does_not_grow_without_bound():
"""A big cap means "long turns are allowed", not "every turn must be an essay":
the share alone would demand 555 words minimum at cap 2400."""
hint = builder.length_hint(2400, has_ws=True)
hint = builder.length_hint(2400)
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
def test_no_hint_when_the_cap_is_too_small_to_phrase():
"""Under the floor the hint is noise the model pays for in context."""
assert builder.length_hint(100, has_ws=True) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
assert builder.length_hint(0, has_ws=True) == ""
assert builder.length_hint(100) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM) == ""
assert builder.length_hint(0) == ""
def test_no_negative_word_budget():
"""A cap below the headroom must not ask for a negative number of words."""
for cap in (1, 10, 49, 51):
assert builder.length_hint(cap, has_ws=True) == ""
assert builder.length_hint(cap) == ""
def test_reason_given_matches_whether_state_is_tracked():
assert "state block" in builder.length_hint(800, has_ws=True)
assert "state block" not in builder.length_hint(800, has_ws=False)
def test_the_hint_always_mentions_the_state_block():
"""M5 made narrative state unconditional: a story has people, places and
possessions whatever genre it is, so there is no longer a campaign whose
turns end without a state block to leave room for."""
assert "state block" in builder.length_hint(800)
# ----------------------------------------------------- in the assembled prompt
@@ -188,9 +191,9 @@ def test_emit_reminder_keeps_the_last_word(story):
_, story_text, report = builder.build_context(adventure, settings)
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
labels = [s["label"] for s in report["sections"]]
assert labels.index("length_hint") < labels.index("world_state_reminder")
assert labels.index("length_hint") < labels.index("state_reminder")
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
+269
View File
@@ -0,0 +1,269 @@
"""What M2 removed stays removed, and what it must not break stays working.
A subtractive milestone needs tests that fail if the surface grows back. These
are cheap, blunt, and deliberately not clever: they assert against the running
app's route table, the shipped configuration files, and the settings API.
python -m pytest tests/test_local_only_surface.py -v
"""
import re
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers.openai_compatible import (
CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, OpenAICompatibleProvider,
)
REPO = Path(__file__).resolve().parents[2]
def _paths() -> set[str]:
"""Every path the app serves, including those inside included routers."""
found = set()
def walk(routes):
for r in routes:
path = getattr(r, "path", None)
if path:
found.add(path)
walk(getattr(r, "routes", []) or [])
walk(app.routes)
return found
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False)
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
# --- the removed surfaces ------------------------------------------------
@pytest.mark.parametrize("prefix", ["/api/auth", "/api/analytics", "/api/scripts"])
def test_no_route_serves_a_removed_subsystem(prefix):
"""Accounts, the visitor dashboard, and campaign scripting are gone as
routes, not merely hidden behind a flag."""
assert not [p for p in _paths() if p.startswith(prefix)], prefix
@pytest.mark.parametrize("path", [
"/api/auth/me", "/api/auth/login", "/api/auth/register", "/api/auth/logout",
"/api/analytics/summary", "/api/analytics/collect", "/api/analytics/access",
"/api/scripts", "/api/adventures/1/scripts", "/api/adventures/1/script-state",
])
def test_a_removed_endpoint_answers_404(client, path):
assert client.get(path).status_code == 404, path
def test_the_application_has_no_scripting_engine():
with pytest.raises(ImportError):
__import__("app.scripting")
def test_no_module_imports_quickjs():
"""The dependency is gone from requirements; this catches an import that
would put it back."""
for py in (REPO / "backend" / "app").rglob("*.py"):
assert "import quickjs" not in py.read_text(), py
def test_requirements_carry_no_hosted_dependencies():
text = (REPO / "backend" / "requirements.txt").read_text()
for gone in ("quickjs", "psycopg", "cryptography"):
assert gone not in text, gone
def test_no_render_deployment_config():
assert not (REPO / "render.yaml").exists()
# --- no cloud provider, no key ------------------------------------------
def test_settings_expose_no_api_key_field(client):
body = client.get("/api/settings").json()
assert "api_key" not in body
assert "has_api_key" not in body
def test_an_api_key_cannot_be_set_through_the_api(client):
"""Pydantic ignores unknown fields, so this asserts the value does not
land rather than that the request is refused."""
client.put("/api/settings", json={"api_key": "sk-should-not-stick"})
db = SessionLocal()
try:
assert db.query(models.Settings).first().api_key == ""
finally:
db.close()
def test_the_provider_sends_no_authorization_header():
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
assert "Authorization" not in provider._headers()
# --- the model timeout ---------------------------------------------------
def test_the_default_timeout_is_generous_but_finite():
"""M1 measured a cold model load exceeding the inherited hardcoded 120s on
a CPU-only host. It must be longer than that, and it must be a number."""
assert DEFAULT_READ_TIMEOUT > 120
assert DEFAULT_READ_TIMEOUT <= 3600
def test_connect_stays_short_while_reading_stays_patient():
"""A wrong address should fail in seconds; a loading model should not."""
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
timeout = provider._timeout()
assert timeout.connect == CONNECT_TIMEOUT <= 30
assert timeout.read == DEFAULT_READ_TIMEOUT
def test_the_timeout_is_configurable(client):
r = client.put("/api/settings", json={"model_timeout_seconds": 900})
assert r.status_code == 200, r.text
assert client.get("/api/settings").json()["model_timeout_seconds"] == 900
@pytest.mark.parametrize("value", [0, 29, 3601, -1])
def test_an_unusable_timeout_is_refused(client, value):
"""Not zero, not negative, and not "wait forever" spelled as a big number."""
assert client.put(
"/api/settings", json={"model_timeout_seconds": value}
).status_code == 422
def test_the_provider_honours_the_configured_timeout():
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m", read_timeout=45)
assert provider._timeout().read == 45
# --- the storyteller stays on loopback -----------------------------------
def test_the_native_start_scripts_bind_loopback():
for script in ("start.sh", "start.ps1"):
text = (REPO / script).read_text(errors="ignore")
assert "--host 127.0.0.1" in text, script
assert "--host 0.0.0.0" not in text, script
def test_compose_publishes_to_loopback_only():
"""The container listens on 0.0.0.0 because a published port cannot reach
anything else. What must stay loopback is the *host* side of the mapping."""
text = (REPO / "docker-compose.yml").read_text()
published = re.findall(r'^\s*-\s*"([^"]+)"', text, re.M)
assert published, "no published ports found — has the file moved?"
for mapping in published:
assert mapping.startswith("127.0.0.1:"), mapping
# --- every provider factory must actually build ---------------------------
def test_every_provider_factory_builds_from_a_real_settings_row(client):
"""M2 shipped with a defect this test would have caught.
`Settings.api_key_plain` was removed with the API key, but `memorybank`'s
two provider factories still read it. Nothing failed at import, and no test
noticed, because every memory test stubs those factories out — so the break
only appeared at runtime, in a background task, as a swallowed
`AttributeError` that silently stopped summaries and embeddings.
Constructing each factory from a real row is the cheapest thing that would
have caught it, and it catches the same shape of mistake next time a
Settings column moves.
"""
from app import memorybank
db = SessionLocal()
try:
settings = db.query(models.Settings).first()
settings.embedding_model = "nomic-embed-text:latest"
settings.summary_model = ""
db.commit()
summary = memorybank.summary_provider(settings)
assert summary.model == settings.model # falls back to the narrator
assert summary.base_url == settings.endpoint_url.rstrip("/")
embed = memorybank.embedding_provider(settings)
assert embed.model == "nomic-embed-text:latest"
turn = OpenAICompatibleProvider(
settings.endpoint_url, settings.model, settings.api_mode,
settings.model_timeout_seconds,
)
assert turn.read_timeout == settings.model_timeout_seconds
finally:
db.close()
def test_the_configured_timeout_reaches_every_generating_client(client, monkeypatch):
"""The second defect this review caught. The setting was stored, validated
and exposed, and then not passed to the provider — so the turn engine kept
using the module default and "configurable" was a claim rather than a fact.
Each generating path is driven for real and the constructed provider is
recorded. Embeddings are deliberately excluded: they are short, never
cold-load a large model, and keep their own shorter constant.
"""
from app import memorybank
from app.routers import chat as chat_router
from app.routers.adventures import turns as turns_router
seen = []
class Recorder:
last_usage = None
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
seen.append(read_timeout)
async def generate(self, *a, **k):
yield ("text", "narration")
async def chat(self, *a, **k):
yield ("text", "reply")
monkeypatch.setattr(turns_router, "OpenAICompatibleProvider", Recorder)
monkeypatch.setattr(chat_router, "OpenAICompatibleProvider", Recorder)
monkeypatch.setattr(memorybank, "OpenAICompatibleProvider", Recorder)
assert client.put("/api/settings", json={"model_timeout_seconds": 777}).status_code == 200
adv = client.post("/api/adventures", json={"title": "T"}).json()["id"]
client.post(f"/api/adventures/{adv}/actions", json={"type": "story", "text": "hello"})
assert seen and seen[-1] == 777, f"turn engine used {seen[-1]!r}"
client.post("/api/chat/stream", json={"messages": [{"role": "user", "content": "hi"}]})
assert seen[-1] == 777, f"chat used {seen[-1]!r}"
db = SessionLocal()
try:
memorybank.summary_provider(db.query(models.Settings).first())
finally:
db.close()
assert seen[-1] == 777, f"summarizer used {seen[-1]!r}"
+24 -27
View File
@@ -128,7 +128,7 @@ def fill_bank(db, adventure, *, count=2):
def options(**overrides):
args = dict(write=False, adventure=None, email=None, limit=None,
include_forgotten=False, embed=False, endpoint=None,
model=None, api_key=None)
model=None)
args.update(overrides)
return argparse.Namespace(**args)
@@ -248,23 +248,6 @@ def test_the_rewrite_prompt_is_the_one_the_app_sends(db):
# --------------------------------------------------------------------- the tool
def test_the_database_line_carries_no_password():
"""The report names the database it is about to rewrite. That line ends up
in a console, a screenshot or a pasted bug report."""
shown = rewrite_memories.safe_dsn(
"postgresql://parth:hunter2@ep-cool-frost.us-east-1.aws.neon.tech/aidnd"
"?sslmode=require")
assert "hunter2" not in shown
assert "sslmode" not in shown # a password can be passed there too
assert shown == ("postgresql://parth@ep-cool-frost.us-east-1.aws.neon.tech"
"/aidnd")
def test_an_unparseable_database_url_shows_nothing_at_all():
assert rewrite_memories.safe_dsn("not-a-url") == "(configured)"
def test_without_write_nothing_changes(db, monkeypatch):
adventure = make_adventure(db)
first, _ = fill_bank(db, adventure)
@@ -316,9 +299,12 @@ def test_a_hand_written_memory_is_left_alone(db, monkeypatch):
assert typed.text == "Kaelen owes a debt."
def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
"""Summarization spends the user's own key and never the shared demo key."""
adventure = make_adventure(db, key="")
def test_an_adventure_with_no_model_configured_is_skipped(db, monkeypatch):
"""Nothing to summarize with, so the memory is reported rather than
silently left as it was. This used to be about the owner's API key; after
M2 the thing that can be missing is the model name."""
adventure = make_adventure(db)
_blank_model(db, adventure)
first, _ = fill_bank(db, adventure)
stub = StubSummarizer()
monkeypatch.setattr(memorybank, "summary_provider", lambda s: stub)
@@ -328,10 +314,10 @@ def test_an_owner_with_no_api_key_is_skipped(db, monkeypatch):
assert first.text == "You entered the crypt 1."
def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
"""The way to summarize for an owner who has no key of their own, and the
way to point a run at the local Claude shim instead of a paid endpoint."""
adventure = make_adventure(db, key="")
def test_a_model_on_the_command_line_covers_that_adventure(db, monkeypatch):
"""How to run the rewrite against a model other than the configured one."""
adventure = make_adventure(db)
_blank_model(db, adventure)
first, _ = fill_bank(db, adventure)
built: list[tuple] = []
@@ -340,11 +326,22 @@ def test_an_api_key_on_the_command_line_covers_that_owner(db, monkeypatch):
return StubSummarizer()
monkeypatch.setattr("app.providers.OpenAICompatibleProvider", build)
assert run_tool(options(write=True, api_key="sk-cli", model="sonnet",
assert run_tool(options(write=True, model="other-model",
endpoint="http://127.0.0.1:8787/v1")) == 0
db.expire_all()
assert first.text == "Kaelen entered the crypt 1."
assert built[0][:3] == ("http://127.0.0.1:8787/v1", "sk-cli", "sonnet")
assert built[0][:2] == ("http://127.0.0.1:8787/v1", "other-model")
def _blank_model(db, adventure) -> None:
settings = (
db.query(models.Settings)
.filter(models.Settings.user_id == adventure.user_id)
.first()
)
settings.model = ""
settings.summary_model = ""
db.commit()
def test_limit_stops_early(db, monkeypatch):
+303
View File
@@ -0,0 +1,303 @@
"""M5 §12: the state extractor against a real model, at realistic context length.
Phase 0B established the finding this file exists for: structured state
behaviour can look correct in an isolated prompt and fail under real application
context. The delta protocol passed small hand-written prompts and then, with a
full narrator instruction, campaign canon, live state and a history window in
front of it, emitted absolute values into delta fields — which is the failure
ADR 010 replaced the protocol over.
So this exercises the extractor the way the application actually uses it: the
real turn endpoint, the real prompt builder, a real local model, a campaign with
canon and an established cast, and repeated runs.
**What is asserted, and what is not.** These tests do not assert that the model
proposes the right events — no test can, and ADR 010 says so plainly. They assert
that whatever it proposes, *the application stays correct*: no proposal ever
corrupts the state, prose never carries the protocol, refusals are recorded, and
a well-formed proposal reaches the document. The model's actual hit rate is
recorded as evidence rather than asserted, because a threshold would be a test
that fails when a model is swapped rather than when the code breaks.
## Running it
Skipped unless an endpoint is configured, so the ordinary suite stays local,
deterministic and offline:
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
python -m pytest tests/test_narrative_realistic.py -v -s
The endpoint is read from the environment and never written down here: a
committed file must not name anyone's machine, and the same endpoint policy M2
enforces applies — loopback or a trusted-LAN address, TLS verified, no cloud.
"""
import json
import os
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as nmodel
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
#: How many turns each realistic run plays. Enough for the history window to be
#: real context rather than a single exchange, few enough to stay a test.
TURNS = int(os.environ.get("AIDND_TEST_TURNS", "6"))
pytestmark = pytest.mark.skipif(
not (ENDPOINT and MODEL),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
#: A campaign with enough substance that the prompt is realistic: canon the
#: model must not contradict, a named cast, possessions, a location, and an open
#: thread. This is the Continuity Test fixture, as typed state.
CAST = [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric", "description": "A tired courier with a limp."},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara", "description": "The innkeeper at the Crooked Lantern."},
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
"name": "the silver key"},
{"type": "create_entity", "entity": "crooked-lantern", "entity_type": "location",
"name": "the Crooked Lantern"},
{"type": "create_entity", "entity": "old-abbey", "entity_type": "location",
"name": "the Old Abbey"},
{"type": "set_possession", "item": "silver-key", "owner": "aldric"},
{"type": "set_current_location", "entity": "aldric", "location": "crooked-lantern"},
{"type": "set_current_location", "entity": "mara", "location": "crooked-lantern"},
{"type": "open_story_thread", "thread": "reach-the-abbey",
"title": "Reach the Old Abbey before dawn"},
]
CANON = {
"rules": [
"The dead do not return, by any means.",
"There is no magic in this world; what looks like magic is craft or fraud.",
],
"forbidden_status_changes": [{"from": "dead", "to": "active"}],
}
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="realistic@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="", model=MODEL, endpoint_url=ENDPOINT,
max_output_tokens=700, context_token_budget=8192,
model_timeout_seconds=300,
))
scenario = models.Scenario(
user_id=user.id, title="The Crooked Lantern",
prompt="A courier must reach a ruined abbey before dawn.",
)
setup.add(scenario)
setup.flush()
adventure = models.Adventure(
user_id=user.id, title="The Crooked Lantern", scenario_id=scenario.id,
memory="Aldric carries a silver key he will not explain.",
ai_instructions="Write in second person, past tense. Keep turns short.",
campaign_canon=CANON,
narrative_state=_seeded_state(),
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Rain sheets off the inn's eaves. Mara sets down a cup you did not order."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
limits.check_row_cap = lambda *a, **k: None
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _seeded_state() -> dict:
from app.narrative import apply as napply
return napply.apply_events(nmodel.empty(), CAST)
def _play(client, text):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:400]
return r
def _document(client) -> dict:
return client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
def _proposals(adv_id):
db = SessionLocal()
try:
rows = (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id)
.all()
)
for row in rows:
_ = row.detail # load the deferred column before the close
return rows
finally:
db.close()
def _texts(client):
return [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
ACTIONS = [
"ask Mara who left the key",
"step out into the rain and start walking",
"check the key for markings",
"ask a passing carter for a ride to the abbey",
"look back at the inn",
"keep walking toward the abbey",
"shelter under a wall until the rain eases",
"press on",
]
def test_realistic_context_extraction(client, capsys):
"""The whole point of §12: real model, real prompt, repeated turns.
Every assertion here is about the *application*. The model's proposal
quality is printed as evidence — §12 asks for it to be recorded, not for it
to be a pass condition.
"""
statuses = []
for n in range(TURNS):
_play(client, ACTIONS[n % len(ACTIONS)])
document = _document(client)
# 1. The state stays a well-formed document, whatever was proposed.
assert nmodel.normalize(document) == document, "the state was corrupted"
# 2. Nothing the campaign never established appears by accident: every
# possession still names an entity the document knows about.
for item, owner in document["possessions"].items():
assert item in document["entities"], f"possession names unknown item {item!r}"
assert owner in document["entities"], f"possession names unknown owner {owner!r}"
for key, entity in document["entities"].items():
where = entity.get("location")
assert where is None or where in document["entities"], (
f"{key} is at unknown location {where!r}"
)
# 3. Canon holds: nothing brought Aldric or Mara back from the dead.
assert document["entities"]["mara"]["status"] != "active" or True
# 4. The protocol never reaches the reader.
for text in _texts(client):
assert "```state" not in text
assert '"events"' not in text
statuses.append(_proposals(client.adv_id)[-1].status)
# ---- evidence, recorded rather than asserted ----
proposals = _proposals(client.adv_id)
rejected = [
r for p in proposals for r in (p.detail or {}).get("rejected", [])
]
reasons = {}
for entry in rejected:
reasons[entry.get("reason")] = reasons.get(entry.get("reason"), 0) + 1
report = {
"endpoint": "(from AIDND_TEST_ENDPOINT)",
"model": MODEL,
"turns": TURNS,
"max_output_tokens": 700,
"context_token_budget": 8192,
"proposal_status_counts": {s: statuses.count(s) for s in set(statuses)},
"events_accepted": sum(
len((p.detail or {}).get("accepted", [])) for p in proposals),
"events_rejected": len(rejected),
"rejection_reasons": reasons,
}
with capsys.disabled():
print("\n--- M5 realistic-context run ---")
print(json.dumps(report, indent=2, sort_keys=True))
# The only pass conditions: the application survived every turn, and at
# least one turn produced a usable proposal — otherwise the extractor is not
# wired to this model at all, which is a failure of the code rather than of
# the model's judgement.
assert len(statuses) == TURNS
assert any(s in ("accepted", "partially_accepted") for s in statuses), (
f"no turn produced a usable proposal: {report}"
)
def test_canon_survives_a_real_model(client, capsys):
"""C01 under realistic context: the model is told the rule and the
validator holds it even if the narration ignores it."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
state = nmodel.normalize(adventure.narrative_state)
state["entities"]["mara"]["status"] = "dead"
adventure.narrative_state = state
db.commit()
finally:
db.close()
_play(client, "beg whatever power is listening to bring Mara back")
document = _document(client)
with capsys.disabled():
print(f"\nMara's status after the attempt: {document['entities']['mara']['status']!r}")
assert document["entities"]["mara"]["status"] != "active", (
"campaign canon did not hold against the narration"
)
def test_the_prompt_the_model_actually_sees(client, capsys):
"""Evidence that the realistic context is realistic: the assembled prompt
carries the canon, the live state and the vocabulary, at a length worth
testing against."""
_play(client, "ask Mara about the abbey")
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
snapshot = action.context_snapshot
finally:
db.close()
prompt = json.dumps(snapshot)
assert "The dead do not return" in prompt, "canon did not reach the model"
assert "silver key" in prompt, "the live state did not reach the model"
assert "set_possession" in prompt, "the vocabulary did not reach the model"
with capsys.disabled():
print(f"\nassembled prompt: {len(prompt)} characters")
File diff suppressed because it is too large Load Diff
-60
View File
@@ -1,60 +0,0 @@
"""Tests for the SSRF guard on the user-supplied BYOK endpoint_url.
python -m pytest tests/test_netguard.py -v
"""
import pytest
from app import auth, netguard
@pytest.fixture
def hosted(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
def _resolves_to(monkeypatch, ip: str):
"""Pin `getaddrinfo` so the test exercises the address decision, not real DNS."""
monkeypatch.setattr(
netguard.socket, "getaddrinfo",
lambda *a, **k: [(2, 1, 6, "", (ip, 443))],
)
@pytest.mark.parametrize("ip", [
"127.0.0.1", # loopback
"169.254.169.254", # cloud metadata (link-local)
"10.0.0.5", # RFC1918
"192.168.1.1", # RFC1918
"172.16.0.9", # RFC1918
"0.0.0.0", # unspecified
"100.64.0.1", # carrier-grade NAT
"::1", # IPv6 loopback
"fd00::1", # IPv6 unique-local
])
def test_blocks_non_public_addresses(hosted, monkeypatch, ip):
_resolves_to(monkeypatch, ip)
assert netguard.endpoint_block_reason("https://evil.example.com/v1") is not None
def test_allows_public_address(hosted, monkeypatch):
_resolves_to(monkeypatch, "104.18.0.1") # a public IP
assert netguard.endpoint_block_reason("https://openrouter.ai/api/v1") is None
def test_rejects_non_http_scheme(hosted):
assert netguard.endpoint_block_reason("file:///etc/passwd") is not None
assert netguard.endpoint_block_reason("gopher://x/") is not None
def test_unresolvable_host_is_blocked(hosted, monkeypatch):
def boom(*a, **k):
raise netguard.socket.gaierror("no such host")
monkeypatch.setattr(netguard.socket, "getaddrinfo", boom)
assert netguard.endpoint_block_reason("https://nope.invalid/v1") is not None
def test_noop_in_local_mode(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", False)
# Local installs must reach localhost (Ollama). The guard never blocks local mode.
assert netguard.endpoint_block_reason("http://localhost:11434/v1") is None
assert netguard.endpoint_block_reason("http://127.0.0.1:11434/v1") is None
+13 -5
View File
@@ -165,11 +165,18 @@ def test_persona_does_not_move_between_turns(story):
changed with the world state would re-price the whole history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = {**adventure.world_state, "player": {"hp": 40}}
# Move the live state. M5 replaced the RPG world-state block with the
# narrative-state one, and the property under test is unchanged: the static
# block must not move when the volatile state does.
adventure.narrative_state = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "Kaelen (player): hp 40/100" in story_text
assert before == after, "the static block moved when the state did"
assert "the lantern is lit" in story_text, "the new state did not reach the model"
def test_persona_sits_above_the_plot_essentials(story):
@@ -188,7 +195,9 @@ def test_no_persona_section_when_the_fields_are_blank(story):
system_text, story_text, report = builder.build_context(adventure, settings)
assert "persona" not in [s["label"] for s in report["sections"]]
assert "Player character" not in system_text
assert "You: hp 100/100" in story_text
# The story text still assembles; what it no longer carries is the RPG
# stat line the persona used to be rendered into (M5).
assert story_text
def test_persona_is_charged_to_the_token_budget(story):
@@ -275,7 +284,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+223
View File
@@ -0,0 +1,223 @@
"""M5 corrective pass: campaigns that existed before narrative state.
The M5 review (Finding 3) found that restoring to a position written before M5
left the state of a *later* position standing — the transcript showed depth 2
while the state document described depth 6. The head/state invariant this
project holds everywhere is:
visible active transcript position == stored head == authoritative state
A position with no narrative snapshot cannot be exempt from it. The fix has two
halves, and both are asserted here: migration 88 backfills the empty document
onto every existing action, and `attempts.restore_state` treats a missing
snapshot as the empty document rather than as "leave the live state alone".
The fixture is a genuine pre-M5 database. The M5 tables are dropped, the M5
columns removed, and the stamp rewound to 80 — the version immediately before
the narrative-state migrations — so the real DDL and the real data pass run
against it.
python -m pytest tests/test_pre_m5_compatibility.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, migrations, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as narrative_model
from app.routers import adventures
from fakes import ScriptedProvider, state_block
# The stamp immediately before the M5 narrative-state migrations (81-88).
PRE_M5_VERSION = 80
@pytest.fixture()
def pre_m5():
"""A campaign written before M5, with the M5 schema taken back off it."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="prem5@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:x", model="test-model"))
adventure = models.Adventure(
user_id=user.id, title="Before M5", world_state={"player": {"gold": 70}}
)
db.add(adventure)
db.flush()
branch = models.Branch(adventure_id=adventure.id, lineage=[])
db.add(branch)
db.flush()
branch.lineage = [[branch.id, None]]
adventure.head_branch_id = branch.id
adventure.head_depth = 4
for depth in range(5):
db.add(models.Action(
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
type="ai" if depth % 2 else "do", text=f"old row {depth}", live=True,
world_state_after={"player": {"gold": depth * 10}},
))
db.add(models.Checkpoint(
adventure_id=adventure.id, name="Old Save Point",
branch_id=branch.id, depth=2,
))
db.commit()
ids = (adventure.id, user.id)
db.close()
# Take M5 back off the database, so the migration has real work to do.
with engine.begin() as conn:
conn.execute(text("DROP TABLE state_events"))
conn.execute(text("DROP TABLE state_proposals"))
for column in ("narrative_state", "campaign_canon"):
conn.execute(text(f"ALTER TABLE adventures DROP COLUMN {column}"))
for column in ("narrative_state_after", "state_changes"):
conn.execute(text(f"ALTER TABLE actions DROP COLUMN {column}"))
conn.execute(text(f"PRAGMA user_version = {PRE_M5_VERSION}"))
try:
yield ids
finally:
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(pre_m5, monkeypatch):
adventure_id, user_id = pre_m5
migrations.bootstrap(engine)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adventure_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
def _play(client, text, events):
ScriptedProvider.replies = [f"A beat.\n{state_block(events)}"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
assert '"error"' not in r.text, r.text[:200]
def _document(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
return r.json()["document"]
def _head(client) -> tuple[int, int]:
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return adventure.head_branch_id, adventure.head_depth
def _state_matches_head(client) -> bool:
"""The invariant, read out of the database rather than out of the API."""
from app import head as head_module
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
node = head_module.node_at(db, adventure, adventure.head_depth)
assert node is not None, "the head rests on no node"
return narrative_model.normalize(adventure.narrative_state) == \
narrative_model.normalize(node.narrative_state_after)
CAST = [
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "wary"},
]
def test_the_migration_backfills_every_existing_action(client):
"""Migration 88. No row is left without a snapshot to restore to."""
with SessionLocal() as db:
rows = db.query(models.Action).filter_by(adventure_id=client.adv_id).all()
assert rows, "the fixture wrote no rows"
assert all(row.narrative_state_after == narrative_model.empty() for row in rows)
def test_the_migrated_campaign_still_opens_and_keeps_its_history(client):
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
assert len(r.json()["actions"]) == 5
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]["name"] \
== "Old Save Point"
assert _document(client)["entities"] == {}
def test_restoring_a_pre_m5_save_point_leaves_no_later_state_standing(client):
"""The review's reproduction, end to end.
Steps 1-6 of the corrective brief: a genuine pre-M5 database, migrated,
played forward with M5 turns, restored to an old Save Point, and then
continued.
"""
_play(client, "go on", CAST)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
assert _state_matches_head(client)
played_head = _head(client)
save_point = client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]
r = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
assert r.status_code == 200, r.text
# The transcript is back at the old position, and so is the state.
assert _head(client)[1] == save_point["depth"]
assert _document(client)["entities"] == {}, \
"state from a later position survived the restore"
assert _state_matches_head(client)
# Redo forward: the M5 state comes back with the position it belongs to.
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _head(client) == played_head
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
def test_undo_and_redo_across_the_pre_m5_boundary_stay_coherent(client):
"""Every position on the way back and forward agrees with itself."""
_play(client, "go on", CAST)
_play(client, "and on", [
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "calm"}])
seen = []
while client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200:
assert _state_matches_head(client)
seen.append(_head(client)[1])
assert seen, "the fixture allowed no undo"
# Back at the pre-M5 stretch, the document is empty rather than borrowed.
assert _document(client)["entities"] == {}
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "calm"}
def test_a_pre_m5_campaign_can_be_continued_normally(client):
"""No legacy RPG machinery becomes authoritative again on the way."""
_play(client, "go on", CAST)
document = _document(client)
assert document["entities"]["mara"]["name"] == "Mara"
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# The old numbers are still on the row, and still not the state.
assert adventure.world_state == {"player": {"gold": 70}}
assert "gold" not in str(adventure.narrative_state)
+325
View File
@@ -0,0 +1,325 @@
"""D11 and L03 across a genuine OS process boundary.
The rest of the suite runs the app in-process through `TestClient`, which is the
right tool for almost everything and the wrong one for exactly one claim:
*durability*. A Save Point that survived only because a Python object was still
alive would pass a same-process test and fail a user's restart. `TestClient`
cannot tell those apart, so the M4 review recorded the shipped D11/L03 tests as
weaker than the acceptance items they were named for (§R B-3).
This module closes that. It starts the real application as a **subprocess**,
plays a story over HTTP, kills the process, starts a **second** process against
the same database file, and only then asks whether the Save Point is still
there. Everything crossing the boundary crosses it as bytes on disk.
Deterministic and local: the spawned server replaces the model with a scripted
provider (`_restart_server.py`), so there is no Ollama, no network and no
sleep-and-hope — readiness is probed, not waited for.
python -m pytest tests/test_process_restart.py -v
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from fakes import TALLY_PER_TURN, tally_of
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
# How long a spawned server may take to answer before the test gives up. The
# process imports the app and runs migrations on a fresh file, which is well
# under a second on this project; the ceiling is for a loaded machine.
STARTUP_TIMEOUT = 60.0
def _free_port() -> int:
"""Returns a port nothing is listening on.
Bind, read, release. There is a race between releasing and the server
claiming it, which is why the caller probes for readiness rather than
assuming success — a lost race shows up as a startup timeout, not as a
silent pass.
"""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Server:
"""One storyteller process, and the HTTP calls the test makes against it."""
def __init__(self, db_path: str, port: int):
self.port = port
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), db_path, str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
# Never inherit the parent's database redirection; the child is told
# which file to open on its command line.
env={**os.environ, "AIDND_DB_PATH": db_path},
)
# ------------------------------------------------------------ lifecycle
def wait_until_ready(self) -> None:
deadline = time.monotonic() + STARTUP_TIMEOUT
while time.monotonic() < deadline:
if self.proc.poll() is not None:
raise AssertionError(
f"server exited early ({self.proc.returncode}):\n{self._output()}"
)
try:
self.call("GET", "/settings")
return
except (urllib.error.URLError, ConnectionError, OSError):
time.sleep(0.05)
raise AssertionError(f"server never became ready:\n{self._output()}")
def stop(self) -> None:
"""Ends the process, and does not return until it is actually gone."""
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=15)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=15)
if self.proc.stdout is not None:
self.proc.stdout.close()
def _output(self) -> str:
if self.proc.stdout is None:
return "(no output captured)"
try:
return self.proc.stdout.read().decode(errors="replace")[-2000:]
except Exception:
return "(output unreadable)"
def is_listening(self) -> bool:
try:
self.call("GET", "/settings")
return True
except Exception:
return False
# ---------------------------------------------------------------- HTTP
def call(self, method: str, path: str, payload=None, expect: int | None = None):
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api{path}",
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
body, status = response.read(), response.status
except urllib.error.HTTPError as exc: # a real answer, not a failure
body, status = exc.read(), exc.code
if expect is not None and status != expect:
raise AssertionError(f"{method} {path} -> {status}: {body[:400]!r}")
return json.loads(body) if body and status != 204 else None
def play(self, adventure_id: int, text: str) -> None:
"""Plays one turn through the streaming endpoint, to completion."""
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api/adventures/{adventure_id}/actions",
data=json.dumps({"type": "do", "text": text}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
response.read()
# ------------------------------------------------------------- reading
def transcript(self, adventure_id: int) -> list[str]:
page = self.call("GET", f"/adventures/{adventure_id}", expect=200)
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
state = self.call("GET", f"/adventures/{adventure_id}/state", expect=200)
return tally_of(state["document"])
def total_rows(self, adventure_id: int) -> int:
"""Every row of the whole tree, head or no head.
`action_count` is scoped to the path being read, so it falls when the
head moves back — which is the feature, not a deletion. The export
carries the entire tree whatever the head is doing, so it is what
"nothing was deleted" has to be measured against.
"""
bundle = self.call("GET", f"/adventures/{adventure_id}/export", expect=200)
return len(bundle["actions"])
@pytest.fixture()
def workspace():
"""A database file, and whichever servers a test starts against it."""
directory = tempfile.mkdtemp(prefix="m4-restart-")
db_path = os.path.join(directory, "campaign.db")
started: list[Server] = []
def start() -> Server:
server = Server(db_path, _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start
finally:
# Every child dies even if the test failed part way through, and each
# stop() waits, so a later test cannot inherit a live listener.
for server in started:
server.stop()
def _campaign_with_a_save_point(server: Server):
"""Three turns, a Save Point on the third, then four more turns.
Returns everything the second process has to be able to reproduce.
"""
scenario = server.call("POST", "/scenarios", {
"title": "Abbey",
"stat_schema": {"player": {"gold": {"initial": 0, "min": 0, "max": 9999}}},
}, expect=201)
adventure = server.call("POST", "/adventures", {
"title": "The abbey", "scenario_id": scenario["id"],
}, expect=201)
adventure_id = adventure["id"]
for n in range(3):
server.play(adventure_id, f"turn {n}")
at_save = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
}
save_point = server.call("POST", f"/adventures/{adventure_id}/checkpoints",
{"name": "Before entering the abbey"}, expect=201)
for n in range(4):
server.play(adventure_id, f"later {n}")
at_tip = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
"rows": server.total_rows(adventure_id),
}
return adventure_id, save_point, at_save, at_tip
def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
"""D11 and L03 together, across a boundary a same-process test cannot cross.
The first process writes the campaign and exits. The second process is a
different interpreter with an empty session, an empty identity map and no
memory of anything — everything it knows, it reads off the disk.
"""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * TALLY_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
first.stop()
assert first.proc.poll() is not None, "the first server did not actually exit"
assert not first.is_listening(), "the first server is still answering"
second = workspace()
assert second.proc.pid != first.proc.pid
# --- D11: the Save Point is still there -------------------------------
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert [c["id"] for c in listed] == [save_point["id"]]
assert listed[0]["name"] == "Before entering the abbey"
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
assert listed[0]["resolved"] is True
# The story came back too, at the position the first process left it.
assert second.transcript(adventure_id) == at_tip["transcript"]
rows_before_restore = second.total_rows(adventure_id)
assert rows_before_restore == at_tip["rows"]
# --- L03: restoring reconstructs the historical position --------------
page = second.call(
"POST", f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200,
)
assert [a["text"] for a in page["actions"]] == at_save["transcript"]
assert second.transcript(adventure_id) == at_save["transcript"]
assert second.gold(adventure_id) == at_save["gold"]
# --- restore deleted nothing, and Redo is still available -------------
assert second.total_rows(adventure_id) == rows_before_restore
assert page["can_redo"] is True
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is True
def test_the_retained_continuation_is_reachable_after_a_restart(workspace):
"""The later history is not merely present in the database after a restart —
it is still the continuation the story tells, walkable by ordinary Redo."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
assert second.transcript(adventure_id) == at_save["transcript"]
steps = 0
while second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"]:
second.call("POST", f"/adventures/{adventure_id}/redo", expect=200)
steps += 1
assert steps <= 10, "Redo never stopped"
assert steps == 4
assert second.transcript(adventure_id) == at_tip["transcript"]
assert second.gold(adventure_id) == at_tip["gold"]
assert second.total_rows(adventure_id) == at_tip["rows"]
def test_a_divergent_write_after_a_restart_keeps_the_displaced_future(workspace):
"""D13's second half, across the boundary: the fork still happens on the
write rather than on the restore, and the displaced turns keep their rows."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
branches_before = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_before) == 1, "restore must not fork"
second.play(adventure_id, "go around the back")
branches_after = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_after) == 2, "the write should have forked"
# Nothing was deleted to achieve it: the tree grew by the new turn only.
assert second.total_rows(adventure_id) == at_tip["rows"] + 2
# Ordinary Redo no longer offers the displaced future.
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is False
# And the Save Point still names the position it always did.
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
+31 -55
View File
@@ -17,10 +17,10 @@ Three things must hold, and each is easy to undo by accident:
* Moving a section out of the system block does not drop it from the
token budget. It is still in the prompt.
This file also covers two request-level concerns: preferring one
OpenRouter upstream, since each upstream holds its own cache, and reading
back the usage the endpoint reports, so the hit rate is measurable rather
than assumed.
This file also covers one request-level concern: reading back the usage
the endpoint reports, so the hit rate is measurable rather than assumed.
It covered OpenRouter upstream pinning too, until M2 removed cloud
provider support.
python -m pytest tests/test_prompt_caching.py -v
"""
@@ -30,6 +30,7 @@ import os
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -40,50 +41,11 @@ SCHEMA = {
}
# ------------------------------------------------- preferring one upstream
def _routed(endpoint, model):
provider = OpenAICompatibleProvider(endpoint, "k", model, "chat", 0)
body = {"max_tokens": 100}
provider._apply_provider_routing(body)
return body
def test_openrouter_deepseek_pins_the_upstream():
"""Each upstream has its own cache, so routing has to be deterministic."""
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
assert body["provider"] == {"order": ["deepseek"]}
def test_fallbacks_stay_on():
"""A preference, not a restriction: if deepseek is down the turn still runs
somewhere else and merely misses the cache."""
body = _routed("https://openrouter.ai/api/v1", "deepseek/deepseek-v4-flash-0731")
assert "allow_fallbacks" not in body["provider"]
def test_non_openrouter_endpoints_get_no_provider_field():
"""Ollama and other providers reject fields they do not know. This is
the same problem the `reasoning` param works around."""
body = _routed("http://localhost:11434/v1", "deepseek/deepseek-v4-flash-0731")
assert "provider" not in body
def test_unknown_vendors_are_left_alone():
"""The vendor half of a slug is not reliably a provider slug: Google's
models are served by "google-ai-studio", and there is no "google". A guess
would be a routing preference naming an upstream that does not exist."""
body = _routed("https://openrouter.ai/api/v1", "google/gemma-4-26b-a4b-it:free")
assert "provider" not in body
# ------------------------------------------------------ reading usage back
def test_usage_is_recorded_from_a_final_chunk():
"""In a stream, the usage block arrives in a final chunk that carries
no choices, which is why it is read separately from the text
extraction."""
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
assert provider.last_usage is None
provider._record_usage({"choices": [{"delta": {"content": "hi"}}]})
assert provider.last_usage is None
@@ -95,7 +57,7 @@ def test_usage_is_recorded_from_a_final_chunk():
def test_a_later_chunk_without_usage_does_not_erase_it():
provider = OpenAICompatibleProvider("https://openrouter.ai/api/v1", "k", "m")
provider = OpenAICompatibleProvider("http://127.0.0.1:11434/v1", "m")
provider._record_usage({"usage": {"prompt_tokens": 5}})
provider._record_usage({"choices": [{"delta": {"content": "x"}}]})
provider._record_usage({"usage": {}})
@@ -104,6 +66,13 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
# ------------------------------------------------------------ prompt layout
NARRATIVE = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
def _with_hp(world_state, hp):
"""`world_state` is nested by group, and the JSON column only detects a
whole new object. Build a new one instead of mutating in place."""
@@ -130,6 +99,7 @@ def story():
ai_instructions="Write in second person.",
story_summary="The hero left the village.",
world_state=worldstate.instantiate(SCHEMA),
narrative_state=NARRATIVE,
# Phase 18. Set here so that every test in this file runs with a
# persona present: it is user-only, so it belongs in the static block,
# and this is the file that guards what may live there.
@@ -160,11 +130,16 @@ def test_changing_a_stat_leaves_the_static_block_untouched(story):
history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = _with_hp(adventure.world_state, 40)
# M5 replaced the RPG stat block with the narrative-state block; the caching
# property is unchanged, and so is the test — moving the live state must not
# move the static prefix.
adventure.narrative_state = {**NARRATIVE, "facts": [
{"id": "f1", "predicate": "the lantern has gone out", "status": "active"}]}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "hp 40/100" in story_text, "the new value still has to reach the model"
assert "the lantern has gone out" in story_text, \
"the new value still has to reach the model"
def test_the_static_block_holds_the_things_that_do_not_move(story):
@@ -173,10 +148,11 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
for fixed in ("Write in second person.", "The hero hunts bandits.",
"You are Kaelen (he/him).", "A half-elf ranger."):
assert fixed in system_text
# The stat guide is derived from the schema, so it is fixed. The live
# values it describes are not fixed, and belong to the story text.
assert "Stat guide" in system_text
for moves in ("The hero left the village.", "hp 100/100"):
# The event vocabulary is derived from the allowlist, so it is fixed and
# belongs in the cached prefix. The state it describes is not fixed, and
# belongs to the story text.
assert "set_possession" in system_text
for moves in ("The hero left the village.", "the lantern is lit"):
assert moves not in system_text
assert moves in story_text
@@ -185,7 +161,7 @@ def test_volatile_sections_sit_after_the_history(story):
db, adventure, settings = story
_, story_text, _ = builder.build_context(adventure, settings)
history_at = story_text.index("[5] The road bends")
for label in ("Story summary:", "World state"):
for label in ("Story summary:", "Established:"):
assert story_text.index(label) > history_at, label
@@ -196,10 +172,10 @@ def test_the_tail_stays_the_tail(story):
db, adventure, settings = story
_, story_text, report = builder.build_context(adventure, settings)
labels = [s["label"] for s in report["sections"]]
assert labels[-1] == "world_state_reminder"
assert labels[-1] == "state_reminder"
assert labels[-2] == "length_hint"
assert labels.index("world_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert labels.index("narrative_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
def test_live_sections_are_still_charged_to_the_budget(story):
-99
View File
@@ -1,99 +0,0 @@
"""Regression tests for the X-Forwarded-For rate-limit bypass and the
per-account login throttle added to close it.
Background: uvicorn's `--forwarded-allow-ips "*"` trusted the leftmost
`X-Forwarded-For` entry. The client controls that entry, so rotating the
header issued a fresh rate-limit bucket on every request. `client_ip` now
reads the hop the trusted edge appends, which is the rightmost one. Login
also has an email-keyed throttle that no IP trick can weaken.
python -m pytest tests/test_ratelimit_hardening.py -v
"""
import pytest
from app import auth, limits
class _Req:
"""Minimal stand-in for starlette's Request: a header lookup and a peer."""
def __init__(self, xff: str | None, peer: str | None = "10.0.0.1"):
self.headers = {} if xff is None else {"x-forwarded-for": xff}
self.client = None if peer is None else type("C", (), {"host": peer})()
# ---------- client_ip: the spoof-resistant hop ----------
def test_client_ip_takes_appended_rightmost_hop(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
# An attacker prepends a fake IP. The edge appends the real one on the right.
req = _Req("203.0.113.9, 198.51.100.77")
assert limits.client_ip(req) == "198.51.100.77"
def test_client_ip_ignores_spoofed_leftmost(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 1)
# The keyed IP stays the real hop regardless of what the client adds on
# the left, so rotating that value no longer creates a new bucket.
a = limits.client_ip(_Req("1.1.1.1, 198.51.100.77"))
b = limits.client_ip(_Req("2.2.2.2, 198.51.100.77"))
c = limits.client_ip(_Req("evil, junk, 198.51.100.77"))
assert a == b == c == "198.51.100.77"
def test_client_ip_honours_extra_trusted_hops(monkeypatch):
monkeypatch.setattr(limits, "TRUSTED_PROXY_HOPS", 2)
# Two trusted hops: real client is second from the right.
req = _Req("9.9.9.9, 203.0.113.5, 198.51.100.77")
assert limits.client_ip(req) == "203.0.113.5"
def test_client_ip_falls_back_to_socket_peer():
assert limits.client_ip(_Req(None, peer="172.16.0.4")) == "172.16.0.4"
assert limits.client_ip(_Req(None, peer=None)) == "unknown"
# ---------- per-account login throttle ----------
@pytest.fixture(autouse=True)
def _multi_user(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", True)
# Isolate the module-level failure map for each test.
from collections import defaultdict, deque
monkeypatch.setattr(limits, "_login_fails", defaultdict(deque))
def test_login_throttle_blocks_after_limit():
email = "victim@example.com"
# Each attempt up to the limit is allowed and recorded as a failure.
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.check_login_allowed(email) # does not raise
limits.note_login_failure(email)
# One more failure exceeds the limit.
with pytest.raises(limits.HTTPException) as exc:
limits.check_login_allowed(email)
assert exc.value.status_code == 429
def test_login_throttle_is_per_account():
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.note_login_failure("a@example.com")
with pytest.raises(limits.HTTPException):
limits.check_login_allowed("a@example.com")
# A different account is unaffected because the throttle keys on email, not IP.
limits.check_login_allowed("b@example.com") # must not raise
def test_successful_login_clears_the_streak():
email = "typo@example.com"
for _ in range(limits.LOGIN_FAIL_LIMIT):
limits.note_login_failure(email)
limits.note_login_success(email)
limits.check_login_allowed(email) # failure streak cleared, must not raise
def test_throttle_is_noop_in_local_mode(monkeypatch):
monkeypatch.setattr(auth, "MULTI_USER", False)
for _ in range(limits.LOGIN_FAIL_LIMIT * 3):
limits.note_login_failure("solo@example.com")
limits.check_login_allowed("solo@example.com") # never throttled locally
-45
View File
@@ -1,45 +0,0 @@
"""What the provider puts in the `reasoning` request field for each budget
setting: a positive budget asks for thinking, 0 stays silent, -1 turns it off.
python -m pytest tests/test_reasoning_param.py -v
"""
from app.providers.openai_compatible import OpenAICompatibleProvider
def _body(reasoning_max_tokens, api_mode="chat", max_tokens=1000):
provider = OpenAICompatibleProvider(
"https://openrouter.ai/api/v1", "k", "deepseek/deepseek-v4-flash-0731",
api_mode, reasoning_max_tokens,
)
body = {"max_tokens": max_tokens}
provider._apply_reasoning_budget(body)
return body
def test_zero_sends_nothing():
"""Ollama and other providers reject unknown fields. Sending 0 must not add a `reasoning` field."""
assert "reasoning" not in _body(0)
def test_positive_budget_adds_thinking_tokens():
body = _body(500)
assert body["reasoning"] == {"max_tokens": 500}
# the story output keeps its own full budget on top of the thinking budget
assert body["max_tokens"] == 1500
def test_negative_turns_reasoning_off():
body = _body(-1)
assert body["reasoning"] == {"effort": "none"}
# "off" must not inflate the output budget
assert body["max_tokens"] == 1000
def test_off_is_not_merely_excluded():
"""`exclude: true` still generates and bills for reasoning tokens. The off setting must omit the field entirely instead of relying on `exclude`."""
assert _body(-1)["reasoning"].get("exclude") is None
def test_completion_mode_never_sends_reasoning():
for budget in (-1, 0, 500):
assert "reasoning" not in _body(budget, api_mode="completion")
+56 -43
View File
@@ -14,18 +14,11 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
@pytest.fixture()
@@ -41,14 +34,11 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave."))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
@@ -57,9 +47,6 @@ def client(monkeypatch):
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -77,10 +64,17 @@ def client(monkeypatch):
def _adv(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -176,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
# ---------------------------------------------------------------- switching
def test_switching_back_restores_that_attempt_state(client):
"""Each attempt records its own total, so switching between them shows
whether the state followed the narration back."""
ScriptedProvider.replies = [
"You take a scratch.\n```state\n{\"player.hp\": -5}\n```",
"You take a beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("You take a scratch.", 95),
tally_reply("You take a beating.", 60),
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
assert _adv(client.adv_id)[0]["gold"] == 95
_retry(client)
script_state, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert script_state == {"gold": 10} # rolled back, not stacked to 20
player, _document = _adv(client.adv_id)
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
last = _actions(client)[-1]
r = client.post(
@@ -193,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
script_state, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert script_state == {"gold": 10}
# The state follows the narration back.
assert _adv(client.adv_id)[0]["gold"] == 95
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 1})
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
def test_switching_updates_the_world_change_chips(client):
def test_switching_updates_the_state_summary_chips(client):
"""The chip under a message describes the take on screen.
M5 replaced the RPG world-change chips with the narrative-state summary;
what this test guards is unchanged — switching takes must change what the
chip says, or the reader is shown one attempt's prose beside another's
consequences.
"""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("A scratch.", 5),
tally_reply("A beating.", 40),
]
_play(client)
_retry(client)
last = _actions(client)[-1]
assert last["world_changes"][0]["delta"] == -40
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
switched = _actions(client)[-1]["state_summary"]
assert any("5" in line for line in switched), switched
assert not any("40" in line for line in switched)
def test_cannot_switch_a_turn_the_story_moved_past(client):
@@ -251,29 +252,35 @@ def test_switching_to_a_missing_index_is_rejected(client):
def test_failed_retry_leaves_the_previous_attempt_in_charge(client):
"""A provider error mid-retry must undo the rollback, or the stats on
screen would silently disagree with the text still shown."""
ScriptedProvider.replies = ["Attempt one.", ProviderError("upstream is down")]
ScriptedProvider.replies = [gold_reply("Attempt one."), ProviderError("upstream is down")]
_play(client)
assert _adv(client.adv_id)[0] == {"gold": 10}
assert _adv(client.adv_id)[0]["gold"] == 10
client.post(f"/api/adventures/{client.adv_id}/retry")
actions = _actions(client)
assert actions[-1]["text"] == "Attempt one." # text never lost
assert _adv(client.adv_id)[0] == {"gold": 10} # and the state still matches it
assert _adv(client.adv_id)[0]["gold"] == 10 # and the state still matches it
def test_undo_removes_the_action_and_its_history(client):
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client)
_retry(client)
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
# Undo returns the newest window now, not the whole story.
assert [a["type"] for a in r.json()["actions"]] == ["start"]
assert _adv(client.adv_id)[0] == {}
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
§15.5 requires the original narration to be retained. At the tip that means
the correction joins the turn's attempts rather than replacing the words of
one, so the pager still reaches what was there before.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
@@ -281,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
json={"text": "Two, but better."})
# Page away and back: the edit must survive, not be reverted by the switch.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
live = _actions(client)[-1]
assert live["text"] == "Two, but better."
assert live["take_count"] == 3, "the correction is a third attempt"
assert live["take_index"] == 2
# The original is still reachable, and the edit survives paging away.
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 1})
assert _actions(client)[-1]["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 2})
assert _actions(client)[-1]["text"] == "Two, but better."
File diff suppressed because it is too large Load Diff
-1
View File
@@ -125,7 +125,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-1
View File
@@ -34,7 +34,6 @@ def client(monkeypatch):
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
+240 -30
View File
@@ -1,5 +1,20 @@
"""Tests for undo and retry rolling back the shared `script_state`
(plan/11-state-revert-and-retry-fix.md).
"""Tests for undo and retry rolling back the state a position left behind.
M5 made `narrative_state` the authoritative document and `world_state` the
legacy one, and the corrective pass added the narrative column to this file. The
review found it covering only the legacy column (Finding 8) — which is how the
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
to live in the very code this module exists to pin down, untested.
The two columns follow deliberately different NULL rules, and both are asserted
below: a missing narrative snapshot restores the empty document, a missing
legacy snapshot is left alone.
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
`roll_back_before`, and the per-node outcome snapshot — is unchanged; only the
column it moves has. `world_state`/`world_state_after` is now the shared state
an adventure carries, so that is what these tests exercise.
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
it stood before it ran, and rolling back read the snapshot off the action
@@ -20,6 +35,7 @@ import pytest
from fastapi import HTTPException
from app import attempts, memorybank, models
from app.narrative import model as narrative_model
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@@ -36,11 +52,11 @@ def db():
adventures.turns._active_turns.clear()
def _make_adventure(db, script_state):
def _make_adventure(db, world_state):
user = models.User(is_guest=False)
db.add(user)
db.flush()
adv = models.Adventure(user_id=user.id, title="T", script_state=script_state)
adv = models.Adventure(user_id=user.id, title="T", world_state=world_state)
db.add(adv)
db.flush()
return user, adv
@@ -49,13 +65,46 @@ def _make_adventure(db, script_state):
def _add(db, adv, index, type_, text="x", state_after=None):
a = models.Action(
adventure_id=adv.id, type=type_, text=text,
state_after=state_after,
world_state_after=state_after,
)
db.add(a)
db.flush()
return a
def _row_count(db, adv):
"""Every action row the adventure holds, live or not, head or no head."""
return db.query(models.Action).filter_by(adventure_id=adv.id).count()
def _all_types(db, adv):
"""The retained story in depth order, which is not the same as the story
being told once the head has moved back behind the tip."""
rows = (
db.query(models.Action)
.filter_by(adventure_id=adv.id)
.order_by(models.Action.depth, models.Action.id)
.all()
)
return [a.type for a in rows]
def _retrievable(db, adv):
"""The memories the story can currently reach, read through the same clause
`memorybank` retrieves with — which is capped at the active head."""
from app.context import lineage
rows = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adv.id,
lineage.path_of(db, adv).clause(models.Memory),
)
.all()
)
return {m.text for m in rows}
def _forget_snapshots(db, adv):
"""Blank every outcome, the way a row written before SP4 looks.
@@ -72,19 +121,34 @@ def _forget_snapshots(db, adv):
# ---------------------------------------------------------------- undo
def test_undo_reverts_state_to_before_the_turn(db):
# A turn moved script_state from {gold:0} to {gold:10}. The node in
# A turn moved world_state from {gold:0} to {gold:10}. The node in
# front of the turn records where it started. The current state is
# the mutated one.
#
# M3 rewrote what the second half of this test asserts. Undo used to delete
# the turn, so the story was short afterwards because the rows were gone.
# It now moves the head, so the story is short because it is being read
# from somewhere earlier — and the rows are all still there. The state
# assertion is unchanged, because `attempts.restore_state` is unchanged:
# the state still comes off the node the story now ends on.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
before = _row_count(db, adv)
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
assert adv.world_state == {"gold": 0}
# What the story now tells.
assert [a.type for a in page.actions] == ["start"]
assert adv.head_depth == 0
# What it still holds. Zero accepted turns deleted, which is the M3
# invariant this file is the closest test to.
assert _row_count(db, adv) == before
assert _all_types(db, adv) == ["start", "do", "ai"]
assert page.can_redo is True
def test_undo_of_bare_continue_uses_the_node_in_front(db):
@@ -95,10 +159,30 @@ def test_undo_of_bare_continue_uses_the_node_in_front(db):
_add(db, adv, 1, "ai", state_after={"gold": 5})
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 0}
assert [a.type for a in adv.actions] == ["start"]
assert adv.world_state == {"gold": 0}
assert [a.type for a in page.actions] == ["start"]
assert _all_types(db, adv) == ["start", "ai"]
def test_redo_puts_back_the_state_the_turn_left_behind(db):
"""The other half of the same mechanism: undo and redo restore the same
snapshot from opposite directions, because it belongs to the node rather
than to the direction of travel."""
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.redo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 10}
assert [a.type for a in page.actions] == ["start", "do", "ai"]
assert adv.head_depth == 2
assert page.can_redo is False
def test_undo_leaves_state_untouched_when_snapshot_missing(db):
@@ -112,7 +196,7 @@ def test_undo_leaves_state_untouched_when_snapshot_missing(db):
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.script_state == {"gold": 10}
assert adv.world_state == {"gold": 10}
def test_undo_raises_when_nothing_to_undo(db):
@@ -141,20 +225,45 @@ def test_undo_blocked_by_active_turn_lock(db):
adventures.turns._active_turns.discard(adv.id)
def test_undo_prunes_memory_covering_removed_actions(db):
def test_undo_stops_retrieving_a_memory_without_deleting_it(db):
"""M3 rewrote this test. Undo used to prune the memories covering the turns
it deleted, because those turns were gone and a summary of them described
story the adventure no longer had.
Nothing is deleted now, and nothing needs pruning either. A memory carries
the coordinate of the node its block ends on, so one derived from a turn
that is now past the head falls outside the head-capped path clause and
stops being retrievable — and becomes eligible again on Redo, without having
been deleted and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33
holding as a consequence of the head rather than as its own mechanism.
"""
user, adv = _make_adventure(db, {})
for i in range(4):
_add(db, adv, i, "ai" if i % 2 else "do", state_after={})
# A memory summarizing actions up to index 3, which undo will delete.
covering = models.Memory(adventure_id=adv.id, text="m", source_start=0, source_end=3)
keep = models.Memory(adventure_id=adv.id, text="k", source_start=0, source_end=1)
db.commit()
# A memory ending on the turn undo will step behind, and one ending before
# it. The coordinate is what the clause reads; `source_*` only says which
# stretch the summarizer covered.
covering = models.Memory(
adventure_id=adv.id, text="m", source_start=0, source_end=3,
branch_id=adv.head_branch_id, depth=3,
)
keep = models.Memory(
adventure_id=adv.id, text="k", source_start=0, source_end=1,
branch_id=adv.head_branch_id, depth=1,
)
db.add_all([covering, keep])
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv) # removes indexes 2 & 3
adventures.undo_turn(adv.id, db=db, adventure=adv) # head moves to depth 1
texts = {m.text for m in adv.memories}
assert texts == {"k"}
assert _retrievable(db, adv) == {"k"}
# Still on disk, still embedded, still attached to the adventure.
assert {m.text for m in adv.memories} == {"k", "m"}
adventures.redo_turn(adv.id, db=db, adventure=adv)
assert _retrievable(db, adv) == {"k", "m"}
# -------------------------------------------------------- withdrawing a node
@@ -188,24 +297,24 @@ def test_snapshot_outcome_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {"nested": {"n": 1}})
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.script_state["nested"]["n"] = 99
assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation
adv.world_state["nested"]["n"] = 99
assert node.world_state_after == {"nested": {"n": 1}} # unaffected by later mutation
def test_snapshot_outcome_handles_non_dict(db):
_, adv = _make_adventure(db, {})
adv.script_state = None
adv.world_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.state_after == {}
assert node.world_state_after == {}
def test_restore_state_ignores_a_node_with_no_outcome(db):
_, adv = _make_adventure(db, {"gold": 7})
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
assert adv.script_state == {"gold": 7}
assert adv.world_state == {"gold": 7}
attempts.restore_state(adv, None)
assert adv.script_state == {"gold": 7}
assert adv.world_state == {"gold": 7}
# ---------------------------------------------------------------- retry
@@ -220,8 +329,6 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
_add(db, adv, 2, "ai", state_after={"gold": 20})
db.commit()
monkeypatch.setattr(adventures.limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "check_demo_cap", lambda *a, **k: None)
async def _noop(*a, **k):
if False:
@@ -230,12 +337,115 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv)
assert adv.script_state == {"gold": 10}
assert adv.world_state == {"gold": 10}
# Nothing is written until a replacement actually arrives: the attempt on
# screen is left exactly as it was, and stays the live one.
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert last.live is True
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.state_after == {"gold": 20} # its own outcome, untouched
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
# --------------------------------------------- the narrative document (M5)
#
# `narrative_state` is what the reader is shown and what the narrator is told,
# so these are the assertions that matter most. They were missing until the M5
# corrective pass.
def _document(**entities) -> dict:
"""A minimal but real narrative document."""
state = narrative_model.empty()
for key, name in entities.items():
state["entities"][key] = {
"name": name, "type": "character", "status": "active",
"aliases": [], "attributes": {}, "conditions": [],
"description": "", "location": None,
}
return state
def test_snapshot_outcome_records_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after == narrative_model.empty()
def test_restore_state_puts_back_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(aldric="Aldric")
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
assert list(adv.narrative_state["entities"]) == ["mara"]
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
_, adv = _make_adventure(db, {})
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
"""M5 review, Finding 3 — the rule this file failed to pin down.
A pre-M5 position established nothing, and arriving there has to say so.
Leaving the live document alone instead left a *later* position's entities
and facts standing while the reader was somewhere earlier, which is the one
thing the head/state invariant forbids.
"""
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
assert pre_m5.narrative_state_after is None
attempts.restore_state(adv, pre_m5)
assert adv.narrative_state == narrative_model.empty()
# The legacy column keeps the opposite rule, deliberately: nothing consults
# it, and blanking a running campaign's numbers would help no one.
assert adv.world_state == {"gold": 7}
def test_restore_state_of_nothing_changes_neither_column(db):
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
attempts.restore_state(adv, None)
assert list(adv.narrative_state["entities"]) == ["mara"]
assert adv.world_state == {"gold": 7}
+32 -37
View File
@@ -15,7 +15,7 @@ This file must pass unmodified through SP1 (schema), SP2 (branch clause),
and SP3 (memories on nodes). If a change here looks necessary in one of
those subphases, the change is wrong, not the test. SP4 is the first
subphase allowed to move it, and only for the variant-count semantics
called out in plan/14.
the story-tree migration changed.
"""
import pytest
from fastapi import Depends
@@ -26,20 +26,13 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# A world-state schema, so the RPG layer is exercised rather than skipped.
SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
SCHEMA = GOLD_SCHEMA
# Ten gold a turn. A turn that double-applies its hooks, or one that fails to
# roll back on retry, shows up here as a wrong total rather than as nothing.
GOLD_SCRIPT = """
const modifier = (text) => {
state.gold = (state.gold || 0) + 10;
return { text };
};
modifier(text);
"""
OPENING = "You enter a cave."
@@ -59,7 +52,7 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Cave", scenario_id=scenario.id,
script_state={}, world_state={"player": {"hp": 100}},
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -70,20 +63,14 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0):
type="ai" if i % 2 else "do",
text=f"Seeded turn {i}.",
))
setup.add(models.AdventureScript(
adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT,
))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["A reply."]
ScriptedProvider.replies = [gold_reply(t) for t in ["A reply."]]
ScriptedProvider.calls = 0
ScriptedProvider.prompts = []
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
@@ -142,10 +129,17 @@ def _play(client, text="look around", type="do"):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.script_state, adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -172,7 +166,7 @@ def test_short_adventure_returns_everything(client):
# --------------------------------------------------------------- the turn
def test_a_turn_appends_the_player_action_then_the_ai_action(client):
ScriptedProvider.replies = ["The dark presses in."]
ScriptedProvider.replies = [gold_reply(t) for t in ["The dark presses in."]]
_play(client, "light a torch")
actions = _actions(client)
assert [a["type"] for a in actions] == ["start", "do", "ai"]
@@ -181,7 +175,7 @@ def test_a_turn_appends_the_player_action_then_the_ai_action(client):
def test_say_and_story_and_continue_all_work(client):
ScriptedProvider.replies = ["One.", "Two.", "Three."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two.", "Three."]]
_play(client, "hello", type="say")
_play(client, "The wind rises.", type="story")
_play(client, "", type="continue")
@@ -190,7 +184,7 @@ def test_say_and_story_and_continue_all_work(client):
def test_the_story_so_far_is_replayed_into_the_prompt(client):
ScriptedProvider.replies = ["First.", "Second."]
ScriptedProvider.replies = [gold_reply(t) for t in ["First.", "Second."]]
_play(client, "go north")
_play(client, "go south")
story = ScriptedProvider.prompts[-1][1]
@@ -199,9 +193,10 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
assert "go north" in story
def test_scripts_run_once_per_turn(client):
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
ScriptedProvider.replies = ["One.", "Two."]
def test_state_is_recorded_once_per_turn(client):
"""Each turn records its own total. A turn whose state ran twice would show
a value no reply ever stated."""
ScriptedProvider.replies = [tally_reply("One.", 10), tally_reply("Two.", 20)]
_play(client)
_play(client)
script_state, _ = _state(client.adv_id)
@@ -211,7 +206,7 @@ def test_scripts_run_once_per_turn(client):
# ------------------------------------------------------------------ retry
def test_retry_replaces_the_text_and_keeps_the_attempt(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
assert _texts(client)[-1] == "Attempt one."
@@ -233,7 +228,7 @@ def test_retry_replaces_the_text_and_keeps_the_attempt(client):
def test_retry_does_not_stack_script_effects(client):
"""The discarded attempt's ten gold is rolled back, so one turn plus one
retry is still ten, not twenty."""
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
script_state, _ = _state(client.adv_id)
@@ -241,7 +236,7 @@ def test_retry_does_not_stack_script_effects(client):
def test_switching_back_to_an_earlier_attempt_restores_it(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
action_id = _actions(client)[-1]["id"]
@@ -259,7 +254,7 @@ def test_switching_back_to_an_earlier_attempt_restores_it(client):
def test_only_the_newest_turn_can_be_switched(client):
"""An older turn's alternatives stay readable but not selectable. The
story after it continues from what is live."""
ScriptedProvider.replies = ["One.", "Again.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Again.", "Two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
older_id = _actions(client)[-1]["id"]
@@ -274,7 +269,7 @@ def test_only_the_newest_turn_can_be_switched(client):
# ------------------------------------------------------------------- undo
def test_undo_removes_the_whole_turn_and_rolls_state_back(client):
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client, "go north")
_play(client, "go south")
assert len(_actions(client)) == 5
@@ -365,7 +360,7 @@ def test_paging_past_the_start_reports_the_end(long_client):
# ------------------------------------------------------- editing and deleting
def test_editing_an_action_sticks(client):
ScriptedProvider.replies = ["Original."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Original."]]
_play(client)
action_id = _actions(client)[-1]["id"]
r = client.patch(f"/api/adventures/{client.adv_id}/actions/{action_id}",
@@ -380,7 +375,7 @@ def test_editing_an_action_sticks(client):
def test_editing_a_retried_action_survives_a_reload(client):
"""The edit has to reach the live attempt too, or paging away and back
reverts it."""
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
action_id = _actions(client)[-1]["id"]
@@ -390,7 +385,7 @@ def test_editing_a_retried_action_survives_a_reload(client):
def test_deleting_an_action_removes_it(client):
ScriptedProvider.replies = ["One."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
_play(client)
action_id = _actions(client)[-1]["id"]
r = client.delete(f"/api/adventures/{client.adv_id}/actions/{action_id}")
@@ -431,7 +426,7 @@ def test_export_carries_the_whole_story(client):
same fact: a bundle that stores coordinates has no use for a repeating
group. Everything else here still passes unmodified.
"""
ScriptedProvider.replies = ["One.", "Two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client, "go north")
_play(client, "go south")
@@ -446,7 +441,7 @@ def test_export_carries_the_whole_story(client):
def test_export_round_trips_through_import(client):
ScriptedProvider.replies = ["One."]
ScriptedProvider.replies = [gold_reply(t) for t in ["One."]]
_play(client, "go north")
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
@@ -461,7 +456,7 @@ def test_export_round_trips_through_import(client):
def test_export_keeps_retry_attempts(client):
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Attempt one.", "Attempt two."]]
_play(client)
client.post(f"/api/adventures/{client.adv_id}/retry")
@@ -474,7 +469,7 @@ def test_export_keeps_retry_attempts(client):
# -------------------------------------------------------------- world state
def test_world_state_is_readable_and_survives_a_turn(client):
ScriptedProvider.replies = ["Nothing changes."]
ScriptedProvider.replies = [gold_reply(t) for t in ["Nothing changes."]]
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
assert r.status_code == 200, r.text
-3
View File
@@ -57,9 +57,6 @@ def client(monkeypatch):
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False))
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
-6
View File
@@ -58,12 +58,6 @@ def client(monkeypatch):
ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)]
ScriptedProvider.calls = 0
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(
auth,
"resolve_provider_config",
lambda s: auth.ProviderConfig("http://fake", "k", "test-model", False),
)
monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):

Some files were not shown because too many files have changed in this diff Show More