The release-validation milestone, and the thing it had to settle first was whether any of the earlier evidence meant what it said. M8 measured a deployment enforcing a 4,096-token input window while the application budgeted 16,384. Every request returned 200. What Ollama does with the excess is drop the oldest tokens, and the oldest tokens here are the system block — the narrator's rules and the campaign canon. A hundred-turn certification against that server would have looked perfect and proved nothing, which is why this milestone could not begin with a hundred turns. So the application asks now. Ollama's window is a property of how a model was loaded rather than of the request — sending num_ctx is accepted, ignored, and worse, reloads the model at the server's own default — so the only honest move is to find out and then tell the truth about it. /api/ps reports what a resident model is being served with, /api/show what an unloaded one will load with, both on the same host inference already uses, through the same endpoint policy and the same TLS trust store. A verified window is a ceiling on the budget; an unverified one leaves the budget alone and is recorded as unverified in the turn's own provenance, so an old turn can be asked afterwards whether it was built against a checked window. There is no third behaviour, and in particular no hard-coded 4,096: a number the server did not say would be right on one machine and wrong on the next. The proof that this is doing something is a campaign whose canon sits at the front of the prompt, 120 turns of history, and a 4,096-token window. The canon is still there afterwards and the oldest history is gone. The same campaign built the old way produces a prompt more than twice the window — the defect, reproduced, so the fix is measured against it rather than asserted. Two defects the validation found on its own, and they are the same defect twice: something was true and nobody was told. A manual state correction of four changes with one bad reference applied three, returned 201, and said nothing — while recording the refusal on the audit row nobody reads. It came to light because the identity diagnostic's own fixture was refused that way and the whole run proceeded on a campaign with no scene, which would have read as a model failure. And the narration-length setting moved no number: brief, medium and long each became one English sentence, while the numeric hint the model actually reads was derived from the global reply cap and said the same thing for all three. Both now say what they did. The other two post-M8 findings are closed as well. The tab said AI D&D, which no document had ever claimed it did not; it says Interactive Story now, with the open campaign first, and the name is the owner's decision rather than a find-and-replace to something narrower than the engine. After an Undo the reader could not tell where they had landed; the control row now ends with "Moment 11 · later story ahead", from the server's own answer, in the word the transcript already uses, with none of head, branch or depth anywhere near it. The identity diagnostic exists and the root cause does not. That campaign was destroyed, so no cause can be established — what M11 owes the finding is something that can classify the next occurrence, and a diagnostic that makes only the judgements a program can honestly make: duplicate keys, shared names, protagonist drift, state and context disagreeing. Whether prose misattributed a line is left to a person reading it beside its prompt, because a regex cannot read dialogue and one that pretended to would produce exactly the confident wrong answer this finding is about. Its detectors are proved to fire against a planted second Alice. Two entities may still share a display name. That was checked first, as the finding asked, and left permitted: a mother and a daughter, or a stranger giving a false name, are ordinary fiction, and refusing them to guard against a model mistake would refuse the wrong thing. What was missing was that it happened silently. It is reported now. Evidence, not inference: a hundred accepted turns against a real narrator with genuine process restarts; a real browser against the built SPA; a container with no network at all; a campaign moved into a data directory that never existed. Each was discarded and re-run whenever the product changed under it, and the runs that were thrown away are listed in the report with the reason, along with ten defects in the harnesses themselves — because a harness that has only ever agreed with itself is not evidence, and two of M8's five harness defects were masking real ones. No dependency was added, removed or upgraded. No acceptance test was retired, relaxed or reclassified. M11 is implemented and verified; it is not accepted, and there is no release tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Listing, creating, reading, renaming, and deleting adventures.
|
|
|
|
The world-state and script-state readers are here too, because they report an
|
|
adventure's stored state rather than play a turn.
|
|
"""
|
|
|
|
from fastapi import Body, Depends, HTTPException
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.orm.attributes import set_committed_value
|
|
|
|
from ... import (
|
|
attempts, head, images, limits, memorybank, models, schemas, summaries, tree,
|
|
worldstate,
|
|
)
|
|
from ...database import get_db
|
|
from ...knowledge import embeddings as knowledge_embeddings
|
|
from ...knowledge import importer as knowledge_importer
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
from .paging import action_window, annotate_takes
|
|
from .scenario_text import fill_placeholders, scenario_card_specs
|
|
|
|
|
|
# How many characters of the last narration a Continue card shows. The limit is
|
|
# long enough to re-establish the scene and short enough to keep the card
|
|
# small.
|
|
SNIPPET_MAX = 220
|
|
|
|
|
|
def _snippet(text: str) -> str:
|
|
"""Condenses stored action text into a single line for a card."""
|
|
# `turns._generate_turn` strips the world-state block before storing AI text,
|
|
# so this function only has to normalize whitespace.
|
|
collapsed = " ".join((text or "").split())
|
|
if len(collapsed) <= SNIPPET_MAX:
|
|
return collapsed
|
|
# Cut at a word boundary rather than mid-word. CSS adds the ellipsis.
|
|
cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0]
|
|
return f"{cut}…"
|
|
|
|
|
|
# Action types that read as narration. `start` is the scenario's opening prompt,
|
|
# which is the only text a newly created adventure has. Without `start`, a new
|
|
# story's card would show no text at all. `do` and `say` are excluded because
|
|
# the card quotes the story's voice rather than the player's.
|
|
NARRATION_TYPES = ("ai", "story", "start")
|
|
|
|
|
|
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
|
|
"""Maps each adventure id to the text of its most recent narrated action.
|
|
|
|
This runs one window-function query rather than one lookup per adventure, so
|
|
the list endpoint makes a fixed number of round trips.
|
|
|
|
The query is scoped by head branch rather than by the full lineage, and this
|
|
is the only place in the codebase that does so. A lineage clause per
|
|
adventure would add a hundred OR terms to the index screen's query to select
|
|
one row each. The two scopes differ only for a branch with no nodes of its
|
|
own, and playing a turn onto a branch is what creates it, so that state does
|
|
not occur. An adventure with no branch has no story to quote.
|
|
"""
|
|
branch_ids = [b for b in head_branches.values() if b is not None]
|
|
if not branch_ids:
|
|
return {}
|
|
ranked = (
|
|
db.query(
|
|
models.Action.adventure_id.label("adventure_id"),
|
|
models.Action.text.label("text"),
|
|
func.row_number()
|
|
.over(
|
|
partition_by=models.Action.adventure_id,
|
|
order_by=(models.Action.depth.desc(), models.Action.id.desc()),
|
|
)
|
|
.label("rank"),
|
|
)
|
|
.filter(
|
|
models.Action.adventure_id.in_(list(head_branches)),
|
|
models.Action.branch_id.in_(branch_ids),
|
|
models.Action.type.in_(NARRATION_TYPES),
|
|
# Sibling attempts share a depth, and the newest has the highest
|
|
# id. Without this filter the snippet quotes the attempt written
|
|
# last rather than the one the story tells. After you switch back to
|
|
# an earlier attempt, the index screen would quote the discarded one
|
|
# and disagree with the story on screen.
|
|
models.Action.live.is_(True),
|
|
)
|
|
.subquery()
|
|
)
|
|
rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all()
|
|
return {adventure_id: text for adventure_id, text in rows}
|
|
|
|
|
|
@router.get("", response_model=list[schemas.AdventureListItem])
|
|
def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser):
|
|
# Select named columns rather than the whole Adventure entity. The entity is
|
|
# sixteen columns wide and includes `script_state`, `world_state`,
|
|
# `placeholders`, `story_summary`, `memory`, `authors_note`, and
|
|
# `ai_instructions`. That is about 15 kB per row in production, fetched once
|
|
# per adventure on every index load, and this screen uses none of it. Naming
|
|
# the columns also means a wide column added to Adventure later has to opt
|
|
# in to being listed here.
|
|
rows = (
|
|
db.query(
|
|
models.Adventure.id,
|
|
models.Adventure.scenario_id,
|
|
models.Adventure.title,
|
|
models.Adventure.updated_at,
|
|
models.Adventure.head_branch_id,
|
|
func.count(models.Action.id),
|
|
models.Scenario.title,
|
|
models.Scenario.image,
|
|
models.Scenario.icon,
|
|
models.Scenario.updated_at,
|
|
)
|
|
.outerjoin(models.Action)
|
|
.outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id)
|
|
.filter(models.Adventure.user_id == user.id)
|
|
# Group by both primary keys. Postgres requires every selected column
|
|
# to be grouped or aggregated. The Adventure columns are covered by its
|
|
# own grouped primary key, but the Scenario columns come from a joined
|
|
# table and have to be listed as well. SQLite accepts the shorter form,
|
|
# and Postgres rejects it.
|
|
.group_by(
|
|
models.Adventure.id,
|
|
models.Scenario.id,
|
|
models.Scenario.title,
|
|
models.Scenario.image,
|
|
models.Scenario.icon,
|
|
models.Scenario.updated_at,
|
|
)
|
|
.order_by(models.Adventure.updated_at.desc())
|
|
.all()
|
|
)
|
|
narration = _latest_narration(db, {row[0]: row[4] for row in rows})
|
|
return [
|
|
schemas.AdventureListItem(
|
|
id=adv_id,
|
|
scenario_id=scenario_id,
|
|
scenario_title=scenario_title,
|
|
title=title,
|
|
updated_at=updated_at,
|
|
action_count=count,
|
|
snippet=_snippet(narration.get(adv_id, "")),
|
|
# The art belongs to the scenario, so the cache-busting stamp uses
|
|
# the scenario's `updated_at`, not the adventure's.
|
|
image_url=images.public_url(scenario_id, image or "", scenario_updated),
|
|
icon=icon or "",
|
|
)
|
|
# `count` counts every action in the adventure, not only the ones on
|
|
# the path. With one branch the two numbers are equal. After forking
|
|
# ships, the index screen overstates a story that has sibling branches.
|
|
# The fix belongs to SP5, which is where a fork can first exist.
|
|
for (adv_id, scenario_id, title, updated_at, _head_branch_id, count,
|
|
scenario_title, image, icon, scenario_updated) in rows
|
|
]
|
|
|
|
|
|
@router.post("", response_model=schemas.AdventureOut, status_code=201)
|
|
def create_adventure(
|
|
payload: schemas.AdventureCreate,
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
):
|
|
limits.check_row_cap("adventures", db, user)
|
|
scenario = None
|
|
if payload.scenario_id is not None:
|
|
scenario = db.get(models.Scenario, payload.scenario_id)
|
|
# A scenario is playable if the user owns it or if it is public.
|
|
if scenario is None or (scenario.user_id != user.id and not scenario.is_public):
|
|
raise HTTPException(404, "Scenario not found")
|
|
|
|
values = payload.placeholders
|
|
adventure = models.Adventure(
|
|
user_id=user.id,
|
|
scenario_id=scenario.id if scenario else None,
|
|
title=payload.title or (scenario.title if scenario else "Untitled Adventure"),
|
|
memory=fill_placeholders(scenario.memory, values) if scenario else "",
|
|
authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "",
|
|
ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "",
|
|
# Phase 12: seed the live RPG state from the scenario's template.
|
|
world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {},
|
|
# Stored so that a later "Update from scenario" fills the copied text
|
|
# with the same answers instead of inserting literal `${...}` tokens.
|
|
placeholders=dict(values),
|
|
# Phase 18: the persona the player named before starting. It belongs to
|
|
# the adventure rather than the scenario, so nothing is copied here and
|
|
# "Update from scenario" never touches it.
|
|
persona_name=payload.persona_name.strip(),
|
|
persona_pronouns=payload.persona_pronouns.strip(),
|
|
persona_desc=payload.persona_desc.strip(),
|
|
# M11: the reader's narration-length choice, kept as data so the prompt
|
|
# builder can turn it into a word range (post-M8 finding C).
|
|
narration_length=payload.narration_length,
|
|
)
|
|
db.add(adventure)
|
|
db.flush()
|
|
# Give every adventure a story tree as soon as it exists, before anything
|
|
# is played onto it. Otherwise the tree code has to tolerate a NULL head
|
|
# everywhere, which buys nothing.
|
|
tree.head_branch(db, adventure)
|
|
|
|
# M8: canon written at setup. Stored in the same document the prompt and the
|
|
# validator already read, so nothing downstream learns a second shape.
|
|
rules = [r.strip() for r in payload.canon_rules if r.strip()]
|
|
if rules:
|
|
adventure.campaign_canon = {"rules": rules}
|
|
|
|
if scenario:
|
|
for ref, spec in scenario_card_specs(scenario, values).items():
|
|
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
|
|
|
|
# The opening scene. A scenario's prompt and M8's `opening` field are the
|
|
# same thing arriving by different routes, so they build the same node —
|
|
# the scenario wins when both are present, because it is the more specific
|
|
# request. Everything downstream (Undo to the opening, retrying the first
|
|
# turn, the drop cap) keys on the `start` type and is unchanged.
|
|
opening_text = (
|
|
fill_placeholders(scenario.prompt, values)
|
|
if scenario and scenario.prompt.strip()
|
|
else payload.opening.strip()
|
|
)
|
|
if opening_text:
|
|
opening = models.Action(
|
|
adventure_id=adventure.id,
|
|
type="start",
|
|
text=opening_text,
|
|
)
|
|
# Record the starting state on the opening node, so undoing or
|
|
# retrying the first turn has a state to roll back to.
|
|
attempts.snapshot_outcome(adventure, opening)
|
|
tree.place_action(db, adventure, opening)
|
|
db.add(opening)
|
|
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
return adventure
|
|
|
|
|
|
@router.get("/{adventure_id}", response_model=schemas.AdventureOut)
|
|
def get_adventure(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the adventure and the newest window of its story.
|
|
|
|
`actions` holds the last `ACTION_PAGE` actions, not all of them.
|
|
`action_count` reports the real total, so the reader knows that more actions
|
|
exist above. `GET /{id}/actions` serves the older pages as the reader
|
|
scrolls up.
|
|
"""
|
|
actions, total, _ = action_window(db, adventure)
|
|
# Annotate before handing over the window. This path serializes through the
|
|
# relationship rather than building `ActionOut` itself, so the pager numbers
|
|
# have to be on the rows before Pydantic reads them.
|
|
annotate_takes(db, adventure.id, actions)
|
|
# Attach the window as if the relationship had loaded it.
|
|
# `set_committed_value` is the only safe way to do this. Assigning
|
|
# `adventure.actions = [...]` marks the collection dirty, and the
|
|
# relationship cascades delete-orphan, so the next flush deletes every
|
|
# action outside the window. `set_committed_value` records the rows as the
|
|
# already-loaded, unmodified value, so serialization triggers no lazy load
|
|
# and leaves nothing pending.
|
|
set_committed_value(adventure, "actions", actions)
|
|
out = schemas.AdventureOut.model_validate(adventure)
|
|
out.action_count = total
|
|
# 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}/world-state")
|
|
def get_world_state(
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns the live RPG world state and the scenario's `stat_schema`.
|
|
|
|
The play view uses both to render the character sheet and the milestones.
|
|
`schema` is null when the adventure has no RPG layer.
|
|
"""
|
|
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
|
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
|
return {
|
|
"state": state,
|
|
"schema": schema if worldstate.has_schema(schema) else None,
|
|
}
|
|
|
|
|
|
@router.put("/{adventure_id}/world-state")
|
|
def override_world_state(
|
|
overrides: dict = Body(...),
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Edits the live RPG values directly, as a manual correction rather than a turn.
|
|
|
|
`overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and
|
|
`milestones.y` to their new absolute values. The endpoint rejects unknown
|
|
paths and wrong types one at a time, and applies the rest.
|
|
"""
|
|
schema = adventure.scenario.stat_schema if adventure.scenario else None
|
|
if not worldstate.has_schema(schema):
|
|
raise HTTPException(400, "This adventure has no RPG world-state layer")
|
|
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
|
new_state, report = worldstate.apply_override(state, schema, overrides)
|
|
adventure.world_state = new_state
|
|
db.commit()
|
|
return {"state": new_state, "report": report}
|
|
|
|
|
|
@router.patch("/{adventure_id}", response_model=schemas.AdventureOut)
|
|
def update_adventure(
|
|
payload: schemas.AdventureUpdate,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
fields = payload.model_dump(exclude_unset=True)
|
|
# M8. `canon_rules` is a read-only view onto the stored `campaign_canon`
|
|
# document, so it is written by hand rather than by the setattr loop — and
|
|
# only the `rules` key is replaced. Whatever else the document holds
|
|
# (`forbidden_status_changes`, which has no browser editor) is left exactly
|
|
# as it was, so editing canon through the browser cannot silently discard
|
|
# the structured half a fixture or an import wrote.
|
|
if "canon_rules" in fields:
|
|
rules = [r.strip() for r in (fields.pop("canon_rules") or []) if r.strip()]
|
|
canon = dict(adventure.campaign_canon or {})
|
|
if rules:
|
|
canon["rules"] = rules
|
|
else:
|
|
canon.pop("rules", None)
|
|
adventure.campaign_canon = canon or None
|
|
for field, value in fields.items():
|
|
setattr(adventure, field, value)
|
|
# M6: a summary the reader typed is still a summary, so it is anchored to
|
|
# the position they typed it at rather than left in a column with no
|
|
# lineage. Otherwise a hand-written summary would survive an Undo and a
|
|
# divergence that its generated equivalent correctly does not (E03).
|
|
if "story_summary" in fields:
|
|
typed = (fields["story_summary"] or "").strip()
|
|
held = summaries.current(db, adventure)
|
|
if typed and (held is None or held.text.strip() != typed):
|
|
summaries.record(db, adventure, typed, trigger="manual")
|
|
db.commit()
|
|
return adventure
|
|
|
|
|
|
@router.delete("/{adventure_id}", status_code=204)
|
|
def delete_adventure(
|
|
adventure_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
# M9. The lexical index first, while the chunks that locate it still exist.
|
|
# It is a virtual table, so nothing cascades into it, and an orphaned index
|
|
# row makes the *next* import into *any* campaign fail — see
|
|
# `knowledge.importer.clear_campaign_index`.
|
|
knowledge_importer.clear_campaign_index(db, adventure)
|
|
db.delete(adventure)
|
|
db.commit()
|
|
# No later request reads this adventure's vectors, so drop them now. The
|
|
# cache would otherwise hold them until the process restarted.
|
|
memorybank.forget_cached_vectors(adventure_id)
|
|
knowledge_embeddings.forget_cached(adventure_id)
|