Implement the world-state summarization from the Phase 2 plan
Context was pure full-text replay of every message, capped at 40k chars with oldest-dropped-first — no summarization/world-state layer, as called out as still-missing in an earlier conversation. Add a new update_world_state DM tool that persists a compact, DM-authored recap (key NPCs, current location, open plot threads, party/inventory state) to a new world_state table, one row per game. It's injected into the system prompt every turn, ahead of the raw message window. The DM is instructed to call it regularly — at every scene change or major event, not just at the end — sending the full current picture each time (matches the replace-not-merge pattern already used for combat_stats/abilities/equipment). Since the summary now backs up everything older, shrink the raw message window from 40k to 16k chars — it's recent continuity now, not the sole memory of the session. Full history remains available via "Volltext laden" regardless, since that reads the messages table directly rather than through this context builder. Simplified from the original plan sketch (state JSONB) to a single free-text summary field — natural-language recaps are something an LLM authors well; a structured world model would need a schema the DM would have to conform to for no real benefit here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
"""add world_state table
|
||||||
|
|
||||||
|
Revision ID: e5f7a1c3b982
|
||||||
|
Revises: d4b2c8a09e51
|
||||||
|
Create Date: 2026-09-01 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e5f7a1c3b982'
|
||||||
|
down_revision: Union[str, None] = 'd4b2c8a09e51'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'world_state',
|
||||||
|
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('summary', sa.Text(), nullable=False, server_default=''),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('game_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('world_state')
|
||||||
@@ -7,8 +7,10 @@ from app.models.character import Character
|
|||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
# Rough char-based budget for Phase 1 (~4 chars/token, leaves headroom for system prompt + output).
|
# Rough char-based budget for the raw message window (~4 chars/token). Kept fairly small now that
|
||||||
MAX_CONTEXT_CHARS = 40_000
|
# 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 build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]:
|
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.config import settings
|
|||||||
from app.llm.client import get_dm_system_prompt, get_llm_client
|
from app.llm.client import get_dm_system_prompt, get_llm_client
|
||||||
from app.llm.context import build_context
|
from app.llm.context import build_context
|
||||||
from app.llm.json_utils import fix_double_escaped_unicode
|
from app.llm.json_utils import fix_double_escaped_unicode
|
||||||
from app.llm.tools import character_sheet, dice, end_game, monster
|
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.rag.retrieval import build_rag_block
|
from app.rag.retrieval import build_rag_block
|
||||||
@@ -24,6 +24,7 @@ TOOLS = [
|
|||||||
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
|
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
|
||||||
{"type": "function", "function": end_game.TOOL_SCHEMA},
|
{"type": "function", "function": end_game.TOOL_SCHEMA},
|
||||||
{"type": "function", "function": monster.TOOL_SCHEMA},
|
{"type": "function", "function": monster.TOOL_SCHEMA},
|
||||||
|
{"type": "function", "function": world_state.TOOL_SCHEMA},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ async def _execute_tool_call(
|
|||||||
return await end_game.end(session, game_id, tool_input)
|
return await end_game.end(session, game_id, tool_input)
|
||||||
if tool_name == "update_monster_hp":
|
if tool_name == "update_monster_hp":
|
||||||
return await monster.update(session, game_id, tool_input)
|
return await monster.update(session, game_id, tool_input)
|
||||||
|
if tool_name == "update_world_state":
|
||||||
|
return await world_state.update(session, game_id, tool_input)
|
||||||
return {"error": f"Unknown tool {tool_name!r}"}
|
return {"error": f"Unknown tool {tool_name!r}"}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Tool call %s failed: %s", tool_name, exc)
|
logger.warning("Tool call %s failed: %s", tool_name, exc)
|
||||||
@@ -85,6 +88,14 @@ async def run_dm_turn(
|
|||||||
f"Name: {game.name}\nBeschreibung: {game.description}"
|
f"Name: {game.name}\nBeschreibung: {game.description}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"trägt alles Ältere):\n{world_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
if latest_player_message:
|
if latest_player_message:
|
||||||
rag_block = await build_rag_block(session, latest_player_message)
|
rag_block = await build_rag_block(session, latest_player_message)
|
||||||
if rag_block:
|
if rag_block:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Phase 2: Das Spiel leiten
|
|||||||
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
|
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
|
||||||
* Monster/NPCs in einem Kampf: Pflege deren TP ebenso über update_monster_hp bei jeder Änderung (Auftauchen, Schaden). Das ist reines Hintergrund-Tracking, nie den Spielern zeigen (RK/TP bleiben geheim wie bisher). Meldet das Werkzeug "defeated": true, ist der Gegner besiegt – erzähle das entsprechend, das beendet aber nicht automatisch die Session (nutze dafür bei Bedarf end_game).
|
* Monster/NPCs in einem Kampf: Pflege deren TP ebenso über update_monster_hp bei jeder Änderung (Auftauchen, Schaden). Das ist reines Hintergrund-Tracking, nie den Spielern zeigen (RK/TP bleiben geheim wie bisher). Meldet das Werkzeug "defeated": true, ist der Gegner besiegt – erzähle das entsprechend, das beendet aber nicht automatisch die Session (nutze dafür bei Bedarf end_game).
|
||||||
* Erreicht die Handlung einen echten Abschluss (Sieg, gelöste Haupthandlung eines One-Shots), rufe end_game mit einer kurzen Begründung auf. Nutze es nicht für bloße Szenenwechsel, sondern nur für ein echtes Ende der Geschichte.
|
* Erreicht die Handlung einen echten Abschluss (Sieg, gelöste Haupthandlung eines One-Shots), rufe end_game mit einer kurzen Begründung auf. Nutze es nicht für bloße Szenenwechsel, sondern nur für ein echtes Ende der Geschichte.
|
||||||
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
|
* Weltzustand: Der Chatverlauf, den du siehst, ist nur ein begrenztes aktuelles Fenster – ältere Nachrichten fallen irgendwann heraus. Halte deshalb über update_world_state eine kompakte, aktuelle Zusammenfassung fest (wichtige NPCs und ihr Status, aktueller Ort, offene Handlungsstränge, Party-/Inventarstand). Ruf es regelmäßig auf, mindestens bei jedem Szenenwechsel oder bedeutendem Ereignis – nicht erst am Ende. Schreibe jedes Mal die vollständige aktuelle Zusammenfassung, kein Diff.
|
||||||
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
|
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
|
||||||
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
|
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.world_state import WorldState
|
||||||
|
|
||||||
|
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
|
||||||
@@ -3,5 +3,6 @@ from app.models.game import Game, GameParticipant
|
|||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.rulebook_chunk import RulebookChunk
|
from app.models.rulebook_chunk import RulebookChunk
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.world_state import WorldState
|
||||||
|
|
||||||
__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk"]
|
__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk", "WorldState"]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WorldState(Base):
|
||||||
|
__tablename__ = "world_state"
|
||||||
|
|
||||||
|
game_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user