Files
DungeonsDragons/backend/app/llm/context.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

92 lines
3.8 KiB
Python

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)