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 __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List
from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -142,3 +143,32 @@ async def list_users(
} }
for u in 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) @router.post("/login", response_model=TokenOut)
async def login(payload: UserLogin, db: AsyncSession = Depends(get_db_dep)): 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() user = result.scalars().first()
if not user or not verify_password(payload.password, user.hashed_password): if not user or not verify_password(payload.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials") 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.summary_messages": int,
"context.max_tokens_total": int, "context.max_tokens_total": int,
"triggers.enabled": bool, "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 # Embeddings / RAG
"embedding.provider": str, # "hash" | "openai" "embedding.provider": str, # "hash" | "openai"
"embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1) "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 [] 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 {} defn = world.definition or {}
system_prompt_template = get_prompt("orchestrator", world.language) system_prompt_template = get_prompt("orchestrator", world.language)
player_state = world.state.get("player", {}) if world.state else {} 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 "", current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:600], player_state=json.dumps(player_state, ensure_ascii=False)[:600],
plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400], 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}] messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add summary as a system note if present # Add summary as a system note if present
if summary_text: 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 # Add recent visible messages
for m in recent: for m in recent:
@@ -86,7 +89,7 @@ async def build_orchestrator_messages(
messages.append({"role": "assistant", "content": m.content}) messages.append({"role": "assistant", "content": m.content})
# The current action # 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 return messages, settings_map
@@ -100,7 +103,11 @@ async def _maybe_compress(
world: World, world: World,
settings_map: Dict[str, Any], settings_map: Dict[str, Any],
) -> None: ) -> 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] visible = [m for m in all_msgs if not m.hidden]
if len(visible) <= recent_n + summary_n: if len(visible) <= recent_n + summary_n:
return return
@@ -110,45 +117,61 @@ async def _maybe_compress(
if not to_summarize: if not to_summarize:
return return
# Build summarization input # Build summarization input (English labels — system content convention)
summary_input_lines = [] summary_input_lines = []
for m in to_summarize: for m in to_summarize:
prefix = { prefix = {
"player_action": "Игрок", "player_action": "Player",
"narrative_step": "Сцена", "narrative_step": "Scene",
"summary": "Сводка", "summary": "Summary",
"orchestrator_plan": "GM", "orchestrator_plan": "GM",
"technical_offscreen": "За кадром", "technical_offscreen": "Offscreen",
}.get(m.kind, m.kind) }.get(m.kind, m.kind)
summary_input_lines.append(f"{prefix}: {m.content[:300]}") summary_input_lines.append(f"{prefix}: {m.content[:300]}")
summary_input = "\n\n".join(summary_input_lines) summary_input = "\n\n".join(summary_input_lines)
llm = LlmClient(settings_map) llm = LlmClient(settings_map)
system_prompt = get_prompt("summarizer", world.language) system_prompt = get_prompt("summarizer", world.language)
from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS
response = await llm.chat( response = await llm.chat(
messages=[ messages=[
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": summary_input[:4000]}, {"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))), temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
max_tokens=300, max_tokens=400,
purpose="summary", purpose="summary",
session_id=session_id, session_id=session_id,
db=db, db=db,
) )
# Parse summary response # Extract from submit_summary tool call; fall back to text parse.
summary_text = response.text summary_text = response.text or ""
facts: List[Dict[str, Any]] = [] facts: List[Dict[str, Any]] = []
import re as _re extracted = False
json_match = _re.search(r"\{[\s\S]*\}", response.text) for tc in (response.tool_calls or []):
if json_match: if tc.get("function", {}).get("name") == "submit_summary":
try: args_str = tc.get("function", {}).get("arguments", "{}")
data = json.loads(json_match.group(0)) try:
summary_text = data.get("summary", response.text) data = json.loads(args_str) if args_str else {}
facts = data.get("facts", []) summary_text = data.get("summary", response.text or "")
except json.JSONDecodeError: facts = data.get("facts", []) or []
pass 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 # Create summary message
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1 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, outcome: str,
narrative_prompt: str, narrative_prompt: str,
) -> List[Dict[str, Any]]: ) -> 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 {} defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {} player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("step_writer", world.language).format( 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], player_state=json.dumps(player_state, ensure_ascii=False)[:400],
outcome=outcome, outcome=outcome,
narrative_prompt=narrative_prompt[:600], narrative_prompt=narrative_prompt[:600],
world_language=world.language or "en",
) )
return [{"role": "system", "content": system_prompt}] 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 from __future__ import annotations
import json import json
@@ -11,15 +22,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings 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 ( from app.engine.context import (
build_orchestrator_messages, build_orchestrator_messages,
build_step_writer_messages, build_step_writer_messages,
build_subagent_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.logging_setup import get_logger
from app.models import DeferredTrigger, Message, Session, World from app.models import Message, Session, World
from app.prompts.templates import get_prompt
log = get_logger("orchestrator") log = get_logger("orchestrator")
@@ -88,19 +104,22 @@ async def run_iteration(
) )
return resp.text 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) === # === Phase 1: Orchestrator with tool calls (max 5 iterations) ===
orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text) 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 max_iters = 5
final_assistant_text: Optional[str] = None parsed: Dict[str, Any] = {}
final_tool_calls: List[Dict[str, Any]] = [] plan_tool_calls_log: List[Dict[str, Any]] = []
orchestrator_text_log: str = ""
for i in range(max_iters): for i in range(max_iters):
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}} yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
@@ -114,48 +133,112 @@ async def run_iteration(
db=db, db=db,
) )
if response.tool_calls: if not response.tool_calls:
# Append assistant message with tool_calls # No tool calls — model gave up or errored. Treat its text as the
orchestrator_messages.append({ # outcome directly so the player still sees SOMETHING.
"role": "assistant", log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or ""))
"content": response.text or "", orchestrator_text_log = response.text or ""
"tool_calls": response.tool_calls, parsed = {
}) "assessment": "(no plan submitted)",
# Execute each tool call "outcome": response.text or "",
for tc in response.tool_calls: "narrative_prompt": "",
fn = tc.get("function", {}) "next_options": [],
name = fn.get("name", "") "state_patch": {},
args_str = fn.get("arguments", "{}") "time_advance": None,
try: "rag_facts": [],
args = json.loads(args_str) if args_str else {} "rails_update": None,
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 break
if final_assistant_text is None: # Append assistant message with tool_calls
# Ran out of iterations - use last text orchestrator_messages.append({
final_assistant_text = response.text or "{}" "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"}} yield {"type": "status", "data": {"message": "writing_scene"}}
# === Parse orchestrator final response ===
parsed = _parse_orchestrator_response(final_assistant_text)
# Apply final state patch (if any) # Apply final state patch (if any)
if parsed.get("state_patch"): if parsed.get("state_patch"):
from app.core.state_validator import apply_patch, validate_state from app.core.state_validator import apply_patch, validate_state
@@ -170,7 +253,7 @@ async def run_iteration(
# Advance time # Advance time
time_advance = parsed.get("time_advance") time_advance = parsed.get("time_advance")
if time_advance and isinstance(time_advance, dict): 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 world.current_time = new_time
# Save orchestrator plan as hidden message # Save orchestrator plan as hidden message
@@ -180,31 +263,40 @@ async def run_iteration(
seq=plan_seq, seq=plan_seq,
role="assistant", role="assistant",
kind="orchestrator_plan", kind="orchestrator_plan",
content=final_assistant_text[:2000], content=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[:2000],
payload={ payload={
"assessment": parsed.get("assessment", ""), "assessment": parsed.get("assessment", ""),
"outcome": parsed.get("outcome", ""), "outcome": parsed.get("outcome", ""),
"state_patch": parsed.get("state_patch", {}), "state_patch": parsed.get("state_patch", {}),
"time_advance": time_advance, "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, "scheduled_triggers": ctx.scheduled_triggers,
"rag_added": ctx.rag_added, "rag_added": ctx.rag_added,
"narrative_prompt": parsed.get("narrative_prompt", ""),
"next_options": parsed.get("next_options", []),
}, },
is_pinned=False, is_pinned=False,
hidden=True, hidden=True,
) )
db.add(plan_msg) 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", "")] narrative_prompt_parts = [parsed.get("narrative_prompt", "")]
# Add RAG context if relevant # Add RAG context if relevant
if parsed.get("outcome"): if parsed.get("outcome"):
try: try:
from app.core.rag import get_rag from app.core.rag import get_rag
rag = await get_rag(settings_map) 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: 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}") narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}")
except Exception as e: except Exception as e:
log.warning("rag_lookup_failed", error=str(e)) log.warning("rag_lookup_failed", error=str(e))
@@ -219,28 +311,46 @@ async def run_iteration(
step_resp = await llm.chat( step_resp = await llm.chat(
messages=step_messages, messages=step_messages,
tools=STEP_WRITER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
max_tokens=800, max_tokens=1200,
purpose="step", purpose="step",
user_id=user_id, user_id=user_id,
session_id=session_id, session_id=session_id,
db=db, 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 [] step_options: List[str] = parsed.get("next_options", []) or []
# Try to extract structured step from JSON for tc in (step_resp.tool_calls or []):
import re as _re if tc.get("function", {}).get("name") == "submit_scene":
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text) args_str = tc.get("function", {}).get("arguments", "{}")
if json_match: try:
try: scene_data = json.loads(args_str) if args_str else {}
step_data = json.loads(json_match.group(0)) if scene_data.get("narrative"):
if "narrative" in step_data: step_text = scene_data["narrative"]
step_text = step_data["narrative"] if scene_data.get("options") and isinstance(scene_data["options"], list):
if "options" in step_data and isinstance(step_data["options"], list): step_options = [str(o) for o in scene_data["options"]][:5]
step_options = [str(o) for o in step_data["options"]][:5] except json.JSONDecodeError:
except json.JSONDecodeError: log.warning("submit_scene_invalid_json", args=args_str[:200])
pass 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 # Save narrative step message
step_seq = await _next_seq(db, session_id) step_seq = await _next_seq(db, session_id)
@@ -276,7 +386,6 @@ async def run_iteration(
completed = set(rails.get("completed_subgoals", [])) completed = set(rails.get("completed_subgoals", []))
completed.update(rails_update["completed_subgoals"]) completed.update(rails_update["completed_subgoals"])
rails["completed_subgoals"] = list(completed) rails["completed_subgoals"] = list(completed)
# Remove completed from subgoals
rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed] rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed]
defn["plot_rails"] = rails defn["plot_rails"] = rails
world.definition = defn world.definition = defn
@@ -316,8 +425,27 @@ async def run_iteration(
await db.commit() await db.commit()
await db.refresh(step_msg) await db.refresh(step_msg)
# Check for triggers that should fire immediately (fire_at <= current_time) # Check for triggers that should fire now (fire_at <= current world time).
fired_now = await _check_due_triggers(db, session_id, world.current_time or "") # 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 { yield {
"type": "step_complete", "type": "step_complete",
@@ -335,21 +463,6 @@ async def run_iteration(
yield {"type": "done", "data": {}} 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: async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
result = await db.execute( result = await db.execute(
select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1) 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 return (row[0] + 1) if row else 1
def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str: def _safe_parse_json(s: str) -> Any:
"""Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime.""" try:
if not current_time: return json.loads(s) if s else {}
# Try to use the world_state's world_time field except Exception:
wt = (world.state or {}).get("world_time", {}) return s
if wt:
day = int(wt.get("day", 1))
hour = int(wt.get("hour", 8))
else:
day, hour = 1, 8
else:
# Parse 'day_N_hour_H' or fall back to numbers
import re as _re
m = _re.match(r"day_(\d+)_hour_(\d+)", current_time)
if m:
day, hour = int(m.group(1)), int(m.group(2))
else:
# Try ISO format
try:
from datetime import datetime as _dt, timedelta as _td
dt = _dt.fromisoformat(current_time)
dt = dt + _td(
days=int(advance.get("days", 0)),
hours=int(advance.get("hours", 0)),
minutes=int(advance.get("minutes", 0)),
)
return dt.isoformat()
except Exception:
day, hour = 1, 8
total_minutes = day * 24 * 60 + hour * 60
total_minutes += int(advance.get("days", 0)) * 24 * 60
total_minutes += int(advance.get("hours", 0)) * 60
total_minutes += int(advance.get("minutes", 0))
new_day = total_minutes // (24 * 60)
new_hour = (total_minutes % (24 * 60)) // 60
new_time = f"day_{new_day}_hour_{new_hour}"
# Also update world_time in state if present
if world.state and "world_time" in world.state:
world.state["world_time"] = {
**world.state["world_time"],
"day": new_day,
"hour": new_hour,
}
return new_time
async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]:
"""Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers."""
import re as _re
def _parse(t: str):
m = _re.match(r"day_(\d+)_hour_(\d+)", t or "")
if m:
return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
try:
from datetime import datetime as _dt
dt = _dt.fromisoformat(t)
return int(dt.timestamp() // 60)
except Exception:
return 0
cur = _parse(current_time)
result = await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.session_id == session_id,
DeferredTrigger.fired.is_(False),
)
)
triggers = list(result.scalars().all())
fired: List[Dict[str, Any]] = []
for t in triggers:
if _parse(t.fire_at) <= cur:
t.fired = True
fired.append({
"id": str(t.id),
"fire_at": t.fire_at,
"description": t.description,
"payload": t.payload,
})
if fired:
await db.commit()
return fired

View File

@@ -91,13 +91,29 @@ RAG_ADD_SCHEMA = build_tool_schema(
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema( SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
name="schedule_trigger", 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={ params={
"type": "object", "type": "object",
"properties": { "properties": {
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"}, "fire_at": {
"description": {"type": "string", "description": "What should happen"}, "type": "string",
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"}, "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"], "required": ["fire_at", "description"],
}, },
@@ -106,14 +122,22 @@ SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
ADVANCE_TIME_SCHEMA = build_tool_schema( ADVANCE_TIME_SCHEMA = build_tool_schema(
name="advance_time", 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={ params={
"type": "object", "type": "object",
"properties": { "properties": {
"days": {"type": "integer", "default": 0}, "days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0}, "hours": {"type": "integer", "default": 0},
"minutes": {"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 = [ ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA, DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA, UPDATE_STATE_SCHEMA,
@@ -141,8 +378,21 @@ ALL_TOOL_SCHEMAS = [
SCHEDULE_TRIGGER_SCHEMA, SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA, ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_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 === # === 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 from __future__ import annotations
import json import json
@@ -14,6 +23,7 @@ from app.logging_setup import get_logger
from app.models import Preset, User, World from app.models import Preset, User, World
from app.prompts.templates import get_prompt from app.prompts.templates import get_prompt
from app.schemas import WorldBuilderReply, WorldDefinition from app.schemas import WorldBuilderReply, WorldDefinition
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
log = get_logger("world_builder") log = get_logger("world_builder")
@@ -36,7 +46,8 @@ async def start_world_builder(
) -> WorldBuilderReply: ) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply.""" """Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4() 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 preset_payload: Optional[Dict[str, Any]] = None
if preset_id: if preset_id:
@@ -55,7 +66,9 @@ async def start_world_builder(
language=language, 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 = [ messages = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief}, {"role": "user", "content": user_brief},
@@ -63,20 +76,27 @@ async def start_world_builder(
response = await llm.chat( response = await llm.chat(
messages=messages, 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", purpose="world_builder",
user_id=user.id, user_id=user.id,
db=db, 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] = { _DIALOGUES[session_id] = {
"user_id": user.id, "user_id": user.id,
"world_name": world_name, "world_name": world_name,
"language": language, "language": language,
"preset_id": preset_id, "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, "turn": 1,
"last_proposed": proposed.model_dump() if proposed else None, "last_proposed": proposed.model_dump() if proposed else None,
} }
@@ -87,7 +107,7 @@ async def start_world_builder(
ai_message=ai_text, ai_message=ai_text,
proposed_definition=proposed, proposed_definition=proposed,
is_final=is_final, is_final=is_final,
followup_questions=followups, followup_questions=[],
) )
@@ -104,20 +124,26 @@ async def continue_world_builder(
if dialogue["user_id"] != user.id: if dialogue["user_id"] != user.id:
raise ValueError("forbidden") 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["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1 dialogue["turn"] += 1
response = await llm.chat( response = await llm.chat(
messages=dialogue["messages"], 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", purpose="world_builder",
user_id=user.id, user_id=user.id,
db=db, 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: if proposed:
dialogue["last_proposed"] = proposed.model_dump() dialogue["last_proposed"] = proposed.model_dump()
@@ -127,7 +153,7 @@ async def continue_world_builder(
ai_message=ai_text, ai_message=ai_text,
proposed_definition=proposed, proposed_definition=proposed,
is_final=is_final, is_final=is_final,
followup_questions=followups, followup_questions=[],
) )
@@ -151,7 +177,7 @@ async def commit_world_builder(
world = World( world = World(
owner_id=user.id, owner_id=user.id,
name=name or dialogue.get("world_name") or "New World", name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"), language=dialogue.get("language", "en"),
definition=definition.model_dump(), definition=definition.model_dump(),
state=definition.initial_state or {}, state=definition.initial_state or {},
current_time=definition.initial_time, current_time=definition.initial_time,
@@ -176,7 +202,8 @@ def _build_user_brief(
preset_payload: Optional[Dict[str, Any]], preset_payload: Optional[Dict[str, Any]],
language: str, language: str,
) -> 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}") parts.append(f"Name: {world_name}")
if preset_payload: if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}") 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}") parts.append(f"Rules: {rules_brief}")
if notes: if notes:
parts.append(f"Notes: {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) return "\n".join(parts)
def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]: def _extract_world_definition(
"""Extract AI message text, proposed definition (if any), is_final flag, and followup questions.""" text: str,
proposed = None 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 is_final = False
followups: List[str] = [] ai_text = text or ""
# Try to find a JSON block in the response # 1) Prefer the submit_world_definition tool call (the proper way).
json_str = _extract_json_block(text) if tool_calls:
if json_str: for tc in tool_calls:
try: if tc.get("function", {}).get("name") == "submit_world_definition":
data = json.loads(json_str) args_str = tc.get("function", {}).get("arguments", "{}")
if isinstance(data, dict): try:
if "proposed_definition" in data: data = json.loads(args_str) if args_str else {}
pd = data["proposed_definition"] except json.JSONDecodeError:
if isinstance(pd, dict): data = {}
try: proposed = _try_build_definition(data)
proposed = WorldDefinition.model_validate(pd) is_final = bool(data.get("is_final", False))
except Exception: break
proposed = None
if "is_final" in data:
is_final = bool(data["is_final"])
if "followup_questions" in data and isinstance(data["followup_questions"], list):
followups = [str(q) for q in data["followup_questions"]]
if "ai_message" in data and isinstance(data["ai_message"], str):
text = data["ai_message"]
except json.JSONDecodeError:
pass
# Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final # 2) Fallback: parse a JSON block from the text (older models).
if proposed is not None: if proposed is None:
low = text.lower() json_str = _extract_json_block(text)
if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]): 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 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]: 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: if not text:
return None return None
# Try fenced ```json ... ```
import re import re
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m: if m:
return m.group(1) return m.group(1)
# Try raw {...} (greedy from first { to matching })
start = text.find("{") start = text.find("{")
if start == -1: if start == -1:
return None return None

View File

@@ -44,8 +44,7 @@ DEFAULT_SETTINGS = [
("context.compress_threshold", settings.default_compress_threshold, "Trigger compression at this count"), ("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.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)"), ("context.max_tokens_total", 6000, "Soft token budget for context window (small models)"),
("triggers.enabled", True, "Enable deferred trigger processing"), ("triggers.enabled", True, "Enable trigger firing on in-game time changes"),
("triggers.check_interval", 30, "Trigger checker interval, seconds"),
# Embeddings / RAG # Embeddings / RAG
("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"), ("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"), ("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: async def init_db() -> None:
setup_logging() setup_logging()
log.info("creating_tables") log.info("creating_tables")
@@ -75,6 +91,7 @@ async def init_db() -> None:
) )
) )
await _seed_settings(session) await _seed_settings(session)
await _sync_env_derived_settings(session)
await _seed_builtin_presets(session) await _seed_builtin_presets(session)
await session.commit() await session.commit()
except Exception as e: 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}") log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}")
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await _seed_settings(session) await _seed_settings(session)
await _sync_env_derived_settings(session)
await _seed_builtin_presets(session) await _seed_builtin_presets(session)
await session.commit() 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() token = settings.admin_setup_token.strip()
if not token: if not token:
# No token forced via env — generate one and persist it (idempotent).
import secrets as _s import secrets as _s
token = _s.token_urlsafe(24) token = _s.token_urlsafe(24)
async with AsyncSessionLocal() as session: 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")) session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
try: try:
await session.commit() 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: except IntegrityError:
# Another process inserted it concurrently — fine. # Another process inserted it concurrently — re-read.
await session.rollback() 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: 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: async def _seed_settings(session) -> None:
@@ -135,6 +167,46 @@ async def _seed_settings(session) -> None:
log.info("settings_already_exist") 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: async def _seed_builtin_presets(session) -> None:
"""Insert built-in presets if none exist yet.""" """Insert built-in presets if none exist yet."""
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True))) 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) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, 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) 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") 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) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) 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_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, 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 # 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) 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) 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) 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 # 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) definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# Current live state of the world (player character, NPC, inventory, time, etc.) # 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) 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) 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) # 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) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, 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).""" """System prompts for all LLM stages.
from __future__ import annotations
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 ===
WORLD_BUILDER_SYSTEM_RU = """Ты — опытный архитектор миров для ролевой игры. WORLD_BUILDER_SYSTEM = """You are a master world-builder for a role-playing game.
Твоя задача — помочь игроку создать мир через диалог. Игрок даёт начальный бриф (сеттинг, персонаж, правила, заметки).
Ты должен:
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.
Your job is to help the player design a world through dialogue. The player gives a brief (setting, character, rules, notes). 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): Workflow:
{ 1. If information is sparse — ask 2-4 short, focused clarifying questions in your reply text.
"setting_description": "expanded setting (1-2 paragraphs)", 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.
"rules": {object with rules: stats, combat, magic, time, inventory, death}, 3. Accept edits and clarifications; the loop continues until the player says the world is ready.
"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')"
}
CRITICAL for small models: When calling `submit_world_definition`:
- Be concise. Max 200 words per message. - `setting_description`: 1-2 paragraph expanded setting.
- Output strictly valid JSON, no comments. - `rules`: object with keys like `stats`, `combat`, `magic`, `time`, `inventory`, `death` (whichever apply).
- In each reply: either ask questions (if data is sparse), or give proposed_definition. - `world_schema`: a JSON Schema describing the shape of the world's state (player, npcs, locations, world_time, flags, etc.).
- When world is ready — set is_final=true (only if the player agreed). - `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 (main game loop with tools) ===
ORCHESTRATOR_SYSTEM_RU = """Ты — Game Master ролевой игры. Ведёшь сессию через инструментальные вызовы. ORCHESTRATOR_SYSTEM = """You are the Game Master of a role-playing game. You run the session through tool calls.
ТЕКУЩИЙ КОНТЕКСТ:
- Мир: {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.
CURRENT CONTEXT: CURRENT CONTEXT:
- World: {world_name} - World: {world_name}
@@ -110,64 +55,24 @@ CURRENT CONTEXT:
- Past summary: {summary} - Past summary: {summary}
TASK: TASK:
The player performed action: "{action_text}" The player performed the 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)
After executing the plan — return your reply as JSON (no text outside JSON): 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.
"assessment": "brief assessment of the action (1-2 sentences)", 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.
"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": "..."}]
}
CRITICAL: CRITICAL RULES:
- Save tokens. 1-3 tool calls per iteration, max 5. - Use 1-3 tool calls per iteration. Max 5.
- Skip dice_roll for trivial actions. - 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. - Stay in setting.
- All tool arguments are structured (JSON). Your text response is ignored — only tool calls matter.
""" """
# === Step Writer === # === Step Writer ===
STEP_WRITER_SYSTEM_RU = """Ты — сценарист ролевой игры. Превращаешь сырой outcome в сценарный шаг как в книге. STEP_WRITER_SYSTEM = """You are the narrative writer of a role-playing game. You turn a raw outcome into a book-like scene.
КОНТЕКСТ:
- Сеттинг: {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.
CONTEXT: CONTEXT:
- Setting: {setting_description} - Setting: {setting_description}
@@ -176,120 +81,86 @@ CONTEXT:
- What happened (raw): {outcome} - What happened (raw): {outcome}
- Additional facts: {narrative_prompt} - Additional facts: {narrative_prompt}
WRITE: YOUR JOB:
1. Narrative description (2-4 paragraphs, cinematic, second-person "You..."). 1. Call the `submit_scene` tool with:
2. End with 3 options for the next action (short, 5-12 words). - `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.
Response format (strict JSON): 2. Your text response is ignored — only the `submit_scene` tool call is used.
{
"narrative": "...",
"options": ["...", "...", "..."]
}
CRITICAL: CRITICAL:
- 200-400 words of narrative. Not more. - Write the narrative in {world_language}.
- Don't repeat what the player already knows. - Do NOT repeat what the player already knows.
- End with a cliffhanger or decision moment. - End with a cliffhanger or decision moment.
- The scene must be consistent with the outcome — do not contradict it.
""" """
# === Summarizer === # === Summarizer ===
SUMMARIZER_SYSTEM_RU = """Ты сжимаешь историю ролевой сессии. Дано несколько сообщений — выдай компактную сводку. SUMMARIZER_SYSTEM = """You compress the history of a role-playing session. Given several messages — produce a compact summary.
Выведи: Call the `submit_summary` tool with:
1. summary: 3-6 предложений ключевых событий и изменений состояния. - `summary`: 3-6 sentences of key events and state changes (max 150 words).
2. facts: массив важных устойчивых фактов [{kind, name, description}] (коротко). - `facts`: array of important persistent facts `[{kind, name, description}]` where kind is one of npc, location, item, lore, event.
Формат (строгий JSON): CRITICAL: Preserve names, numbers, and important state changes. Your text response is ignored — only the `submit_summary` tool call is used.
{"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.
""" """
# === Sub-agent (clean context detail generator) === # === Sub-agent (clean context detail generator) ===
SUBAGENT_SYSTEM_RU = """Ты — суб-агент с чистым контекстом. Получаешь задачу от главного GM, выдаёшь конкретный результат. SUBAGENT_SYSTEM = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result.
Задача: {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.
Task: {task} Task: {task}
Context: {context} 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 ===
TRIGGER_RUNNER_SYSTEM_RU = """Ты обрабатываешь отложенное событие в ролевой игре. TRIGGER_RUNNER_SYSTEM = """You process a deferred event in a role-playing game. A scheduled trigger has fired.
Событие: {description} Event description: {description}
Payload: {payload} Event 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}
Current world state: {state} Current world state: {state}
Return JSON: The player-facing narrative must be written in: {world_language}.
{
"outcome": "what happened (1-2 sentences)", Call the `submit_trigger_result` tool with:
"state_patch": {JSON-patch}, - `outcome`: 1-2 sentence raw description of what happened (English, for logs).
"narrative": "scene description for the player (1 paragraph, optional — empty string if player doesn't witness)", - `state_patch`: JSON-patch for world state (set/unset/append/increment/remove). Empty object if no state change.
"should_notify_player": true|false - `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 = { 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": { "en": {
"world_builder": WORLD_BUILDER_SYSTEM_EN, "world_builder": WORLD_BUILDER_SYSTEM,
"orchestrator": ORCHESTRATOR_SYSTEM_EN, "orchestrator": ORCHESTRATOR_SYSTEM,
"step_writer": STEP_WRITER_SYSTEM_EN, "step_writer": STEP_WRITER_SYSTEM,
"summarizer": SUMMARIZER_SYSTEM_EN, "summarizer": SUMMARIZER_SYSTEM,
"subagent": SUBAGENT_SYSTEM_EN, "subagent": SUBAGENT_SYSTEM,
"trigger_runner": TRIGGER_RUNNER_SYSTEM_EN, "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: def get_prompt(stage: str, language: str = "en") -> str:
lang = language if language in PROMPTS else "ru" """Return the system prompt for `stage`.
return PROMPTS[lang].get(stage, PROMPTS["ru"][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): class UserLogin(BaseModel):
email: EmailStr # Accepts either email or username — the backend resolves it.
login: str
password: str password: str
@@ -72,7 +73,7 @@ class PresetCreate(BaseModel):
slug: str slug: str
title: str title: str
description: Optional[str] = None description: Optional[str] = None
language: str = "ru" language: str = "en"
is_public: bool = True is_public: bool = True
payload: Dict[str, Any] payload: Dict[str, Any]
@@ -80,7 +81,7 @@ class PresetCreate(BaseModel):
# === Worlds === # === Worlds ===
class WorldCreate(BaseModel): class WorldCreate(BaseModel):
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
language: str = "ru" language: str = "en"
preset_id: Optional[UUID] = None preset_id: Optional[UUID] = None
@@ -120,7 +121,7 @@ class WorldUpdate(BaseModel):
class WorldBuilderStart(BaseModel): class WorldBuilderStart(BaseModel):
"""Kick off a new world-building conversation.""" """Kick off a new world-building conversation."""
world_name: str = Field(min_length=1, max_length=255) 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. # Either pick a preset to start from, or fill the freeform brief.
preset_id: Optional[UUID] = None preset_id: Optional[UUID] = None
setting_brief: str = "" 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 Historically this ran a trigger-polling loop. Triggers now fire in-process
the background loops. This prevents the worker from crashing when the inside the orchestrator when in-game time changes (see app.core.triggers),
backend hasn't finished running `init_db()` yet (typical docker-compose so the worker has nothing to do at the moment. We keep the container running
race condition where both services start in parallel). 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 from __future__ import annotations
@@ -11,7 +14,6 @@ import asyncio
from app.db_wait import wait_for_db_or_exit from app.db_wait import wait_for_db_or_exit
from app.logging_setup import get_logger, setup_logging from app.logging_setup import get_logger, setup_logging
from app.workers.trigger_runner import main_loop as trigger_loop
log = get_logger("worker") log = get_logger("worker")
@@ -20,17 +22,19 @@ async def main():
setup_logging() setup_logging()
log.info("worker_starting") log.info("worker_starting")
# Wait until DB is reachable and the `settings` table exists. # Make sure the DB is reachable and tables exist before doing anything.
# The backend's lifespan runs `init_db()` which creates tables; if the # (Future background jobs may need this.)
# worker comes up first, this loop will retry until that completes.
await wait_for_db_or_exit(max_retries=60, delay=2.0) await wait_for_db_or_exit(max_retries=60, delay=2.0)
log.info("worker_db_ready_starting_loops") log.info("worker_ready_no_jobs_registered")
# Run all background loops concurrently
await asyncio.gather( # Nothing to do for now — sleep forever. Future background jobs go here:
trigger_loop(), # await asyncio.gather(
# Future: rag indexer, summary compactor, etc. # some_future_loop(),
) # another_future_loop(),
# )
while True:
await asyncio.sleep(3600)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -46,8 +46,9 @@ export const authApi = {
const { data } = await api.post("/auth/register", { email, username, password }); const { data } = await api.post("/auth/register", { email, username, password });
return data; return data;
}, },
login: async (email: string, password: string): Promise<TokenOut> => { login: async (login: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/login", { email, password }); // `login` accepts either email or username.
const { data } = await api.post("/auth/login", { login, password });
return data; return data;
}, },
me: async (): Promise<User> => { me: async (): Promise<User> => {
@@ -93,6 +94,10 @@ export const adminApi = {
const { data } = await api.get("/admin/users"); const { data } = await api.get("/admin/users");
return data; 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 = { export const presetsApi = {

View File

@@ -17,6 +17,7 @@ export const en = {
register_title: "Register", register_title: "Register",
email: "Email", email: "Email",
username: "Username", username: "Username",
login_or_email: "Email or username",
password: "Password", password: "Password",
login_btn: "Login", login_btn: "Login",
register_btn: "Register", register_btn: "Register",
@@ -102,8 +103,9 @@ export const en = {
summary_messages: "Messages per summary", summary_messages: "Messages per summary",
max_tokens_total: "Context token budget", max_tokens_total: "Context token budget",
trigger_settings: "Deferred triggers", trigger_settings: "Deferred triggers",
trigger_settings_desc: "Fire when in-world time advances (no polling)",
triggers_enabled: "Enabled", 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_settings: "Embeddings (RAG)",
embedding_provider: "Provider", embedding_provider: "Provider",
embedding_provider_hash: "Hash (offline fallback, no semantics)", embedding_provider_hash: "Hash (offline fallback, no semantics)",
@@ -121,6 +123,9 @@ export const en = {
saved: "Saved!", saved: "Saved!",
llm_logs: "LLM logs", llm_logs: "LLM logs",
users: "Users", users: "Users",
users_actions: "Actions",
users_ban: "Ban",
users_unban: "Unban",
back: "Back", back: "Back",
}, },
glossary: { glossary: {

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import type { LlmLog, SettingsOut } from "@/types";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card"; 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() { export function AdminPanelPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -22,6 +22,7 @@ export function AdminPanelPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null); const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
const [testingEmbeddings, setTestingEmbeddings] = useState(false); const [testingEmbeddings, setTestingEmbeddings] = useState(false);
const [userActionError, setUserActionError] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (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) { if (loading) {
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>; return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
} }
@@ -302,23 +313,17 @@ export function AdminPanelPage() {
</Card> </Card>
<Card> <Card>
<CardHeader title={t("admin.trigger_settings")} /> <CardHeader title={t("admin.trigger_settings")} subtitle={t("admin.trigger_settings_desc")} />
<CardBody> <CardBody>
<div className="grid grid-cols-2 gap-3 items-end"> <label className="flex items-center gap-2 text-xs text-ink-300">
<label className="flex items-center gap-2 text-xs text-ink-300"> <input
<input type="checkbox"
type="checkbox" checked={!!values["triggers.enabled"]}
checked={!!values["triggers.enabled"]} onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
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 })}
/> />
</div> {t("admin.triggers_enabled")}
</label>
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
</CardBody> </CardBody>
</Card> </Card>
@@ -382,6 +387,9 @@ export function AdminPanelPage() {
<Card> <Card>
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} /> <CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
<CardBody> <CardBody>
{userActionError && (
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
@@ -390,7 +398,8 @@ export function AdminPanelPage() {
<th className="py-2 pr-3">Username</th> <th className="py-2 pr-3">Username</th>
<th className="py-2 pr-3">Role</th> <th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Active</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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -412,9 +421,32 @@ export function AdminPanelPage() {
<span className="text-red-400"></span> <span className="text-red-400"></span>
)} )}
</td> </td>
<td className="py-2 text-ink-400"> <td className="py-2 pr-3 text-ink-400">
{new Date(u.created_at).toLocaleDateString()} {new Date(u.created_at).toLocaleDateString()}
</td> </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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -55,12 +55,6 @@ export function HomePage() {
desc="RU / EN" desc="RU / EN"
/> />
</div> </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> </div>
); );
} }

View File

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

View File

@@ -24,7 +24,7 @@ export function WorldBuilderPage() {
const fromScratch = location.state?.fromScratch as boolean | undefined; const fromScratch = location.state?.fromScratch as boolean | undefined;
const [worldName, setWorldName] = useState(""); 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 [settingBrief, setSettingBrief] = useState("");
const [characterBrief, setCharacterBrief] = useState(""); const [characterBrief, setCharacterBrief] = useState("");
const [rulesBrief, setRulesBrief] = useState(""); const [rulesBrief, setRulesBrief] = useState("");