b77f066259
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>
59 lines
2.0 KiB
Python
59 lines
2.0 KiB
Python
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)
|