This commit is contained in:
Mikan
2026-06-19 16:31:45 +03:00
parent d0d1f003ae
commit 5a78def096
21 changed files with 1250 additions and 548 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any, Dict, List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -142,3 +143,32 @@ async def list_users(
}
for u in users
]
@router.post("/users/{user_id}/set-active")
async def set_user_active(
user_id: UUID,
payload: Dict[str, Any] = Body(default={}),
db: AsyncSession = Depends(get_db_dep),
admin: User = Depends(require_admin),
):
"""Activate or ban a user. Banned users cannot log in (see auth.login).
Body: `{"is_active": true|false}`. Admins cannot ban themselves.
"""
is_active = bool(payload.get("is_active"))
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(status_code=404, detail="user_not_found")
if user.id == admin.id and not is_active:
raise HTTPException(status_code=400, detail="cannot_ban_self")
user.is_active = is_active
await db.commit()
return {
"id": str(user.id),
"email": user.email,
"username": user.username,
"is_admin": user.is_admin,
"is_active": user.is_active,
}

View File

@@ -36,7 +36,13 @@ async def register(payload: UserRegister, db: AsyncSession = Depends(get_db_dep)
@router.post("/login", response_model=TokenOut)
async def login(payload: UserLogin, db: AsyncSession = Depends(get_db_dep)):
result = await db.execute(select(User).where(User.email == payload.email))
# Accept either email or username in the `login` field.
login_value = (payload.login or "").strip()
if not login_value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="login_required")
result = await db.execute(
select(User).where((User.email == login_value) | (User.username == login_value))
)
user = result.scalars().first()
if not user or not verify_password(payload.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials")

View File

@@ -25,7 +25,8 @@ EDITABLE_SETTING_KEYS = {
"context.summary_messages": int,
"context.max_tokens_total": int,
"triggers.enabled": bool,
"triggers.check_interval": int,
# Note: triggers.check_interval was removed — triggers now fire in-process
# when in-game time changes, not via a polling worker.
# Embeddings / RAG
"embedding.provider": str, # "hash" | "openai"
"embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1)

View File

@@ -0,0 +1,310 @@
"""World calendar + trigger firing helpers.
Triggers fire on changes to in-world time (NOT real-time polling). When the
orchestrator advances world time (via the `advance_time` tool or the
`time_advance` field in a plan), the engine checks all unfired triggers for
that session and fires any whose `fire_at` is now <= the new world time.
Each world may define its own calendar via `world.definition.calendar`:
{
"hours_per_day": 24, # default 24
"days_per_week": 7, # informational only (not used in math)
"minutes_per_hour": 60 # default 60
}
World time is stored as a string "day_{D}_hour_{H}" (we don't track minutes
in the string to keep it compact — minutes are tracked separately in
world.state.world_time if needed).
The trigger's `fire_at` is also a "day_D_hour_H" string. We compare by
totaling the in-world minutes since day-0-hour-0 for each side.
"""
from __future__ import annotations
import json
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient
from app.core.settings_service import get_all_settings
from app.core.state_validator import apply_patch, validate_state
from app.engine.tools.tools import TRIGGER_RUNNER_TOOL_SCHEMAS
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("triggers")
_TIME_RE = re.compile(r"^day_(\d+)_hour_(\d+)(?:_min_(\d+))?$")
def get_calendar(world: World) -> Dict[str, int]:
"""Return the world's calendar config with defaults applied."""
defn = world.definition or {}
cal = (defn.get("calendar") or {}) if isinstance(defn, dict) else {}
return {
"hours_per_day": int(cal.get("hours_per_day", 24) or 24),
"minutes_per_hour": int(cal.get("minutes_per_hour", 60) or 60),
"days_per_week": int(cal.get("days_per_week", 7) or 7),
}
def parse_world_time(t: Optional[str], cal: Dict[str, int]) -> int:
"""Parse 'day_D_hour_H[_min_M]' into total in-world minutes since day 0 hour 0.
Returns 0 for unparseable input (so triggers with bad fire_at fire
immediately rather than never — fail-open for visibility).
"""
if not t:
return 0
m = _TIME_RE.match(t.strip())
if not m:
# Try ISO datetime as a fallback (rare).
try:
from datetime import datetime
return int(datetime.fromisoformat(t).timestamp() // 60)
except Exception:
return 0
day = int(m.group(1))
hour = int(m.group(2))
minute = int(m.group(3) or 0)
hours_per_day = max(1, cal.get("hours_per_day", 24))
minutes_per_hour = max(1, cal.get("minutes_per_hour", 60))
return day * hours_per_day * minutes_per_hour + hour * minutes_per_hour + minute
def format_world_time(total_minutes: int, cal: Dict[str, int]) -> str:
"""Inverse of parse_world_time: total minutes -> 'day_D_hour_H' string."""
hours_per_day = max(1, cal.get("hours_per_day", 24))
minutes_per_hour = max(1, cal.get("minutes_per_hour", 60))
minutes_per_day = hours_per_day * minutes_per_hour
day = total_minutes // minutes_per_day
rem = total_minutes % minutes_per_day
hour = rem // minutes_per_hour
minute = rem % minutes_per_hour
if minute:
return f"day_{day}_hour_{hour}_min_{minute}"
return f"day_{day}_hour_{hour}"
def advance_world_time(
current_time: Optional[str],
advance: Dict[str, int],
world: World,
) -> Tuple[str, int, int]:
"""Advance world time by days/hours/minutes, honoring the world's calendar.
Returns (new_time_string, new_total_minutes, delta_minutes).
"""
cal = get_calendar(world)
cur_total = parse_world_time(current_time, cal)
hours_per_day = cal["hours_per_day"]
minutes_per_hour = cal["minutes_per_hour"]
delta = (
int(advance.get("days", 0)) * hours_per_day * minutes_per_hour
+ int(advance.get("hours", 0)) * minutes_per_hour
+ int(advance.get("minutes", 0))
)
new_total = cur_total + delta
new_str = format_world_time(new_total, cal)
# Also update world_time in state if present.
if world.state and isinstance(world.state, dict) and "world_time" in world.state:
wt = world.state["world_time"]
if isinstance(wt, dict):
day = new_total // (hours_per_day * minutes_per_hour)
rem = new_total % (hours_per_day * minutes_per_hour)
hour = rem // minutes_per_hour
minute = rem % minutes_per_hour
wt["day"] = day
wt["hour"] = hour
wt["minute"] = minute
wt["hours_per_day"] = hours_per_day
wt["minutes_per_hour"] = minutes_per_hour
return new_str, new_total, delta
async def fire_due_triggers(
db: AsyncSession,
session_id: uuid.UUID,
world: World,
settings_map: Optional[Dict[str, Any]] = None,
user_id: Optional[uuid.UUID] = None,
) -> List[Dict[str, Any]]:
"""Fire all due triggers for this session.
"Due" = trigger.fired is False AND parse_world_time(trigger.fire_at) <=
parse_world_time(world.current_time).
Each fired trigger:
1. Calls the LLM (trigger_runner prompt) to produce narrative + state patch.
2. Applies the state patch to the world.
3. Saves a Message (visible if should_notify_player, hidden otherwise).
4. Marks trigger.fired = True.
Returns a list of fired trigger dicts (for the orchestrator to include in
the step_complete event).
"""
cal = get_calendar(world)
cur_total = parse_world_time(world.current_time, cal)
result = await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.session_id == session_id,
DeferredTrigger.fired.is_(False),
)
)
triggers = list(result.scalars().all())
if not triggers:
return []
# Sort by fire_at ascending so they fire in chronological order.
triggers.sort(key=lambda t: parse_world_time(t.fire_at, cal))
fired: List[Dict[str, Any]] = []
for trigger in triggers:
if parse_world_time(trigger.fire_at, cal) > cur_total:
continue # not due yet
try:
await _fire_one(db, trigger, session_id, world, settings_map, user_id)
fired.append({
"id": str(trigger.id),
"fire_at": trigger.fire_at,
"description": trigger.description,
"payload": trigger.payload,
})
except Exception as e:
log.error(
"trigger_fire_failed",
trigger_id=str(trigger.id),
session_id=str(session_id),
error=f"{type(e).__name__}: {e}",
)
# Mark as fired anyway so we don't retry forever on a broken trigger.
trigger.fired = True
if fired:
await db.commit()
return fired
async def _fire_one(
db: AsyncSession,
trigger: DeferredTrigger,
session_id: uuid.UUID,
world: World,
settings_map: Optional[Dict[str, Any]],
user_id: Optional[uuid.UUID],
) -> None:
"""Fire a single trigger: produce narrative + apply state patch.
Uses the `submit_trigger_result` tool (tool-calling-first design) to get
structured output from the LLM.
"""
if settings_map is None:
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
# Trigger-runner prompt is always English (system content convention);
# the LLM produces player-facing narrative in world.language (interpolated).
system_prompt = get_prompt("trigger_runner", "en").format(
description=trigger.description,
payload=json.dumps(trigger.payload, ensure_ascii=False)[:600],
state=json.dumps(world.state, ensure_ascii=False)[:1000],
world_language=world.language,
)
response = await llm.chat(
messages=[{"role": "system", "content": system_prompt}],
tools=TRIGGER_RUNNER_TOOL_SCHEMAS,
temperature=0.5,
max_tokens=600,
purpose="trigger",
user_id=user_id,
session_id=session_id,
db=db,
)
# Extract from submit_trigger_result tool call; fall back to JSON parse.
parsed: Dict[str, Any] = {}
extracted = False
for tc in (response.tool_calls or []):
if tc.get("function", {}).get("name") == "submit_trigger_result":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
parsed = json.loads(args_str) if args_str else {}
extracted = True
except json.JSONDecodeError:
pass
break
if not extracted:
m = re.search(r"\{[\s\S]*\}", response.text or "")
if m:
try:
parsed = json.loads(m.group(0))
except json.JSONDecodeError:
pass
# Apply state patch
state_patch = parsed.get("state_patch", {}) or {}
if state_patch:
new_state = apply_patch(world.state, state_patch)
schema = world.definition.get("world_schema", {})
ok, _errors = validate_state(new_state, schema)
if ok:
world.state = new_state
narrative = parsed.get("narrative", "") or ""
should_notify = bool(parsed.get("should_notify_player", True))
# Compute next message seq
seq_result = await db.execute(
select(Message.seq)
.where(Message.session_id == session_id)
.order_by(Message.seq.desc())
.limit(1)
)
row = seq_result.first()
next_seq = (row[0] + 1) if row else 1
if should_notify and narrative:
msg = Message(
session_id=session_id,
seq=next_seq,
role="system",
kind="narrative_step",
content=narrative,
payload={
"trigger_id": str(trigger.id),
"triggered_at": trigger.fire_at,
"outcome": parsed.get("outcome", trigger.description),
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
"options": [],
},
is_pinned=True,
hidden=False,
)
else:
msg = Message(
session_id=session_id,
seq=next_seq,
role="system",
kind="technical_offscreen",
content=f"[Trigger fired: {trigger.description}] Outcome: {parsed.get('outcome', '')}",
payload={
"trigger_id": str(trigger.id),
"outcome": parsed.get("outcome", ""),
"state_patch": state_patch,
},
is_pinned=False,
hidden=True,
)
db.add(msg)
trigger.fired = True
log.info("trigger_fired", trigger_id=str(trigger.id), session_id=str(session_id))

View File

@@ -58,7 +58,10 @@ async def build_orchestrator_messages(
recent = visible_msgs[-recent_n:] if visible_msgs else []
# Build orchestrator system prompt with current state
# Build orchestrator system prompt with current state.
# NOTE: prompts are always English (system content convention). The LLM
# produces player-facing text in world.language when relevant (the
# step-writer prompt interpolates world_language explicitly).
defn = world.definition or {}
system_prompt_template = get_prompt("orchestrator", world.language)
player_state = world.state.get("player", {}) if world.state else {}
@@ -69,14 +72,14 @@ async def build_orchestrator_messages(
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 "(нет сводки)",
summary=summary_text or "(no summary yet)",
)
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}"})
messages.append({"role": "system", "content": f"Past summary:\n{summary_text}"})
# Add recent visible messages
for m in recent:
@@ -86,7 +89,7 @@ async def build_orchestrator_messages(
messages.append({"role": "assistant", "content": m.content})
# The current action
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
messages.append({"role": "user", "content": f'Player action: "{action_text}"'})
return messages, settings_map
@@ -100,7 +103,11 @@ async def _maybe_compress(
world: World,
settings_map: Dict[str, Any],
) -> None:
"""If history exceeds threshold, summarize older messages into a single summary message."""
"""If history exceeds threshold, summarize older messages into a single summary message.
Uses the `submit_summary` tool (tool-calling-first design) to get structured
output from the summarizer LLM.
"""
visible = [m for m in all_msgs if not m.hidden]
if len(visible) <= recent_n + summary_n:
return
@@ -110,45 +117,61 @@ async def _maybe_compress(
if not to_summarize:
return
# Build summarization input
# Build summarization input (English labels — system content convention)
summary_input_lines = []
for m in to_summarize:
prefix = {
"player_action": "Игрок",
"narrative_step": "Сцена",
"summary": "Сводка",
"player_action": "Player",
"narrative_step": "Scene",
"summary": "Summary",
"orchestrator_plan": "GM",
"technical_offscreen": "За кадром",
"technical_offscreen": "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)
from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS
response = await llm.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": summary_input[:4000]},
],
tools=SUMMARIZER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
max_tokens=300,
max_tokens=400,
purpose="summary",
session_id=session_id,
db=db,
)
# Parse summary response
summary_text = response.text
# Extract from submit_summary tool call; fall back to text parse.
summary_text = response.text or ""
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
extracted = False
for tc in (response.tool_calls or []):
if tc.get("function", {}).get("name") == "submit_summary":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
data = json.loads(args_str) if args_str else {}
summary_text = data.get("summary", response.text or "")
facts = data.get("facts", []) or []
extracted = True
except json.JSONDecodeError:
pass
break
if not extracted:
# Fallback: extract JSON from text response (older models).
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", response.text or "")
if json_match:
try:
data = json.loads(json_match.group(0))
summary_text = data.get("summary", response.text or "")
facts = data.get("facts", []) or []
except json.JSONDecodeError:
pass
# Create summary message
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
@@ -207,7 +230,11 @@ async def build_step_writer_messages(
outcome: str,
narrative_prompt: str,
) -> List[Dict[str, Any]]:
"""Build messages for the step writer LLM call."""
"""Build messages for the step writer LLM call.
The step-writer prompt is in English (system content convention) but
instructs the LLM to produce the narrative in world.language.
"""
defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("step_writer", world.language).format(
@@ -216,6 +243,7 @@ async def build_step_writer_messages(
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
outcome=outcome,
narrative_prompt=narrative_prompt[:600],
world_language=world.language or "en",
)
return [{"role": "system", "content": system_prompt}]

View File

@@ -1,4 +1,15 @@
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step.
Design (v2 — tool-calling-first):
- The orchestrator LLM is given a set of game tools (dice_roll, update_state,
rag_query, rag_add, schedule_trigger, advance_time, run_subagent) PLUS a
`submit_plan` tool. The LLM calls game tools to execute its plan, then
calls `submit_plan` to terminate the loop with structured data.
- The step-writer LLM is given only a `submit_scene` tool. It calls this
to return the narrative + options; its text response is ignored.
- This replaces the old "return JSON in your text response" pattern which
conflicted with tool use and caused the model to dump raw JSON into chat.
"""
from __future__ import annotations
import json
@@ -11,15 +22,20 @@ 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.core.triggers import advance_world_time, fire_due_triggers
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.engine.tools.tools import (
ALL_TOOL_SCHEMAS,
STEP_WRITER_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
from app.models import Message, Session, World
log = get_logger("orchestrator")
@@ -88,19 +104,22 @@ async def run_iteration(
)
return resp.text
ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
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]] = []
parsed: Dict[str, Any] = {}
plan_tool_calls_log: List[Dict[str, Any]] = []
orchestrator_text_log: str = ""
for i in range(max_iters):
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
@@ -114,48 +133,112 @@ async def run_iteration(
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
if not response.tool_calls:
# No tool calls — model gave up or errored. Treat its text as the
# outcome directly so the player still sees SOMETHING.
log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or ""))
orchestrator_text_log = response.text or ""
parsed = {
"assessment": "(no plan submitted)",
"outcome": response.text or "",
"narrative_prompt": "",
"next_options": [],
"state_patch": {},
"time_advance": None,
"rag_facts": [],
"rails_update": None,
}
break
if final_assistant_text is None:
# Ran out of iterations - use last text
final_assistant_text = response.text or "{}"
# Append assistant message with tool_calls
orchestrator_messages.append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls,
})
# Check for submit_plan — if present, extract plan and break
submit_plan_call = None
for tc in response.tool_calls:
if tc.get("function", {}).get("name") == "submit_plan":
submit_plan_call = tc
break
if submit_plan_call:
# Extract plan from the submit_plan tool call
args_str = submit_plan_call.get("function", {}).get("arguments", "{}")
try:
parsed = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
log.warning("submit_plan_invalid_json", args=args_str[:200])
parsed = {}
# Make sure required keys exist
parsed.setdefault("assessment", "")
parsed.setdefault("outcome", "")
parsed.setdefault("narrative_prompt", "")
parsed.setdefault("next_options", [])
parsed.setdefault("state_patch", {})
parsed.setdefault("time_advance", None)
parsed.setdefault("rag_facts", [])
parsed.setdefault("rails_update", None)
# Acknowledge the tool call so the model's history is consistent
orchestrator_messages.append({
"role": "tool",
"tool_call_id": submit_plan_call.get("id", ""),
"name": "submit_plan",
"content": json.dumps({"ok": True}),
})
# Log OTHER tool calls made this iteration (for debugging)
for tc in response.tool_calls:
fn = tc.get("function", {})
if fn.get("name") != "submit_plan":
plan_tool_calls_log.append({
"name": fn.get("name"),
"args": _safe_parse_json(fn.get("arguments", "{}")),
})
break
# Otherwise: execute all tool calls and continue
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}}
try:
result_dict = await handle_tool_call(name, args, ctx)
except Exception as e:
result_dict = {"error": f"{type(e).__name__}: {e}"}
log.error("tool_call_failed", name=name, error=str(e))
yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
plan_tool_calls_log.append({"name": name, "args": args, "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()
else:
# Ran out of iterations without submit_plan — use a minimal fallback.
log.warning("orchestrator_exhausted_iterations")
parsed = parsed or {
"assessment": "(iteration limit reached)",
"outcome": orchestrator_text_log or action_text,
"narrative_prompt": "",
"next_options": [],
"state_patch": {},
"time_advance": None,
"rag_facts": [],
"rails_update": None,
}
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
@@ -170,7 +253,7 @@ async def run_iteration(
# 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)
new_time, _total, _delta = advance_world_time(world.current_time, time_advance, world)
world.current_time = new_time
# Save orchestrator plan as hidden message
@@ -180,31 +263,40 @@ async def run_iteration(
seq=plan_seq,
role="assistant",
kind="orchestrator_plan",
content=final_assistant_text[:2000],
content=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[: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],
"tool_calls_made": plan_tool_calls_log,
"scheduled_triggers": ctx.scheduled_triggers,
"rag_added": ctx.rag_added,
"narrative_prompt": parsed.get("narrative_prompt", ""),
"next_options": parsed.get("next_options", []),
},
is_pinned=False,
hidden=True,
)
db.add(plan_msg)
# === Phase 2: Step writer (narrative scene) ===
# === Phase 2: Step writer (narrative scene) — uses submit_scene tool ===
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)
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)
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))
@@ -219,28 +311,46 @@ async def run_iteration(
step_resp = await llm.chat(
messages=step_messages,
tools=STEP_WRITER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
max_tokens=800,
max_tokens=1200,
purpose="step",
user_id=user_id,
session_id=session_id,
db=db,
)
step_text = step_resp.text
# Extract scene from submit_scene tool call (if present); fall back to text.
step_text = step_resp.text or ""
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
for tc in (step_resp.tool_calls or []):
if tc.get("function", {}).get("name") == "submit_scene":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
scene_data = json.loads(args_str) if args_str else {}
if scene_data.get("narrative"):
step_text = scene_data["narrative"]
if scene_data.get("options") and isinstance(scene_data["options"], list):
step_options = [str(o) for o in scene_data["options"]][:5]
except json.JSONDecodeError:
log.warning("submit_scene_invalid_json", args=args_str[:200])
break
else:
# No submit_scene call — try to extract JSON from text as a last resort.
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "")
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
# If still no narrative, use the orchestrator's outcome as fallback.
if not step_text.strip():
step_text = parsed.get("outcome", action_text)
# Save narrative step message
step_seq = await _next_seq(db, session_id)
@@ -276,7 +386,6 @@ async def run_iteration(
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
@@ -316,8 +425,27 @@ async def run_iteration(
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 "")
# Check for triggers that should fire now (fire_at <= current world time).
# Triggers fire on in-game time changes, not real-time polling — see
# app.core.triggers. We do this AFTER committing the narrative step so the
# player sees the main scene first, then any trigger consequences.
triggers_enabled = bool(cast_setting(
"triggers.enabled",
settings_map.get("triggers.enabled", True),
))
fired_now: List[Dict[str, Any]] = []
if triggers_enabled:
try:
await db.refresh(world)
fired_now = await fire_due_triggers(
db=db,
session_id=session_id,
world=world,
settings_map=settings_map,
user_id=user_id,
)
except Exception as e:
log.warning("trigger_fire_failed_in_iteration", error=f"{type(e).__name__}: {e}")
yield {
"type": "step_complete",
@@ -335,21 +463,6 @@ async def run_iteration(
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)
@@ -358,86 +471,8 @@ async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
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
def _safe_parse_json(s: str) -> Any:
try:
return json.loads(s) if s else {}
except Exception:
return s

View File

@@ -91,13 +91,29 @@ RAG_ADD_SCHEMA = build_tool_schema(
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.",
description=(
"Schedule a deferred event tied to in-world time. When the world's "
"internal clock reaches fire_at, the engine fires the event (calls the "
"LLM with the description to produce a narrative beat and optional "
"state patch). fire_at must use the same format as world.current_time "
"('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar "
"(hours_per_day, minutes_per_hour) is honored when comparing times."
),
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"},
"fire_at": {
"type": "string",
"description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.",
},
"description": {
"type": "string",
"description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.",
},
"payload": {
"type": "object",
"description": "Optional structured payload (e.g. who, conditions, parameters).",
},
},
"required": ["fire_at", "description"],
},
@@ -106,14 +122,22 @@ SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
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.",
description=(
"Advance the world's internal clock by days / hours / minutes. Use "
"this when the player's action takes measurable in-world time (travel, "
"sleep, crafting, long rest). The world's calendar (hours_per_day, "
"minutes_per_hour) is honored. After time advances, any scheduled "
"triggers whose fire_at is now <= the new time will fire "
"automatically — so this is also how you 'run out the clock' on a "
"scheduled event."
),
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"},
"reason": {"type": "string", "description": "Why time advances (logged for debugging)."},
},
},
)
@@ -133,6 +157,219 @@ RUN_SUBAGENT_SCHEMA = build_tool_schema(
)
# === Submission tools (how the LLM returns structured results) ===
# These replace the old "return JSON in your text response" pattern, which
# conflicted with tool use and caused the model to dump raw JSON into chat.
SUBMIT_PLAN_SCHEMA = build_tool_schema(
name="submit_plan",
description=(
"Submit the orchestrator's final plan for this iteration. This MUST be "
"the last tool you call. After you call it, the iteration ends and the "
"step-writer takes over to produce the cinematic scene."
),
params={
"type": "object",
"properties": {
"assessment": {
"type": "string",
"description": "Brief assessment of the player's action (1-2 sentences, English).",
},
"outcome": {
"type": "string",
"description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.",
},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"time_advance": {
"type": "object",
"description": "How much in-world time advances. null/omitted if no time passes.",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
},
},
"narrative_prompt": {
"type": "string",
"description": "Facts the step-writer should know to write the scene (English). Max ~100 words.",
},
"next_options": {
"type": "array",
"items": {"type": "string"},
"description": "3 suggested next actions for the player (short, 5-12 words each).",
},
"rails_update": {
"type": "object",
"description": "Optional update to plot rails. Omit if no change.",
"properties": {
"main_goal": {"type": "string"},
"new_subgoals": {"type": "array", "items": {"type": "string"}},
"completed_subgoals": {"type": "array", "items": {"type": "string"}},
},
},
"rag_facts": {
"type": "array",
"description": "New persistent facts to add to the glossary. Empty array if none.",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["assessment", "outcome", "narrative_prompt", "next_options"],
},
)
SUBMIT_SCENE_SCHEMA = build_tool_schema(
name="submit_scene",
description=(
"Submit the narrative scene for this step. This is the ONLY way to "
"return the scene — your text response is ignored. The narrative "
"should be 200-400 words, cinematic, second-person ('You...'), in the "
"world's player-facing language."
),
params={
"type": "object",
"properties": {
"narrative": {
"type": "string",
"description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "Exactly 3 short (5-12 words) options for the player's next action.",
},
},
"required": ["narrative", "options"],
},
)
SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema(
name="submit_world_definition",
description=(
"Submit a proposed world definition. Call this when you have enough "
"information to build the world. Your text response will be shown to "
"the player as your conversational reply (use it to summarize the "
"proposed world in 2-4 sentences)."
),
params={
"type": "object",
"properties": {
"setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."},
"rules": {
"type": "object",
"description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).",
},
"world_schema": {
"type": "object",
"description": "JSON Schema describing the shape of the world state.",
},
"plot_rails": {
"type": "object",
"description": "{main_goal, subgoals, hooks}.",
"properties": {
"main_goal": {"type": "string"},
"subgoals": {"type": "array", "items": {"type": "string"}},
"hooks": {"type": "array", "items": {"type": "string"}},
},
},
"initial_state": {
"type": "object",
"description": "Initial world state matching world_schema.",
},
"initial_time": {
"type": "string",
"description": "World time string e.g. 'day_1_hour_8'.",
},
"calendar": {
"type": "object",
"description": "Optional. Custom calendar. Include only if non-standard.",
"properties": {
"hours_per_day": {"type": "integer"},
"minutes_per_hour": {"type": "integer"},
"days_per_week": {"type": "integer"},
},
},
"is_final": {
"type": "boolean",
"description": "True ONLY when the player has explicitly accepted the world.",
},
},
"required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"],
},
)
SUBMIT_SUMMARY_SCHEMA = build_tool_schema(
name="submit_summary",
description="Submit the compressed summary of older session messages.",
params={
"type": "object",
"properties": {
"summary": {"type": "string", "description": "3-6 sentences, max 150 words."},
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["summary", "facts"],
},
)
SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema(
name="submit_trigger_result",
description="Submit the result of firing a deferred trigger.",
params={
"type": "object",
"properties": {
"outcome": {"type": "string", "description": "1-2 sentences, English. For logs."},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."},
"should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."},
},
"required": ["outcome", "narrative", "should_notify_player"],
},
)
# Tools available to the orchestrator (game-loop tools + submit_plan)
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA,
@@ -141,8 +378,21 @@ ALL_TOOL_SCHEMAS = [
SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_SCHEMA,
SUBMIT_PLAN_SCHEMA,
]
# Tools for the step writer (only submit_scene)
STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA]
# Tools for the world builder (only submit_world_definition)
WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA]
# Tools for the summarizer
SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA]
# Tools for the trigger runner
TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA]
# === Tool handlers ===

View File

@@ -1,4 +1,13 @@
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.
Design (v2 — tool-calling-first):
The world-builder LLM is given a single tool, `submit_world_definition`,
which it calls when it has enough information to propose a world. The LLM's
text response is the conversational reply shown to the player (in
world.language). This replaces the old "return JSON in your text response"
pattern which conflicted with tool use and caused raw JSON to leak into
the chat.
"""
from __future__ import annotations
import json
@@ -14,6 +23,7 @@ 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
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
log = get_logger("world_builder")
@@ -36,7 +46,8 @@ async def start_world_builder(
) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4()
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
preset_payload: Optional[Dict[str, Any]] = None
if preset_id:
@@ -55,7 +66,9 @@ async def start_world_builder(
language=language,
)
system_prompt = get_prompt("world_builder", language)
# System prompt is English (system content convention). The LLM is told to
# produce player-facing text in `language` (interpolated as world_language).
system_prompt = get_prompt("world_builder", language).format(world_language=language)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
@@ -63,20 +76,27 @@ async def start_world_builder(
response = await llm.chat(
messages=messages,
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
"messages": messages + [{"role": "assistant", "content": response.text}],
"messages": messages + [
{
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
},
],
"turn": 1,
"last_proposed": proposed.model_dump() if proposed else None,
}
@@ -87,7 +107,7 @@ async def start_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -104,20 +124,26 @@ async def continue_world_builder(
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
dialogue["messages"].append({"role": "assistant", "content": response.text})
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
if proposed:
dialogue["last_proposed"] = proposed.model_dump()
@@ -127,7 +153,7 @@ async def continue_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -151,7 +177,7 @@ async def commit_world_builder(
world = World(
owner_id=user.id,
name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"),
language=dialogue.get("language", "en"),
definition=definition.model_dump(),
state=definition.initial_state or {},
current_time=definition.initial_time,
@@ -176,7 +202,8 @@ def _build_user_brief(
preset_payload: Optional[Dict[str, Any]],
language: str,
) -> str:
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
parts = [f"=== WORLD BRIEF ==="]
parts.append(f"Player-facing language: {language}")
parts.append(f"Name: {world_name}")
if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
@@ -189,57 +216,78 @@ def _build_user_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.")
parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.")
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
def _extract_world_definition(
text: str,
tool_calls: Optional[List[Dict[str, Any]]],
) -> tuple[str, Optional[WorldDefinition], bool]:
"""Extract AI message text, proposed definition (if any), and is_final flag.
Looks for a `submit_world_definition` tool call first. Falls back to
JSON-in-text parse for older models that don't honor the tool.
"""
proposed: Optional[WorldDefinition] = None
is_final = False
followups: List[str] = []
ai_text = text or ""
# 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
# 1) Prefer the submit_world_definition tool call (the proper way).
if tool_calls:
for tc in tool_calls:
if tc.get("function", {}).get("name") == "submit_world_definition":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
data = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
data = {}
proposed = _try_build_definition(data)
is_final = bool(data.get("is_final", False))
break
# 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"]):
# 2) Fallback: parse a JSON block from the text (older models).
if proposed is None:
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
proposed = _try_build_definition(data["proposed_definition"])
if "is_final" in data:
is_final = bool(data["is_final"])
if "ai_message" in data and isinstance(data["ai_message"], str):
ai_text = data["ai_message"]
except json.JSONDecodeError:
pass
# 3) Heuristic: if a definition was proposed and the text mentions "ready",
# mark as final.
if proposed is not None and not is_final:
low = ai_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
return ai_text, proposed, is_final
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
try:
return WorldDefinition.model_validate(data)
except Exception as e:
log.warning("world_definition_invalid", error=str(e))
return None
def _extract_json_block(text: str) -> Optional[str]:
"""Find the first JSON object/array block in text."""
"""Find the first JSON object/array block in text (fallback path only)."""
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

View File

@@ -44,8 +44,7 @@ DEFAULT_SETTINGS = [
("context.compress_threshold", settings.default_compress_threshold, "Trigger compression at this count"),
("context.summary_messages", settings.default_summary_messages, "Number of messages per summary block"),
("context.max_tokens_total", 6000, "Soft token budget for context window (small models)"),
("triggers.enabled", True, "Enable deferred trigger processing"),
("triggers.check_interval", 30, "Trigger checker interval, seconds"),
("triggers.enabled", True, "Enable trigger firing on in-game time changes"),
# Embeddings / RAG
("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"),
("embedding.base_url", settings.default_embedding_base_url, "OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url"),
@@ -56,6 +55,23 @@ DEFAULT_SETTINGS = [
]
# Keys whose values come from environment variables (via Settings fields).
# These are re-applied on EVERY startup so .env is the source of truth.
# Admin-panel changes to these keys are runtime overrides that get reset on
# restart unless the operator also updates .env.
ENV_DERIVED_SETTING_KEYS = {
"llm.base_url",
"llm.api_key",
"llm.model",
"embedding.provider",
"embedding.base_url",
"embedding.api_key",
"embedding.model",
"embedding.dim",
"embedding.request_timeout",
}
async def init_db() -> None:
setup_logging()
log.info("creating_tables")
@@ -75,6 +91,7 @@ async def init_db() -> None:
)
)
await _seed_settings(session)
await _sync_env_derived_settings(session)
await _seed_builtin_presets(session)
await session.commit()
except Exception as e:
@@ -82,12 +99,19 @@ async def init_db() -> None:
log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}")
async with AsyncSessionLocal() as session:
await _seed_settings(session)
await _sync_env_derived_settings(session)
await _seed_builtin_presets(session)
await session.commit()
# Ensure admin_setup_token is set; if empty, generate and print
# Ensure admin_setup_token is set and print it on every startup.
#
# The admin-setup endpoint refuses to create a second admin (see app/api/auth.py),
# so it's safe to always print the token — even after an admin exists, the token
# is useless. We print on every startup (not just first run) so the operator can
# always find the URL in the logs without having to dig through old logs.
token = settings.admin_setup_token.strip()
if not token:
# No token forced via env — generate one and persist it (idempotent).
import secrets as _s
token = _s.token_urlsafe(24)
async with AsyncSessionLocal() as session:
@@ -97,17 +121,25 @@ async def init_db() -> None:
session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
try:
await session.commit()
print("=" * 60)
print("ADMIN SETUP TOKEN (use at /admin/setup):")
print(token)
print("=" * 60)
log.info("admin_setup_token_generated")
except IntegrityError:
# Another process inserted it concurrently — fine.
# Another process inserted it concurrently — re-read.
await session.rollback()
log.info("admin_setup_token_already_set")
await session.rollback()
existing = await session.execute(select(Setting).where(Setting.key == "admin.setup_token"))
existing_obj = existing.scalars().first()
if existing_obj is not None:
token = str(existing_obj.value)
else:
log.info("admin_setup_token_already_set")
# Use the persisted token (env was empty, DB has one).
token = str(existing_obj.value)
# Always print — operator convenience.
print("=" * 60)
print("ADMIN SETUP URL:")
print(f" /admin/setup")
print("ADMIN SETUP TOKEN:")
print(f" {token}")
print("=" * 60)
log.info("admin_setup_token_printed")
async def _seed_settings(session) -> None:
@@ -135,6 +167,46 @@ async def _seed_settings(session) -> None:
log.info("settings_already_exist")
async def _sync_env_derived_settings(session) -> None:
"""Re-apply env-derived setting values from .env on every startup.
This makes .env the source of truth for these keys: changing .env and
restarting the container takes effect immediately. Admin-panel edits to
these keys are runtime overrides that are reset on the next restart
(unless the operator also updates .env).
Only the env-derived keys (see ENV_DERIVED_SETTING_KEYS) are touched;
other settings (temperature, context params, etc.) are preserved as
configured via the admin panel.
"""
env_values = {key: value for key, value, _desc in DEFAULT_SETTINGS if key in ENV_DERIVED_SETTING_KEYS}
updated = 0
for key, new_value in env_values.items():
result = await session.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
if row is None:
# Shouldn't happen (seeded above) but handle defensively.
session.add(Setting(key=key, value=new_value, description="Env-derived"))
updated += 1
else:
if row.value != new_value:
log.info(
"env_setting_resynced",
key=key,
old_value=str(row.value)[:80],
new_value=str(new_value)[:80],
)
row.value = new_value
updated += 1
if updated:
try:
await session.commit()
log.info("env_settings_synced", count=updated)
except IntegrityError:
await session.rollback()
log.warning("env_settings_sync_failed_concurrent")
async def _seed_builtin_presets(session) -> None:
"""Insert built-in presets if none exist yet."""
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))

View File

@@ -34,7 +34,7 @@ class User(Base):
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferred_language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
preferred_language: Mapped[str] = mapped_column(String(8), default="en", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
worlds: Mapped[List["World"]] = relationship(back_populates="owner", cascade="all, delete-orphan")
@@ -58,7 +58,7 @@ class Preset(Base):
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
language: Mapped[str] = mapped_column(String(8), default="en", nullable=False)
is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# JSON: world_schema, default_rules, initial_state, world_seed_prompt, suggested_system_prompt
@@ -73,7 +73,7 @@ class World(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
language: Mapped[str] = mapped_column(String(8), default="en", nullable=False)
# Frozen world definition: setting description, rules, world_schema (JSON Schema for state), plot_rails
definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# Current live state of the world (player character, NPC, inventory, time, etc.)
@@ -95,7 +95,7 @@ class Session(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
world_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("worlds.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), default="Новая сессия", nullable=False)
title: Mapped[str] = mapped_column(String(255), default="New session", nullable=False)
# Snapshot of world state at session start (we mutate world.state during play; session stores narrative history)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)

View File

@@ -1,104 +1,49 @@
"""System prompts for all LLM stages. Bilingual (RU/EN)."""
from __future__ import annotations
"""System prompts for all LLM stages.
from typing import Dict
IMPORTANT: All system prompts are in English (per the convention that
"invisible" content the LLM processes internally should be English for best
tokenization and instruction-following, regardless of the world's player-
facing language). The LLM is instructed to produce player-facing narrative
in world.language.
These prompts use a tool-calling-first design: instead of asking the LLM to
emit JSON in its text response (which conflicts with tool use and produces
"raw JSON in chat" bugs), the LLM is given a `submit_*` tool whose arguments
carry the structured data. The LLM's text response is the human-readable
message to the user.
"""
from __future__ import annotations
# === World Builder ===
WORLD_BUILDER_SYSTEM_RU = """Ты — опытный архитектор миров для ролевой игры.
Твоя задача — помочь игроку создать мир через диалог. Игрок даёт начальный бриф (сеттинг, персонаж, правила, заметки).
Ты должен:
1. Если информации мало — задать 2-4 уточняющих вопроса коротко и по делу.
2. Если информации достаточно — построить complete world definition и представить его игроку как draft.
3. Принять правки и уточнения, цикл продолжается пока игрок не скажет "готово".
Структура world definition (выводи в JSON в поле proposed_definition когда считаешь что мир готов или близок):
{
"setting_description": "расширенный сеттинг (1-2 абзаца)",
"rules": {объект с правилами: статы, бой, магия, время, инвентарь, смерть},
"world_schema": {JSON Schema для состояния мира: player, npcs, locations, world_time, flags},
"plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
"initial_state": {начальное состояние мира согласно schema},
"initial_time": "строка времени мира (например 'day_1_hour_8')"
}
ВАЖНО для small models:
- Будь лаконичен. Не более 200 слов в каждом сообщении.
- JSON выводи строго валидный, без комментариев.
- В каждом ответе: либо задавай вопросы (если данных мало), либо давай proposed_definition.
- Когда мир готов — поставь is_final=true (но только если игрок согласился).
"""
WORLD_BUILDER_SYSTEM_EN = """You are a master world-builder for a role-playing game.
WORLD_BUILDER_SYSTEM = """You are a master world-builder for a role-playing game.
Your job is to help the player design a world through dialogue. The player gives a brief (setting, character, rules, notes).
You must:
1. If information is sparse — ask 2-4 short, focused clarifying questions.
2. If information is sufficient — build a complete world definition and present it as a draft.
3. Accept edits and clarifications; the loop continues until the player says "ok".
World definition structure (output in JSON as proposed_definition when the world is ready or near-ready):
{
"setting_description": "expanded setting (1-2 paragraphs)",
"rules": {object with rules: stats, combat, magic, time, inventory, death},
"world_schema": {JSON Schema for world state: player, npcs, locations, world_time, flags},
"plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
"initial_state": {initial world state matching schema},
"initial_time": "world time string (e.g. 'day_1_hour_8')"
}
Workflow:
1. If information is sparse — ask 2-4 short, focused clarifying questions in your reply text.
2. If information is sufficient — propose a world definition by calling the `submit_world_definition` tool. Also write a short summary of the proposed world in your reply text (2-4 sentences) so the player can react to it.
3. Accept edits and clarifications; the loop continues until the player says the world is ready.
CRITICAL for small models:
- Be concise. Max 200 words per message.
- Output strictly valid JSON, no comments.
- In each reply: either ask questions (if data is sparse), or give proposed_definition.
- When world is ready — set is_final=true (only if the player agreed).
When calling `submit_world_definition`:
- `setting_description`: 1-2 paragraph expanded setting.
- `rules`: object with keys like `stats`, `combat`, `magic`, `time`, `inventory`, `death` (whichever apply).
- `world_schema`: a JSON Schema describing the shape of the world's state (player, npcs, locations, world_time, flags, etc.).
- `plot_rails`: `{main_goal, subgoals, hooks}`.
- `initial_state`: the initial world state matching `world_schema`.
- `initial_time`: world-time string in the form `day_N_hour_H` (e.g. `day_1_hour_8`).
- `calendar`: optional. `{hours_per_day: 24, minutes_per_hour: 60, days_per_week: 7}`. Include only if the world uses a non-standard calendar (e.g. 28-hour days).
- `is_final`: set to `true` ONLY when the player has explicitly accepted the world.
CRITICAL:
- Be concise. Max 200 words of text per message.
- The reply text is shown to the player in their language ({world_language}). Write in that language.
- The `submit_world_definition` arguments are machine-parsed — keep them structured and valid.
- If you only need to ask questions, do NOT call `submit_world_definition` yet.
"""
# === Orchestrator (main game loop with tools) ===
ORCHESTRATOR_SYSTEM_RU = """Ты — Game Master ролевой игры. Ведёшь сессию через инструментальные вызовы.
ТЕКУЩИЙ КОНТЕКСТ:
- Мир: {world_name}
- Сеттинг: {setting_description}
- Правила: {rules}
- Текущее время мира: {current_time}
- Состояние игрока: {player_state}
- Главные рельсы сюжета: {plot_rails}
- Сводка прошлого: {summary}
ЗАДАЧА:
Игрок сделал действие: "{action_text}"
Оцени реалистичность (соответствие сеттингу и правилам), спланируй что должно произойти, используй инструменты для:
- бросков кубиков (dice_roll)
- обновления состояния (update_state)
- проверки/добавления фактов в RAG (rag_query, rag_add)
- планирования отложенных событий (schedule_trigger)
- обновления времени мира (advance_time)
- запуска sub-агента для генерации деталей с чистым контекстом (run_subagent)
После выполнения плана — верни ответ в виде JSON (без текста вне JSON):
{
"assessment": "краткая оценка действия (1-2 предложения)",
"outcome": "что произошло (сырой, 1-3 предложения)",
"state_patch": {JSON-patch для состояния мира},
"time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
"narrative_prompt": "факты которые должен знать step-writer для написания сценария",
"next_options": ["вариант 1", "вариант 2", "вариант 3"],
"triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
"rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
"rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
}
ВАЖНО:
- Экономь токены. Минимум 1-3 tool calls на итерацию, не больше 5.
- Если действие тривиальное — пропусти dice_roll.
- Не пиши сценарное описание — это задача step-writer.
- Соблюдай сеттинг.
"""
ORCHESTRATOR_SYSTEM_EN = """You are the Game Master of a role-playing game. You run the session through tool calls.
ORCHESTRATOR_SYSTEM = """You are the Game Master of a role-playing game. You run the session through tool calls.
CURRENT CONTEXT:
- World: {world_name}
@@ -110,64 +55,24 @@ CURRENT CONTEXT:
- Past summary: {summary}
TASK:
The player performed action: "{action_text}"
Assess realism (consistency with setting and rules), plan what should happen, use tools to:
- roll dice (dice_roll)
- update state (update_state)
- query / add facts to RAG (rag_query, rag_add)
- schedule deferred events (schedule_trigger)
- advance world time (advance_time)
- spawn a sub-agent for detail generation with clean context (run_subagent)
The player performed the action: "{action_text}"
After executing the plan — return your reply as JSON (no text outside JSON):
{
"assessment": "brief assessment of the action (1-2 sentences)",
"outcome": "what happened (raw, 1-3 sentences)",
"state_patch": {JSON-patch for world state},
"time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
"narrative_prompt": "facts the step-writer should know to write the scene",
"next_options": ["option 1", "option 2", "option 3"],
"triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
"rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
"rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
}
Assess realism (consistency with setting and rules), plan what should happen, then:
1. Use tools to execute the plan (dice_roll, update_state, rag_query, rag_add, schedule_trigger, advance_time, run_subagent) as needed.
2. After all your tool calls, call `submit_plan` with your structured plan. The plan's `outcome` and `narrative_prompt` will be passed to the step-writer to produce the cinematic scene.
CRITICAL:
- Save tokens. 1-3 tool calls per iteration, max 5.
CRITICAL RULES:
- Use 1-3 tool calls per iteration. Max 5.
- Skip dice_roll for trivial actions.
- Do NOT write the narrative scene — that's the step-writer's job.
- Do NOT write the narrative scene — that is the step-writer's job. Your job is to plan and execute mechanics.
- The `submit_plan` call MUST be your last action. After you call it, the iteration ends.
- Stay in setting.
- All tool arguments are structured (JSON). Your text response is ignored — only tool calls matter.
"""
# === Step Writer ===
STEP_WRITER_SYSTEM_RU = """Ты — сценарист ролевой игры. Превращаешь сырой outcome в сценарный шаг как в книге.
КОНТЕКСТ:
- Сеттинг: {setting_description}
- Текущее время мира: {current_time}
- Состояние игрока: {player_state}
- Что произошло (сырое): {outcome}
- Дополнительные факты: {narrative_prompt}
НАПИШИ:
1. Сценарное описание (2-4 абзаца, кинематографично, от второго лица "Ты...").
2. В конце — 3 опции следующего действия (короткие, 5-12 слов).
Формат ответа (строгий JSON):
{
"narrative": "...",
"options": ["...", "...", "..."]
}
ВАЖНО:
- 200-400 слов сценария. Не больше.
- Не повторяй то что игрок уже знает.
- Заканчивай клиффхэнгером или моментом выбора.
"""
STEP_WRITER_SYSTEM_EN = """You are the narrative writer of a role-playing game. You turn raw outcome into a book-like scene.
STEP_WRITER_SYSTEM = """You are the narrative writer of a role-playing game. You turn a raw outcome into a book-like scene.
CONTEXT:
- Setting: {setting_description}
@@ -176,120 +81,86 @@ CONTEXT:
- What happened (raw): {outcome}
- Additional facts: {narrative_prompt}
WRITE:
1. Narrative description (2-4 paragraphs, cinematic, second-person "You...").
2. End with 3 options for the next action (short, 5-12 words).
Response format (strict JSON):
{
"narrative": "...",
"options": ["...", "...", "..."]
}
YOUR JOB:
1. Call the `submit_scene` tool with:
- `narrative`: 200-400 words of cinematic, second-person ("You...") prose describing what happens.
- `options`: exactly 3 short (5-12 words) options for the player's next action.
2. Your text response is ignored — only the `submit_scene` tool call is used.
CRITICAL:
- 200-400 words of narrative. Not more.
- Don't repeat what the player already knows.
- Write the narrative in {world_language}.
- Do NOT repeat what the player already knows.
- End with a cliffhanger or decision moment.
- The scene must be consistent with the outcome — do not contradict it.
"""
# === Summarizer ===
SUMMARIZER_SYSTEM_RU = """Ты сжимаешь историю ролевой сессии. Дано несколько сообщений — выдай компактную сводку.
SUMMARIZER_SYSTEM = """You compress the history of a role-playing session. Given several messages — produce a compact summary.
Выведи:
1. summary: 3-6 предложений ключевых событий и изменений состояния.
2. facts: массив важных устойчивых фактов [{kind, name, description}] (коротко).
Call the `submit_summary` tool with:
- `summary`: 3-6 sentences of key events and state changes (max 150 words).
- `facts`: array of important persistent facts `[{kind, name, description}]` where kind is one of npc, location, item, lore, event.
Формат (строгий JSON):
{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
ВАЖНО: Не более 150 слов в summary. Сохраняй имена, числа, важные изменения.
"""
SUMMARIZER_SYSTEM_EN = """You compress the history of a role-playing session. Given several messages — produce a compact summary.
Output:
1. summary: 3-6 sentences of key events and state changes.
2. facts: array of important persistent facts [{kind, name, description}] (brief).
Format (strict JSON):
{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
CRITICAL: Max 150 words in summary. Preserve names, numbers, important changes.
CRITICAL: Preserve names, numbers, and important state changes. Your text response is ignored — only the `submit_summary` tool call is used.
"""
# === Sub-agent (clean context detail generator) ===
SUBAGENT_SYSTEM_RU = """Ты — суб-агент с чистым контекстом. Получаешь задачу от главного GM, выдаёшь конкретный результат.
Задача: {task}
Контекст: {context}
Дай компактный, сфокусированный ответ. Не более 150 слов.
"""
SUBAGENT_SYSTEM_EN = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result.
SUBAGENT_SYSTEM = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result.
Task: {task}
Context: {context}
Give a compact, focused answer. Max 150 words.
"""
Give a compact, focused answer. Max 150 words. Your text response IS the result (no tool call needed)."""
# === Trigger runner ===
TRIGGER_RUNNER_SYSTEM_RU = """Ты обрабатываешь отложенное событие в ролевой игре.
TRIGGER_RUNNER_SYSTEM = """You process a deferred event in a role-playing game. A scheduled trigger has fired.
Событие: {description}
Payload: {payload}
Текущее состояние мира: {state}
Верни JSON:
{
"outcome": "что произошло (1-2 предложения)",
"state_patch": {JSON-patch},
"narrative": "сценарное описание для игрока (1 абзац, опционально если игрок не видит — пустая строка)",
"should_notify_player": true|false
}
"""
TRIGGER_RUNNER_SYSTEM_EN = """You process a deferred event in a role-playing game.
Event: {description}
Payload: {payload}
Event description: {description}
Event payload: {payload}
Current world state: {state}
Return JSON:
{
"outcome": "what happened (1-2 sentences)",
"state_patch": {JSON-patch},
"narrative": "scene description for the player (1 paragraph, optional — empty string if player doesn't witness)",
"should_notify_player": true|false
}
"""
The player-facing narrative must be written in: {world_language}.
Call the `submit_trigger_result` tool with:
- `outcome`: 1-2 sentence raw description of what happened (English, for logs).
- `state_patch`: JSON-patch for world state (set/unset/append/increment/remove). Empty object if no state change.
- `narrative`: 1-paragraph scene description for the player, in {world_language}. Empty string if the player doesn't witness the event.
- `should_notify_player`: true if the narrative should be shown to the player, false if it's an offscreen event.
Your text response is ignored — only the tool call is used."""
PROMPTS = {
"ru": {
"world_builder": WORLD_BUILDER_SYSTEM_RU,
"orchestrator": ORCHESTRATOR_SYSTEM_RU,
"step_writer": STEP_WRITER_SYSTEM_RU,
"summarizer": SUMMARIZER_SYSTEM_RU,
"subagent": SUBAGENT_SYSTEM_RU,
"trigger_runner": TRIGGER_RUNNER_SYSTEM_RU,
},
"en": {
"world_builder": WORLD_BUILDER_SYSTEM_EN,
"orchestrator": ORCHESTRATOR_SYSTEM_EN,
"step_writer": STEP_WRITER_SYSTEM_EN,
"summarizer": SUMMARIZER_SYSTEM_EN,
"subagent": SUBAGENT_SYSTEM_EN,
"trigger_runner": TRIGGER_RUNNER_SYSTEM_EN,
"world_builder": WORLD_BUILDER_SYSTEM,
"orchestrator": ORCHESTRATOR_SYSTEM,
"step_writer": STEP_WRITER_SYSTEM,
"summarizer": SUMMARIZER_SYSTEM,
"subagent": SUBAGENT_SYSTEM,
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
},
# Russian keys kept for backward compatibility but always return the English
# prompts — system content is always English per the project convention.
"ru": {
"world_builder": WORLD_BUILDER_SYSTEM,
"orchestrator": ORCHESTRATOR_SYSTEM,
"step_writer": STEP_WRITER_SYSTEM,
"summarizer": SUMMARIZER_SYSTEM,
"subagent": SUBAGENT_SYSTEM,
"trigger_runner": TRIGGER_RUNNER_SYSTEM,
},
}
def get_prompt(stage: str, language: str = "ru") -> str:
lang = language if language in PROMPTS else "ru"
return PROMPTS[lang].get(stage, PROMPTS["ru"][stage])
def get_prompt(stage: str, language: str = "en") -> str:
"""Return the system prompt for `stage`.
`language` is kept for backward compatibility but is ignored — all system
prompts are English by design. Player-facing output language is controlled
via prompts that interpolate {world_language}.
"""
lang = language if language in PROMPTS else "en"
return PROMPTS[lang].get(stage, PROMPTS["en"][stage])

View File

@@ -16,7 +16,8 @@ class UserRegister(BaseModel):
class UserLogin(BaseModel):
email: EmailStr
# Accepts either email or username — the backend resolves it.
login: str
password: str
@@ -72,7 +73,7 @@ class PresetCreate(BaseModel):
slug: str
title: str
description: Optional[str] = None
language: str = "ru"
language: str = "en"
is_public: bool = True
payload: Dict[str, Any]
@@ -80,7 +81,7 @@ class PresetCreate(BaseModel):
# === Worlds ===
class WorldCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
language: str = "ru"
language: str = "en"
preset_id: Optional[UUID] = None
@@ -120,7 +121,7 @@ class WorldUpdate(BaseModel):
class WorldBuilderStart(BaseModel):
"""Kick off a new world-building conversation."""
world_name: str = Field(min_length=1, max_length=255)
language: str = "ru"
language: str = "en"
# Either pick a preset to start from, or fill the freeform brief.
preset_id: Optional[UUID] = None
setting_brief: str = ""

View File

@@ -1,9 +1,12 @@
"""Worker entrypoint: runs trigger checker + future background jobs.
"""Worker entrypoint.
Waits for the database (and required tables) to be ready before starting
the background loops. This prevents the worker from crashing when the
backend hasn't finished running `init_db()` yet (typical docker-compose
race condition where both services start in parallel).
Historically this ran a trigger-polling loop. Triggers now fire in-process
inside the orchestrator when in-game time changes (see app.core.triggers),
so the worker has nothing to do at the moment. We keep the container running
as a placeholder for future background jobs (RAG re-indexer, summary
compactor, etc.).
If you add a background job, register it in `asyncio.gather(...)` below.
"""
from __future__ import annotations
@@ -11,7 +14,6 @@ import asyncio
from app.db_wait import wait_for_db_or_exit
from app.logging_setup import get_logger, setup_logging
from app.workers.trigger_runner import main_loop as trigger_loop
log = get_logger("worker")
@@ -20,17 +22,19 @@ async def main():
setup_logging()
log.info("worker_starting")
# Wait until DB is reachable and the `settings` table exists.
# The backend's lifespan runs `init_db()` which creates tables; if the
# worker comes up first, this loop will retry until that completes.
# Make sure the DB is reachable and tables exist before doing anything.
# (Future background jobs may need this.)
await wait_for_db_or_exit(max_retries=60, delay=2.0)
log.info("worker_db_ready_starting_loops")
# Run all background loops concurrently
await asyncio.gather(
trigger_loop(),
# Future: rag indexer, summary compactor, etc.
)
log.info("worker_ready_no_jobs_registered")
# Nothing to do for now — sleep forever. Future background jobs go here:
# await asyncio.gather(
# some_future_loop(),
# another_future_loop(),
# )
while True:
await asyncio.sleep(3600)
if __name__ == "__main__":

View File

@@ -46,8 +46,9 @@ export const authApi = {
const { data } = await api.post("/auth/register", { email, username, password });
return data;
},
login: async (email: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/login", { email, password });
login: async (login: string, password: string): Promise<TokenOut> => {
// `login` accepts either email or username.
const { data } = await api.post("/auth/login", { login, password });
return data;
},
me: async (): Promise<User> => {
@@ -93,6 +94,10 @@ export const adminApi = {
const { data } = await api.get("/admin/users");
return data;
},
setUserActive: async (userId: string, isActive: boolean): Promise<any> => {
const { data } = await api.post(`/admin/users/${userId}/set-active`, { is_active: isActive });
return data;
},
};
export const presetsApi = {

View File

@@ -17,6 +17,7 @@ export const en = {
register_title: "Register",
email: "Email",
username: "Username",
login_or_email: "Email or username",
password: "Password",
login_btn: "Login",
register_btn: "Register",
@@ -102,8 +103,9 @@ export const en = {
summary_messages: "Messages per summary",
max_tokens_total: "Context token budget",
trigger_settings: "Deferred triggers",
trigger_settings_desc: "Fire when in-world time advances (no polling)",
triggers_enabled: "Enabled",
triggers_check_interval: "Check interval (sec)",
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
embedding_settings: "Embeddings (RAG)",
embedding_provider: "Provider",
embedding_provider_hash: "Hash (offline fallback, no semantics)",
@@ -121,6 +123,9 @@ export const en = {
saved: "Saved!",
llm_logs: "LLM logs",
users: "Users",
users_actions: "Actions",
users_ban: "Ban",
users_unban: "Unban",
back: "Back",
},
glossary: {

View File

@@ -12,8 +12,11 @@ i18n
ru: { translation: ru },
en: { translation: en },
},
fallbackLng: "ru",
supportedLngs: ["ru", "en"],
// English is the default. The user can switch via the language picker
// in the navbar; the choice is cached in localStorage and overrides
// browser settings on subsequent visits.
fallbackLng: "en",
supportedLngs: ["en", "ru"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator"],

View File

@@ -17,6 +17,7 @@ export const ru = {
register_title: "Регистрация",
email: "Email",
username: "Имя пользователя",
login_or_email: "Email или имя пользователя",
password: "Пароль",
login_btn: "Войти",
register_btn: "Зарегистрироваться",
@@ -102,8 +103,9 @@ export const ru = {
summary_messages: "Сообщений на сводку",
max_tokens_total: "Бюджет токенов контекста",
trigger_settings: "Отложенные триггеры",
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
triggers_enabled: "Включены",
triggers_check_interval: "Интервал проверки (сек)",
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
embedding_settings: "Эмбеддинги (RAG)",
embedding_provider: "Провайдер",
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
@@ -121,6 +123,9 @@ export const ru = {
saved: "Сохранено!",
llm_logs: "Логи LLM",
users: "Пользователи",
users_actions: "Действия",
users_ban: "Забанить",
users_unban: "Разбанить",
back: "Назад",
},
glossary: {

View File

@@ -6,7 +6,7 @@ import type { LlmLog, SettingsOut } from "@/types";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react";
import { Save, ArrowLeft, Activity, Users, Zap, Ban, CheckCircle2 } from "lucide-react";
export function AdminPanelPage() {
const { t } = useTranslation();
@@ -22,6 +22,7 @@ export function AdminPanelPage() {
const [error, setError] = useState("");
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
const [testingEmbeddings, setTestingEmbeddings] = useState(false);
const [userActionError, setUserActionError] = useState("");
useEffect(() => {
(async () => {
@@ -110,6 +111,16 @@ export function AdminPanelPage() {
}
};
const toggleUserActive = async (userId: string, currentActive: boolean) => {
setUserActionError("");
try {
const updated = await adminApi.setUserActive(userId, !currentActive);
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: updated.is_active } : u)));
} catch (err: any) {
setUserActionError(err.response?.data?.detail || t("errors.unknown"));
}
};
if (loading) {
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
}
@@ -302,23 +313,17 @@ export function AdminPanelPage() {
</Card>
<Card>
<CardHeader title={t("admin.trigger_settings")} />
<CardHeader title={t("admin.trigger_settings")} subtitle={t("admin.trigger_settings_desc")} />
<CardBody>
<div className="grid grid-cols-2 gap-3 items-end">
<label className="flex items-center gap-2 text-xs text-ink-300">
<input
type="checkbox"
checked={!!values["triggers.enabled"]}
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
/>
{t("admin.triggers_enabled")}
</label>
<NumberInput
label={t("admin.triggers_check_interval")}
value={values["triggers.check_interval"]}
onChange={(v) => setValues({ ...values, "triggers.check_interval": v })}
<label className="flex items-center gap-2 text-xs text-ink-300">
<input
type="checkbox"
checked={!!values["triggers.enabled"]}
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
/>
</div>
{t("admin.triggers_enabled")}
</label>
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
</CardBody>
</Card>
@@ -382,6 +387,9 @@ export function AdminPanelPage() {
<Card>
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
<CardBody>
{userActionError && (
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -390,7 +398,8 @@ export function AdminPanelPage() {
<th className="py-2 pr-3">Username</th>
<th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Active</th>
<th className="py-2">Created</th>
<th className="py-2 pr-3">Created</th>
<th className="py-2 text-right">{t("admin.users_actions")}</th>
</tr>
</thead>
<tbody>
@@ -412,9 +421,32 @@ export function AdminPanelPage() {
<span className="text-red-400"></span>
)}
</td>
<td className="py-2 text-ink-400">
<td className="py-2 pr-3 text-ink-400">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="py-2 text-right">
{u.is_admin ? (
<span className="text-ink-500 text-xs"></span>
) : u.is_active ? (
<Button
size="sm"
variant="ghost"
onClick={() => toggleUserActive(u.id, u.is_active)}
>
<Ban size={12} className="mr-1" />
{t("admin.users_ban")}
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => toggleUserActive(u.id, u.is_active)}
>
<CheckCircle2 size={12} className="mr-1" />
{t("admin.users_unban")}
</Button>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -55,12 +55,6 @@ export function HomePage() {
desc="RU / EN"
/>
</div>
<div className="mt-12 text-center text-sm text-ink-500">
<Link to="/admin/setup" className="hover:text-accent-400 underline">
{t("auth.admin_setup_title")}
</Link>
</div>
</div>
);
}

View File

@@ -11,7 +11,7 @@ export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [email, setEmail] = useState("");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -21,7 +21,8 @@ export function LoginPage() {
setError("");
setLoading(true);
try {
const { access_token, user } = await authApi.login(email, password);
// `login` accepts either email or username.
const { access_token, user } = await authApi.login(login, password);
setAuth(access_token, user);
navigate("/dashboard");
} catch (err: any) {
@@ -38,13 +39,14 @@ export function LoginPage() {
<CardBody>
<form onSubmit={onSubmit} className="space-y-4">
<Input
label={t("auth.email")}
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
label={t("auth.login_or_email")}
type="text"
name="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
autoComplete="email"
autoComplete="username"
placeholder="alice / alice@example.com"
/>
<Input
label={t("auth.password")}

View File

@@ -24,7 +24,7 @@ export function WorldBuilderPage() {
const fromScratch = location.state?.fromScratch as boolean | undefined;
const [worldName, setWorldName] = useState("");
const [language, setLanguage] = useState(i18n.language === "en" ? "en" : "ru");
const [language, setLanguage] = useState(i18n.language === "ru" ? "ru" : "en");
const [settingBrief, setSettingBrief] = useState("");
const [characterBrief, setCharacterBrief] = useState("");
const [rulesBrief, setRulesBrief] = useState("");