Add per-game adventure text with RAG-based retrieval

Players can now paste the full text of a freely available adventure
when creating a game, and the DM will follow it instead of inventing
its own plot/NPCs/locations. Reuses the existing rulebook RAG
pipeline (chunking, local embedding) rather than injecting the raw
text into every turn, since real adventure modules range from a few
pages to hundreds — far beyond what fits in a prompt.

- New adventure_chunks table (game-scoped, unlike the global
  rulebook_chunks) + Game.adventure_text storing the raw source.
- ingest_adventure_text() chunks/embeds synchronously during
  POST /api/games when adventure_text is non-empty.
- build_adventure_rag_block() retrieves by similarity to the latest
  player message, scoped to game_id — plus always includes chunk 0
  (the adventure's opening) regardless of the query, since early
  Session-0 messages rarely resemble the adventure's actual hook and
  pure similarity search could miss the beginning entirely.
- System prompt instructs the DM to follow provided adventure
  excerpts strictly, deviating only when players clearly go off-script.
- Frontend: optional adventure-text field in the game creation form,
  a has_adventure flag surfaced as a small badge on the game card and
  session header.

Verified with an isolated two-game test that chunks/retrieval never
leak across games (the critical failure mode here), and live against
x.ai: a custom-written one-shot's specific NPCs, location, and hook
appeared verbatim in the DM's opening scene instead of invented ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-02 13:13:59 +02:00
parent ff4c6f07e3
commit b77f066259
14 changed files with 224 additions and 6 deletions
+40
View File
@@ -0,0 +1,40 @@
import asyncio
import uuid
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.adventure_chunk import AdventureChunk
from app.rag.chunking import chunk_text
from app.rag.embeddings import embed_texts
EMBED_BATCH_SIZE = 32
async def ingest_adventure_text(session: AsyncSession, game_id: uuid.UUID, text: str) -> int:
"""Chunks and embeds a player-supplied adventure text, scoped to one game. Safe to call
again for the same game (e.g. future re-ingest) — previous chunks are cleared first."""
await session.execute(delete(AdventureChunk).where(AdventureChunk.game_id == game_id))
chunks = chunk_text(text)
if not chunks:
await session.commit()
return 0
for batch_start in range(0, len(chunks), EMBED_BATCH_SIZE):
batch = chunks[batch_start : batch_start + EMBED_BATCH_SIZE]
# embed_texts is a synchronous, CPU-bound sentence-transformers call — run it off the
# event loop so a large adventure doesn't stall other concurrent requests/WS connections.
embeddings = await asyncio.to_thread(embed_texts, batch)
for i, (content, embedding) in enumerate(zip(batch, embeddings)):
session.add(
AdventureChunk(
game_id=game_id,
chunk_index=batch_start + i,
content=content,
embedding=embedding,
)
)
await session.commit()
return len(chunks)
+58
View File
@@ -0,0 +1,58 @@
import asyncio
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.adventure_chunk import AdventureChunk
from app.rag.embeddings import embed_query
DEFAULT_TOP_K = 5
# Same calibration as the rulebook RAG (app/rag/retrieval.py) — see there for how these were chosen.
MAX_DISTANCE = 0.6
MAX_BLOCK_CHARS = 4000
async def build_adventure_rag_block(
session: AsyncSession, game_id: uuid.UUID, query: str, top_k: int = DEFAULT_TOP_K
) -> str:
# Chunk 0 (the adventure's opening) is always included alongside the similarity search:
# on the very first turns the player's message is often just answering Session-0 questions
# and won't necessarily be similar to the adventure's actual hook, so pure similarity search
# could miss the beginning entirely and leave the DM to improvise instead of following it.
opening = (
await session.execute(
select(AdventureChunk)
.where(AdventureChunk.game_id == game_id, AdventureChunk.chunk_index == 0)
)
).scalar_one_or_none()
query_embedding = await asyncio.to_thread(embed_query, query)
distance = AdventureChunk.embedding.cosine_distance(query_embedding)
rows = (
await session.execute(
select(AdventureChunk, distance.label("distance"))
.where(AdventureChunk.game_id == game_id)
.order_by(distance)
.limit(top_k)
)
).all()
relevant = [chunk for chunk, dist in rows if dist <= MAX_DISTANCE]
if opening is not None and opening.id not in {c.id for c in relevant}:
relevant.insert(0, opening)
if not relevant:
return ""
parts = [
"Auszüge aus dem vorgegebenen Abenteuer — halte dich strikt daran (siehe Systemanweisung):"
]
used_chars = 0
for chunk in relevant:
entry = f"\n{chunk.content}"
if used_chars + len(entry) > MAX_BLOCK_CHARS and used_chars > 0:
break
parts.append(entry)
used_chars += len(entry)
return "\n".join(parts)