diff --git a/backend/app/llm/context.py b/backend/app/llm/context.py index 56ec930..3dcdf31 100644 --- a/backend/app/llm/context.py +++ b/backend/app/llm/context.py @@ -1,6 +1,7 @@ import uuid +from datetime import datetime -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.character import Character @@ -13,12 +14,19 @@ from app.models.user import User 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() +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 [] @@ -47,6 +55,11 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict] 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 @@ -59,3 +72,20 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict] 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) diff --git a/backend/app/llm/orchestrator.py b/backend/app/llm/orchestrator.py index 3bdee3c..b0d576a 100644 --- a/backend/app/llm/orchestrator.py +++ b/backend/app/llm/orchestrator.py @@ -88,11 +88,12 @@ async def run_dm_turn( f"Name: {game.name}\nBeschreibung: {game.description}" ) + await world_state.refresh_if_due(session, game_id, client, settings.dm_model) world_summary = await world_state.get_summary(session, game_id) if world_summary: system_prompt = ( - f"{system_prompt}\n\nBisheriger Weltzustand (von dir zuletzt über update_world_state " - f"zusammengefasst — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier " + f"{system_prompt}\n\nBisheriger Weltzustand (automatisch/von dir über update_world_state " + f"aktuell gehalten — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier " f"trägt alles Ältere):\n{world_summary}" ) diff --git a/backend/app/llm/tools/world_state.py b/backend/app/llm/tools/world_state.py index ebfc8d6..c5cccdb 100644 --- a/backend/app/llm/tools/world_state.py +++ b/backend/app/llm/tools/world_state.py @@ -1,9 +1,23 @@ +import logging import uuid from sqlalchemy.ext.asyncio import AsyncSession +from app.llm.context import build_transcript_text, count_messages_since from app.models.world_state import WorldState +logger = logging.getLogger("app.llm.tools.world_state") + +# After this many new messages without an update_world_state call, force a dedicated +# summarization call instead of relying on the DM to remember — see run_dm_turn(). +AUTO_REFRESH_MESSAGE_THRESHOLD = 10 + +SUMMARIZER_SYSTEM_PROMPT = ( + "Du fasst den bisherigen Verlauf eines laufenden D&D-Text-Adventures kompakt zusammen. " + "Antworte NUR mit der Zusammenfassung selbst — keine Einleitung, keine Meta-Kommentare. " + "Schreibe in der Sprache des Transkripts." +) + TOOL_SCHEMA = { "name": "update_world_state", "description": ( @@ -45,3 +59,54 @@ async def update(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> async def get_summary(session: AsyncSession, game_id: uuid.UUID) -> str | None: state = await session.get(WorldState, game_id) return state.summary if state is not None and state.summary else None + + +async def refresh_if_due(session: AsyncSession, game_id: uuid.UUID, client, model: str) -> bool: + """Hard backstop for update_world_state: if AUTO_REFRESH_MESSAGE_THRESHOLD new messages have + piled up since the summary was last touched (by the DM's tool call or a prior auto-refresh), + force a dedicated summarization call instead of trusting the DM to remember. Returns whether + it actually refreshed.""" + state = await session.get(WorldState, game_id) + since = state.updated_at if state is not None else None + + new_count = await count_messages_since(session, game_id, since) + if new_count < AUTO_REFRESH_MESSAGE_THRESHOLD: + return False + + transcript = await build_transcript_text(session, game_id, since=since) + if not transcript: + return False + + prior_summary = state.summary if state is not None and state.summary else "(noch keine)" + user_prompt = ( + f"Bisherige Zusammenfassung:\n{prior_summary}\n\n" + f"Neue Ereignisse seither:\n{transcript}\n\n" + "Schreibe die vollständige, aktuelle Zusammenfassung neu (nicht nur die neuen Ereignisse " + "anhängen): wichtige NPCs und ihr Status, aktueller Ort, offene Handlungsstränge, " + "Party-/Inventarstand. Ein paar kurze Absätze oder Stichpunkte, kein Transkript." + ) + + try: + response = await client.chat.completions.create( + model=model, + max_tokens=800, + messages=[ + {"role": "system", "content": SUMMARIZER_SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + ) + summary = response.choices[0].message.content + except Exception as exc: # noqa: BLE001 + logger.warning("Auto world-state summarization failed for game %s: %s", game_id, exc) + return False + + if not summary: + return False + + if state is None: + state = WorldState(game_id=game_id, summary=summary) + session.add(state) + else: + state.summary = summary + await session.commit() + return True