Add a hard trigger for world-state summarization

update_world_state relied entirely on the DM remembering to call it —
no code checked whether it actually happened. Add refresh_if_due(),
called at the top of every DM turn: it counts messages since the
summary was last touched (by the tool or a prior auto-refresh), and
once AUTO_REFRESH_MESSAGE_THRESHOLD (10) is crossed, forces a
dedicated summarization call — a separate, tool-free completion whose
only job is to fold the new messages into the existing summary — and
persists the result directly, without waiting on the main DM turn's
discretion.

The trigger condition (message count) is deterministic; only the
summary text itself still needs an LLM, which is unavoidable for a
task that requires understanding, not just counting.

context.py gained count_messages_since() and build_transcript_text()
(an uncapped, since-filtered plain-text transcript for the
summarizer, as opposed to build_context()'s char-budget-capped chat
list for the main DM call) — factored out of the same underlying
message-labeling logic to avoid duplicating the name-resolution joins.

Verified with a fake LLM client: confirmed the trigger fires exactly
at the threshold and not before, resets after firing, and that the
second refresh's prompt carries the prior summary forward while only
including messages since that refresh (not the whole history again).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-01 18:12:10 +02:00
parent 81eaf667cd
commit a8763e175f
3 changed files with 105 additions and 9 deletions
+37 -7
View File
@@ -1,6 +1,7 @@
import uuid import uuid
from datetime import datetime
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character from app.models.character import Character
@@ -13,12 +14,19 @@ from app.models.user import User
MAX_CONTEXT_CHARS = 16_000 MAX_CONTEXT_CHARS = 16_000
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]: async def _load_messages(
messages = ( session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None
await session.execute( ) -> list[Message]:
select(Message).where(Message.game_id == game_id).order_by(Message.id.asc()) query = select(Message).where(Message.game_id == game_id)
) if since is not None:
).scalars().all() 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: if not messages:
return [] 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 label = f"{player_name} ({character_name})" if character_name else player_name
entries.append({"role": "user", "content": f"{label}: {m.content}"}) entries.append({"role": "user", "content": f"{label}: {m.content}"})
# sender_type == "system" messages are not sent to the model in Phase 1 # 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. # Keep the newest entries within the char budget, dropping oldest first.
total = 0 total = 0
@@ -59,3 +72,20 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]
kept.reverse() kept.reverse()
return kept 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)
+3 -2
View File
@@ -88,11 +88,12 @@ async def run_dm_turn(
f"Name: {game.name}\nBeschreibung: {game.description}" 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) world_summary = await world_state.get_summary(session, game_id)
if world_summary: if world_summary:
system_prompt = ( system_prompt = (
f"{system_prompt}\n\nBisheriger Weltzustand (von dir zuletzt über update_world_state " f"{system_prompt}\n\nBisheriger Weltzustand (automatisch/von dir über update_world_state "
f"zusammengefasst — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier " f"aktuell gehalten — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier "
f"trägt alles Ältere):\n{world_summary}" f"trägt alles Ältere):\n{world_summary}"
) )
+65
View File
@@ -1,9 +1,23 @@
import logging
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.context import build_transcript_text, count_messages_since
from app.models.world_state import WorldState 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 = { TOOL_SCHEMA = {
"name": "update_world_state", "name": "update_world_state",
"description": ( "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: async def get_summary(session: AsyncSession, game_id: uuid.UUID) -> str | None:
state = await session.get(WorldState, game_id) state = await session.get(WorldState, game_id)
return state.summary if state is not None and state.summary else None 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