From b77f066259d78860d73af30bcf79caf90222be29 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 2 Sep 2026 13:13:59 +0200 Subject: [PATCH] Add per-game adventure text with RAG-based retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../f1a2b3c4d5e6_add_adventure_chunks.py | 45 ++++++++++++++ backend/app/api/routes_games.py | 6 ++ backend/app/llm/orchestrator.py | 5 ++ backend/app/llm/prompts/dm_system_prompt.txt | 1 + backend/app/models/__init__.py | 12 +++- backend/app/models/adventure_chunk.py | 23 ++++++++ backend/app/models/game.py | 3 + backend/app/rag/adventure_ingestion.py | 40 +++++++++++++ backend/app/rag/adventure_retrieval.py | 58 +++++++++++++++++++ backend/app/schemas/game.py | 2 + frontend/src/api/games.ts | 8 ++- frontend/src/components/GameCard.tsx | 1 + frontend/src/components/GameSetupChat.tsx | 22 ++++++- frontend/src/pages/GameSession.tsx | 4 +- 14 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 backend/app/alembic/versions/f1a2b3c4d5e6_add_adventure_chunks.py create mode 100644 backend/app/models/adventure_chunk.py create mode 100644 backend/app/rag/adventure_ingestion.py create mode 100644 backend/app/rag/adventure_retrieval.py diff --git a/backend/app/alembic/versions/f1a2b3c4d5e6_add_adventure_chunks.py b/backend/app/alembic/versions/f1a2b3c4d5e6_add_adventure_chunks.py new file mode 100644 index 0000000..4cbd1b7 --- /dev/null +++ b/backend/app/alembic/versions/f1a2b3c4d5e6_add_adventure_chunks.py @@ -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') diff --git a/backend/app/api/routes_games.py b/backend/app/api/routes_games.py index cb28043..9b2d727 100644 --- a/backend/app/api/routes_games.py +++ b/backend/app/api/routes_games.py @@ -13,6 +13,7 @@ from app.models.character import Character from app.models.game import Game, GameParticipant from app.models.message import Message 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_setup import GameSetupChatRequest, GameSetupChatResponse from app.schemas.message import MessageRead @@ -73,6 +74,7 @@ async def _serialize_games( my_character_id=viewer_participation.get(game.id), status=game.status, ended_reason=game.ended_reason, + has_adventure=bool(game.adventure_text.strip()), ) ) return result @@ -98,6 +100,7 @@ async def create_game( game = Game( name=payload.name, description=payload.description, + adventure_text=payload.adventure_text, creator_id=user.id, participation_code=_generate_participation_code(), ) @@ -108,6 +111,9 @@ async def create_game( await session.commit() 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) return serialized[0] diff --git a/backend/app/llm/orchestrator.py b/backend/app/llm/orchestrator.py index b0d576a..6bc4cbd 100644 --- a/backend/app/llm/orchestrator.py +++ b/backend/app/llm/orchestrator.py @@ -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.models.game import Game from app.models.message import Message +from app.rag.adventure_retrieval import build_adventure_rag_block from app.rag.retrieval import build_rag_block logger = logging.getLogger("app.llm.orchestrator") @@ -102,6 +103,10 @@ async def run_dm_turn( if 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.extend(await build_context(session, game_id)) diff --git a/backend/app/llm/prompts/dm_system_prompt.txt b/backend/app/llm/prompts/dm_system_prompt.txt index efa19c8..c7a11ff 100644 --- a/backend/app/llm/prompts/dm_system_prompt.txt +++ b/backend/app/llm/prompts/dm_system_prompt.txt @@ -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. * 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). +* 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 Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1bfbef2..16656c6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.adventure_chunk import AdventureChunk from app.models.character import Character from app.models.game import Game, GameParticipant 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.world_state import WorldState -__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk", "WorldState"] +__all__ = [ + "User", + "Game", + "GameParticipant", + "Character", + "Message", + "RulebookChunk", + "WorldState", + "AdventureChunk", +] diff --git a/backend/app/models/adventure_chunk.py b/backend/app/models/adventure_chunk.py new file mode 100644 index 0000000..6da7e7c --- /dev/null +++ b/backend/app/models/adventure_chunk.py @@ -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) diff --git a/backend/app/models/game.py b/backend/app/models/game.py index 1e18f97..bb6977b 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -27,6 +27,9 @@ class Game(Base): # 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. 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( server_default=func.now(), nullable=False, index=True ) diff --git a/backend/app/rag/adventure_ingestion.py b/backend/app/rag/adventure_ingestion.py new file mode 100644 index 0000000..87ab891 --- /dev/null +++ b/backend/app/rag/adventure_ingestion.py @@ -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) diff --git a/backend/app/rag/adventure_retrieval.py b/backend/app/rag/adventure_retrieval.py new file mode 100644 index 0000000..c4a9046 --- /dev/null +++ b/backend/app/rag/adventure_retrieval.py @@ -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) diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py index 31ea714..e404002 100644 --- a/backend/app/schemas/game.py +++ b/backend/app/schemas/game.py @@ -7,6 +7,7 @@ from pydantic import BaseModel class GameCreate(BaseModel): name: str description: str = "" + adventure_text: str = "" class GameRead(BaseModel): @@ -23,6 +24,7 @@ class GameRead(BaseModel): my_character_id: uuid.UUID | None status: str ended_reason: str | None + has_adventure: bool class GameJoin(BaseModel): diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index d10fc21..7164593 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -14,6 +14,7 @@ export interface Game { my_character_id: string | null; status: "active" | "ended"; ended_reason: string | null; + has_adventure: boolean; } export interface Message { @@ -47,8 +48,11 @@ export const runGameSetupChat = (messages: Record[], signal?: A signal, }); -export const createGame = (name: string, description: string) => - apiFetch("/api/games", { method: "POST", body: JSON.stringify({ name, description }) }); +export const createGame = (name: string, description: string, adventureText?: string) => + apiFetch("/api/games", { + method: "POST", + body: JSON.stringify({ name, description, adventure_text: adventureText ?? "" }), + }); export const getGame = (id: string) => apiFetch(`/api/games/${id}`); diff --git a/frontend/src/components/GameCard.tsx b/frontend/src/components/GameCard.tsx index 9ea32ba..72955dc 100644 --- a/frontend/src/components/GameCard.tsx +++ b/frontend/src/components/GameCard.tsx @@ -58,6 +58,7 @@ export default function GameCard({ game }: { game: Game }) { {game.player_count} Spieler {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} + {game.has_adventure && 📖 Eigenes Abenteuer} ); diff --git a/frontend/src/components/GameSetupChat.tsx b/frontend/src/components/GameSetupChat.tsx index efbf867..da56035 100644 --- a/frontend/src/components/GameSetupChat.tsx +++ b/frontend/src/components/GameSetupChat.tsx @@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = const [proposal, setProposal] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [adventureText, setAdventureText] = useState(""); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -76,7 +77,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = setCreating(true); setError(null); try { - const game = await createGame(name, description); + const game = await createGame(name, description, adventureText); onCreated(game); } catch { 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" rows={3} /> +
+