Files
interactive-story/backend/app/memorybank.py
T
parththakkar106andClaude Opus 5 1dd31086c1 Add power-user AI Chat page; centralize demo-key model pinning
AI Chat is a plain scratchpad for talking to a model directly — no story
context, scripts or world state — for poking at models, prompts and endpoints
without starting an adventure. Power users only: the router 404s (rather than
403s) for everyone else and the nav link is hidden. The conversation lives in
localStorage, so there's no new table or migration.

is_power_user() now also returns True in local mode: it's the operator's own
machine and their own key, the same reasoning that makes the provider debug log
local-only.

Alongside that, the rule keeping the shared demo key off paid models now lives
in exactly one place. It had been duplicated into the chat router, which is how
one copy eventually drifts:

- resolve_provider_config() takes an optional model_override and is the only
  place the whitelist is applied, so turns, AI Chat and the connection test all
  inherit it. An override is a per-request preference, never a grant.
- ProviderConfig.__post_init__ refuses to exist when api_key is the demo key
  and the model isn't whitelisted. It keys on the key itself rather than the
  using_demo flag, so a mislabelled config can't slip past, and it raises so a
  future path that bypasses the resolver fails loudly instead of billing.
- The demo branch still pins endpoint_url too — a user-controlled endpoint
  would leak the key itself, which is worse than spending it.

Provider gained chat(messages, ...) beside generate(), both delegating to a
shared _stream(url, body); completion-mode endpoints get the messages flattened
into a labelled transcript. Settings' /models fetch moved to
list_endpoint_models() and is shared with /api/chat/config.

Tests: 10 new in tests/test_chat.py (70 total). These deliberately do not stub
resolve_provider_config — the point is to exercise the real BYOK-vs-demo
decision and assert on what the provider actually received: off-whitelist
override pinned, off-whitelist Settings.model pinned, redirected endpoint
pinned, BYOK passed through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
2026-07-26 20:16:56 +05:30

322 lines
12 KiB
Python

"""Phase 6 — auto summarization + embedding memory bank
(per help.aidungeon.com/faq/the-memory-system).
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
session:
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
block of actions is summarized into a short "memory";
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
the new memories (the user-edited text is always the base, never clobbered);
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
is evicted down to capacity ("forgotten" memories are kept for the UI).
At generation time, `retrieve_memories` embeds the recent story text and ranks
the bank by cosine similarity; the top-K become the "Memories" context section.
All AI calls here are best-effort: failures are logged (debug page) and retried
on a later turn because the cursors only advance on success.
"""
import asyncio
import math
from sqlalchemy.orm import Session
from . import models
from .context import truncate_to_last_tokens
from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError
MEMORY_INTERVAL = 6 # actions per memory
MEMORY_START = 12 # first memory once the adventure reaches this many actions
SUMMARY_INTERVAL = 15 # actions between Story Summary updates
MAX_MEMORIES_PER_RUN = 5 # cap catch-up work (e.g. imported adventures) per turn
MAX_EMBED_BATCH = 32
RETRIEVAL_WINDOW_TOKENS = 600 # recent story text used as the similarity query
SUMMARY_MAX_WORDS = 250
MEMORY_SYSTEM_PROMPT = (
"You compress interactive-fiction story excerpts into memories. Respond with "
"1-2 plain sentences in past tense stating the concrete facts and events "
"(names, places, items, promises, injuries). No preamble, no commentary."
)
SUMMARY_SYSTEM_PROMPT = (
"You maintain the running summary of an interactive-fiction story. Respond "
"with only the updated summary: a single plain-prose overview of the plot "
f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established "
"facts; compress older events harder than recent ones."
)
# Adventures with a post-turn task currently running (single-process app).
_running: set[int] = set()
# Strong refs to in-flight tasks — the event loop only keeps weak references,
# so a fire-and-forget task can otherwise be garbage-collected mid-run.
_tasks: set[asyncio.Task] = set()
# BYOK-only by construction: both factories below take the user's own
# endpoint/key straight from Settings and never auth.DEMO_*, so summarization
# and embedding can't spend the shared demo key (their call sites are also
# skipped when using_demo). Don't "fix" this by passing a ProviderConfig in —
# summary_model/embedding_model are free-form user input and are not on the
# demo whitelist.
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,
)
def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
settings.endpoint_url, settings.api_key_plain, settings.embedding_model
)
def cosine(a: list[float], b: list[float]) -> float:
# Different lengths means the embedding model changed since this vector was
# stored; zip() would silently score garbage.
if len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
return dot / norm if norm else 0.0
def story_actions(adventure: models.Adventure) -> list[models.Action]:
return [a for a in adventure.actions if a.text.strip()]
def prune_dangling_memories(adventure: models.Adventure, db: Session) -> int:
"""Delete memories that summarized actions which no longer exist (e.g. after
undo). source_start/source_end are Action.index values; a memory is dangling
if any covered action is past the current end of the story. Returns the count
removed. Cursors are self-healing in run_post_turn, so this is cleanup only."""
max_index = max((a.index for a in adventure.actions), default=-1)
dangling = [
m for m in adventure.memories
if m.source_end is not None and m.source_end > max_index
]
for m in dangling:
db.delete(m)
return len(dangling)
# ---------- Retrieval (runs inside the turn, before build_context) ----------
async def retrieve_memories(
adventure: models.Adventure,
settings: models.Settings,
*,
update_stats: bool,
) -> dict | None:
"""Returns {"used": [{id, text, similarity, pinned}], "error": str|None},
or None when the memory bank is off for this adventure. `update_stats`
bumps use counters (real turns only, not Insights dry runs); the caller's
commit persists them."""
if not adventure.memory_bank_enabled:
return None
if not settings.embedding_model.strip():
return {"used": [], "error": "No embedding model configured in Settings."}
candidates = [m for m in adventure.memories if not m.forgotten and m.embedding]
if not candidates:
return {"used": [], "error": None}
actions = story_actions(adventure)
query = truncate_to_last_tokens(
"\n\n".join(a.text for a in actions[-4:]), RETRIEVAL_WINDOW_TOKENS
)
if not query.strip():
return {"used": [], "error": None}
try:
[query_vec] = await embedding_provider(settings).embed([query])
except ProviderError as exc:
return {"used": [], "error": str(exc)}
scored = sorted(
((cosine(query_vec, m.embedding), m) for m in candidates),
key=lambda pair: pair[0],
reverse=True,
)
# Pinned memories are always used and count toward top_k, so the injected
# set never exceeds the configured budget (unless pinned alone exceed it).
top_k = max(1, settings.memory_top_k)
used = [(score, m) for score, m in scored if m.pinned]
remaining = max(0, top_k - len(used))
used += [(score, m) for score, m in scored if not m.pinned][:remaining]
used.sort(key=lambda pair: pair[0], reverse=True)
if update_stats:
now = models.utcnow()
for _, m in used:
m.use_count += 1
m.last_used_at = now
return {
"used": [
{"id": m.id, "text": m.text, "similarity": round(score, 4), "pinned": m.pinned}
for score, m in used
],
"error": None,
}
# ---------- Post-turn background work ----------
def schedule_post_turn(adventure: models.Adventure) -> None:
"""Fire-and-forget summarization/embedding work after a turn is saved."""
if not (adventure.auto_summarize or adventure.memory_bank_enabled):
return
if adventure.id in _running:
return
task = asyncio.get_running_loop().create_task(run_post_turn(adventure.id))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
async def run_post_turn(adventure_id: int) -> None:
if adventure_id in _running:
return
_running.add(adventure_id)
db = SessionLocal()
try:
adventure = db.get(models.Adventure, adventure_id)
if adventure is None:
return
# Settings are per-user (Phase 8): use the adventure owner's row.
settings = (
db.query(models.Settings)
.filter(models.Settings.user_id == adventure.user_id)
.first()
)
if settings is None:
return
# Undo/retry can shrink the action list below a stored cursor, which
# would stall summarization until the story grew past it again.
count = len(story_actions(adventure))
adventure.memory_cursor = min(adventure.memory_cursor, count)
adventure.summary_cursor = min(adventure.summary_cursor, count)
if adventure.auto_summarize:
await _create_due_memories(adventure, settings, db)
await _update_story_summary(adventure, settings, db)
if adventure.memory_bank_enabled and settings.embedding_model.strip():
await _embed_pending(adventure, settings, db)
_evict_over_capacity(adventure, settings, db)
finally:
db.close()
_running.discard(adventure_id)
async def _create_due_memories(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
provider = summary_provider(settings)
for _ in range(MAX_MEMORIES_PER_RUN):
cursor = adventure.memory_cursor
if len(actions) < MEMORY_START or len(actions) - cursor < MEMORY_INTERVAL:
return
block = actions[cursor:cursor + MEMORY_INTERVAL]
excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
try:
text = await provider.complete(
MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:"
)
except ProviderError:
return # logged in the debug page; cursor unchanged → retried next turn
if not text:
return
db.add(
models.Memory(
adventure_id=adventure.id,
text=text,
source_start=block[0].index,
source_end=block[-1].index,
)
)
adventure.memory_cursor = cursor + MEMORY_INTERVAL
db.commit()
async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
if len(actions) - adventure.summary_cursor < SUMMARY_INTERVAL:
return
# Fold in memories covering the uncovered stretch; fall back to raw story
# text if memory creation is lagging (e.g. it just failed).
# summary_cursor is a position into story_actions(); Memory.source_end is
# an Action.index. Translate the cursor to an index boundary before
# comparing — the two spaces diverge once actions are deleted or empty.
if adventure.summary_cursor < len(actions):
boundary = actions[adventure.summary_cursor].index
else:
boundary = actions[-1].index + 1 if actions else 0
new_events = [
m.text
for m in adventure.memories
if m.source_end is not None and m.source_end >= boundary
]
if new_events:
events_text = "\n".join(f"- {t}" for t in new_events)
else:
block = actions[adventure.summary_cursor:]
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
current = adventure.story_summary.strip()
user_prompt = (
f"Current story summary:\n{current or '(none yet)'}\n\n"
f"New events since the last update:\n{events_text}\n\n"
"Updated summary:"
)
try:
text = await summary_provider(settings).complete(
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
)
except ProviderError:
return
if not text:
return
adventure.story_summary = text
adventure.summary_cursor = len(actions)
db.commit()
async def _embed_pending(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
pending = [m for m in adventure.memories if m.embedding is None and not m.forgotten]
pending = pending[:MAX_EMBED_BATCH]
if not pending:
return
try:
vectors = await embedding_provider(settings).embed([m.text for m in pending])
except ProviderError:
return
for memory, vector in zip(pending, vectors):
memory.embedding = vector
db.commit()
def _evict_over_capacity(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
active = [m for m in adventure.memories if not m.forgotten]
overflow = len(active) - max(1, settings.memory_bank_capacity)
if overflow <= 0:
return
evictable = sorted(
(m for m in active if not m.pinned),
key=lambda m: (m.use_count, m.last_used_at or m.created_at),
)
for memory in evictable[:overflow]:
memory.forgotten = True
db.commit()