Files
ai-rpg/backend/app/engine/context.py
2026-06-19 11:28:04 +03:00

231 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Context manager: builds the LLM prompt context with guaranteed-recent + dynamic summarization."""
from __future__ import annotations
import json
import uuid
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.logging_setup import get_logger
from app.models import Message, World
from app.prompts.templates import get_prompt
log = get_logger("context")
async def build_orchestrator_messages(
db: AsyncSession,
world: World,
session_id: uuid.UUID,
action_text: str,
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Build the messages list for the orchestrator LLM call.
Returns (messages, settings_used).
"""
settings_map = await get_all_settings(db)
recent_n = int(cast_setting("context.recent_messages", settings_map.get("context.recent_messages", 10)))
threshold = int(cast_setting("context.compress_threshold", settings_map.get("context.compress_threshold", 20)))
summary_n = int(cast_setting("context.summary_messages", settings_map.get("context.summary_messages", 10)))
# Load all messages ordered by seq
result = await db.execute(
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
)
all_msgs: List[Message] = list(result.scalars().all())
# Check if we need to compress
if len(all_msgs) >= threshold:
await _maybe_compress(db, session_id, all_msgs, summary_n, recent_n, world, settings_map)
# Reload after compression
result = await db.execute(
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
)
all_msgs = list(result.scalars().all())
# Get summary message (the latest summary before the recent window)
summary_text = ""
visible_msgs = [m for m in all_msgs if not m.hidden]
if len(visible_msgs) > recent_n:
# Look for the latest summary
summaries = [m for m in all_msgs if m.kind == "summary"]
if summaries:
summary_text = summaries[-1].content
recent = visible_msgs[-recent_n:] if visible_msgs else []
# Build orchestrator system prompt with current state
defn = world.definition or {}
system_prompt_template = get_prompt("orchestrator", world.language)
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = system_prompt_template.format(
world_name=world.name,
setting_description=defn.get("setting_description", "")[:800],
rules=json.dumps(defn.get("rules", {}), ensure_ascii=False)[:600],
current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:600],
plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400],
summary=summary_text or "(нет сводки)",
)
messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add summary as a system note if present
if summary_text:
messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"})
# Add recent visible messages
for m in recent:
if m.kind == "player_action":
messages.append({"role": "user", "content": m.content})
elif m.kind == "narrative_step":
messages.append({"role": "assistant", "content": m.content})
# The current action
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
return messages, settings_map
async def _maybe_compress(
db: AsyncSession,
session_id: uuid.UUID,
all_msgs: List[Message],
summary_n: int,
recent_n: int,
world: World,
settings_map: Dict[str, Any],
) -> None:
"""If history exceeds threshold, summarize older messages into a single summary message."""
visible = [m for m in all_msgs if not m.hidden]
if len(visible) <= recent_n + summary_n:
return
# Take the messages that will be summarized (everything before the recent window)
to_summarize = visible[:-recent_n]
if not to_summarize:
return
# Build summarization input
summary_input_lines = []
for m in to_summarize:
prefix = {
"player_action": "Игрок",
"narrative_step": "Сцена",
"summary": "Сводка",
"orchestrator_plan": "GM",
"technical_offscreen": "За кадром",
}.get(m.kind, m.kind)
summary_input_lines.append(f"{prefix}: {m.content[:300]}")
summary_input = "\n\n".join(summary_input_lines)
llm = LlmClient(settings_map)
system_prompt = get_prompt("summarizer", world.language)
response = await llm.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": summary_input[:4000]},
],
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
max_tokens=300,
purpose="summary",
session_id=session_id,
db=db,
)
# Parse summary response
summary_text = response.text
facts: List[Dict[str, Any]] = []
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", response.text)
if json_match:
try:
data = json.loads(json_match.group(0))
summary_text = data.get("summary", response.text)
facts = data.get("facts", [])
except json.JSONDecodeError:
pass
# Create summary message
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
summary_msg = Message(
session_id=session_id,
seq=next_seq,
role="system",
kind="summary",
content=summary_text,
payload={"summarized_count": len(to_summarize), "facts": facts},
is_pinned=True,
hidden=False,
)
db.add(summary_msg)
# Hide the summarized messages (but keep them in DB)
for m in to_summarize:
m.hidden = True
# Index facts into RAG glossary
if facts:
from app.core.rag import get_rag
from app.models import GlossaryEntry
rag = await get_rag(settings_map)
for f in facts:
if not isinstance(f, dict):
continue
entry = GlossaryEntry(
world_id=world.id,
session_id=session_id,
kind=f.get("kind", "lore"),
name=f.get("name", "unknown"),
description=f.get("description", ""),
payload={},
)
db.add(entry)
await db.flush()
await rag.upsert_glossary(
world_id=world.id,
entry_id=entry.id,
kind=entry.kind,
name=entry.name,
description=entry.description,
payload={},
settings_map=settings_map,
)
await db.commit()
log.info("context_compressed", session_id=str(session_id), summarized=len(to_summarize))
async def build_step_writer_messages(
db: AsyncSession,
world: World,
session_id: uuid.UUID,
outcome: str,
narrative_prompt: str,
) -> List[Dict[str, Any]]:
"""Build messages for the step writer LLM call."""
defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("step_writer", world.language).format(
setting_description=defn.get("setting_description", "")[:600],
current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
outcome=outcome,
narrative_prompt=narrative_prompt[:600],
)
return [{"role": "system", "content": system_prompt}]
async def build_subagent_messages(
world: World,
task: str,
context: str,
) -> List[Dict[str, Any]]:
"""Build messages for a clean-context sub-agent call."""
system_prompt = get_prompt("subagent", world.language).format(task=task, context=context[:600])
return [{"role": "system", "content": system_prompt}]