81eaf667cd
Context was pure full-text replay of every message, capped at 40k chars with oldest-dropped-first — no summarization/world-state layer, as called out as still-missing in an earlier conversation. Add a new update_world_state DM tool that persists a compact, DM-authored recap (key NPCs, current location, open plot threads, party/inventory state) to a new world_state table, one row per game. It's injected into the system prompt every turn, ahead of the raw message window. The DM is instructed to call it regularly — at every scene change or major event, not just at the end — sending the full current picture each time (matches the replace-not-merge pattern already used for combat_stats/abilities/equipment). Since the summary now backs up everything older, shrink the raw message window from 40k to 16k chars — it's recent continuity now, not the sole memory of the session. Full history remains available via "Volltext laden" regardless, since that reads the messages table directly rather than through this context builder. Simplified from the original plan sketch (state JSONB) to a single free-text summary field — natural-language recaps are something an LLM authors well; a structured world model would need a schema the DM would have to conform to for no real benefit here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
62 lines
2.3 KiB
Python
62 lines
2.3 KiB
Python
import uuid
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.character import Character
|
|
from app.models.message import Message
|
|
from app.models.user import User
|
|
|
|
# Rough char-based budget for the raw message window (~4 chars/token). Kept fairly small now that
|
|
# update_world_state carries older context forward — this window is just recent continuity, not
|
|
# the sole memory of the session anymore. Full history is still available via "Volltext laden".
|
|
MAX_CONTEXT_CHARS = 16_000
|
|
|
|
|
|
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]:
|
|
messages = (
|
|
await session.execute(
|
|
select(Message).where(Message.game_id == game_id).order_by(Message.id.asc())
|
|
)
|
|
).scalars().all()
|
|
if not messages:
|
|
return []
|
|
|
|
user_ids = {m.user_id for m in messages if m.user_id is not None}
|
|
character_ids = {m.character_id for m in messages if m.character_id is not None}
|
|
|
|
names: dict[uuid.UUID, str] = {}
|
|
if user_ids:
|
|
rows = (await session.execute(select(User.id, User.name).where(User.id.in_(user_ids)))).all()
|
|
names = {row.id: row.name for row in rows}
|
|
|
|
char_names: dict[uuid.UUID, str] = {}
|
|
if character_ids:
|
|
rows = (
|
|
await session.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
|
|
).all()
|
|
char_names = {row.id: row.name for row in rows}
|
|
|
|
entries: list[dict] = []
|
|
for m in messages:
|
|
if m.sender_type == "dm":
|
|
entries.append({"role": "assistant", "content": m.content})
|
|
elif m.sender_type == "player":
|
|
player_name = names.get(m.user_id, "Spieler") if m.user_id else "Spieler"
|
|
character_name = char_names.get(m.character_id) if m.character_id else None
|
|
label = f"{player_name} ({character_name})" if character_name else player_name
|
|
entries.append({"role": "user", "content": f"{label}: {m.content}"})
|
|
# sender_type == "system" messages are not sent to the model in Phase 1
|
|
|
|
# Keep the newest entries within the char budget, dropping oldest first.
|
|
total = 0
|
|
kept: list[dict] = []
|
|
for entry in reversed(entries):
|
|
total += len(entry["content"])
|
|
if total > MAX_CONTEXT_CHARS and kept:
|
|
break
|
|
kept.append(entry)
|
|
kept.reverse()
|
|
|
|
return kept
|