import uuid from datetime import datetime from sqlalchemy import func, 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 _load_messages( session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None ) -> list[Message]: query = select(Message).where(Message.game_id == game_id) if since is not None: query = query.where(Message.created_at > since) query = query.order_by(Message.id.asc()) return list((await session.execute(query)).scalars().all()) async def _label_messages(session: AsyncSession, messages: list[Message]) -> list[dict]: """Resolves player/character names and renders each message as a (role, content) entry — the shared groundwork for both the chat-style context and the plain-text transcript.""" 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 return entries async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]: entries = await _label_messages(session, await _load_messages(session, game_id)) # 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 async def count_messages_since(session: AsyncSession, game_id: uuid.UUID, since: datetime | None) -> int: query = select(func.count()).select_from(Message).where(Message.game_id == game_id) if since is not None: query = query.where(Message.created_at > since) return (await session.execute(query)).scalar_one() async def build_transcript_text( session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None ) -> str: """Full, uncapped plain-text transcript (optionally only messages after `since`) — used to feed the world-state summarizer, which needs everything new, not a char-budget window.""" entries = await _label_messages(session, await _load_messages(session, game_id, since=since)) lines = [f"DM: {e['content']}" if e["role"] == "assistant" else e["content"] for e in entries] return "\n\n".join(lines)