This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

144
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,144 @@
"""Admin panel routes: settings, LLM logs, users."""
from __future__ import annotations
from typing import Any, Dict, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Body, Depends, HTTPException
from app.core.settings_service import EDITABLE_SETTING_KEYS, get_all_settings, update_settings
from app.db import get_db_dep
from app.deps import require_admin
from app.models import LlmCallLog, Setting, User
from app.schemas import LlmLogOut, SettingsOut, SettingsUpdate
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _mask_secrets(values: Dict[str, Any]) -> Dict[str, Any]:
"""Mask sensitive api_key fields in outbound responses."""
for k in ("llm.api_key", "embedding.api_key"):
v = values.get(k)
if isinstance(v, str) and v:
values[k] = v[:4] + "***" + v[-4:] if len(v) > 8 else "***"
# Never expose admin setup token via this endpoint
values.pop("admin.setup_token", None)
return values
@router.get("/settings", response_model=SettingsOut)
async def get_settings_endpoint(
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
values = await get_all_settings(db)
values = _mask_secrets(values)
return SettingsOut(values=values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
@router.put("/settings", response_model=SettingsOut)
async def update_settings_endpoint(
payload: SettingsUpdate,
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
# Strip masked api_key fields unless the user typed a new value
cleaned: Dict[str, Any] = {}
for k, v in (payload.values or {}).items():
if k in ("llm.api_key", "embedding.api_key") and isinstance(v, str) and "***" in v:
continue
cleaned[k] = v
new_values = await update_settings(db, cleaned)
# If embedding settings changed, drop the cached RAG client so the next
# get_rag() call rebuilds it (and reconfigures Qdrant collections if dim changed).
if any(k.startswith("embedding.") for k in cleaned):
from app.core.rag import reset_rag
await reset_rag()
new_values = _mask_secrets(new_values)
return SettingsOut(values=new_values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
@router.post("/embeddings/test")
async def test_embeddings_endpoint(
payload: Dict[str, Any] = Body(default={}),
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
"""Probe the currently configured embeddings endpoint.
Accepts an optional `overrides` dict with embedding.* keys (e.g. to test
a new endpoint before saving). Returns: ok, provider, base_url, model,
dim, sample_norm (or error).
"""
from app.core.rag import probe_embeddings
settings_map = await get_all_settings(db)
# Apply ad-hoc overrides (without saving) so the admin can try before save
overrides = (payload or {}).get("overrides") or {}
for k, v in overrides.items():
if k in EDITABLE_SETTING_KEYS:
settings_map[k] = v
return await probe_embeddings(settings_map)
@router.get("/llm-logs", response_model=List[LlmLogOut])
async def list_llm_logs(
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
result = await db.execute(
select(LlmCallLog).order_by(LlmCallLog.created_at.desc()).limit(min(limit, 200)).offset(offset)
)
return result.scalars().all()
@router.get("/llm-logs/{log_id}")
async def get_llm_log(
log_id: str,
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
from uuid import UUID
result = await db.execute(select(LlmCallLog).where(LlmCallLog.id == UUID(log_id)))
log = result.scalars().first()
if not log:
raise HTTPException(status_code=404, detail="log_not_found")
return {
"id": str(log.id),
"purpose": log.purpose,
"model": log.model,
"base_url": log.base_url,
"prompt_messages": log.prompt_messages,
"tools": log.tools,
"response_text": log.response_text,
"tool_calls": log.tool_calls,
"prompt_tokens": log.prompt_tokens,
"completion_tokens": log.completion_tokens,
"total_tokens": log.total_tokens,
"latency_ms": log.latency_ms,
"error": log.error,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
@router.get("/users")
async def list_users(
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(require_admin),
):
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return [
{
"id": str(u.id),
"email": u.email,
"username": u.username,
"is_admin": u.is_admin,
"is_active": u.is_active,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]

83
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,83 @@
"""Authentication routes: register, login, me, admin setup."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.security import create_access_token, hash_password, verify_password
from app.core.settings_service import get_setting
from app.db import get_db_dep
from app.deps import get_current_user
from app.models import User
from app.schemas import AdminSetupRequest, TokenOut, UserLogin, UserOut, UserRegister
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=TokenOut, status_code=status.HTTP_201_CREATED)
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db_dep)):
existing = await db.execute(select(User).where((User.email == payload.email) | (User.username == payload.username)))
if existing.scalars().first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="user_already_exists")
user = User(
email=payload.email,
username=payload.username,
hashed_password=hash_password(payload.password),
is_admin=False,
)
db.add(user)
await db.commit()
await db.refresh(user)
token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
return TokenOut(access_token=token, user=UserOut.model_validate(user))
@router.post("/login", response_model=TokenOut)
async def login(payload: UserLogin, db: AsyncSession = Depends(get_db_dep)):
result = await db.execute(select(User).where(User.email == payload.email))
user = result.scalars().first()
if not user or not verify_password(payload.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="user_disabled")
token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
return TokenOut(access_token=token, user=UserOut.model_validate(user))
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user
@router.post("/admin-setup", response_model=TokenOut)
async def admin_setup(payload: AdminSetupRequest, db: AsyncSession = Depends(get_db_dep)):
"""One-time endpoint to create the first admin user using a setup token."""
# Check if any admin already exists
existing_admins = await db.execute(select(User).where(User.is_admin.is_(True)))
if existing_admins.scalars().first() is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="admin_already_exists")
# Validate setup token (from DB or env)
db_token = await get_setting(db, "admin.setup_token", default=None)
env_token = payload.token # what the user supplied
if not db_token or db_token != env_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid_setup_token")
# Check user collision
existing = await db.execute(select(User).where((User.email == payload.email) | (User.username == payload.username)))
if existing.scalars().first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="user_already_exists")
user = User(
email=payload.email,
username=payload.username,
hashed_password=hash_password(payload.password),
is_admin=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
return TokenOut(access_token=token, user=UserOut.model_validate(user))

65
backend/app/api/misc.py Normal file
View File

@@ -0,0 +1,65 @@
"""Glossary + Triggers routes."""
from __future__ import annotations
from typing import List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException
from app.db import get_db_dep
from app.deps import get_current_user
from app.models import DeferredTrigger, GlossaryEntry, Session, User, World
from app.schemas import GlossaryEntryOut, TriggerOut
router = APIRouter(prefix="/api", tags=["misc"])
@router.get("/worlds/{world_id}/glossary", response_model=List[GlossaryEntryOut])
async def list_glossary(
world_id: UUID,
kind: str | None = None,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
w_result = await db.execute(select(World).where(World.id == world_id))
world = w_result.scalars().first()
if not world:
raise HTTPException(status_code=404, detail="world_not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(status_code=403, detail="forbidden")
q = select(GlossaryEntry).where(GlossaryEntry.world_id == world_id)
if kind:
q = q.where(GlossaryEntry.kind == kind)
q = q.order_by(GlossaryEntry.created_at.desc())
result = await db.execute(q)
return result.scalars().all()
@router.get("/sessions/{session_id}/triggers", response_model=List[TriggerOut])
async def list_triggers(
session_id: UUID,
include_fired: bool = True,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
s_result = await db.execute(
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
)
session = s_result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="session_not_found")
w_result = await db.execute(select(World).where(World.id == session.world_id))
world = w_result.scalars().first()
if not world or (world.owner_id != user.id and not user.is_admin):
raise HTTPException(status_code=403, detail="forbidden")
q = select(DeferredTrigger).where(DeferredTrigger.session_id == session_id)
if not include_fired:
q = q.where(DeferredTrigger.fired.is_(False))
q = q.order_by(DeferredTrigger.fire_at)
result = await db.execute(q)
return result.scalars().all()

View File

@@ -0,0 +1,71 @@
"""Preset routes: list / get / create."""
from __future__ import annotations
from typing import List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException
from app.db import get_db_dep
from app.deps import get_current_user
from app.models import Preset, User
from app.schemas import PresetCreate, PresetOut
router = APIRouter(prefix="/api/presets", tags=["presets"])
@router.get("", response_model=List[PresetOut])
async def list_presets(
language: str | None = None,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
"""List public presets + user's private ones, optionally filtered by language."""
q = select(Preset).where(
(Preset.is_public.is_(True)) | (Preset.author_id == user.id)
)
if language:
q = q.where(Preset.language == language)
q = q.order_by(Preset.is_builtin.desc(), Preset.created_at.desc())
result = await db.execute(q)
return result.scalars().all()
@router.get("/{preset_id}", response_model=PresetOut)
async def get_preset(
preset_id: UUID,
db: AsyncSession = Depends(get_db_dep),
_: User = Depends(get_current_user),
):
result = await db.execute(select(Preset).where(Preset.id == preset_id))
preset = result.scalars().first()
if not preset:
raise HTTPException(status_code=404, detail="preset_not_found")
if not preset.is_public and preset.author_id != _.id:
raise HTTPException(status_code=403, detail="forbidden")
return preset
@router.post("", response_model=PresetOut, status_code=201)
async def create_preset(
payload: PresetCreate,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
preset = Preset(
slug=payload.slug,
title=payload.title,
description=payload.description,
language=payload.language,
is_public=payload.is_public,
is_builtin=False,
payload=payload.payload,
author_id=user.id,
)
db.add(preset)
await db.commit()
await db.refresh(preset)
return preset

162
backend/app/api/sessions.py Normal file
View File

@@ -0,0 +1,162 @@
"""Sessions routes: list / create / get / messages / start iteration (SSE)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Query
from sse_starlette.sse import EventSourceResponse
from app.db import get_db_dep
from app.deps import get_current_user
from app.engine.orchestrator import run_iteration
from app.models import Message, Session, User, World
from app.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@router.get("", response_model=List[SessionOut])
async def list_sessions(
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(Session)
.join(World, Session.world_id == World.id)
.where(World.owner_id == user.id)
.order_by(Session.last_played_at.desc().nullslast())
)
return result.scalars().all()
@router.post("", response_model=SessionOut, status_code=201)
async def create_session(
payload: SessionCreate,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
# Verify world ownership
result = await db.execute(select(World).where(World.id == payload.world_id))
world = result.scalars().first()
if not world:
raise HTTPException(status_code=404, detail="world_not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(status_code=403, detail="forbidden")
if world.status not in ("ready", "active"):
raise HTTPException(status_code=400, detail=f"world_not_ready: status={world.status}")
session = Session(
world_id=world.id,
title=payload.title or f"Сессия в мире «{world.name}»",
)
db.add(session)
# Mark world as active
world.status = "active"
await db.commit()
await db.refresh(session)
return session
@router.get("/{session_id}", response_model=SessionOut)
async def get_session(
session_id: UUID,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="session_not_found")
# Verify ownership via world
w_result = await db.execute(select(World).where(World.id == session.world_id))
world = w_result.scalars().first()
if not world or (world.owner_id != user.id and not user.is_admin):
raise HTTPException(status_code=403, detail="forbidden")
return session
@router.get("/{session_id}/messages", response_model=List[MessageOut])
async def list_messages(
session_id: UUID,
include_hidden: bool = Query(False),
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
# Verify access
result = await db.execute(
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="session_not_found")
w_result = await db.execute(select(World).where(World.id == session.world_id))
world = w_result.scalars().first()
if not world or (world.owner_id != user.id and not user.is_admin):
raise HTTPException(status_code=403, detail="forbidden")
q = select(Message).where(Message.session_id == session_id).order_by(Message.seq)
if not include_hidden:
q = q.where(Message.hidden.is_(False))
result = await db.execute(q)
return result.scalars().all()
@router.post("/{session_id}/iterate")
async def iterate_session(
session_id: UUID,
payload: IterationRequest,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
"""SSE stream of the iteration."""
# Verify access
result = await db.execute(
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="session_not_found")
w_result = await db.execute(select(World).where(World.id == session.world_id))
world = w_result.scalars().first()
if not world or (world.owner_id != user.id and not user.is_admin):
raise HTTPException(status_code=403, detail="forbidden")
if payload.session_id != session_id:
raise HTTPException(status_code=400, detail="session_id_mismatch")
async def event_generator():
try:
async for event in run_iteration(db=db, user_id=user.id, session_id=session_id, action_text=payload.action_text):
yield {"event": event["type"], "data": json.dumps(event.get("data", {}), ensure_ascii=False, default=str)}
except Exception as e:
yield {"event": "error", "data": json.dumps({"message": str(e)}, ensure_ascii=False)}
yield {"event": "done", "data": "{}"}
return EventSourceResponse(event_generator())
@router.delete("/{session_id}", status_code=204)
async def delete_session(
session_id: UUID,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=404, detail="session_not_found")
w_result = await db.execute(select(World).where(World.id == session.world_id))
world = w_result.scalars().first()
if not world or (world.owner_id != user.id and not user.is_admin):
raise HTTPException(status_code=403, detail="forbidden")
await db.delete(session)
await db.commit()

161
backend/app/api/worlds.py Normal file
View File

@@ -0,0 +1,161 @@
"""Worlds routes: CRUD + world builder flow."""
from __future__ import annotations
from typing import List
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException
from app.db import get_db_dep
from app.deps import get_current_user
from app.engine.world_builder import commit_world_builder, continue_world_builder, start_world_builder
from app.models import User, World
from app.schemas import (
WorldBuilderCommit,
WorldBuilderMessage,
WorldBuilderReply,
WorldBuilderStart,
WorldCreate,
WorldOut,
WorldUpdate,
)
router = APIRouter(prefix="/api/worlds", tags=["worlds"])
@router.get("", response_model=List[WorldOut])
async def list_worlds(
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(
select(World).where(World.owner_id == user.id).order_by(World.updated_at.desc())
)
return result.scalars().all()
@router.get("/{world_id}", response_model=WorldOut)
async def get_world(
world_id: UUID,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalars().first()
if not world:
raise HTTPException(status_code=404, detail="world_not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(status_code=403, detail="forbidden")
return world
@router.post("", response_model=WorldOut, status_code=201)
async def create_world(
payload: WorldCreate,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
world = World(
owner_id=user.id,
name=payload.name,
language=payload.language,
definition={},
state={},
status="draft",
preset_id=payload.preset_id,
)
db.add(world)
await db.commit()
await db.refresh(world)
return world
@router.patch("/{world_id}", response_model=WorldOut)
async def update_world(
world_id: UUID,
payload: WorldUpdate,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalars().first()
if not world:
raise HTTPException(status_code=404, detail="world_not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(status_code=403, detail="forbidden")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(world, field, value)
await db.commit()
await db.refresh(world)
return world
@router.delete("/{world_id}", status_code=204)
async def delete_world(
world_id: UUID,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalars().first()
if not world:
raise HTTPException(status_code=404, detail="world_not_found")
if world.owner_id != user.id and not user.is_admin:
raise HTTPException(status_code=403, detail="forbidden")
await db.delete(world)
await db.commit()
# === World Builder flow ===
@router.post("/builder/start", response_model=WorldBuilderReply)
async def builder_start(
payload: WorldBuilderStart,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
try:
return await start_world_builder(
db=db,
user=user,
world_name=payload.world_name,
language=payload.language,
preset_id=payload.preset_id,
setting_brief=payload.setting_brief,
character_brief=payload.character_brief,
rules_brief=payload.rules_brief,
notes=payload.notes,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"builder_start_failed: {e}")
@router.post("/builder/continue", response_model=WorldBuilderReply)
async def builder_continue(
payload: WorldBuilderMessage,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
try:
return await continue_world_builder(db=db, user=user, session_id=payload.session_id, user_message=payload.message)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"builder_continue_failed: {e}")
@router.post("/builder/commit", response_model=WorldOut)
async def builder_commit(
payload: WorldBuilderCommit,
db: AsyncSession = Depends(get_db_dep),
user: User = Depends(get_current_user),
):
try:
return await commit_world_builder(db=db, user=user, session_id=payload.session_id, name=payload.name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"builder_commit_failed: {e}")

81
backend/app/config.py Normal file
View File

@@ -0,0 +1,81 @@
"""Application configuration loaded from environment + DB-backed admin settings."""
from __future__ import annotations
import os
import secrets
from functools import lru_cache
from typing import List
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
# Database
database_url: str = "postgresql+asyncpg://airpg:airpg_secret@localhost:5432/airpg"
# Redis
redis_url: str = "redis://localhost:6379/0"
# Qdrant
qdrant_url: str = "http://localhost:6333"
# Auth
jwt_secret: str = Field(default_factory=lambda: secrets.token_hex(32))
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Admin setup
# If empty, will be generated at first run and printed to console.
admin_setup_token: str = ""
# CORS
cors_origins: List[str] = Field(default_factory=lambda: ["http://localhost:5173"])
@field_validator("cors_origins", mode="before")
@classmethod
def _split_origins(cls, v):
if isinstance(v, str):
return [o.strip() for o in v.split(",") if o.strip()]
return v
# Logging
log_level: str = "INFO"
# Default LLM (used to seed DB on first run; overridable via admin panel)
default_llm_base_url: str = "http://localhost:1234/v1"
default_llm_api_key: str = "dummy"
default_llm_model: str = "local-model"
# Default embeddings / RAG settings (overridable via admin panel)
# provider="hash" is a deterministic offline fallback (no semantic quality).
# Switch to "openai" and point embedding.base_url at an OpenAI-compatible /embeddings endpoint
# for real semantic search.
default_embedding_provider: str = "hash"
default_embedding_base_url: str = "" # empty = reuse llm.base_url
default_embedding_api_key: str = "" # empty = reuse llm.api_key
default_embedding_model: str = "text-embedding-3-small"
default_embedding_dim: int = 0 # 0 = auto-probe from endpoint
default_embedding_request_timeout: int = 60
# Context manager defaults (admin-overridable)
default_recent_messages: int = 10
default_compress_threshold: int = 20
default_summary_messages: int = 10
# Worker mode flag
worker_mode: bool = False
@property
def is_worker(self) -> bool:
return bool(os.getenv("WORKER_MODE")) or self.worker_mode
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

243
backend/app/core/llm.py Normal file
View File

@@ -0,0 +1,243 @@
"""OpenAI-compatible LLM client with tool calling, streaming, and logging."""
from __future__ import annotations
import json
import time
import uuid
from typing import Any, AsyncIterator, Dict, List, Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.settings_service import get_all_settings, cast_setting
from app.logging_setup import get_logger
from app.models import LlmCallLog
log = get_logger("llm")
class LlmResponse:
"""Non-streaming response wrapper."""
def __init__(self, text: str, tool_calls: List[Dict[str, Any]], usage: Optional[Dict[str, int]]):
self.text = text
self.tool_calls = tool_calls
self.usage = usage or {}
class LlmClient:
"""Lightweight OpenAI-compatible chat-completions client."""
def __init__(self, settings_map: Dict[str, Any]):
self.base_url: str = str(settings_map.get("llm.base_url", "")).rstrip("/")
self.api_key: str = str(settings_map.get("llm.api_key", "dummy"))
self.model: str = str(settings_map.get("llm.model", "local-model"))
self.temperature: float = float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7)))
self.max_tokens: int = int(cast_setting("llm.max_tokens", settings_map.get("llm.max_tokens", 1024)))
self.timeout: int = int(cast_setting("llm.request_timeout", settings_map.get("llm.request_timeout", 120)))
self.streaming: bool = bool(cast_setting("llm.streaming", settings_map.get("llm.streaming", True)))
@classmethod
async def from_db(cls, db: AsyncSession) -> "LlmClient":
s = await get_all_settings(db)
return cls(s)
def _headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "dummy":
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
purpose: str = "orchestrator",
user_id: Optional[uuid.UUID] = None,
session_id: Optional[uuid.UUID] = None,
db: Optional[AsyncSession] = None,
) -> LlmResponse:
"""Non-streaming chat completion with tool support."""
url = f"{self.base_url}/chat/completions"
payload: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature if temperature is not None else self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": False,
}
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
started = time.monotonic()
err: Optional[str] = None
text = ""
tool_calls: List[Dict[str, Any]] = []
usage: Dict[str, int] = {}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=self._headers())
resp.raise_for_status()
data = resp.json()
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message", {})
text = msg.get("content") or ""
tool_calls = msg.get("tool_calls") or []
usage = data.get("usage") or {}
except Exception as e:
err = f"{type(e).__name__}: {e}"
log.error("llm_call_failed", purpose=purpose, error=err)
raise
finally:
latency_ms = int((time.monotonic() - started) * 1000)
if db is not None:
db.add(LlmCallLog(
user_id=user_id,
session_id=session_id,
purpose=purpose,
model=self.model,
base_url=self.base_url,
prompt_messages=messages,
tools=tools,
response_text=text,
tool_calls=tool_calls,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
total_tokens=usage.get("total_tokens"),
latency_ms=latency_ms,
error=err,
))
try:
await db.commit()
except Exception:
await db.rollback()
return LlmResponse(text=text, tool_calls=tool_calls, usage=usage)
async def stream_chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
purpose: str = "orchestrator",
user_id: Optional[uuid.UUID] = None,
session_id: Optional[uuid.UUID] = None,
db: Optional[AsyncSession] = None,
) -> AsyncIterator[Dict[str, Any]]:
"""Streaming chat completion. Yields incremental deltas.
Yields dicts of the form:
{"type": "delta", "content": "..."} - text delta
{"type": "tool_calls", "tool_calls": [...]} - final tool calls (if any)
{"type": "done", "usage": {...}}
{"type": "error", "error": "..."}
"""
url = f"{self.base_url}/chat/completions"
payload: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature if temperature is not None else self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
started = time.monotonic()
full_text_parts: List[str] = []
tool_call_accum: Dict[int, Dict[str, Any]] = {}
usage: Dict[str, int] = {}
err: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with client.stream("POST", url, json=payload, headers=self._headers()) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
if chunk.get("usage"):
usage = chunk["usage"]
continue
delta = choices[0].get("delta", {})
if delta.get("content"):
full_text_parts.append(delta["content"])
yield {"type": "delta", "content": delta["content"]}
if delta.get("tool_calls"):
for tc in delta["tool_calls"]:
idx = tc.get("index", 0)
acc = tool_call_accum.setdefault(idx, {
"id": tc.get("id", ""),
"type": "function",
"function": {"name": "", "arguments": ""},
})
if tc.get("id"):
acc["id"] = tc["id"]
if tc.get("function", {}).get("name"):
acc["function"]["name"] += tc["function"]["name"]
if tc.get("function", {}).get("arguments"):
acc["function"]["arguments"] += tc["function"]["arguments"]
if chunk.get("usage"):
usage = chunk["usage"]
except Exception as e:
err = f"{type(e).__name__}: {e}"
log.error("llm_stream_failed", purpose=purpose, error=err)
yield {"type": "error", "error": err}
return
full_text = "".join(full_text_parts)
final_tool_calls = [tool_call_accum[i] for i in sorted(tool_call_accum.keys())]
if final_tool_calls:
yield {"type": "tool_calls", "tool_calls": final_tool_calls}
yield {"type": "done", "usage": usage, "full_text": full_text}
latency_ms = int((time.monotonic() - started) * 1000)
if db is not None:
db.add(LlmCallLog(
user_id=user_id,
session_id=session_id,
purpose=purpose,
model=self.model,
base_url=self.base_url,
prompt_messages=messages,
tools=tools,
response_text=full_text,
tool_calls=final_tool_calls,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
total_tokens=usage.get("total_tokens"),
latency_ms=latency_ms,
error=err,
))
try:
await db.commit()
except Exception:
await db.rollback()
def build_tool_schema(name: str, description: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""Helper to build an OpenAI-style tool schema."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": params,
},
}

463
backend/app/core/rag.py Normal file
View File

@@ -0,0 +1,463 @@
"""Qdrant RAG client: glossary / facts / history indexing and retrieval.
Embeddings are configurable via admin settings (see `embedding.*` keys):
* `embedding.provider = "hash"` — deterministic offline fallback (no semantic quality).
* `embedding.provider = "openai"` — calls the OpenAI-compatible `/embeddings`
endpoint of `embedding.base_url` (falls back to `llm.base_url` if empty).
Vector dimension (`embedding.dim`) is normally auto-probed from the endpoint on
first use (set it to 0). When the configured dimension changes, the Qdrant
collections are dropped and recreated — already-indexed points are lost, but
they will be repopulated on the next RAG upsert from the engine.
"""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
import httpx
from qdrant_client import AsyncQdrantClient
from qdrant_client.http import models as qm
from app.config import settings
from app.core.settings_service import cast_setting
from app.logging_setup import get_logger
log = get_logger("rag")
COLLECTION_GLOSSARY = "glossary"
COLLECTION_HISTORY = "history"
ALL_COLLECTIONS = (COLLECTION_GLOSSARY, COLLECTION_HISTORY)
# Fallback dimension for the hash embedder (kept stable across restarts).
HASH_EMBED_DIM = 384
# ---------------------------------------------------------------------------
# Embedders
# ---------------------------------------------------------------------------
class _HashEmbedder:
"""Deterministic lightweight embedder used as an offline fallback.
Not semantically rich, but provides stable vectors for retrieval by keyword
overlap (bag-of-tokens hashed into a fixed-dim vector, L2-normalized).
"""
def __init__(self, dim: int = HASH_EMBED_DIM):
self.dim = dim
async def embed(self, text: str) -> List[float]:
vec = [0.0] * self.dim
tokens = [t for t in text.lower().split() if t]
if not tokens:
return vec
for tok in tokens:
h = abs(hash(tok)) % self.dim
vec[h] += 1.0
h2 = abs(hash(tok + "_b")) % self.dim
vec[h2] += 0.5
norm = sum(v * v for v in vec) ** 0.5
if norm > 0:
vec = [v / norm for v in vec]
return vec
async def probe_dim(self) -> int:
return self.dim
class OpenAIEmbedder:
"""Real embeddings via OpenAI-compatible `/embeddings` endpoint.
Falls back to `_HashEmbedder` per-call if the endpoint is unreachable or
returns an error — so RAG keeps working even if the embeddings server is
temporarily down.
"""
def __init__(
self,
base_url: str,
api_key: str,
model: str,
timeout: int = 60,
fallback_dim: int = HASH_EMBED_DIM,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model or "text-embedding-3-small"
self.timeout = timeout
self._fallback = _HashEmbedder(fallback_dim)
def _headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "dummy":
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def _raw_embed(self, text: str) -> Optional[List[float]]:
url = f"{self.base_url}/embeddings"
payload = {"model": self.model, "input": text}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=self._headers())
resp.raise_for_status()
data = resp.json()
arr = (data.get("data") or [{}])[0].get("embedding") or []
if not arr:
return None
return [float(x) for x in arr]
except Exception as e:
log.warning("openai_embed_failed", model=self.model, error=f"{type(e).__name__}: {e}")
return None
async def embed(self, text: str) -> List[float]:
vec = await self._raw_embed(text)
if vec:
return vec
# Network/endpoint failure — degrade gracefully to hash fallback
return await self._fallback.embed(text)
async def probe_dim(self) -> int:
"""Probe the endpoint with a short text and return the vector dimension.
Returns HASH_EMBED_DIM if the endpoint is unreachable so the system
keeps working (with degraded retrieval quality).
"""
vec = await self._raw_embed("dimension probe")
if vec:
return len(vec)
log.warning("embed_probe_failed_using_hash_dim", dim=HASH_EMBED_DIM)
return HASH_EMBED_DIM
# ---------------------------------------------------------------------------
# RAG client
# ---------------------------------------------------------------------------
class RagClient:
"""Qdrant-backed RAG client with configurable embeddings."""
def __init__(self, url: str | None = None):
url = url or settings.qdrant_url
self.client = AsyncQdrantClient(url=url)
# Cache of {collection_name: configured_dim}. Populated by ensure_collections.
self._collection_dims: Dict[str, int] = {}
# Lazily constructed embedder + its config signature (so we rebuild on settings change).
self._embedder: Optional[Any] = None
self._embedder_sig: Optional[str] = None
self._configured_dim: Optional[int] = None # resolved dim (after probe)
@staticmethod
def _resolve_embedder_config(settings_map: Dict[str, Any]) -> Dict[str, Any]:
provider = str(settings_map.get("embedding.provider", "hash")).lower().strip() or "hash"
base_url = str(settings_map.get("embedding.base_url", "") or "").strip()
if not base_url:
base_url = str(settings_map.get("llm.base_url", "") or "").strip()
api_key = str(settings_map.get("embedding.api_key", "") or "").strip()
if not api_key:
api_key = str(settings_map.get("llm.api_key", "") or "").strip()
model = str(settings_map.get("embedding.model", "text-embedding-3-small") or "text-embedding-3-small")
dim = int(cast_setting("embedding.dim", settings_map.get("embedding.dim", 0)) or 0)
timeout = int(cast_setting("embedding.request_timeout", settings_map.get("embedding.request_timeout", 60)) or 60)
return {
"provider": provider,
"base_url": base_url,
"api_key": api_key,
"model": model,
"dim": dim,
"timeout": timeout,
}
@staticmethod
def _build_embedder(cfg: Dict[str, Any]) -> Any:
if cfg["provider"] == "openai" and cfg["base_url"]:
return OpenAIEmbedder(
base_url=cfg["base_url"],
api_key=cfg["api_key"],
model=cfg["model"],
timeout=cfg["timeout"],
fallback_dim=HASH_EMBED_DIM,
)
return _HashEmbedder(HASH_EMBED_DIM)
def _embedder_signature(self, cfg: Dict[str, Any]) -> str:
# Only fields that affect the produced vector — `dim` is resolved via probe.
return f"{cfg['provider']}|{cfg['base_url']}|{cfg['model']}"
async def get_embedder(self, settings_map: Optional[Dict[str, Any]] = None) -> Any:
"""Return the current embedder, rebuilding it if settings changed.
If `settings_map` is provided and the provider/base_url/model changed,
the embedder is rebuilt and Qdrant collections are reconfigured.
"""
if settings_map is None:
# Caller has no DB context — return whatever is cached.
if self._embedder is None:
self._embedder = _HashEmbedder(HASH_EMBED_DIM)
self._embedder_sig = "hash||"
return self._embedder
cfg = RagClient._resolve_embedder_config(settings_map)
sig = self._embedder_signature(cfg)
if self._embedder is None or sig != self._embedder_sig:
self._embedder = RagClient._build_embedder(cfg)
self._embedder_sig = sig
self._configured_dim = None # force re-probe on next ensure_collections
await self.ensure_collections(settings_map)
return self._embedder
async def _resolve_dim(self, embedder: Any, cfg: Dict[str, Any]) -> int:
if cfg["dim"] and cfg["dim"] > 0:
return cfg["dim"]
if self._configured_dim is not None:
return self._configured_dim
# Auto-probe from the endpoint (or fallback to HASH_EMBED_DIM).
dim = await embedder.probe_dim()
self._configured_dim = dim
log.info("rag_dim_probed", dim=dim, provider=cfg["provider"])
return dim
async def ensure_collections(self, settings_map: Optional[Dict[str, Any]] = None) -> None:
"""Create Qdrant collections if missing; recreate if dim changed.
Recreating drops all points — they will be repopulated by subsequent
upserts from the engine (glossary tool, history indexing).
"""
cfg = RagClient._resolve_embedder_config(settings_map or {})
embedder = await self.get_embedder(settings_map)
desired_dim = await self._resolve_dim(embedder, cfg)
for name in ALL_COLLECTIONS:
existing_dim = await self._get_collection_dim(name)
if existing_dim is None:
try:
await self.client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
)
self._collection_dims[name] = desired_dim
log.info("rag_collection_created", name=name, dim=desired_dim)
except Exception as e:
log.warning("rag_collection_create_failed", name=name, error=str(e))
elif existing_dim != desired_dim:
log.warning(
"rag_collection_dim_mismatch_recreate",
name=name,
old=existing_dim,
new=desired_dim,
)
try:
await self.client.delete_collection(collection_name=name)
except Exception:
pass
try:
await self.client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
)
self._collection_dims[name] = desired_dim
except Exception as e:
log.warning("rag_collection_recreate_failed", name=name, error=str(e))
else:
self._collection_dims[name] = existing_dim
async def _get_collection_dim(self, name: str) -> Optional[int]:
try:
info = await self.client.get_collection(collection_name=name)
cfg = info.config.params.vectors
# Qdrant returns either a single VectorParams or a NamedVectors dict
if isinstance(cfg, qm.VectorParams):
return cfg.size
# NamedVectors: take first vector config
if hasattr(cfg, "size") and isinstance(cfg.size, int):
return cfg.size
if isinstance(cfg, dict):
for v in cfg.values():
if hasattr(v, "size") and isinstance(v.size, int):
return v.size
except Exception:
return None
return None
async def embed(self, text: str, settings_map: Optional[Dict[str, Any]] = None) -> List[float]:
embedder = await self.get_embedder(settings_map)
return await embedder.embed(text)
async def upsert_glossary(
self,
world_id: uuid.UUID,
entry_id: uuid.UUID,
kind: str,
name: str,
description: str,
payload: Dict[str, Any],
settings_map: Optional[Dict[str, Any]] = None,
) -> None:
text = f"{kind}: {name}. {description}"
vector = await self.embed(text, settings_map)
await self.client.upsert(
collection_name=COLLECTION_GLOSSARY,
points=[
qm.PointStruct(
id=str(entry_id),
vector=vector,
payload={
"world_id": str(world_id),
"entry_id": str(entry_id),
"kind": kind,
"name": name,
"description": description,
"text": text,
**payload,
},
)
],
)
async def upsert_history(
self,
session_id: uuid.UUID,
message_id: uuid.UUID,
seq: int,
text: str,
kind: str,
settings_map: Optional[Dict[str, Any]] = None,
) -> None:
vector = await self.embed(text, settings_map)
await self.client.upsert(
collection_name=COLLECTION_HISTORY,
points=[
qm.PointStruct(
id=str(message_id),
vector=vector,
payload={
"session_id": str(session_id),
"message_id": str(message_id),
"seq": seq,
"kind": kind,
"text": text,
},
)
],
)
async def search_glossary(
self,
world_id: uuid.UUID,
query: str,
limit: int = 5,
settings_map: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
try:
vector = await self.embed(query, settings_map)
res = await self.client.search(
collection_name=COLLECTION_GLOSSARY,
query_vector=vector,
query_filter=qm.Filter(
must=[qm.FieldCondition(key="world_id", match=qm.MatchValue(value=str(world_id)))]
),
limit=limit,
with_payload=True,
)
return [r.payload for r in res]
except Exception as e:
log.warning("rag_search_glossary_failed", error=str(e))
return []
async def search_history(
self,
session_id: uuid.UUID,
query: str,
limit: int = 5,
settings_map: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
try:
vector = await self.embed(query, settings_map)
res = await self.client.search(
collection_name=COLLECTION_HISTORY,
query_vector=vector,
query_filter=qm.Filter(
must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))]
),
limit=limit,
with_payload=True,
)
return [r.payload for r in res]
except Exception as e:
log.warning("rag_search_history_failed", error=str(e))
return []
async def delete_history(self, session_id: uuid.UUID) -> None:
try:
await self.client.delete(
collection_name=COLLECTION_HISTORY,
points_selector=qm.FilterSelector(
filter=qm.Filter(must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))])
),
)
except Exception:
pass
# ---------------------------------------------------------------------------
# Singleton + cache invalidation
# ---------------------------------------------------------------------------
_rag: Optional[RagClient] = None
async def get_rag(settings_map: Optional[Dict[str, Any]] = None) -> RagClient:
"""Get the shared RagClient, ensuring collections are configured for the
current embedding settings.
Pass `settings_map` from DB on the first call (or whenever settings may
have changed) so the client can rebuild its embedder and reconfigure
Qdrant collections if `embedding.provider` / `embedding.base_url` /
`embedding.model` / `embedding.dim` changed.
"""
global _rag
if _rag is None:
_rag = RagClient()
await _rag.ensure_collections(settings_map)
elif settings_map is not None:
# Re-check embedder signature; ensure_collections runs only if changed.
await _rag.get_embedder(settings_map)
return _rag
async def reset_rag() -> None:
"""Drop the cached RAG client so the next `get_rag()` rebuilds it from
current settings. Call this after admin updates embedding.* settings.
"""
global _rag
_rag = None
async def probe_embeddings(settings_map: Dict[str, Any]) -> Dict[str, Any]:
"""Standalone probe used by the admin "Test embeddings" button.
Returns dict with: ok, provider, base_url, model, dim, sample_norm, error.
Does not touch the shared singleton or Qdrant.
"""
cfg = RagClient._resolve_embedder_config(settings_map)
embedder = RagClient._build_embedder(cfg)
try:
vec = await embedder.embed("RAG embedding probe: a brave adventurer enters a tavern.")
if not vec:
return {"ok": False, "provider": cfg["provider"], "error": "empty_vector"}
norm = sum(v * v for v in vec) ** 0.5
return {
"ok": True,
"provider": cfg["provider"],
"base_url": cfg["base_url"],
"model": cfg["model"],
"dim": len(vec),
"sample_norm": round(norm, 4),
}
except Exception as e:
return {
"ok": False,
"provider": cfg["provider"],
"base_url": cfg["base_url"],
"model": cfg["model"],
"error": f"{type(e).__name__}: {e}",
}

View File

@@ -0,0 +1,44 @@
"""Security: password hashing + JWT."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return _pwd_ctx.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
try:
return _pwd_ctx.verify(plain, hashed)
except Exception:
return False
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"type": "access",
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any] | None:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except JWTError:
return None

View File

@@ -0,0 +1,92 @@
"""Admin settings service (DB-backed)."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Setting
# Settings that can be edited by admin via the admin panel
EDITABLE_SETTING_KEYS = {
"llm.base_url": str,
"llm.api_key": str,
"llm.model": str,
"llm.temperature": float,
"llm.step_temperature": float,
"llm.summary_temperature": float,
"llm.max_tokens": int,
"llm.request_timeout": int,
"llm.streaming": bool,
"context.recent_messages": int,
"context.compress_threshold": int,
"context.summary_messages": int,
"context.max_tokens_total": int,
"triggers.enabled": bool,
"triggers.check_interval": int,
# Embeddings / RAG
"embedding.provider": str, # "hash" | "openai"
"embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1)
"embedding.api_key": str, # API key (may be empty for local servers)
"embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text
"embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint
"embedding.request_timeout": int, # request timeout, seconds
}
async def get_all_settings(db: AsyncSession) -> Dict[str, Any]:
result = await db.execute(select(Setting))
return {row.key: row.value for row in result.scalars().all()}
async def get_setting(db: AsyncSession, key: str, default: Any = None) -> Any:
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
return row.value if row else default
async def update_settings(db: AsyncSession, updates: Dict[str, Any]) -> Dict[str, Any]:
for key, value in updates.items():
if key not in EDITABLE_SETTING_KEYS:
continue
expected = EDITABLE_SETTING_KEYS[key]
try:
if expected is bool:
value = bool(value)
elif expected is int:
value = int(value)
elif expected is float:
value = float(value)
else:
value = str(value)
except (TypeError, ValueError):
continue
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
if row is None:
db.add(Setting(key=key, value=value))
else:
row.value = value
await db.commit()
return await get_all_settings(db)
def cast_setting(key: str, value: Any) -> Any:
"""Cast raw DB value to the expected type for use."""
if key not in EDITABLE_SETTING_KEYS:
return value
expected = EDITABLE_SETTING_KEYS[key]
try:
if expected is bool:
if isinstance(value, str):
return value.lower() in ("1", "true", "yes", "on")
return bool(value)
if expected is int:
return int(value)
if expected is float:
return float(value)
return str(value)
except (TypeError, ValueError):
return value

View File

@@ -0,0 +1,108 @@
"""World-state JSON schema validator (player/NPC stats, inventory, etc.)."""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from jsonschema import ValidationError, validate
from app.logging_setup import get_logger
log = get_logger("state_validator")
def validate_state(state: Dict[str, Any], schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Validate state against world's JSON Schema. Returns (ok, errors)."""
if not schema:
return True, []
try:
validate(instance=state, schema=schema)
return True, []
except ValidationError as e:
return False, [f"{e.message} at path {list(e.absolute_path)}"]
except Exception as e:
return False, [f"schema_error: {e}"]
def apply_patch(state: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
"""Apply a JSON-patch-like update to state.
Patch format:
{"set": {"path.to.field": value, ...},
"unset": ["path.to.field", ...],
"append": {"path.to.list": value, ...},
"increment": {"path.to.number": delta, ...}}
Paths use dot notation. Creates intermediate dicts as needed.
"""
if not patch:
return state
new_state = _deep_copy(state)
for op, items in patch.items():
if op == "set":
for path, value in items.items():
_set_path(new_state, path, value)
elif op == "unset":
for path in items:
_unset_path(new_state, path)
elif op == "append":
for path, value in items.items():
lst = _get_path(new_state, path) or []
if not isinstance(lst, list):
lst = []
lst.append(value)
_set_path(new_state, path, lst)
elif op == "increment":
for path, delta in items.items():
cur = _get_path(new_state, path) or 0
try:
cur = float(cur)
except (TypeError, ValueError):
cur = 0
_set_path(new_state, path, cur + delta)
elif op == "remove":
for path, value in items.items():
lst = _get_path(new_state, path) or []
if isinstance(lst, list):
lst = [x for x in lst if x != value]
_set_path(new_state, path, lst)
return new_state
def _deep_copy(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _deep_copy(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_deep_copy(v) for v in obj]
return obj
def _get_path(obj: Any, path: str) -> Any:
cur = obj
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None
return cur
def _set_path(obj: Dict[str, Any], path: str, value: Any) -> None:
cur = obj
parts = path.split(".")
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def _unset_path(obj: Dict[str, Any], path: str) -> None:
cur = obj
parts = path.split(".")
for part in parts[:-1]:
if not isinstance(cur, dict) or part not in cur:
return
cur = cur[part]
if isinstance(cur, dict):
cur.pop(parts[-1], None)

49
backend/app/db.py Normal file
View File

@@ -0,0 +1,49 @@
"""Database engine + session factory."""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
pass
_engine_kwargs: dict = dict(
echo=False,
pool_pre_ping=True,
)
# Pool size params only for Postgres/MySQL (not SQLite)
if "sqlite" not in settings.database_url:
_engine_kwargs.update(pool_size=10, max_overflow=20)
engine = create_async_engine(settings.database_url, **_engine_kwargs)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
)
@asynccontextmanager
async def get_db() -> AsyncIterator[AsyncSession]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_db_dep() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

40
backend/app/deps.py Normal file
View File

@@ -0,0 +1,40 @@
"""FastAPI dependencies: DB, current user, admin-only."""
from __future__ import annotations
from typing import AsyncIterator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_access_token
from app.db import get_db_dep
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user(
token: str | None = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db_dep),
) -> User:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_token")
payload = decode_access_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user_not_found")
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_required")
return user

View File

View File

@@ -0,0 +1,230 @@
"""Context manager: builds the LLM prompt context with guaranteed-recent + dynamic summarization."""
from __future__ import annotations
import json
import uuid
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.logging_setup import get_logger
from app.models import Message, World
from app.prompts.templates import get_prompt
log = get_logger("context")
async def build_orchestrator_messages(
db: AsyncSession,
world: World,
session_id: uuid.UUID,
action_text: str,
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Build the messages list for the orchestrator LLM call.
Returns (messages, settings_used).
"""
settings_map = await get_all_settings(db)
recent_n = int(cast_setting("context.recent_messages", settings_map.get("context.recent_messages", 10)))
threshold = int(cast_setting("context.compress_threshold", settings_map.get("context.compress_threshold", 20)))
summary_n = int(cast_setting("context.summary_messages", settings_map.get("context.summary_messages", 10)))
# Load all messages ordered by seq
result = await db.execute(
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
)
all_msgs: List[Message] = list(result.scalars().all())
# Check if we need to compress
if len(all_msgs) >= threshold:
await _maybe_compress(db, session_id, all_msgs, summary_n, recent_n, world, settings_map)
# Reload after compression
result = await db.execute(
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
)
all_msgs = list(result.scalars().all())
# Get summary message (the latest summary before the recent window)
summary_text = ""
visible_msgs = [m for m in all_msgs if not m.hidden]
if len(visible_msgs) > recent_n:
# Look for the latest summary
summaries = [m for m in all_msgs if m.kind == "summary"]
if summaries:
summary_text = summaries[-1].content
recent = visible_msgs[-recent_n:] if visible_msgs else []
# Build orchestrator system prompt with current state
defn = world.definition or {}
system_prompt_template = get_prompt("orchestrator", world.language)
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = system_prompt_template.format(
world_name=world.name,
setting_description=defn.get("setting_description", "")[:800],
rules=json.dumps(defn.get("rules", {}), ensure_ascii=False)[:600],
current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:600],
plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400],
summary=summary_text or "(нет сводки)",
)
messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add summary as a system note if present
if summary_text:
messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"})
# Add recent visible messages
for m in recent:
if m.kind == "player_action":
messages.append({"role": "user", "content": m.content})
elif m.kind == "narrative_step":
messages.append({"role": "assistant", "content": m.content})
# The current action
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
return messages, settings_map
async def _maybe_compress(
db: AsyncSession,
session_id: uuid.UUID,
all_msgs: List[Message],
summary_n: int,
recent_n: int,
world: World,
settings_map: Dict[str, Any],
) -> None:
"""If history exceeds threshold, summarize older messages into a single summary message."""
visible = [m for m in all_msgs if not m.hidden]
if len(visible) <= recent_n + summary_n:
return
# Take the messages that will be summarized (everything before the recent window)
to_summarize = visible[:-recent_n]
if not to_summarize:
return
# Build summarization input
summary_input_lines = []
for m in to_summarize:
prefix = {
"player_action": "Игрок",
"narrative_step": "Сцена",
"summary": "Сводка",
"orchestrator_plan": "GM",
"technical_offscreen": "За кадром",
}.get(m.kind, m.kind)
summary_input_lines.append(f"{prefix}: {m.content[:300]}")
summary_input = "\n\n".join(summary_input_lines)
llm = LlmClient(settings_map)
system_prompt = get_prompt("summarizer", world.language)
response = await llm.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": summary_input[:4000]},
],
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
max_tokens=300,
purpose="summary",
session_id=session_id,
db=db,
)
# Parse summary response
summary_text = response.text
facts: List[Dict[str, Any]] = []
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", response.text)
if json_match:
try:
data = json.loads(json_match.group(0))
summary_text = data.get("summary", response.text)
facts = data.get("facts", [])
except json.JSONDecodeError:
pass
# Create summary message
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
summary_msg = Message(
session_id=session_id,
seq=next_seq,
role="system",
kind="summary",
content=summary_text,
payload={"summarized_count": len(to_summarize), "facts": facts},
is_pinned=True,
hidden=False,
)
db.add(summary_msg)
# Hide the summarized messages (but keep them in DB)
for m in to_summarize:
m.hidden = True
# Index facts into RAG glossary
if facts:
from app.core.rag import get_rag
from app.models import GlossaryEntry
rag = await get_rag(settings_map)
for f in facts:
if not isinstance(f, dict):
continue
entry = GlossaryEntry(
world_id=world.id,
session_id=session_id,
kind=f.get("kind", "lore"),
name=f.get("name", "unknown"),
description=f.get("description", ""),
payload={},
)
db.add(entry)
await db.flush()
await rag.upsert_glossary(
world_id=world.id,
entry_id=entry.id,
kind=entry.kind,
name=entry.name,
description=entry.description,
payload={},
settings_map=settings_map,
)
await db.commit()
log.info("context_compressed", session_id=str(session_id), summarized=len(to_summarize))
async def build_step_writer_messages(
db: AsyncSession,
world: World,
session_id: uuid.UUID,
outcome: str,
narrative_prompt: str,
) -> List[Dict[str, Any]]:
"""Build messages for the step writer LLM call."""
defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("step_writer", world.language).format(
setting_description=defn.get("setting_description", "")[:600],
current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
outcome=outcome,
narrative_prompt=narrative_prompt[:600],
)
return [{"role": "system", "content": system_prompt}]
async def build_subagent_messages(
world: World,
task: str,
context: str,
) -> List[Dict[str, Any]]:
"""Build messages for a clean-context sub-agent call."""
system_prompt = get_prompt("subagent", world.language).format(task=task, context=context[:600])
return [{"role": "system", "content": system_prompt}]

View File

@@ -0,0 +1,443 @@
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.engine.context import (
build_orchestrator_messages,
build_step_writer_messages,
build_subagent_messages,
)
from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call
from app.logging_setup import get_logger
from app.models import DeferredTrigger, Message, Session, World
from app.prompts.templates import get_prompt
log = get_logger("orchestrator")
async def run_iteration(
db: AsyncSession,
user_id: uuid.UUID,
session_id: uuid.UUID,
action_text: str,
) -> AsyncIterator[Dict[str, Any]]:
"""Run one full iteration: plan -> tools -> step -> technical side-effects.
Yields SSE-ready event dicts:
{"type": "status", "data": {"message": "..."}}
{"type": "plan", "data": {...}} # orchestrator plan with tool calls
{"type": "tool_call", "data": {"name": ..., "args": ..., "result": ...}}
{"type": "narrative_chunk", "data": {"content": "..."}}
{"type": "step_complete", "data": {"message_id": ..., "options": [...], "state": ...}}
{"type": "error", "data": {"message": "..."}}
{"type": "done", "data": {}}
"""
# Load session + world
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalars().first()
if not session:
yield {"type": "error", "data": {"message": "session_not_found"}}
return
result = await db.execute(select(World).where(World.id == session.world_id))
world = result.scalars().first()
if not world:
yield {"type": "error", "data": {"message": "world_not_found"}}
return
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
# Save the player's action as a message
next_seq = await _next_seq(db, session_id)
player_msg = Message(
session_id=session_id,
seq=next_seq,
role="user",
kind="player_action",
content=action_text,
payload={},
is_pinned=True,
hidden=False,
)
db.add(player_msg)
await db.commit()
await db.refresh(player_msg)
yield {"type": "status", "data": {"message": "planning"}}
# Subagent runner
async def _subagent(task: str, context: str) -> str:
sub_messages = await build_subagent_messages(world, task, context)
resp = await llm.chat(
messages=sub_messages,
temperature=0.7,
max_tokens=300,
purpose="subagent",
user_id=user_id,
session_id=session_id,
db=db,
)
return resp.text
ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
# === Phase 1: Orchestrator with tool calls (max 5 iterations) ===
orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text)
# Add a final user instruction forcing JSON output
orchestrator_messages.append({
"role": "user",
"content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.",
})
max_iters = 5
final_assistant_text: Optional[str] = None
final_tool_calls: List[Dict[str, Any]] = []
for i in range(max_iters):
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
response = await llm.chat(
messages=orchestrator_messages,
tools=ALL_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="orchestrator",
user_id=user_id,
session_id=session_id,
db=db,
)
if response.tool_calls:
# Append assistant message with tool_calls
orchestrator_messages.append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls,
})
# Execute each tool call
for tc in response.tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args_str = fn.get("arguments", "{}")
try:
args = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
args = {}
yield {"type": "tool_call", "data": {"name": name, "args": args}}
result_dict = await handle_tool_call(name, args, ctx)
yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
# Append tool result message
orchestrator_messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": name,
"content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800],
})
await db.commit()
continue # Let orchestrator continue with tool results
else:
# No tool calls - this is the final answer
final_assistant_text = response.text
break
if final_assistant_text is None:
# Ran out of iterations - use last text
final_assistant_text = response.text or "{}"
yield {"type": "status", "data": {"message": "writing_scene"}}
# === Parse orchestrator final response ===
parsed = _parse_orchestrator_response(final_assistant_text)
# Apply final state patch (if any)
if parsed.get("state_patch"):
from app.core.state_validator import apply_patch, validate_state
new_state = apply_patch(world.state, parsed["state_patch"])
schema = world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if ok:
world.state = new_state
else:
log.warning("state_patch_invalid", errors=errors)
# Advance time
time_advance = parsed.get("time_advance")
if time_advance and isinstance(time_advance, dict):
new_time = _advance_world_time(world.current_time, time_advance, world)
world.current_time = new_time
# Save orchestrator plan as hidden message
plan_seq = await _next_seq(db, session_id)
plan_msg = Message(
session_id=session_id,
seq=plan_seq,
role="assistant",
kind="orchestrator_plan",
content=final_assistant_text[:2000],
payload={
"assessment": parsed.get("assessment", ""),
"outcome": parsed.get("outcome", ""),
"state_patch": parsed.get("state_patch", {}),
"time_advance": time_advance,
"tool_calls_made": [tc for tc in final_tool_calls],
"scheduled_triggers": ctx.scheduled_triggers,
"rag_added": ctx.rag_added,
},
is_pinned=False,
hidden=True,
)
db.add(plan_msg)
# === Phase 2: Step writer (narrative scene) ===
narrative_prompt_parts = [parsed.get("narrative_prompt", "")]
# Add RAG context if relevant
if parsed.get("outcome"):
try:
from app.core.rag import get_rag
rag = await get_rag(settings_map)
rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map)
if rag_results:
rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results)
narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}")
except Exception as e:
log.warning("rag_lookup_failed", error=str(e))
step_messages = await build_step_writer_messages(
db=db,
world=world,
session_id=session_id,
outcome=parsed.get("outcome", action_text),
narrative_prompt="\n".join(p for p in narrative_prompt_parts if p),
)
step_resp = await llm.chat(
messages=step_messages,
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
max_tokens=800,
purpose="step",
user_id=user_id,
session_id=session_id,
db=db,
)
step_text = step_resp.text
step_options: List[str] = parsed.get("next_options", []) or []
# Try to extract structured step from JSON
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text)
if json_match:
try:
step_data = json.loads(json_match.group(0))
if "narrative" in step_data:
step_text = step_data["narrative"]
if "options" in step_data and isinstance(step_data["options"], list):
step_options = [str(o) for o in step_data["options"]][:5]
except json.JSONDecodeError:
pass
# Save narrative step message
step_seq = await _next_seq(db, session_id)
step_msg = Message(
session_id=session_id,
seq=step_seq,
role="assistant",
kind="narrative_step",
content=step_text,
payload={
"options": step_options,
"outcome": parsed.get("outcome", ""),
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
},
is_pinned=True,
hidden=False,
)
db.add(step_msg)
# === Phase 3: Update plot rails (if any) ===
rails_update = parsed.get("rails_update")
if rails_update and isinstance(rails_update, dict):
defn = dict(world.definition)
rails = dict(defn.get("plot_rails", {}))
if "main_goal" in rails_update:
rails["main_goal"] = rails_update["main_goal"]
if "new_subgoals" in rails_update:
existing = list(rails.get("subgoals", []))
existing.extend(rails_update["new_subgoals"])
rails["subgoals"] = existing
if "completed_subgoals" in rails_update:
completed = set(rails.get("completed_subgoals", []))
completed.update(rails_update["completed_subgoals"])
rails["completed_subgoals"] = list(completed)
# Remove completed from subgoals
rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed]
defn["plot_rails"] = rails
world.definition = defn
# Add RAG facts from orchestrator response
rag_facts = parsed.get("rag_facts", []) or []
if rag_facts:
from app.core.rag import get_rag
from app.models import GlossaryEntry
rag = await get_rag(settings_map)
for f in rag_facts:
if not isinstance(f, dict):
continue
entry = GlossaryEntry(
world_id=world.id,
session_id=session_id,
kind=f.get("kind", "lore"),
name=f.get("name", "unknown"),
description=f.get("description", ""),
payload={},
)
db.add(entry)
await db.flush()
await rag.upsert_glossary(
world_id=world.id,
entry_id=entry.id,
kind=entry.kind,
name=entry.name,
description=entry.description,
payload={},
settings_map=settings_map,
)
# Update session last_played_at
session.last_played_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(step_msg)
# Check for triggers that should fire immediately (fire_at <= current_time)
fired_now = await _check_due_triggers(db, session_id, world.current_time or "")
yield {
"type": "step_complete",
"data": {
"message_id": str(step_msg.id),
"seq": step_msg.seq,
"narrative": step_text,
"options": step_options,
"state": world.state,
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
"fired_triggers": fired_now,
},
}
yield {"type": "done", "data": {}}
def _parse_orchestrator_response(text: str) -> Dict[str, Any]:
"""Extract the JSON object from the orchestrator's final response."""
if not text:
return {}
import re as _re
m = _re.search(r"\{[\s\S]*\}", text)
if not m:
return {"outcome": text, "narrative_prompt": text, "next_options": []}
try:
data = json.loads(m.group(0))
return data
except json.JSONDecodeError:
return {"outcome": text, "narrative_prompt": text, "next_options": []}
async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
result = await db.execute(
select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1)
)
row = result.first()
return (row[0] + 1) if row else 1
def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str:
"""Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime."""
if not current_time:
# Try to use the world_state's world_time field
wt = (world.state or {}).get("world_time", {})
if wt:
day = int(wt.get("day", 1))
hour = int(wt.get("hour", 8))
else:
day, hour = 1, 8
else:
# Parse 'day_N_hour_H' or fall back to numbers
import re as _re
m = _re.match(r"day_(\d+)_hour_(\d+)", current_time)
if m:
day, hour = int(m.group(1)), int(m.group(2))
else:
# Try ISO format
try:
from datetime import datetime as _dt, timedelta as _td
dt = _dt.fromisoformat(current_time)
dt = dt + _td(
days=int(advance.get("days", 0)),
hours=int(advance.get("hours", 0)),
minutes=int(advance.get("minutes", 0)),
)
return dt.isoformat()
except Exception:
day, hour = 1, 8
total_minutes = day * 24 * 60 + hour * 60
total_minutes += int(advance.get("days", 0)) * 24 * 60
total_minutes += int(advance.get("hours", 0)) * 60
total_minutes += int(advance.get("minutes", 0))
new_day = total_minutes // (24 * 60)
new_hour = (total_minutes % (24 * 60)) // 60
new_time = f"day_{new_day}_hour_{new_hour}"
# Also update world_time in state if present
if world.state and "world_time" in world.state:
world.state["world_time"] = {
**world.state["world_time"],
"day": new_day,
"hour": new_hour,
}
return new_time
async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]:
"""Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers."""
import re as _re
def _parse(t: str):
m = _re.match(r"day_(\d+)_hour_(\d+)", t or "")
if m:
return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
try:
from datetime import datetime as _dt
dt = _dt.fromisoformat(t)
return int(dt.timestamp() // 60)
except Exception:
return 0
cur = _parse(current_time)
result = await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.session_id == session_id,
DeferredTrigger.fired.is_(False),
)
)
triggers = list(result.scalars().all())
fired: List[Dict[str, Any]] = []
for t in triggers:
if _parse(t.fire_at) <= cur:
t.fired = True
fired.append({
"id": str(t.id),
"fire_at": t.fire_at,
"description": t.description,
"payload": t.payload,
})
if fired:
await db.commit()
return fired

View File

View File

@@ -0,0 +1,295 @@
"""Tool definitions and handlers for the orchestrator's tool-calling loop."""
from __future__ import annotations
import json
import random
import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import build_tool_schema
from app.core.rag import get_rag
from app.core.state_validator import apply_patch, validate_state
from app.logging_setup import get_logger
from app.models import DeferredTrigger, GlossaryEntry, World
log = get_logger("tools")
# === Tool schemas (OpenAI function-calling format) ===
DICE_ROLL_SCHEMA = build_tool_schema(
name="dice_roll",
description="Roll dice. Use 'sides' (e.g. 20 for d20) and optional 'count' (default 1) and 'modifier'. Returns the rolls and total.",
params={
"type": "object",
"properties": {
"sides": {"type": "integer", "description": "Number of sides on the die, e.g. 20 for d20"},
"count": {"type": "integer", "description": "Number of dice to roll", "default": 1},
"modifier": {"type": "integer", "description": "Modifier to add to total", "default": 0},
"label": {"type": "string", "description": "What this roll represents, e.g. 'attack' or 'perception'"},
},
"required": ["sides"],
},
)
UPDATE_STATE_SCHEMA = build_tool_schema(
name="update_state",
description="Apply a patch to world state. Paths use dot notation. ops: set, unset, append, increment, remove.",
params={
"type": "object",
"properties": {
"patch": {
"type": "object",
"description": "JSON-patch object with optional keys: set, unset, append, increment, remove. Each is a dict of path->value (or list of paths for unset).",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
}
},
"required": ["patch"],
},
)
RAG_QUERY_SCHEMA = build_tool_schema(
name="rag_query",
description="Search the glossary (NPCs, locations, items, lore) for relevant facts.",
params={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Free-text search query"},
"limit": {"type": "integer", "description": "Max results", "default": 5},
},
"required": ["query"],
},
)
RAG_ADD_SCHEMA = build_tool_schema(
name="rag_add",
description="Add a new entry to the glossary (NPC, location, item, lore, event).",
params={
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event", "rule"]},
"name": {"type": "string"},
"description": {"type": "string"},
"payload": {"type": "object", "description": "Optional extra fields"},
},
"required": ["kind", "name", "description"],
},
)
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
name="schedule_trigger",
description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.",
params={
"type": "object",
"properties": {
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
"description": {"type": "string", "description": "What should happen"},
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
},
"required": ["fire_at", "description"],
},
)
ADVANCE_TIME_SCHEMA = build_tool_schema(
name="advance_time",
description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.",
params={
"type": "object",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
"reason": {"type": "string", "description": "Why time advances"},
},
},
)
RUN_SUBAGENT_SCHEMA = build_tool_schema(
name="run_subagent",
description="Spawn a sub-agent with clean context for a focused sub-task (e.g. generate NPC backstory, room description).",
params={
"type": "object",
"properties": {
"task": {"type": "string", "description": "The specific task for the sub-agent"},
"context": {"type": "string", "description": "Minimal context needed (max 200 words)"},
},
"required": ["task"],
},
)
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA,
RAG_QUERY_SCHEMA,
RAG_ADD_SCHEMA,
SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_SCHEMA,
]
# === Tool handlers ===
class ToolContext:
"""Holds everything tools need to execute."""
def __init__(
self,
db: AsyncSession,
world: World,
session_id: uuid.UUID,
user_id: uuid.UUID,
subagent_runner: Optional[Callable[[str, str], Awaitable[str]]] = None,
settings_map: Optional[Dict[str, Any]] = None,
):
self.db = db
self.world = world
self.session_id = session_id
self.user_id = user_id
self.subagent_runner = subagent_runner
self.settings_map = settings_map or {}
# Track time advancement during this iteration
self.time_advance: Dict[str, int] = {"days": 0, "hours": 0, "minutes": 0}
# Track scheduled triggers
self.scheduled_triggers: List[Dict[str, Any]] = []
# Track rag facts added
self.rag_added: List[Dict[str, Any]] = []
async def handle_tool_call(name: str, args: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
if name == "dice_roll":
sides = int(args.get("sides", 20))
count = int(args.get("count", 1))
modifier = int(args.get("modifier", 0))
label = args.get("label", "")
rolls = [random.randint(1, sides) for _ in range(max(1, count))]
total = sum(rolls) + modifier
return {"rolls": rolls, "modifier": modifier, "total": total, "label": label}
if name == "update_state":
patch = args.get("patch", {})
new_state = apply_patch(ctx.world.state, patch)
schema = ctx.world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if not ok:
return {"ok": False, "errors": errors, "state_unchanged": True}
ctx.world.state = new_state
return {"ok": True, "new_state_summary": _summarize_state(new_state)}
if name == "rag_query":
query = args.get("query", "")
limit = int(args.get("limit", 5))
rag = await get_rag(ctx.settings_map)
results = await rag.search_glossary(ctx.world.id, query, limit=limit, settings_map=ctx.settings_map)
return {"results": results}
if name == "rag_add":
kind = args.get("kind", "lore")
entry_name = args.get("name", "")
desc = args.get("description", "")
extra = args.get("payload", {}) or {}
entry = GlossaryEntry(
world_id=ctx.world.id,
session_id=ctx.session_id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
)
ctx.db.add(entry)
await ctx.db.flush()
rag = await get_rag(ctx.settings_map)
await rag.upsert_glossary(
world_id=ctx.world.id,
entry_id=entry.id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
settings_map=ctx.settings_map,
)
ctx.rag_added.append({"kind": kind, "name": entry_name, "description": desc})
return {"ok": True, "entry_id": str(entry.id)}
if name == "schedule_trigger":
fire_at = args.get("fire_at", "")
description = args.get("description", "")
payload = args.get("payload", {}) or {}
trigger = DeferredTrigger(
session_id=ctx.session_id,
fire_at=fire_at,
description=description,
payload=payload,
)
ctx.db.add(trigger)
await ctx.db.flush()
ctx.scheduled_triggers.append({
"id": str(trigger.id),
"fire_at": fire_at,
"description": description,
})
return {"ok": True, "trigger_id": str(trigger.id)}
if name == "advance_time":
days = int(args.get("days", 0))
hours = int(args.get("hours", 0))
minutes = int(args.get("minutes", 0))
ctx.time_advance["days"] += days
ctx.time_advance["hours"] += hours
ctx.time_advance["minutes"] += minutes
return {
"ok": True,
"advance": {"days": days, "hours": hours, "minutes": minutes},
"reason": args.get("reason", ""),
}
if name == "run_subagent":
if ctx.subagent_runner is None:
return {"error": "subagent_runner_not_available"}
task = args.get("task", "")
context = args.get("context", "")
try:
result = await ctx.subagent_runner(task, context)
return {"result": result}
except Exception as e:
return {"error": str(e)}
return {"error": f"unknown_tool: {name}"}
def _summarize_state(state: Dict[str, Any]) -> str:
"""Quick human-readable summary of state for the LLM."""
if not state:
return "(empty)"
parts: List[str] = []
player = state.get("player", {})
if player:
name = player.get("name", "?")
stats = player.get("stats", {})
location = player.get("location", "?")
hp = stats.get("health", "?")
hp_max = stats.get("health_max", "?")
mp = stats.get("mana", "?")
parts.append(f"player={name} hp={hp}/{hp_max} mp={mp} loc={location}")
inv = player.get("inventory", []) if isinstance(player, dict) else []
if inv:
parts.append("inv=" + ", ".join(f"{i.get('name','?')}x{i.get('qty',1)}" for i in inv[:8]))
npcs = state.get("npcs", [])
if npcs:
parts.append(f"npcs={len(npcs)}")
return " | ".join(parts)

View File

@@ -0,0 +1,267 @@
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
from __future__ import annotations
import json
import uuid
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.logging_setup import get_logger
from app.models import Preset, User, World
from app.prompts.templates import get_prompt
from app.schemas import WorldBuilderReply, WorldDefinition
log = get_logger("world_builder")
# In-memory store of world-builder dialogues (session_id -> dialogue state).
# For production scale, move this to Redis. For MVP single-instance it's fine.
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
async def start_world_builder(
db: AsyncSession,
user: User,
world_name: str,
language: str,
preset_id: Optional[uuid.UUID],
setting_brief: str,
character_brief: str,
rules_brief: str,
notes: str,
) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4()
llm = await LlmClient.from_db(db)
preset_payload: Optional[Dict[str, Any]] = None
if preset_id:
result = await db.execute(select(Preset).where(Preset.id == preset_id))
preset = result.scalars().first()
if preset:
preset_payload = preset.payload
user_brief = _build_user_brief(
world_name=world_name,
setting_brief=setting_brief,
character_brief=character_brief,
rules_brief=rules_brief,
notes=notes,
preset_payload=preset_payload,
language=language,
)
system_prompt = get_prompt("world_builder", language)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
]
response = await llm.chat(
messages=messages,
temperature=0.7,
purpose="world_builder",
user_id=user.id,
db=db,
)
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
"messages": messages + [{"role": "assistant", "content": response.text}],
"turn": 1,
"last_proposed": proposed.model_dump() if proposed else None,
}
return WorldBuilderReply(
session_id=session_id,
turn=1,
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
)
async def continue_world_builder(
db: AsyncSession,
user: User,
session_id: uuid.UUID,
user_message: str,
) -> WorldBuilderReply:
"""Continue an existing world-builder dialogue."""
dialogue = _DIALOGUES.get(session_id)
if not dialogue:
raise ValueError("dialogue_not_found")
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
llm = await LlmClient.from_db(db)
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
temperature=0.7,
purpose="world_builder",
user_id=user.id,
db=db,
)
dialogue["messages"].append({"role": "assistant", "content": response.text})
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
if proposed:
dialogue["last_proposed"] = proposed.model_dump()
return WorldBuilderReply(
session_id=session_id,
turn=dialogue["turn"],
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
)
async def commit_world_builder(
db: AsyncSession,
user: User,
session_id: uuid.UUID,
name: Optional[str] = None,
) -> World:
"""Commit the proposed world definition into a real World row."""
dialogue = _DIALOGUES.get(session_id)
if not dialogue:
raise ValueError("dialogue_not_found")
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
proposed = dialogue.get("last_proposed")
if not proposed:
raise ValueError("no_proposed_definition")
definition = WorldDefinition.model_validate(proposed)
world = World(
owner_id=user.id,
name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"),
definition=definition.model_dump(),
state=definition.initial_state or {},
current_time=definition.initial_time,
status="ready",
preset_id=dialogue.get("preset_id"),
)
db.add(world)
await db.commit()
await db.refresh(world)
# Clean up dialogue
_DIALOGUES.pop(session_id, None)
return world
def _build_user_brief(
world_name: str,
setting_brief: str,
character_brief: str,
rules_brief: str,
notes: str,
preset_payload: Optional[Dict[str, Any]],
language: str,
) -> str:
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
parts.append(f"Name: {world_name}")
if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
parts.append(f"Suggested rules: {json.dumps(preset_payload.get('rules', {}), ensure_ascii=False)[:400]}")
if setting_brief:
parts.append(f"Setting: {setting_brief}")
if character_brief:
parts.append(f"Character: {character_brief}")
if rules_brief:
parts.append(f"Rules: {rules_brief}")
if notes:
parts.append(f"Notes: {notes}")
parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.")
return "\n".join(parts)
def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]:
"""Extract AI message text, proposed definition (if any), is_final flag, and followup questions."""
proposed = None
is_final = False
followups: List[str] = []
# Try to find a JSON block in the response
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data:
pd = data["proposed_definition"]
if isinstance(pd, dict):
try:
proposed = WorldDefinition.model_validate(pd)
except Exception:
proposed = None
if "is_final" in data:
is_final = bool(data["is_final"])
if "followup_questions" in data and isinstance(data["followup_questions"], list):
followups = [str(q) for q in data["followup_questions"]]
if "ai_message" in data and isinstance(data["ai_message"], str):
text = data["ai_message"]
except json.JSONDecodeError:
pass
# Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final
if proposed is not None:
low = text.lower()
if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]):
is_final = True
return text, proposed, is_final, followups
def _extract_json_block(text: str) -> Optional[str]:
"""Find the first JSON object/array block in text."""
if not text:
return None
# Try fenced ```json ... ```
import re
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m:
return m.group(1)
# Try raw {...} (greedy from first { to matching })
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None

View File

@@ -0,0 +1,38 @@
"""Structured logging setup."""
from __future__ import annotations
import logging
import sys
import structlog
from app.config import settings
def setup_logging() -> None:
level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=level,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(level),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None):
return structlog.get_logger(name)

76
backend/app/main.py Normal file
View File

@@ -0,0 +1,76 @@
"""FastAPI application entrypoint."""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import admin, auth, misc, presets, sessions, worlds
from app.config import settings
from app.logging_setup import get_logger, setup_logging
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_logging()
log = get_logger("app")
log.info("app_starting", worker_mode=settings.is_worker)
# Initialize DB tables and seed defaults
from app.migrations.init_db import init_db
try:
await init_db()
except Exception as e:
log.error("init_db_failed", error=str(e))
# Initialize RAG collections (using current DB-backed embedding settings)
try:
from app.core.rag import get_rag
from app.core.settings_service import get_all_settings
from app.db import AsyncSessionLocal
async with AsyncSessionLocal() as session:
settings_map = await get_all_settings(session)
await get_rag(settings_map)
except Exception as e:
log.warning("rag_init_failed", error=str(e))
yield
log.info("app_stopping")
app = FastAPI(
title="AI RPG Backend",
version="0.1.0",
description="Flexible AI-powered role-playing game backend.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/")
async def root():
return {"app": "ai-rpg", "version": "0.1.0"}
# Routers
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(presets.router)
app.include_router(worlds.router)
app.include_router(sessions.router)
app.include_router(misc.router)

View File

View File

@@ -0,0 +1,103 @@
"""Database initialization: create all tables and seed defaults."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from sqlalchemy import select, text
from app.db import AsyncSessionLocal, Base, engine
from app.models import GlossaryEntry, Preset, Setting, User
from app.config import settings
from app.logging_setup import get_logger, setup_logging
from app.core.security import hash_password
from app.prompts.fantasy_preset import FANTASY_PRESET_RU, FANTASY_PRESET_EN
log = get_logger("migrations")
DEFAULT_SETTINGS = [
("llm.base_url", settings.default_llm_base_url, "OpenAI-compatible base URL"),
("llm.api_key", settings.default_llm_api_key, "API key for LLM endpoint"),
("llm.model", settings.default_llm_model, "Default model name"),
("llm.temperature", 0.7, "Temperature for orchestrator"),
("llm.step_temperature", 0.85, "Temperature for narrative step writer"),
("llm.summary_temperature", 0.3, "Temperature for summarizer"),
("llm.max_tokens", 1024, "Max tokens per LLM response"),
("llm.request_timeout", 120, "LLM request timeout, seconds"),
("llm.streaming", True, "Whether to use streaming responses"),
("context.recent_messages", settings.default_recent_messages, "Guaranteed recent messages in prompt"),
("context.compress_threshold", settings.default_compress_threshold, "Trigger compression at this count"),
("context.summary_messages", settings.default_summary_messages, "Number of messages per summary block"),
("context.max_tokens_total", 6000, "Soft token budget for context window (small models)"),
("triggers.enabled", True, "Enable deferred trigger processing"),
("triggers.check_interval", 30, "Trigger checker interval, seconds"),
# Embeddings / RAG
("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"),
("embedding.base_url", settings.default_embedding_base_url, "OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url"),
("embedding.api_key", settings.default_embedding_api_key, "API key for embeddings endpoint. Empty = reuse llm.api_key"),
("embedding.model", settings.default_embedding_model, "Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text)"),
("embedding.dim", settings.default_embedding_dim, "Vector dimension. 0 = auto-probe from endpoint on first use"),
("embedding.request_timeout", settings.default_embedding_request_timeout, "Embeddings request timeout, seconds"),
]
async def init_db() -> None:
setup_logging()
log.info("creating_tables")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
log.info("tables_ready")
async with AsyncSessionLocal() as session:
# Seed settings
result = await session.execute(select(Setting).limit(1))
if result.scalars().first() is None:
for key, value, desc in DEFAULT_SETTINGS:
session.add(Setting(key=key, value=value, description=desc))
await session.commit()
log.info("settings_seeded", count=len(DEFAULT_SETTINGS))
else:
log.info("settings_already_exist")
# Seed built-in Fantasy preset
result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
if result.scalars().first() is None:
for preset_def in (FANTASY_PRESET_RU, FANTASY_PRESET_EN):
session.add(Preset(
slug=preset_def["slug"],
title=preset_def["title"],
description=preset_def["description"],
language=preset_def["language"],
is_public=True,
is_builtin=True,
payload=preset_def["payload"],
))
await session.commit()
log.info("builtin_presets_seeded")
else:
log.info("builtin_presets_already_exist")
# Ensure admin_setup_token is set; if empty, generate and print
token = settings.admin_setup_token.strip()
if not token:
import secrets as _s
token = _s.token_urlsafe(24)
async with AsyncSessionLocal() as session:
existing = await session.execute(select(Setting).where(Setting.key == "admin.setup_token"))
existing_obj = existing.scalars().first()
if existing_obj is None:
session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
await session.commit()
print("=" * 60)
print("ADMIN SETUP TOKEN (use at /admin/setup):")
print(token)
print("=" * 60)
log.info("admin_setup_token_generated")
else:
log.info("admin_setup_token_already_set")
if __name__ == "__main__":
asyncio.run(init_db())

View File

@@ -0,0 +1,189 @@
"""SQLAlchemy models for the AI RPG backend."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import uuid
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
JSON,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferred_language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
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")
class Setting(Base):
"""Key/value admin settings. Override defaults (LLM, context manager params)."""
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[Any] = mapped_column(JSONB, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
class Preset(Base):
"""World presets published by admin or users."""
__tablename__ = "presets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# JSON: world_schema, default_rules, initial_state, world_seed_prompt, suggested_system_prompt
payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False)
author_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
class World(Base):
__tablename__ = "worlds"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
# Frozen world definition: setting description, rules, world_schema (JSON Schema for state), plot_rails
definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# Current live state of the world (player character, NPC, inventory, time, etc.)
state: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# Current world time (ISO string)
current_time: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
# Status: draft / ready / active / archived
status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False, index=True)
preset_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("presets.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
owner: Mapped[User] = relationship(back_populates="worlds")
sessions: Mapped[List["Session"]] = relationship(back_populates="world", cascade="all, delete-orphan")
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
world_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("worlds.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), default="Новая сессия", nullable=False)
# Snapshot of world state at session start (we mutate world.state during play; session stores narrative history)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
last_played_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
world: Mapped[World] = relationship(back_populates="sessions")
messages: Mapped[List["Message"]] = relationship(
back_populates="session", cascade="all, delete-orphan", order_by="Message.seq"
)
triggers: Mapped[List["DeferredTrigger"]] = relationship(
back_populates="session", cascade="all, delete-orphan"
)
class Message(Base):
"""Conversation messages: scene steps, player actions, orchestrator thoughts, summaries."""
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=False, index=True)
seq: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# role: system / user / assistant / scene / summary / technical / tool
role: Mapped[str] = mapped_column(String(32), nullable=False)
# kind: narrative_step / player_action / orchestrator_plan / tool_call / summary / technical_offscreen / system_note
kind: Mapped[str] = mapped_column(String(64), default="narrative_step", nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# Structured payload: suggested_options, tool_calls, state_diff, time_diff, etc.
payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# Whether this message is in the "guaranteed recent" context window
is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# True if message is hidden from the chat UI (technical, tool, summary)
hidden: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
session: Mapped[Session] = relationship(back_populates="messages")
class DeferredTrigger(Base):
"""Scheduled events tied to in-world time."""
__tablename__ = "deferred_triggers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=False, index=True)
# ISO datetime in world's internal time
fire_at: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
# Arbitrary payload (what should happen, who, conditions)
payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
fired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
session: Mapped[Session] = relationship(back_populates="triggers")
class LlmCallLog(Base):
"""All LLM calls logged for observability and cost tracking."""
__tablename__ = "llm_call_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=True, index=True)
purpose: Mapped[str] = mapped_column(String(64), nullable=False) # orchestrator / step / summary / world_builder / subagent
model: Mapped[str] = mapped_column(String(255), nullable=False)
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
prompt_messages: Mapped[List[Dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
# tools schema sent
tools: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
# response
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tool_calls: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
prompt_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
completion_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False, index=True)
class GlossaryEntry(Base):
"""Indexed facts for RAG (glossary terms, NPCs, locations, items)."""
__tablename__ = "glossary_entries"
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)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=True, index=True)
# kind: npc / location / item / lore / event / rule
kind: Mapped[str] = mapped_column(String(32), default="lore", nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)

View File

View File

@@ -0,0 +1,225 @@
"""Built-in Fantasy preset (RU + EN)."""
from __future__ import annotations
FANTASY_PRESET_RU = {
"slug": "fantasy-default-ru",
"title": "Фэнтези: Меч и Магия",
"description": "Классический фэнтези-сеттинг с HP/MP, инвентарём, фракциями и заклинаниями.",
"language": "ru",
"payload": {
"world_seed_prompt": (
"Классическое темное фэнтези в духе позднего средневековья. Королевства людей, эльфийские леса, "
"гномьи города под горами, орды орков на восточных рубежах. Магия редкая и опасная, церковь "
"борется с ересями. Герой — начинающий авантюрист, ищущий славы и средств к существованию."
),
"rules": {
"stats": ["health", "mana", "stamina", "gold", "level", "xp"],
"combat": "пошаговые броски d20 + модификатор против сложности",
"magic": "трата маны на заклинания, восстановление во сне",
"death": "при health <= 0 — состояние при смерти, нужно стабилизировать",
"inventory": "слоты = 10 + сила модификатор",
"time": "внутренний календарь: дни, часы. Сон = 8ч, путешествие между локациями 4-12ч.",
},
"world_schema": {
"type": "object",
"properties": {
"player": {
"type": "object",
"properties": {
"name": {"type": "string"},
"race": {"type": "string"},
"class": {"type": "string"},
"level": {"type": "integer", "minimum": 1},
"xp": {"type": "integer", "minimum": 0},
"stats": {
"type": "object",
"properties": {
"health": {"type": "number"},
"health_max": {"type": "number"},
"mana": {"type": "number"},
"mana_max": {"type": "number"},
"stamina": {"type": "number"},
"stamina_max": {"type": "number"},
"strength": {"type": "integer"},
"dexterity": {"type": "integer"},
"constitution": {"type": "integer"},
"intelligence": {"type": "integer"},
"wisdom": {"type": "integer"},
"charisma": {"type": "integer"},
},
"required": ["health", "health_max", "mana", "mana_max"],
},
"inventory": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"qty": {"type": "integer", "minimum": 0},
"type": {"type": "string"},
"notes": {"type": "string"},
},
"required": ["name", "qty"],
},
},
"effects": {"type": "array", "items": {"type": "object"}},
"gold": {"type": "integer", "minimum": 0},
"location": {"type": "string"},
},
"required": ["name", "stats", "inventory"],
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"description": {"type": "string"},
"relation": {"type": "string"},
"stats": {"type": "object"},
"location": {"type": "string"},
},
"required": ["id", "name"],
},
},
"locations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string"},
"danger": {"type": "string"},
},
"required": ["id", "name"],
},
},
"world_time": {
"type": "object",
"properties": {
"day": {"type": "integer"},
"hour": {"type": "integer"},
"season": {"type": "string"},
"weather": {"type": "string"},
},
},
"flags": {"type": "object"},
},
"required": ["player"],
},
"initial_state": {
"player": {
"name": "Герой",
"race": "Человек",
"class": "Авантюрист",
"level": 1,
"xp": 0,
"stats": {
"health": 20, "health_max": 20,
"mana": 10, "mana_max": 10,
"stamina": 15, "stamina_max": 15,
"strength": 10, "dexterity": 10, "constitution": 10,
"intelligence": 10, "wisdom": 10, "charisma": 10,
},
"inventory": [
{"name": "Старый меч", "qty": 1, "type": "weapon", "notes": "1d8 урон"},
{"name": "Кожаная броня", "qty": 1, "type": "armor", "notes": "+1 AC"},
{"name": "Хлеб", "qty": 3, "type": "food", "notes": "восстанавливает 2 стамины"},
{"name": "Факел", "qty": 5, "type": "tool", "notes": "горит 1 час"},
],
"effects": [],
"gold": 10,
"location": "Деревня Старый Дуб",
},
"npcs": [],
"locations": [
{
"id": "village_old_oak",
"name": "Деревня Старый Дуб",
"description": "Маленькая деревня на опушке Тёмного Леса.",
"type": "settlement",
"danger": "safe",
}
],
"world_time": {"day": 1, "hour": 8, "season": "spring", "weather": "clear"},
"flags": {},
},
"initial_time": "day_1_hour_8",
"suggested_system_prompt": (
"Ты — Game Master классического фэнтези. Используй пошаговые правила: броски d20, "
"трата маны на заклинания, учёт усталости. Описывай сцены кинематографично, но коротко. "
"Соблюдай сеттинг средневекового тёмного фэнтези. Не давай игроку несбыточных обещаний."
),
},
}
FANTASY_PRESET_EN = {
"slug": "fantasy-default-en",
"title": "Fantasy: Sword & Sorcery",
"description": "Classic fantasy setting with HP/MP, inventory, factions and spells.",
"language": "en",
"payload": {
"world_seed_prompt": (
"Classic dark fantasy in a late-medieval style. Human kingdoms, elven forests, dwarven cities "
"under the mountains, orc hordes on the eastern marches. Magic is rare and dangerous, the "
"church hunts heretics. The hero is a novice adventurer seeking fame and coin."
),
"rules": {
"stats": ["health", "mana", "stamina", "gold", "level", "xp"],
"combat": "turn-based d20 rolls + modifier vs difficulty",
"magic": "mana cost per spell, recovered by sleep",
"death": "at health <= 0 — dying state, must be stabilized",
"inventory": "slots = 10 + strength modifier",
"time": "internal calendar: days, hours. Sleep = 8h, travel between locations 4-12h.",
},
"world_schema": FANTASY_PRESET_RU["payload"]["world_schema"],
"initial_state": {
"player": {
"name": "Hero",
"race": "Human",
"class": "Adventurer",
"level": 1,
"xp": 0,
"stats": {
"health": 20, "health_max": 20,
"mana": 10, "mana_max": 10,
"stamina": 15, "stamina_max": 15,
"strength": 10, "dexterity": 10, "constitution": 10,
"intelligence": 10, "wisdom": 10, "charisma": 10,
},
"inventory": [
{"name": "Old sword", "qty": 1, "type": "weapon", "notes": "1d8 damage"},
{"name": "Leather armor", "qty": 1, "type": "armor", "notes": "+1 AC"},
{"name": "Bread", "qty": 3, "type": "food", "notes": "restores 2 stamina"},
{"name": "Torch", "qty": 5, "type": "tool", "notes": "burns 1 hour"},
],
"effects": [],
"gold": 10,
"location": "Old Oak Village",
},
"npcs": [],
"locations": [
{
"id": "village_old_oak",
"name": "Old Oak Village",
"description": "A small village on the edge of the Darkwood.",
"type": "settlement",
"danger": "safe",
}
],
"world_time": {"day": 1, "hour": 8, "season": "spring", "weather": "clear"},
"flags": {},
},
"initial_time": "day_1_hour_8",
"suggested_system_prompt": (
"You are the Game Master of a classic fantasy. Use turn-based rules: d20 rolls, mana "
"costs for spells, track fatigue. Describe scenes cinematically but briefly. Stay in "
"the dark-fantasy medieval setting. Don't make the player impossible promises."
),
},
}

View File

@@ -0,0 +1,295 @@
"""System prompts for all LLM stages. Bilingual (RU/EN)."""
from __future__ import annotations
from typing import Dict
# === World Builder ===
WORLD_BUILDER_SYSTEM_RU = """Ты — опытный архитектор миров для ролевой игры.
Твоя задача — помочь игроку создать мир через диалог. Игрок даёт начальный бриф (сеттинг, персонаж, правила, заметки).
Ты должен:
1. Если информации мало — задать 2-4 уточняющих вопроса коротко и по делу.
2. Если информации достаточно — построить complete world definition и представить его игроку как draft.
3. Принять правки и уточнения, цикл продолжается пока игрок не скажет "готово".
Структура world definition (выводи в JSON в поле proposed_definition когда считаешь что мир готов или близок):
{
"setting_description": "расширенный сеттинг (1-2 абзаца)",
"rules": {объект с правилами: статы, бой, магия, время, инвентарь, смерть},
"world_schema": {JSON Schema для состояния мира: player, npcs, locations, world_time, flags},
"plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
"initial_state": {начальное состояние мира согласно schema},
"initial_time": "строка времени мира (например 'day_1_hour_8')"
}
ВАЖНО для small models:
- Будь лаконичен. Не более 200 слов в каждом сообщении.
- JSON выводи строго валидный, без комментариев.
- В каждом ответе: либо задавай вопросы (если данных мало), либо давай proposed_definition.
- Когда мир готов — поставь is_final=true (но только если игрок согласился).
"""
WORLD_BUILDER_SYSTEM_EN = """You are a master world-builder for a role-playing game.
Your job is to help the player design a world through dialogue. The player gives a brief (setting, character, rules, notes).
You must:
1. If information is sparse — ask 2-4 short, focused clarifying questions.
2. If information is sufficient — build a complete world definition and present it as a draft.
3. Accept edits and clarifications; the loop continues until the player says "ok".
World definition structure (output in JSON as proposed_definition when the world is ready or near-ready):
{
"setting_description": "expanded setting (1-2 paragraphs)",
"rules": {object with rules: stats, combat, magic, time, inventory, death},
"world_schema": {JSON Schema for world state: player, npcs, locations, world_time, flags},
"plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
"initial_state": {initial world state matching schema},
"initial_time": "world time string (e.g. 'day_1_hour_8')"
}
CRITICAL for small models:
- Be concise. Max 200 words per message.
- Output strictly valid JSON, no comments.
- In each reply: either ask questions (if data is sparse), or give proposed_definition.
- When world is ready — set is_final=true (only if the player agreed).
"""
# === Orchestrator (main game loop with tools) ===
ORCHESTRATOR_SYSTEM_RU = """Ты — Game Master ролевой игры. Ведёшь сессию через инструментальные вызовы.
ТЕКУЩИЙ КОНТЕКСТ:
- Мир: {world_name}
- Сеттинг: {setting_description}
- Правила: {rules}
- Текущее время мира: {current_time}
- Состояние игрока: {player_state}
- Главные рельсы сюжета: {plot_rails}
- Сводка прошлого: {summary}
ЗАДАЧА:
Игрок сделал действие: "{action_text}"
Оцени реалистичность (соответствие сеттингу и правилам), спланируй что должно произойти, используй инструменты для:
- бросков кубиков (dice_roll)
- обновления состояния (update_state)
- проверки/добавления фактов в RAG (rag_query, rag_add)
- планирования отложенных событий (schedule_trigger)
- обновления времени мира (advance_time)
- запуска sub-агента для генерации деталей с чистым контекстом (run_subagent)
После выполнения плана — верни ответ в виде JSON (без текста вне JSON):
{
"assessment": "краткая оценка действия (1-2 предложения)",
"outcome": "что произошло (сырой, 1-3 предложения)",
"state_patch": {JSON-patch для состояния мира},
"time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
"narrative_prompt": "факты которые должен знать step-writer для написания сценария",
"next_options": ["вариант 1", "вариант 2", "вариант 3"],
"triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
"rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
"rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
}
ВАЖНО:
- Экономь токены. Минимум 1-3 tool calls на итерацию, не больше 5.
- Если действие тривиальное — пропусти dice_roll.
- Не пиши сценарное описание — это задача step-writer.
- Соблюдай сеттинг.
"""
ORCHESTRATOR_SYSTEM_EN = """You are the Game Master of a role-playing game. You run the session through tool calls.
CURRENT CONTEXT:
- World: {world_name}
- Setting: {setting_description}
- Rules: {rules}
- Current world time: {current_time}
- Player state: {player_state}
- Main plot rails: {plot_rails}
- Past summary: {summary}
TASK:
The player performed action: "{action_text}"
Assess realism (consistency with setting and rules), plan what should happen, use tools to:
- roll dice (dice_roll)
- update state (update_state)
- query / add facts to RAG (rag_query, rag_add)
- schedule deferred events (schedule_trigger)
- advance world time (advance_time)
- spawn a sub-agent for detail generation with clean context (run_subagent)
After executing the plan — return your reply as JSON (no text outside JSON):
{
"assessment": "brief assessment of the action (1-2 sentences)",
"outcome": "what happened (raw, 1-3 sentences)",
"state_patch": {JSON-patch for world state},
"time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
"narrative_prompt": "facts the step-writer should know to write the scene",
"next_options": ["option 1", "option 2", "option 3"],
"triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
"rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
"rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
}
CRITICAL:
- Save tokens. 1-3 tool calls per iteration, max 5.
- Skip dice_roll for trivial actions.
- Do NOT write the narrative scene — that's the step-writer's job.
- Stay in setting.
"""
# === Step Writer ===
STEP_WRITER_SYSTEM_RU = """Ты — сценарист ролевой игры. Превращаешь сырой outcome в сценарный шаг как в книге.
КОНТЕКСТ:
- Сеттинг: {setting_description}
- Текущее время мира: {current_time}
- Состояние игрока: {player_state}
- Что произошло (сырое): {outcome}
- Дополнительные факты: {narrative_prompt}
НАПИШИ:
1. Сценарное описание (2-4 абзаца, кинематографично, от второго лица "Ты...").
2. В конце — 3 опции следующего действия (короткие, 5-12 слов).
Формат ответа (строгий JSON):
{
"narrative": "...",
"options": ["...", "...", "..."]
}
ВАЖНО:
- 200-400 слов сценария. Не больше.
- Не повторяй то что игрок уже знает.
- Заканчивай клиффхэнгером или моментом выбора.
"""
STEP_WRITER_SYSTEM_EN = """You are the narrative writer of a role-playing game. You turn raw outcome into a book-like scene.
CONTEXT:
- Setting: {setting_description}
- Current world time: {current_time}
- Player state: {player_state}
- What happened (raw): {outcome}
- Additional facts: {narrative_prompt}
WRITE:
1. Narrative description (2-4 paragraphs, cinematic, second-person "You...").
2. End with 3 options for the next action (short, 5-12 words).
Response format (strict JSON):
{
"narrative": "...",
"options": ["...", "...", "..."]
}
CRITICAL:
- 200-400 words of narrative. Not more.
- Don't repeat what the player already knows.
- End with a cliffhanger or decision moment.
"""
# === Summarizer ===
SUMMARIZER_SYSTEM_RU = """Ты сжимаешь историю ролевой сессии. Дано несколько сообщений — выдай компактную сводку.
Выведи:
1. summary: 3-6 предложений ключевых событий и изменений состояния.
2. facts: массив важных устойчивых фактов [{kind, name, description}] (коротко).
Формат (строгий JSON):
{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
ВАЖНО: Не более 150 слов в summary. Сохраняй имена, числа, важные изменения.
"""
SUMMARIZER_SYSTEM_EN = """You compress the history of a role-playing session. Given several messages — produce a compact summary.
Output:
1. summary: 3-6 sentences of key events and state changes.
2. facts: array of important persistent facts [{kind, name, description}] (brief).
Format (strict JSON):
{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
CRITICAL: Max 150 words in summary. Preserve names, numbers, important changes.
"""
# === Sub-agent (clean context detail generator) ===
SUBAGENT_SYSTEM_RU = """Ты — суб-агент с чистым контекстом. Получаешь задачу от главного GM, выдаёшь конкретный результат.
Задача: {task}
Контекст: {context}
Дай компактный, сфокусированный ответ. Не более 150 слов.
"""
SUBAGENT_SYSTEM_EN = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result.
Task: {task}
Context: {context}
Give a compact, focused answer. Max 150 words.
"""
# === Trigger runner ===
TRIGGER_RUNNER_SYSTEM_RU = """Ты обрабатываешь отложенное событие в ролевой игре.
Событие: {description}
Payload: {payload}
Текущее состояние мира: {state}
Верни JSON:
{
"outcome": "что произошло (1-2 предложения)",
"state_patch": {JSON-patch},
"narrative": "сценарное описание для игрока (1 абзац, опционально если игрок не видит — пустая строка)",
"should_notify_player": true|false
}
"""
TRIGGER_RUNNER_SYSTEM_EN = """You process a deferred event in a role-playing game.
Event: {description}
Payload: {payload}
Current world state: {state}
Return JSON:
{
"outcome": "what happened (1-2 sentences)",
"state_patch": {JSON-patch},
"narrative": "scene description for the player (1 paragraph, optional — empty string if player doesn't witness)",
"should_notify_player": true|false
}
"""
PROMPTS = {
"ru": {
"world_builder": WORLD_BUILDER_SYSTEM_RU,
"orchestrator": ORCHESTRATOR_SYSTEM_RU,
"step_writer": STEP_WRITER_SYSTEM_RU,
"summarizer": SUMMARIZER_SYSTEM_RU,
"subagent": SUBAGENT_SYSTEM_RU,
"trigger_runner": TRIGGER_RUNNER_SYSTEM_RU,
},
"en": {
"world_builder": WORLD_BUILDER_SYSTEM_EN,
"orchestrator": ORCHESTRATOR_SYSTEM_EN,
"step_writer": STEP_WRITER_SYSTEM_EN,
"summarizer": SUMMARIZER_SYSTEM_EN,
"subagent": SUBAGENT_SYSTEM_EN,
"trigger_runner": TRIGGER_RUNNER_SYSTEM_EN,
},
}
def get_prompt(stage: str, language: str = "ru") -> str:
lang = language if language in PROMPTS else "ru"
return PROMPTS[lang].get(stage, PROMPTS["ru"][stage])

View File

@@ -0,0 +1,238 @@
"""Pydantic schemas for API request/response."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
# === Auth ===
class UserRegister(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=6, max_length=128)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
email: EmailStr
username: str
is_admin: bool
is_active: bool
preferred_language: str
created_at: datetime
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class AdminSetupRequest(BaseModel):
token: str
email: EmailStr
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=6, max_length=128)
# === Settings ===
class SettingsUpdate(BaseModel):
values: Dict[str, Any]
class SettingsOut(BaseModel):
values: Dict[str, Any]
editable_keys: List[str]
# === Presets ===
class PresetOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
slug: str
title: str
description: Optional[str] = None
language: str
is_public: bool
is_builtin: bool
payload: Dict[str, Any]
created_at: datetime
class PresetCreate(BaseModel):
slug: str
title: str
description: Optional[str] = None
language: str = "ru"
is_public: bool = True
payload: Dict[str, Any]
# === Worlds ===
class WorldCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
language: str = "ru"
preset_id: Optional[UUID] = None
class WorldDefinition(BaseModel):
setting_description: str = ""
rules: Dict[str, Any] = Field(default_factory=dict)
world_schema: Dict[str, Any] = Field(default_factory=dict)
plot_rails: Dict[str, Any] = Field(default_factory=dict)
initial_state: Dict[str, Any] = Field(default_factory=dict)
initial_time: Optional[str] = None
class WorldOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
owner_id: UUID
name: str
language: str
definition: Dict[str, Any]
state: Dict[str, Any]
current_time: Optional[str]
status: str
preset_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
class WorldUpdate(BaseModel):
name: Optional[str] = None
definition: Optional[Dict[str, Any]] = None
state: Optional[Dict[str, Any]] = None
current_time: Optional[str] = None
status: Optional[str] = None
# === World Builder ===
class WorldBuilderStart(BaseModel):
"""Kick off a new world-building conversation."""
world_name: str = Field(min_length=1, max_length=255)
language: str = "ru"
# Either pick a preset to start from, or fill the freeform brief.
preset_id: Optional[UUID] = None
setting_brief: str = ""
character_brief: str = ""
rules_brief: str = ""
notes: str = ""
class WorldBuilderMessage(BaseModel):
"""User reply in the world-builder dialogue."""
session_id: UUID
message: str
class WorldBuilderReply(BaseModel):
"""AI reply in the world-builder dialogue."""
session_id: UUID
turn: int
ai_message: str
proposed_definition: Optional[WorldDefinition] = None
is_final: bool = False # True when AI thinks world is ready to commit
followup_questions: List[str] = Field(default_factory=list)
class WorldBuilderCommit(BaseModel):
"""User accepts the proposed world definition and creates the world."""
session_id: UUID
name: Optional[str] = None
# === Sessions ===
class SessionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
world_id: UUID
title: str
is_active: bool
created_at: datetime
last_played_at: Optional[datetime] = None
class SessionCreate(BaseModel):
world_id: UUID
title: Optional[str] = None
# === Messages ===
class MessageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
seq: int
role: str
kind: str
content: str
payload: Dict[str, Any]
is_pinned: bool
hidden: bool
created_at: datetime
# === Iteration ===
class IterationRequest(BaseModel):
"""Player submits an action/choice for the next iteration."""
session_id: UUID
action_text: str = Field(min_length=1, max_length=4000)
class IterationEvent(BaseModel):
"""SSE event sent to the frontend during an iteration."""
type: str # status / plan / tool_call / tool_result / narrative_chunk / step_complete / error / done
data: Dict[str, Any] = Field(default_factory=dict)
# === Glossary ===
class GlossaryEntryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
kind: str
name: str
description: str
payload: Dict[str, Any]
# === LLM Logs ===
class LlmLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
purpose: str
model: str
base_url: str
prompt_tokens: Optional[int]
completion_tokens: Optional[int]
total_tokens: Optional[int]
latency_ms: Optional[int]
error: Optional[str]
created_at: datetime
# === Triggers ===
class TriggerOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
session_id: UUID
fire_at: str
description: str
payload: Dict[str, Any]
fired: bool
created_at: datetime
class TriggerCreate(BaseModel):
session_id: UUID
fire_at: str
description: str
payload: Dict[str, Any] = Field(default_factory=dict)

View File

View File

@@ -0,0 +1,23 @@
"""Worker entrypoint: runs trigger checker + future background jobs."""
from __future__ import annotations
import asyncio
from app.logging_setup import get_logger, setup_logging
from app.workers.trigger_runner import main_loop as trigger_loop
log = get_logger("worker")
async def main():
setup_logging()
log.info("worker_starting")
# Run all background loops concurrently
await asyncio.gather(
trigger_loop(),
# Future: rag indexer, summary compactor, etc.
)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,190 @@
"""Trigger checker: scans due deferred triggers and fires them.
The actual firing = creating a new narrative step for the player to see,
OR a hidden technical message if the event is "offscreen".
"""
from __future__ import annotations
import asyncio
import json
import re
import uuid
from typing import Any, Dict, List
from sqlalchemy import select
from app.core.llm import LlmClient
from app.core.settings_service import cast_setting, get_all_settings
from app.core.state_validator import apply_patch, validate_state
from app.db import AsyncSessionLocal
from app.logging_setup import get_logger, setup_logging
from app.models import DeferredTrigger, Message, Session, World
from app.prompts.templates import get_prompt
log = get_logger("trigger_runner")
def _parse_time(t: str) -> int:
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
return int(datetime.fromisoformat(t).timestamp() // 60)
except Exception:
return 0
async def check_and_fire_triggers() -> int:
"""Find all unfired triggers whose fire_at <= current world time, fire them.
Returns the number of triggers fired.
"""
setup_logging()
async with AsyncSessionLocal() as db:
result = await db.execute(
select(DeferredTrigger, Session, World)
.join(Session, DeferredTrigger.session_id == Session.id)
.join(World, Session.world_id == World.id)
.where(DeferredTrigger.fired.is_(False))
)
rows = result.all()
if not rows:
return 0
fired = 0
for trigger, session, world in rows:
cur = _parse_time(world.current_time or "")
fire_at = _parse_time(trigger.fire_at)
if fire_at > cur:
continue
try:
await _fire_trigger(db, trigger, session, world)
fired += 1
except Exception as e:
log.error("trigger_fire_failed", trigger_id=str(trigger.id), error=str(e))
if fired:
await db.commit()
return fired
async def _fire_trigger(db, trigger: DeferredTrigger, session: Session, world: World) -> None:
"""Fire a single trigger: produce narrative + apply state patch."""
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
system_prompt = get_prompt("trigger_runner", world.language).format(
description=trigger.description,
payload=json.dumps(trigger.payload, ensure_ascii=False)[:600],
state=json.dumps(world.state, ensure_ascii=False)[:1000],
)
response = await llm.chat(
messages=[{"role": "system", "content": system_prompt}],
temperature=0.5,
max_tokens=500,
purpose="trigger",
session_id=session.id,
db=db,
)
# Parse response
parsed: Dict[str, Any] = {}
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", {})
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", "")
should_notify = bool(parsed.get("should_notify_player", True))
# Save as message
next_seq_result = await db.execute(
select(Message.seq).where(Message.session_id == session.id).order_by(Message.seq.desc()).limit(1)
)
row = next_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=f"[Событие] {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": [], # triggers don't usually offer choices
},
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))
async def main_loop():
"""Main worker loop. Polls every N seconds for due triggers."""
setup_logging()
log.info("trigger_worker_started")
while True:
try:
async with AsyncSessionLocal() as db:
enabled = await _get_setting(db, "triggers.enabled", True)
interval = int(await _get_setting(db, "triggers.check_interval", 30))
if enabled:
fired = await check_and_fire_triggers()
if fired:
log.info("triggers_fired", count=fired)
except Exception as e:
log.error("trigger_worker_iteration_failed", error=str(e))
await asyncio.sleep(max(5, int(await _get_setting_sleep())))
async def _get_setting(db, key: str, default):
from app.models import Setting
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
if row is None:
return default
return cast_setting(key, row.value)
async def _get_setting_sleep() -> int:
async with AsyncSessionLocal() as db:
return int(await _get_setting(db, "triggers.check_interval", 30))
if __name__ == "__main__":
asyncio.run(main_loop())