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