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:
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user