diff --git a/backend/app/alembic/versions/e5f7a1c3b982_add_world_state.py b/backend/app/alembic/versions/e5f7a1c3b982_add_world_state.py new file mode 100644 index 0000000..cb40a4d --- /dev/null +++ b/backend/app/alembic/versions/e5f7a1c3b982_add_world_state.py @@ -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') diff --git a/backend/app/llm/context.py b/backend/app/llm/context.py index ff6987a..56ec930 100644 --- a/backend/app/llm/context.py +++ b/backend/app/llm/context.py @@ -7,8 +7,10 @@ from app.models.character import Character from app.models.message import Message from app.models.user import User -# Rough char-based budget for Phase 1 (~4 chars/token, leaves headroom for system prompt + output). -MAX_CONTEXT_CHARS = 40_000 +# 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 build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]: diff --git a/backend/app/llm/orchestrator.py b/backend/app/llm/orchestrator.py index 31a47e5..3bdee3c 100644 --- a/backend/app/llm/orchestrator.py +++ b/backend/app/llm/orchestrator.py @@ -9,7 +9,7 @@ from app.config import settings from app.llm.client import get_dm_system_prompt, get_llm_client from app.llm.context import build_context 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.message import Message from app.rag.retrieval import build_rag_block @@ -24,6 +24,7 @@ TOOLS = [ {"type": "function", "function": character_sheet.TOOL_SCHEMA}, {"type": "function", "function": end_game.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) if tool_name == "update_monster_hp": 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}"} except Exception as exc: # noqa: BLE001 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}" ) + 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: rag_block = await build_rag_block(session, latest_player_message) if rag_block: diff --git a/backend/app/llm/prompts/dm_system_prompt.txt b/backend/app/llm/prompts/dm_system_prompt.txt index d6e2957..efa19c8 100644 --- a/backend/app/llm/prompts/dm_system_prompt.txt +++ b/backend/app/llm/prompts/dm_system_prompt.txt @@ -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. * 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. -* 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. * 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. diff --git a/backend/app/llm/tools/world_state.py b/backend/app/llm/tools/world_state.py new file mode 100644 index 0000000..ebfc8d6 --- /dev/null +++ b/backend/app/llm/tools/world_state.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bb395bc..1bfbef2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,5 +3,6 @@ from app.models.game import Game, GameParticipant from app.models.message import Message from app.models.rulebook_chunk import RulebookChunk 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"] diff --git a/backend/app/models/world_state.py b/backend/app/models/world_state.py new file mode 100644 index 0000000..6dcb247 --- /dev/null +++ b/backend/app/models/world_state.py @@ -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 + )