This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

View File

View File

@@ -0,0 +1,230 @@
"""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}]

View File

@@ -0,0 +1,443 @@
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Any, AsyncIterator, 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.engine.context import (
build_orchestrator_messages,
build_step_writer_messages,
build_subagent_messages,
)
from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call
from app.logging_setup import get_logger
from app.models import DeferredTrigger, Message, Session, World
from app.prompts.templates import get_prompt
log = get_logger("orchestrator")
async def run_iteration(
db: AsyncSession,
user_id: uuid.UUID,
session_id: uuid.UUID,
action_text: str,
) -> AsyncIterator[Dict[str, Any]]:
"""Run one full iteration: plan -> tools -> step -> technical side-effects.
Yields SSE-ready event dicts:
{"type": "status", "data": {"message": "..."}}
{"type": "plan", "data": {...}} # orchestrator plan with tool calls
{"type": "tool_call", "data": {"name": ..., "args": ..., "result": ...}}
{"type": "narrative_chunk", "data": {"content": "..."}}
{"type": "step_complete", "data": {"message_id": ..., "options": [...], "state": ...}}
{"type": "error", "data": {"message": "..."}}
{"type": "done", "data": {}}
"""
# Load session + world
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalars().first()
if not session:
yield {"type": "error", "data": {"message": "session_not_found"}}
return
result = await db.execute(select(World).where(World.id == session.world_id))
world = result.scalars().first()
if not world:
yield {"type": "error", "data": {"message": "world_not_found"}}
return
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
# Save the player's action as a message
next_seq = await _next_seq(db, session_id)
player_msg = Message(
session_id=session_id,
seq=next_seq,
role="user",
kind="player_action",
content=action_text,
payload={},
is_pinned=True,
hidden=False,
)
db.add(player_msg)
await db.commit()
await db.refresh(player_msg)
yield {"type": "status", "data": {"message": "planning"}}
# Subagent runner
async def _subagent(task: str, context: str) -> str:
sub_messages = await build_subagent_messages(world, task, context)
resp = await llm.chat(
messages=sub_messages,
temperature=0.7,
max_tokens=300,
purpose="subagent",
user_id=user_id,
session_id=session_id,
db=db,
)
return resp.text
ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
# === Phase 1: Orchestrator with tool calls (max 5 iterations) ===
orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text)
# Add a final user instruction forcing JSON output
orchestrator_messages.append({
"role": "user",
"content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.",
})
max_iters = 5
final_assistant_text: Optional[str] = None
final_tool_calls: List[Dict[str, Any]] = []
for i in range(max_iters):
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
response = await llm.chat(
messages=orchestrator_messages,
tools=ALL_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="orchestrator",
user_id=user_id,
session_id=session_id,
db=db,
)
if response.tool_calls:
# Append assistant message with tool_calls
orchestrator_messages.append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls,
})
# Execute each tool call
for tc in response.tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args_str = fn.get("arguments", "{}")
try:
args = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
args = {}
yield {"type": "tool_call", "data": {"name": name, "args": args}}
result_dict = await handle_tool_call(name, args, ctx)
yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
# Append tool result message
orchestrator_messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": name,
"content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800],
})
await db.commit()
continue # Let orchestrator continue with tool results
else:
# No tool calls - this is the final answer
final_assistant_text = response.text
break
if final_assistant_text is None:
# Ran out of iterations - use last text
final_assistant_text = response.text or "{}"
yield {"type": "status", "data": {"message": "writing_scene"}}
# === Parse orchestrator final response ===
parsed = _parse_orchestrator_response(final_assistant_text)
# Apply final state patch (if any)
if parsed.get("state_patch"):
from app.core.state_validator import apply_patch, validate_state
new_state = apply_patch(world.state, parsed["state_patch"])
schema = world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if ok:
world.state = new_state
else:
log.warning("state_patch_invalid", errors=errors)
# Advance time
time_advance = parsed.get("time_advance")
if time_advance and isinstance(time_advance, dict):
new_time = _advance_world_time(world.current_time, time_advance, world)
world.current_time = new_time
# Save orchestrator plan as hidden message
plan_seq = await _next_seq(db, session_id)
plan_msg = Message(
session_id=session_id,
seq=plan_seq,
role="assistant",
kind="orchestrator_plan",
content=final_assistant_text[:2000],
payload={
"assessment": parsed.get("assessment", ""),
"outcome": parsed.get("outcome", ""),
"state_patch": parsed.get("state_patch", {}),
"time_advance": time_advance,
"tool_calls_made": [tc for tc in final_tool_calls],
"scheduled_triggers": ctx.scheduled_triggers,
"rag_added": ctx.rag_added,
},
is_pinned=False,
hidden=True,
)
db.add(plan_msg)
# === Phase 2: Step writer (narrative scene) ===
narrative_prompt_parts = [parsed.get("narrative_prompt", "")]
# Add RAG context if relevant
if parsed.get("outcome"):
try:
from app.core.rag import get_rag
rag = await get_rag(settings_map)
rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map)
if rag_results:
rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results)
narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}")
except Exception as e:
log.warning("rag_lookup_failed", error=str(e))
step_messages = await build_step_writer_messages(
db=db,
world=world,
session_id=session_id,
outcome=parsed.get("outcome", action_text),
narrative_prompt="\n".join(p for p in narrative_prompt_parts if p),
)
step_resp = await llm.chat(
messages=step_messages,
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
max_tokens=800,
purpose="step",
user_id=user_id,
session_id=session_id,
db=db,
)
step_text = step_resp.text
step_options: List[str] = parsed.get("next_options", []) or []
# Try to extract structured step from JSON
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text)
if json_match:
try:
step_data = json.loads(json_match.group(0))
if "narrative" in step_data:
step_text = step_data["narrative"]
if "options" in step_data and isinstance(step_data["options"], list):
step_options = [str(o) for o in step_data["options"]][:5]
except json.JSONDecodeError:
pass
# Save narrative step message
step_seq = await _next_seq(db, session_id)
step_msg = Message(
session_id=session_id,
seq=step_seq,
role="assistant",
kind="narrative_step",
content=step_text,
payload={
"options": step_options,
"outcome": parsed.get("outcome", ""),
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
},
is_pinned=True,
hidden=False,
)
db.add(step_msg)
# === Phase 3: Update plot rails (if any) ===
rails_update = parsed.get("rails_update")
if rails_update and isinstance(rails_update, dict):
defn = dict(world.definition)
rails = dict(defn.get("plot_rails", {}))
if "main_goal" in rails_update:
rails["main_goal"] = rails_update["main_goal"]
if "new_subgoals" in rails_update:
existing = list(rails.get("subgoals", []))
existing.extend(rails_update["new_subgoals"])
rails["subgoals"] = existing
if "completed_subgoals" in rails_update:
completed = set(rails.get("completed_subgoals", []))
completed.update(rails_update["completed_subgoals"])
rails["completed_subgoals"] = list(completed)
# Remove completed from subgoals
rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed]
defn["plot_rails"] = rails
world.definition = defn
# Add RAG facts from orchestrator response
rag_facts = parsed.get("rag_facts", []) or []
if rag_facts:
from app.core.rag import get_rag
from app.models import GlossaryEntry
rag = await get_rag(settings_map)
for f in rag_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,
)
# Update session last_played_at
session.last_played_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(step_msg)
# Check for triggers that should fire immediately (fire_at <= current_time)
fired_now = await _check_due_triggers(db, session_id, world.current_time or "")
yield {
"type": "step_complete",
"data": {
"message_id": str(step_msg.id),
"seq": step_msg.seq,
"narrative": step_text,
"options": step_options,
"state": world.state,
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
"fired_triggers": fired_now,
},
}
yield {"type": "done", "data": {}}
def _parse_orchestrator_response(text: str) -> Dict[str, Any]:
"""Extract the JSON object from the orchestrator's final response."""
if not text:
return {}
import re as _re
m = _re.search(r"\{[\s\S]*\}", text)
if not m:
return {"outcome": text, "narrative_prompt": text, "next_options": []}
try:
data = json.loads(m.group(0))
return data
except json.JSONDecodeError:
return {"outcome": text, "narrative_prompt": text, "next_options": []}
async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
result = await db.execute(
select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1)
)
row = result.first()
return (row[0] + 1) if row else 1
def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str:
"""Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime."""
if not current_time:
# Try to use the world_state's world_time field
wt = (world.state or {}).get("world_time", {})
if wt:
day = int(wt.get("day", 1))
hour = int(wt.get("hour", 8))
else:
day, hour = 1, 8
else:
# Parse 'day_N_hour_H' or fall back to numbers
import re as _re
m = _re.match(r"day_(\d+)_hour_(\d+)", current_time)
if m:
day, hour = int(m.group(1)), int(m.group(2))
else:
# Try ISO format
try:
from datetime import datetime as _dt, timedelta as _td
dt = _dt.fromisoformat(current_time)
dt = dt + _td(
days=int(advance.get("days", 0)),
hours=int(advance.get("hours", 0)),
minutes=int(advance.get("minutes", 0)),
)
return dt.isoformat()
except Exception:
day, hour = 1, 8
total_minutes = day * 24 * 60 + hour * 60
total_minutes += int(advance.get("days", 0)) * 24 * 60
total_minutes += int(advance.get("hours", 0)) * 60
total_minutes += int(advance.get("minutes", 0))
new_day = total_minutes // (24 * 60)
new_hour = (total_minutes % (24 * 60)) // 60
new_time = f"day_{new_day}_hour_{new_hour}"
# Also update world_time in state if present
if world.state and "world_time" in world.state:
world.state["world_time"] = {
**world.state["world_time"],
"day": new_day,
"hour": new_hour,
}
return new_time
async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]:
"""Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers."""
import re as _re
def _parse(t: str):
m = _re.match(r"day_(\d+)_hour_(\d+)", t or "")
if m:
return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
try:
from datetime import datetime as _dt
dt = _dt.fromisoformat(t)
return int(dt.timestamp() // 60)
except Exception:
return 0
cur = _parse(current_time)
result = await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.session_id == session_id,
DeferredTrigger.fired.is_(False),
)
)
triggers = list(result.scalars().all())
fired: List[Dict[str, Any]] = []
for t in triggers:
if _parse(t.fire_at) <= cur:
t.fired = True
fired.append({
"id": str(t.id),
"fire_at": t.fire_at,
"description": t.description,
"payload": t.payload,
})
if fired:
await db.commit()
return fired

View File

View File

@@ -0,0 +1,295 @@
"""Tool definitions and handlers for the orchestrator's tool-calling loop."""
from __future__ import annotations
import json
import random
import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import build_tool_schema
from app.core.rag import get_rag
from app.core.state_validator import apply_patch, validate_state
from app.logging_setup import get_logger
from app.models import DeferredTrigger, GlossaryEntry, World
log = get_logger("tools")
# === Tool schemas (OpenAI function-calling format) ===
DICE_ROLL_SCHEMA = build_tool_schema(
name="dice_roll",
description="Roll dice. Use 'sides' (e.g. 20 for d20) and optional 'count' (default 1) and 'modifier'. Returns the rolls and total.",
params={
"type": "object",
"properties": {
"sides": {"type": "integer", "description": "Number of sides on the die, e.g. 20 for d20"},
"count": {"type": "integer", "description": "Number of dice to roll", "default": 1},
"modifier": {"type": "integer", "description": "Modifier to add to total", "default": 0},
"label": {"type": "string", "description": "What this roll represents, e.g. 'attack' or 'perception'"},
},
"required": ["sides"],
},
)
UPDATE_STATE_SCHEMA = build_tool_schema(
name="update_state",
description="Apply a patch to world state. Paths use dot notation. ops: set, unset, append, increment, remove.",
params={
"type": "object",
"properties": {
"patch": {
"type": "object",
"description": "JSON-patch object with optional keys: set, unset, append, increment, remove. Each is a dict of path->value (or list of paths for unset).",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
}
},
"required": ["patch"],
},
)
RAG_QUERY_SCHEMA = build_tool_schema(
name="rag_query",
description="Search the glossary (NPCs, locations, items, lore) for relevant facts.",
params={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Free-text search query"},
"limit": {"type": "integer", "description": "Max results", "default": 5},
},
"required": ["query"],
},
)
RAG_ADD_SCHEMA = build_tool_schema(
name="rag_add",
description="Add a new entry to the glossary (NPC, location, item, lore, event).",
params={
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event", "rule"]},
"name": {"type": "string"},
"description": {"type": "string"},
"payload": {"type": "object", "description": "Optional extra fields"},
},
"required": ["kind", "name", "description"],
},
)
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
name="schedule_trigger",
description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.",
params={
"type": "object",
"properties": {
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
"description": {"type": "string", "description": "What should happen"},
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
},
"required": ["fire_at", "description"],
},
)
ADVANCE_TIME_SCHEMA = build_tool_schema(
name="advance_time",
description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.",
params={
"type": "object",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
"reason": {"type": "string", "description": "Why time advances"},
},
},
)
RUN_SUBAGENT_SCHEMA = build_tool_schema(
name="run_subagent",
description="Spawn a sub-agent with clean context for a focused sub-task (e.g. generate NPC backstory, room description).",
params={
"type": "object",
"properties": {
"task": {"type": "string", "description": "The specific task for the sub-agent"},
"context": {"type": "string", "description": "Minimal context needed (max 200 words)"},
},
"required": ["task"],
},
)
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA,
RAG_QUERY_SCHEMA,
RAG_ADD_SCHEMA,
SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_SCHEMA,
]
# === Tool handlers ===
class ToolContext:
"""Holds everything tools need to execute."""
def __init__(
self,
db: AsyncSession,
world: World,
session_id: uuid.UUID,
user_id: uuid.UUID,
subagent_runner: Optional[Callable[[str, str], Awaitable[str]]] = None,
settings_map: Optional[Dict[str, Any]] = None,
):
self.db = db
self.world = world
self.session_id = session_id
self.user_id = user_id
self.subagent_runner = subagent_runner
self.settings_map = settings_map or {}
# Track time advancement during this iteration
self.time_advance: Dict[str, int] = {"days": 0, "hours": 0, "minutes": 0}
# Track scheduled triggers
self.scheduled_triggers: List[Dict[str, Any]] = []
# Track rag facts added
self.rag_added: List[Dict[str, Any]] = []
async def handle_tool_call(name: str, args: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
if name == "dice_roll":
sides = int(args.get("sides", 20))
count = int(args.get("count", 1))
modifier = int(args.get("modifier", 0))
label = args.get("label", "")
rolls = [random.randint(1, sides) for _ in range(max(1, count))]
total = sum(rolls) + modifier
return {"rolls": rolls, "modifier": modifier, "total": total, "label": label}
if name == "update_state":
patch = args.get("patch", {})
new_state = apply_patch(ctx.world.state, patch)
schema = ctx.world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if not ok:
return {"ok": False, "errors": errors, "state_unchanged": True}
ctx.world.state = new_state
return {"ok": True, "new_state_summary": _summarize_state(new_state)}
if name == "rag_query":
query = args.get("query", "")
limit = int(args.get("limit", 5))
rag = await get_rag(ctx.settings_map)
results = await rag.search_glossary(ctx.world.id, query, limit=limit, settings_map=ctx.settings_map)
return {"results": results}
if name == "rag_add":
kind = args.get("kind", "lore")
entry_name = args.get("name", "")
desc = args.get("description", "")
extra = args.get("payload", {}) or {}
entry = GlossaryEntry(
world_id=ctx.world.id,
session_id=ctx.session_id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
)
ctx.db.add(entry)
await ctx.db.flush()
rag = await get_rag(ctx.settings_map)
await rag.upsert_glossary(
world_id=ctx.world.id,
entry_id=entry.id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
settings_map=ctx.settings_map,
)
ctx.rag_added.append({"kind": kind, "name": entry_name, "description": desc})
return {"ok": True, "entry_id": str(entry.id)}
if name == "schedule_trigger":
fire_at = args.get("fire_at", "")
description = args.get("description", "")
payload = args.get("payload", {}) or {}
trigger = DeferredTrigger(
session_id=ctx.session_id,
fire_at=fire_at,
description=description,
payload=payload,
)
ctx.db.add(trigger)
await ctx.db.flush()
ctx.scheduled_triggers.append({
"id": str(trigger.id),
"fire_at": fire_at,
"description": description,
})
return {"ok": True, "trigger_id": str(trigger.id)}
if name == "advance_time":
days = int(args.get("days", 0))
hours = int(args.get("hours", 0))
minutes = int(args.get("minutes", 0))
ctx.time_advance["days"] += days
ctx.time_advance["hours"] += hours
ctx.time_advance["minutes"] += minutes
return {
"ok": True,
"advance": {"days": days, "hours": hours, "minutes": minutes},
"reason": args.get("reason", ""),
}
if name == "run_subagent":
if ctx.subagent_runner is None:
return {"error": "subagent_runner_not_available"}
task = args.get("task", "")
context = args.get("context", "")
try:
result = await ctx.subagent_runner(task, context)
return {"result": result}
except Exception as e:
return {"error": str(e)}
return {"error": f"unknown_tool: {name}"}
def _summarize_state(state: Dict[str, Any]) -> str:
"""Quick human-readable summary of state for the LLM."""
if not state:
return "(empty)"
parts: List[str] = []
player = state.get("player", {})
if player:
name = player.get("name", "?")
stats = player.get("stats", {})
location = player.get("location", "?")
hp = stats.get("health", "?")
hp_max = stats.get("health_max", "?")
mp = stats.get("mana", "?")
parts.append(f"player={name} hp={hp}/{hp_max} mp={mp} loc={location}")
inv = player.get("inventory", []) if isinstance(player, dict) else []
if inv:
parts.append("inv=" + ", ".join(f"{i.get('name','?')}x{i.get('qty',1)}" for i in inv[:8]))
npcs = state.get("npcs", [])
if npcs:
parts.append(f"npcs={len(npcs)}")
return " | ".join(parts)

View File

@@ -0,0 +1,267 @@
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
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 Preset, User, World
from app.prompts.templates import get_prompt
from app.schemas import WorldBuilderReply, WorldDefinition
log = get_logger("world_builder")
# In-memory store of world-builder dialogues (session_id -> dialogue state).
# For production scale, move this to Redis. For MVP single-instance it's fine.
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
async def start_world_builder(
db: AsyncSession,
user: User,
world_name: str,
language: str,
preset_id: Optional[uuid.UUID],
setting_brief: str,
character_brief: str,
rules_brief: str,
notes: str,
) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4()
llm = await LlmClient.from_db(db)
preset_payload: Optional[Dict[str, Any]] = None
if preset_id:
result = await db.execute(select(Preset).where(Preset.id == preset_id))
preset = result.scalars().first()
if preset:
preset_payload = preset.payload
user_brief = _build_user_brief(
world_name=world_name,
setting_brief=setting_brief,
character_brief=character_brief,
rules_brief=rules_brief,
notes=notes,
preset_payload=preset_payload,
language=language,
)
system_prompt = get_prompt("world_builder", language)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
]
response = await llm.chat(
messages=messages,
temperature=0.7,
purpose="world_builder",
user_id=user.id,
db=db,
)
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
"messages": messages + [{"role": "assistant", "content": response.text}],
"turn": 1,
"last_proposed": proposed.model_dump() if proposed else None,
}
return WorldBuilderReply(
session_id=session_id,
turn=1,
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
)
async def continue_world_builder(
db: AsyncSession,
user: User,
session_id: uuid.UUID,
user_message: str,
) -> WorldBuilderReply:
"""Continue an existing world-builder dialogue."""
dialogue = _DIALOGUES.get(session_id)
if not dialogue:
raise ValueError("dialogue_not_found")
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
llm = await LlmClient.from_db(db)
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
temperature=0.7,
purpose="world_builder",
user_id=user.id,
db=db,
)
dialogue["messages"].append({"role": "assistant", "content": response.text})
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
if proposed:
dialogue["last_proposed"] = proposed.model_dump()
return WorldBuilderReply(
session_id=session_id,
turn=dialogue["turn"],
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
)
async def commit_world_builder(
db: AsyncSession,
user: User,
session_id: uuid.UUID,
name: Optional[str] = None,
) -> World:
"""Commit the proposed world definition into a real World row."""
dialogue = _DIALOGUES.get(session_id)
if not dialogue:
raise ValueError("dialogue_not_found")
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
proposed = dialogue.get("last_proposed")
if not proposed:
raise ValueError("no_proposed_definition")
definition = WorldDefinition.model_validate(proposed)
world = World(
owner_id=user.id,
name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"),
definition=definition.model_dump(),
state=definition.initial_state or {},
current_time=definition.initial_time,
status="ready",
preset_id=dialogue.get("preset_id"),
)
db.add(world)
await db.commit()
await db.refresh(world)
# Clean up dialogue
_DIALOGUES.pop(session_id, None)
return world
def _build_user_brief(
world_name: str,
setting_brief: str,
character_brief: str,
rules_brief: str,
notes: str,
preset_payload: Optional[Dict[str, Any]],
language: str,
) -> str:
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
parts.append(f"Name: {world_name}")
if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
parts.append(f"Suggested rules: {json.dumps(preset_payload.get('rules', {}), ensure_ascii=False)[:400]}")
if setting_brief:
parts.append(f"Setting: {setting_brief}")
if character_brief:
parts.append(f"Character: {character_brief}")
if rules_brief:
parts.append(f"Rules: {rules_brief}")
if notes:
parts.append(f"Notes: {notes}")
parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.")
return "\n".join(parts)
def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]:
"""Extract AI message text, proposed definition (if any), is_final flag, and followup questions."""
proposed = None
is_final = False
followups: List[str] = []
# Try to find a JSON block in the response
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data:
pd = data["proposed_definition"]
if isinstance(pd, dict):
try:
proposed = WorldDefinition.model_validate(pd)
except Exception:
proposed = None
if "is_final" in data:
is_final = bool(data["is_final"])
if "followup_questions" in data and isinstance(data["followup_questions"], list):
followups = [str(q) for q in data["followup_questions"]]
if "ai_message" in data and isinstance(data["ai_message"], str):
text = data["ai_message"]
except json.JSONDecodeError:
pass
# Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final
if proposed is not None:
low = text.lower()
if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]):
is_final = True
return text, proposed, is_final, followups
def _extract_json_block(text: str) -> Optional[str]:
"""Find the first JSON object/array block in text."""
if not text:
return None
# Try fenced ```json ... ```
import re
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m:
return m.group(1)
# Try raw {...} (greedy from first { to matching })
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None