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
@@ -0,0 +1,45 @@
"""add adventure_text on games and adventure_chunks table (per-game RAG)
Revision ID: f1a2b3c4d5e6
Revises: e5f7a1c3b982
Create Date: 2026-09-02 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from pgvector.sqlalchemy import Vector
# revision identifiers, used by Alembic.
revision: str = 'f1a2b3c4d5e6'
down_revision: Union[str, None] = 'e5f7a1c3b982'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'games',
sa.Column('adventure_text', sa.Text(), nullable=False, server_default=''),
)
op.create_table(
'adventure_chunks',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('game_id', sa.UUID(), nullable=False),
sa.Column('chunk_index', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('embedding', Vector(384), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_adventure_chunks_game_id'), 'adventure_chunks', ['game_id'], unique=False
)
def downgrade() -> None:
op.drop_index(op.f('ix_adventure_chunks_game_id'), table_name='adventure_chunks')
op.drop_table('adventure_chunks')
op.drop_column('games', 'adventure_text')
+6
View File
@@ -13,6 +13,7 @@ from app.models.character import Character
from app.models.game import Game, GameParticipant from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
from app.models.user import User from app.models.user import User
from app.rag.adventure_ingestion import ingest_adventure_text
from app.schemas.game import GameCreate, GameJoin, GameRead from app.schemas.game import GameCreate, GameJoin, GameRead
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
from app.schemas.message import MessageRead from app.schemas.message import MessageRead
@@ -73,6 +74,7 @@ async def _serialize_games(
my_character_id=viewer_participation.get(game.id), my_character_id=viewer_participation.get(game.id),
status=game.status, status=game.status,
ended_reason=game.ended_reason, ended_reason=game.ended_reason,
has_adventure=bool(game.adventure_text.strip()),
) )
) )
return result return result
@@ -98,6 +100,7 @@ async def create_game(
game = Game( game = Game(
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
adventure_text=payload.adventure_text,
creator_id=user.id, creator_id=user.id,
participation_code=_generate_participation_code(), participation_code=_generate_participation_code(),
) )
@@ -108,6 +111,9 @@ async def create_game(
await session.commit() await session.commit()
await session.refresh(game) await session.refresh(game)
if payload.adventure_text.strip():
await ingest_adventure_text(session, game.id, payload.adventure_text)
serialized = await _serialize_games(session, [game], user.id) serialized = await _serialize_games(session, [game], user.id)
return serialized[0] return serialized[0]
+5
View File
@@ -12,6 +12,7 @@ from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game, monster, world_state from app.llm.tools import character_sheet, dice, end_game, monster, world_state
from app.models.game import Game from app.models.game import Game
from app.models.message import Message from app.models.message import Message
from app.rag.adventure_retrieval import build_adventure_rag_block
from app.rag.retrieval import build_rag_block from app.rag.retrieval import build_rag_block
logger = logging.getLogger("app.llm.orchestrator") logger = logging.getLogger("app.llm.orchestrator")
@@ -102,6 +103,10 @@ async def run_dm_turn(
if rag_block: if rag_block:
system_prompt = f"{system_prompt}\n\n{rag_block}" system_prompt = f"{system_prompt}\n\n{rag_block}"
adventure_block = await build_adventure_rag_block(session, game_id, latest_player_message)
if adventure_block:
system_prompt = f"{system_prompt}\n\n{adventure_block}"
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
messages.extend(await build_context(session, game_id)) messages.extend(await build_context(session, game_id))
@@ -33,6 +33,7 @@ Regeln & Wissensquellen
* Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt. * Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt.
* Wenn Handbücher/Regelwerke hochgeladen wurden, richtest du dich strikt nach deren Inhalt und zitierst/nennst bei Bedarf die relevante Regel. Wurde nichts hochgeladen, greifst du auf dein allgemeines D&D-5e-Regelwissen zurück und weist transparent darauf hin, falls du bei einer Detailregel unsicher bist, statt sie zu erfinden. * Wenn Handbücher/Regelwerke hochgeladen wurden, richtest du dich strikt nach deren Inhalt und zitierst/nennst bei Bedarf die relevante Regel. Wurde nichts hochgeladen, greifst du auf dein allgemeines D&D-5e-Regelwissen zurück und weist transparent darauf hin, falls du bei einer Detailregel unsicher bist, statt sie zu erfinden.
* Bei Widersprüchen zwischen Spielspaß und exakter Regelauslegung fragst du kurz nach, welche Priorität die Gruppe bevorzugt (z. B. eher erzählerisch-locker oder regelgenau). * Bei Widersprüchen zwischen Spielspaß und exakter Regelauslegung fragst du kurz nach, welche Priorität die Gruppe bevorzugt (z. B. eher erzählerisch-locker oder regelgenau).
* Stehen unten Auszüge aus einem vorgegebenen Abenteuer, hält sich die Handlung strikt daran – Ort, NPCs, Encounter, Plot so, wie im Text vorgegeben, statt eigene zu erfinden. Weiche nur ab, wenn die Spieler klar einen eigenen Weg einschlagen oder der Text an der Stelle keine Vorgabe macht; führe dann sinnvoll weiter und kehre zum Abenteuer zurück, sobald es passt.
Sprache Sprache
Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit. Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit.
+11 -1
View File
@@ -1,3 +1,4 @@
from app.models.adventure_chunk import AdventureChunk
from app.models.character import Character from app.models.character import Character
from app.models.game import Game, GameParticipant from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
@@ -5,4 +6,13 @@ from app.models.rulebook_chunk import RulebookChunk
from app.models.user import User from app.models.user import User
from app.models.world_state import WorldState from app.models.world_state import WorldState
__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk", "WorldState"] __all__ = [
"User",
"Game",
"GameParticipant",
"Character",
"Message",
"RulebookChunk",
"WorldState",
"AdventureChunk",
]
+23
View File
@@ -0,0 +1,23 @@
import uuid
from pgvector.sqlalchemy import Vector
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
from app.models.rulebook_chunk import EMBEDDING_DIM
class AdventureChunk(Base):
__tablename__ = "adventure_chunks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
game_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
embedding: Mapped[list[float]] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
+3
View File
@@ -27,6 +27,9 @@ class Game(Base):
# DM-only bookkeeping (name/current_hp/max_hp per monster) — never serialized to any # DM-only bookkeeping (name/current_hp/max_hp per monster) — never serialized to any
# API response, since HP/AC of NPCs is meant to stay secret from players. # API response, since HP/AC of NPCs is meant to stay secret from players.
monsters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) monsters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# Raw pasted adventure-module text (if any) — chunked/embedded into adventure_chunks for
# retrieval; kept here verbatim as the source of truth for future re-ingestion.
adventure_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), nullable=False, index=True server_default=func.now(), nullable=False, index=True
) )
+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)
+2
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel
class GameCreate(BaseModel): class GameCreate(BaseModel):
name: str name: str
description: str = "" description: str = ""
adventure_text: str = ""
class GameRead(BaseModel): class GameRead(BaseModel):
@@ -23,6 +24,7 @@ class GameRead(BaseModel):
my_character_id: uuid.UUID | None my_character_id: uuid.UUID | None
status: str status: str
ended_reason: str | None ended_reason: str | None
has_adventure: bool
class GameJoin(BaseModel): class GameJoin(BaseModel):
+6 -2
View File
@@ -14,6 +14,7 @@ export interface Game {
my_character_id: string | null; my_character_id: string | null;
status: "active" | "ended"; status: "active" | "ended";
ended_reason: string | null; ended_reason: string | null;
has_adventure: boolean;
} }
export interface Message { export interface Message {
@@ -47,8 +48,11 @@ export const runGameSetupChat = (messages: Record<string, unknown>[], signal?: A
signal, signal,
}); });
export const createGame = (name: string, description: string) => export const createGame = (name: string, description: string, adventureText?: string) =>
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) }); apiFetch<Game>("/api/games", {
method: "POST",
body: JSON.stringify({ name, description, adventure_text: adventureText ?? "" }),
});
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`); export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
+1
View File
@@ -58,6 +58,7 @@ export default function GameCard({ game }: { game: Game }) {
{game.player_count} Spieler {game.player_count} Spieler
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
</span> </span>
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
</div> </div>
</Link> </Link>
); );
+20 -2
View File
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
const [proposal, setProposal] = useState<GameSetupProposal | null>(null); const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [adventureText, setAdventureText] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -76,7 +77,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
setCreating(true); setCreating(true);
setError(null); setError(null);
try { try {
const game = await createGame(name, description); const game = await createGame(name, description, adventureText);
onCreated(game); onCreated(game);
} catch { } catch {
setError("Spiel konnte nicht erstellt werden."); setError("Spiel konnte nicht erstellt werden.");
@@ -120,13 +121,30 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400" className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
rows={3} rows={3}
/> />
<div>
<textarea
placeholder="Abenteuertext (optional) — vollständiger Text eines frei verfügbaren Abenteuers"
value={adventureText}
onChange={(e) => setAdventureText(e.target.value)}
className="w-full rounded bg-slate-800 px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-amber-400"
rows={6}
/>
<p className="mt-1 text-xs text-slate-500">
Wenn hier etwas steht, hält sich der Dungeon Master strikt an diesen Text (Handlung, NPCs,
Orte, Encounter) statt eigene Inhalte zu erfinden.
</p>
</div>
{error && <p className="text-sm text-red-400">{error}</p>} {error && <p className="text-sm text-red-400">{error}</p>}
<button <button
type="submit" type="submit"
disabled={creating} disabled={creating}
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50" className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
> >
{creating ? "Erstelle…" : "Erstellen"} {creating
? adventureText.trim()
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
: "Erstelle…"
: "Erstellen"}
</button> </button>
</form> </form>
)} )}
+3 -1
View File
@@ -163,7 +163,9 @@ export default function GameSession() {
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4"> <div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1> <h1 className="text-xl font-bold text-amber-400">
{game.name} {game.has_adventure && <span className="text-sm font-normal">📖</span>}
</h1>
<p className="text-xs text-slate-500">{game.description}</p> <p className="text-xs text-slate-500">{game.description}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">