Files
ai-rpg/backend/app/engine/orchestrator.py

444 lines
16 KiB
Python
Raw Normal View History

2026-06-19 11:28:04 +03:00
"""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