Stage 2, items 1, 4, and 5 of `plan/17-refactor.md`. **One path resolver in `worldstate`.** `apply_delta` and `apply_override` routed `flags.<name>`, `milestones.<id>`, `world.<stat>`, `player.<stat>`, and `npc.<id>.<stat>` with parallel code, about 100 lines each. `_resolve` now says what a path points at and returns either a target or the rejection to report. Each function keeps its own write rule, because the rules genuinely differ: an override sets a number rather than adding to it, ignores `cooldown`, `max_delta_per_turn`, and the rule that a counter only counts up, and can un-set a milestone. A differential check ran both implementations over 3960 payloads: twenty paths, fourteen values, three starting states, plus every three-path combination. The results are identical except that 674 rejections from `apply_override` now carry a `fix` string. `apply_delta` already worded those, and the world-state editor renders them, so an override that names an unknown flag now explains itself the way a delta does. **`sse`, `SSE_HEADERS`, and `turn_error` move to `app/sse.py`.** Two routers stream, and `chat.py` had to import from `routers.adventures` to reach them. **`get_adventure_or_404` becomes the `current_adventure` dependency.** All 32 handlers repeated the call as their first statement. The ownership check now reads in the signature and runs before the body. FastAPI caches a dependency for one request, so the handler's `db` is the session the adventure came from. The generated OpenAPI document is byte-identical except on `rename_branch`, where `branch_id` is now listed before `adventure_id`, because that handler no longer names `adventure_id` itself. Parameter order in the document is cosmetic. Six tests in `test_state_revert.py` call `undo_turn` and `retry_action` directly rather than over HTTP. They pass the adventure they already hold instead of an id. 549 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""CRUD for the memory bank entries attached to one adventure.
|
|
|
|
`app/memorybank.py` owns embedding and retrieval. These endpoints only edit the
|
|
rows.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy.orm import Session, load_only
|
|
|
|
from ... import limits, memorybank, models, schemas, tree
|
|
from ...context import lineage
|
|
from ...database import get_db
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
|
|
|
|
# The columns `schemas.MemoryOut` renders. `embedded` is a real column and
|
|
# belongs here. The vector it describes does not.
|
|
MEMORY_LIST_COLUMNS = (
|
|
models.Memory.adventure_id,
|
|
models.Memory.text,
|
|
models.Memory.pinned,
|
|
models.Memory.forgotten,
|
|
models.Memory.embedded,
|
|
models.Memory.use_count,
|
|
models.Memory.last_used_at,
|
|
models.Memory.source_start,
|
|
models.Memory.source_end,
|
|
models.Memory.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut])
|
|
def list_memories(
|
|
adventure_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
# Name the columns in a query rather than walk `adventure.memories`.
|
|
# Retrieval used to walk the relationship, which is why a turn cost
|
|
# megabytes: a relationship load returns whole entities, so it reads
|
|
# whatever the model carries. `embedding_blob` is deferred and would stay
|
|
# out today, so this rule is about the next wide column rather than that
|
|
# one.
|
|
#
|
|
# The drawer shows the same bank the model reads. The filter uses the same
|
|
# clause retrieval uses, so the drawer answers one question rather than two.
|
|
# An adventure-wide list would show memories from branches this story never
|
|
# went down, which are never retrieved, and a reader cannot tell those apart
|
|
# from the ones in play. No memory becomes unreachable, because a memory
|
|
# belongs to a branch: switching to that branch shows it, and deleting the
|
|
# branch deletes its memories.
|
|
return (
|
|
db.query(models.Memory)
|
|
.options(load_only(*MEMORY_LIST_COLUMNS))
|
|
.filter(
|
|
models.Memory.adventure_id == adventure_id,
|
|
lineage.path_of(db, adventure).clause(models.Memory),
|
|
)
|
|
.order_by(models.Memory.id)
|
|
.all()
|
|
)
|
|
|
|
|
|
@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201)
|
|
def create_memory(
|
|
payload: schemas.MemoryCreate,
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Adds a memory manually. The next post-turn pass embeds it."""
|
|
limits.check_row_cap("memories", db, user, adventure=adventure)
|
|
if not payload.text.strip():
|
|
raise HTTPException(400, "Memory text cannot be empty")
|
|
memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip())
|
|
# No node produced this memory, so it gets a branch but no depth.
|
|
tree.place_memory(db, adventure, memory)
|
|
db.add(memory)
|
|
db.commit()
|
|
db.refresh(memory)
|
|
return memory
|
|
|
|
|
|
@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut)
|
|
def update_memory(
|
|
adventure_id: int,
|
|
memory_id: int,
|
|
payload: schemas.MemoryUpdate,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
memory = db.get(models.Memory, memory_id)
|
|
if memory is None or memory.adventure_id != adventure_id:
|
|
raise HTTPException(404, "Memory not found")
|
|
fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None}
|
|
if "text" in fields and fields["text"].strip() != memory.text:
|
|
memorybank.set_vector(memory, None) # Re-embed on the next post-turn pass.
|
|
for field, value in fields.items():
|
|
setattr(memory, field, value)
|
|
db.commit()
|
|
return memory
|
|
|
|
|
|
@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204)
|
|
def delete_memory(
|
|
adventure_id: int,
|
|
memory_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
memory = db.get(models.Memory, memory_id)
|
|
if memory is None or memory.adventure_id != adventure_id:
|
|
raise HTTPException(404, "Memory not found")
|
|
db.delete(memory)
|
|
db.commit()
|