"""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}]