Files
DungeonsDragons/backend/app/llm/tools/world_state.py
T
Thorsten a8763e175f 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>
2026-09-01 18:12:10 +02:00

113 lines
4.3 KiB
Python

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": (
"Persist a compact, up-to-date recap of everything that matters long-term for this game: "
"key NPCs and their status, current location, open plot threads, important party/inventory "
"state. The raw chat history you see is only a recent window — this summary is what carries "
"everything older forward. Call it regularly (at least on every scene change or major "
"development), not just at the end. Send the full current picture each time, not a diff — "
"this replaces whatever was stored before."
),
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": (
"The complete current world-state recap, in the language you're playing in. "
"A few short paragraphs or a bullet list, not a full transcript."
),
},
},
"required": ["summary"],
},
}
async def update(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
state = await session.get(WorldState, game_id)
if state is None:
state = WorldState(game_id=game_id, summary=tool_input["summary"])
session.add(state)
else:
state.summary = tool_input["summary"]
await session.commit()
return {"summary": state.summary}
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