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)