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:
@@ -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')
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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<string, unknown>[], signal?: A
|
||||
signal,
|
||||
});
|
||||
|
||||
export const createGame = (name: string, description: string) =>
|
||||
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) });
|
||||
export const createGame = (name: string, description: string, adventureText?: string) =>
|
||||
apiFetch<Game>("/api/games", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, description, adventure_text: adventureText ?? "" }),
|
||||
});
|
||||
|
||||
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function GameCard({ game }: { game: Game }) {
|
||||
{game.player_count} Spieler
|
||||
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
||||
</span>
|
||||
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
const [proposal, setProposal] = useState<GameSetupProposal | null>(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<string | null>(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}
|
||||
/>
|
||||
<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>}
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{creating ? "Erstelle…" : "Erstellen"}
|
||||
{creating
|
||||
? adventureText.trim()
|
||||
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
||||
: "Erstelle…"
|
||||
: "Erstellen"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -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="mb-3 flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user