initial
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
144
backend/app/api/admin.py
Normal file
144
backend/app/api/admin.py
Normal 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
83
backend/app/api/auth.py
Normal 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
65
backend/app/api/misc.py
Normal 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()
|
||||
71
backend/app/api/presets.py
Normal file
71
backend/app/api/presets.py
Normal 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
162
backend/app/api/sessions.py
Normal 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
161
backend/app/api/worlds.py
Normal 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
81
backend/app/config.py
Normal 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()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
243
backend/app/core/llm.py
Normal file
243
backend/app/core/llm.py
Normal 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
463
backend/app/core/rag.py
Normal 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}",
|
||||
}
|
||||
44
backend/app/core/security.py
Normal file
44
backend/app/core/security.py
Normal 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
|
||||
92
backend/app/core/settings_service.py
Normal file
92
backend/app/core/settings_service.py
Normal 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
|
||||
108
backend/app/core/state_validator.py
Normal file
108
backend/app/core/state_validator.py
Normal 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
49
backend/app/db.py
Normal 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
40
backend/app/deps.py
Normal 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
|
||||
0
backend/app/engine/__init__.py
Normal file
0
backend/app/engine/__init__.py
Normal file
230
backend/app/engine/context.py
Normal file
230
backend/app/engine/context.py
Normal 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}]
|
||||
443
backend/app/engine/orchestrator.py
Normal file
443
backend/app/engine/orchestrator.py
Normal 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
|
||||
0
backend/app/engine/tools/__init__.py
Normal file
0
backend/app/engine/tools/__init__.py
Normal file
295
backend/app/engine/tools/tools.py
Normal file
295
backend/app/engine/tools/tools.py
Normal 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)
|
||||
267
backend/app/engine/world_builder.py
Normal file
267
backend/app/engine/world_builder.py
Normal 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
|
||||
38
backend/app/logging_setup.py
Normal file
38
backend/app/logging_setup.py
Normal 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
76
backend/app/main.py
Normal 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)
|
||||
0
backend/app/migrations/__init__.py
Normal file
0
backend/app/migrations/__init__.py
Normal file
103
backend/app/migrations/init_db.py
Normal file
103
backend/app/migrations/init_db.py
Normal 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())
|
||||
189
backend/app/models/__init__.py
Normal file
189
backend/app/models/__init__.py
Normal 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)
|
||||
0
backend/app/prompts/__init__.py
Normal file
0
backend/app/prompts/__init__.py
Normal file
225
backend/app/prompts/fantasy_preset.py
Normal file
225
backend/app/prompts/fantasy_preset.py
Normal 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."
|
||||
),
|
||||
},
|
||||
}
|
||||
295
backend/app/prompts/templates.py
Normal file
295
backend/app/prompts/templates.py
Normal 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])
|
||||
238
backend/app/schemas/__init__.py
Normal file
238
backend/app/schemas/__init__.py
Normal 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)
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
23
backend/app/workers/main.py
Normal file
23
backend/app/workers/main.py
Normal 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())
|
||||
190
backend/app/workers/trigger_runner.py
Normal file
190
backend/app/workers/trigger_runner.py
Normal 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())
|
||||
Reference in New Issue
Block a user