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,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)
|
||||
Reference in New Issue
Block a user