From 5a78def09608c70e8a677658aeacb04fcaa6d961 Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:31:45 +0300 Subject: [PATCH] fix --- backend/app/api/admin.py | 30 ++ backend/app/api/auth.py | 8 +- backend/app/core/settings_service.py | 3 +- backend/app/core/triggers.py | 310 ++++++++++++++++++++ backend/app/engine/context.py | 74 +++-- backend/app/engine/orchestrator.py | 375 +++++++++++++----------- backend/app/engine/tools/tools.py | 262 ++++++++++++++++- backend/app/engine/world_builder.py | 144 ++++++--- backend/app/migrations/init_db.py | 94 +++++- backend/app/models/__init__.py | 8 +- backend/app/prompts/templates.py | 323 ++++++-------------- backend/app/schemas/__init__.py | 9 +- backend/app/workers/main.py | 34 ++- frontend/src/api/index.ts | 9 +- frontend/src/i18n/en.ts | 7 +- frontend/src/i18n/index.ts | 7 +- frontend/src/i18n/ru.ts | 7 +- frontend/src/pages/AdminPanelPage.tsx | 68 +++-- frontend/src/pages/HomePage.tsx | 6 - frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/WorldBuilderPage.tsx | 2 +- 21 files changed, 1250 insertions(+), 548 deletions(-) create mode 100644 backend/app/core/triggers.py diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 77ac52e..5099e1e 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any, Dict, List +from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -142,3 +143,32 @@ async def list_users( } for u in users ] + + +@router.post("/users/{user_id}/set-active") +async def set_user_active( + user_id: UUID, + payload: Dict[str, Any] = Body(default={}), + db: AsyncSession = Depends(get_db_dep), + admin: User = Depends(require_admin), +): + """Activate or ban a user. Banned users cannot log in (see auth.login). + + Body: `{"is_active": true|false}`. Admins cannot ban themselves. + """ + is_active = bool(payload.get("is_active")) + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalars().first() + if not user: + raise HTTPException(status_code=404, detail="user_not_found") + if user.id == admin.id and not is_active: + raise HTTPException(status_code=400, detail="cannot_ban_self") + user.is_active = is_active + await db.commit() + return { + "id": str(user.id), + "email": user.email, + "username": user.username, + "is_admin": user.is_admin, + "is_active": user.is_active, + } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 0485749..aa31e51 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -36,7 +36,13 @@ async def register(payload: UserRegister, db: AsyncSession = Depends(get_db_dep) @router.post("/login", response_model=TokenOut) async def login(payload: UserLogin, db: AsyncSession = Depends(get_db_dep)): - result = await db.execute(select(User).where(User.email == payload.email)) + # Accept either email or username in the `login` field. + login_value = (payload.login or "").strip() + if not login_value: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="login_required") + result = await db.execute( + select(User).where((User.email == login_value) | (User.username == login_value)) + ) user = result.scalars().first() if not user or not verify_password(payload.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials") diff --git a/backend/app/core/settings_service.py b/backend/app/core/settings_service.py index 52f6449..75bcce6 100644 --- a/backend/app/core/settings_service.py +++ b/backend/app/core/settings_service.py @@ -25,7 +25,8 @@ EDITABLE_SETTING_KEYS = { "context.summary_messages": int, "context.max_tokens_total": int, "triggers.enabled": bool, - "triggers.check_interval": int, + # Note: triggers.check_interval was removed — triggers now fire in-process + # when in-game time changes, not via a polling worker. # Embeddings / RAG "embedding.provider": str, # "hash" | "openai" "embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1) diff --git a/backend/app/core/triggers.py b/backend/app/core/triggers.py new file mode 100644 index 0000000..4a0fac6 --- /dev/null +++ b/backend/app/core/triggers.py @@ -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)) diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index 112c857..aca0839 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -58,7 +58,10 @@ async def build_orchestrator_messages( recent = visible_msgs[-recent_n:] if visible_msgs else [] - # Build orchestrator system prompt with current state + # Build orchestrator system prompt with current state. + # NOTE: prompts are always English (system content convention). The LLM + # produces player-facing text in world.language when relevant (the + # step-writer prompt interpolates world_language explicitly). defn = world.definition or {} system_prompt_template = get_prompt("orchestrator", world.language) player_state = world.state.get("player", {}) if world.state else {} @@ -69,14 +72,14 @@ async def build_orchestrator_messages( current_time=world.current_time or "", player_state=json.dumps(player_state, ensure_ascii=False)[:600], plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400], - summary=summary_text or "(нет сводки)", + summary=summary_text or "(no summary yet)", ) messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] # Add summary as a system note if present if summary_text: - messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"}) + messages.append({"role": "system", "content": f"Past summary:\n{summary_text}"}) # Add recent visible messages for m in recent: @@ -86,7 +89,7 @@ async def build_orchestrator_messages( messages.append({"role": "assistant", "content": m.content}) # The current action - messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'}) + messages.append({"role": "user", "content": f'Player action: "{action_text}"'}) return messages, settings_map @@ -100,7 +103,11 @@ async def _maybe_compress( world: World, settings_map: Dict[str, Any], ) -> None: - """If history exceeds threshold, summarize older messages into a single summary message.""" + """If history exceeds threshold, summarize older messages into a single summary message. + + Uses the `submit_summary` tool (tool-calling-first design) to get structured + output from the summarizer LLM. + """ visible = [m for m in all_msgs if not m.hidden] if len(visible) <= recent_n + summary_n: return @@ -110,45 +117,61 @@ async def _maybe_compress( if not to_summarize: return - # Build summarization input + # Build summarization input (English labels — system content convention) summary_input_lines = [] for m in to_summarize: prefix = { - "player_action": "Игрок", - "narrative_step": "Сцена", - "summary": "Сводка", + "player_action": "Player", + "narrative_step": "Scene", + "summary": "Summary", "orchestrator_plan": "GM", - "technical_offscreen": "За кадром", + "technical_offscreen": "Offscreen", }.get(m.kind, m.kind) summary_input_lines.append(f"{prefix}: {m.content[:300]}") summary_input = "\n\n".join(summary_input_lines) llm = LlmClient(settings_map) system_prompt = get_prompt("summarizer", world.language) + from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS response = await llm.chat( messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": summary_input[:4000]}, ], + tools=SUMMARIZER_TOOL_SCHEMAS, temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))), - max_tokens=300, + max_tokens=400, purpose="summary", session_id=session_id, db=db, ) - # Parse summary response - summary_text = response.text + # Extract from submit_summary tool call; fall back to text parse. + summary_text = response.text or "" facts: List[Dict[str, Any]] = [] - import re as _re - json_match = _re.search(r"\{[\s\S]*\}", response.text) - if json_match: - try: - data = json.loads(json_match.group(0)) - summary_text = data.get("summary", response.text) - facts = data.get("facts", []) - except json.JSONDecodeError: - pass + extracted = False + for tc in (response.tool_calls or []): + if tc.get("function", {}).get("name") == "submit_summary": + args_str = tc.get("function", {}).get("arguments", "{}") + try: + data = json.loads(args_str) if args_str else {} + summary_text = data.get("summary", response.text or "") + facts = data.get("facts", []) or [] + extracted = True + except json.JSONDecodeError: + pass + break + if not extracted: + # Fallback: extract JSON from text response (older models). + import re as _re + json_match = _re.search(r"\{[\s\S]*\}", response.text or "") + if json_match: + try: + data = json.loads(json_match.group(0)) + summary_text = data.get("summary", response.text or "") + facts = data.get("facts", []) or [] + except json.JSONDecodeError: + pass # Create summary message next_seq = (max((m.seq for m in all_msgs), default=0)) + 1 @@ -207,7 +230,11 @@ async def build_step_writer_messages( outcome: str, narrative_prompt: str, ) -> List[Dict[str, Any]]: - """Build messages for the step writer LLM call.""" + """Build messages for the step writer LLM call. + + The step-writer prompt is in English (system content convention) but + instructs the LLM to produce the narrative in world.language. + """ defn = world.definition or {} player_state = world.state.get("player", {}) if world.state else {} system_prompt = get_prompt("step_writer", world.language).format( @@ -216,6 +243,7 @@ async def build_step_writer_messages( player_state=json.dumps(player_state, ensure_ascii=False)[:400], outcome=outcome, narrative_prompt=narrative_prompt[:600], + world_language=world.language or "en", ) return [{"role": "system", "content": system_prompt}] diff --git a/backend/app/engine/orchestrator.py b/backend/app/engine/orchestrator.py index 0a667d5..cdb9423 100644 --- a/backend/app/engine/orchestrator.py +++ b/backend/app/engine/orchestrator.py @@ -1,4 +1,15 @@ -"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step.""" +"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step. + +Design (v2 — tool-calling-first): + - The orchestrator LLM is given a set of game tools (dice_roll, update_state, + rag_query, rag_add, schedule_trigger, advance_time, run_subagent) PLUS a + `submit_plan` tool. The LLM calls game tools to execute its plan, then + calls `submit_plan` to terminate the loop with structured data. + - The step-writer LLM is given only a `submit_scene` tool. It calls this + to return the narrative + options; its text response is ignored. + - This replaces the old "return JSON in your text response" pattern which + conflicted with tool use and caused the model to dump raw JSON into chat. +""" from __future__ import annotations import json @@ -11,15 +22,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.llm import LlmClient from app.core.settings_service import cast_setting, get_all_settings +from app.core.triggers import advance_world_time, fire_due_triggers from app.engine.context import ( build_orchestrator_messages, build_step_writer_messages, build_subagent_messages, ) -from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call +from app.engine.tools.tools import ( + ALL_TOOL_SCHEMAS, + STEP_WRITER_TOOL_SCHEMAS, + ToolContext, + handle_tool_call, +) from app.logging_setup import get_logger -from app.models import DeferredTrigger, Message, Session, World -from app.prompts.templates import get_prompt +from app.models import Message, Session, World log = get_logger("orchestrator") @@ -88,19 +104,22 @@ async def run_iteration( ) return resp.text - ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map) + ctx = ToolContext( + db=db, + world=world, + session_id=session_id, + user_id=user_id, + subagent_runner=_subagent, + settings_map=settings_map, + ) # === Phase 1: Orchestrator with tool calls (max 5 iterations) === orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text) - # Add a final user instruction forcing JSON output - orchestrator_messages.append({ - "role": "user", - "content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.", - }) max_iters = 5 - final_assistant_text: Optional[str] = None - final_tool_calls: List[Dict[str, Any]] = [] + parsed: Dict[str, Any] = {} + plan_tool_calls_log: List[Dict[str, Any]] = [] + orchestrator_text_log: str = "" for i in range(max_iters): yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}} @@ -114,48 +133,112 @@ async def run_iteration( db=db, ) - if response.tool_calls: - # Append assistant message with tool_calls - orchestrator_messages.append({ - "role": "assistant", - "content": response.text or "", - "tool_calls": response.tool_calls, - }) - # Execute each tool call - for tc in response.tool_calls: - fn = tc.get("function", {}) - name = fn.get("name", "") - args_str = fn.get("arguments", "{}") - try: - args = json.loads(args_str) if args_str else {} - except json.JSONDecodeError: - args = {} - yield {"type": "tool_call", "data": {"name": name, "args": args}} - result_dict = await handle_tool_call(name, args, ctx) - yield {"type": "tool_result", "data": {"name": name, "result": result_dict}} - # Append tool result message - orchestrator_messages.append({ - "role": "tool", - "tool_call_id": tc.get("id", ""), - "name": name, - "content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800], - }) - await db.commit() - continue # Let orchestrator continue with tool results - else: - # No tool calls - this is the final answer - final_assistant_text = response.text + if not response.tool_calls: + # No tool calls — model gave up or errored. Treat its text as the + # outcome directly so the player still sees SOMETHING. + log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or "")) + orchestrator_text_log = response.text or "" + parsed = { + "assessment": "(no plan submitted)", + "outcome": response.text or "", + "narrative_prompt": "", + "next_options": [], + "state_patch": {}, + "time_advance": None, + "rag_facts": [], + "rails_update": None, + } break - if final_assistant_text is None: - # Ran out of iterations - use last text - final_assistant_text = response.text or "{}" + # Append assistant message with tool_calls + orchestrator_messages.append({ + "role": "assistant", + "content": response.text or "", + "tool_calls": response.tool_calls, + }) + + # Check for submit_plan — if present, extract plan and break + submit_plan_call = None + for tc in response.tool_calls: + if tc.get("function", {}).get("name") == "submit_plan": + submit_plan_call = tc + break + + if submit_plan_call: + # Extract plan from the submit_plan tool call + args_str = submit_plan_call.get("function", {}).get("arguments", "{}") + try: + parsed = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + log.warning("submit_plan_invalid_json", args=args_str[:200]) + parsed = {} + # Make sure required keys exist + parsed.setdefault("assessment", "") + parsed.setdefault("outcome", "") + parsed.setdefault("narrative_prompt", "") + parsed.setdefault("next_options", []) + parsed.setdefault("state_patch", {}) + parsed.setdefault("time_advance", None) + parsed.setdefault("rag_facts", []) + parsed.setdefault("rails_update", None) + # Acknowledge the tool call so the model's history is consistent + orchestrator_messages.append({ + "role": "tool", + "tool_call_id": submit_plan_call.get("id", ""), + "name": "submit_plan", + "content": json.dumps({"ok": True}), + }) + # Log OTHER tool calls made this iteration (for debugging) + for tc in response.tool_calls: + fn = tc.get("function", {}) + if fn.get("name") != "submit_plan": + plan_tool_calls_log.append({ + "name": fn.get("name"), + "args": _safe_parse_json(fn.get("arguments", "{}")), + }) + break + + # Otherwise: execute all tool calls and continue + for tc in response.tool_calls: + fn = tc.get("function", {}) + name = fn.get("name", "") + args_str = fn.get("arguments", "{}") + try: + args = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + args = {} + yield {"type": "tool_call", "data": {"name": name, "args": args}} + try: + result_dict = await handle_tool_call(name, args, ctx) + except Exception as e: + result_dict = {"error": f"{type(e).__name__}: {e}"} + log.error("tool_call_failed", name=name, error=str(e)) + yield {"type": "tool_result", "data": {"name": name, "result": result_dict}} + plan_tool_calls_log.append({"name": name, "args": args, "result": result_dict}) + # Append tool result message + orchestrator_messages.append({ + "role": "tool", + "tool_call_id": tc.get("id", ""), + "name": name, + "content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800], + }) + await db.commit() + else: + # Ran out of iterations without submit_plan — use a minimal fallback. + log.warning("orchestrator_exhausted_iterations") + parsed = parsed or { + "assessment": "(iteration limit reached)", + "outcome": orchestrator_text_log or action_text, + "narrative_prompt": "", + "next_options": [], + "state_patch": {}, + "time_advance": None, + "rag_facts": [], + "rails_update": None, + } yield {"type": "status", "data": {"message": "writing_scene"}} - # === Parse orchestrator final response === - parsed = _parse_orchestrator_response(final_assistant_text) - # Apply final state patch (if any) if parsed.get("state_patch"): from app.core.state_validator import apply_patch, validate_state @@ -170,7 +253,7 @@ async def run_iteration( # Advance time time_advance = parsed.get("time_advance") if time_advance and isinstance(time_advance, dict): - new_time = _advance_world_time(world.current_time, time_advance, world) + new_time, _total, _delta = advance_world_time(world.current_time, time_advance, world) world.current_time = new_time # Save orchestrator plan as hidden message @@ -180,31 +263,40 @@ async def run_iteration( seq=plan_seq, role="assistant", kind="orchestrator_plan", - content=final_assistant_text[:2000], + content=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[:2000], payload={ "assessment": parsed.get("assessment", ""), "outcome": parsed.get("outcome", ""), "state_patch": parsed.get("state_patch", {}), "time_advance": time_advance, - "tool_calls_made": [tc for tc in final_tool_calls], + "tool_calls_made": plan_tool_calls_log, "scheduled_triggers": ctx.scheduled_triggers, "rag_added": ctx.rag_added, + "narrative_prompt": parsed.get("narrative_prompt", ""), + "next_options": parsed.get("next_options", []), }, is_pinned=False, hidden=True, ) db.add(plan_msg) - # === Phase 2: Step writer (narrative scene) === + # === Phase 2: Step writer (narrative scene) — uses submit_scene tool === narrative_prompt_parts = [parsed.get("narrative_prompt", "")] # Add RAG context if relevant if parsed.get("outcome"): try: from app.core.rag import get_rag rag = await get_rag(settings_map) - rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map) + rag_results = await rag.search_glossary( + world.id, + parsed.get("outcome", ""), + limit=3, + settings_map=settings_map, + ) if rag_results: - rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results) + rag_text = "\n".join( + f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results + ) narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}") except Exception as e: log.warning("rag_lookup_failed", error=str(e)) @@ -219,28 +311,46 @@ async def run_iteration( step_resp = await llm.chat( messages=step_messages, + tools=STEP_WRITER_TOOL_SCHEMAS, temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), - max_tokens=800, + max_tokens=1200, purpose="step", user_id=user_id, session_id=session_id, db=db, ) - step_text = step_resp.text + # Extract scene from submit_scene tool call (if present); fall back to text. + step_text = step_resp.text or "" step_options: List[str] = parsed.get("next_options", []) or [] - # Try to extract structured step from JSON - import re as _re - json_match = _re.search(r"\{[\s\S]*\}", step_resp.text) - if json_match: - try: - step_data = json.loads(json_match.group(0)) - if "narrative" in step_data: - step_text = step_data["narrative"] - if "options" in step_data and isinstance(step_data["options"], list): - step_options = [str(o) for o in step_data["options"]][:5] - except json.JSONDecodeError: - pass + for tc in (step_resp.tool_calls or []): + if tc.get("function", {}).get("name") == "submit_scene": + args_str = tc.get("function", {}).get("arguments", "{}") + try: + scene_data = json.loads(args_str) if args_str else {} + if scene_data.get("narrative"): + step_text = scene_data["narrative"] + if scene_data.get("options") and isinstance(scene_data["options"], list): + step_options = [str(o) for o in scene_data["options"]][:5] + except json.JSONDecodeError: + log.warning("submit_scene_invalid_json", args=args_str[:200]) + break + else: + # No submit_scene call — try to extract JSON from text as a last resort. + import re as _re + json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "") + if json_match: + try: + step_data = json.loads(json_match.group(0)) + if "narrative" in step_data: + step_text = step_data["narrative"] + if "options" in step_data and isinstance(step_data["options"], list): + step_options = [str(o) for o in step_data["options"]][:5] + except json.JSONDecodeError: + pass + # If still no narrative, use the orchestrator's outcome as fallback. + if not step_text.strip(): + step_text = parsed.get("outcome", action_text) # Save narrative step message step_seq = await _next_seq(db, session_id) @@ -276,7 +386,6 @@ async def run_iteration( completed = set(rails.get("completed_subgoals", [])) completed.update(rails_update["completed_subgoals"]) rails["completed_subgoals"] = list(completed) - # Remove completed from subgoals rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed] defn["plot_rails"] = rails world.definition = defn @@ -316,8 +425,27 @@ async def run_iteration( await db.commit() await db.refresh(step_msg) - # Check for triggers that should fire immediately (fire_at <= current_time) - fired_now = await _check_due_triggers(db, session_id, world.current_time or "") + # Check for triggers that should fire now (fire_at <= current world time). + # Triggers fire on in-game time changes, not real-time polling — see + # app.core.triggers. We do this AFTER committing the narrative step so the + # player sees the main scene first, then any trigger consequences. + triggers_enabled = bool(cast_setting( + "triggers.enabled", + settings_map.get("triggers.enabled", True), + )) + fired_now: List[Dict[str, Any]] = [] + if triggers_enabled: + try: + await db.refresh(world) + fired_now = await fire_due_triggers( + db=db, + session_id=session_id, + world=world, + settings_map=settings_map, + user_id=user_id, + ) + except Exception as e: + log.warning("trigger_fire_failed_in_iteration", error=f"{type(e).__name__}: {e}") yield { "type": "step_complete", @@ -335,21 +463,6 @@ async def run_iteration( yield {"type": "done", "data": {}} -def _parse_orchestrator_response(text: str) -> Dict[str, Any]: - """Extract the JSON object from the orchestrator's final response.""" - if not text: - return {} - import re as _re - m = _re.search(r"\{[\s\S]*\}", text) - if not m: - return {"outcome": text, "narrative_prompt": text, "next_options": []} - try: - data = json.loads(m.group(0)) - return data - except json.JSONDecodeError: - return {"outcome": text, "narrative_prompt": text, "next_options": []} - - async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int: result = await db.execute( select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1) @@ -358,86 +471,8 @@ async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int: return (row[0] + 1) if row else 1 -def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str: - """Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime.""" - if not current_time: - # Try to use the world_state's world_time field - wt = (world.state or {}).get("world_time", {}) - if wt: - day = int(wt.get("day", 1)) - hour = int(wt.get("hour", 8)) - else: - day, hour = 1, 8 - else: - # Parse 'day_N_hour_H' or fall back to numbers - import re as _re - m = _re.match(r"day_(\d+)_hour_(\d+)", current_time) - if m: - day, hour = int(m.group(1)), int(m.group(2)) - else: - # Try ISO format - try: - from datetime import datetime as _dt, timedelta as _td - dt = _dt.fromisoformat(current_time) - dt = dt + _td( - days=int(advance.get("days", 0)), - hours=int(advance.get("hours", 0)), - minutes=int(advance.get("minutes", 0)), - ) - return dt.isoformat() - except Exception: - day, hour = 1, 8 - - total_minutes = day * 24 * 60 + hour * 60 - total_minutes += int(advance.get("days", 0)) * 24 * 60 - total_minutes += int(advance.get("hours", 0)) * 60 - total_minutes += int(advance.get("minutes", 0)) - new_day = total_minutes // (24 * 60) - new_hour = (total_minutes % (24 * 60)) // 60 - new_time = f"day_{new_day}_hour_{new_hour}" - - # Also update world_time in state if present - if world.state and "world_time" in world.state: - world.state["world_time"] = { - **world.state["world_time"], - "day": new_day, - "hour": new_hour, - } - - return new_time - - -async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]: - """Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers.""" - import re as _re - def _parse(t: str): - m = _re.match(r"day_(\d+)_hour_(\d+)", t or "") - if m: - return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60 - try: - from datetime import datetime as _dt - dt = _dt.fromisoformat(t) - return int(dt.timestamp() // 60) - except Exception: - return 0 - cur = _parse(current_time) - result = await db.execute( - select(DeferredTrigger).where( - DeferredTrigger.session_id == session_id, - DeferredTrigger.fired.is_(False), - ) - ) - triggers = list(result.scalars().all()) - fired: List[Dict[str, Any]] = [] - for t in triggers: - if _parse(t.fire_at) <= cur: - t.fired = True - fired.append({ - "id": str(t.id), - "fire_at": t.fire_at, - "description": t.description, - "payload": t.payload, - }) - if fired: - await db.commit() - return fired +def _safe_parse_json(s: str) -> Any: + try: + return json.loads(s) if s else {} + except Exception: + return s diff --git a/backend/app/engine/tools/tools.py b/backend/app/engine/tools/tools.py index d655e76..e2aa2d2 100644 --- a/backend/app/engine/tools/tools.py +++ b/backend/app/engine/tools/tools.py @@ -91,13 +91,29 @@ RAG_ADD_SCHEMA = build_tool_schema( SCHEDULE_TRIGGER_SCHEMA = build_tool_schema( name="schedule_trigger", - description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.", + description=( + "Schedule a deferred event tied to in-world time. When the world's " + "internal clock reaches fire_at, the engine fires the event (calls the " + "LLM with the description to produce a narrative beat and optional " + "state patch). fire_at must use the same format as world.current_time " + "('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar " + "(hours_per_day, minutes_per_hour) is honored when comparing times." + ), params={ "type": "object", "properties": { - "fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"}, - "description": {"type": "string", "description": "What should happen"}, - "payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"}, + "fire_at": { + "type": "string", + "description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.", + }, + "description": { + "type": "string", + "description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.", + }, + "payload": { + "type": "object", + "description": "Optional structured payload (e.g. who, conditions, parameters).", + }, }, "required": ["fire_at", "description"], }, @@ -106,14 +122,22 @@ SCHEDULE_TRIGGER_SCHEMA = build_tool_schema( ADVANCE_TIME_SCHEMA = build_tool_schema( name="advance_time", - description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.", + description=( + "Advance the world's internal clock by days / hours / minutes. Use " + "this when the player's action takes measurable in-world time (travel, " + "sleep, crafting, long rest). The world's calendar (hours_per_day, " + "minutes_per_hour) is honored. After time advances, any scheduled " + "triggers whose fire_at is now <= the new time will fire " + "automatically — so this is also how you 'run out the clock' on a " + "scheduled event." + ), params={ "type": "object", "properties": { "days": {"type": "integer", "default": 0}, "hours": {"type": "integer", "default": 0}, "minutes": {"type": "integer", "default": 0}, - "reason": {"type": "string", "description": "Why time advances"}, + "reason": {"type": "string", "description": "Why time advances (logged for debugging)."}, }, }, ) @@ -133,6 +157,219 @@ RUN_SUBAGENT_SCHEMA = build_tool_schema( ) +# === Submission tools (how the LLM returns structured results) === +# These replace the old "return JSON in your text response" pattern, which +# conflicted with tool use and caused the model to dump raw JSON into chat. + +SUBMIT_PLAN_SCHEMA = build_tool_schema( + name="submit_plan", + description=( + "Submit the orchestrator's final plan for this iteration. This MUST be " + "the last tool you call. After you call it, the iteration ends and the " + "step-writer takes over to produce the cinematic scene." + ), + params={ + "type": "object", + "properties": { + "assessment": { + "type": "string", + "description": "Brief assessment of the player's action (1-2 sentences, English).", + }, + "outcome": { + "type": "string", + "description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.", + }, + "state_patch": { + "type": "object", + "description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.", + "properties": { + "set": {"type": "object"}, + "unset": {"type": "array", "items": {"type": "string"}}, + "append": {"type": "object"}, + "increment": {"type": "object"}, + "remove": {"type": "object"}, + }, + }, + "time_advance": { + "type": "object", + "description": "How much in-world time advances. null/omitted if no time passes.", + "properties": { + "days": {"type": "integer", "default": 0}, + "hours": {"type": "integer", "default": 0}, + "minutes": {"type": "integer", "default": 0}, + }, + }, + "narrative_prompt": { + "type": "string", + "description": "Facts the step-writer should know to write the scene (English). Max ~100 words.", + }, + "next_options": { + "type": "array", + "items": {"type": "string"}, + "description": "3 suggested next actions for the player (short, 5-12 words each).", + }, + "rails_update": { + "type": "object", + "description": "Optional update to plot rails. Omit if no change.", + "properties": { + "main_goal": {"type": "string"}, + "new_subgoals": {"type": "array", "items": {"type": "string"}}, + "completed_subgoals": {"type": "array", "items": {"type": "string"}}, + }, + }, + "rag_facts": { + "type": "array", + "description": "New persistent facts to add to the glossary. Empty array if none.", + "items": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]}, + "name": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["kind", "name", "description"], + }, + }, + }, + "required": ["assessment", "outcome", "narrative_prompt", "next_options"], + }, +) + + +SUBMIT_SCENE_SCHEMA = build_tool_schema( + name="submit_scene", + description=( + "Submit the narrative scene for this step. This is the ONLY way to " + "return the scene — your text response is ignored. The narrative " + "should be 200-400 words, cinematic, second-person ('You...'), in the " + "world's player-facing language." + ), + params={ + "type": "object", + "properties": { + "narrative": { + "type": "string", + "description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').", + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Exactly 3 short (5-12 words) options for the player's next action.", + }, + }, + "required": ["narrative", "options"], + }, +) + + +SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema( + name="submit_world_definition", + description=( + "Submit a proposed world definition. Call this when you have enough " + "information to build the world. Your text response will be shown to " + "the player as your conversational reply (use it to summarize the " + "proposed world in 2-4 sentences)." + ), + params={ + "type": "object", + "properties": { + "setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."}, + "rules": { + "type": "object", + "description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).", + }, + "world_schema": { + "type": "object", + "description": "JSON Schema describing the shape of the world state.", + }, + "plot_rails": { + "type": "object", + "description": "{main_goal, subgoals, hooks}.", + "properties": { + "main_goal": {"type": "string"}, + "subgoals": {"type": "array", "items": {"type": "string"}}, + "hooks": {"type": "array", "items": {"type": "string"}}, + }, + }, + "initial_state": { + "type": "object", + "description": "Initial world state matching world_schema.", + }, + "initial_time": { + "type": "string", + "description": "World time string e.g. 'day_1_hour_8'.", + }, + "calendar": { + "type": "object", + "description": "Optional. Custom calendar. Include only if non-standard.", + "properties": { + "hours_per_day": {"type": "integer"}, + "minutes_per_hour": {"type": "integer"}, + "days_per_week": {"type": "integer"}, + }, + }, + "is_final": { + "type": "boolean", + "description": "True ONLY when the player has explicitly accepted the world.", + }, + }, + "required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"], + }, +) + + +SUBMIT_SUMMARY_SCHEMA = build_tool_schema( + name="submit_summary", + description="Submit the compressed summary of older session messages.", + params={ + "type": "object", + "properties": { + "summary": {"type": "string", "description": "3-6 sentences, max 150 words."}, + "facts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]}, + "name": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["kind", "name", "description"], + }, + }, + }, + "required": ["summary", "facts"], + }, +) + + +SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema( + name="submit_trigger_result", + description="Submit the result of firing a deferred trigger.", + params={ + "type": "object", + "properties": { + "outcome": {"type": "string", "description": "1-2 sentences, English. For logs."}, + "state_patch": { + "type": "object", + "description": "JSON-patch for world state. Empty object if no change.", + "properties": { + "set": {"type": "object"}, + "unset": {"type": "array", "items": {"type": "string"}}, + "append": {"type": "object"}, + "increment": {"type": "object"}, + "remove": {"type": "object"}, + }, + }, + "narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."}, + "should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."}, + }, + "required": ["outcome", "narrative", "should_notify_player"], + }, +) + + +# Tools available to the orchestrator (game-loop tools + submit_plan) ALL_TOOL_SCHEMAS = [ DICE_ROLL_SCHEMA, UPDATE_STATE_SCHEMA, @@ -141,8 +378,21 @@ ALL_TOOL_SCHEMAS = [ SCHEDULE_TRIGGER_SCHEMA, ADVANCE_TIME_SCHEMA, RUN_SUBAGENT_SCHEMA, + SUBMIT_PLAN_SCHEMA, ] +# Tools for the step writer (only submit_scene) +STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA] + +# Tools for the world builder (only submit_world_definition) +WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA] + +# Tools for the summarizer +SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA] + +# Tools for the trigger runner +TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA] + # === Tool handlers === diff --git a/backend/app/engine/world_builder.py b/backend/app/engine/world_builder.py index 677ee06..67fc354 100644 --- a/backend/app/engine/world_builder.py +++ b/backend/app/engine/world_builder.py @@ -1,4 +1,13 @@ -"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.""" +"""World builder: multi-turn dialogue to produce a finalized WorldDefinition. + +Design (v2 — tool-calling-first): + The world-builder LLM is given a single tool, `submit_world_definition`, + which it calls when it has enough information to propose a world. The LLM's + text response is the conversational reply shown to the player (in + world.language). This replaces the old "return JSON in your text response" + pattern which conflicted with tool use and caused raw JSON to leak into + the chat. +""" from __future__ import annotations import json @@ -14,6 +23,7 @@ from app.logging_setup import get_logger from app.models import Preset, User, World from app.prompts.templates import get_prompt from app.schemas import WorldBuilderReply, WorldDefinition +from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS log = get_logger("world_builder") @@ -36,7 +46,8 @@ async def start_world_builder( ) -> WorldBuilderReply: """Kick off a new world-builder dialogue. Returns the first AI reply.""" session_id = uuid.uuid4() - llm = await LlmClient.from_db(db) + settings_map = await get_all_settings(db) + llm = LlmClient(settings_map) preset_payload: Optional[Dict[str, Any]] = None if preset_id: @@ -55,7 +66,9 @@ async def start_world_builder( language=language, ) - system_prompt = get_prompt("world_builder", language) + # System prompt is English (system content convention). The LLM is told to + # produce player-facing text in `language` (interpolated as world_language). + system_prompt = get_prompt("world_builder", language).format(world_language=language) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_brief}, @@ -63,20 +76,27 @@ async def start_world_builder( response = await llm.chat( messages=messages, - temperature=0.7, + tools=WORLD_BUILDER_TOOL_SCHEMAS, + temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), purpose="world_builder", user_id=user.id, db=db, ) - ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text) + ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls) _DIALOGUES[session_id] = { "user_id": user.id, "world_name": world_name, "language": language, "preset_id": preset_id, - "messages": messages + [{"role": "assistant", "content": response.text}], + "messages": messages + [ + { + "role": "assistant", + "content": response.text or "", + "tool_calls": response.tool_calls or None, + }, + ], "turn": 1, "last_proposed": proposed.model_dump() if proposed else None, } @@ -87,7 +107,7 @@ async def start_world_builder( ai_message=ai_text, proposed_definition=proposed, is_final=is_final, - followup_questions=followups, + followup_questions=[], ) @@ -104,20 +124,26 @@ async def continue_world_builder( if dialogue["user_id"] != user.id: raise ValueError("forbidden") - llm = await LlmClient.from_db(db) + settings_map = await get_all_settings(db) + llm = LlmClient(settings_map) dialogue["messages"].append({"role": "user", "content": user_message}) dialogue["turn"] += 1 response = await llm.chat( messages=dialogue["messages"], - temperature=0.7, + tools=WORLD_BUILDER_TOOL_SCHEMAS, + temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), purpose="world_builder", user_id=user.id, db=db, ) - dialogue["messages"].append({"role": "assistant", "content": response.text}) + dialogue["messages"].append({ + "role": "assistant", + "content": response.text or "", + "tool_calls": response.tool_calls or None, + }) - ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text) + ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls) if proposed: dialogue["last_proposed"] = proposed.model_dump() @@ -127,7 +153,7 @@ async def continue_world_builder( ai_message=ai_text, proposed_definition=proposed, is_final=is_final, - followup_questions=followups, + followup_questions=[], ) @@ -151,7 +177,7 @@ async def commit_world_builder( world = World( owner_id=user.id, name=name or dialogue.get("world_name") or "New World", - language=dialogue.get("language", "ru"), + language=dialogue.get("language", "en"), definition=definition.model_dump(), state=definition.initial_state or {}, current_time=definition.initial_time, @@ -176,7 +202,8 @@ def _build_user_brief( preset_payload: Optional[Dict[str, Any]], language: str, ) -> str: - parts = [f"=== WORLD BRIEF ({language.upper()}) ==="] + parts = [f"=== WORLD BRIEF ==="] + parts.append(f"Player-facing language: {language}") parts.append(f"Name: {world_name}") if preset_payload: parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}") @@ -189,57 +216,78 @@ def _build_user_brief( parts.append(f"Rules: {rules_brief}") if notes: parts.append(f"Notes: {notes}") - parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.") + parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.") return "\n".join(parts) -def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]: - """Extract AI message text, proposed definition (if any), is_final flag, and followup questions.""" - proposed = None +def _extract_world_definition( + text: str, + tool_calls: Optional[List[Dict[str, Any]]], +) -> tuple[str, Optional[WorldDefinition], bool]: + """Extract AI message text, proposed definition (if any), and is_final flag. + + Looks for a `submit_world_definition` tool call first. Falls back to + JSON-in-text parse for older models that don't honor the tool. + """ + proposed: Optional[WorldDefinition] = None is_final = False - followups: List[str] = [] + ai_text = text or "" - # Try to find a JSON block in the response - json_str = _extract_json_block(text) - if json_str: - try: - data = json.loads(json_str) - if isinstance(data, dict): - if "proposed_definition" in data: - pd = data["proposed_definition"] - if isinstance(pd, dict): - try: - proposed = WorldDefinition.model_validate(pd) - except Exception: - proposed = None - if "is_final" in data: - is_final = bool(data["is_final"]) - if "followup_questions" in data and isinstance(data["followup_questions"], list): - followups = [str(q) for q in data["followup_questions"]] - if "ai_message" in data and isinstance(data["ai_message"], str): - text = data["ai_message"] - except json.JSONDecodeError: - pass + # 1) Prefer the submit_world_definition tool call (the proper way). + if tool_calls: + for tc in tool_calls: + if tc.get("function", {}).get("name") == "submit_world_definition": + args_str = tc.get("function", {}).get("arguments", "{}") + try: + data = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + data = {} + proposed = _try_build_definition(data) + is_final = bool(data.get("is_final", False)) + break - # Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final - if proposed is not None: - low = text.lower() - if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]): + # 2) Fallback: parse a JSON block from the text (older models). + if proposed is None: + json_str = _extract_json_block(text) + if json_str: + try: + data = json.loads(json_str) + if isinstance(data, dict): + if "proposed_definition" in data and isinstance(data["proposed_definition"], dict): + proposed = _try_build_definition(data["proposed_definition"]) + if "is_final" in data: + is_final = bool(data["is_final"]) + if "ai_message" in data and isinstance(data["ai_message"], str): + ai_text = data["ai_message"] + except json.JSONDecodeError: + pass + + # 3) Heuristic: if a definition was proposed and the text mentions "ready", + # mark as final. + if proposed is not None and not is_final: + low = ai_text.lower() + if any(kw in low for kw in ["world is ready", "world_ready", "ready to commit", "мир готов"]): is_final = True - return text, proposed, is_final, followups + return ai_text, proposed, is_final + + +def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]: + try: + return WorldDefinition.model_validate(data) + except Exception as e: + log.warning("world_definition_invalid", error=str(e)) + return None def _extract_json_block(text: str) -> Optional[str]: - """Find the first JSON object/array block in text.""" + """Find the first JSON object/array block in text (fallback path only).""" if not text: return None - # Try fenced ```json ... ``` import re m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) if m: return m.group(1) - # Try raw {...} (greedy from first { to matching }) start = text.find("{") if start == -1: return None diff --git a/backend/app/migrations/init_db.py b/backend/app/migrations/init_db.py index 168cbfe..5aa1000 100644 --- a/backend/app/migrations/init_db.py +++ b/backend/app/migrations/init_db.py @@ -44,8 +44,7 @@ DEFAULT_SETTINGS = [ ("context.compress_threshold", settings.default_compress_threshold, "Trigger compression at this count"), ("context.summary_messages", settings.default_summary_messages, "Number of messages per summary block"), ("context.max_tokens_total", 6000, "Soft token budget for context window (small models)"), - ("triggers.enabled", True, "Enable deferred trigger processing"), - ("triggers.check_interval", 30, "Trigger checker interval, seconds"), + ("triggers.enabled", True, "Enable trigger firing on in-game time changes"), # Embeddings / RAG ("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"), ("embedding.base_url", settings.default_embedding_base_url, "OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url"), @@ -56,6 +55,23 @@ DEFAULT_SETTINGS = [ ] +# Keys whose values come from environment variables (via Settings fields). +# These are re-applied on EVERY startup so .env is the source of truth. +# Admin-panel changes to these keys are runtime overrides that get reset on +# restart unless the operator also updates .env. +ENV_DERIVED_SETTING_KEYS = { + "llm.base_url", + "llm.api_key", + "llm.model", + "embedding.provider", + "embedding.base_url", + "embedding.api_key", + "embedding.model", + "embedding.dim", + "embedding.request_timeout", +} + + async def init_db() -> None: setup_logging() log.info("creating_tables") @@ -75,6 +91,7 @@ async def init_db() -> None: ) ) await _seed_settings(session) + await _sync_env_derived_settings(session) await _seed_builtin_presets(session) await session.commit() except Exception as e: @@ -82,12 +99,19 @@ async def init_db() -> None: log.warning("advisory_lock_unavailable_proceeding", error=f"{type(e).__name__}: {e}") async with AsyncSessionLocal() as session: await _seed_settings(session) + await _sync_env_derived_settings(session) await _seed_builtin_presets(session) await session.commit() - # Ensure admin_setup_token is set; if empty, generate and print + # Ensure admin_setup_token is set and print it on every startup. + # + # The admin-setup endpoint refuses to create a second admin (see app/api/auth.py), + # so it's safe to always print the token — even after an admin exists, the token + # is useless. We print on every startup (not just first run) so the operator can + # always find the URL in the logs without having to dig through old logs. token = settings.admin_setup_token.strip() if not token: + # No token forced via env — generate one and persist it (idempotent). import secrets as _s token = _s.token_urlsafe(24) async with AsyncSessionLocal() as session: @@ -97,17 +121,25 @@ async def init_db() -> None: session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup")) try: await session.commit() - print("=" * 60) - print("ADMIN SETUP TOKEN (use at /admin/setup):") - print(token) - print("=" * 60) - log.info("admin_setup_token_generated") except IntegrityError: - # Another process inserted it concurrently — fine. + # Another process inserted it concurrently — re-read. await session.rollback() - log.info("admin_setup_token_already_set") + await session.rollback() + existing = await session.execute(select(Setting).where(Setting.key == "admin.setup_token")) + existing_obj = existing.scalars().first() + if existing_obj is not None: + token = str(existing_obj.value) else: - log.info("admin_setup_token_already_set") + # Use the persisted token (env was empty, DB has one). + token = str(existing_obj.value) + # Always print — operator convenience. + print("=" * 60) + print("ADMIN SETUP URL:") + print(f" /admin/setup") + print("ADMIN SETUP TOKEN:") + print(f" {token}") + print("=" * 60) + log.info("admin_setup_token_printed") async def _seed_settings(session) -> None: @@ -135,6 +167,46 @@ async def _seed_settings(session) -> None: log.info("settings_already_exist") +async def _sync_env_derived_settings(session) -> None: + """Re-apply env-derived setting values from .env on every startup. + + This makes .env the source of truth for these keys: changing .env and + restarting the container takes effect immediately. Admin-panel edits to + these keys are runtime overrides that are reset on the next restart + (unless the operator also updates .env). + + Only the env-derived keys (see ENV_DERIVED_SETTING_KEYS) are touched; + other settings (temperature, context params, etc.) are preserved as + configured via the admin panel. + """ + env_values = {key: value for key, value, _desc in DEFAULT_SETTINGS if key in ENV_DERIVED_SETTING_KEYS} + updated = 0 + for key, new_value in env_values.items(): + result = await session.execute(select(Setting).where(Setting.key == key)) + row = result.scalars().first() + if row is None: + # Shouldn't happen (seeded above) but handle defensively. + session.add(Setting(key=key, value=new_value, description="Env-derived")) + updated += 1 + else: + if row.value != new_value: + log.info( + "env_setting_resynced", + key=key, + old_value=str(row.value)[:80], + new_value=str(new_value)[:80], + ) + row.value = new_value + updated += 1 + if updated: + try: + await session.commit() + log.info("env_settings_synced", count=updated) + except IntegrityError: + await session.rollback() + log.warning("env_settings_sync_failed_concurrent") + + async def _seed_builtin_presets(session) -> None: """Insert built-in presets if none exist yet.""" result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True))) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 59d6fd1..5c5c221 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -34,7 +34,7 @@ class User(Base): hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - preferred_language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False) + preferred_language: Mapped[str] = mapped_column(String(8), default="en", nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) worlds: Mapped[List["World"]] = relationship(back_populates="owner", cascade="all, delete-orphan") @@ -58,7 +58,7 @@ class Preset(Base): slug: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False) + language: Mapped[str] = mapped_column(String(8), default="en", nullable=False) is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # JSON: world_schema, default_rules, initial_state, world_seed_prompt, suggested_system_prompt @@ -73,7 +73,7 @@ class World(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) - language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False) + language: Mapped[str] = mapped_column(String(8), default="en", nullable=False) # Frozen world definition: setting description, rules, world_schema (JSON Schema for state), plot_rails definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) # Current live state of the world (player character, NPC, inventory, time, etc.) @@ -95,7 +95,7 @@ class Session(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) world_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("worlds.id"), nullable=False, index=True) - title: Mapped[str] = mapped_column(String(255), default="Новая сессия", nullable=False) + title: Mapped[str] = mapped_column(String(255), default="New session", nullable=False) # Snapshot of world state at session start (we mutate world.state during play; session stores narrative history) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) diff --git a/backend/app/prompts/templates.py b/backend/app/prompts/templates.py index 586000d..3b49904 100644 --- a/backend/app/prompts/templates.py +++ b/backend/app/prompts/templates.py @@ -1,104 +1,49 @@ -"""System prompts for all LLM stages. Bilingual (RU/EN).""" -from __future__ import annotations +"""System prompts for all LLM stages. -from typing import Dict +IMPORTANT: All system prompts are in English (per the convention that +"invisible" content the LLM processes internally should be English for best +tokenization and instruction-following, regardless of the world's player- +facing language). The LLM is instructed to produce player-facing narrative +in world.language. + +These prompts use a tool-calling-first design: instead of asking the LLM to +emit JSON in its text response (which conflicts with tool use and produces +"raw JSON in chat" bugs), the LLM is given a `submit_*` tool whose arguments +carry the structured data. The LLM's text response is the human-readable +message to the user. +""" +from __future__ import annotations # === World Builder === -WORLD_BUILDER_SYSTEM_RU = """Ты — опытный архитектор миров для ролевой игры. -Твоя задача — помочь игроку создать мир через диалог. Игрок даёт начальный бриф (сеттинг, персонаж, правила, заметки). -Ты должен: -1. Если информации мало — задать 2-4 уточняющих вопроса коротко и по делу. -2. Если информации достаточно — построить complete world definition и представить его игроку как draft. -3. Принять правки и уточнения, цикл продолжается пока игрок не скажет "готово". - -Структура world definition (выводи в JSON в поле proposed_definition когда считаешь что мир готов или близок): -{ - "setting_description": "расширенный сеттинг (1-2 абзаца)", - "rules": {объект с правилами: статы, бой, магия, время, инвентарь, смерть}, - "world_schema": {JSON Schema для состояния мира: player, npcs, locations, world_time, flags}, - "plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]}, - "initial_state": {начальное состояние мира согласно schema}, - "initial_time": "строка времени мира (например 'day_1_hour_8')" -} - -ВАЖНО для small models: -- Будь лаконичен. Не более 200 слов в каждом сообщении. -- JSON выводи строго валидный, без комментариев. -- В каждом ответе: либо задавай вопросы (если данных мало), либо давай proposed_definition. -- Когда мир готов — поставь is_final=true (но только если игрок согласился). -""" - - -WORLD_BUILDER_SYSTEM_EN = """You are a master world-builder for a role-playing game. +WORLD_BUILDER_SYSTEM = """You are a master world-builder for a role-playing game. Your job is to help the player design a world through dialogue. The player gives a brief (setting, character, rules, notes). -You must: -1. If information is sparse — ask 2-4 short, focused clarifying questions. -2. If information is sufficient — build a complete world definition and present it as a draft. -3. Accept edits and clarifications; the loop continues until the player says "ok". -World definition structure (output in JSON as proposed_definition when the world is ready or near-ready): -{ - "setting_description": "expanded setting (1-2 paragraphs)", - "rules": {object with rules: stats, combat, magic, time, inventory, death}, - "world_schema": {JSON Schema for world state: player, npcs, locations, world_time, flags}, - "plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]}, - "initial_state": {initial world state matching schema}, - "initial_time": "world time string (e.g. 'day_1_hour_8')" -} +Workflow: +1. If information is sparse — ask 2-4 short, focused clarifying questions in your reply text. +2. If information is sufficient — propose a world definition by calling the `submit_world_definition` tool. Also write a short summary of the proposed world in your reply text (2-4 sentences) so the player can react to it. +3. Accept edits and clarifications; the loop continues until the player says the world is ready. -CRITICAL for small models: -- Be concise. Max 200 words per message. -- Output strictly valid JSON, no comments. -- In each reply: either ask questions (if data is sparse), or give proposed_definition. -- When world is ready — set is_final=true (only if the player agreed). +When calling `submit_world_definition`: +- `setting_description`: 1-2 paragraph expanded setting. +- `rules`: object with keys like `stats`, `combat`, `magic`, `time`, `inventory`, `death` (whichever apply). +- `world_schema`: a JSON Schema describing the shape of the world's state (player, npcs, locations, world_time, flags, etc.). +- `plot_rails`: `{main_goal, subgoals, hooks}`. +- `initial_state`: the initial world state matching `world_schema`. +- `initial_time`: world-time string in the form `day_N_hour_H` (e.g. `day_1_hour_8`). +- `calendar`: optional. `{hours_per_day: 24, minutes_per_hour: 60, days_per_week: 7}`. Include only if the world uses a non-standard calendar (e.g. 28-hour days). +- `is_final`: set to `true` ONLY when the player has explicitly accepted the world. + +CRITICAL: +- Be concise. Max 200 words of text per message. +- The reply text is shown to the player in their language ({world_language}). Write in that language. +- The `submit_world_definition` arguments are machine-parsed — keep them structured and valid. +- If you only need to ask questions, do NOT call `submit_world_definition` yet. """ # === Orchestrator (main game loop with tools) === -ORCHESTRATOR_SYSTEM_RU = """Ты — Game Master ролевой игры. Ведёшь сессию через инструментальные вызовы. - -ТЕКУЩИЙ КОНТЕКСТ: -- Мир: {world_name} -- Сеттинг: {setting_description} -- Правила: {rules} -- Текущее время мира: {current_time} -- Состояние игрока: {player_state} -- Главные рельсы сюжета: {plot_rails} -- Сводка прошлого: {summary} - -ЗАДАЧА: -Игрок сделал действие: "{action_text}" -Оцени реалистичность (соответствие сеттингу и правилам), спланируй что должно произойти, используй инструменты для: -- бросков кубиков (dice_roll) -- обновления состояния (update_state) -- проверки/добавления фактов в RAG (rag_query, rag_add) -- планирования отложенных событий (schedule_trigger) -- обновления времени мира (advance_time) -- запуска sub-агента для генерации деталей с чистым контекстом (run_subagent) - -После выполнения плана — верни ответ в виде JSON (без текста вне JSON): -{ - "assessment": "краткая оценка действия (1-2 предложения)", - "outcome": "что произошло (сырой, 1-3 предложения)", - "state_patch": {JSON-patch для состояния мира}, - "time_advance": {"days": 0, "hours": 0, "minutes": 0} | null, - "narrative_prompt": "факты которые должен знать step-writer для написания сценария", - "next_options": ["вариант 1", "вариант 2", "вариант 3"], - "triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}], - "rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null, - "rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}] -} - -ВАЖНО: -- Экономь токены. Минимум 1-3 tool calls на итерацию, не больше 5. -- Если действие тривиальное — пропусти dice_roll. -- Не пиши сценарное описание — это задача step-writer. -- Соблюдай сеттинг. -""" - - -ORCHESTRATOR_SYSTEM_EN = """You are the Game Master of a role-playing game. You run the session through tool calls. +ORCHESTRATOR_SYSTEM = """You are the Game Master of a role-playing game. You run the session through tool calls. CURRENT CONTEXT: - World: {world_name} @@ -110,64 +55,24 @@ CURRENT CONTEXT: - Past summary: {summary} TASK: -The player performed action: "{action_text}" -Assess realism (consistency with setting and rules), plan what should happen, use tools to: -- roll dice (dice_roll) -- update state (update_state) -- query / add facts to RAG (rag_query, rag_add) -- schedule deferred events (schedule_trigger) -- advance world time (advance_time) -- spawn a sub-agent for detail generation with clean context (run_subagent) +The player performed the action: "{action_text}" -After executing the plan — return your reply as JSON (no text outside JSON): -{ - "assessment": "brief assessment of the action (1-2 sentences)", - "outcome": "what happened (raw, 1-3 sentences)", - "state_patch": {JSON-patch for world state}, - "time_advance": {"days": 0, "hours": 0, "minutes": 0} | null, - "narrative_prompt": "facts the step-writer should know to write the scene", - "next_options": ["option 1", "option 2", "option 3"], - "triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}], - "rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null, - "rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}] -} +Assess realism (consistency with setting and rules), plan what should happen, then: +1. Use tools to execute the plan (dice_roll, update_state, rag_query, rag_add, schedule_trigger, advance_time, run_subagent) as needed. +2. After all your tool calls, call `submit_plan` with your structured plan. The plan's `outcome` and `narrative_prompt` will be passed to the step-writer to produce the cinematic scene. -CRITICAL: -- Save tokens. 1-3 tool calls per iteration, max 5. +CRITICAL RULES: +- Use 1-3 tool calls per iteration. Max 5. - Skip dice_roll for trivial actions. -- Do NOT write the narrative scene — that's the step-writer's job. +- Do NOT write the narrative scene — that is the step-writer's job. Your job is to plan and execute mechanics. +- The `submit_plan` call MUST be your last action. After you call it, the iteration ends. - Stay in setting. +- All tool arguments are structured (JSON). Your text response is ignored — only tool calls matter. """ # === Step Writer === -STEP_WRITER_SYSTEM_RU = """Ты — сценарист ролевой игры. Превращаешь сырой outcome в сценарный шаг как в книге. - -КОНТЕКСТ: -- Сеттинг: {setting_description} -- Текущее время мира: {current_time} -- Состояние игрока: {player_state} -- Что произошло (сырое): {outcome} -- Дополнительные факты: {narrative_prompt} - -НАПИШИ: -1. Сценарное описание (2-4 абзаца, кинематографично, от второго лица "Ты..."). -2. В конце — 3 опции следующего действия (короткие, 5-12 слов). - -Формат ответа (строгий JSON): -{ - "narrative": "...", - "options": ["...", "...", "..."] -} - -ВАЖНО: -- 200-400 слов сценария. Не больше. -- Не повторяй то что игрок уже знает. -- Заканчивай клиффхэнгером или моментом выбора. -""" - - -STEP_WRITER_SYSTEM_EN = """You are the narrative writer of a role-playing game. You turn raw outcome into a book-like scene. +STEP_WRITER_SYSTEM = """You are the narrative writer of a role-playing game. You turn a raw outcome into a book-like scene. CONTEXT: - Setting: {setting_description} @@ -176,120 +81,86 @@ CONTEXT: - What happened (raw): {outcome} - Additional facts: {narrative_prompt} -WRITE: -1. Narrative description (2-4 paragraphs, cinematic, second-person "You..."). -2. End with 3 options for the next action (short, 5-12 words). - -Response format (strict JSON): -{ - "narrative": "...", - "options": ["...", "...", "..."] -} +YOUR JOB: +1. Call the `submit_scene` tool with: + - `narrative`: 200-400 words of cinematic, second-person ("You...") prose describing what happens. + - `options`: exactly 3 short (5-12 words) options for the player's next action. +2. Your text response is ignored — only the `submit_scene` tool call is used. CRITICAL: -- 200-400 words of narrative. Not more. -- Don't repeat what the player already knows. +- Write the narrative in {world_language}. +- Do NOT repeat what the player already knows. - End with a cliffhanger or decision moment. +- The scene must be consistent with the outcome — do not contradict it. """ # === Summarizer === -SUMMARIZER_SYSTEM_RU = """Ты сжимаешь историю ролевой сессии. Дано несколько сообщений — выдай компактную сводку. +SUMMARIZER_SYSTEM = """You compress the history of a role-playing session. Given several messages — produce a compact summary. -Выведи: -1. summary: 3-6 предложений ключевых событий и изменений состояния. -2. facts: массив важных устойчивых фактов [{kind, name, description}] (коротко). +Call the `submit_summary` tool with: +- `summary`: 3-6 sentences of key events and state changes (max 150 words). +- `facts`: array of important persistent facts `[{kind, name, description}]` where kind is one of npc, location, item, lore, event. -Формат (строгий JSON): -{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]} - -ВАЖНО: Не более 150 слов в summary. Сохраняй имена, числа, важные изменения. -""" - - -SUMMARIZER_SYSTEM_EN = """You compress the history of a role-playing session. Given several messages — produce a compact summary. - -Output: -1. summary: 3-6 sentences of key events and state changes. -2. facts: array of important persistent facts [{kind, name, description}] (brief). - -Format (strict JSON): -{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]} - -CRITICAL: Max 150 words in summary. Preserve names, numbers, important changes. +CRITICAL: Preserve names, numbers, and important state changes. Your text response is ignored — only the `submit_summary` tool call is used. """ # === Sub-agent (clean context detail generator) === -SUBAGENT_SYSTEM_RU = """Ты — суб-агент с чистым контекстом. Получаешь задачу от главного GM, выдаёшь конкретный результат. - -Задача: {task} -Контекст: {context} - -Дай компактный, сфокусированный ответ. Не более 150 слов. -""" - -SUBAGENT_SYSTEM_EN = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result. +SUBAGENT_SYSTEM = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result. Task: {task} Context: {context} -Give a compact, focused answer. Max 150 words. -""" +Give a compact, focused answer. Max 150 words. Your text response IS the result (no tool call needed).""" # === Trigger runner === -TRIGGER_RUNNER_SYSTEM_RU = """Ты обрабатываешь отложенное событие в ролевой игре. +TRIGGER_RUNNER_SYSTEM = """You process a deferred event in a role-playing game. A scheduled trigger has fired. -Событие: {description} -Payload: {payload} -Текущее состояние мира: {state} - -Верни JSON: -{ - "outcome": "что произошло (1-2 предложения)", - "state_patch": {JSON-patch}, - "narrative": "сценарное описание для игрока (1 абзац, опционально если игрок не видит — пустая строка)", - "should_notify_player": true|false -} -""" - -TRIGGER_RUNNER_SYSTEM_EN = """You process a deferred event in a role-playing game. - -Event: {description} -Payload: {payload} +Event description: {description} +Event payload: {payload} Current world state: {state} -Return JSON: -{ - "outcome": "what happened (1-2 sentences)", - "state_patch": {JSON-patch}, - "narrative": "scene description for the player (1 paragraph, optional — empty string if player doesn't witness)", - "should_notify_player": true|false -} -""" +The player-facing narrative must be written in: {world_language}. + +Call the `submit_trigger_result` tool with: +- `outcome`: 1-2 sentence raw description of what happened (English, for logs). +- `state_patch`: JSON-patch for world state (set/unset/append/increment/remove). Empty object if no state change. +- `narrative`: 1-paragraph scene description for the player, in {world_language}. Empty string if the player doesn't witness the event. +- `should_notify_player`: true if the narrative should be shown to the player, false if it's an offscreen event. + +Your text response is ignored — only the tool call is used.""" PROMPTS = { - "ru": { - "world_builder": WORLD_BUILDER_SYSTEM_RU, - "orchestrator": ORCHESTRATOR_SYSTEM_RU, - "step_writer": STEP_WRITER_SYSTEM_RU, - "summarizer": SUMMARIZER_SYSTEM_RU, - "subagent": SUBAGENT_SYSTEM_RU, - "trigger_runner": TRIGGER_RUNNER_SYSTEM_RU, - }, "en": { - "world_builder": WORLD_BUILDER_SYSTEM_EN, - "orchestrator": ORCHESTRATOR_SYSTEM_EN, - "step_writer": STEP_WRITER_SYSTEM_EN, - "summarizer": SUMMARIZER_SYSTEM_EN, - "subagent": SUBAGENT_SYSTEM_EN, - "trigger_runner": TRIGGER_RUNNER_SYSTEM_EN, + "world_builder": WORLD_BUILDER_SYSTEM, + "orchestrator": ORCHESTRATOR_SYSTEM, + "step_writer": STEP_WRITER_SYSTEM, + "summarizer": SUMMARIZER_SYSTEM, + "subagent": SUBAGENT_SYSTEM, + "trigger_runner": TRIGGER_RUNNER_SYSTEM, + }, + # Russian keys kept for backward compatibility but always return the English + # prompts — system content is always English per the project convention. + "ru": { + "world_builder": WORLD_BUILDER_SYSTEM, + "orchestrator": ORCHESTRATOR_SYSTEM, + "step_writer": STEP_WRITER_SYSTEM, + "summarizer": SUMMARIZER_SYSTEM, + "subagent": SUBAGENT_SYSTEM, + "trigger_runner": TRIGGER_RUNNER_SYSTEM, }, } -def get_prompt(stage: str, language: str = "ru") -> str: - lang = language if language in PROMPTS else "ru" - return PROMPTS[lang].get(stage, PROMPTS["ru"][stage]) +def get_prompt(stage: str, language: str = "en") -> str: + """Return the system prompt for `stage`. + + `language` is kept for backward compatibility but is ignored — all system + prompts are English by design. Player-facing output language is controlled + via prompts that interpolate {world_language}. + """ + lang = language if language in PROMPTS else "en" + return PROMPTS[lang].get(stage, PROMPTS["en"][stage]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b78ffac..0de50cd 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -16,7 +16,8 @@ class UserRegister(BaseModel): class UserLogin(BaseModel): - email: EmailStr + # Accepts either email or username — the backend resolves it. + login: str password: str @@ -72,7 +73,7 @@ class PresetCreate(BaseModel): slug: str title: str description: Optional[str] = None - language: str = "ru" + language: str = "en" is_public: bool = True payload: Dict[str, Any] @@ -80,7 +81,7 @@ class PresetCreate(BaseModel): # === Worlds === class WorldCreate(BaseModel): name: str = Field(min_length=1, max_length=255) - language: str = "ru" + language: str = "en" preset_id: Optional[UUID] = None @@ -120,7 +121,7 @@ class WorldUpdate(BaseModel): class WorldBuilderStart(BaseModel): """Kick off a new world-building conversation.""" world_name: str = Field(min_length=1, max_length=255) - language: str = "ru" + language: str = "en" # Either pick a preset to start from, or fill the freeform brief. preset_id: Optional[UUID] = None setting_brief: str = "" diff --git a/backend/app/workers/main.py b/backend/app/workers/main.py index fda106c..851ac40 100644 --- a/backend/app/workers/main.py +++ b/backend/app/workers/main.py @@ -1,9 +1,12 @@ -"""Worker entrypoint: runs trigger checker + future background jobs. +"""Worker entrypoint. -Waits for the database (and required tables) to be ready before starting -the background loops. This prevents the worker from crashing when the -backend hasn't finished running `init_db()` yet (typical docker-compose -race condition where both services start in parallel). +Historically this ran a trigger-polling loop. Triggers now fire in-process +inside the orchestrator when in-game time changes (see app.core.triggers), +so the worker has nothing to do at the moment. We keep the container running +as a placeholder for future background jobs (RAG re-indexer, summary +compactor, etc.). + +If you add a background job, register it in `asyncio.gather(...)` below. """ from __future__ import annotations @@ -11,7 +14,6 @@ import asyncio from app.db_wait import wait_for_db_or_exit from app.logging_setup import get_logger, setup_logging -from app.workers.trigger_runner import main_loop as trigger_loop log = get_logger("worker") @@ -20,17 +22,19 @@ async def main(): setup_logging() log.info("worker_starting") - # Wait until DB is reachable and the `settings` table exists. - # The backend's lifespan runs `init_db()` which creates tables; if the - # worker comes up first, this loop will retry until that completes. + # Make sure the DB is reachable and tables exist before doing anything. + # (Future background jobs may need this.) await wait_for_db_or_exit(max_retries=60, delay=2.0) - log.info("worker_db_ready_starting_loops") - # Run all background loops concurrently - await asyncio.gather( - trigger_loop(), - # Future: rag indexer, summary compactor, etc. - ) + log.info("worker_ready_no_jobs_registered") + + # Nothing to do for now — sleep forever. Future background jobs go here: + # await asyncio.gather( + # some_future_loop(), + # another_future_loop(), + # ) + while True: + await asyncio.sleep(3600) if __name__ == "__main__": diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c0ae778..2837793 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -46,8 +46,9 @@ export const authApi = { const { data } = await api.post("/auth/register", { email, username, password }); return data; }, - login: async (email: string, password: string): Promise => { - const { data } = await api.post("/auth/login", { email, password }); + login: async (login: string, password: string): Promise => { + // `login` accepts either email or username. + const { data } = await api.post("/auth/login", { login, password }); return data; }, me: async (): Promise => { @@ -93,6 +94,10 @@ export const adminApi = { const { data } = await api.get("/admin/users"); return data; }, + setUserActive: async (userId: string, isActive: boolean): Promise => { + const { data } = await api.post(`/admin/users/${userId}/set-active`, { is_active: isActive }); + return data; + }, }; export const presetsApi = { diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 078e108..339e902 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -17,6 +17,7 @@ export const en = { register_title: "Register", email: "Email", username: "Username", + login_or_email: "Email or username", password: "Password", login_btn: "Login", register_btn: "Register", @@ -102,8 +103,9 @@ export const en = { summary_messages: "Messages per summary", max_tokens_total: "Context token budget", trigger_settings: "Deferred triggers", + trigger_settings_desc: "Fire when in-world time advances (no polling)", triggers_enabled: "Enabled", - triggers_check_interval: "Check interval (sec)", + triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.", embedding_settings: "Embeddings (RAG)", embedding_provider: "Provider", embedding_provider_hash: "Hash (offline fallback, no semantics)", @@ -121,6 +123,9 @@ export const en = { saved: "Saved!", llm_logs: "LLM logs", users: "Users", + users_actions: "Actions", + users_ban: "Ban", + users_unban: "Unban", back: "Back", }, glossary: { diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 3feb494..d71db3d 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -12,8 +12,11 @@ i18n ru: { translation: ru }, en: { translation: en }, }, - fallbackLng: "ru", - supportedLngs: ["ru", "en"], + // English is the default. The user can switch via the language picker + // in the navbar; the choice is cached in localStorage and overrides + // browser settings on subsequent visits. + fallbackLng: "en", + supportedLngs: ["en", "ru"], interpolation: { escapeValue: false }, detection: { order: ["localStorage", "navigator"], diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 321bd4d..4d65b35 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -17,6 +17,7 @@ export const ru = { register_title: "Регистрация", email: "Email", username: "Имя пользователя", + login_or_email: "Email или имя пользователя", password: "Пароль", login_btn: "Войти", register_btn: "Зарегистрироваться", @@ -102,8 +103,9 @@ export const ru = { summary_messages: "Сообщений на сводку", max_tokens_total: "Бюджет токенов контекста", trigger_settings: "Отложенные триггеры", + trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)", triggers_enabled: "Включены", - triggers_check_interval: "Интервал проверки (сек)", + triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.", embedding_settings: "Эмбеддинги (RAG)", embedding_provider: "Провайдер", embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)", @@ -121,6 +123,9 @@ export const ru = { saved: "Сохранено!", llm_logs: "Логи LLM", users: "Пользователи", + users_actions: "Действия", + users_ban: "Забанить", + users_unban: "Разбанить", back: "Назад", }, glossary: { diff --git a/frontend/src/pages/AdminPanelPage.tsx b/frontend/src/pages/AdminPanelPage.tsx index e92ea4d..811bb9f 100644 --- a/frontend/src/pages/AdminPanelPage.tsx +++ b/frontend/src/pages/AdminPanelPage.tsx @@ -6,7 +6,7 @@ import type { LlmLog, SettingsOut } from "@/types"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Card, CardBody, CardHeader } from "@/components/ui/Card"; -import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react"; +import { Save, ArrowLeft, Activity, Users, Zap, Ban, CheckCircle2 } from "lucide-react"; export function AdminPanelPage() { const { t } = useTranslation(); @@ -22,6 +22,7 @@ export function AdminPanelPage() { const [error, setError] = useState(""); const [embeddingTest, setEmbeddingTest] = useState(null); const [testingEmbeddings, setTestingEmbeddings] = useState(false); + const [userActionError, setUserActionError] = useState(""); useEffect(() => { (async () => { @@ -110,6 +111,16 @@ export function AdminPanelPage() { } }; + const toggleUserActive = async (userId: string, currentActive: boolean) => { + setUserActionError(""); + try { + const updated = await adminApi.setUserActive(userId, !currentActive); + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: updated.is_active } : u))); + } catch (err: any) { + setUserActionError(err.response?.data?.detail || t("errors.unknown")); + } + }; + if (loading) { return
{t("common.loading")}
; } @@ -302,23 +313,17 @@ export function AdminPanelPage() { - + -
- - setValues({ ...values, "triggers.check_interval": v })} +
+ {t("admin.triggers_enabled")} + +

{t("admin.triggers_enabled_desc")}

@@ -382,6 +387,9 @@ export function AdminPanelPage() { + {userActionError && ( +

{userActionError}

+ )}
@@ -390,7 +398,8 @@ export function AdminPanelPage() { - + + @@ -412,9 +421,32 @@ export function AdminPanelPage() { )} - + ))} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1da13eb..cdc465e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -55,12 +55,6 @@ export function HomePage() { desc="RU / EN" /> - -
- - {t("auth.admin_setup_title")} - -
); } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index dafdfe3..9e14a0c 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -11,7 +11,7 @@ export function LoginPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { setAuth } = useAuthStore(); - const [email, setEmail] = useState(""); + const [login, setLogin] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -21,7 +21,8 @@ export function LoginPage() { setError(""); setLoading(true); try { - const { access_token, user } = await authApi.login(email, password); + // `login` accepts either email or username. + const { access_token, user } = await authApi.login(login, password); setAuth(access_token, user); navigate("/dashboard"); } catch (err: any) { @@ -38,13 +39,14 @@ export function LoginPage() {
setEmail(e.target.value)} + label={t("auth.login_or_email")} + type="text" + name="login" + value={login} + onChange={(e) => setLogin(e.target.value)} required - autoComplete="email" + autoComplete="username" + placeholder="alice / alice@example.com" />
Username Role ActiveCreatedCreated{t("admin.users_actions")}
+ {new Date(u.created_at).toLocaleDateString()} + {u.is_admin ? ( + + ) : u.is_active ? ( + + ) : ( + + )} +